Many changes and new features:
All checks were successful
CI / test (push) Successful in 5m17s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 25s

* Make reminders be its own dataset rather than tied to current string.
 * Add support for repeated reminders
 * Make reminders be a feature that can be turned on and off
 * Add syntax highlighting for code blocks (right-click to set it)
 * Add a Pomodoro-style timer for measuring time spent on a task (stopping the timer offers to log it to Time Log)
 * Add ability to create markdown tables. Right-click to edit the table in a friendlier table dialog
This commit is contained in:
Miguel Jacq 2025-11-25 14:52:26 +11:00
parent 26737fbfb2
commit e0169db52a
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
28 changed files with 4191 additions and 17 deletions

365
bouquin/code_highlighter.py Normal file
View file

@ -0,0 +1,365 @@
from __future__ import annotations
import re
from typing import Optional, Dict
from PySide6.QtGui import QColor, QTextCharFormat, QFont
class CodeHighlighter:
"""Syntax highlighter for different programming languages."""
# Language keywords
KEYWORDS = {
"python": [
"False",
"None",
"True",
"and",
"as",
"assert",
"async",
"await",
"break",
"class",
"continue",
"def",
"del",
"elif",
"else",
"except",
"finally",
"for",
"from",
"global",
"if",
"import",
"in",
"is",
"lambda",
"nonlocal",
"not",
"or",
"pass",
"print",
"raise",
"return",
"try",
"while",
"with",
"yield",
],
"javascript": [
"abstract",
"arguments",
"await",
"boolean",
"break",
"byte",
"case",
"catch",
"char",
"class",
"const",
"continue",
"debugger",
"default",
"delete",
"do",
"double",
"else",
"enum",
"eval",
"export",
"extends",
"false",
"final",
"finally",
"float",
"for",
"function",
"goto",
"if",
"implements",
"import",
"in",
"instanceof",
"int",
"interface",
"let",
"long",
"native",
"new",
"null",
"package",
"private",
"protected",
"public",
"return",
"short",
"static",
"super",
"switch",
"synchronized",
"this",
"throw",
"throws",
"transient",
"true",
"try",
"typeof",
"var",
"void",
"volatile",
"while",
"with",
"yield",
],
"php": [
"abstract",
"and",
"array",
"as",
"break",
"callable",
"case",
"catch",
"class",
"clone",
"const",
"continue",
"declare",
"default",
"die",
"do",
"echo",
"else",
"elseif",
"empty",
"enddeclare",
"endfor",
"endforeach",
"endif",
"endswitch",
"endwhile",
"eval",
"exit",
"extends",
"final",
"for",
"foreach",
"function",
"global",
"goto",
"if",
"implements",
"include",
"include_once",
"instanceof",
"insteadof",
"interface",
"isset",
"list",
"namespace",
"new",
"or",
"print",
"print_r",
"private",
"protected",
"public",
"require",
"require_once",
"return",
"static",
"syslog",
"switch",
"throw",
"trait",
"try",
"unset",
"use",
"var",
"while",
"xor",
"yield",
],
"bash": [
"if",
"then",
"echo",
"else",
"elif",
"fi",
"case",
"esac",
"for",
"select",
"while",
"until",
"do",
"done",
"in",
"function",
"time",
"coproc",
],
"html": [
"DOCTYPE",
"html",
"head",
"title",
"meta",
"link",
"style",
"script",
"body",
"div",
"span",
"p",
"a",
"img",
"ul",
"ol",
"li",
"table",
"tr",
"td",
"th",
"form",
"input",
"button",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"br",
"hr",
],
"css": [
"color",
"background",
"background-color",
"border",
"margin",
"padding",
"width",
"height",
"font",
"font-size",
"font-weight",
"display",
"position",
"top",
"left",
"right",
"bottom",
"float",
"clear",
"overflow",
"z-index",
"opacity",
],
}
@staticmethod
def get_language_patterns(language: str) -> list:
"""Get highlighting patterns for a language."""
patterns = []
keywords = CodeHighlighter.KEYWORDS.get(language.lower(), [])
if language.lower() in ["python", "bash", "php"]:
# Comments (#)
patterns.append((r"#.*$", "comment"))
if language.lower() in ["javascript", "php", "css"]:
# Comments (//)
patterns.append((r"//.*$", "comment"))
# Multi-line comments (/* */)
patterns.append((r"/\*.*?\*/", "comment"))
if language.lower() in ["html", "xml"]:
# HTML/XML tags
patterns.append((r"<[^>]+>", "tag"))
# HTML comments
patterns.append((r"<!--.*?-->", "comment"))
# Strings (double quotes)
patterns.append((r'"[^"\\]*(\\.[^"\\]*)*"', "string"))
# Strings (single quotes)
patterns.append((r"'[^'\\]*(\\.[^'\\]*)*'", "string"))
# Numbers
patterns.append((r"\b\d+\.?\d*\b", "number"))
# Keywords
for keyword in keywords:
patterns.append((r"\b" + keyword + r"\b", "keyword"))
return patterns
@staticmethod
def get_format_for_type(
format_type: str, base_format: QTextCharFormat
) -> QTextCharFormat:
"""Get text format for a specific syntax type."""
fmt = QTextCharFormat(base_format)
if format_type == "keyword":
fmt.setForeground(QColor(86, 156, 214)) # Blue
fmt.setFontWeight(QFont.Weight.Bold)
elif format_type == "string":
fmt.setForeground(QColor(206, 145, 120)) # Orange
elif format_type == "comment":
fmt.setForeground(QColor(106, 153, 85)) # Green
fmt.setFontItalic(True)
elif format_type == "number":
fmt.setForeground(QColor(181, 206, 168)) # Light green
elif format_type == "tag":
fmt.setForeground(QColor(78, 201, 176)) # Cyan
return fmt
class CodeBlockMetadata:
"""Stores metadata about code blocks (language, etc.) for a document."""
def __init__(self):
self._block_languages: Dict[int, str] = {} # block_number -> language
def set_language(self, block_number: int, language: str):
"""Set the language for a code block."""
self._block_languages[block_number] = language.lower()
def get_language(self, block_number: int) -> Optional[str]:
"""Get the language for a code block."""
return self._block_languages.get(block_number)
def serialize(self) -> str:
"""Serialize metadata to a string."""
# Store as JSON-like format in a comment at the end
if not self._block_languages:
return ""
items = [f"{k}:{v}" for k, v in sorted(self._block_languages.items())]
return "<!-- code-langs: " + ",".join(items) + " -->"
def deserialize(self, text: str):
"""Deserialize metadata from text."""
self._block_languages.clear()
# Look for metadata comment at the end
match = re.search(r"<!-- code-langs: ([^>]+) -->", text)
if match:
pairs = match.group(1).split(",")
for pair in pairs:
if ":" in pair:
block_num, lang = pair.split(":", 1)
try:
self._block_languages[int(block_num)] = lang
except ValueError:
pass

View file

