From 8823a304cf5a411e8a3ebedcd9d2a2217b4c8f55 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Wed, 3 Dec 2025 15:14:27 +1100 Subject: [PATCH 1/4] Comment adjutments --- bouquin/markdown_highlighter.py | 1 - bouquin/statistics_dialog.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/bouquin/markdown_highlighter.py b/bouquin/markdown_highlighter.py index 7489953..81b08b4 100644 --- a/bouquin/markdown_highlighter.py +++ b/bouquin/markdown_highlighter.py @@ -356,7 +356,6 @@ class MarkdownHighlighter(QSyntaxHighlighter): for m in re.finditer(r"[☐☑]", text): self._overlay_range(m.start(), 1, self.checkbox_format) - # (If you add Unicode bullets later…) for m in re.finditer(r"•", text): self._overlay_range(m.start(), 1, self.bullet_format) diff --git a/bouquin/statistics_dialog.py b/bouquin/statistics_dialog.py index d0c9c5a..f71c447 100644 --- a/bouquin/statistics_dialog.py +++ b/bouquin/statistics_dialog.py @@ -151,7 +151,7 @@ class DateHeatmap(QWidget): fm = painter.fontMetrics() # --- 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"] for dow in range(7): From b06f2135223de035084530db5eed7d1e7fd0fd7c Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Wed, 3 Dec 2025 17:19:30 +1100 Subject: [PATCH 2/4] Pomodoro timer is now in the sidebar when toggled on, rather than as a separate dialog, so it stays out of the way --- CHANGELOG.md | 1 + bouquin/main_window.py | 32 ++++++---- bouquin/pomodoro_timer.py | 79 ++++++++++++++++++----- bouquin/time_log.py | 26 ++++++++ bouquin/toolbar.py | 1 + tests/test_pomodoro_timer.py | 119 +++++++++++++++++++++++++---------- 6 files changed, 196 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46c3d9d..8a0c2b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ * 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. * 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 # 0.6.1 diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 1f1bb7e..aab7bbb 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -1194,22 +1194,30 @@ class MainWindow(QMainWindow): self.upcoming_reminders._add_reminder() def _on_timer_requested(self): - """Start a Pomodoro timer for the current line.""" - editor = getattr(self, "editor", None) - if editor is None: - return + """Toggle the embedded Pomodoro timer for the current line.""" + action = self.toolBar.actTimer - # Get the current line text - line_text = editor.get_current_line_task_text() + # Turned on -> start a new timer for the current line + if action.isChecked(): + editor = getattr(self, "editor", None) + if editor is None: + # No editor; immediately reset the toggle + action.setChecked(False) + return - if not line_text: - line_text = strings._("pomodoro_time_log_default_text") + # Get the current line text + line_text = editor.get_current_line_task_text() + if not line_text: + line_text = strings._("pomodoro_time_log_default_text") - # Get current date - date_iso = self.editor.current_date.toString("yyyy-MM-dd") + # Get current date + date_iso = self.editor.current_date.toString("yyyy-MM-dd") - # Start the timer - self.pomodoro_manager.start_timer_for_line(line_text, date_iso) + # Start the timer embedded in the sidebar + 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): """ diff --git a/bouquin/pomodoro_timer.py b/bouquin/pomodoro_timer.py index 445120c..e986122 100644 --- a/bouquin/pomodoro_timer.py +++ b/bouquin/pomodoro_timer.py @@ -3,9 +3,9 @@ from __future__ import annotations import math 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 ( - QDialog, + QFrame, QVBoxLayout, QHBoxLayout, QLabel, @@ -18,16 +18,13 @@ from .db import DBManager from .time_log import TimeLogDialog -class PomodoroTimer(QDialog): - """A simple timer dialog for tracking work time on a specific task.""" +class PomodoroTimer(QFrame): + """A simple timer for tracking work time on a specific task.""" timerStopped = Signal(int, str) # Emits (elapsed_seconds, task_text) def __init__(self, task_text: str, parent: Optional[QWidget] = None): super().__init__(parent) - self.setWindowTitle(strings._("toolbar_pomodoro_timer")) - self.setModal(False) - self.setMinimumWidth(300) self._task_text = task_text self._elapsed_seconds = 0 @@ -43,7 +40,7 @@ class PomodoroTimer(QDialog): # Timer display self.time_label = QLabel("00:00:00") font = self.time_label.font() - font.setPointSize(24) + font.setPointSize(20) font.setBold(True) self.time_label.setFont(font) self.time_label.setAlignment(Qt.AlignCenter) @@ -103,7 +100,7 @@ class PomodoroTimer(QDialog): self._timer.stop() self.timerStopped.emit(self._elapsed_seconds, self._task_text) - self.accept() + self.close() class PomodoroManager: @@ -115,17 +112,47 @@ class PomodoroManager: self._active_timer: Optional[PomodoroTimer] = None 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 - if self._active_timer and self._active_timer.isVisible(): - self._active_timer.close() + """ + Start a new timer for the given line of text and embed it into the + TimeLogWidget in the main window sidebar. + """ + # Cancel any existing timer first + self.cancel_timer() - # Create new timer - self._active_timer = PomodoroTimer(line_text, self._parent) + # The timer lives inside the TimeLogWidget in the sidebar + 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( lambda seconds, text: self._on_timer_stopped(seconds, text, date_iso) ) - self._active_timer.show() + + # 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() + + 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): """Handle timer stop - open time log dialog with pre-filled data.""" @@ -137,6 +164,16 @@ class PomodoroManager: if 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 dlg = TimeLogDialog( self._db, @@ -155,3 +192,13 @@ class PomodoroManager: # Show the dialog 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") + ) diff --git a/bouquin/time_log.py b/bouquin/time_log.py index 2afbea8..78c17ed 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -106,6 +106,8 @@ class TimeLogWidget(QFrame): self.summary_label = QLabel(strings._("time_log_no_entries")) self.summary_label.setWordWrap(True) 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) main = QVBoxLayout(self) @@ -121,6 +123,30 @@ class TimeLogWidget(QFrame): if not self.toggle_btn.isChecked(): 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 --------------------------------------------------- def _on_toggle(self, checked: bool) -> None: diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index a0e83dc..8090fe7 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -119,6 +119,7 @@ class ToolBar(QToolBar): # Focus timer self.actTimer = QAction("⌛", self) self.actTimer.setToolTip(strings._("toolbar_pomodoro_timer")) + self.actTimer.setCheckable(True) self.actTimer.triggered.connect(self.timerRequested) # Documents diff --git a/tests/test_pomodoro_timer.py b/tests/test_pomodoro_timer.py index 98bc682..5ffeafd 100644 --- a/tests/test_pomodoro_timer.py +++ b/tests/test_pomodoro_timer.py @@ -1,6 +1,54 @@ from unittest.mock import Mock, patch from bouquin.pomodoro_timer import PomodoroTimer, PomodoroManager 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): @@ -148,15 +196,6 @@ def test_pomodoro_timer_modal_state(qtbot, app): 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): """Test PomodoroManager initialization.""" parent = Mock() @@ -169,10 +208,10 @@ def test_pomodoro_manager_init(app, fresh_db): def test_pomodoro_manager_start_timer(qtbot, app, fresh_db): """Test starting a timer through the manager.""" - from PySide6.QtWidgets import QWidget - - parent = QWidget() + parent = DummyMainWindow(app) qtbot.addWidget(parent) + qtbot.addWidget(parent.time_log) + manager = PomodoroManager(fresh_db, parent) 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._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): - """Test that starting a new timer closes the previous one.""" - from PySide6.QtWidgets import QWidget - - parent = QWidget() + """Test that starting a new timer closes/replaces the previous one.""" + parent = DummyMainWindow(app) qtbot.addWidget(parent) + qtbot.addWidget(parent.time_log) + manager = PomodoroManager(fresh_db, parent) # 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 second_timer._task_text == "Task 2" + assert second_timer.parent() is parent.time_log def test_pomodoro_manager_on_timer_stopped_minimum_hours( qtbot, app, fresh_db, monkeypatch ): - """Test that timer stopped with very short time logs minimum hours.""" - parent = Mock() + """Timer stopped with very short time logs should enforce minimum hours.""" + parent = DummyMainWindow(app) + qtbot.addWidget(parent) + qtbot.addWidget(parent.time_log) + manager = PomodoroManager(fresh_db, parent) - # Mock TimeLogDialog to avoid actually showing it + # Mock TimeLogDialog to avoid showing it mock_dialog = Mock() mock_dialog.hours_spin = 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): - """Test that elapsed time is properly rounded to decimal hours.""" - parent = Mock() + """Elapsed time should be rounded up to the nearest 0.25 hours.""" + parent = DummyMainWindow(app) + qtbot.addWidget(parent) + qtbot.addWidget(parent.time_log) + manager = PomodoroManager(fresh_db, parent) mock_dialog = Mock() @@ -241,21 +288,25 @@ def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkey mock_dialog.exec = Mock() 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") mock_dialog.hours_spin.setValue.assert_called_once() 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 * 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( qtbot, app, fresh_db, monkeypatch ): - """Test that timer stopped pre-fills the note in time log dialog.""" - parent = Mock() + """Timer stopped should pre-fill the note in the time log dialog.""" + parent = DummyMainWindow(app) + qtbot.addWidget(parent) + qtbot.addWidget(parent.time_log) + manager = PomodoroManager(fresh_db, parent) mock_dialog = Mock() @@ -274,12 +325,11 @@ def test_pomodoro_manager_on_timer_stopped_prefills_note( def test_pomodoro_manager_timer_stopped_signal_connection( qtbot, app, fresh_db, monkeypatch ): - """Test that timer stopped signal is properly connected.""" - from PySide6.QtWidgets import QWidget - - parent = QWidget() - parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + """Timer's stop button should result in TimeLogDialog being executed.""" + parent = DummyMainWindow(app) qtbot.addWidget(parent) + qtbot.addWidget(parent.time_log) + manager = PomodoroManager(fresh_db, parent) # Mock TimeLogDialog @@ -293,11 +343,12 @@ def test_pomodoro_manager_timer_stopped_signal_connection( timer = manager._active_timer qtbot.addWidget(timer) - # Simulate timer stopped + # Simulate timer having run for a bit timer._elapsed_seconds = 1000 + + # Clicking "Stop and log" should emit timerStopped and open the dialog timer._stop_and_log() - # TimeLogDialog should have been created assert mock_dialog.exec.called From 3d0f4a77876f62da2a9b3fcbff5697053bdc8d60 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Wed, 3 Dec 2025 17:27:15 +1100 Subject: [PATCH 3/4] Indent tabs by 4 spaces in code block editor dialog --- CHANGELOG.md | 1 + bouquin/code_block_editor_dialog.py | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a0c2b7..3a6a1b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * 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) * 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 diff --git a/bouquin/code_block_editor_dialog.py b/bouquin/code_block_editor_dialog.py index af1c99f..59162c0 100644 --- a/bouquin/code_block_editor_dialog.py +++ b/bouquin/code_block_editor_dialog.py @@ -40,9 +40,21 @@ class CodeEditorWithLineNumbers(QPlainTextEdit): self.cursorPositionChanged.connect(self._line_number_area.update) self._update_line_number_area_width() + self._update_tab_stop_width() # ---- 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: # Enough digits for large-ish code blocks. digits = max(2, len(str(max(1, self.blockCount())))) From 9ded9b4a10eab5863ad50142fcb7bbd7e524232b Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Wed, 3 Dec 2025 17:27:36 +1100 Subject: [PATCH 4/4] 0.6.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2d72bb2..b5c7cda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.6.1" +version = "0.6.2" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md"