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
* 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

View file

@ -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()))))

View file

@ -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):
"""

View file

@ -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)

View file

@ -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")
)

View file

@ -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):

View file

@ -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:

View file

@ -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

View file

@ -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 <mig@mig5.net>"]
readme = "README.md"

View file

@ -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