@ -63,6 +63,7 @@ class DBConfig:
move_todos: bool = False
tags: bool = True
time_log: bool = True
reminders: bool = True
locale: str = "en"
font_size: int = 11
@ -195,6 +196,20 @@ class DBManager:
ON time_log(project_id);
CREATE INDEX IF NOT EXISTS ix_time_log_activity
ON time_log(activity_id);
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY,
text TEXT NOT NULL,
time_str TEXT NOT NULL, -- HH:MM
reminder_type TEXT NOT NULL, -- once|daily|weekdays|weekly
weekday INTEGER, -- 0-6 for weekly (0=Mon)
date_iso TEXT, -- for once type
active INTEGER NOT NULL DEFAULT 1, -- 0=inactive, 1=active
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE INDEX IF NOT EXISTS ix_reminders_active
ON reminders(active);
"""
)
self.conn.commit()
@ -1015,3 +1030,90 @@ class DBManager:
if self.conn is not None:
self.conn.close()
self.conn = None
# ------------------------- Reminders logic here ------------------------#
def save_reminder(self, reminder) -> int:
"""Save or update a reminder. Returns the reminder ID."""
cur = self.conn.cursor()
if reminder.id:
# Update existing
cur.execute(
"""
UPDATE reminders
SET text = ?, time_str = ?, reminder_type = ?,
weekday = ?, date_iso = ?, active = ?
WHERE id = ?
""",
(
reminder.text,
reminder.time_str,
reminder.reminder_type.value,
reminder.weekday,
reminder.date_iso,
1 if reminder.active else 0,
reminder.id,
),
)
self.conn.commit()
return reminder.id
else:
# Insert new
cur.execute(
"""
INSERT INTO reminders (text, time_str, reminder_type, weekday, date_iso, active)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
reminder.text,
reminder.time_str,
reminder.reminder_type.value,
reminder.weekday,
reminder.date_iso,
1 if reminder.active else 0,
),
)
self.conn.commit()
return cur.lastrowid
def get_all_reminders(self):
"""Get all reminders."""
from .reminders import Reminder, ReminderType
cur = self.conn.cursor()
rows = cur.execute(
"""
SELECT id, text, time_str, reminder_type, weekday, date_iso, active
FROM reminders
ORDER BY time_str
"""
).fetchall()
result = []
for r in rows:
result.append(
Reminder(
id=r["id"],
text=r["text"],
time_str=r["time_str"],
reminder_type=ReminderType(r["reminder_type"]),
weekday=r["weekday"],
date_iso=r["date_iso"],
active=bool(r["active"]),
)
)
return result
def update_reminder_active(self, reminder_id: int, active: bool) -> None:
"""Update the active status of a reminder."""
cur = self.conn.cursor()
cur.execute(
"UPDATE reminders SET active = ? WHERE id = ?",
(1 if active else 0, reminder_id),
)
self.conn.commit()
def delete_reminder(self, reminder_id: int) -> None:
"""Delete a reminder."""
cur = self.conn.cursor()
cur.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,))
self.conn.commit()

View file

@ -49,6 +49,9 @@
"backup_complete": "Backup complete",
"backup_failed": "Backup failed",
"quit": "Quit",
"cancel": "Cancel",
"ok": "OK",
"save": "Save",
"help": "Help",
"saved": "Saved",
"saved_to": "Saved to",
@ -256,5 +259,44 @@
"export_pdf_error_title": "PDF export failed",
"export_pdf_error_message": "Could not write PDF file:\n{error}",
"enable_tags_feature": "Enable Tags",
"enable_time_log_feature": "Enable Time Logging"
"enable_time_log_feature": "Enable Time Logging",
"enable_reminders_feature": "Enable Reminders",
"pomodoro_time_log_default_text": "Focus session",
"toolbar_pomodoro_timer": "Time-logging timer",
"set_code_language": "Set code language",
"cut": "Cut",
"copy": "Copy",
"paste": "Paste",
"edit_table": "Edit table",
"toolbar_insert_table": "Insert table",
"start": "Start",
"pause": "Pause",
"resume": "Resume",
"stop_and_log": "Stop and log",
"once": "once",
"daily": "daily",
"weekdays": "weekdays",
"weekly": "weekly",
"set_reminder": "Set reminder",
"edit_reminder": "Edit reminder",
"reminder": "Reminder",
"time": "Time",
"once_today": "Once (today)",
"every_day": "Every day",
"every_weekday": "Every weekday (Mon-Fri)",
"every_week": "Every week",
"repeat": "Repeat",
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday",
"day": "Day",
"add_row": "Add row",
"add_column": "Add column",
"delete_row": "Delete row",
"delete_column": "Delete column",
"column": "Column"
}

View file

@ -121,5 +121,16 @@
"change_color": "Changer la couleur",
"delete_tag": "Supprimer l'étiquette",
"delete_tag_confirm": "Êtes-vous sûr de vouloir supprimer l'étiquette '{name}' ? Cela la supprimera de toutes les pages.",
"tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà"
"tag_already_exists_with_that_name": "Une étiquette portant ce nom existe déjà",
"cut" : "Couper",
"copy" : "Copier",
"paste" : "Coller",
"monday" : "Lundi",
"tuesday" : "Mardi",
"wednesday" : "Mercredi",
"thursday" : "Jeudi",
"friday" : "Vendredi",
"saturday" : "Samedi",
"sunday" : "Dimanche",
"day" : "Jour"
}

View file

@ -148,5 +148,16 @@
"bug_report_explanation": "Descrivi il problema, cosa dovrebbe succedere e istruzioni per riprodurlo.\n Non raccogliamo nessun dato all'infuori del numero di versione di Bouquin.\n\nSe volessi essere contattato, per favore lascia un contatto.",
"bug_report_placeholder": "Scrivi la tua segnalazione qui",
"update": "Aggiornamento",
"you_are_running_the_latest_version": "La tua versione di Bouquin è la più recente:\n"
"you_are_running_the_latest_version": "La tua versione di Bouquin è la più recente:\n",
"cut": "Taglia",
"copy": "Copia",
"paste": "Incolla",
"monday": "Lunedì",
"tuesday": "Martedì",
"wednesday": "Mercoledì",
"thursday": "Giovedì",
"friday": "Venerdì",
"saturday": "Sabato",
"sunday": "Domenica",
"day": "Giorno"
}

View file

@ -57,6 +57,8 @@ from .history_dialog import HistoryDialog
from .key_prompt import KeyPrompt
from .lock_overlay import LockOverlay
from .markdown_editor import MarkdownEditor
from .pomodoro_timer import PomodoroManager
from .reminders import UpcomingRemindersWidget
from .save_dialog import SaveDialog
from .search import Search
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
@ -106,12 +108,18 @@ class MainWindow(QMainWindow):
self.search.openDateRequested.connect(self._load_selected_date)
self.search.resultDatesChanged.connect(self._on_search_dates_changed)
# Features
self.time_log = TimeLogWidget(self.db)
self.tags = PageTagsWidget(self.db)
self.tags.tagActivated.connect(self._on_tag_activated)
self.tags.tagAdded.connect(self._on_tag_added)
self.upcoming_reminders = UpcomingRemindersWidget(self.db)
self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder)
self.pomodoro_manager = PomodoroManager(self.db, self)
# Lock the calendar to the left panel at the top to stop it stretching
# when the main window is resized.
left_panel = QWidget()
@ -119,6 +127,7 @@ class MainWindow(QMainWindow):
left_layout.setContentsMargins(8, 8, 8, 8)
left_layout.addWidget(self.calendar)
left_layout.addWidget(self.search)
left_layout.addWidget(self.upcoming_reminders)
left_layout.addWidget(self.time_log)
left_layout.addWidget(self.tags)
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
@ -324,6 +333,10 @@ class MainWindow(QMainWindow):
self.tags.hide()
if not self.cfg.time_log:
self.time_log.hide()
self.toolBar.actTimer.setVisible(False)
if not self.cfg.reminders:
self.upcoming_reminders.hide()
self.toolBar.actAlarm.setVisible(False)
# Restore window position from settings
self._restore_window_position()
@ -1087,6 +1100,8 @@ class MainWindow(QMainWindow):
self._tb_numbers = lambda: self._call_editor("toggle_numbers")
self._tb_checkboxes = lambda: self._call_editor("toggle_checkboxes")
self._tb_alarm = self._on_alarm_requested
self._tb_timer = self._on_timer_requested
self._tb_table = self._on_table_requested
self._tb_font_larger = self._on_font_larger_requested
self._tb_font_smaller = self._on_font_smaller_requested
@ -1099,6 +1114,8 @@ class MainWindow(QMainWindow):
tb.numbersRequested.connect(self._tb_numbers)
tb.checkboxesRequested.connect(self._tb_checkboxes)
tb.alarmRequested.connect(self._tb_alarm)
tb.timerRequested.connect(self._tb_timer)
tb.tableRequested.connect(self._tb_table)
tb.insertImageRequested.connect(self._on_insert_image)
tb.historyRequested.connect(self._open_history)
tb.fontSizeLargerRequested.connect(self._tb_font_larger)
@ -1228,6 +1245,23 @@ class MainWindow(QMainWindow):
# Rebuild timers, but only if this page is for "today"
self._rebuild_reminders_for_today()
def _on_timer_requested(self):
"""Start a Pomodoro timer for the current line."""
editor = getattr(self, "editor", None)
if editor is None:
return
# Get the current line text
line_text = editor.get_current_line_text().strip()
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
self.pomodoro_manager.start_timer_for_line(line_text, date_iso)
def _show_flashing_reminder(self, text: str):
"""
Show a small flashing dialog and request attention from the OS.
@ -1344,6 +1378,36 @@ class MainWindow(QMainWindow):
timer.start(msecs)
self._reminder_timers.append(timer)
# ----------- Table handler ------------#
def _on_table_requested(self):
"""Insert a basic markdown table template."""
editor = getattr(self, "editor", None)
if editor is None:
return
# Basic 3x3 table template
table_template = """| Column 1 | Column 2 | Column 3 |
| --- | --- | --- |
| Cell 1 | Cell 2 | Cell 3 |
| Cell 4 | Cell 5 | Cell 6 |
"""
cursor = editor.textCursor()
cursor.insertText(table_template)
# Move cursor to first cell for easy editing
# Find the start of "Column 1" text
cursor.movePosition(
QTextCursor.Left, QTextCursor.MoveAnchor, len(table_template)
)
cursor.movePosition(QTextCursor.Right, QTextCursor.MoveAnchor, 2) # After "| "
cursor.movePosition(
QTextCursor.Right, QTextCursor.KeepAnchor, 8
) # Select "Column 1"
editor.setTextCursor(cursor)
editor.setFocus()
# ----------- History handler ------------#
def _open_history(self):
if hasattr(self.editor, "current_date"):
@ -1444,6 +1508,7 @@ class MainWindow(QMainWindow):
self.cfg.move_todos = getattr(new_cfg, "move_todos", self.cfg.move_todos)
self.cfg.tags = getattr(new_cfg, "tags", self.cfg.tags)
self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders)
self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale)
self.cfg.font_size = getattr(new_cfg, "font_size", self.cfg.font_size)
@ -1471,8 +1536,16 @@ class MainWindow(QMainWindow):
self.tags.hide() if not self.cfg.tags else self.tags.show()
if not self.cfg.time_log:
self.time_log.hide()
self.toolBar.actTimer.setVisible(False)
else:
self.time_log.show()
self.toolBar.actTimer.setVisible(True)
if not self.cfg.reminders:
self.upcoming_reminders.hide()
self.toolBar.actAlarm.setVisible(False)
else:
self.upcoming_reminders.show()
self.toolBar.actAlarm.setVisible(True)
# ------------ Statistics handler --------------- #

View file

@ -22,6 +22,7 @@ from PySide6.QtWidgets import QTextEdit
from .theme import ThemeManager
from .markdown_highlighter import MarkdownHighlighter
from . import strings
class MarkdownEditor(QTextEdit):
@ -63,7 +64,12 @@ class MarkdownEditor(QTextEdit):
self._BULLET_STORAGE = "-"
# Install syntax highlighter
self.highlighter = MarkdownHighlighter(self.document(), theme_manager)
self.highlighter = MarkdownHighlighter(self.document(), theme_manager, self)
# Initialize code block metadata
from .code_highlighter import CodeBlockMetadata
self._code_metadata = CodeBlockMetadata()
# Track current list type for smart enter handling
self._last_enter_was_empty = False
@ -91,7 +97,9 @@ class MarkdownEditor(QTextEdit):
# Recreate the highlighter for the new document
# (the old one gets deleted with the old document)
if hasattr(self, "highlighter") and hasattr(self, "theme_manager"):
self.highlighter = MarkdownHighlighter(self.document(), self.theme_manager)
self.highlighter = MarkdownHighlighter(
self.document(), self.theme_manager, self
)
self._apply_line_spacing()
self._apply_code_block_spacing()
QTimer.singleShot(0, self._update_code_block_row_backgrounds)
@ -274,6 +282,12 @@ class MarkdownEditor(QTextEdit):
text,
)
# Append code block metadata if present
if hasattr(self, "_code_metadata"):
metadata_str = self._code_metadata.serialize()
if metadata_str:
text = text.rstrip() + "\n\n" + metadata_str
return text
def _extract_images_to_markdown(self) -> str:
@ -312,6 +326,16 @@ class MarkdownEditor(QTextEdit):
def from_markdown(self, markdown_text: str):
"""Load markdown text into the editor."""
# Extract and load code block metadata if present
from .code_highlighter import CodeBlockMetadata
if not hasattr(self, "_code_metadata"):
self._code_metadata = CodeBlockMetadata()
self._code_metadata.deserialize(markdown_text)
# Remove metadata comment from displayed text
markdown_text = re.sub(r"\s*<!-- code-langs: [^>]+ -->\s*$", "", markdown_text)
# Convert markdown checkboxes to Unicode for display
display_text = markdown_text.replace(
f"- {self._CHECK_CHECKED_STORAGE} ", f"{self._CHECK_CHECKED_DISPLAY} "
@ -432,10 +456,6 @@ class MarkdownEditor(QTextEdit):
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
return cursor.selectedText()
def get_current_line_text(self) -> str:
"""Public wrapper used by MainWindow for reminders."""
return self._get_current_line()
def _list_prefix_length_for_block(self, block) -> int:
"""Return the length (in chars) of the visual list prefix for the given
block (including leading indentation), or 0 if it's not a list item.
@ -1218,3 +1238,114 @@ class MarkdownEditor(QTextEdit):
cursor = self.textCursor()
cursor.insertImage(img_format)
cursor.insertText("\n") # Add newline after image
# ========== Context Menu Support ==========
def contextMenuEvent(self, event):
"""Override context menu to add custom actions."""
from PySide6.QtGui import QAction
from PySide6.QtWidgets import QMenu
menu = QMenu(self)
cursor = self.cursorForPosition(event.pos())
# Check if we're in a table
text = self.toPlainText()
cursor_pos = cursor.position()
from .table_editor import find_table_at_cursor
table_info = find_table_at_cursor(text, cursor_pos)
if table_info:
# Add table editing action
edit_table_action = QAction(strings._("edit_table"), self)
edit_table_action.triggered.connect(
lambda: self._edit_table_at_cursor(cursor_pos)
)
menu.addAction(edit_table_action)
menu.addSeparator()
# Check if we're in a code block
block = cursor.block()
if self._is_inside_code_block(block):
# Add language selection submenu
lang_menu = menu.addMenu(strings._("set_code_language"))
languages = [
"python",
"bash",
"php",
"javascript",
"html",
"css",
"sql",
"java",
"go",
]
for lang in languages:
action = QAction(lang.capitalize(), self)
action.triggered.connect(
lambda checked, l=lang: self._set_code_block_language(block, l)
)
lang_menu.addAction(action)
menu.addSeparator()
# Add standard context menu actions
if self.textCursor().hasSelection():
menu.addAction(strings._("cut"), self.cut)
menu.addAction(strings._("copy"), self.copy)
menu.addAction(strings._("paste"), self.paste)
menu.exec(event.globalPos())
def _edit_table_at_cursor(self, cursor_pos: int):
"""Open table editor dialog for the table at cursor position."""
from .table_editor import find_table_at_cursor, TableEditorDialog
from PySide6.QtWidgets import QDialog
text = self.toPlainText()
table_info = find_table_at_cursor(text, cursor_pos)
if not table_info:
return
start_pos, end_pos, table_text = table_info
# Open table editor
dlg = TableEditorDialog(table_text, self)
if dlg.exec() == QDialog.Accepted:
# Replace the table with edited version
new_table = dlg.get_markdown_table()
cursor = QTextCursor(self.document())
cursor.setPosition(start_pos)
cursor.setPosition(end_pos, QTextCursor.KeepAnchor)
cursor.insertText(new_table)
def _set_code_block_language(self, block, language: str):
"""Set the language for a code block and store metadata."""
if not hasattr(self, "_code_metadata"):
from .code_highlighter import CodeBlockMetadata
self._code_metadata = CodeBlockMetadata()
# Find the opening fence block for this code block
fence_block = block
while fence_block.isValid() and not fence_block.text().strip().startswith(
"```"
):
fence_block = fence_block.previous()
if fence_block.isValid():
self._code_metadata.set_language(fence_block.blockNumber(), language)
# Trigger rehighlight
self.highlighter.rehighlight()
def get_current_line_text(self) -> str:
"""Get the text of the current line."""
cursor = self.textCursor()
block = cursor.block()
return block.text()

View file

@ -19,9 +19,12 @@ from .theme import ThemeManager, Theme
class MarkdownHighlighter(QSyntaxHighlighter):
"""Live syntax highlighter for markdown that applies formatting as you type."""
def __init__(self, document: QTextDocument, theme_manager: ThemeManager):
def __init__(
self, document: QTextDocument, theme_manager: ThemeManager, editor=None
):
super().__init__(document)
self.theme_manager = theme_manager
self._editor = editor # Reference to the MarkdownEditor
self._setup_formats()
# Recompute formats whenever the app theme changes
self.theme_manager.themeChanged.connect(self._on_theme_changed)
@ -149,6 +152,36 @@ class MarkdownHighlighter(QSyntaxHighlighter):
if in_code_block:
# inside code: apply block bg and language rules
self.setFormat(0, len(text), self.code_block_format)
# Try to apply language-specific highlighting
if self._editor and hasattr(self._editor, "_code_metadata"):
from .code_highlighter import CodeHighlighter
# Find the opening fence block
prev_block = self.currentBlock().previous()
fence_block_num = None
temp_inside = in_code_block
while prev_block.isValid():
if prev_block.text().strip().startswith("```"):
temp_inside = not temp_inside
if not temp_inside:
fence_block_num = prev_block.blockNumber()
break
prev_block = prev_block.previous()
if fence_block_num is not None:
language = self._editor._code_metadata.get_language(fence_block_num)
if language:
patterns = CodeHighlighter.get_language_patterns(language)
for pattern, syntax_type in patterns:
for match in re.finditer(pattern, text):
start, end = match.span()
fmt = CodeHighlighter.get_format_for_type(
syntax_type, self.code_block_format
)
self.setFormat(start, end - start, fmt)
self.setCurrentBlockState(1)
return

149
bouquin/pomodoro_timer.py Normal file
View file

