diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a6a1b5..46c3d9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,6 @@ * 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 - * 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 59162c0..af1c99f 100644 --- a/bouquin/code_block_editor_dialog.py +++ b/bouquin/code_block_editor_dialog.py @@ -40,21 +40,9 @@ 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())))) diff --git a/bouquin/main_window.py b/bouquin/main_window.py index aab7bbb..1f1bb7e 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -1194,30 +1194,22 @@ class MainWindow(QMainWindow): self.upcoming_reminders._add_reminder() def _on_timer_requested(self): - """Toggle the embedded Pomodoro timer for the current line.""" - action = self.toolBar.actTimer + """Start a Pomodoro timer for the current line.""" + editor = getattr(self, "editor", None) + if editor is None: + return - # 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 + # Get the current line text + line_text = editor.get_current_line_task_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") + 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 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() + # Start the timer + self.pomodoro_manager.start_timer_for_line(line_text, date_iso) def _show_flashing_reminder(self, text: str): """ diff --git a/bouquin/markdown_highlighter.py b/bouquin/markdown_highlighter.py index 81b08b4..7489953 100644 --- a/bouquin/markdown_highlighter.py +++ b/bouquin/markdown_highlighter.py @@ -356,6 +356,7 @@ 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/pomodoro_timer.py b/bouquin/pomodoro_timer.py index e986122..445120c 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, QSignalBlocker +from PySide6.QtCore import Qt, QTimer, Signal, Slot from PySide6.QtWidgets import ( - QFrame, + QDialog, QVBoxLayout, QHBoxLayout, QLabel, @@ -18,13 +18,16 @@ from .db import DBManager from .time_log import TimeLogDialog -class PomodoroTimer(QFrame): - """A simple timer for tracking work time on a specific task.""" +class PomodoroTimer(QDialog): + """A simple timer dialog 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 @@ -40,7 +43,7 @@ class PomodoroTimer(QFrame): # Timer display self.time_label = QLabel("00:00:00") font = self.time_label.font() - font.setPointSize(20) + font.setPointSize(24) font.setBold(True) self.time_label.setFont(font) self.time_label.setAlignment(Qt.AlignCenter) @@ -100,7 +103,7 @@ class PomodoroTimer(QFrame): self._timer.stop() self.timerStopped.emit(self._elapsed_seconds, self._task_text) - self.close() + self.accept() class PomodoroManager: @@ -112,47 +115,17 @@ 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 and embed it into the - TimeLogWidget in the main window sidebar. - """ - # Cancel any existing timer first - self.cancel_timer() + """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() - # 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) + # Create new timer + self._active_timer = PomodoroTimer(line_text, self._parent) self._active_timer.timerStopped.connect( 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() - - 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 + self._active_timer.show() 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.""" @@ -164,16 +137,6 @@ 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, @@ -192,13 +155,3 @@ 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/statistics_dialog.py b/bouquin/statistics_dialog.py index f71c447..d0c9c5a 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 + # Python's weekday(): Monday=0 ... Sunday=6, same as your rows. weekday_labels = ["M", "T", "W", "T", "F", "S", "S"] for dow in range(7): diff --git a/bouquin/time_log.py b/bouquin/time_log.py index 78c17ed..2afbea8 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -106,8 +106,6 @@ 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) @@ -123,30 +121,6 @@ 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 8090fe7..a0e83dc 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -119,7 +119,6 @@ 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/pyproject.toml b/pyproject.toml index b5c7cda..2d72bb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.6.2" +version = "0.6.1" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" diff --git a/tests/test_pomodoro_timer.py b/tests/test_pomodoro_timer.py index 5ffeafd..98bc682 100644 --- a/tests/test_pomodoro_timer.py +++ b/tests/test_pomodoro_timer.py @@ -1,54 +1,6 @@ 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): @@ -196,6 +148,15 @@ 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() @@ -208,10 +169,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.""" - parent = DummyMainWindow(app) - qtbot.addWidget(parent) - qtbot.addWidget(parent.time_log) + from PySide6.QtWidgets import QWidget + parent = QWidget() + qtbot.addWidget(parent) manager = PomodoroManager(fresh_db, parent) line_text = "Important task" @@ -221,16 +182,15 @@ 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 - # Timer should be embedded in the sidebar time log widget - assert manager._active_timer.parent() is parent.time_log + qtbot.addWidget(manager._active_timer) def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db): - """Test that starting a new timer closes/replaces the previous one.""" - parent = DummyMainWindow(app) - qtbot.addWidget(parent) - qtbot.addWidget(parent.time_log) + """Test that starting a new timer closes the previous one.""" + from PySide6.QtWidgets import QWidget + parent = QWidget() + qtbot.addWidget(parent) manager = PomodoroManager(fresh_db, parent) # Start first timer @@ -246,20 +206,16 @@ 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 ): - """Timer stopped with very short time logs should enforce minimum hours.""" - parent = DummyMainWindow(app) - qtbot.addWidget(parent) - qtbot.addWidget(parent.time_log) - + """Test that timer stopped with very short time logs minimum hours.""" + parent = Mock() manager = PomodoroManager(fresh_db, parent) - # Mock TimeLogDialog to avoid showing it + # Mock TimeLogDialog to avoid actually showing it mock_dialog = Mock() mock_dialog.hours_spin = Mock() mock_dialog.note = Mock() @@ -275,11 +231,8 @@ def test_pomodoro_manager_on_timer_stopped_minimum_hours( def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch): - """Elapsed time should be rounded up to the nearest 0.25 hours.""" - parent = DummyMainWindow(app) - qtbot.addWidget(parent) - qtbot.addWidget(parent.time_log) - + """Test that elapsed time is properly rounded to decimal hours.""" + parent = Mock() manager = PomodoroManager(fresh_db, parent) mock_dialog = Mock() @@ -288,25 +241,21 @@ 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): - # 1800 seconds (30 min) should round up to 0.5 + # Test with 1800 seconds (30 minutes) 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 - # Should be a multiple of 0.25 - assert hours_set * 4 == int(hours_set * 4) + assert hours_set * 4 == int(hours_set * 4) # Multiple of 0.25 def test_pomodoro_manager_on_timer_stopped_prefills_note( qtbot, app, fresh_db, monkeypatch ): - """Timer stopped should pre-fill the note in the time log dialog.""" - parent = DummyMainWindow(app) - qtbot.addWidget(parent) - qtbot.addWidget(parent.time_log) - + """Test that timer stopped pre-fills the note in time log dialog.""" + parent = Mock() manager = PomodoroManager(fresh_db, parent) mock_dialog = Mock() @@ -325,11 +274,12 @@ def test_pomodoro_manager_on_timer_stopped_prefills_note( def test_pomodoro_manager_timer_stopped_signal_connection( qtbot, app, fresh_db, monkeypatch ): - """Timer's stop button should result in TimeLogDialog being executed.""" - parent = DummyMainWindow(app) - qtbot.addWidget(parent) - qtbot.addWidget(parent.time_log) + """Test that timer stopped signal is properly connected.""" + from PySide6.QtWidgets import QWidget + parent = QWidget() + parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + qtbot.addWidget(parent) manager = PomodoroManager(fresh_db, parent) # Mock TimeLogDialog @@ -343,12 +293,11 @@ def test_pomodoro_manager_timer_stopped_signal_connection( timer = manager._active_timer qtbot.addWidget(timer) - # Simulate timer having run for a bit + # Simulate timer stopped 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