bouquin 0.1.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bouquin/save_dialog.py ADDED
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+
5
+ from PySide6.QtWidgets import (
6
+ QDialog,
7
+ QVBoxLayout,
8
+ QLabel,
9
+ QLineEdit,
10
+ QDialogButtonBox,
11
+ )
12
+
13
+
14
+ class SaveDialog(QDialog):
15
+ def __init__(
16
+ self,
17
+ parent=None,
18
+ title: str = "Enter a name for this version",
19
+ message: str = "Enter a name for this version?",
20
+ ):
21
+ super().__init__(parent)
22
+ self.setWindowTitle(title)
23
+ v = QVBoxLayout(self)
24
+ v.addWidget(QLabel(message))
25
+ self.note = QLineEdit()
26
+ now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
27
+ self.note.setText(f"New version I saved at {now}")
28
+ v.addWidget(self.note)
29
+ bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
30
+ bb.accepted.connect(self.accept)
31
+ bb.rejected.connect(self.reject)
32
+ v.addWidget(bb)
33
+
34
+ def note_text(self) -> str:
35
+ return self.note.text()
bouquin/search.py ADDED
@@ -0,0 +1,209 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Iterable, Tuple
5
+
6
+ from PySide6.QtCore import Qt, Signal
7
+ from PySide6.QtGui import QFont, QTextCharFormat, QTextCursor, QTextDocument
8
+ from PySide6.QtWidgets import (
9
+ QFrame,
10
+ QLabel,
11
+ QLineEdit,
12
+ QListWidget,
13
+ QListWidgetItem,
14
+ QSizePolicy,
15
+ QHBoxLayout,
16
+ QVBoxLayout,
17
+ QWidget,
18
+ )
19
+
20
+ Row = Tuple[str, str]
21
+
22
+
23
+ class Search(QWidget):
24
+ """Encapsulates the search UI + logic and emits a signal when a result is chosen."""
25
+
26
+ openDateRequested = Signal(str)
27
+ resultDatesChanged = Signal(list)
28
+
29
+ def __init__(self, db, parent: QWidget | None = None):
30
+ super().__init__(parent)
31
+ self._db = db
32
+
33
+ self.search = QLineEdit()
34
+ self.search.setPlaceholderText("Search for notes here")
35
+ self.search.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
36
+ self.search.textChanged.connect(self._search)
37
+
38
+ self.results = QListWidget()
39
+ self.results.setUniformItemSizes(False)
40
+ self.results.setSelectionMode(self.results.SelectionMode.SingleSelection)
41
+ self.results.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
42
+ self.results.itemClicked.connect(self._open_selected)
43
+ self.results.hide()
44
+ self.results.setMinimumHeight(250)
45
+
46
+ lay = QVBoxLayout(self)
47
+ lay.setContentsMargins(0, 0, 0, 0)
48
+ lay.setSpacing(6)
49
+ lay.setAlignment(Qt.AlignTop)
50
+ lay.addWidget(self.search)
51
+ lay.addWidget(self.results)
52
+
53
+ def _open_selected(self, item: QListWidgetItem):
54
+ date_str = item.data(Qt.ItemDataRole.UserRole)
55
+ if date_str:
56
+ self.openDateRequested.emit(date_str)
57
+
58
+ def _search(self, text: str):
59
+ """
60
+ Search for the supplied text in the database.
61
+ For all rows found, populate the results widget with a clickable preview.
62
+ """
63
+ q = text.strip()
64
+ if not q:
65
+ self.results.clear()
66
+ self.results.hide()
67
+ self.resultDatesChanged.emit([]) # clear highlights
68
+ return
69
+
70
+ try:
71
+ rows: Iterable[Row] = self._db.search_entries(q)
72
+ except Exception:
73
+ # be quiet on DB errors here; caller can surface if desired
74
+ rows = []
75
+
76
+ self._populate_results(q, rows)
77
+
78
+ def _populate_results(self, query: str, rows: Iterable[Row]):
79
+ self.results.clear()
80
+ rows = list(rows)
81
+ if not rows:
82
+ self.results.hide()
83
+ self.resultDatesChanged.emit([]) # clear highlights
84
+ return
85
+
86
+ self.resultDatesChanged.emit(sorted({d for d, _ in rows}))
87
+ self.results.show()
88
+
89
+ for date_str, content in rows:
90
+ # Build an HTML fragment around the match and whether to show ellipses
91
+ frag_html, left_ell, right_ell = self._make_html_snippet(
92
+ content, query, radius=30, maxlen=90
93
+ )
94
+
95
+ # ---- Per-item widget: date on top, preview row below (with ellipses) ----
96
+ container = QWidget()
97
+ outer = QVBoxLayout(container)
98
+ outer.setContentsMargins(8, 6, 8, 6)
99
+ outer.setSpacing(2)
100
+
101
+ # Date label (plain text)
102
+ date_lbl = QLabel()
103
+ date_lbl.setTextFormat(Qt.TextFormat.RichText)
104
+ date_lbl.setText(f"<h3><i>{date_str}</i></h3>")
105
+ date_f = date_lbl.font()
106
+ date_f.setPointSizeF(date_f.pointSizeF() + 1)
107
+ date_lbl.setFont(date_f)
108
+ outer.addWidget(date_lbl)
109
+
110
+ # Preview row with optional ellipses
111
+ row = QWidget()
112
+ h = QHBoxLayout(row)
113
+ h.setContentsMargins(0, 0, 0, 0)
114
+ h.setSpacing(4)
115
+
116
+ if left_ell:
117
+ left = QLabel("…")
118
+ left.setStyleSheet("color:#888;")
119
+ h.addWidget(left, 0, Qt.AlignmentFlag.AlignTop)
120
+
121
+ preview = QLabel()
122
+ preview.setTextFormat(Qt.TextFormat.RichText)
123
+ preview.setWordWrap(True)
124
+ preview.setOpenExternalLinks(True)
125
+ preview.setText(
126
+ frag_html
127
+ if frag_html
128
+ else "<span style='color:#888'>(no preview)</span>"
129
+ )
130
+ h.addWidget(preview, 1)
131
+
132
+ if right_ell:
133
+ right = QLabel("…")
134
+ right.setStyleSheet("color:#888;")
135
+ h.addWidget(right, 0, Qt.AlignmentFlag.AlignBottom)
136
+
137
+ outer.addWidget(row)
138
+
139
+ line = QFrame()
140
+ line.setFrameShape(QFrame.HLine)
141
+ line.setFrameShadow(QFrame.Sunken)
142
+ outer.addWidget(line)
143
+
144
+ # ---- Add to list ----
145
+ item = QListWidgetItem()
146
+ item.setData(Qt.ItemDataRole.UserRole, date_str)
147
+ item.setSizeHint(container.sizeHint())
148
+
149
+ self.results.addItem(item)
150
+ self.results.setItemWidget(item, container)
151
+
152
+ # --- Snippet/highlight helpers -----------------------------------------
153
+ def _make_html_snippet(self, html_src: str, query: str, *, radius=60, maxlen=180):
154
+ doc = QTextDocument()
155
+ doc.setHtml(html_src)
156
+ plain = doc.toPlainText()
157
+ if not plain:
158
+ return "", False, False
159
+
160
+ tokens = [t for t in re.split(r"\s+", query.strip()) if t]
161
+ L = len(plain)
162
+
163
+ # Find first occurrence (phrase first, then earliest token)
164
+ idx, mlen = -1, 0
165
+ if tokens:
166
+ lower = plain.lower()
167
+ phrase = " ".join(tokens).lower()
168
+ j = lower.find(phrase)
169
+ if j >= 0:
170
+ idx, mlen = j, len(phrase)
171
+ else:
172
+ for t in tokens:
173
+ tj = lower.find(t.lower())
174
+ if tj >= 0 and (idx < 0 or tj < idx):
175
+ idx, mlen = tj, len(t)
176
+ # Compute window
177
+ if idx < 0:
178
+ start, end = 0, min(L, maxlen)
179
+ else:
180
+ start = max(0, min(idx - radius, max(0, L - maxlen)))
181
+ end = min(L, max(idx + mlen + radius, start + maxlen))
182
+
183
+ # Bold all token matches that fall inside [start, end)
184
+ if tokens:
185
+ lower = plain.lower()
186
+ fmt = QTextCharFormat()
187
+ fmt.setFontWeight(QFont.Weight.Bold)
188
+ for t in tokens:
189
+ t_low = t.lower()
190
+ pos = start
191
+ while True:
192
+ k = lower.find(t_low, pos)
193
+ if k == -1 or k >= end:
194
+ break
195
+ c = QTextCursor(doc)
196
+ c.setPosition(k)
197
+ c.setPosition(k + len(t), QTextCursor.MoveMode.KeepAnchor)
198
+ c.mergeCharFormat(fmt)
199
+ pos = k + len(t)
200
+
201
+ # Select the window and export as HTML fragment
202
+ c = QTextCursor(doc)
203
+ c.setPosition(start)
204
+ c.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
205
+ fragment_html = (
206
+ c.selection().toHtml()
207
+ ) # preserves original styles + our bolding
208
+
209
+ return fragment_html, start > 0, end < L
bouquin/settings.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from PySide6.QtCore import QSettings, QStandardPaths
5
+
6
+ from .db import DBConfig
7
+
8
+ APP_ORG = "Bouquin"
9
+ APP_NAME = "Bouquin"
10
+
11
+
12
+ def default_db_path() -> Path:
13
+ base = Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation))
14
+ return base / "notebook.db"
15
+
16
+
17
+ def get_settings() -> QSettings:
18
+ return QSettings(APP_ORG, APP_NAME)
19
+
20
+
21
+ def load_db_config() -> DBConfig:
22
+ s = get_settings()
23
+ path = Path(s.value("db/path", str(default_db_path())))
24
+ key = s.value("db/key", "")
25
+ idle = s.value("ui/idle_minutes", 15, type=int)
26
+ theme = s.value("ui/theme", "system", type=str)
27
+ move_todos = s.value("ui/move_todos", False, type=bool)
28
+ return DBConfig(
29
+ path=path, key=key, idle_minutes=idle, theme=theme, move_todos=move_todos
30
+ )
31
+
32
+
33
+ def save_db_config(cfg: DBConfig) -> None:
34
+ s = get_settings()
35
+ s.setValue("db/path", str(cfg.path))
36
+ s.setValue("db/key", str(cfg.key))
37
+ s.setValue("ui/idle_minutes", str(cfg.idle_minutes))
38
+ s.setValue("ui/theme", str(cfg.theme))
39
+ s.setValue("ui/move_todos", str(cfg.move_todos))
@@ -0,0 +1,302 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from PySide6.QtWidgets import (
6
+ QCheckBox,
7
+ QDialog,
8
+ QFormLayout,
9
+ QFrame,
10
+ QGroupBox,
11
+ QLabel,
12
+ QHBoxLayout,
13
+ QVBoxLayout,
14
+ QWidget,
15
+ QLineEdit,
16
+ QPushButton,
17
+ QFileDialog,
18
+ QDialogButtonBox,
19
+ QRadioButton,
20
+ QSizePolicy,
21
+ QSpinBox,
22
+ QMessageBox,
23
+ )
24
+ from PySide6.QtCore import Qt, Slot
25
+ from PySide6.QtGui import QPalette
26
+
27
+
28
+ from .db import DBConfig, DBManager
29
+ from .settings import load_db_config, save_db_config
30
+ from .theme import Theme
31
+ from .key_prompt import KeyPrompt
32
+
33
+
34
+ class SettingsDialog(QDialog):
35
+ def __init__(self, cfg: DBConfig, db: DBManager, parent=None):
36
+ super().__init__(parent)
37
+ self.setWindowTitle("Settings")
38
+ self._cfg = DBConfig(path=cfg.path, key="")
39
+ self._db = db
40
+ self.key = ""
41
+
42
+ form = QFormLayout()
43
+ form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
44
+ self.setMinimumWidth(560)
45
+ self.setSizeGripEnabled(True)
46
+
47
+ current_settings = load_db_config()
48
+
49
+ # Add theme selection
50
+ theme_group = QGroupBox("Theme")
51
+ theme_layout = QVBoxLayout(theme_group)
52
+
53
+ self.theme_system = QRadioButton("System")
54
+ self.theme_light = QRadioButton("Light")
55
+ self.theme_dark = QRadioButton("Dark")
56
+
57
+ # Load current theme from settings
58
+ current_theme = current_settings.theme
59
+ if current_theme == Theme.DARK.value:
60
+ self.theme_dark.setChecked(True)
61
+ elif current_theme == Theme.LIGHT.value:
62
+ self.theme_light.setChecked(True)
63
+ else:
64
+ self.theme_system.setChecked(True)
65
+
66
+ theme_layout.addWidget(self.theme_system)
67
+ theme_layout.addWidget(self.theme_light)
68
+ theme_layout.addWidget(self.theme_dark)
69
+
70
+ form.addRow(theme_group)
71
+
72
+ # Add Behaviour
73
+ behaviour_group = QGroupBox("Behaviour")
74
+ behaviour_layout = QVBoxLayout(behaviour_group)
75
+
76
+ self.move_todos = QCheckBox(
77
+ "Move yesterday's unchecked TODOs to today on startup"
78
+ )
79
+ self.move_todos.setChecked(current_settings.move_todos)
80
+ self.move_todos.setCursor(Qt.PointingHandCursor)
81
+
82
+ behaviour_layout.addWidget(self.move_todos)
83
+ form.addRow(behaviour_group)
84
+
85
+ self.path_edit = QLineEdit(str(self._cfg.path))
86
+ self.path_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
87
+ browse_btn = QPushButton("Browse…")
88
+ browse_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
89
+ browse_btn.clicked.connect(self._browse)
90
+ path_row = QWidget()
91
+ h = QHBoxLayout(path_row)
92
+ h.setContentsMargins(0, 0, 0, 0)
93
+ h.addWidget(self.path_edit, 1)
94
+ h.addWidget(browse_btn, 0)
95
+ h.setStretch(0, 1)
96
+ h.setStretch(1, 0)
97
+ form.addRow("Database path", path_row)
98
+
99
+ # Encryption settings
100
+ enc_group = QGroupBox("Encryption")
101
+ enc = QVBoxLayout(enc_group)
102
+ enc.setContentsMargins(12, 8, 12, 12)
103
+ enc.setSpacing(6)
104
+
105
+ # Checkbox to remember key
106
+ self.save_key_btn = QCheckBox("Remember key")
107
+ self.key = current_settings.key or ""
108
+ self.save_key_btn.setChecked(bool(self.key))
109
+ self.save_key_btn.setCursor(Qt.PointingHandCursor)
110
+ self.save_key_btn.toggled.connect(self._save_key_btn_clicked)
111
+ enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
112
+
113
+ # Explanation for remembering key
114
+ self.save_key_label = QLabel(
115
+ "If you don't want to be prompted for your encryption key, check this to remember it. "
116
+ "WARNING: the key is saved to disk and could be recoverable if your disk is compromised."
117
+ )
118
+ self.save_key_label.setWordWrap(True)
119
+ self.save_key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
120
+ # make it look secondary
121
+ pal = self.save_key_label.palette()
122
+ pal.setColor(self.save_key_label.foregroundRole(), pal.color(QPalette.Mid))
123
+ self.save_key_label.setPalette(pal)
124
+
125
+ exp_row = QHBoxLayout()
126
+ exp_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the checkbox
127
+ exp_row.addWidget(self.save_key_label)
128
+ enc.addLayout(exp_row)
129
+
130
+ line = QFrame()
131
+ line.setFrameShape(QFrame.HLine)
132
+ line.setFrameShadow(QFrame.Sunken)
133
+ enc.addWidget(line)
134
+
135
+ # Change key button
136
+ self.rekey_btn = QPushButton("Change encryption key")
137
+ self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
138
+ self.rekey_btn.clicked.connect(self._change_key)
139
+
140
+ enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
141
+
142
+ form.addRow(enc_group)
143
+
144
+ # Privacy settings
145
+ priv_group = QGroupBox("Lock screen when idle")
146
+ priv = QVBoxLayout(priv_group)
147
+ priv.setContentsMargins(12, 8, 12, 12)
148
+ priv.setSpacing(6)
149
+
150
+ self.idle_spin = QSpinBox()
151
+ self.idle_spin.setRange(0, 240)
152
+ self.idle_spin.setSingleStep(1)
153
+ self.idle_spin.setAccelerated(True)
154
+ self.idle_spin.setSuffix(" min")
155
+ self.idle_spin.setSpecialValueText("Never")
156
+ self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15))
157
+ priv.addWidget(self.idle_spin, 0, Qt.AlignLeft)
158
+ # Explanation for idle option (autolock)
159
+ self.idle_spin_label = QLabel(
160
+ "Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it. "
161
+ "Set to 0 (never) to never lock."
162
+ )
163
+ self.idle_spin_label.setWordWrap(True)
164
+ self.idle_spin_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
165
+ # make it look secondary
166
+ spal = self.idle_spin_label.palette()
167
+ spal.setColor(self.idle_spin_label.foregroundRole(), spal.color(QPalette.Mid))
168
+ self.idle_spin_label.setPalette(spal)
169
+
170
+ spin_row = QHBoxLayout()
171
+ spin_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the spinbox
172
+ spin_row.addWidget(self.idle_spin_label)
173
+ priv.addLayout(spin_row)
174
+
175
+ form.addRow(priv_group)
176
+
177
+ # Maintenance settings
178
+ maint_group = QGroupBox("Database maintenance")
179
+ maint = QVBoxLayout(maint_group)
180
+ maint.setContentsMargins(12, 8, 12, 12)
181
+ maint.setSpacing(6)
182
+
183
+ self.compact_btn = QPushButton("Compact database")
184
+ self.compact_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
185
+ self.compact_btn.clicked.connect(self._compact_btn_clicked)
186
+
187
+ maint.addWidget(self.compact_btn, 0, Qt.AlignLeft)
188
+
189
+ # Explanation for compating button
190
+ self.compact_label = QLabel(
191
+ "Compacting runs VACUUM on the database. This can help reduce its size."
192
+ )
193
+ self.compact_label.setWordWrap(True)
194
+ self.compact_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
195
+ # make it look secondary
196
+ cpal = self.compact_label.palette()
197
+ cpal.setColor(self.compact_label.foregroundRole(), cpal.color(QPalette.Mid))
198
+ self.compact_label.setPalette(cpal)
199
+
200
+ maint_row = QHBoxLayout()
201
+ maint_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the button
202
+ maint_row.addWidget(self.compact_label)
203
+ maint.addLayout(maint_row)
204
+
205
+ form.addRow(maint_group)
206
+
207
+ # Buttons
208
+ bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
209
+ bb.accepted.connect(self._save)
210
+ bb.rejected.connect(self.reject)
211
+
212
+ # Root layout (adjust margins/spacing a bit)
213
+ v = QVBoxLayout(self)
214
+ v.setContentsMargins(12, 12, 12, 12)
215
+ v.setSpacing(10)
216
+ v.addLayout(form)
217
+ v.addWidget(bb, 0, Qt.AlignRight)
218
+
219
+ def _browse(self):
220
+ p, _ = QFileDialog.getSaveFileName(
221
+ self,
222
+ "Choose database file",
223
+ self.path_edit.text(),
224
+ "DB Files (*.db);;All Files (*)",
225
+ )
226
+ if p:
227
+ self.path_edit.setText(p)
228
+
229
+ def _save(self):
230
+ # Save the selected theme into QSettings
231
+ if self.theme_dark.isChecked():
232
+ selected_theme = Theme.DARK
233
+ elif self.theme_light.isChecked():
234
+ selected_theme = Theme.LIGHT
235
+ else:
236
+ selected_theme = Theme.SYSTEM
237
+
238
+ key_to_save = self.key if self.save_key_btn.isChecked() else ""
239
+
240
+ self._cfg = DBConfig(
241
+ path=Path(self.path_edit.text()),
242
+ key=key_to_save,
243
+ idle_minutes=self.idle_spin.value(),
244
+ theme=selected_theme.value,
245
+ move_todos=self.move_todos.isChecked(),
246
+ )
247
+
248
+ save_db_config(self._cfg)
249
+ self.parent().themes.apply(selected_theme)
250
+ self.accept()
251
+
252
+ def _change_key(self):
253
+ p1 = KeyPrompt(self, title="Change key", message="Enter a new encryption key")
254
+ if p1.exec() != QDialog.Accepted:
255
+ return
256
+ new_key = p1.key()
257
+ p2 = KeyPrompt(self, title="Change key", message="Re-enter the new key")
258
+ if p2.exec() != QDialog.Accepted:
259
+ return
260
+ if new_key != p2.key():
261
+ QMessageBox.warning(self, "Key mismatch", "The two entries did not match.")
262
+ return
263
+ if not new_key:
264
+ QMessageBox.warning(self, "Empty key", "Key cannot be empty.")
265
+ return
266
+ try:
267
+ self.key = new_key
268
+ self._db.rekey(new_key)
269
+ QMessageBox.information(
270
+ self, "Key changed", "The notebook was re-encrypted with the new key!"
271
+ )
272
+ except Exception as e:
273
+ QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
274
+
275
+ @Slot(bool)
276
+ def _save_key_btn_clicked(self, checked: bool):
277
+ self.key = ""
278
+ if checked:
279
+ if not self.key:
280
+ p1 = KeyPrompt(
281
+ self, title="Enter your key", message="Enter the encryption key"
282
+ )
283
+ if p1.exec() != QDialog.Accepted:
284
+ self.save_key_btn.blockSignals(True)
285
+ self.save_key_btn.setChecked(False)
286
+ self.save_key_btn.blockSignals(False)
287
+ return
288
+ self.key = p1.key() or ""
289
+
290
+ @Slot(bool)
291
+ def _compact_btn_clicked(self):
292
+ try:
293
+ self._db.compact()
294
+ QMessageBox.information(
295
+ self, "Compact complete", "Database compacted successfully!"
296
+ )
297
+ except Exception as e:
298
+ QMessageBox.critical(self, "Error", f"Could not compact database:\n{e}")
299
+
300
+ @property
301
+ def config(self) -> DBConfig:
302
+ return self._cfg
bouquin/theme.py ADDED
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+ from PySide6.QtGui import QPalette, QColor, QGuiApplication
5
+ from PySide6.QtWidgets import QApplication
6
+ from PySide6.QtCore import QObject, Signal
7
+
8
+
9
+ class Theme(Enum):
10
+ SYSTEM = "system"
11
+ LIGHT = "light"
12
+ DARK = "dark"
13
+ ORANGE_ANCHOR = "#FFA500"
14
+ ORANGE_ANCHOR_VISITED = "#B38000"
15
+
16
+
17
+ @dataclass
18
+ class ThemeConfig:
19
+ theme: Theme = Theme.SYSTEM
20
+
21
+
22
+ class ThemeManager(QObject):
23
+ themeChanged = Signal(Theme)
24
+
25
+ def __init__(self, app: QApplication, cfg: ThemeConfig):
26
+ super().__init__()
27
+ self._app = app
28
+ self._cfg = cfg
29
+
30
+ # Follow OS if supported (Qt 6+)
31
+ hints = QGuiApplication.styleHints()
32
+ if hasattr(hints, "colorSchemeChanged"):
33
+ hints.colorSchemeChanged.connect(
34
+ lambda _: (self._cfg.theme == Theme.SYSTEM)
35
+ and self.apply(self._cfg.theme)
36
+ )
37
+
38
+ def current(self) -> Theme:
39
+ return self._cfg.theme
40
+
41
+ def set(self, theme: Theme):
42
+ self._cfg.theme = theme
43
+ self.apply(theme)
44
+
45
+ def apply(self, theme: Theme):
46
+ # Resolve "system"
47
+ if theme == Theme.SYSTEM:
48
+ hints = QGuiApplication.styleHints()
49
+ scheme = getattr(hints, "colorScheme", None)
50
+ if callable(scheme):
51
+ scheme = hints.colorScheme()
52
+ # 0=Light, 1=Dark in newer Qt; fall back to Light
53
+ theme = Theme.DARK if scheme == 1 else Theme.LIGHT
54
+
55
+ # Always use Fusion so palette applies consistently cross-platform
56
+ self._app.setStyle("Fusion")
57
+
58
+ if theme == Theme.DARK:
59
+ pal = self._dark_palette()
60
+ self._app.setPalette(pal)
61
+ # keep stylesheet empty unless you need widget-specific tweaks
62
+ self._app.setStyleSheet("")
63
+ else:
64
+ pal = self._light_palette()
65
+ self._app.setPalette(pal)
66
+ self._app.setStyleSheet("")
67
+
68
+ self.themeChanged.emit(theme)
69
+
70
+ # ----- Palettes -----
71
+ def _dark_palette(self) -> QPalette:
72
+ pal = QPalette()
73
+ base = QColor(35, 35, 35)
74
+ window = QColor(53, 53, 53)
75
+ text = QColor(220, 220, 220)
76
+ disabled = QColor(127, 127, 127)
77
+ focus = QColor(42, 130, 218)
78
+
79
+ pal.setColor(QPalette.Window, window)
80
+ pal.setColor(QPalette.WindowText, text)
81
+ pal.setColor(QPalette.Base, base)
82
+ pal.setColor(QPalette.AlternateBase, window)
83
+ pal.setColor(QPalette.ToolTipBase, window)
84
+ pal.setColor(QPalette.ToolTipText, text)
85
+ pal.setColor(QPalette.Text, text)
86
+ pal.setColor(QPalette.PlaceholderText, disabled)
87
+ pal.setColor(QPalette.Button, window)
88
+ pal.setColor(QPalette.ButtonText, text)
89
+ pal.setColor(QPalette.BrightText, QColor(255, 84, 84))
90
+ pal.setColor(QPalette.Highlight, focus)
91
+ pal.setColor(QPalette.HighlightedText, QColor(0, 0, 0))
92
+ pal.setColor(QPalette.Link, QColor(Theme.ORANGE_ANCHOR.value))
93
+ pal.setColor(QPalette.LinkVisited, QColor(Theme.ORANGE_ANCHOR_VISITED.value))
94
+
95
+ return pal
96
+
97
+ def _light_palette(self) -> QPalette:
98
+ # Let Qt provide its default light palette, but nudge a couple roles
99
+ pal = self._app.style().standardPalette()
100
+ pal.setColor(QPalette.Highlight, QColor(0, 120, 215))
101
+ pal.setColor(QPalette.HighlightedText, QColor(255, 255, 255))
102
+ pal.setColor(
103
+ QPalette.Link, QColor("#1a73e8")
104
+ ) # Light blue for links in light mode
105
+ return pal