bouquin 0.1.9__tar.gz → 0.2.0.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bouquin
3
- Version: 0.1.9
3
+ Version: 0.2.0.1
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
@@ -23,7 +23,7 @@ Description-Content-Type: text/markdown
23
23
 
24
24
  ## Introduction
25
25
 
26
- Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
26
+ Bouquin ("Book-ahn") is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
27
27
 
28
28
  It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a drop-in replacement
29
29
  for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
@@ -37,19 +37,26 @@ There is deliberately no network connectivity or syncing intended.
37
37
 
38
38
  ![Screenshot of Bouquin](./screenshot.png)
39
39
 
40
+ ![Screenshot of Bouquin in dark mode](./screenshot_dark.png)
41
+
40
42
  ## Features
41
43
 
42
44
  * Data is encrypted at rest
43
45
  * Encryption key is prompted for and never stored, unless user chooses to via Settings
44
46
  * Every 'page' is linked to the calendar day
45
47
  * All changes are version controlled, with ability to view/diff versions and revert
46
- * Text is HTML with basic styling
48
+ * Text is Markdown with basic styling
49
+ * Images are supported
47
50
  * Search
48
51
  * Automatic periodic saving (or explicitly save)
49
52
  * Transparent integrity checking of the database when it opens
50
53
  * Automatic locking of the app after a period of inactivity (default 15 min)
51
54
  * Rekey the database (change the password)
52
- * Export the database to json, txt, html or csv
55
+ * Export the database to json, txt, html, csv, markdown or .sql (for sqlite3)
56
+ * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
57
+ * Dark and light themes
58
+ * Automatically generate checkboxes when typing 'TODO'
59
+ * Optionally automatically move unchecked checkboxes from yesterday to today, on startup
53
60
 
54
61
 
55
62
  ## How to install
