bouquin 0.1.10__py3-none-any.whl → 0.1.12__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/db.py +38 -58
- bouquin/editor.py +209 -97
- bouquin/find_bar.py +186 -0
- bouquin/history_dialog.py +2 -2
- bouquin/key_prompt.py +6 -0
- bouquin/lock_overlay.py +8 -2
- bouquin/main_window.py +115 -11
- bouquin/save_dialog.py +3 -0
- bouquin/search.py +0 -1
- bouquin/settings_dialog.py +1 -1
- bouquin/theme.py +1 -2
- bouquin/toolbar.py +5 -0
- {bouquin-0.1.10.dist-info → bouquin-0.1.12.dist-info}/METADATA +8 -3
- bouquin-0.1.12.dist-info/RECORD +21 -0
- bouquin-0.1.10.dist-info/RECORD +0 -20
- {bouquin-0.1.10.dist-info → bouquin-0.1.12.dist-info}/LICENSE +0 -0
- {bouquin-0.1.10.dist-info → bouquin-0.1.12.dist-info}/WHEEL +0 -0
- {bouquin-0.1.10.dist-info → bouquin-0.1.12.dist-info}/entry_points.txt +0 -0
bouquin/db.py
CHANGED
|
@@ -257,68 +257,31 @@ class DBManager:
|
|
|
257
257
|
).fetchall()
|
|
258
258
|
return [dict(r) for r in rows]
|
|
259
259
|
|
|
260
|
-
def get_version(
|
|
261
|
-
self,
|
|
262
|
-
*,
|
|
263
|
-
date_iso: str | None = None,
|
|
264
|
-
version_no: int | None = None,
|
|
265
|
-
version_id: int | None = None,
|
|
266
|
-
) -> dict | None:
|
|
260
|
+
def get_version(self, *, version_id: int) -> dict | None:
|
|
267
261
|
"""
|
|
268
|
-
Fetch a specific version by
|
|
262
|
+
Fetch a specific version by version_id.
|
|
269
263
|
Returns a dict with keys: id, date, version_no, created_at, note, content.
|
|
270
264
|
"""
|
|
271
265
|
cur = self.conn.cursor()
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
).fetchone()
|
|
278
|
-
else:
|
|
279
|
-
if date_iso is None or version_no is None:
|
|
280
|
-
raise ValueError(
|
|
281
|
-
"Provide either version_id OR (date_iso and version_no)"
|
|
282
|
-
)
|
|
283
|
-
row = cur.execute(
|
|
284
|
-
"SELECT id, date, version_no, created_at, note, content "
|
|
285
|
-
"FROM versions WHERE date=? AND version_no=?;",
|
|
286
|
-
(date_iso, version_no),
|
|
287
|
-
).fetchone()
|
|
266
|
+
row = cur.execute(
|
|
267
|
+
"SELECT id, date, version_no, created_at, note, content "
|
|
268
|
+
"FROM versions WHERE id=?;",
|
|
269
|
+
(version_id,),
|
|
270
|
+
).fetchone()
|
|
288
271
|
return dict(row) if row else None
|
|
289
272
|
|
|
290
|
-
def revert_to_version(
|
|
291
|
-
self,
|
|
292
|
-
date_iso: str,
|
|
293
|
-
*,
|
|
294
|
-
version_no: int | None = None,
|
|
295
|
-
version_id: int | None = None,
|
|
296
|
-
) -> None:
|
|
273
|
+
def revert_to_version(self, date_iso: str, version_id: int) -> None:
|
|
297
274
|
"""
|
|
298
275
|
Point the page head (pages.current_version_id) to an existing version.
|
|
299
|
-
Fast revert: no content is rewritten.
|
|
300
276
|
"""
|
|
301
|
-
if self.conn is None:
|
|
302
|
-
raise RuntimeError("Database is not connected")
|
|
303
277
|
cur = self.conn.cursor()
|
|
304
278
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
).fetchone()
|
|
312
|
-
if row is None:
|
|
313
|
-
raise ValueError("Version not found for this date")
|
|
314
|
-
version_id = int(row["id"])
|
|
315
|
-
else:
|
|
316
|
-
# Ensure that version_id belongs to the given date
|
|
317
|
-
row = cur.execute(
|
|
318
|
-
"SELECT date FROM versions WHERE id=?;", (version_id,)
|
|
319
|
-
).fetchone()
|
|
320
|
-
if row is None or row["date"] != date_iso:
|
|
321
|
-
raise ValueError("version_id does not belong to the given date")
|
|
279
|
+
# Ensure that version_id belongs to the given date
|
|
280
|
+
row = cur.execute(
|
|
281
|
+
"SELECT date FROM versions WHERE id=?;", (version_id,)
|
|
282
|
+
).fetchone()
|
|
283
|
+
if row is None or row["date"] != date_iso:
|
|
284
|
+
raise ValueError("version_id does not belong to the given date")
|
|
322
285
|
|
|
323
286
|
with self.conn:
|
|
324
287
|
cur.execute(
|
|
@@ -342,20 +305,18 @@ class DBManager:
|
|
|
342
305
|
).fetchall()
|
|
343
306
|
return [(r[0], r[1]) for r in rows]
|
|
344
307
|
|
|
345
|
-
def export_json(
|
|
346
|
-
self, entries: Sequence[Entry], file_path: str, pretty: bool = True
|
|
347
|
-
) -> None:
|
|
308
|
+
def export_json(self, entries: Sequence[Entry], file_path: str) -> None:
|
|
348
309
|
"""
|
|
349
310
|
Export to json.
|
|
350
311
|
"""
|
|
351
312
|
data = [{"date": d, "content": c} for d, c in entries]
|
|
352
313
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
353
|
-
|
|
354
|
-
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
355
|
-
else:
|
|
356
|
-
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
|
|
314
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
357
315
|
|
|
358
316
|
def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
|
|
317
|
+
"""
|
|
318
|
+
Export pages to CSV.
|
|
319
|
+
"""
|
|
359
320
|
# utf-8-sig adds a BOM so Excel opens as UTF-8 by default.
|
|
360
321
|
with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
|
|
361
322
|
writer = csv.writer(f)
|
|
@@ -369,6 +330,10 @@ class DBManager:
|
|
|
369
330
|
separator: str = "\n\n— — — — —\n\n",
|
|
370
331
|
strip_html: bool = True,
|
|
371
332
|
) -> None:
|
|
333
|
+
"""
|
|
334
|
+
Strip the HTML from the latest version of the pages
|
|
335
|
+
and save to a text file.
|
|
336
|
+
"""
|
|
372
337
|
import re, html as _html
|
|
373
338
|
|
|
374
339
|
# Precompiled patterns
|
|
@@ -407,6 +372,9 @@ class DBManager:
|
|
|
407
372
|
def export_html(
|
|
408
373
|
self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
|
|
409
374
|
) -> None:
|
|
375
|
+
"""
|
|
376
|
+
Export to HTML with a heading.
|
|
377
|
+
"""
|
|
410
378
|
parts = [
|
|
411
379
|
"<!doctype html>",
|
|
412
380
|
'<html lang="en">',
|
|
@@ -429,6 +397,10 @@ class DBManager:
|
|
|
429
397
|
def export_markdown(
|
|
430
398
|
self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
|
|
431
399
|
) -> None:
|
|
400
|
+
"""
|
|
401
|
+
Export to HTML, similar to export_html, but then convert to Markdown
|
|
402
|
+
using markdownify, and finally save to file.
|
|
403
|
+
"""
|
|
432
404
|
parts = [
|
|
433
405
|
"<!doctype html>",
|
|
434
406
|
'<html lang="en">',
|
|
@@ -469,6 +441,10 @@ class DBManager:
|
|
|
469
441
|
cur.execute("DETACH DATABASE backup")
|
|
470
442
|
|
|
471
443
|
def export_by_extension(self, file_path: str) -> None:
|
|
444
|
+
"""
|
|
445
|
+
Fallback catch-all that runs one of the above functions based on
|
|
446
|
+
the extension of the file name that was chosen by the user.
|
|
447
|
+
"""
|
|
472
448
|
entries = self.get_all_entries()
|
|
473
449
|
ext = os.path.splitext(file_path)[1].lower()
|
|
474
450
|
|
|
@@ -480,6 +456,10 @@ class DBManager:
|
|
|
480
456
|
self.export_txt(entries, file_path)
|
|
481
457
|
elif ext in {".html", ".htm"}:
|
|
482
458
|
self.export_html(entries, file_path)
|
|
459
|
+
elif ext in {".sql", ".sqlite"}:
|
|
460
|
+
self.export_sql(file_path)
|
|
461
|
+
elif ext == ".md":
|
|
462
|
+
self.export_markdown(entries, file_path)
|
|
483
463
|
else:
|
|
484
464
|
raise ValueError(f"Unsupported extension: {ext}")
|
|
485
465
|
|
bouquin/editor.py
CHANGED
|
@@ -68,13 +68,161 @@ class Editor(QTextEdit):
|
|
|
68
68
|
self._retint_anchors_to_palette()
|
|
69
69
|
|
|
70
70
|
self._themes = theme_manager
|
|
71
|
+
self._apply_code_theme() # set initial code colors
|
|
71
72
|
# Refresh on theme change
|
|
72
73
|
self._themes.themeChanged.connect(self._on_theme_changed)
|
|
74
|
+
self._themes.themeChanged.connect(
|
|
75
|
+
lambda _t: QTimer.singleShot(0, self._apply_code_theme)
|
|
76
|
+
)
|
|
73
77
|
|
|
74
78
|
self._linkifying = False
|
|
75
79
|
self.textChanged.connect(self._linkify_document)
|
|
76
80
|
self.viewport().setMouseTracking(True)
|
|
77
81
|
|
|
82
|
+
# ---------------- Helpers ---------------- #
|
|
83
|
+
|
|
84
|
+
def _iter_frames(self, root=None):
|
|
85
|
+
"""Depth-first traversal of all frames (including root if passed)."""
|
|
86
|
+
doc = self.document()
|
|
87
|
+
stack = [root or doc.rootFrame()]
|
|
88
|
+
while stack:
|
|
89
|
+
f = stack.pop()
|
|
90
|
+
yield f
|
|
91
|
+
it = f.begin()
|
|
92
|
+
while not it.atEnd():
|
|
93
|
+
cf = it.currentFrame()
|
|
94
|
+
if cf is not None:
|
|
95
|
+
stack.append(cf)
|
|
96
|
+
it += 1
|
|
97
|
+
|
|
98
|
+
def _is_code_frame(self, frame, tolerant: bool = False) -> bool:
|
|
99
|
+
"""
|
|
100
|
+
True if 'frame' is a code frame.
|
|
101
|
+
- tolerant=False: require our property marker
|
|
102
|
+
- tolerant=True: also accept legacy background or non-wrapping heuristic
|
|
103
|
+
"""
|
|
104
|
+
ff = frame.frameFormat()
|
|
105
|
+
if ff.property(self._CODE_FRAME_PROP):
|
|
106
|
+
return True
|
|
107
|
+
if not tolerant:
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
# Background colour check
|
|
111
|
+
bg = ff.background()
|
|
112
|
+
if bg.style() != Qt.NoBrush:
|
|
113
|
+
c = bg.color()
|
|
114
|
+
if c.isValid():
|
|
115
|
+
if (
|
|
116
|
+
abs(c.red() - 245) <= 2
|
|
117
|
+
and abs(c.green() - 245) <= 2
|
|
118
|
+
and abs(c.blue() - 245) <= 2
|
|
119
|
+
):
|
|
120
|
+
return True
|
|
121
|
+
if (
|
|
122
|
+
abs(c.red() - 43) <= 2
|
|
123
|
+
and abs(c.green() - 43) <= 2
|
|
124
|
+
and abs(c.blue() - 43) <= 2
|
|
125
|
+
):
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
# Heuristic: mostly non-wrapping blocks
|
|
129
|
+
doc = self.document()
|
|
130
|
+
bc = QTextCursor(doc)
|
|
131
|
+
bc.setPosition(frame.firstPosition())
|
|
132
|
+
blocks = codeish = 0
|
|
133
|
+
while bc.position() < frame.lastPosition():
|
|
134
|
+
b = bc.block()
|
|
135
|
+
if not b.isValid():
|
|
136
|
+
break
|
|
137
|
+
blocks += 1
|
|
138
|
+
if b.blockFormat().nonBreakableLines():
|
|
139
|
+
codeish += 1
|
|
140
|
+
bc.setPosition(b.position() + b.length())
|
|
141
|
+
return blocks > 0 and (codeish / blocks) >= 0.6
|
|
142
|
+
|
|
143
|
+
def _nearest_code_frame(self, cursor, tolerant: bool = False):
|
|
144
|
+
"""Walk up parents from the cursor and return the first code frame."""
|
|
145
|
+
f = cursor.currentFrame()
|
|
146
|
+
while f:
|
|
147
|
+
if self._is_code_frame(f, tolerant=tolerant):
|
|
148
|
+
return f
|
|
149
|
+
f = f.parentFrame()
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def _code_block_formats(self, fg: QColor | None = None):
|
|
153
|
+
"""(QTextBlockFormat, QTextCharFormat) for code blocks."""
|
|
154
|
+
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
|
155
|
+
|
|
156
|
+
bf = QTextBlockFormat()
|
|
157
|
+
bf.setTopMargin(0)
|
|
158
|
+
bf.setBottomMargin(0)
|
|
159
|
+
bf.setLeftMargin(12)
|
|
160
|
+
bf.setRightMargin(12)
|
|
161
|
+
bf.setNonBreakableLines(True)
|
|
162
|
+
|
|
163
|
+
cf = QTextCharFormat()
|
|
164
|
+
cf.setFont(mono)
|
|
165
|
+
cf.setFontFixedPitch(True)
|
|
166
|
+
if fg is not None:
|
|
167
|
+
cf.setForeground(fg)
|
|
168
|
+
return bf, cf
|
|
169
|
+
|
|
170
|
+
def _new_code_frame_format(self, bg: QColor) -> QTextFrameFormat:
|
|
171
|
+
"""Standard frame format for code blocks."""
|
|
172
|
+
ff = QTextFrameFormat()
|
|
173
|
+
ff.setBackground(bg)
|
|
174
|
+
ff.setPadding(6)
|
|
175
|
+
ff.setBorder(0)
|
|
176
|
+
ff.setLeftMargin(0)
|
|
177
|
+
ff.setRightMargin(0)
|
|
178
|
+
ff.setTopMargin(0)
|
|
179
|
+
ff.setBottomMargin(0)
|
|
180
|
+
ff.setProperty(self._CODE_FRAME_PROP, True)
|
|
181
|
+
return ff
|
|
182
|
+
|
|
183
|
+
def _retint_code_frame(self, frame, bg: QColor, fg: QColor | None):
|
|
184
|
+
"""Apply background to frame and standard code formats to all blocks inside."""
|
|
185
|
+
ff = frame.frameFormat()
|
|
186
|
+
ff.setBackground(bg)
|
|
187
|
+
frame.setFrameFormat(ff)
|
|
188
|
+
|
|
189
|
+
bf, cf = self._code_block_formats(fg)
|
|
190
|
+
doc = self.document()
|
|
191
|
+
bc = QTextCursor(doc)
|
|
192
|
+
bc.setPosition(frame.firstPosition())
|
|
193
|
+
while bc.position() < frame.lastPosition():
|
|
194
|
+
bc.select(QTextCursor.BlockUnderCursor)
|
|
195
|
+
bc.mergeBlockFormat(bf)
|
|
196
|
+
bc.mergeBlockCharFormat(cf)
|
|
197
|
+
if not bc.movePosition(QTextCursor.NextBlock):
|
|
198
|
+
break
|
|
199
|
+
|
|
200
|
+
def _safe_block_insertion_cursor(self):
|
|
201
|
+
"""
|
|
202
|
+
Return a cursor positioned for inserting an inline object (like an image):
|
|
203
|
+
- not inside a code frame (moves to after frame if necessary)
|
|
204
|
+
- at a fresh paragraph (inserts a block if mid-line)
|
|
205
|
+
Also updates the editor's current cursor to that position.
|
|
206
|
+
"""
|
|
207
|
+
c = QTextCursor(self.textCursor())
|
|
208
|
+
frame = self._nearest_code_frame(c, tolerant=False) # strict: our frames only
|
|
209
|
+
if frame:
|
|
210
|
+
out = QTextCursor(self.document())
|
|
211
|
+
out.setPosition(frame.lastPosition())
|
|
212
|
+
self.setTextCursor(out)
|
|
213
|
+
c = self.textCursor()
|
|
214
|
+
if c.positionInBlock() != 0:
|
|
215
|
+
c.insertBlock()
|
|
216
|
+
return c
|
|
217
|
+
|
|
218
|
+
def _scale_to_viewport(self, img: QImage, ratio: float = 0.92) -> QImage:
|
|
219
|
+
"""If the image is wider than viewport*ratio, scale it down proportionally."""
|
|
220
|
+
if self.viewport():
|
|
221
|
+
max_w = int(self.viewport().width() * ratio)
|
|
222
|
+
if img.width() > max_w:
|
|
223
|
+
return img.scaledToWidth(max_w, Qt.SmoothTransformation)
|
|
224
|
+
return img
|
|
225
|
+
|
|
78
226
|
def _approx(self, a: float, b: float, eps: float = 0.5) -> bool:
|
|
79
227
|
return abs(float(a) - float(b)) <= eps
|
|
80
228
|
|
|
@@ -91,16 +239,35 @@ class Editor(QTextEdit):
|
|
|
91
239
|
nf.setFontWeight(QFont.Weight.Normal)
|
|
92
240
|
self.mergeCurrentCharFormat(nf)
|
|
93
241
|
|
|
94
|
-
def
|
|
95
|
-
"""Return
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
242
|
+
def _code_theme_colors(self):
|
|
243
|
+
"""Return (bg, fg) for code blocks based on the effective palette."""
|
|
244
|
+
pal = QApplication.instance().palette()
|
|
245
|
+
# simple luminance check on the window color
|
|
246
|
+
win = pal.color(QPalette.Window)
|
|
247
|
+
is_dark = win.value() < 128
|
|
248
|
+
if is_dark:
|
|
249
|
+
bg = QColor(43, 43, 43) # dark code background
|
|
250
|
+
fg = pal.windowText().color() # readable on dark
|
|
251
|
+
else:
|
|
252
|
+
bg = QColor(245, 245, 245) # light code background
|
|
253
|
+
fg = pal.text().color() # readable on light
|
|
254
|
+
return bg, fg
|
|
255
|
+
|
|
256
|
+
def _apply_code_theme(self):
|
|
257
|
+
"""Retint all code frames (even those reloaded from HTML) to match the current theme."""
|
|
258
|
+
bg, fg = self._code_theme_colors()
|
|
259
|
+
self._CODE_BG = bg # used by future apply_code() calls
|
|
260
|
+
|
|
261
|
+
doc = self.document()
|
|
262
|
+
cur = QTextCursor(doc)
|
|
263
|
+
cur.beginEditBlock()
|
|
264
|
+
try:
|
|
265
|
+
for f in self._iter_frames(doc.rootFrame()):
|
|
266
|
+
if f is not doc.rootFrame() and self._is_code_frame(f, tolerant=True):
|
|
267
|
+
self._retint_code_frame(f, bg, fg)
|
|
268
|
+
finally:
|
|
269
|
+
cur.endEditBlock()
|
|
270
|
+
self.viewport().update()
|
|
104
271
|
|
|
105
272
|
def _trim_url_end(self, url: str) -> str:
|
|
106
273
|
# strip common trailing punctuation not part of the URL
|
|
@@ -149,7 +316,7 @@ class Editor(QTextEdit):
|
|
|
149
316
|
fmt.setFontUnderline(True)
|
|
150
317
|
fmt.setForeground(self.palette().brush(QPalette.Link))
|
|
151
318
|
|
|
152
|
-
cur.mergeCharFormat(fmt) # merge so we don
|
|
319
|
+
cur.mergeCharFormat(fmt) # merge so we don't clobber other styling
|
|
153
320
|
|
|
154
321
|
cur.endEditBlock()
|
|
155
322
|
finally:
|
|
@@ -209,26 +376,12 @@ class Editor(QTextEdit):
|
|
|
209
376
|
html = html.replace(f"src='{old}'", f"src='{data_url}'")
|
|
210
377
|
return html
|
|
211
378
|
|
|
212
|
-
|
|
213
|
-
c = self.textCursor()
|
|
214
|
-
|
|
215
|
-
# Don’t drop inside a code frame
|
|
216
|
-
frame = self._find_code_frame(c)
|
|
217
|
-
if frame:
|
|
218
|
-
out = QTextCursor(self.document())
|
|
219
|
-
out.setPosition(frame.lastPosition())
|
|
220
|
-
self.setTextCursor(out)
|
|
221
|
-
c = self.textCursor()
|
|
222
|
-
|
|
223
|
-
# Start a fresh paragraph if mid-line
|
|
224
|
-
if c.positionInBlock() != 0:
|
|
225
|
-
c.insertBlock()
|
|
226
|
-
|
|
227
|
-
if autoscale and self.viewport():
|
|
228
|
-
max_w = int(self.viewport().width() * 0.92)
|
|
229
|
-
if img.width() > max_w:
|
|
230
|
-
img = img.scaledToWidth(max_w, Qt.SmoothTransformation)
|
|
379
|
+
# ---------------- Image insertion & sizing (DRY’d) ---------------- #
|
|
231
380
|
|
|
381
|
+
def _insert_qimage_at_cursor(self, img: QImage, autoscale=True):
|
|
382
|
+
c = self._safe_block_insertion_cursor()
|
|
383
|
+
if autoscale:
|
|
384
|
+
img = self._scale_to_viewport(img)
|
|
232
385
|
c.insertImage(img)
|
|
233
386
|
c.insertBlock() # one blank line after the image
|
|
234
387
|
|
|
@@ -338,6 +491,8 @@ class Editor(QTextEdit):
|
|
|
338
491
|
return
|
|
339
492
|
self._apply_image_size(tc, imgfmt, float(orig.width()), orig)
|
|
340
493
|
|
|
494
|
+
# ---------------- Context menu ---------------- #
|
|
495
|
+
|
|
341
496
|
def contextMenuEvent(self, e):
|
|
342
497
|
menu = self.createStandardContextMenu()
|
|
343
498
|
tc, imgfmt, orig = self._image_info_at_cursor()
|
|
@@ -351,6 +506,8 @@ class Editor(QTextEdit):
|
|
|
351
506
|
sub.addAction("Reset to original", self._reset_image_size)
|
|
352
507
|
menu.exec(e.globalPos())
|
|
353
508
|
|
|
509
|
+
# ---------------- Clipboard / DnD ---------------- #
|
|
510
|
+
|
|
354
511
|
def insertFromMimeData(self, source):
|
|
355
512
|
# 1) Direct image from clipboard
|
|
356
513
|
if source.hasImage():
|
|
@@ -398,7 +555,7 @@ class Editor(QTextEdit):
|
|
|
398
555
|
data = base64.b64decode(m.group(1))
|
|
399
556
|
img = QImage.fromData(data)
|
|
400
557
|
if not img.isNull():
|
|
401
|
-
self._insert_qimage_at_cursor(
|
|
558
|
+
self._insert_qimage_at_cursor(img, autoscale=True)
|
|
402
559
|
return
|
|
403
560
|
except Exception:
|
|
404
561
|
pass # fall through
|
|
@@ -412,19 +569,7 @@ class Editor(QTextEdit):
|
|
|
412
569
|
Insert one or more images at the cursor. Large images can be auto-scaled
|
|
413
570
|
to fit the viewport width while preserving aspect ratio.
|
|
414
571
|
"""
|
|
415
|
-
c = self.
|
|
416
|
-
|
|
417
|
-
# Avoid dropping images inside a code frame
|
|
418
|
-
frame = self._find_code_frame(c)
|
|
419
|
-
if frame:
|
|
420
|
-
out = QTextCursor(self.document())
|
|
421
|
-
out.setPosition(frame.lastPosition())
|
|
422
|
-
self.setTextCursor(out)
|
|
423
|
-
c = self.textCursor()
|
|
424
|
-
|
|
425
|
-
# Ensure there's a paragraph break if we're mid-line
|
|
426
|
-
if c.positionInBlock() != 0:
|
|
427
|
-
c.insertBlock()
|
|
572
|
+
c = self._safe_block_insertion_cursor()
|
|
428
573
|
|
|
429
574
|
for path in paths:
|
|
430
575
|
reader = QImageReader(path)
|
|
@@ -432,14 +577,14 @@ class Editor(QTextEdit):
|
|
|
432
577
|
if img.isNull():
|
|
433
578
|
continue
|
|
434
579
|
|
|
435
|
-
if autoscale
|
|
436
|
-
|
|
437
|
-
if img.width() > max_w:
|
|
438
|
-
img = img.scaledToWidth(max_w, Qt.SmoothTransformation)
|
|
580
|
+
if autoscale:
|
|
581
|
+
img = self._scale_to_viewport(img)
|
|
439
582
|
|
|
440
583
|
c.insertImage(img)
|
|
441
584
|
c.insertBlock() # put each image on its own line
|
|
442
585
|
|
|
586
|
+
# ---------------- Mouse & key handling ---------------- #
|
|
587
|
+
|
|
443
588
|
def mouseReleaseEvent(self, e):
|
|
444
589
|
if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier):
|
|
445
590
|
href = self.anchorAt(e.pos())
|
|
@@ -510,7 +655,7 @@ class Editor(QTextEdit):
|
|
|
510
655
|
|
|
511
656
|
# If we're on an empty line inside a code frame, consume Enter and jump out
|
|
512
657
|
if c.block().length() == 1:
|
|
513
|
-
frame = self.
|
|
658
|
+
frame = self._nearest_code_frame(c, tolerant=False)
|
|
514
659
|
if frame:
|
|
515
660
|
out = QTextCursor(self.document())
|
|
516
661
|
out.setPosition(frame.lastPosition()) # after the frame's contents
|
|
@@ -588,7 +733,7 @@ class Editor(QTextEdit):
|
|
|
588
733
|
|
|
589
734
|
# ====== Checkbox core ======
|
|
590
735
|
def _base_point_size_for_block(self, block) -> float:
|
|
591
|
-
# Try the block
|
|
736
|
+
# Try the block's char format, then editor font
|
|
592
737
|
sz = block.charFormat().fontPointSize()
|
|
593
738
|
if sz <= 0:
|
|
594
739
|
sz = self.fontPointSize()
|
|
@@ -697,14 +842,6 @@ class Editor(QTextEdit):
|
|
|
697
842
|
break
|
|
698
843
|
b = b.next()
|
|
699
844
|
|
|
700
|
-
def toggle_current_checkbox_state(self):
|
|
701
|
-
"""Tick/untick the current line if it starts with a checkbox."""
|
|
702
|
-
b = self.textCursor().block()
|
|
703
|
-
state, _ = self._checkbox_info_for_block(b)
|
|
704
|
-
if state is None:
|
|
705
|
-
return
|
|
706
|
-
self._set_block_checkbox_state(b, not state)
|
|
707
|
-
|
|
708
845
|
@Slot()
|
|
709
846
|
def apply_weight(self):
|
|
710
847
|
cur = self.currentCharFormat()
|
|
@@ -744,46 +881,16 @@ class Editor(QTextEdit):
|
|
|
744
881
|
if not c.hasSelection():
|
|
745
882
|
c.select(QTextCursor.BlockUnderCursor)
|
|
746
883
|
|
|
747
|
-
|
|
748
|
-
ff = QTextFrameFormat()
|
|
749
|
-
ff.setBackground(self._CODE_BG)
|
|
750
|
-
ff.setPadding(6) # visual padding for the WHOLE block
|
|
751
|
-
ff.setBorder(0)
|
|
752
|
-
ff.setLeftMargin(0)
|
|
753
|
-
ff.setRightMargin(0)
|
|
754
|
-
ff.setTopMargin(0)
|
|
755
|
-
ff.setBottomMargin(0)
|
|
756
|
-
ff.setProperty(self._CODE_FRAME_PROP, True)
|
|
757
|
-
|
|
758
|
-
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
|
884
|
+
ff = self._new_code_frame_format(self._CODE_BG)
|
|
759
885
|
|
|
760
886
|
c.beginEditBlock()
|
|
761
887
|
try:
|
|
762
888
|
c.insertFrame(ff) # with a selection, this wraps the selection
|
|
763
889
|
|
|
764
|
-
# Format all blocks inside the new frame
|
|
765
|
-
frame = self.
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
while bc.position() < frame.lastPosition():
|
|
770
|
-
bc.select(QTextCursor.BlockUnderCursor)
|
|
771
|
-
|
|
772
|
-
bf = QTextBlockFormat()
|
|
773
|
-
bf.setTopMargin(0)
|
|
774
|
-
bf.setBottomMargin(0)
|
|
775
|
-
bf.setLeftMargin(12)
|
|
776
|
-
bf.setRightMargin(12)
|
|
777
|
-
bf.setNonBreakableLines(True)
|
|
778
|
-
|
|
779
|
-
cf = QTextCharFormat()
|
|
780
|
-
cf.setFont(mono)
|
|
781
|
-
cf.setFontFixedPitch(True)
|
|
782
|
-
|
|
783
|
-
bc.mergeBlockFormat(bf)
|
|
784
|
-
bc.mergeBlockCharFormat(cf)
|
|
785
|
-
|
|
786
|
-
bc.setPosition(bc.block().position() + bc.block().length())
|
|
890
|
+
# Format all blocks inside the new frame (keep fg=None on creation)
|
|
891
|
+
frame = self._nearest_code_frame(c, tolerant=False)
|
|
892
|
+
if frame:
|
|
893
|
+
self._retint_code_frame(frame, self._CODE_BG, fg=None)
|
|
787
894
|
finally:
|
|
788
895
|
c.endEditBlock()
|
|
789
896
|
|
|
@@ -855,6 +962,7 @@ class Editor(QTextEdit):
|
|
|
855
962
|
def _on_theme_changed(self, _theme: Theme):
|
|
856
963
|
# Defer one event-loop tick so widgets have the new palette
|
|
857
964
|
QTimer.singleShot(0, self._retint_anchors_to_palette)
|
|
965
|
+
QTimer.singleShot(0, self._apply_code_theme)
|
|
858
966
|
|
|
859
967
|
@Slot()
|
|
860
968
|
def _retint_anchors_to_palette(self, *_):
|
|
@@ -874,10 +982,13 @@ class Editor(QTextEdit):
|
|
|
874
982
|
if fmt.isAnchor():
|
|
875
983
|
new_fmt = QTextCharFormat(fmt)
|
|
876
984
|
new_fmt.setForeground(link_brush) # force palette link color
|
|
877
|
-
|
|
878
|
-
cur.setPosition(
|
|
879
|
-
|
|
880
|
-
|
|
985
|
+
start = frag.position()
|
|
986
|
+
cur.setPosition(start)
|
|
987
|
+
cur.movePosition(
|
|
988
|
+
QTextCursor.NextCharacter,
|
|
989
|
+
QTextCursor.KeepAnchor,
|
|
990
|
+
frag.length(),
|
|
991
|
+
) # select exactly this fragment
|
|
881
992
|
cur.setCharFormat(new_fmt)
|
|
882
993
|
it += 1
|
|
883
994
|
block = block.next()
|
|
@@ -895,3 +1006,4 @@ class Editor(QTextEdit):
|
|
|
895
1006
|
|
|
896
1007
|
# Ensure anchors adopt the palette color on startup
|
|
897
1008
|
self._retint_anchors_to_palette()
|
|
1009
|
+
self._apply_code_theme()
|
bouquin/find_bar.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from PySide6.QtCore import Qt, Signal
|
|
4
|
+
from PySide6.QtGui import (
|
|
5
|
+
QShortcut,
|
|
6
|
+
QTextCursor,
|
|
7
|
+
QTextCharFormat,
|
|
8
|
+
QTextDocument,
|
|
9
|
+
)
|
|
10
|
+
from PySide6.QtWidgets import (
|
|
11
|
+
QWidget,
|
|
12
|
+
QHBoxLayout,
|
|
13
|
+
QLineEdit,
|
|
14
|
+
QLabel,
|
|
15
|
+
QPushButton,
|
|
16
|
+
QCheckBox,
|
|
17
|
+
QTextEdit,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FindBar(QWidget):
|
|
22
|
+
"""Widget for finding text in the Editor"""
|
|
23
|
+
|
|
24
|
+
closed = (
|
|
25
|
+
Signal()
|
|
26
|
+
) # emitted when the bar is hidden (Esc/✕), so caller can refocus editor
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
editor: QTextEdit,
|
|
31
|
+
shortcut_parent: QWidget | None = None,
|
|
32
|
+
parent: QWidget | None = None,
|
|
33
|
+
):
|
|
34
|
+
super().__init__(parent)
|
|
35
|
+
self.editor = editor
|
|
36
|
+
|
|
37
|
+
# UI
|
|
38
|
+
layout = QHBoxLayout(self)
|
|
39
|
+
layout.setContentsMargins(6, 0, 6, 0)
|
|
40
|
+
|
|
41
|
+
layout.addWidget(QLabel("Find:"))
|
|
42
|
+
self.edit = QLineEdit(self)
|
|
43
|
+
self.edit.setPlaceholderText("Type to search…")
|
|
44
|
+
layout.addWidget(self.edit)
|
|
45
|
+
|
|
46
|
+
self.case = QCheckBox("Match case", self)
|
|
47
|
+
layout.addWidget(self.case)
|
|
48
|
+
|
|
49
|
+
self.prevBtn = QPushButton("Prev", self)
|
|
50
|
+
self.nextBtn = QPushButton("Next", self)
|
|
51
|
+
self.closeBtn = QPushButton("✕", self)
|
|
52
|
+
self.closeBtn.setFlat(True)
|
|
53
|
+
layout.addWidget(self.prevBtn)
|
|
54
|
+
layout.addWidget(self.nextBtn)
|
|
55
|
+
layout.addWidget(self.closeBtn)
|
|
56
|
+
|
|
57
|
+
self.setVisible(False)
|
|
58
|
+
|
|
59
|
+
# Shortcut escape key to close findBar
|
|
60
|
+
sp = shortcut_parent if shortcut_parent is not None else (parent or self)
|
|
61
|
+
self._scEsc = QShortcut(Qt.Key_Escape, sp, activated=self._maybe_hide)
|
|
62
|
+
|
|
63
|
+
# Signals
|
|
64
|
+
self.edit.returnPressed.connect(self.find_next)
|
|
65
|
+
self.edit.textChanged.connect(self._update_highlight)
|
|
66
|
+
self.case.toggled.connect(self._update_highlight)
|
|
67
|
+
self.nextBtn.clicked.connect(self.find_next)
|
|
68
|
+
self.prevBtn.clicked.connect(self.find_prev)
|
|
69
|
+
self.closeBtn.clicked.connect(self.hide_bar)
|
|
70
|
+
|
|
71
|
+
# ----- Public API -----
|
|
72
|
+
|
|
73
|
+
def show_bar(self):
|
|
74
|
+
"""Show the bar, seed with current selection if sensible, focus the line edit."""
|
|
75
|
+
tc = self.editor.textCursor()
|
|
76
|
+
sel = tc.selectedText().strip()
|
|
77
|
+
if sel and "\u2029" not in sel: # ignore multi-paragraph selections
|
|
78
|
+
self.edit.setText(sel)
|
|
79
|
+
self.setVisible(True)
|
|
80
|
+
self.edit.setFocus(Qt.ShortcutFocusReason)
|
|
81
|
+
self.edit.selectAll()
|
|
82
|
+
self._update_highlight()
|
|
83
|
+
|
|
84
|
+
def hide_bar(self):
|
|
85
|
+
self.setVisible(False)
|
|
86
|
+
self._clear_highlight()
|
|
87
|
+
self.closed.emit()
|
|
88
|
+
|
|
89
|
+
def refresh(self):
|
|
90
|
+
"""Recompute highlights"""
|
|
91
|
+
self._update_highlight()
|
|
92
|
+
|
|
93
|
+
# ----- Internals -----
|
|
94
|
+
|
|
95
|
+
def _maybe_hide(self):
|
|
96
|
+
if self.isVisible():
|
|
97
|
+
self.hide_bar()
|
|
98
|
+
|
|
99
|
+
def _flags(self, backward: bool = False) -> QTextDocument.FindFlags:
|
|
100
|
+
flags = QTextDocument.FindFlags()
|
|
101
|
+
if backward:
|
|
102
|
+
flags |= QTextDocument.FindBackward
|
|
103
|
+
if self.case.isChecked():
|
|
104
|
+
flags |= QTextDocument.FindCaseSensitively
|
|
105
|
+
return flags
|
|
106
|
+
|
|
107
|
+
def find_next(self):
|
|
108
|
+
txt = self.edit.text()
|
|
109
|
+
if not txt:
|
|
110
|
+
return
|
|
111
|
+
# If current selection == query, bump caret to the end so we don't re-match it.
|
|
112
|
+
c = self.editor.textCursor()
|
|
113
|
+
if c.hasSelection():
|
|
114
|
+
sel = c.selectedText()
|
|
115
|
+
same = (
|
|
116
|
+
(sel == txt)
|
|
117
|
+
if self.case.isChecked()
|
|
118
|
+
else (sel.casefold() == txt.casefold())
|
|
119
|
+
)
|
|
120
|
+
if same:
|
|
121
|
+
end = max(c.position(), c.anchor())
|
|
122
|
+
c.setPosition(end, QTextCursor.MoveAnchor)
|
|
123
|
+
self.editor.setTextCursor(c)
|
|
124
|
+
if not self.editor.find(txt, self._flags(False)):
|
|
125
|
+
cur = self.editor.textCursor()
|
|
126
|
+
cur.movePosition(QTextCursor.Start)
|
|
127
|
+
self.editor.setTextCursor(cur)
|
|
128
|
+
self.editor.find(txt, self._flags(False))
|
|
129
|
+
self.editor.ensureCursorVisible()
|
|
130
|
+
self._update_highlight()
|
|
131
|
+
|
|
132
|
+
def find_prev(self):
|
|
133
|
+
txt = self.edit.text()
|
|
134
|
+
if not txt:
|
|
135
|
+
return
|
|
136
|
+
# If current selection == query, bump caret to the start so we don't re-match it.
|
|
137
|
+
c = self.editor.textCursor()
|
|
138
|
+
if c.hasSelection():
|
|
139
|
+
sel = c.selectedText()
|
|
140
|
+
same = (
|
|
141
|
+
(sel == txt)
|
|
142
|
+
if self.case.isChecked()
|
|
143
|
+
else (sel.casefold() == txt.casefold())
|
|
144
|
+
)
|
|
145
|
+
if same:
|
|
146
|
+
start = min(c.position(), c.anchor())
|
|
147
|
+
c.setPosition(start, QTextCursor.MoveAnchor)
|
|
148
|
+
self.editor.setTextCursor(c)
|
|
149
|
+
if not self.editor.find(txt, self._flags(True)):
|
|
150
|
+
cur = self.editor.textCursor()
|
|
151
|
+
cur.movePosition(QTextCursor.End)
|
|
152
|
+
self.editor.setTextCursor(cur)
|
|
153
|
+
self.editor.find(txt, self._flags(True))
|
|
154
|
+
self.editor.ensureCursorVisible()
|
|
155
|
+
self._update_highlight()
|
|
156
|
+
|
|
157
|
+
def _update_highlight(self):
|
|
158
|
+
txt = self.edit.text()
|
|
159
|
+
if not txt:
|
|
160
|
+
self._clear_highlight()
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
doc = self.editor.document()
|
|
164
|
+
flags = self._flags(False)
|
|
165
|
+
cur = QTextCursor(doc)
|
|
166
|
+
cur.movePosition(QTextCursor.Start)
|
|
167
|
+
|
|
168
|
+
fmt = QTextCharFormat()
|
|
169
|
+
hl = self.palette().highlight().color()
|
|
170
|
+
hl.setAlpha(90)
|
|
171
|
+
fmt.setBackground(hl)
|
|
172
|
+
|
|
173
|
+
selections = []
|
|
174
|
+
while True:
|
|
175
|
+
cur = doc.find(txt, cur, flags)
|
|
176
|
+
if cur.isNull():
|
|
177
|
+
break
|
|
178
|
+
sel = QTextEdit.ExtraSelection()
|
|
179
|
+
sel.cursor = cur
|
|
180
|
+
sel.format = fmt
|
|
181
|
+
selections.append(sel)
|
|
182
|
+
|
|
183
|
+
self.editor.setExtraSelections(selections)
|
|
184
|
+
|
|
185
|
+
def _clear_highlight(self):
|
|
186
|
+
self.editor.setExtraSelections([])
|
bouquin/history_dialog.py
CHANGED
|
@@ -156,7 +156,7 @@ class HistoryDialog(QDialog):
|
|
|
156
156
|
# Diff vs current (textual diff)
|
|
157
157
|
cur = self._db.get_version(version_id=self._current_id)
|
|
158
158
|
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
|
|
159
|
-
# Enable revert only if selecting a non-current
|
|
159
|
+
# Enable revert only if selecting a non-current version
|
|
160
160
|
self.btn_revert.setEnabled(sel_id != self._current_id)
|
|
161
161
|
|
|
162
162
|
@Slot()
|
|
@@ -167,7 +167,7 @@ class HistoryDialog(QDialog):
|
|
|
167
167
|
sel_id = item.data(Qt.UserRole)
|
|
168
168
|
if sel_id == self._current_id:
|
|
169
169
|
return
|
|
170
|
-
# Flip head pointer
|
|
170
|
+
# Flip head pointer to the older version
|
|
171
171
|
try:
|
|
172
172
|
self._db.revert_to_version(self._date, version_id=sel_id)
|
|
173
173
|
except Exception as e:
|
bouquin/key_prompt.py
CHANGED
|
@@ -17,6 +17,12 @@ class KeyPrompt(QDialog):
|
|
|
17
17
|
title: str = "Enter key",
|
|
18
18
|
message: str = "Enter key",
|
|
19
19
|
):
|
|
20
|
+
"""
|
|
21
|
+
Prompt the user for the key required to decrypt the database.
|
|
22
|
+
|
|
23
|
+
Used when opening the app, unlocking the idle locked screen,
|
|
24
|
+
or when rekeying.
|
|
25
|
+
"""
|
|
20
26
|
super().__init__(parent)
|
|
21
27
|
self.setWindowTitle(title)
|
|
22
28
|
v = QVBoxLayout(self)
|
bouquin/lock_overlay.py
CHANGED
|
@@ -7,6 +7,9 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
|
|
|
7
7
|
|
|
8
8
|
class LockOverlay(QWidget):
|
|
9
9
|
def __init__(self, parent: QWidget, on_unlock: callable):
|
|
10
|
+
"""
|
|
11
|
+
Widget that 'locks' the screen after a configured idle time.
|
|
12
|
+
"""
|
|
10
13
|
super().__init__(parent)
|
|
11
14
|
self.setObjectName("LockOverlay")
|
|
12
15
|
self.setAttribute(Qt.WA_StyledBackground, True)
|
|
@@ -39,6 +42,9 @@ class LockOverlay(QWidget):
|
|
|
39
42
|
self.hide()
|
|
40
43
|
|
|
41
44
|
def _is_dark(self, pal: QPalette) -> bool:
|
|
45
|
+
"""
|
|
46
|
+
Detect if dark mode is in use.
|
|
47
|
+
"""
|
|
42
48
|
c = pal.color(QPalette.Window)
|
|
43
49
|
luma = 0.2126 * c.redF() + 0.7152 * c.greenF() + 0.0722 * c.blueF()
|
|
44
50
|
return luma < 0.5
|
|
@@ -58,7 +64,7 @@ class LockOverlay(QWidget):
|
|
|
58
64
|
|
|
59
65
|
self.setStyleSheet(
|
|
60
66
|
f"""
|
|
61
|
-
#LockOverlay {{ background-color: rgb(0,0,0); }}
|
|
67
|
+
#LockOverlay {{ background-color: rgb(0,0,0); }}
|
|
62
68
|
#LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }}
|
|
63
69
|
|
|
64
70
|
#LockOverlay QPushButton#unlockButton {{
|
|
@@ -113,7 +119,7 @@ class LockOverlay(QWidget):
|
|
|
113
119
|
|
|
114
120
|
def changeEvent(self, ev):
|
|
115
121
|
super().changeEvent(ev)
|
|
116
|
-
# Only re-style on palette flips
|
|
122
|
+
# Only re-style on palette flips (user changed theme)
|
|
117
123
|
if ev.type() in (QEvent.PaletteChange, QEvent.ApplicationPaletteChange):
|
|
118
124
|
self._apply_overlay_style()
|
|
119
125
|
|
bouquin/main_window.py
CHANGED
|
@@ -24,8 +24,10 @@ from PySide6.QtGui import (
|
|
|
24
24
|
QDesktopServices,
|
|
25
25
|
QFont,
|
|
26
26
|
QGuiApplication,
|
|
27
|
+
QKeySequence,
|
|
27
28
|
QPalette,
|
|
28
29
|
QTextCharFormat,
|
|
30
|
+
QTextCursor,
|
|
29
31
|
QTextListFormat,
|
|
30
32
|
)
|
|
31
33
|
from PySide6.QtWidgets import (
|
|
@@ -43,6 +45,7 @@ from PySide6.QtWidgets import (
|
|
|
43
45
|
|
|
44
46
|
from .db import DBManager
|
|
45
47
|
from .editor import Editor
|
|
48
|
+
from .find_bar import FindBar
|
|
46
49
|
from .history_dialog import HistoryDialog
|
|
47
50
|
from .key_prompt import KeyPrompt
|
|
48
51
|
from .lock_overlay import LockOverlay
|
|
@@ -121,7 +124,7 @@ class MainWindow(QMainWindow):
|
|
|
121
124
|
split = QSplitter()
|
|
122
125
|
split.addWidget(left_panel)
|
|
123
126
|
split.addWidget(self.editor)
|
|
124
|
-
split.setStretchFactor(1, 1)
|
|
127
|
+
split.setStretchFactor(1, 1)
|
|
125
128
|
|
|
126
129
|
container = QWidget()
|
|
127
130
|
lay = QVBoxLayout(container)
|
|
@@ -146,8 +149,23 @@ class MainWindow(QMainWindow):
|
|
|
146
149
|
|
|
147
150
|
QApplication.instance().installEventFilter(self)
|
|
148
151
|
|
|
152
|
+
# Focus on the editor
|
|
153
|
+
self.setFocusPolicy(Qt.StrongFocus)
|
|
154
|
+
self.editor.setFocusPolicy(Qt.StrongFocus)
|
|
155
|
+
self.toolBar.setFocusPolicy(Qt.NoFocus)
|
|
156
|
+
for w in self.toolBar.findChildren(QWidget):
|
|
157
|
+
w.setFocusPolicy(Qt.NoFocus)
|
|
158
|
+
QGuiApplication.instance().applicationStateChanged.connect(
|
|
159
|
+
self._on_app_state_changed
|
|
160
|
+
)
|
|
161
|
+
|
|
149
162
|
# Status bar for feedback
|
|
150
163
|
self.statusBar().showMessage("Ready", 800)
|
|
164
|
+
# Add findBar and add it to the statusBar
|
|
165
|
+
self.findBar = FindBar(self.editor, shortcut_parent=self, parent=self)
|
|
166
|
+
self.statusBar().addPermanentWidget(self.findBar)
|
|
167
|
+
# When the findBar closes, put the caret back in the editor
|
|
168
|
+
self.findBar.closed.connect(self._focus_editor_now)
|
|
151
169
|
|
|
152
170
|
# Menu bar (File)
|
|
153
171
|
mb = self.menuBar()
|
|
@@ -202,6 +220,24 @@ class MainWindow(QMainWindow):
|
|
|
202
220
|
nav_menu.addAction(act_today)
|
|
203
221
|
self.addAction(act_today)
|
|
204
222
|
|
|
223
|
+
act_find = QAction("Find on page", self)
|
|
224
|
+
act_find.setShortcut(QKeySequence.Find)
|
|
225
|
+
act_find.triggered.connect(self.findBar.show_bar)
|
|
226
|
+
nav_menu.addAction(act_find)
|
|
227
|
+
self.addAction(act_find)
|
|
228
|
+
|
|
229
|
+
act_find_next = QAction("Find Next", self)
|
|
230
|
+
act_find_next.setShortcut(QKeySequence.FindNext)
|
|
231
|
+
act_find_next.triggered.connect(self.findBar.find_next)
|
|
232
|
+
nav_menu.addAction(act_find_next)
|
|
233
|
+
self.addAction(act_find_next)
|
|
234
|
+
|
|
235
|
+
act_find_prev = QAction("Find Previous", self)
|
|
236
|
+
act_find_prev.setShortcut(QKeySequence.FindPrevious)
|
|
237
|
+
act_find_prev.triggered.connect(self.findBar.find_prev)
|
|
238
|
+
nav_menu.addAction(act_find_prev)
|
|
239
|
+
self.addAction(act_find_prev)
|
|
240
|
+
|
|
205
241
|
# Help menu with drop-down
|
|
206
242
|
help_menu = mb.addMenu("&Help")
|
|
207
243
|
act_docs = QAction("Documentation", self)
|
|
@@ -281,7 +317,7 @@ class MainWindow(QMainWindow):
|
|
|
281
317
|
if hasattr(self, "_lock_overlay"):
|
|
282
318
|
self._lock_overlay._apply_overlay_style()
|
|
283
319
|
self._apply_calendar_text_colors()
|
|
284
|
-
self._apply_link_css()
|
|
320
|
+
self._apply_link_css()
|
|
285
321
|
self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set()))
|
|
286
322
|
self.calendar.update()
|
|
287
323
|
self.editor.viewport().update()
|
|
@@ -298,7 +334,6 @@ class MainWindow(QMainWindow):
|
|
|
298
334
|
css = "" # Default to no custom styling for links (system or light theme)
|
|
299
335
|
|
|
300
336
|
try:
|
|
301
|
-
# Apply to the editor (QTextEdit or any other relevant widgets)
|
|
302
337
|
self.editor.document().setDefaultStyleSheet(css)
|
|
303
338
|
except Exception:
|
|
304
339
|
pass
|
|
@@ -347,7 +382,6 @@ class MainWindow(QMainWindow):
|
|
|
347
382
|
self.calendar.setPalette(app_pal)
|
|
348
383
|
self.calendar.setStyleSheet("")
|
|
349
384
|
|
|
350
|
-
# Keep weekend text color in sync with the current palette
|
|
351
385
|
self._apply_calendar_text_colors()
|
|
352
386
|
self.calendar.update()
|
|
353
387
|
|
|
@@ -483,18 +517,16 @@ class MainWindow(QMainWindow):
|
|
|
483
517
|
# Inject the extra_data before the closing </body></html>
|
|
484
518
|
modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text)
|
|
485
519
|
text = modified
|
|
486
|
-
|
|
520
|
+
# Force a save now so we don't lose it.
|
|
521
|
+
self._set_editor_html_preserve_view(text)
|
|
487
522
|
self._dirty = True
|
|
488
523
|
self._save_date(date_iso, True)
|
|
489
524
|
|
|
490
|
-
print("end")
|
|
491
525
|
except Exception as e:
|
|
492
526
|
QMessageBox.critical(self, "Read Error", str(e))
|
|
493
527
|
return
|
|
494
528
|
|
|
495
|
-
self.
|
|
496
|
-
self.editor.setHtml(text)
|
|
497
|
-
self.editor.blockSignals(False)
|
|
529
|
+
self._set_editor_html_preserve_view(text)
|
|
498
530
|
|
|
499
531
|
self._dirty = False
|
|
500
532
|
# track which date the editor currently represents
|
|
@@ -708,7 +740,7 @@ class MainWindow(QMainWindow):
|
|
|
708
740
|
QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen()
|
|
709
741
|
)
|
|
710
742
|
r = screen.availableGeometry()
|
|
711
|
-
# Center the window in that screen
|
|
743
|
+
# Center the window in that screen's available area
|
|
712
744
|
self.move(r.center() - self.rect().center())
|
|
713
745
|
|
|
714
746
|
# ----------------- Export handler ----------------- #
|
|
@@ -837,7 +869,7 @@ If you want an encrypted backup, choose Backup instead of Export.
|
|
|
837
869
|
return
|
|
838
870
|
if minutes == 0:
|
|
839
871
|
self._idle_timer.stop()
|
|
840
|
-
# If
|
|
872
|
+
# If currently locked, unlock when user disables the timer:
|
|
841
873
|
if getattr(self, "_locked", False):
|
|
842
874
|
try:
|
|
843
875
|
self._locked = False
|
|
@@ -853,9 +885,14 @@ If you want an encrypted backup, choose Backup instead of Export.
|
|
|
853
885
|
def eventFilter(self, obj, event):
|
|
854
886
|
if event.type() == QEvent.KeyPress and not self._locked:
|
|
855
887
|
self._idle_timer.start()
|
|
888
|
+
if event.type() in (QEvent.ApplicationActivate, QEvent.WindowActivate):
|
|
889
|
+
QTimer.singleShot(0, self._focus_editor_now)
|
|
856
890
|
return super().eventFilter(obj, event)
|
|
857
891
|
|
|
858
892
|
def _enter_lock(self):
|
|
893
|
+
"""
|
|
894
|
+
Trigger the lock overlay and disable widgets
|
|
895
|
+
"""
|
|
859
896
|
if self._locked:
|
|
860
897
|
return
|
|
861
898
|
self._locked = True
|
|
@@ -871,6 +908,10 @@ If you want an encrypted backup, choose Backup instead of Export.
|
|
|
871
908
|
|
|
872
909
|
@Slot()
|
|
873
910
|
def _on_unlock_clicked(self):
|
|
911
|
+
"""
|
|
912
|
+
Prompt for key to unlock screen
|
|
913
|
+
If successful, re-enable widgets
|
|
914
|
+
"""
|
|
874
915
|
try:
|
|
875
916
|
ok = self._prompt_for_key_until_valid(first_time=False)
|
|
876
917
|
except Exception as e:
|
|
@@ -887,6 +928,7 @@ If you want an encrypted backup, choose Backup instead of Export.
|
|
|
887
928
|
if tb:
|
|
888
929
|
tb.setEnabled(True)
|
|
889
930
|
self._idle_timer.start()
|
|
931
|
+
QTimer.singleShot(0, self._focus_editor_now)
|
|
890
932
|
|
|
891
933
|
# ----------------- Close handlers ----------------- #
|
|
892
934
|
def closeEvent(self, event):
|
|
@@ -902,3 +944,65 @@ If you want an encrypted backup, choose Backup instead of Export.
|
|
|
902
944
|
except Exception:
|
|
903
945
|
pass
|
|
904
946
|
super().closeEvent(event)
|
|
947
|
+
|
|
948
|
+
# ----------------- Below logic helps focus the editor ----------------- #
|
|
949
|
+
|
|
950
|
+
def _focus_editor_now(self):
|
|
951
|
+
"""Give focus to the editor and ensure the caret is visible."""
|
|
952
|
+
if getattr(self, "_locked", False):
|
|
953
|
+
return
|
|
954
|
+
if not self.isActiveWindow():
|
|
955
|
+
return
|
|
956
|
+
# Belt-and-suspenders: do it now and once more on the next tick
|
|
957
|
+
self.editor.setFocus(Qt.ActiveWindowFocusReason)
|
|
958
|
+
self.editor.ensureCursorVisible()
|
|
959
|
+
QTimer.singleShot(
|
|
960
|
+
0,
|
|
961
|
+
lambda: (
|
|
962
|
+
self.editor.setFocus(Qt.ActiveWindowFocusReason),
|
|
963
|
+
self.editor.ensureCursorVisible(),
|
|
964
|
+
),
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
def _on_app_state_changed(self, state):
|
|
968
|
+
# Called on macOS/Wayland/Windows when the whole app re-activates
|
|
969
|
+
if state == Qt.ApplicationActive and self.isActiveWindow():
|
|
970
|
+
QTimer.singleShot(0, self._focus_editor_now)
|
|
971
|
+
|
|
972
|
+
def changeEvent(self, ev):
|
|
973
|
+
# Called on some platforms when the window's activation state flips
|
|
974
|
+
super().changeEvent(ev)
|
|
975
|
+
if ev.type() == QEvent.ActivationChange and self.isActiveWindow():
|
|
976
|
+
QTimer.singleShot(0, self._focus_editor_now)
|
|
977
|
+
|
|
978
|
+
def _set_editor_html_preserve_view(self, html: str):
|
|
979
|
+
ed = self.editor
|
|
980
|
+
|
|
981
|
+
# Save caret/selection and scroll
|
|
982
|
+
cur = ed.textCursor()
|
|
983
|
+
old_pos, old_anchor = cur.position(), cur.anchor()
|
|
984
|
+
v = ed.verticalScrollBar().value()
|
|
985
|
+
h = ed.horizontalScrollBar().value()
|
|
986
|
+
|
|
987
|
+
# Only touch the doc if it actually changed
|
|
988
|
+
ed.blockSignals(True)
|
|
989
|
+
if ed.toHtml() != html:
|
|
990
|
+
ed.setHtml(html)
|
|
991
|
+
ed.blockSignals(False)
|
|
992
|
+
|
|
993
|
+
# Restore scroll first
|
|
994
|
+
ed.verticalScrollBar().setValue(v)
|
|
995
|
+
ed.horizontalScrollBar().setValue(h)
|
|
996
|
+
|
|
997
|
+
# Restore caret/selection
|
|
998
|
+
cur = ed.textCursor()
|
|
999
|
+
cur.setPosition(old_anchor)
|
|
1000
|
+
mode = (
|
|
1001
|
+
QTextCursor.KeepAnchor if old_anchor != old_pos else QTextCursor.MoveAnchor
|
|
1002
|
+
)
|
|
1003
|
+
cur.setPosition(old_pos, mode)
|
|
1004
|
+
ed.setTextCursor(cur)
|
|
1005
|
+
|
|
1006
|
+
# Refresh highlights if the theme changed
|
|
1007
|
+
if hasattr(self, "findBar"):
|
|
1008
|
+
self.findBar.refresh()
|
bouquin/save_dialog.py
CHANGED
|
@@ -18,6 +18,9 @@ class SaveDialog(QDialog):
|
|
|
18
18
|
title: str = "Enter a name for this version",
|
|
19
19
|
message: str = "Enter a name for this version?",
|
|
20
20
|
):
|
|
21
|
+
"""
|
|
22
|
+
Used for explicitly saving a new version of a page.
|
|
23
|
+
"""
|
|
21
24
|
super().__init__(parent)
|
|
22
25
|
self.setWindowTitle(title)
|
|
23
26
|
v = QVBoxLayout(self)
|
bouquin/search.py
CHANGED
bouquin/settings_dialog.py
CHANGED
bouquin/theme.py
CHANGED
|
@@ -49,7 +49,7 @@ class ThemeManager(QObject):
|
|
|
49
49
|
scheme = getattr(hints, "colorScheme", None)
|
|
50
50
|
if callable(scheme):
|
|
51
51
|
scheme = hints.colorScheme()
|
|
52
|
-
# 0=Light, 1=Dark
|
|
52
|
+
# 0=Light, 1=Dark; fall back to Light
|
|
53
53
|
theme = Theme.DARK if scheme == 1 else Theme.LIGHT
|
|
54
54
|
|
|
55
55
|
# Always use Fusion so palette applies consistently cross-platform
|
|
@@ -58,7 +58,6 @@ class ThemeManager(QObject):
|
|
|
58
58
|
if theme == Theme.DARK:
|
|
59
59
|
pal = self._dark_palette()
|
|
60
60
|
self._app.setPalette(pal)
|
|
61
|
-
# keep stylesheet empty unless you need widget-specific tweaks
|
|
62
61
|
self._app.setStyleSheet("")
|
|
63
62
|
else:
|
|
64
63
|
pal = self._light_palette()
|
bouquin/toolbar.py
CHANGED
|
@@ -140,6 +140,11 @@ class ToolBar(QToolBar):
|
|
|
140
140
|
for a in (self.actAlignL, self.actAlignC, self.actAlignR):
|
|
141
141
|
a.setActionGroup(self.grpAlign)
|
|
142
142
|
|
|
143
|
+
self.grpLists = QActionGroup(self)
|
|
144
|
+
self.grpLists.setExclusive(True)
|
|
145
|
+
for a in (self.actBullets, self.actNumbers, self.actCheckboxes):
|
|
146
|
+
a.setActionGroup(self.grpLists)
|
|
147
|
+
|
|
143
148
|
# Add actions
|
|
144
149
|
self.addActions(
|
|
145
150
|
[
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: bouquin
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.12
|
|
4
4
|
Summary: Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
|
|
5
5
|
Home-page: https://git.mig5.net/mig5/bouquin
|
|
6
6
|
License: GPL-3.0-or-later
|
|
@@ -38,6 +38,8 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
38
38
|
|
|
39
39
|

|
|
40
40
|
|
|
41
|
+

|
|
42
|
+
|
|
41
43
|
## Features
|
|
42
44
|
|
|
43
45
|
* Data is encrypted at rest
|
|
@@ -51,8 +53,11 @@ There is deliberately no network connectivity or syncing intended.
|
|
|
51
53
|
* Transparent integrity checking of the database when it opens
|
|
52
54
|
* Automatic locking of the app after a period of inactivity (default 15 min)
|
|
53
55
|
* Rekey the database (change the password)
|
|
54
|
-
* Export the database to json, txt, html, csv or .sql (for sqlite3)
|
|
56
|
+
* Export the database to json, txt, html, csv, markdown or .sql (for sqlite3)
|
|
55
57
|
* Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
|
|
58
|
+
* Dark and light themes
|
|
59
|
+
* Automatically generate checkboxes when typing 'TODO'
|
|
60
|
+
* Optionally automatically move unchecked checkboxes from yesterday to today, on startup
|
|
56
61
|
|
|
57
62
|
|
|
58
63
|
## How to install
|
|
@@ -79,5 +84,5 @@ Make sure you have `libxcb-cursor0` installed (it may be called something else o
|
|
|
79
84
|
* Clone the repo
|
|
80
85
|
* Ensure you have poetry installed
|
|
81
86
|
* Run `poetry install --with test`
|
|
82
|
-
* Run `
|
|
87
|
+
* Run `./tests.sh`
|
|
83
88
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
bouquin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
bouquin/__main__.py,sha256=Vdhw8YA1K3wPMlbJQYL5WqvRzAKVeZ16mZQFO9VRmCo,62
|
|
3
|
+
bouquin/db.py,sha256=b39sofZZe4Uw54quYgjcWeF6jNFe0xekBT7fpIP5ySs,16910
|
|
4
|
+
bouquin/editor.py,sha256=62p6yoXsqCdlKiwgatjnD606lpsPbynlcnifT03gHW0,36529
|
|
5
|
+
bouquin/find_bar.py,sha256=RwBbQzfgBXDefpACQoheHH1XJNH-w08Snhk1_o2OmD4,5783
|
|
6
|
+
bouquin/history_dialog.py,sha256=PcbKxR-xXxPryydWK5MRMmOC0Zc8SUcoPSkktMdFuFo,6204
|
|
7
|
+
bouquin/key_prompt.py,sha256=oQhLDOQv1QUr_ImA9Zu78JkDpVqPbZZJdhu0c_5Cq5U,1266
|
|
8
|
+
bouquin/lock_overlay.py,sha256=d1xoBMx2CSNk0zP5V6k65UqJCC4aiIrwNlfDld49ymA,4197
|
|
9
|
+
bouquin/main.py,sha256=lBOMS7THgHb4CAJVj8NRYABtNAEez9jlL0wI1oOtfT4,611
|
|
10
|
+
bouquin/main_window.py,sha256=xJzArxoFBpGshzB0Lgg570YhDSVtCbfnEmm_wr3EFwc,37703
|
|
11
|
+
bouquin/save_dialog.py,sha256=YUkZ8kL1hja15D8qv68yY2zPyjBAJZsDQbp6Y6EvDbA,1023
|
|
12
|
+
bouquin/search.py,sha256=qOPxW8Q8bb6Y0Yf0D0w5wBbPw4wVLvvwrymTeJWEWJc,7027
|
|
13
|
+
bouquin/settings.py,sha256=F3WLkk2G_By3ppZsRbrnq3PtL2Zav7aA-mIegvGTc8Y,1128
|
|
14
|
+
bouquin/settings_dialog.py,sha256=YQHYjn3y2sgJGtkkApADxAodojDfLvnZYeQmynXLkos,10699
|
|
15
|
+
bouquin/theme.py,sha256=6ODq9oKLAk7lnvW9uGRMIIjfhf70SgPivLYh3ZN671M,3489
|
|
16
|
+
bouquin/toolbar.py,sha256=ejjFLZGa5UFldvI01ragkQtA7fTMwYPA3TjRfSZT06A,8301
|
|
17
|
+
bouquin-0.1.12.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
18
|
+
bouquin-0.1.12.dist-info/METADATA,sha256=MyNgd331GomD5IzWWY8oVWbU1Ez08bPSq6XGD4CU6Hw,3064
|
|
19
|
+
bouquin-0.1.12.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
20
|
+
bouquin-0.1.12.dist-info/entry_points.txt,sha256=d2C5Mc85suj1vWg_mmcfFuEBAYEkdwhZquusme5EWuQ,49
|
|
21
|
+
bouquin-0.1.12.dist-info/RECORD,,
|
bouquin-0.1.10.dist-info/RECORD
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
bouquin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
bouquin/__main__.py,sha256=Vdhw8YA1K3wPMlbJQYL5WqvRzAKVeZ16mZQFO9VRmCo,62
|
|
3
|
-
bouquin/db.py,sha256=Ukh37u397QS_BuH-BJOJUF_F9Q9t3iCOBIf7O4Tqims,17699
|
|
4
|
-
bouquin/editor.py,sha256=IkczFheXi5QMUGWqFpdRD1VQxzKRWAusUZz_sP29vV8,32301
|
|
5
|
-
bouquin/history_dialog.py,sha256=Z3BO60HD-vO9KQ84s0ccpzVWs0X898GzzHfjqARQPqg,6175
|
|
6
|
-
bouquin/key_prompt.py,sha256=N5UxgDDnVAaoAIs9AqoydPSRjJ4Likda4-ejlE-lr-Y,1076
|
|
7
|
-
bouquin/lock_overlay.py,sha256=8Q9NG8ejaGRZ6Tmc5uB6532JWgb3lLO5TQj718CraPA,4052
|
|
8
|
-
bouquin/main.py,sha256=lBOMS7THgHb4CAJVj8NRYABtNAEez9jlL0wI1oOtfT4,611
|
|
9
|
-
bouquin/main_window.py,sha256=am6EksRbExZQjOlyFMK7-TEwf5w2NCtgFhMVGbWXXvw,33853
|
|
10
|
-
bouquin/save_dialog.py,sha256=nPLNWeImJZamNg53qE7_aeMK_p16aOiry0G4VvJsIWY,939
|
|
11
|
-
bouquin/search.py,sha256=6ygbXck21iwA3RUM6yLIuxUr7AsLI4UYOc7H30XwsZw,7099
|
|
12
|
-
bouquin/settings.py,sha256=F3WLkk2G_By3ppZsRbrnq3PtL2Zav7aA-mIegvGTc8Y,1128
|
|
13
|
-
bouquin/settings_dialog.py,sha256=apqCjrvKsZWZrMxDTF0EK-6Dehw6tvUgXdVzmkMxsM0,10701
|
|
14
|
-
bouquin/theme.py,sha256=rjiAJCjoJbKrsDJmbTPxWFLv_WxzPfWJB08bj5cNW7I,3576
|
|
15
|
-
bouquin/toolbar.py,sha256=3FH-hNdOD64C6v78IdFTf4nAdtEdOWnqhMrs4ZYdEow,8099
|
|
16
|
-
bouquin-0.1.10.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
17
|
-
bouquin-0.1.10.dist-info/METADATA,sha256=3C8lmSuzl006oXC7oaCN5_7R0tAu0y5UdbKV3h5aDHo,2848
|
|
18
|
-
bouquin-0.1.10.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
19
|
-
bouquin-0.1.10.dist-info/entry_points.txt,sha256=d2C5Mc85suj1vWg_mmcfFuEBAYEkdwhZquusme5EWuQ,49
|
|
20
|
-
bouquin-0.1.10.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|