Compare commits

...

4 commits

Author SHA1 Message Date
9ded9b4a10
0.6.2
Some checks failed
CI / test (push) Successful in 6m5s
Lint / test (push) Failing after 31s
Trivy / test (push) Successful in 24s
2025-12-03 17:27:36 +11:00
3d0f4a7787
Indent tabs by 4 spaces in code block editor dialog 2025-12-03 17:27:15 +11:00
b06f213522
Pomodoro timer is now in the sidebar when toggled on, rather than as a separate dialog, so it stays out of the way 2025-12-03 17:19:30 +11:00
8823a304cf
Comment adjutments 2025-12-03 15:14:27 +11:00
10 changed files with 211 additions and 65 deletions

View file

@ -3,6 +3,8 @@
* Ensure that adding a document whilst on an older date page, uses that date as its upload date * Ensure that adding a document whilst on an older date page, uses that date as its upload date
* Add 'Created at' to time log table. * Add 'Created at' to time log table.
* Show total hours for the day in the time log table (not just in the widget in sidebar) * Show total hours for the day in the time log table (not just in the widget in sidebar)
* Pomodoro timer is now in the sidebar when toggled on, rather than as a separate dialog, so it stays out of the way
* Indent tabs by 4 spaces in code block editor dialog
# 0.6.1 # 0.6.1

View file

@ -40,9 +40,21 @@ class CodeEditorWithLineNumbers(QPlainTextEdit):
self.cursorPositionChanged.connect(self._line_number_area.update) self.cursorPositionChanged.connect(self._line_number_area.update)
self._update_line_number_area_width() self._update_line_number_area_width()
self._update_tab_stop_width()
# ---- layout / sizing ------------------------------------------------- # ---- layout / sizing -------------------------------------------------
def setFont(self, font: QFont) -> None: # type: ignore[override]
"""Ensure tab width stays at 4 spaces when the font changes."""
super().setFont(font)
self._update_tab_stop_width()
def _update_tab_stop_width(self) -> None:
"""Set tab width to 4 spaces."""
metrics = QFontMetrics(self.font())
# Tab width = width of 4 space characters
self.setTabStopDistance(metrics.horizontalAdvance(" ") * 4)
def line_number_area_width(self) -> int: def line_number_area_width(self) -> int:
# Enough digits for large-ish code blocks. # Enough digits for large-ish code blocks.
digits = max(2, len(str(max(1, self.blockCount())))) digits = max(2, len(str(max(1, self.blockCount()))))

View file

@ -1194,22 +1194,30 @@ class MainWindow(QMainWindow):
self.upcoming_reminders._add_reminder() self.upcoming_reminders._add_reminder()
def _on_timer_requested(self): def _on_timer_requested(self):
"""Start a Pomodoro timer for the current line.""" """Toggle the embedded Pomodoro timer for the current line."""
action = self.toolBar.actTimer
# Turned on -> start a new timer for the current line
if action.isChecked():
editor = getattr(self, "editor", None) editor = getattr(self, "editor", None)
if editor is None: if editor is None:
# No editor; immediately reset the toggle
action.setChecked(False)
return return
# Get the current line text # Get the current line text
line_text = editor.get_current_line_task_text() line_text = editor.get_current_line_task_text()
if not line_text: if not line_text:
line_text = strings._("pomodoro_time_log_default_text") line_text = strings._("pomodoro_time_log_default_text")
# Get current date # Get current date
date_iso = self.editor.current_date.toString("yyyy-MM-dd") date_iso = self.editor.current_date.toString("yyyy-MM-dd")
# Start the timer # Start the timer embedded in the sidebar
self.pomodoro_manager.start_timer_for_line(line_text, date_iso) self.pomodoro_manager.start_timer_for_line(line_text, date_iso)
else:
# Turned off -> cancel any running timer and remove the widget
self.pomodoro_manager.cancel_timer()
def _show_flashing_reminder(self, text: str): def _show_flashing_reminder(self, text: str):
""" """

View file

@ -356,7 +356,6 @@ class MarkdownHighlighter(QSyntaxHighlighter):
for m in re.finditer(r"[☐☑]", text): for m in re.finditer(r"[☐☑]", text):
self._overlay_range(m.start(), 1, self.checkbox_format) self._overlay_range(m.start(), 1, self.checkbox_format)
# (If you add Unicode bullets later…)
for m in re.finditer(r"", text): for m in re.finditer(r"", text):
self._overlay_range(m.start(), 1, self.bullet_format) self._overlay_range(m.start(), 1, self.bullet_format)