@@ -76,5 +83,5 @@ Make sure you have `libxcb-cursor0` installed (it may be called something else o
76
83
  * Clone the repo
77
84
  * Ensure you have poetry installed
78
85
  * Run `poetry install --with test`
79
- * Run `poetry run pytest -vvvv --cov=bouquin`
86
+ * Run `./tests.sh`
80
87
 
@@ -3,7 +3,7 @@
3
3
 
4
4
  ## Introduction
5
5
 
6
- Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
6
+ Bouquin ("Book-ahn") is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
7
7
 
8
8
  It uses [SQLCipher bindings](https://pypi.org/project/sqlcipher3-wheels) as a drop-in replacement
9
9
  for SQLite3. This means that the underlying database for the notebook is encrypted at rest.
@@ -17,19 +17,26 @@ There is deliberately no network connectivity or syncing intended.
17
17
 
18
18
  ![Screenshot of Bouquin](./screenshot.png)
19
19
 
20
+ ![Screenshot of Bouquin in dark mode](./screenshot_dark.png)
21
+
20
22
  ## Features
21
23
 
22
24
  * Data is encrypted at rest
23
25
  * Encryption key is prompted for and never stored, unless user chooses to via Settings
24
26
  * Every 'page' is linked to the calendar day
25
27
  * All changes are version controlled, with ability to view/diff versions and revert
26
- * Text is HTML with basic styling
28
+ * Text is Markdown with basic styling
29
+ * Images are supported
27
30
  * Search
28
31
  * Automatic periodic saving (or explicitly save)
29
32
  * Transparent integrity checking of the database when it opens
30
33
  * Automatic locking of the app after a period of inactivity (default 15 min)
31
34
  * Rekey the database (change the password)
32
- * Export the database to json, txt, html or csv
35
+ * Export the database to json, txt, html, csv, markdown or .sql (for sqlite3)
36
+ * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
37
+ * Dark and light themes
38
+ * Automatically generate checkboxes when typing 'TODO'
39
+ * Optionally automatically move unchecked checkboxes from yesterday to today, on startup
33
40
 
34
41
 
35
42
  ## How to install
@@ -56,4 +63,4 @@ Make sure you have `libxcb-cursor0` installed (it may be called something else o
56
63
  * Clone the repo
57
64
  * Ensure you have poetry installed
58
65
  * Run `poetry install --with test`
59
- * Run `poetry run pytest -vvvv --cov=bouquin`
66
+ * Run `./tests.sh`
File without changes
@@ -18,6 +18,8 @@ class DBConfig:
18
18
  path: Path
19
19
  key: str
20
20
  idle_minutes: int = 15 # 0 = never lock
21
+ theme: str = "system"
22
+ move_todos: bool = False
21
23
 
22
24
 
23
25
  class DBManager:
@@ -159,13 +161,6 @@ class DBManager:
159
161
  ).fetchone()
160
162
  return row[0] if row else ""
161
163
 
162
- def upsert_entry(self, date_iso: str, content: str) -> None:
163
- """
164
- Insert or update an entry.
165
- """
166
- # Make a new version and set it as current
167
- self.save_new_version(date_iso, content, note=None, set_current=True)
168
-
169
164
  def search_entries(self, text: str) -> list[str]:
170
165
  """
171
166
  Search for entries by term. This only works against the latest
@@ -261,68 +256,31 @@ class DBManager:
261
256
  ).fetchall()
262
257
  return [dict(r) for r in rows]
263
258
 
264
- def get_version(
265
- self,
266
- *,
267
- date_iso: str | None = None,
268
- version_no: int | None = None,
269
- version_id: int | None = None,
270
- ) -> dict | None:
259
+ def get_version(self, *, version_id: int) -> dict | None:
271
260
  """
272
- Fetch a specific version by (date, version_no) OR by version_id.
261
+ Fetch a specific version by version_id.
273
262
  Returns a dict with keys: id, date, version_no, created_at, note, content.
274
263
  """
275
264
  cur = self.conn.cursor()
276
- if version_id is not None:
277
- row = cur.execute(
278
- "SELECT id, date, version_no, created_at, note, content "
279
- "FROM versions WHERE id=?;",
280
- (version_id,),
281
- ).fetchone()
282
- else:
283
- if date_iso is None or version_no is None:
284
- raise ValueError(
285
- "Provide either version_id OR (date_iso and version_no)"
286
- )
287
- row = cur.execute(
288
- "SELECT id, date, version_no, created_at, note, content "
289
- "FROM versions WHERE date=? AND version_no=?;",
290
- (date_iso, version_no),
291
- ).fetchone()
265
+ row = cur.execute(
266
+ "SELECT id, date, version_no, created_at, note, content "
267
+ "FROM versions WHERE id=?;",
268
+ (version_id,),
269
+ ).fetchone()
292
270
  return dict(row) if row else None
293
271
 
294
- def revert_to_version(
295
- self,
296
- date_iso: str,
297
- *,
298
- version_no: int | None = None,
299
- version_id: int | None = None,
300
- ) -> None:
272
+ def revert_to_version(self, date_iso: str, version_id: int) -> None:
301
273
  """
302
274
  Point the page head (pages.current_version_id) to an existing version.
303
- Fast revert: no content is rewritten.
304
275
  """
305
- if self.conn is None:
306
- raise RuntimeError("Database is not connected")
307
276
  cur = self.conn.cursor()
308
277
 
309
- if version_id is None:
310
- if version_no is None:
311
- raise ValueError("Provide version_no or version_id")
312
- row = cur.execute(
313
- "SELECT id FROM versions WHERE date=? AND version_no=?;",
314
- (date_iso, version_no),
315
- ).fetchone()
316
- if row is None:
317
- raise ValueError("Version not found for this date")
318
- version_id = int(row["id"])
319
- else:
320
- # Ensure that version_id belongs to the given date
321
- row = cur.execute(
322
- "SELECT date FROM versions WHERE id=?;", (version_id,)
323
- ).fetchone()
324
- if row is None or row["date"] != date_iso:
325
- raise ValueError("version_id does not belong to the given date")
278
+ # Ensure that version_id belongs to the given date
279
+ row = cur.execute(
280
+ "SELECT date FROM versions WHERE id=?;", (version_id,)
281
+ ).fetchone()
282
+ if row is None or row["date"] != date_iso:
283
+ raise ValueError("version_id does not belong to the given date")
326
284
 
327
285
  with self.conn:
328
286
  cur.execute(
@@ -346,20 +304,18 @@ class DBManager:
346
304
  ).fetchall()
347
305
  return [(r[0], r[1]) for r in rows]
348
306
 
349
- def export_json(
350
- self, entries: Sequence[Entry], file_path: str, pretty: bool = True
351
- ) -> None:
307
+ def export_json(self, entries: Sequence[Entry], file_path: str) -> None:
352
308
  """
353
309
  Export to json.
354
310
  """
355
311
  data = [{"date": d, "content": c} for d, c in entries]
356
312
  with open(file_path, "w", encoding="utf-8") as f:
357
- if pretty:
358
- json.dump(data, f, ensure_ascii=False, indent=2)
359
- else:
360
- json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
313
+ json.dump(data, f, ensure_ascii=False, indent=2)
361
314
 
362
315
  def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
316
+ """
317
+ Export pages to CSV.
318
+ """
363
319
  # utf-8-sig adds a BOM so Excel opens as UTF-8 by default.
364
320
  with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
365
321
  writer = csv.writer(f)
@@ -373,6 +329,10 @@ class DBManager:
373
329
  separator: str = "\n\n— — — — —\n\n",
374
330
  strip_html: bool = True,
375
331
  ) -> None:
332
+ """
333
+ Strip the HTML from the latest version of the pages
334
+ and save to a text file.
335
+ """
376
336
  import re, html as _html
377
337
 
378
338
  # Precompiled patterns
@@ -411,6 +371,9 @@ class DBManager:
411
371
  def export_html(
412
372
  self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
413
373
  ) -> None:
374
+ """
375
+ Export to HTML with a heading.
376
+ """
414
377
  parts = [
415
378
  "<!doctype html>",
416
379
  '<html lang="en">',
@@ -430,6 +393,21 @@ class DBManager:
430
393
  with open(file_path, "w", encoding="utf-8") as f:
431
394
  f.write("\n".join(parts))
432
395
 
396
+ def export_markdown(
397
+ self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
398
+ ) -> None:
399
+ """
400
+ Export to HTML, similar to export_html, but then convert to Markdown
401
+ using markdownify, and finally save to file.
402
+ """
403
+ parts = []
404
+ for d, c in entries:
405
+ parts.append(f"# {d}")
406
+ parts.append(c)
407
+
408
+ with open(file_path, "w", encoding="utf-8") as f:
409
+ f.write("\n".join(parts))
410
+
433
411
  def export_sql(self, file_path: str) -> None:
434
412
  """
435
413
  Exports the encrypted database as plaintext SQL.
@@ -450,6 +428,10 @@ class DBManager:
450
428
  cur.execute("DETACH DATABASE backup")
451
429
 
452
430
  def export_by_extension(self, file_path: str) -> None:
431
+ """
432
+ Fallback catch-all that runs one of the above functions based on
433
+ the extension of the file name that was chosen by the user.
434
+ """
453
435
  entries = self.get_all_entries()
454
436
  ext = os.path.splitext(file_path)[1].lower()
455
437
 
@@ -461,6 +443,10 @@ class DBManager:
461
443
  self.export_txt(entries, file_path)
462
444
  elif ext in {".html", ".htm"}:
463
445
  self.export_html(entries, file_path)
446
+ elif ext in {".sql", ".sqlite"}:
447
+ self.export_sql(file_path)
448
+ elif ext == ".md":
449
+ self.export_markdown(entries, file_path)
464
450
  else:
465
451
  raise ValueError(f"Unsupported extension: {ext}")
466
452
 
@@ -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([])
@@ -16,29 +16,33 @@ from PySide6.QtWidgets import (
16
16
  )
17
17
 
18
18
 
19
- def _html_to_text(s: str) -> str:
20
- """Lightweight HTML→text for diff (keeps paragraphs/line breaks)."""
21
- STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
22
- COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
23
- BR_RE = re.compile(r"(?i)<br\s*/?>")
24
- BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\s*>")
25
- TAG_RE = re.compile(r"<[^>]+>")
26
- MULTINL_RE = re.compile(r"\n{3,}")
27
-
28
- s = STYLE_SCRIPT_RE.sub("", s)
29
- s = COMMENT_RE.sub("", s)
30
- s = BR_RE.sub("\n", s)
31
- s = BLOCK_END_RE.sub("\n", s)
32
- s = TAG_RE.sub("", s)
33
- s = _html.unescape(s)
34
- s = MULTINL_RE.sub("\n\n", s)
19
+ def _markdown_to_text(s: str) -> str:
20
+ """Convert markdown to plain text for diff comparison."""
21
+ # Remove images
22
+ s = re.sub(r"!\[.*?\]\(.*?\)", "[ Image ]", s)
23
+ # Remove inline code formatting
24
+ s = re.sub(r"`([^`]+)`", r"\1", s)
25
+ # Remove bold/italic markers
26
+ s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s)
27
+ s = re.sub(r"__([^_]+)__", r"\1", s)
28
+ s = re.sub(r"\*([^*]+)\*", r"\1", s)
29
+ s = re.sub(r"_([^_]+)_", r"\1", s)
30
+ # Remove strikethrough
31
+ s = re.sub(r"~~([^~]+)~~", r"\1", s)
32
+ # Remove heading markers
33
+ s = re.sub(r"^#{1,6}\s+", "", s, flags=re.MULTILINE)
34
+ # Remove list markers
35
+ s = re.sub(r"^\s*[-*+]\s+", "", s, flags=re.MULTILINE)
36
+ s = re.sub(r"^\s*\d+\.\s+", "", s, flags=re.MULTILINE)
37
+ # Remove checkbox markers
38
+ s = re.sub(r"^\s*-\s*\[[x ☐☑]\]\s+", "", s, flags=re.MULTILINE)
35
39
  return s.strip()
36
40
 
37
41
 
38
- def _colored_unified_diff_html(old_html: str, new_html: str) -> str:
42
+ def _colored_unified_diff_html(old_md: str, new_md: str) -> str:
39
43
  """Return HTML with colored unified diff (+ green, - red, context gray)."""
40
- a = _html_to_text(old_html).splitlines()
41
- b = _html_to_text(new_html).splitlines()
44
+ a = _markdown_to_text(old_md).splitlines()
45
+ b = _markdown_to_text(new_md).splitlines()
42
46
  ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="")
43
47
  lines = []
44
48
  for line in ud:
@@ -148,13 +152,17 @@ class HistoryDialog(QDialog):
148
152
  self.btn_revert.setEnabled(False)
149
153
  return
150
154
  sel_id = item.data(Qt.UserRole)
151
- # Preview selected as HTML
155
+ # Preview selected as plain text (markdown)
152
156
  sel = self._db.get_version(version_id=sel_id)
153
- self.preview.setHtml(sel["content"])
157
+ # Show markdown as plain text with monospace font for better readability
158
+ self.preview.setPlainText(sel["content"])
159
+ self.preview.setStyleSheet(
160
+ "font-family: Consolas, Menlo, Monaco, monospace; font-size: 13px;"
161
+ )
154
162
  # Diff vs current (textual diff)
155
163
  cur = self._db.get_version(version_id=self._current_id)
156
164
  self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
157
- # Enable revert only if selecting a non-current
165
+ # Enable revert only if selecting a non-current version
158
166
  self.btn_revert.setEnabled(sel_id != self._current_id)
159
167
 
160
168
  @Slot()
@@ -165,12 +173,10 @@ class HistoryDialog(QDialog):
165
173
  sel_id = item.data(Qt.UserRole)
166
174
  if sel_id == self._current_id:
167
175
  return
168
- sel = self._db.get_version(version_id=sel_id)
169
- vno = sel["version_no"]
170
- # Flip head pointer
176
+ # Flip head pointer to the older version
171
177
  try:
172
178
  self._db.revert_to_version(self._date, version_id=sel_id)
173
179
  except Exception as e:
174
180
  QMessageBox.critical(self, "Revert failed", str(e))
175
181
  return
176
- self.accept() # let the caller refresh the editor
182
+ self.accept()
@@ -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)