Compare commits

...

5 commits

8 changed files with 207 additions and 186 deletions

View file

@ -1,6 +1,7 @@
# 0.2.1.7
* Fix being able to set bold, italic and strikethrough at the same time.
* Fixes for system dark theme and move stylesheets for Calendar/Lock Overlay into the ThemeManager
* Add AppImage
# 0.2.1.6

View file

@ -1,12 +1,13 @@
from __future__ import annotations
from PySide6.QtCore import Qt, QEvent
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
from .theme import ThemeManager
class LockOverlay(QWidget):
def __init__(self, parent: QWidget, on_unlock: callable):
def __init__(self, parent: QWidget, on_unlock: callable, themes: ThemeManager):
"""
Widget that 'locks' the screen after a configured idle time.
"""
@ -16,7 +17,6 @@ class LockOverlay(QWidget):
self.setFocusPolicy(Qt.StrongFocus)
self.setGeometry(parent.rect())
self._styling = False # <-- reentrancy guard
self._last_dark: bool | None = None
lay = QVBoxLayout(self)
@ -38,91 +38,9 @@ class LockOverlay(QWidget):
lay.addWidget(self._btn, 0, Qt.AlignCenter)
lay.addStretch(1)
self._apply_overlay_style()
themes.register_lock_overlay(self)
self.hide()
def _is_dark(self, pal: QPalette) -> bool:
"""
Detect if dark mode is in use.
"""
c = pal.color(QPalette.Window)
luma = 0.2126 * c.redF() + 0.7152 * c.greenF() + 0.0722 * c.blueF()
return luma < 0.5
def _apply_overlay_style(self):
if self._styling:
return
dark = self._is_dark(self.palette())
if dark == self._last_dark:
return
self._styling = True
try:
if dark:
link = self.palette().color(QPalette.Link)
accent_hex = link.name() # e.g. "#FFA500"
r, g, b = link.red(), link.green(), link.blue()
self.setStyleSheet(
f"""
#LockOverlay {{ background-color: rgb(0,0,0); }}
#LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }}
#LockOverlay QPushButton#unlockButton {{
color: {accent_hex};
background-color: rgba({r},{g},{b},0.10);
border: 1px solid {accent_hex};
border-radius: 8px;
padding: 8px 16px;
}}
#LockOverlay QPushButton#unlockButton:hover {{
background-color: rgba({r},{g},{b},0.16);
border-color: {accent_hex};
}}
#LockOverlay QPushButton#unlockButton:pressed {{
background-color: rgba({r},{g},{b},0.24);
}}
#LockOverlay QPushButton#unlockButton:focus {{
outline: none;
border-color: {accent_hex};
}}
"""
)
else:
# (light mode unchanged)
self.setStyleSheet(
"""
#LockOverlay { background-color: rgba(0,0,0,120); }
#LockOverlay QLabel#lockLabel { color: palette(window-text); font-weight: 600; }
#LockOverlay QPushButton#unlockButton {
color: palette(button-text);
background-color: rgba(255,255,255,0.92);
border: 1px solid rgba(0,0,0,0.25);
border-radius: 8px;
padding: 8px 16px;
}
#LockOverlay QPushButton#unlockButton:hover {
background-color: rgba(255,255,255,1.0);
border-color: rgba(0,0,0,0.35);
}
#LockOverlay QPushButton#unlockButton:pressed {
background-color: rgba(245,245,245,1.0);
}
#LockOverlay QPushButton#unlockButton:focus {
outline: none;
border-color: palette(highlight);
}
"""
)
self._last_dark = dark
finally:
self._styling = False
def changeEvent(self, ev):
super().changeEvent(ev)
# Only re-style on palette flips (user changed theme)
if ev.type() in (QEvent.PaletteChange, QEvent.ApplicationPaletteChange):
self._apply_overlay_style()
def eventFilter(self, obj, event):
if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
self.setGeometry(obj.rect())

View file

