Pomodoro timer is now in the sidebar when toggled on, rather than as a separate dialog, so it stays out of the way
This commit is contained in:
parent
8823a304cf
commit
b06f213522
6 changed files with 196 additions and 62 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
"""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)
|
||||
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()
|
||||
|
||||
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")
|
||||
|
||||
# Start the timer
|
||||
# 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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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,18 +112,48 @@ 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)
|
||||
)
|
||||
|
||||
# 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."""
|
||||
# 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:
|
||||
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")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue