Many changes and new features:
* 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:
parent
26737fbfb2
commit
e0169db52a
28 changed files with 4191 additions and 17 deletions
365
bouquin/code_highlighter.py
Normal file
365
bouquin/code_highlighter.py
Normal 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
|
||||
102
bouquin/db.py
102
bouquin/db.py
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 --------------- #
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
149
bouquin/pomodoro_timer.py
Normal 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
637
bouquin/reminders.py
Normal 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()
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
255
bouquin/table_editor.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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, "⎌")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue