diff --git a/CHANGELOG.md b/CHANGELOG.md index 425b5f1..0e0763e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Fix styling issue with text that comes after a URL, so it doesn't appear as part of the URL. * Add ability to export to Markdown (and fix heading styles) * Represent in the History diff pane when an image was the thing that changed + * Support theme choice in settings (light/dark/system) # 0.1.9 diff --git a/bouquin/db.py b/bouquin/db.py index 68f956d..e8c4903 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -19,6 +19,7 @@ class DBConfig: path: Path key: str idle_minutes: int = 15 # 0 = never lock + theme: str = "system" class DBManager: @@ -160,13 +161,6 @@ class DBManager: ).fetchone() return row[0] if row else "" - def upsert_entry(self, date_iso: str, content: str) -> None: - """ - Insert or update an entry. - """ - # Make a new version and set it as current - self.save_new_version(date_iso, content, note=None, set_current=True) - def search_entries(self, text: str) -> list[str]: """ Search for entries by term. This only works against the latest diff --git a/bouquin/editor.py b/bouquin/editor.py index 296ca34..f68d3c1 100644 --- a/bouquin/editor.py +++ b/bouquin/editor.py @@ -10,6 +10,7 @@ from PySide6.QtGui import ( QFontDatabase, QImage, QImageReader, + QPalette, QPixmap, QTextCharFormat, QTextCursor, @@ -28,8 +29,11 @@ from PySide6.QtCore import ( QBuffer, QByteArray, QIODevice, + QTimer, ) -from PySide6.QtWidgets import QTextEdit +from PySide6.QtWidgets import QTextEdit, QApplication + +from .theme import Theme, ThemeManager class Editor(QTextEdit): @@ -42,7 +46,7 @@ class Editor(QTextEdit): _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") _DATA_IMG_RX = re.compile(r'src=["\']data:image/[^;]+;base64,([^"\']+)["\']', re.I) - def __init__(self, *args, **kwargs): + def __init__(self, theme_manager: ThemeManager, *args, **kwargs): super().__init__(*args, **kwargs) tab_w = 4 * self.fontMetrics().horizontalAdvance(" ") self.setTabStopDistance(tab_w) @@ -55,7 +59,13 @@ class Editor(QTextEdit): self.setAcceptRichText(True) - # Turn raw URLs into anchors + # If older docs have a baked-in color, normalize once: + self._retint_anchors_to_palette() + + self._themes = theme_manager + # Refresh on theme change + self._themes.themeChanged.connect(self._on_theme_changed) + self._linkifying = False self.textChanged.connect(self._linkify_document) self.viewport().setMouseTracking(True) @@ -87,15 +97,6 @@ class Editor(QTextEdit): f = f.parentFrame() return None - def _is_code_block(self, block) -> bool: - if not block.isValid(): - return False - bf = block.blockFormat() - return bool( - bf.nonBreakableLines() - and bf.background().color().rgb() == self._CODE_BG.rgb() - ) - def _trim_url_end(self, url: str) -> str: # strip common trailing punctuation not part of the URL trimmed = url.rstrip(".,;:!?\"'") @@ -141,7 +142,7 @@ class Editor(QTextEdit): fmt.setAnchor(True) fmt.setAnchorHref(href) # always refresh to the latest full URL fmt.setFontUnderline(True) - fmt.setForeground(Qt.blue) + fmt.setForeground(self.palette().brush(QPalette.Link)) cur.mergeCharFormat(fmt) # merge so we don’t clobber other styling @@ -481,11 +482,6 @@ class Editor(QTextEdit): # otherwise default handling return super().keyPressEvent(e) - def _clear_insertion_char_format(self): - """Reset inline typing format (keeps lists, alignment, margins, etc.).""" - nf = QTextCharFormat() - self.setCurrentCharFormat(nf) - def _break_anchor_for_next_char(self): """ Ensure the *next* typed character is not part of a hyperlink. @@ -669,3 +665,41 @@ class Editor(QTextEdit): fmt = QTextListFormat() fmt.setStyle(QTextListFormat.Style.ListDecimal) c.createList(fmt) + + @Slot(Theme) + def _on_theme_changed(self, _theme: Theme): + # Defer one event-loop tick so widgets have the new palette + QTimer.singleShot(0, self._retint_anchors_to_palette) + + @Slot() + def _retint_anchors_to_palette(self, *_): + # Always read from the *application* palette to avoid stale widget palette + app = QApplication.instance() + link_brush = app.palette().brush(QPalette.Link) + doc = self.document() + cur = QTextCursor(doc) + cur.beginEditBlock() + block = doc.firstBlock() + while block.isValid(): + it = block.begin() + while not it.atEnd(): + frag = it.fragment() + if frag.isValid(): + fmt = frag.charFormat() + if fmt.isAnchor(): + new_fmt = QTextCharFormat(fmt) + new_fmt.setForeground(link_brush) # force palette link color + cur.setPosition(frag.position()) + cur.setPosition( + frag.position() + frag.length(), QTextCursor.KeepAnchor + ) + cur.setCharFormat(new_fmt) + it += 1 + block = block.next() + cur.endEditBlock() + self.viewport().update() + + def setHtml(self, html: str) -> None: + super().setHtml(html) + # Ensure anchors adopt the palette color on startup + self._retint_anchors_to_palette() diff --git a/bouquin/main.py b/bouquin/main.py index 3e5f90b..a481480 100644 --- a/bouquin/main.py +++ b/bouquin/main.py @@ -3,14 +3,22 @@ from __future__ import annotations import sys from PySide6.QtWidgets import QApplication -from .settings import APP_NAME, APP_ORG +from .settings import APP_NAME, APP_ORG, get_settings from .main_window import MainWindow +from .theme import Theme, ThemeConfig, ThemeManager def main(): app = QApplication(sys.argv) app.setApplicationName(APP_NAME) app.setOrganizationName(APP_ORG) - win = MainWindow() + + s = get_settings() + theme_str = s.value("ui/theme", "system") + cfg = ThemeConfig(theme=Theme(theme_str)) + themes = ThemeManager(app, cfg) + themes.apply(cfg.theme) + + win = MainWindow(themes=themes) win.show() sys.exit(app.exec()) diff --git a/bouquin/main_window.py b/bouquin/main_window.py index cc01f6d..5f8f5fd 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -23,9 +23,12 @@ from PySide6.QtGui import ( QDesktopServices, QFont, QGuiApplication, + QPalette, + QTextCharFormat, QTextListFormat, ) from PySide6.QtWidgets import ( + QApplication, QCalendarWidget, QDialog, QFileDialog, @@ -48,6 +51,7 @@ from .search import Search from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config from .settings_dialog import SettingsDialog from .toolbar import ToolBar +from .theme import Theme, ThemeManager class _LockOverlay(QWidget): @@ -58,23 +62,6 @@ class _LockOverlay(QWidget): self.setFocusPolicy(Qt.StrongFocus) self.setGeometry(parent.rect()) - self.setStyleSheet( - """ -#LockOverlay { background-color: #ccc; } -#LockOverlay QLabel { color: #fff; font-size: 18px; } -#LockOverlay QPushButton { - background-color: #f2f2f2; - color: #000; - padding: 6px 14px; - border: 1px solid #808080; - border-radius: 6px; - font-size: 14px; -} -#LockOverlay QPushButton:hover { background-color: #ffffff; } -#LockOverlay QPushButton:pressed { background-color: #e6e6e6; } -""" - ) - lay = QVBoxLayout(self) lay.addStretch(1) @@ -92,8 +79,42 @@ class _LockOverlay(QWidget): lay.addWidget(self._btn, 0, Qt.AlignCenter) lay.addStretch(1) + self._apply_overlay_style() + self.hide() # start hidden + def _apply_overlay_style(self): + pal = self.palette() + bg = ( + pal.window().color().darker(180) + if pal.color(QPalette.Window).value() < 128 + else pal.window().color().lighter(110) + ) + text = pal.windowText().color() + btn_bg = pal.button().color() + btn_fg = pal.buttonText().color() + border = pal.mid().color() + + hover_bg = btn_bg.lighter(106) # +6% + press_bg = btn_bg.darker(106) # -6% + + self.setStyleSheet( + f""" + #LockOverlay {{ background-color: {bg.name()}; }} + #LockOverlay QLabel {{ color: {text.name()}; font-size: 18px; }} + #LockOverlay QPushButton {{ + background-color: {btn_bg.name()}; + color: {btn_fg.name()}; + padding: 6px 14px; + border: 1px solid {border.name()}; + border-radius: 6px; + font-size: 14px; + }} + #LockOverlay QPushButton:hover {{ background-color: {hover_bg.name()}; }} + #LockOverlay QPushButton:pressed {{ background-color: {press_bg.name()}; }} + """ + ) + # keep overlay sized with its parent def eventFilter(self, obj, event): if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show): @@ -106,11 +127,13 @@ class _LockOverlay(QWidget): class MainWindow(QMainWindow): - def __init__(self, *args, **kwargs): + def __init__(self, themes: ThemeManager, *args, **kwargs): super().__init__(*args, **kwargs) self.setWindowTitle(APP_NAME) self.setMinimumSize(1000, 650) + self.themes = themes # Store the themes manager + self.cfg = load_db_config() if not os.path.exists(self.cfg.path): # Fresh database/first time use, so guide the user re: setting a key @@ -145,7 +168,7 @@ class MainWindow(QMainWindow): left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16) # This is the note-taking editor - self.editor = Editor() + self.editor = Editor(self.themes) # Toolbar for controlling styling self.toolBar = ToolBar() @@ -185,6 +208,7 @@ class MainWindow(QMainWindow): # full-window overlay that sits on top of the central widget self._lock_overlay = _LockOverlay(self.centralWidget(), self._on_unlock_clicked) + self._lock_overlay._apply_overlay_style() self.centralWidget().installEventFilter(self._lock_overlay) self._locked = False @@ -280,6 +304,16 @@ class MainWindow(QMainWindow): self.settings = QSettings(APP_ORG, APP_NAME) self._restore_window_position() + self._apply_link_css() # Apply link color on startup + # re-apply all runtime color tweaks when theme changes + self.themes.themeChanged.connect(lambda _t: self._retheme_overrides()) + self.themes.themeChanged.connect(self._apply_calendar_theme) + self._apply_calendar_text_colors() + self._apply_calendar_theme(self.themes.current()) + + # apply once on startup so links / calendar colors are set immediately + self._retheme_overrides() + def _try_connect(self) -> bool: """ Try to connect to the database. @@ -314,6 +348,86 @@ class MainWindow(QMainWindow): if self._try_connect(): return True + def _retheme_overrides(self): + if hasattr(self, "_lock_overlay"): + self._lock_overlay._apply_overlay_style() + self._apply_calendar_text_colors() + self._apply_link_css() # Reapply link styles based on the current theme + self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set())) + self.calendar.update() + self.editor.viewport().update() + + def _apply_link_css(self): + if self.themes and self.themes.current() == Theme.DARK: + anchor = "#FFA500" # Orange links + visited = "#B38000" # Visited links color + css = f""" + a {{ color: {anchor}; text-decoration: underline; }} + a:visited {{ color: {visited}; }} + """ + else: + css = "" # Default to no custom styling for links (system or light theme) + + try: + # Apply to the editor (QTextEdit or any other relevant widgets) + self.editor.document().setDefaultStyleSheet(css) + except Exception: + pass + + try: + # Apply to the search widget (if it's also a rich-text widget) + self.search.document().setDefaultStyleSheet(css) + except Exception: + pass + + def _apply_calendar_theme(self, theme: Theme): + """Use orange accents on the calendar in dark mode only.""" + app_pal = QApplication.instance().palette() + + if theme == Theme.DARK: + orange = QColor("#FFA500") + black = QColor(0, 0, 0) + + # Per-widget palette: selection color inside the date grid + pal = self.calendar.palette() + pal.setColor(QPalette.Highlight, orange) + pal.setColor(QPalette.HighlightedText, black) + self.calendar.setPalette(pal) + + # Stylesheet: nav bar + selected-day background + self.calendar.setStyleSheet(""" + QWidget#qt_calendar_navigationbar { background-color: #FFA500; } + QCalendarWidget QToolButton { color: black; } + QCalendarWidget QToolButton:hover { background-color: rgba(255,165,0,0.20); } + /* Selected day color in the table view */ + QCalendarWidget QTableView:enabled { + selection-background-color: #FFA500; + selection-color: black; + } + /* Optional: keep weekday header readable */ + QCalendarWidget QTableView QHeaderView::section { + background: transparent; + color: palette(windowText); + } + """) + else: + # Back to app defaults in light/system + self.calendar.setPalette(app_pal) + self.calendar.setStyleSheet("") + + # Keep weekend text color in sync with the current palette + self._apply_calendar_text_colors() + self.calendar.update() + + def _apply_calendar_text_colors(self): + pal = self.palette() + txt = pal.windowText().color() + fmt = QTextCharFormat() + fmt.setForeground(txt) + # Use normal text color for weekends + self.calendar.setWeekdayTextFormat(Qt.Saturday, fmt) + self.calendar.setWeekdayTextFormat(Qt.Sunday, fmt) + def _on_search_dates_changed(self, date_strs: list[str]): dates = set() for ds in date_strs or []: @@ -323,7 +437,16 @@ class MainWindow(QMainWindow): self._apply_search_highlights(dates) def _apply_search_highlights(self, dates: set): - yellow = QBrush(QColor("#fff9c4")) + pal = self.palette() + base = pal.base().color() + hi = pal.highlight().color() + # Blend highlight with base so it looks soft in both modes + blend = QColor( + (2 * hi.red() + base.red()) // 3, + (2 * hi.green() + base.green()) // 3, + (2 * hi.blue() + base.blue()) // 3, + ) + yellow = QBrush(blend) old = getattr(self, "_search_highlighted_dates", set()) for d in old - dates: # clear removed @@ -364,10 +487,10 @@ class MainWindow(QMainWindow): bf = c.blockFormat() # Block signals so setChecked() doesn't re-trigger actions - blocker1 = QSignalBlocker(self.toolBar.actBold) - blocker2 = QSignalBlocker(self.toolBar.actItalic) - blocker3 = QSignalBlocker(self.toolBar.actUnderline) - blocker4 = QSignalBlocker(self.toolBar.actStrike) + QSignalBlocker(self.toolBar.actBold) + QSignalBlocker(self.toolBar.actItalic) + QSignalBlocker(self.toolBar.actUnderline) + QSignalBlocker(self.toolBar.actStrike) self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold) self.toolBar.actItalic.setChecked(fmt.fontItalic()) @@ -384,10 +507,10 @@ class MainWindow(QMainWindow): bH2 = _approx(cur_size, 18) bH3 = _approx(cur_size, 14) - b1 = QSignalBlocker(self.toolBar.actH1) - b2 = QSignalBlocker(self.toolBar.actH2) - b3 = QSignalBlocker(self.toolBar.actH3) - bN = QSignalBlocker(self.toolBar.actNormal) + QSignalBlocker(self.toolBar.actH1) + QSignalBlocker(self.toolBar.actH2) + QSignalBlocker(self.toolBar.actH3) + QSignalBlocker(self.toolBar.actNormal) self.toolBar.actH1.setChecked(bH1) self.toolBar.actH2.setChecked(bH2) @@ -538,6 +661,7 @@ class MainWindow(QMainWindow): self.cfg.path = new_cfg.path self.cfg.key = new_cfg.key self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes) + self.cfg.theme = getattr(new_cfg, "theme", self.cfg.theme) # Persist once save_db_config(self.cfg) diff --git a/bouquin/search.py b/bouquin/search.py index 27c7e17..2805e4c 100644 --- a/bouquin/search.py +++ b/bouquin/search.py @@ -17,7 +17,6 @@ from PySide6.QtWidgets import ( QWidget, ) -# type: rows are (date_iso, content) Row = Tuple[str, str] @@ -102,11 +101,10 @@ class Search(QWidget): # Date label (plain text) date_lbl = QLabel() date_lbl.setTextFormat(Qt.TextFormat.RichText) - date_lbl.setText(f"{date_str}:") + date_lbl.setText(f"

{date_str}

") date_f = date_lbl.font() date_f.setPointSizeF(date_f.pointSizeF() + 1) date_lbl.setFont(date_f) - date_lbl.setStyleSheet("color:#000;") outer.addWidget(date_lbl) # Preview row with optional ellipses diff --git a/bouquin/settings.py b/bouquin/settings.py index fc92394..8860ed2 100644 --- a/bouquin/settings.py +++ b/bouquin/settings.py @@ -22,12 +22,14 @@ def load_db_config() -> DBConfig: s = get_settings() path = Path(s.value("db/path", str(default_db_path()))) key = s.value("db/key", "") - idle = s.value("db/idle_minutes", 15, type=int) - return DBConfig(path=path, key=key, idle_minutes=idle) + idle = s.value("ui/idle_minutes", 15, type=int) + theme = s.value("ui/theme", "system", type=str) + return DBConfig(path=path, key=key, idle_minutes=idle, theme=theme) def save_db_config(cfg: DBConfig) -> None: s = get_settings() s.setValue("db/path", str(cfg.path)) s.setValue("db/key", str(cfg.key)) - s.setValue("db/idle_minutes", str(cfg.idle_minutes)) + s.setValue("ui/idle_minutes", str(cfg.idle_minutes)) + s.setValue("ui/theme", str(cfg.theme)) diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index 0a3dfd8..48acfe6 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -16,6 +16,7 @@ from PySide6.QtWidgets import ( QPushButton, QFileDialog, QDialogButtonBox, + QRadioButton, QSizePolicy, QSpinBox, QMessageBox, @@ -26,6 +27,7 @@ from PySide6.QtGui import QPalette from .db import DBConfig, DBManager from .settings import load_db_config, save_db_config +from .theme import Theme from .key_prompt import KeyPrompt @@ -42,6 +44,31 @@ class SettingsDialog(QDialog): self.setMinimumWidth(560) self.setSizeGripEnabled(True) + current_settings = load_db_config() + + # Add theme selection + theme_group = QGroupBox("Theme") + theme_layout = QVBoxLayout(theme_group) + + self.theme_system = QRadioButton("System") + self.theme_light = QRadioButton("Light") + self.theme_dark = QRadioButton("Dark") + + # Load current theme from settings + current_theme = current_settings.theme + if current_theme == Theme.DARK.value: + self.theme_dark.setChecked(True) + elif current_theme == Theme.LIGHT.value: + self.theme_light.setChecked(True) + else: + self.theme_system.setChecked(True) + + theme_layout.addWidget(self.theme_system) + theme_layout.addWidget(self.theme_light) + theme_layout.addWidget(self.theme_dark) + + form.addRow(theme_group) + self.path_edit = QLineEdit(str(self._cfg.path)) self.path_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) browse_btn = QPushButton("Browse…") @@ -64,7 +91,6 @@ class SettingsDialog(QDialog): # Checkbox to remember key self.save_key_btn = QCheckBox("Remember key") - current_settings = load_db_config() self.key = current_settings.key or "" self.save_key_btn.setChecked(bool(self.key)) self.save_key_btn.setCursor(Qt.PointingHandCursor) @@ -188,13 +214,24 @@ class SettingsDialog(QDialog): self.path_edit.setText(p) def _save(self): + # Save the selected theme into QSettings + if self.theme_dark.isChecked(): + selected_theme = Theme.DARK + elif self.theme_light.isChecked(): + selected_theme = Theme.LIGHT + else: + selected_theme = Theme.SYSTEM + key_to_save = self.key if self.save_key_btn.isChecked() else "" self._cfg = DBConfig( path=Path(self.path_edit.text()), key=key_to_save, idle_minutes=self.idle_spin.value(), + theme=selected_theme.value, ) + save_db_config(self._cfg) + self.parent().themes.apply(selected_theme) self.accept() def _change_key(self): diff --git a/bouquin/theme.py b/bouquin/theme.py new file mode 100644 index 0000000..61f9458 --- /dev/null +++ b/bouquin/theme.py @@ -0,0 +1,103 @@ +from __future__ import annotations +from dataclasses import dataclass +from enum import Enum +from PySide6.QtGui import QPalette, QColor, QGuiApplication +from PySide6.QtWidgets import QApplication +from PySide6.QtCore import QObject, Signal + + +class Theme(Enum): + SYSTEM = "system" + LIGHT = "light" + DARK = "dark" + + +@dataclass +class ThemeConfig: + theme: Theme = Theme.SYSTEM + + +class ThemeManager(QObject): + themeChanged = Signal(Theme) + + def __init__(self, app: QApplication, cfg: ThemeConfig): + super().__init__() + self._app = app + self._cfg = cfg + + # Follow OS if supported (Qt 6+) + hints = QGuiApplication.styleHints() + if hasattr(hints, "colorSchemeChanged"): + hints.colorSchemeChanged.connect( + lambda _: (self._cfg.theme == Theme.SYSTEM) + and self.apply(self._cfg.theme) + ) + + def current(self) -> Theme: + return self._cfg.theme + + def set(self, theme: Theme): + self._cfg.theme = theme + self.apply(theme) + + def apply(self, theme: Theme): + # Resolve "system" + if theme == Theme.SYSTEM: + hints = QGuiApplication.styleHints() + scheme = getattr(hints, "colorScheme", None) + if callable(scheme): + scheme = hints.colorScheme() + # 0=Light, 1=Dark in newer Qt; fall back to Light + theme = Theme.DARK if scheme == 1 else Theme.LIGHT + + # Always use Fusion so palette applies consistently cross-platform + self._app.setStyle("Fusion") + + if theme == Theme.DARK: + pal = self._dark_palette() + self._app.setPalette(pal) + # keep stylesheet empty unless you need widget-specific tweaks + self._app.setStyleSheet("") + else: + pal = self._light_palette() + self._app.setPalette(pal) + self._app.setStyleSheet("") + + self.themeChanged.emit(theme) + + # ----- Palettes ----- + def _dark_palette(self) -> QPalette: + pal = QPalette() + base = QColor(35, 35, 35) + window = QColor(53, 53, 53) + text = QColor(220, 220, 220) + disabled = QColor(127, 127, 127) + focus = QColor(42, 130, 218) + + pal.setColor(QPalette.Window, window) + pal.setColor(QPalette.WindowText, text) + pal.setColor(QPalette.Base, base) + pal.setColor(QPalette.AlternateBase, window) + pal.setColor(QPalette.ToolTipBase, window) + pal.setColor(QPalette.ToolTipText, text) + pal.setColor(QPalette.Text, text) + pal.setColor(QPalette.PlaceholderText, disabled) + pal.setColor(QPalette.Button, window) + pal.setColor(QPalette.ButtonText, text) + pal.setColor(QPalette.BrightText, QColor(255, 84, 84)) + pal.setColor(QPalette.Highlight, focus) + pal.setColor(QPalette.HighlightedText, QColor(0, 0, 0)) + pal.setColor(QPalette.Link, QColor("#FFA500")) + pal.setColor(QPalette.LinkVisited, QColor("#B38000")) + + return pal + + def _light_palette(self) -> QPalette: + # Let Qt provide its default light palette, but nudge a couple roles + pal = self._app.style().standardPalette() + pal.setColor(QPalette.Highlight, QColor(0, 120, 215)) + pal.setColor(QPalette.HighlightedText, QColor(255, 255, 255)) + pal.setColor( + QPalette.Link, QColor("#1a73e8") + ) # Light blue for links in light mode + return pal