Compare commits

..

No commits in common. "9ded9b4a10eab5863ad50142fcb7bbd7e524232b" and "f8909d7fcb6a5d61bdd2d818bc3e8fb772ff5ccc" have entirely different histories.

10 changed files with 65 additions and 211 deletions

View file

@ -3,8 +3,6 @@
* 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,21 +40,9 @@ 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,30 +1194,22 @@ class MainWindow(QMainWindow):
self.upcoming_reminders._add_reminder() self.upcoming_reminders._add_reminder()
def _on_timer_requested(self): def _on_timer_requested(self):
"""Toggle the embedded Pomodoro timer for the current line.""" """Start a Pomodoro timer for the current line."""
action = self.toolBar.actTimer editor = getattr(self, "editor", None)
if editor is None:
return
# Turned on -> start a new timer for the current line # Get the current line text
if action.isChecked(): line_text = editor.get_current_line_task_text()
editor = getattr(self, "editor", None)
if editor is None:
# No editor; immediately reset the toggle
action.setChecked(False)
return
# Get the current line text if not line_text:
line_text = editor.get_current_line_task_text() line_text = strings._("pomodoro_time_log_default_text")
if not line_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 embedded in the sidebar # Start the timer
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,6 +356,7 @@ 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, QSignalBlocker from PySide6.QtCore import Qt, QTimer, Signal, Slot
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFrame, QDialog,
QVBoxLayout, QVBoxLayout,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
@ -18,13 +18,16 @@ from .db import DBManager
from .time_log import TimeLogDialog from .time_log import TimeLogDialog
class PomodoroTimer(QFrame): class PomodoroTimer(QDialog):
"""A simple timer for tracking work time on a specific task.""" """A simple timer dialog 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
@ -40,7 +43,7 @@ class PomodoroTimer(QFrame):
# 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(20) font.setPointSize(24)
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)
@ -100,7 +103,7 @@ class PomodoroTimer(QFrame):
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.close() self.accept()
class PomodoroManager: class PomodoroManager:
@ -112,47 +115,17 @@ 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."""
Start a new timer for the given line of text and embed it into the # Stop any existing timer
TimeLogWidget in the main window sidebar. if self._active_timer and self._active_timer.isVisible():
""" self._active_timer.close()
# Cancel any existing timer first
self.cancel_timer()
# The timer lives inside the TimeLogWidget in the sidebar # Create new timer
time_log_widget = getattr(self._parent, "time_log", None) self._active_timer = PomodoroTimer(line_text, self._parent)
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)
) )
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): 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."""
@ -164,16 +137,6 @@ 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,
@ -192,13 +155,3 @@ 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 # Python's weekday(): Monday=0 ... Sunday=6, same as your rows.
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,8 +106,6 @@ 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)
@ -123,30 +121,6 @@ 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,7 +119,6 @@ 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.2" version = "0.6.1"
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,54 +1,6 @@
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):
@ -196,6 +148,15 @@ 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()
@ -208,10 +169,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."""
parent = DummyMainWindow(app) from PySide6.QtWidgets import QWidget
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
parent = QWidget()
qtbot.addWidget(parent)
manager = PomodoroManager(fresh_db, parent) manager = PomodoroManager(fresh_db, parent)
line_text = "Important task" 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 is not None
assert manager._active_timer._task_text == line_text assert manager._active_timer._task_text == line_text
# Timer should be embedded in the sidebar time log widget qtbot.addWidget(manager._active_timer)
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/replaces the previous one.""" """Test that starting a new timer closes the previous one."""
parent = DummyMainWindow(app) from PySide6.QtWidgets import QWidget
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
parent = QWidget()
qtbot.addWidget(parent)
manager = PomodoroManager(fresh_db, parent) manager = PomodoroManager(fresh_db, parent)
# Start first timer # 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 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
): ):
"""Timer stopped with very short time logs should enforce minimum hours.""" """Test that timer stopped with very short time logs minimum hours."""
parent = DummyMainWindow(app) parent = Mock()
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent) manager = PomodoroManager(fresh_db, parent)
# Mock TimeLogDialog to avoid showing it # Mock TimeLogDialog to avoid actually 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()
@ -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): 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.""" """Test that elapsed time is properly rounded to decimal hours."""
parent = DummyMainWindow(app) parent = Mock()
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent) manager = PomodoroManager(fresh_db, parent)
mock_dialog = Mock() mock_dialog = Mock()
@ -288,25 +241,21 @@ 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):
# 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") 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
# Should be a multiple of 0.25 assert hours_set * 4 == int(hours_set * 4) # 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
): ):
"""Timer stopped should pre-fill the note in the time log dialog.""" """Test that timer stopped pre-fills the note in time log dialog."""
parent = DummyMainWindow(app) parent = Mock()
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
manager = PomodoroManager(fresh_db, parent) manager = PomodoroManager(fresh_db, parent)
mock_dialog = Mock() mock_dialog = Mock()
@ -325,11 +274,12 @@ 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
): ):
"""Timer's stop button should result in TimeLogDialog being executed.""" """Test that timer stopped signal is properly connected."""
parent = DummyMainWindow(app) from PySide6.QtWidgets import QWidget
qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log)
parent = QWidget()
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
qtbot.addWidget(parent)
manager = PomodoroManager(fresh_db, parent) manager = PomodoroManager(fresh_db, parent)
# Mock TimeLogDialog # Mock TimeLogDialog
@ -343,12 +293,11 @@ def test_pomodoro_manager_timer_stopped_signal_connection(
timer = manager._active_timer timer = manager._active_timer
qtbot.addWidget(timer) qtbot.addWidget(timer)
# Simulate timer having run for a bit # Simulate timer stopped
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