@ -25,13 +25,11 @@ from PySide6.QtGui import (
QFont,
QGuiApplication,
QKeySequence,
QPalette,
QTextCharFormat,
QTextCursor,
QTextListFormat,
)
from PySide6.QtWidgets import (
QApplication,
QCalendarWidget,
QDialog,
QFileDialog,
@ -57,7 +55,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
from .theme import ThemeManager
class MainWindow(QMainWindow):
@ -87,6 +85,7 @@ class MainWindow(QMainWindow):
self.calendar.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self.calendar.setGridVisible(True)
self.calendar.selectionChanged.connect(self._on_date_changed)
self.themes.register_calendar(self.calendar)
self.search = Search(self.db)
self.search.openDateRequested.connect(self._load_selected_date)
@ -147,7 +146,9 @@ class MainWindow(QMainWindow):
self._idle_timer.start()
# full-window overlay that sits on top of the central widget
self._lock_overlay = LockOverlay(self.centralWidget(), self._on_unlock_clicked)
self._lock_overlay = LockOverlay(
self.centralWidget(), self._on_unlock_clicked, themes=self.themes
)
self.centralWidget().installEventFilter(self._lock_overlay)
self._locked = False
@ -278,12 +279,9 @@ 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()
@ -812,69 +810,11 @@ class MainWindow(QMainWindow):
# ----------------- Some theme helpers -------------------#
def _retheme_overrides(self):
if hasattr(self, "_lock_overlay"):
self._lock_overlay._apply_overlay_style()
self._apply_calendar_text_colors()
self._apply_link_css()
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 = Theme.ORANGE_ANCHOR.value
visited = Theme.ORANGE_ANCHOR_VISITED.value
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)
self.editor.document().setDefaultStyleSheet(css)
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:
highlight = QColor(Theme.ORANGE_ANCHOR.value)
black = QColor(0, 0, 0)
highlight_css = Theme.ORANGE_ANCHOR.value
# Per-widget palette: selection color inside the date grid
pal = self.calendar.palette()
pal.setColor(QPalette.Highlight, highlight)
pal.setColor(QPalette.HighlightedText, black)
self.calendar.setPalette(pal)
# Stylesheet: nav bar + selected-day background
self.calendar.setStyleSheet(
f"""
QWidget#qt_calendar_navigationbar {{ background-color: {highlight_css}; }}
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: {highlight_css};
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("")
self._apply_calendar_text_colors()
self.calendar.update()
def _apply_calendar_text_colors(self):
pal = self.palette()
txt = pal.windowText().color()

View file

@ -62,14 +62,19 @@ class MarkdownHighlighter(QSyntaxHighlighter):
self.code_block_format.setFontFixedPitch(True)
pal = QGuiApplication.palette()
if self.theme_manager.current() == Theme.DARK:
if (
self.theme_manager.current() == Theme.DARK
or self.theme_manager._is_system_dark
):
# In dark mode, use a darker panel-like background
bg = pal.color(QPalette.AlternateBase)
fg = pal.color(QPalette.Text)
else:
# Light mode: keep the existing light gray
bg = QColor(245, 245, 245)
fg = pal.color(QPalette.Text)
fg = QColor(
0, 0, 0
) # avoiding using QPalette.Text as it can be white on macOS
self.code_block_format.setBackground(bg)
self.code_block_format.setForeground(fg)

View file

@ -119,7 +119,7 @@ class SettingsDialog(QDialog):
self.save_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
# make it look secondary
pal = self.save_key_label.palette()
pal.setColor(self.save_key_label.foregroundRole(), pal.color(QPalette.Mid))
self.save_key_label.setForegroundRole(QPalette.PlaceholderText)
self.save_key_label.setPalette(pal)
exp_row = QHBoxLayout()
@ -165,7 +165,7 @@ class SettingsDialog(QDialog):
self.idle_spin_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
# make it look secondary
spal = self.idle_spin_label.palette()
spal.setColor(self.idle_spin_label.foregroundRole(), spal.color(QPalette.Mid))
self.idle_spin_label.setForegroundRole(QPalette.PlaceholderText)
self.idle_spin_label.setPalette(spal)
spin_row = QHBoxLayout()
@ -195,7 +195,7 @@ class SettingsDialog(QDialog):
self.compact_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
# make it look secondary
cpal = self.compact_label.palette()
cpal.setColor(self.compact_label.foregroundRole(), cpal.color(QPalette.Mid))
self.compact_label.setForegroundRole(QPalette.PlaceholderText)
self.compact_label.setPalette(cpal)
maint_row = QHBoxLayout()

View file

@ -2,8 +2,9 @@ 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.QtWidgets import QApplication, QCalendarWidget, QWidget
from PySide6.QtCore import QObject, Signal
from weakref import WeakSet
class Theme(Enum):
@ -26,6 +27,9 @@ class ThemeManager(QObject):
super().__init__()
self._app = app
self._cfg = cfg
self._current = None
self._calendars: "WeakSet[QCalendarWidget]" = WeakSet()
self._lock_overlays: "WeakSet[QWidget]" = WeakSet()
# Follow OS if supported (Qt 6+)
hints = QGuiApplication.styleHints()
@ -35,6 +39,20 @@ class ThemeManager(QObject):
and self.apply(self._cfg.theme)
)
def _is_system_dark(self) -> bool:
pal = QGuiApplication.palette()
# Heuristic: dark windows/backgrounds mean dark system theme
return pal.color(QPalette.Window).lightness() < 128
def _restyle_registered(self) -> None:
for cal in list(self._calendars):
if cal is not None:
self._apply_calendar_theme(cal)
for overlay in list(self._lock_overlays):
if overlay is not None:
self._apply_lock_overlay_theme(overlay)
def current(self) -> Theme:
return self._cfg.theme
@ -43,28 +61,34 @@ class ThemeManager(QObject):
self.apply(theme)
def apply(self, theme: Theme):
# Resolve "system"
# Resolve "system" into a concrete theme
resolved = theme
if theme == Theme.SYSTEM:
hints = QGuiApplication.styleHints()
scheme = getattr(hints, "colorScheme", None)
if callable(scheme):
scheme = hints.colorScheme()
# 0=Light, 1=Dark; fall back to Light
theme = Theme.DARK if scheme == 1 else Theme.LIGHT
resolved = Theme.DARK if self._is_system_dark() else Theme.LIGHT
# Always use Fusion so palette applies consistently cross-platform
self._app.setStyle("Fusion")
if theme == Theme.DARK:
if resolved == Theme.DARK:
pal = self._dark_palette()
self._app.setPalette(pal)
self._app.setStyleSheet("")
else:
pal = self._light_palette()
self._app.setPalette(pal)
self._app.setStyleSheet("")
self.themeChanged.emit(theme)
# Always use Fusion so palette applies consistently cross-platform
QApplication.setStyle("Fusion")
self._app.setPalette(pal)
self._current = resolved
# Re-style any registered widgets
self._restyle_registered()
self.themeChanged.emit(self._current)
def register_calendar(self, cal: QCalendarWidget) -> None:
"""Start theming calendar and keep it in sync with theme changes."""
self._calendars.add(cal)
self._apply_calendar_theme(cal)
def register_lock_overlay(self, overlay: QWidget) -> None:
"""Start theming lock overlay and keep it in sync with theme changes."""
self._lock_overlays.add(overlay)
self._apply_lock_overlay_theme(overlay)
# ----- Palettes -----
def _dark_palette(self) -> QPalette:
@ -75,17 +99,24 @@ class ThemeManager(QObject):
disabled = QColor(127, 127, 127)
focus = QColor(42, 130, 218)
# Base surfaces
pal.setColor(QPalette.Window, window)
pal.setColor(QPalette.WindowText, text)
pal.setColor(QPalette.Base, base)
pal.setColor(QPalette.AlternateBase, window)
# Text
pal.setColor(QPalette.WindowText, text)
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)
# Buttons/frames
pal.setColor(QPalette.Button, window)
pal.setColor(QPalette.BrightText, QColor(255, 84, 84))
# Links / selection
pal.setColor(QPalette.Highlight, focus)
pal.setColor(QPalette.HighlightedText, QColor(0, 0, 0))
pal.setColor(QPalette.Link, QColor(Theme.ORANGE_ANCHOR.value))
@ -94,11 +125,136 @@ class ThemeManager(QObject):
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
pal = QPalette()
# Base surfaces
pal.setColor(QPalette.Window, QColor("#ffffff"))
pal.setColor(QPalette.Base, QColor("#ffffff"))
pal.setColor(QPalette.AlternateBase, QColor("#f5f5f5"))
# Text
pal.setColor(QPalette.WindowText, QColor("#000000"))
pal.setColor(QPalette.Text, QColor("#000000"))
pal.setColor(QPalette.ButtonText, QColor("#000000"))
# Buttons/frames
pal.setColor(QPalette.Button, QColor("#f0f0f0"))
pal.setColor(QPalette.Mid, QColor("#9e9e9e"))
# Links / selection
pal.setColor(QPalette.Highlight, QColor("#1a73e8"))
pal.setColor(QPalette.HighlightedText, QColor("#ffffff"))
pal.setColor(QPalette.Link, QColor("#1a73e8"))
pal.setColor(QPalette.LinkVisited, QColor("#6b4ca5"))
return pal
def _apply_calendar_theme(self, cal: QCalendarWidget) -> None:
"""Use orange accents on the calendar in dark mode only."""
app_pal = QApplication.instance().palette()
is_dark = (self.current() == Theme.DARK) or (
self.current() == Theme.SYSTEM and self._is_system_dark()
)
if is_dark:
highlight_css = Theme.ORANGE_ANCHOR.value
highlight = QColor(highlight_css)
black = QColor(0, 0, 0)
# Per-widget palette: selection color inside the date grid
pal = cal.palette()
pal.setColor(QPalette.Highlight, highlight)
pal.setColor(QPalette.HighlightedText, black)
cal.setPalette(pal)
# Stylesheet: nav bar + selected-day background
cal.setStyleSheet(self._calendar_qss(highlight_css))
else:
# Back to app defaults in light/system-light
cal.setPalette(app_pal)
cal.setStyleSheet("")
cal.update()
def _calendar_qss(self, highlight_css: str) -> str:
return f"""
QWidget#qt_calendar_navigationbar {{ background-color: {highlight_css}; }}
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: {highlight_css};
selection-color: black;
}}
/* Keep weekday header readable */
QCalendarWidget QTableView QHeaderView::section {{
background: transparent;
color: palette(windowText);
}}
"""
def _apply_lock_overlay_theme(self, overlay: QWidget) -> None:
"""
Style the LockOverlay (objectName 'LockOverlay') using theme colors.
Dark: opaque black bg, orange accent; Light: translucent scrim, palette-driven colors.
"""
pal = QApplication.instance().palette()
is_dark = (self.current() == Theme.DARK) or (
self.current() == Theme.SYSTEM and self._is_system_dark()
)
if is_dark:
# Use the link color as the accent (you set this to ORANGE in dark palette)
accent = pal.color(QPalette.Link)
r, g, b = accent.red(), accent.green(), accent.blue()
accent_hex = accent.name()
qss = f"""
#LockOverlay {{ background-color: rgb(0,0,0); }}
#LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }}
#LockOverlay QPushButton#unlockButton {{
color: {accent_hex};
background-color: rgba({r},{g},{b},0.10);
border: 1px solid {accent_hex};
border-radius: 8px;
padding: 8px 16px;
}}
#LockOverlay QPushButton#unlockButton:hover {{
background-color: rgba({r},{g},{b},0.16);
border-color: {accent_hex};
}}
#LockOverlay QPushButton#unlockButton:pressed {{
background-color: rgba({r},{g},{b},0.24);
}}
#LockOverlay QPushButton#unlockButton:focus {{
outline: none;
border-color: {accent_hex};
}}
"""
else:
qss = """
#LockOverlay { background-color: rgba(0,0,0,120); }
#LockOverlay QLabel#lockLabel { color: palette(window-text); font-weight: 600; }
#LockOverlay QPushButton#unlockButton {
color: palette(button-text);
background-color: rgba(255,255,255,0.92);
border: 1px solid rgba(0,0,0,0.25);
border-radius: 8px;
padding: 8px 16px;
}
#LockOverlay QPushButton#unlockButton:hover {
background-color: rgba(255,255,255,1.0);
border-color: rgba(0,0,0,0.35);
}
#LockOverlay QPushButton#unlockButton:pressed {
background-color: rgba(245,245,245,1.0);
}
#LockOverlay QPushButton#unlockButton:focus {
outline: none;
border-color: palette(highlight);
}
"""
overlay.setStyleSheet(qss)
overlay.update()

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "bouquin"
version = "0.2.1.6"
version = "0.2.1.7"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md"

View file

@ -2,15 +2,16 @@ import pytest
from PySide6.QtCore import QEvent
from PySide6.QtWidgets import QWidget
from bouquin.lock_overlay import LockOverlay
from bouquin.theme import ThemeManager, ThemeConfig, Theme
@pytest.mark.gui
def test_lock_overlay_reacts_to_theme(qtbot):
def test_lock_overlay_reacts_to_theme(app, qtbot):
host = QWidget()
qtbot.addWidget(host)
host.show()
ol = LockOverlay(host, on_unlock=lambda: None)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
ol = LockOverlay(host, on_unlock=lambda: None, themes=themes)
qtbot.addWidget(ol)
ol.show()