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/__init__.py +0 -0
- bouquin/__main__.py +4 -0
- bouquin/db.py +499 -0
- bouquin/editor.py +897 -0
- bouquin/history_dialog.py +176 -0
- bouquin/key_prompt.py +41 -0
- bouquin/lock_overlay.py +127 -0
- bouquin/main.py +24 -0
- bouquin/main_window.py +904 -0
- bouquin/save_dialog.py +35 -0
- bouquin/search.py +209 -0
- bouquin/settings.py +39 -0
- bouquin/settings_dialog.py +302 -0
- bouquin/theme.py +105 -0
- bouquin/toolbar.py +221 -0
- bouquin-0.1.10.dist-info/LICENSE +674 -0
- bouquin-0.1.10.dist-info/METADATA +83 -0
- bouquin-0.1.10.dist-info/RECORD +20 -0
- bouquin-0.1.10.dist-info/WHEEL +4 -0
- bouquin-0.1.10.dist-info/entry_points.txt +3 -0
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
|