@ -0,0 +1,149 @@
from __future__ import annotations
import math
from typing import Optional
from PySide6.QtCore import Qt, QTimer, Signal, Slot
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QWidget,
)
from . import strings
from .db import DBManager
from .time_log import TimeLogDialog
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
self._running = False
layout = QVBoxLayout(self)
# Task label
task_label = QLabel(task_text)
task_label.setWordWrap(True)
layout.addWidget(task_label)
# Timer display
self.time_label = QLabel("00:00:00")
font = self.time_label.font()
font.setPointSize(24)
font.setBold(True)
self.time_label.setFont(font)
self.time_label.setAlignment(Qt.AlignCenter)
layout.addWidget(self.time_label)
# Control buttons
btn_layout = QHBoxLayout()
self.start_pause_btn = QPushButton(strings._("start"))
self.start_pause_btn.clicked.connect(self._toggle_timer)
btn_layout.addWidget(self.start_pause_btn)
self.stop_btn = QPushButton(strings._("stop_and_log"))
self.stop_btn.clicked.connect(self._stop_and_log)
self.stop_btn.setEnabled(False)
btn_layout.addWidget(self.stop_btn)
layout.addLayout(btn_layout)
# Internal timer (ticks every second)
self._timer = QTimer(self)
self._timer.timeout.connect(self._tick)
@Slot()
def _toggle_timer(self):
"""Start or pause the timer."""
if self._running:
# Pause
self._running = False
self._timer.stop()
self.start_pause_btn.setText(strings._("resume"))
else:
# Start/Resume
self._running = True
self._timer.start(1000) # 1 second
self.start_pause_btn.setText(strings._("pause"))
self.stop_btn.setEnabled(True)
@Slot()
def _tick(self):
"""Update the elapsed time display."""
self._elapsed_seconds += 1
self._update_display()
def _update_display(self):
"""Update the time display label."""
hours = self._elapsed_seconds // 3600
minutes = (self._elapsed_seconds % 3600) // 60
seconds = self._elapsed_seconds % 60
self.time_label.setText(f"{hours:02d}:{minutes:02d}:{seconds:02d}")
@Slot()
def _stop_and_log(self):
"""Stop the timer and emit signal to open time log."""
if self._running:
self._running = False
self._timer.stop()
self.timerStopped.emit(self._elapsed_seconds, self._task_text)
self.accept()
class PomodoroManager:
"""Manages Pomodoro timers and integrates with time log."""
def __init__(self, db: DBManager, parent_window):
self._db = db
self._parent = parent_window
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()
# 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)
)
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."""
# Convert seconds to decimal hours, rounded up
hours = math.ceil(elapsed_seconds / 360) / 25 # Round up to nearest 0.25 hour
# Ensure minimum of 0.25 hours
if hours < 0.25:
hours = 0.25
# Open time log dialog
dlg = TimeLogDialog(self._db, date_iso, self._parent)
# Pre-fill the hours
dlg.hours_spin.setValue(hours)
# Pre-fill the note with task text
dlg.note.setText(task_text)
# Show the dialog
dlg.exec()

637
bouquin/reminders.py Normal file
View file

@ -0,0 +1,637 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Optional
from PySide6.QtCore import Qt, QDate, QTime, QDateTime, QTimer, Slot, Signal
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QFormLayout,
QLineEdit,
QComboBox,
QTimeEdit,
QPushButton,
QFrame,
QWidget,
QToolButton,
QListWidget,
QListWidgetItem,
QStyle,
QSizePolicy,
QMessageBox,
QTableWidget,
QTableWidgetItem,
QAbstractItemView,
QHeaderView,
)
from . import strings
from .db import DBManager
class ReminderType(Enum):
ONCE = strings._("once")
DAILY = strings._("daily")
WEEKDAYS = strings._("weekdays") # Mon-Fri
WEEKLY = strings._("weekly") # specific day of week
@dataclass
class Reminder:
id: Optional[int]
text: str
time_str: str # HH:MM
reminder_type: ReminderType
weekday: Optional[int] = None # 0=Mon, 6=Sun (for weekly type)
active: bool = True
date_iso: Optional[str] = None # For ONCE type
class ReminderDialog(QDialog):
"""Dialog for creating/editing reminders with recurrence support."""
def __init__(self, db: DBManager, parent=None, reminder: Optional[Reminder] = None):
super().__init__(parent)
self._db = db
self._reminder = reminder
self.setWindowTitle(
strings._("set_reminder") if not reminder else strings._("edit_reminder")
)
self.setMinimumWidth(400)
layout = QVBoxLayout(self)
form = QFormLayout()
# Reminder text
self.text_edit = QLineEdit()
if reminder:
self.text_edit.setText(reminder.text)
form.addRow("&" + strings._("reminder") + ":", self.text_edit)
# Time
self.time_edit = QTimeEdit()
self.time_edit.setDisplayFormat("HH:mm")
if reminder:
parts = reminder.time_str.split(":")
self.time_edit.setTime(QTime(int(parts[0]), int(parts[1])))
else:
self.time_edit.setTime(QTime.currentTime())
form.addRow("&" + strings._("time") + ":", self.time_edit)
# Recurrence type
self.type_combo = QComboBox()
self.type_combo.addItem(strings._("once_today"), ReminderType.ONCE)
self.type_combo.addItem(strings._("every_day"), ReminderType.DAILY)
self.type_combo.addItem(strings._("every_weekday"), ReminderType.WEEKDAYS)
self.type_combo.addItem(strings._("every_week"), ReminderType.WEEKLY)
if reminder:
for i in range(self.type_combo.count()):
if self.type_combo.itemData(i) == reminder.reminder_type:
self.type_combo.setCurrentIndex(i)
break
self.type_combo.currentIndexChanged.connect(self._on_type_changed)
form.addRow("&" + strings._("repeat") + ":", self.type_combo)
# Weekday selector (for weekly reminders)
self.weekday_combo = QComboBox()
days = [
strings._("monday"),
strings._("tuesday"),
strings._("wednesday"),
strings._("thursday"),
strings._("friday"),
strings._("saturday"),
strings._("sunday"),
]
for i, day in enumerate(days):
self.weekday_combo.addItem(day, i)
if reminder and reminder.weekday is not None:
self.weekday_combo.setCurrentIndex(reminder.weekday)
else:
self.weekday_combo.setCurrentIndex(QDate.currentDate().dayOfWeek() - 1)
form.addRow("&" + strings._("day") + ":", self.weekday_combo)
layout.addLayout(form)
# Buttons
btn_layout = QHBoxLayout()
btn_layout.addStretch()
save_btn = QPushButton("&" + strings._("save"))
save_btn.clicked.connect(self.accept)
save_btn.setDefault(True)
btn_layout.addWidget(save_btn)
cancel_btn = QPushButton("&" + strings._("cancel"))
cancel_btn.clicked.connect(self.reject)
btn_layout.addWidget(cancel_btn)
layout.addLayout(btn_layout)
self._on_type_changed()
def _on_type_changed(self):
"""Show/hide weekday selector based on reminder type."""
reminder_type = self.type_combo.currentData()
self.weekday_combo.setVisible(reminder_type == ReminderType.WEEKLY)
def get_reminder(self) -> Reminder:
"""Get the configured reminder."""
reminder_type = self.type_combo.currentData()
time_obj = self.time_edit.time()
time_str = f"{time_obj.hour():02d}:{time_obj.minute():02d}"
weekday = None
if reminder_type == ReminderType.WEEKLY:
weekday = self.weekday_combo.currentData()
date_iso = None
if reminder_type == ReminderType.ONCE:
date_iso = QDate.currentDate().toString("yyyy-MM-dd")
return Reminder(
id=self._reminder.id if self._reminder else None,
text=self.text_edit.text(),
time_str=time_str,
reminder_type=reminder_type,
weekday=weekday,
date_iso=date_iso,
)
class UpcomingRemindersWidget(QFrame):
"""Collapsible widget showing upcoming reminders for today and next 7 days."""
reminderTriggered = Signal(str) # Emits reminder text
def __init__(self, db: DBManager, parent: Optional[QWidget] = None):
super().__init__(parent)
self._db = db
self.setFrameShape(QFrame.StyledPanel)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
# Header with toggle button
self.toggle_btn = QToolButton()
self.toggle_btn.setText("Upcoming Reminders")
self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
self.toggle_btn.setCheckable(True)
self.toggle_btn.setChecked(False)
self.toggle_btn.setArrowType(Qt.RightArrow)
self.toggle_btn.clicked.connect(self._on_toggle)
self.add_btn = QToolButton()
self.add_btn.setIcon(self.style().standardIcon(QStyle.SP_FileDialogNewFolder))
self.add_btn.setToolTip("Add Reminder")
self.add_btn.setAutoRaise(True)
self.add_btn.clicked.connect(self._add_reminder)
self.manage_btn = QToolButton()
self.manage_btn.setIcon(
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
)
self.manage_btn.setToolTip("Manage All Reminders")
self.manage_btn.setAutoRaise(True)
self.manage_btn.clicked.connect(self._manage_reminders)
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
header.addWidget(self.toggle_btn)
header.addStretch()
header.addWidget(self.add_btn)
header.addWidget(self.manage_btn)
# Body with reminder list
self.body = QWidget()
body_layout = QVBoxLayout(self.body)
body_layout.setContentsMargins(0, 4, 0, 0)
body_layout.setSpacing(2)
self.reminder_list = QListWidget()
self.reminder_list.setMaximumHeight(200)
self.reminder_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.reminder_list.itemDoubleClicked.connect(self._edit_reminder)
self.reminder_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.reminder_list.customContextMenuRequested.connect(
self._show_reminder_context_menu
)
body_layout.addWidget(self.reminder_list)
self.body.setVisible(False)
main = QVBoxLayout(self)
main.setContentsMargins(0, 0, 0, 0)
main.addLayout(header)
main.addWidget(self.body)
# Timer to check and fire reminders
# Start by syncing to the next minute boundary
self._check_timer = QTimer(self)
self._check_timer.timeout.connect(self._check_reminders)
# Calculate milliseconds until next minute (HH:MM:00)
now = QDateTime.currentDateTime()
current_second = now.time().second()
current_msec = now.time().msec()
# Milliseconds until next minute
ms_until_next_minute = (60 - current_second) * 1000 - current_msec
# Start with a single-shot to sync to the minute
self._sync_timer = QTimer(self)
self._sync_timer.setSingleShot(True)
self._sync_timer.timeout.connect(self._start_regular_timer)
self._sync_timer.start(ms_until_next_minute)
# Also check immediately in case there are pending reminders
QTimer.singleShot(1000, self._check_reminders)
def __del__(self):
"""Cleanup timers when widget is destroyed."""
try:
if hasattr(self, "_check_timer") and self._check_timer:
self._check_timer.stop()
if hasattr(self, "_sync_timer") and self._sync_timer:
self._sync_timer.stop()
except:
pass # Ignore any cleanup errors
def _start_regular_timer(self):
"""Start the regular check timer after initial sync."""
# Now we're at a minute boundary, check and start regular timer
self._check_reminders()
self._check_timer.start(60000) # Check every minute
def _on_toggle(self, checked: bool):
"""Toggle visibility of reminder list."""
self.body.setVisible(checked)
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
if checked:
self.refresh()
def refresh(self):
"""Reload and display upcoming reminders."""
# Guard: Check if database connection is valid
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
return
self.reminder_list.clear()
reminders = self._db.get_all_reminders()
now = QDateTime.currentDateTime()
today = QDate.currentDate()
# Get reminders for the next 7 days
upcoming = []
for i in range(8): # Today + 7 days
check_date = today.addDays(i)
for reminder in reminders:
if not reminder.active:
continue
if self._should_fire_on_date(reminder, check_date):
# Parse time
hour, minute = map(int, reminder.time_str.split(":"))
dt = QDateTime(check_date, QTime(hour, minute))
# Skip past reminders
if dt < now:
continue
upcoming.append((dt, reminder))
# Sort by datetime
upcoming.sort(key=lambda x: x[0])
# Display
for dt, reminder in upcoming[:20]: # Show max 20
date_str = dt.date().toString("ddd MMM d")
time_str = dt.time().toString("HH:mm")
item = QListWidgetItem(f"{date_str} {time_str} - {reminder.text}")
item.setData(Qt.UserRole, reminder)
self.reminder_list.addItem(item)
if not upcoming:
item = QListWidgetItem("No upcoming reminders")
item.setFlags(Qt.NoItemFlags)
self.reminder_list.addItem(item)
def _should_fire_on_date(self, reminder: Reminder, date: QDate) -> bool:
"""Check if a reminder should fire on a given date."""
if reminder.reminder_type == ReminderType.ONCE:
if reminder.date_iso:
return date.toString("yyyy-MM-dd") == reminder.date_iso
return False
elif reminder.reminder_type == ReminderType.DAILY:
return True
elif reminder.reminder_type == ReminderType.WEEKDAYS:
# Monday=1, Sunday=7
return 1 <= date.dayOfWeek() <= 5
elif reminder.reminder_type == ReminderType.WEEKLY:
# Qt: Monday=1, reminder: Monday=0
return date.dayOfWeek() - 1 == reminder.weekday
return False
def _check_reminders(self):
"""Check if any reminders should fire now."""
# Guard: Check if database connection is valid
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
return
now = QDateTime.currentDateTime()
today = QDate.currentDate()
# Round current time to the minute (set seconds to 0)
current_minute = QDateTime(
today, QTime(now.time().hour(), now.time().minute(), 0)
)
reminders = self._db.get_all_reminders()
for reminder in reminders:
if not reminder.active:
continue
if not self._should_fire_on_date(reminder, today):
continue
# Parse time
hour, minute = map(int, reminder.time_str.split(":"))
target = QDateTime(today, QTime(hour, minute, 0))
# Fire if we've passed the target minute (within last 2 minutes to catch missed ones)
seconds_diff = current_minute.secsTo(target)
if -120 <= seconds_diff <= 0:
# Check if we haven't already fired this one
if not hasattr(self, "_fired_reminders"):
self._fired_reminders = {}
reminder_key = (reminder.id, target.toString())
# Only fire once per reminder per target time
if reminder_key not in self._fired_reminders:
self._fired_reminders[reminder_key] = current_minute
self.reminderTriggered.emit(reminder.text)
# For ONCE reminders, deactivate after firing
if reminder.reminder_type == ReminderType.ONCE:
self._db.update_reminder_active(reminder.id, False)
self.refresh() # Refresh the list to show deactivated reminder
@Slot()
def _add_reminder(self):
"""Open dialog to add a new reminder."""
dlg = ReminderDialog(self._db, self)
if dlg.exec() == QDialog.Accepted:
reminder = dlg.get_reminder()
self._db.save_reminder(reminder)
self.refresh()
@Slot(QListWidgetItem)
def _edit_reminder(self, item: QListWidgetItem):
"""Edit an existing reminder."""
reminder = item.data(Qt.UserRole)
if not reminder:
return
dlg = ReminderDialog(self._db, self, reminder)
if dlg.exec() == QDialog.Accepted:
updated = dlg.get_reminder()
self._db.save_reminder(updated)
self.refresh()
@Slot()
def _show_reminder_context_menu(self, pos):
"""Show context menu for reminder list item(s)."""
selected_items = self.reminder_list.selectedItems()
if not selected_items:
return
from PySide6.QtWidgets import QMenu
from PySide6.QtGui import QAction
menu = QMenu(self)
# Only show Edit if single item selected
if len(selected_items) == 1:
reminder = selected_items[0].data(Qt.UserRole)
if reminder:
edit_action = QAction("Edit", self)
edit_action.triggered.connect(
lambda: self._edit_reminder(selected_items[0])
)
menu.addAction(edit_action)
# Delete option for any selection
if len(selected_items) == 1:
delete_text = "Delete"
else:
delete_text = f"Delete {len(selected_items)} Reminders"
delete_action = QAction(delete_text, self)
delete_action.triggered.connect(lambda: self._delete_selected_reminders())
menu.addAction(delete_action)
menu.exec(self.reminder_list.mapToGlobal(pos))
def _delete_selected_reminders(self):
"""Delete all selected reminders (handling duplicates)."""
selected_items = self.reminder_list.selectedItems()
if not selected_items:
return
# Collect unique reminder IDs
unique_reminders = {}
for item in selected_items:
reminder = item.data(Qt.UserRole)
if reminder and reminder.id not in unique_reminders:
unique_reminders[reminder.id] = reminder
if not unique_reminders:
return
# Confirmation message
if len(unique_reminders) == 1:
reminder = list(unique_reminders.values())[0]
msg = f"Delete reminder '{reminder.text}'?"
if reminder.reminder_type != ReminderType.ONCE:
msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences."
else:
msg = f"Delete {len(unique_reminders)} reminders?\n\nNote: This will delete the actual reminders, not just individual occurrences."
reply = QMessageBox.question(
self,
"Delete Reminders",
msg,
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
for reminder_id in unique_reminders:
self._db.delete_reminder(reminder_id)
self.refresh()
def _delete_reminder(self, reminder):
"""Delete a single reminder after confirmation."""
msg = f"Delete reminder '{reminder.text}'?"
if reminder.reminder_type != ReminderType.ONCE:
msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences."
reply = QMessageBox.question(
self,
"Delete Reminder",
msg,
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
self._db.delete_reminder(reminder.id)
self.refresh()
@Slot()
def _manage_reminders(self):
"""Open dialog to manage all reminders."""
dlg = ManageRemindersDialog(self._db, self)
dlg.exec()
self.refresh()
class ManageRemindersDialog(QDialog):
"""Dialog for managing all reminders."""
def __init__(self, db: DBManager, parent: Optional[QWidget] = None):
super().__init__(parent)
self._db = db
self.setWindowTitle("Manage Reminders")
self.setMinimumSize(700, 500)
layout = QVBoxLayout(self)
# Reminder list table
self.table = QTableWidget()
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(
["Text", "Time", "Type", "Active", "Actions"]
)
self.table.horizontalHeader().setStretchLastSection(False)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
layout.addWidget(self.table)
# Buttons
btn_layout = QHBoxLayout()
add_btn = QPushButton("Add Reminder")
add_btn.clicked.connect(self._add_reminder)
btn_layout.addWidget(add_btn)
btn_layout.addStretch()
close_btn = QPushButton("Close")
close_btn.clicked.connect(self.accept)
btn_layout.addWidget(close_btn)
layout.addLayout(btn_layout)
self._load_reminders()
def _load_reminders(self):
"""Load all reminders into the table."""
# Guard: Check if database connection is valid
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
return
reminders = self._db.get_all_reminders()
self.table.setRowCount(len(reminders))
for row, reminder in enumerate(reminders):
# Text
text_item = QTableWidgetItem(reminder.text)
text_item.setData(Qt.UserRole, reminder)
self.table.setItem(row, 0, text_item)
# Time
time_item = QTableWidgetItem(reminder.time_str)
self.table.setItem(row, 1, time_item)
# Type
type_str = {
ReminderType.ONCE: "Once",
ReminderType.DAILY: "Daily",
ReminderType.WEEKDAYS: "Weekdays",
ReminderType.WEEKLY: "Weekly",
}.get(reminder.reminder_type, "Unknown")
if (
reminder.reminder_type == ReminderType.WEEKLY
and reminder.weekday is not None
):
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
type_str += f" ({days[reminder.weekday]})"
type_item = QTableWidgetItem(type_str)
self.table.setItem(row, 2, type_item)
# Active
active_item = QTableWidgetItem("" if reminder.active else "")
self.table.setItem(row, 3, active_item)
# Actions
actions_widget = QWidget()
actions_layout = QHBoxLayout(actions_widget)
actions_layout.setContentsMargins(2, 2, 2, 2)
edit_btn = QPushButton("Edit")
edit_btn.clicked.connect(lambda checked, r=reminder: self._edit_reminder(r))
actions_layout.addWidget(edit_btn)
delete_btn = QPushButton("Delete")
delete_btn.clicked.connect(
lambda checked, r=reminder: self._delete_reminder(r)
)
actions_layout.addWidget(delete_btn)
self.table.setCellWidget(row, 4, actions_widget)
def _add_reminder(self):
"""Add a new reminder."""
dlg = ReminderDialog(self._db, self)
if dlg.exec() == QDialog.Accepted:
reminder = dlg.get_reminder()
self._db.save_reminder(reminder)
self._load_reminders()
def _edit_reminder(self, reminder):
"""Edit an existing reminder."""
dlg = ReminderDialog(self._db, self, reminder)
if dlg.exec() == QDialog.Accepted:
updated = dlg.get_reminder()
self._db.save_reminder(updated)
self._load_reminders()
def _delete_reminder(self, reminder):
"""Delete a reminder."""
reply = QMessageBox.question(
self,
"Delete Reminder",
f"Delete reminder '{reminder.text}'?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
self._db.delete_reminder(reminder.id)
self._load_reminders()

View file

@ -43,6 +43,7 @@ def load_db_config() -> DBConfig:
move_todos = s.value("ui/move_todos", False, type=bool)
tags = s.value("ui/tags", True, type=bool)
time_log = s.value("ui/time_log", True, type=bool)
reminders = s.value("ui/reminders", True, type=bool)
locale = s.value("ui/locale", "en", type=str)
font_size = s.value("ui/font_size", 11, type=int)
return DBConfig(
@ -53,6 +54,7 @@ def load_db_config() -> DBConfig:
move_todos=move_todos,
tags=tags,
time_log=time_log,
reminders=reminders,
locale=locale,
font_size=font_size,
)
@ -67,5 +69,6 @@ def save_db_config(cfg: DBConfig) -> None:
s.setValue("ui/move_todos", str(cfg.move_todos))
s.setValue("ui/tags", str(cfg.tags))
s.setValue("ui/time_log", str(cfg.time_log))
s.setValue("ui/reminders", str(cfg.reminders))
s.setValue("ui/locale", str(cfg.locale))
s.setValue("ui/font_size", str(cfg.font_size))

View file

@ -176,6 +176,11 @@ class SettingsDialog(QDialog):
self.time_log.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.time_log)
self.reminders = QCheckBox(strings._("enable_reminders_feature"))
self.reminders.setChecked(self.current_settings.reminders)
self.reminders.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.reminders)
layout.addWidget(features_group)
layout.addStretch()
return page
@ -302,6 +307,7 @@ class SettingsDialog(QDialog):
move_todos=self.move_todos.isChecked(),
tags=self.tags.isChecked(),
time_log=self.time_log.isChecked(),
reminders=self.reminders.isChecked(),
locale=self.locale_combobox.currentText(),
font_size=self.font_size.value(),
)

255
bouquin/table_editor.py Normal file
View file

@ -0,0 +1,255 @@
from __future__ import annotations
import re
from typing import Optional
from PySide6.QtCore import Slot
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QTableWidget,
QTableWidgetItem,
QPushButton,
QHeaderView,
QWidget,
)
from . import strings
class TableEditorDialog(QDialog):
"""Dialog for editing markdown tables visually."""
def __init__(self, table_text: str, parent: Optional[QWidget] = None):
super().__init__(parent)
self.setWindowTitle(strings._("edit_table"))
self.setMinimumSize(600, 400)
layout = QVBoxLayout(self)
# Parse the table
self.table_widget = QTableWidget()
self._parse_table(table_text)
# Allow editing
self.table_widget.horizontalHeader().setSectionResizeMode(
QHeaderView.Interactive
)
layout.addWidget(self.table_widget)
# Buttons for table operations
btn_layout = QHBoxLayout()
add_row_btn = QPushButton(strings._("add_row"))
add_row_btn.clicked.connect(self._add_row)
btn_layout.addWidget(add_row_btn)
add_col_btn = QPushButton(strings._("add_column"))
add_col_btn.clicked.connect(self._add_column)
btn_layout.addWidget(add_col_btn)
del_row_btn = QPushButton(strings._("delete_row"))
del_row_btn.clicked.connect(self._delete_row)
btn_layout.addWidget(del_row_btn)
del_col_btn = QPushButton(strings._("delete_column"))
del_col_btn.clicked.connect(self._delete_column)
btn_layout.addWidget(del_col_btn)
layout.addLayout(btn_layout)
# OK/Cancel buttons
btn_layout2 = QHBoxLayout()
btn_layout2.addStretch()
ok_btn = QPushButton(strings._("ok"))
ok_btn.clicked.connect(self.accept)
ok_btn.setDefault(True)
btn_layout2.addWidget(ok_btn)
cancel_btn = QPushButton(strings._("cancel"))
cancel_btn.clicked.connect(self.reject)
btn_layout2.addWidget(cancel_btn)
layout.addLayout(btn_layout2)
def _parse_table(self, text: str):
"""Parse markdown table into QTableWidget."""
lines = [line.strip() for line in text.split("\n") if line.strip()]
if len(lines) < 1:
return
# Parse header
header_line = lines[0]
# Split by | and remove first/last empty strings from leading/trailing pipes
header_parts = header_line.split("|")
if len(header_parts) > 0 and not header_parts[0].strip():
header_parts = header_parts[1:]
if len(header_parts) > 0 and not header_parts[-1].strip():
header_parts = header_parts[:-1]
headers = [cell.strip() for cell in header_parts]
# Check if line[1] is a separator line (contains ---)
# If not, treat all lines after header as data
start_data_idx = 1
if len(lines) > 1:
separator_check = lines[1]
# Split by | and remove first/last empty strings
sep_parts = separator_check.split("|")
if len(sep_parts) > 0 and not sep_parts[0].strip():
sep_parts = sep_parts[1:]
if len(sep_parts) > 0 and not sep_parts[-1].strip():
sep_parts = sep_parts[:-1]
cells = [cell.strip() for cell in sep_parts]
# Check if this looks like a separator (contains --- or :--: etc)
if cells and all(re.match(r"^:?-+:?$", cell) for cell in cells):
start_data_idx = 2 # Skip separator line
# Parse data rows
data_rows = []
for line in lines[start_data_idx:]:
# Split by | and remove first/last empty strings from leading/trailing pipes
parts = line.split("|")
if len(parts) > 0 and not parts[0].strip():
parts = parts[1:]
if len(parts) > 0 and not parts[-1].strip():
parts = parts[:-1]
cells = [cell.strip() for cell in parts]
data_rows.append(cells)
# Set up table
self.table_widget.setColumnCount(len(headers))
self.table_widget.setHorizontalHeaderLabels(headers)
self.table_widget.setRowCount(len(data_rows))
# Populate cells
for row_idx, row_data in enumerate(data_rows):
for col_idx, cell_text in enumerate(row_data):
if col_idx < len(headers):
item = QTableWidgetItem(cell_text)
self.table_widget.setItem(row_idx, col_idx, item)
@Slot()
def _add_row(self):
"""Add a new row to the table."""
row_count = self.table_widget.rowCount()
self.table_widget.insertRow(row_count)
# Add empty items
for col in range(self.table_widget.columnCount()):
self.table_widget.setItem(row_count, col, QTableWidgetItem(""))
@Slot()
def _add_column(self):
"""Add a new column to the table."""
col_count = self.table_widget.columnCount()
self.table_widget.insertColumn(col_count)
self.table_widget.setHorizontalHeaderItem(
col_count, QTableWidgetItem(strings._("column") + f"{col_count + 1}")
)
# Add empty items
for row in range(self.table_widget.rowCount()):
self.table_widget.setItem(row, col_count, QTableWidgetItem(""))
@Slot()
def _delete_row(self):
"""Delete the currently selected row."""
current_row = self.table_widget.currentRow()
if current_row >= 0:
self.table_widget.removeRow(current_row)
@Slot()
def _delete_column(self):
"""Delete the currently selected column."""
current_col = self.table_widget.currentColumn()
if current_col >= 0:
self.table_widget.removeColumn(current_col)
def get_markdown_table(self) -> str:
"""Convert the table back to markdown format."""
if self.table_widget.rowCount() == 0 or self.table_widget.columnCount() == 0:
return ""
lines = []
# Header
headers = []
for col in range(self.table_widget.columnCount()):
header_item = self.table_widget.horizontalHeaderItem(col)
headers.append(
header_item.text()
if header_item
else strings._("column") + f"{col + 1}"
)
lines.append("| " + " | ".join(headers) + " |")
# Separator
lines.append("| " + " | ".join(["---"] * len(headers)) + " |")
# Data rows
for row in range(self.table_widget.rowCount()):
cells = []
for col in range(self.table_widget.columnCount()):
item = self.table_widget.item(row, col)
cells.append(item.text() if item else "")
lines.append("| " + " | ".join(cells) + " |")
return "\n".join(lines)
def find_table_at_cursor(text: str, cursor_pos: int) -> Optional[tuple[int, int, str]]:
"""
Find a markdown table containing the cursor position.
Returns (start_pos, end_pos, table_text) or None.
"""
lines = text.split("\n")
# Find which line the cursor is on
current_pos = 0
cursor_line_idx = 0
for i, line in enumerate(lines):
if current_pos + len(line) >= cursor_pos:
cursor_line_idx = i
break
current_pos += len(line) + 1 # +1 for newline
# Check if cursor line is part of a table
if not _is_table_line(lines[cursor_line_idx]):
return None
# Find table start
start_idx = cursor_line_idx
while start_idx > 0 and _is_table_line(lines[start_idx - 1]):
start_idx -= 1
# Find table end
end_idx = cursor_line_idx
while end_idx < len(lines) - 1 and _is_table_line(lines[end_idx + 1]):
end_idx += 1
# Extract table text
table_lines = lines[start_idx : end_idx + 1]
table_text = "\n".join(table_lines)
# Calculate character positions
start_pos = sum(len(lines[i]) + 1 for i in range(start_idx))
end_pos = start_pos + len(table_text)
return (start_pos, end_pos, table_text)
def _is_table_line(line: str) -> bool:
"""Check if a line is part of a markdown table."""
stripped = line.strip()
if not stripped:
return False
# Table lines start and end with |
if not (stripped.startswith("|") and stripped.endswith("|")):
return False
# Must have at least one | in between
return stripped.count("|") >= 2

View file

@ -274,7 +274,7 @@ class TimeLogDialog(QDialog):
# --- Close button
close_row = QHBoxLayout()
close_row.addStretch(1)
close_btn = QPushButton("&" + strings._("close"))
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
close_row.addWidget(close_btn)
root.addLayout(close_row)
@ -572,7 +572,7 @@ class TimeCodeManagerDialog(QDialog):
# Close
close_row = QHBoxLayout()
close_row.addStretch(1)
close_btn = QPushButton("&" + strings._("close"))
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
close_row.addWidget(close_btn)
root.addLayout(close_row)
@ -916,7 +916,7 @@ class TimeReportDialog(QDialog):
# Close
close_row = QHBoxLayout()
close_row.addStretch(1)
close_btn = QPushButton("&" + strings._("close"))
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
close_row.addWidget(close_btn)
root.addLayout(close_row)

View file

@ -19,6 +19,8 @@ class ToolBar(QToolBar):
historyRequested = Signal()
insertImageRequested = Signal()
alarmRequested = Signal()
timerRequested = Signal()
tableRequested = Signal()
fontSizeLargerRequested = Signal()
fontSizeSmallerRequested = Signal()
@ -103,6 +105,16 @@ class ToolBar(QToolBar):
self.actAlarm.setToolTip(strings._("toolbar_alarm"))
self.actAlarm.triggered.connect(self.alarmRequested)
# Focus timer
self.actTimer = QAction("", self)
self.actTimer.setToolTip(strings._("toolbar_pomodoro_timer"))
self.actTimer.triggered.connect(self.timerRequested)
# Table
self.actTable = QAction("", self)
self.actTable.setToolTip(strings._("toolbar_insert_table"))
self.actTable.triggered.connect(self.tableRequested)
# Images
self.actInsertImg = QAction("📸", self)
self.actInsertImg.setToolTip(strings._("insert_images"))
@ -151,6 +163,8 @@ class ToolBar(QToolBar):
self.actNumbers,
self.actCheckboxes,
self.actAlarm,
self.actTimer,
self.actTable,
self.actInsertImg,
self.actHistory,
]
@ -177,6 +191,8 @@ class ToolBar(QToolBar):
self._style_letter_button(self.actNumbers, "1.")
self._style_letter_button(self.actCheckboxes, "")
self._style_letter_button(self.actAlarm, "")
self._style_letter_button(self.actTimer, "")
self._style_letter_button(self.actTable, "")
# History
self._style_letter_button(self.actHistory, "")

View file

@ -408,5 +408,5 @@ class VersionChecker:
QMessageBox.information(
self._parent,
strings._("update"),
strings._("downloaded_and_verified_new_appimage") + appimage_path,
strings._("downloaded_and_verified_new_appimage") + str(appimage_path),
)