View file

@ -3,9 +3,9 @@ from __future__ import annotations
import math import math
from typing import Optional from typing import Optional
from PySide6.QtCore import Qt, QTimer, Signal, Slot from PySide6.QtCore import Qt, QTimer, Signal, Slot, QSignalBlocker
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QFrame,
QVBoxLayout, QVBoxLayout,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
@ -18,16 +18,13 @@ from .db import DBManager
from .time_log import TimeLogDialog from .time_log import TimeLogDialog
class PomodoroTimer(QDialog): class PomodoroTimer(QFrame):
"""A simple timer dialog for tracking work time on a specific task.""" """A simple timer for tracking work time on a specific task."""
timerStopped = Signal(int, str) # Emits (elapsed_seconds, task_text) timerStopped = Signal(int, str) # Emits (elapsed_seconds, task_text)
def __init__(self, task_text: str, parent: Optional[QWidget] = None): def __init__(self, task_text: str, parent: Optional[QWidget] = None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle(strings._("toolbar_pomodoro_timer"))
self.setModal(False)
self.setMinimumWidth(300)
self._task_text = task_text self._task_text = task_text
self._elapsed_seconds = 0 self._elapsed_seconds = 0
@ -43,7 +40,7 @@ class PomodoroTimer(QDialog):
# Timer display # Timer display
self.time_label = QLabel("00:00:00") self.time_label = QLabel("00:00:00")
font = self.time_label.font() font = self.time_label.font()
font.setPointSize(24) font.setPointSize(20)
font.setBold(True) font.setBold(True)
self.time_label.setFont(font) self.time_label.setFont(font)
self.time_label.setAlignment(Qt.AlignCenter) self.time_label.setAlignment(Qt.AlignCenter)
@ -103,7 +100,7 @@ class PomodoroTimer(QDialog):
self._timer.stop() self._timer.stop()
self.timerStopped.emit(self._elapsed_seconds, self._task_text) self.timerStopped.emit(self._elapsed_seconds, self._task_text)
self.accept() self.close()
class PomodoroManager: class PomodoroManager:
@ -115,18 +112,48 @@ class PomodoroManager:
self._active_timer: Optional[PomodoroTimer] = None self._active_timer: Optional[PomodoroTimer] = None
def start_timer_for_line(self, line_text: str, date_iso: str): def start_timer_for_line(self, line_text: str, date_iso: str):
"""Start a new timer for the given line of text.""" """
# Stop any existing timer Start a new timer for the given line of text and embed it into the
if self._active_timer and self._active_timer.isVisible(): TimeLogWidget in the main window sidebar.
self._active_timer.close() """
# Cancel any existing timer first
self.cancel_timer()
# Create new timer # The timer lives inside the TimeLogWidget in the sidebar
self._active_timer = PomodoroTimer(line_text, self._parent) time_log_widget = getattr(self._parent, "time_log", None)
if time_log_widget is None:
return
self._active_timer = PomodoroTimer(line_text, time_log_widget)
self._active_timer.timerStopped.connect( self._active_timer.timerStopped.connect(
lambda seconds, text: self._on_timer_stopped(seconds, text, date_iso) lambda seconds, text: self._on_timer_stopped(seconds, text, date_iso)
) )
# Ask the TimeLogWidget to own and display the widget
if hasattr(time_log_widget, "show_pomodoro_widget"):
time_log_widget.show_pomodoro_widget(self._active_timer)
else:
# Fallback just attach it as a child widget
self._active_timer.setParent(time_log_widget)
self._active_timer.show() self._active_timer.show()
def cancel_timer(self):
"""Cancel any running timer without logging and remove it from the sidebar."""
if not self._active_timer:
return
time_log_widget = getattr(self._parent, "time_log", None)
if time_log_widget is not None and hasattr(
time_log_widget, "clear_pomodoro_widget"
):
time_log_widget.clear_pomodoro_widget()
else:
# Fallback if the widget API doesn't exist
self._active_timer.setParent(None)
self._active_timer.deleteLater()
self._active_timer = None
def _on_timer_stopped(self, elapsed_seconds: int, task_text: str, date_iso: str): def _on_timer_stopped(self, elapsed_seconds: int, task_text: str, date_iso: str):
"""Handle timer stop - open time log dialog with pre-filled data.""" """Handle timer stop - open time log dialog with pre-filled data."""
# Convert seconds to decimal hours, rounding up to the nearest 0.25 hour (15 minutes) # Convert seconds to decimal hours, rounding up to the nearest 0.25 hour (15 minutes)
@ -137,6 +164,16 @@ class PomodoroManager:
if hours < 0.25: if hours < 0.25:
hours = 0.25 hours = 0.25
# Untoggle the toolbar button without retriggering the slot
tool_bar = getattr(self._parent, "toolBar", None)
if tool_bar is not None and hasattr(tool_bar, "actTimer"):
blocker = QSignalBlocker(tool_bar.actTimer)
tool_bar.actTimer.setChecked(False)
del blocker
# Remove the embedded widget
self.cancel_timer()
# Open time log dialog # Open time log dialog
dlg = TimeLogDialog( dlg = TimeLogDialog(
self._db, self._db,
@ -155,3 +192,13 @@ class PomodoroManager:
# Show the dialog # Show the dialog
dlg.exec() dlg.exec()
time_log_widget = getattr(self._parent, "time_log", None)
if time_log_widget is not None:
# Same behaviour as TimeLogWidget._open_dialog/_open_dialog_log_only:
# reload the summary so the TimeLogWidget in sidebar updates its totals
time_log_widget._reload_summary()
if not time_log_widget.toggle_btn.isChecked():
time_log_widget.summary_label.setText(
strings._("time_log_collapsed_hint")
)

View file

@ -151,7 +151,7 @@ class DateHeatmap(QWidget):
fm = painter.fontMetrics() fm = painter.fontMetrics()
# --- weekday labels on left ------------------------------------- # --- weekday labels on left -------------------------------------
# Python's weekday(): Monday=0 ... Sunday=6, same as your rows. # Python's weekday(): Monday=0 ... Sunday=6
weekday_labels = ["M", "T", "W", "T", "F", "S", "S"] weekday_labels = ["M", "T", "W", "T", "F", "S", "S"]
for dow in range(7): for dow in range(7):

View file

@ -106,6 +106,8 @@ class TimeLogWidget(QFrame):
self.summary_label = QLabel(strings._("time_log_no_entries")) self.summary_label = QLabel(strings._("time_log_no_entries"))
self.summary_label.setWordWrap(True) self.summary_label.setWordWrap(True)
self.body_layout.addWidget(self.summary_label) self.body_layout.addWidget(self.summary_label)
# Optional embedded Pomodoro timer widget lives underneath the summary.
self._pomodoro_widget: Optional[QWidget] = None
self.body.setVisible(False) self.body.setVisible(False)
main = QVBoxLayout(self) main = QVBoxLayout(self)
@ -121,6 +123,30 @@ class TimeLogWidget(QFrame):
if not self.toggle_btn.isChecked(): if not self.toggle_btn.isChecked():
self.summary_label.setText(strings._("time_log_collapsed_hint")) self.summary_label.setText(strings._("time_log_collapsed_hint"))
def show_pomodoro_widget(self, widget: QWidget) -> None:
"""Embed Pomodoro timer widget in the body area."""
if self._pomodoro_widget is not None:
self.body_layout.removeWidget(self._pomodoro_widget)
self._pomodoro_widget.deleteLater()
self._pomodoro_widget = widget
self.body_layout.addWidget(widget)
widget.show()
# Ensure the body is visible so the timer is obvious
self.body.setVisible(True)
self.toggle_btn.setChecked(True)
self.toggle_btn.setArrowType(Qt.DownArrow)
def clear_pomodoro_widget(self) -> None:
"""Remove any embedded Pomodoro timer widget."""
if self._pomodoro_widget is None:
return
self.body_layout.removeWidget(self._pomodoro_widget)
self._pomodoro_widget.deleteLater()
self._pomodoro_widget = None
# ----- internals --------------------------------------------------- # ----- internals ---------------------------------------------------
def _on_toggle(self, checked: bool) -> None: def _on_toggle(self, checked: bool) -> None:

View file

@ -119,6 +119,7 @@ class ToolBar(QToolBar):
# Focus timer # Focus timer
self.actTimer = QAction("", self) self.actTimer = QAction("", self)
self.actTimer.setToolTip(strings._("toolbar_pomodoro_timer")) self.actTimer.setToolTip(strings._("toolbar_pomodoro_timer"))
self.actTimer.setCheckable(True)
self.actTimer.triggered.connect(self.timerRequested) self.actTimer.triggered.connect(self.timerRequested)
# Documents # Documents

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "bouquin" name = "bouquin"
version = "0.6.1" version = "0.6.2"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"] authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md" readme = "README.md"

View file

@ -1,6 +1,54 @@
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from bouquin.pomodoro_timer import PomodoroTimer, PomodoroManager from bouquin.pomodoro_timer import PomodoroTimer, PomodoroManager
from bouquin.theme import ThemeManager, ThemeConfig, Theme from bouquin.theme import ThemeManager, ThemeConfig, Theme
from PySide6.QtWidgets import QWidget, QVBoxLayout, QToolBar, QLabel
from PySide6.QtGui import QAction
class DummyTimeLogWidget(QWidget):
"""Minimal stand-in for the real TimeLogWidget used by PomodoroManager."""
def __init__(self, parent=None):
super().__init__(parent)
self.layout = QVBoxLayout(self)
self.summary_label = QLabel(self)
# toggle_btn and _reload_summary are used by PomodoroManager._on_timer_stopped
self.toggle_btn = Mock()
self.toggle_btn.isChecked.return_value = True
def show_pomodoro_widget(self, widget):
# Manager calls this when embedding the timer
if widget is not None:
self.layout.addWidget(widget)
def clear_pomodoro_widget(self):
# Manager calls this when removing the embedded timer
while self.layout.count():
item = self.layout.takeAt(0)
w = item.widget()
if w is not None:
w.setParent(None)
def _reload_summary(self):
# Called after TimeLogDialog closes; no-op is fine for tests
pass
class DummyMainWindow(QWidget):
"""Minimal stand-in for MainWindow that PomodoroManager expects."""
def __init__(self, app, parent=None):
super().__init__(parent)
# Sidebar time log widget
self.time_log = DummyTimeLogWidget(self)
# Toolbar with an actTimer QAction so QSignalBlocker works
self.toolBar = QToolBar(self)
self.toolBar.actTimer = QAction(self)
self.toolBar.addAction(self.toolBar.actTimer)
# Themes attribute used when constructing TimeLogDialog
self.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
def test_pomodoro_timer_init(qtbot, app, fresh_db): def test_pomodoro_timer_init(qtbot, app, fresh_db):
@ -148,15 +196,6 @@ def test_pomodoro_timer_modal_state(qtbot, app):
assert timer.isModal() is False assert timer.isModal() is False
def test_pomodoro_timer_window_title(qtbot, app):
"""Test timer window title."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
# Window title should contain some reference to timer/pomodoro
assert len(timer.windowTitle()) > 0
def test_pomodoro_manager_init(app, fresh_db): def test_pomodoro_manager_init(app, fresh_db):
"""Test PomodoroManager initialization.""" """Test PomodoroManager initialization."""
parent = Mock() parent = Mock()
@ -169,10 +208,10 @@ def test_pomodoro_manager_init(app, fresh_db):
def test_pomodoro_manager_start_timer(qtbot, app, fresh_db): def test_pomodoro_manager_start_timer(qtbot, app, fresh_db):
"""Test starting a timer through the manager.""" """Test starting a timer through the manager."""
from PySide6.QtWidgets import QWidget parent = DummyMainWindow(app)
parent = QWidget()
qtbot.addWidget(parent) qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent) manager = PomodoroManager(fresh_db, parent)
line_text = "Important task" line_text = "Important task"
@ -182,15 +221,16 @@ def test_pomodoro_manager_start_timer(qtbot, app, fresh_db):
assert manager._active_timer is not None assert manager._active_timer is not None
assert manager._active_timer._task_text == line_text assert manager._active_timer._task_text == line_text
qtbot.addWidget(manager._active_timer) # Timer should be embedded in the sidebar time log widget
assert manager._active_timer.parent() is parent.time_log
def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db): def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db):
"""Test that starting a new timer closes the previous one.""" """Test that starting a new timer closes/replaces the previous one."""
from PySide6.QtWidgets import QWidget parent = DummyMainWindow(app)
parent = QWidget()
qtbot.addWidget(parent) qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent) manager = PomodoroManager(fresh_db, parent)
# Start first timer # Start first timer
@ -206,16 +246,20 @@ def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db):
assert first_timer is not second_timer assert first_timer is not second_timer
assert second_timer._task_text == "Task 2" assert second_timer._task_text == "Task 2"
assert second_timer.parent() is parent.time_log
def test_pomodoro_manager_on_timer_stopped_minimum_hours( def test_pomodoro_manager_on_timer_stopped_minimum_hours(
qtbot, app, fresh_db, monkeypatch qtbot, app, fresh_db, monkeypatch
): ):
"""Test that timer stopped with very short time logs minimum hours.""" """Timer stopped with very short time logs should enforce minimum hours."""
parent = Mock() parent = DummyMainWindow(app)
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent) manager = PomodoroManager(fresh_db, parent)
# Mock TimeLogDialog to avoid actually showing it # Mock TimeLogDialog to avoid showing it
mock_dialog = Mock() mock_dialog = Mock()
mock_dialog.hours_spin = Mock() mock_dialog.hours_spin = Mock()
mock_dialog.note = Mock() mock_dialog.note = Mock()
@ -231,8 +275,11 @@ def test_pomodoro_manager_on_timer_stopped_minimum_hours(
def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch): def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch):
"""Test that elapsed time is properly rounded to decimal hours.""" """Elapsed time should be rounded up to the nearest 0.25 hours."""
parent = Mock() parent = DummyMainWindow(app)
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent) manager = PomodoroManager(fresh_db, parent)
mock_dialog = Mock() mock_dialog = Mock()
@ -241,21 +288,25 @@ def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkey
mock_dialog.exec = Mock() mock_dialog.exec = Mock()
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog): with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
# Test with 1800 seconds (30 minutes) # 1800 seconds (30 min) should round up to 0.5
manager._on_timer_stopped(1800, "Task", "2024-01-15") manager._on_timer_stopped(1800, "Task", "2024-01-15")
mock_dialog.hours_spin.setValue.assert_called_once() mock_dialog.hours_spin.setValue.assert_called_once()
hours_set = mock_dialog.hours_spin.setValue.call_args[0][0] hours_set = mock_dialog.hours_spin.setValue.call_args[0][0]
# Should round up and be a multiple of 0.25
assert hours_set > 0 assert hours_set > 0
assert hours_set * 4 == int(hours_set * 4) # Multiple of 0.25 # Should be a multiple of 0.25
assert hours_set * 4 == int(hours_set * 4)
def test_pomodoro_manager_on_timer_stopped_prefills_note( def test_pomodoro_manager_on_timer_stopped_prefills_note(
qtbot, app, fresh_db, monkeypatch qtbot, app, fresh_db, monkeypatch
): ):
"""Test that timer stopped pre-fills the note in time log dialog.""" """Timer stopped should pre-fill the note in the time log dialog."""
parent = Mock() parent = DummyMainWindow(app)
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent) manager = PomodoroManager(fresh_db, parent)
mock_dialog = Mock() mock_dialog = Mock()
@ -274,12 +325,11 @@ def test_pomodoro_manager_on_timer_stopped_prefills_note(
def test_pomodoro_manager_timer_stopped_signal_connection( def test_pomodoro_manager_timer_stopped_signal_connection(
qtbot, app, fresh_db, monkeypatch qtbot, app, fresh_db, monkeypatch
): ):
"""Test that timer stopped signal is properly connected.""" """Timer's stop button should result in TimeLogDialog being executed."""
from PySide6.QtWidgets import QWidget parent = DummyMainWindow(app)
parent = QWidget()
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
qtbot.addWidget(parent) qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent) manager = PomodoroManager(fresh_db, parent)
# Mock TimeLogDialog # Mock TimeLogDialog
@ -293,11 +343,12 @@ def test_pomodoro_manager_timer_stopped_signal_connection(
timer = manager._active_timer timer = manager._active_timer
qtbot.addWidget(timer) qtbot.addWidget(timer)
# Simulate timer stopped # Simulate timer having run for a bit
timer._elapsed_seconds = 1000 timer._elapsed_seconds = 1000
# Clicking "Stop and log" should emit timerStopped and open the dialog
timer._stop_and_log() timer._stop_and_log()
# TimeLogDialog should have been created
assert mock_dialog.exec.called assert mock_dialog.exec.called