bouquin 0.1.5__py3-none-any.whl → 0.1.9__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.

Potentially problematic release.


This version of bouquin might be problematic. Click here for more details.

bouquin/history_dialog.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import difflib, re, html as _html
4
+ from datetime import datetime
4
5
  from PySide6.QtCore import Qt, Slot
5
6
  from PySide6.QtWidgets import (
6
7
  QDialog,
@@ -77,14 +78,14 @@ class HistoryDialog(QDialog):
77
78
  self.list.currentItemChanged.connect(self._on_select)
78
79
  top.addWidget(self.list, 1)
79
80
 
80
- # Right: tabs (Preview / Diff vs current)
81
+ # Right: tabs (Preview / Diff)
81
82
  self.tabs = QTabWidget()
82
83
  self.preview = QTextBrowser()
83
84
  self.preview.setOpenExternalLinks(True)
84
85
  self.diff = QTextBrowser()
85
86
  self.diff.setOpenExternalLinks(False)
86
87
  self.tabs.addTab(self.preview, "Preview")
87
- self.tabs.addTab(self.diff, "Diff vs current")
88
+ self.tabs.addTab(self.diff, "Diff")
88
89
  self.tabs.setMinimumSize(500, 650)
89
90
  top.addWidget(self.tabs, 2)
90
91
 
@@ -104,6 +105,14 @@ class HistoryDialog(QDialog):
104
105
  self._load_versions()
105
106
 
106
107
  # --- Data/UX helpers ---
108
+ def _fmt_local(self, iso_utc: str) -> str:
109
+ """
110
+ Convert UTC in the database to user's local tz
111
+ """
112
+ dt = datetime.fromisoformat(iso_utc.replace("Z", "+00:00"))
113
+ local = dt.astimezone()
114
+ return local.strftime("%Y-%m-%d %H:%M:%S %Z")
115
+
107
116
  def _load_versions(self):
108
117
  self._versions = self._db.list_versions(
109
118
  self._date
@@ -113,7 +122,7 @@ class HistoryDialog(QDialog):
113
122
  )
114
123
  self.list.clear()
115
124
  for v in self._versions:
116
- label = f"v{v['version_no']} — {v['created_at']}"
125
+ label = f"v{v['version_no']} — {self._fmt_local(v['created_at'])}"
117
126
  if v.get("note"):
118
127
  label += f" · {v['note']}"
119
128
  if v["is_current"]:
@@ -158,22 +167,10 @@ class HistoryDialog(QDialog):
158
167
  return
159
168
  sel = self._db.get_version(version_id=sel_id)
160
169
  vno = sel["version_no"]
161
- # Confirm
162
- if (
163
- QMessageBox.question(
164
- self,
165
- "Revert",
166
- f"Revert {self._date} to version v{vno}?\n\nYou can always change your mind later.",
167
- QMessageBox.Yes | QMessageBox.No,
168
- )
169
- != QMessageBox.Yes
170
- ):
171
- return
172
170
  # Flip head pointer
173
171
  try:
174
172
  self._db.revert_to_version(self._date, version_id=sel_id)
175
173
  except Exception as e:
176
174
  QMessageBox.critical(self, "Revert failed", str(e))
177
175
  return
178
- QMessageBox.information(self, "Reverted", f"{self._date} is now at v{vno}.")
179
176
  self.accept() # let the caller refresh the editor
bouquin/main_window.py CHANGED
@@ -1,10 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import datetime
3
4
  import os
4
5
  import sys
5
6
 
6
7
  from pathlib import Path
7
- from PySide6.QtCore import QDate, QTimer, Qt, QSettings, Slot, QUrl, QEvent
8
+ from PySide6.QtCore import (
9
+ QDate,
10
+ QTimer,
11
+ Qt,
12
+ QSettings,
13
+ Slot,
14
+ QUrl,
15
+ QEvent,
16
+ QSignalBlocker,
17
+ )
8
18
  from PySide6.QtGui import (
9
19
  QAction,
10
20
  QCursor,
@@ -12,6 +22,7 @@ from PySide6.QtGui import (
12
22
  QFont,
13
23
  QGuiApplication,
14
24
  QTextCharFormat,
25
+ QTextListFormat,
15
26
  )
16
27
  from PySide6.QtWidgets import (
17
28
  QCalendarWidget,
@@ -94,8 +105,8 @@ class _LockOverlay(QWidget):
94
105
 
95
106
 
96
107
  class MainWindow(QMainWindow):
97
- def __init__(self):
98
- super().__init__()
108
+ def __init__(self, *args, **kwargs):
109
+ super().__init__(*args, **kwargs)
99
110
  self.setWindowTitle(APP_NAME)
100
111
  self.setMinimumSize(1000, 650)
101
112
 
@@ -149,6 +160,10 @@ class MainWindow(QMainWindow):
149
160
  self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
150
161
  self.toolBar.alignRequested.connect(self.editor.setAlignment)
151
162
  self.toolBar.historyRequested.connect(self._open_history)
163
+ self.toolBar.insertImageRequested.connect(self._on_insert_image)
164
+
165
+ self.editor.currentCharFormatChanged.connect(lambda _f: self._sync_toolbar())
166
+ self.editor.cursorPositionChanged.connect(self._sync_toolbar)
152
167
 
153
168
  split = QSplitter()
154
169
  split.addWidget(left_panel)
@@ -201,6 +216,10 @@ class MainWindow(QMainWindow):
201
216
  act_export.setShortcut("Ctrl+E")
202
217
  act_export.triggered.connect(self._export)
203
218
  file_menu.addAction(act_export)
219
+ act_backup = QAction("&Backup", self)
220
+ act_backup.setShortcut("Ctrl+Shift+B")
221
+ act_backup.triggered.connect(self._backup)
222
+ file_menu.addAction(act_backup)
204
223
  file_menu.addSeparator()
205
224
  act_quit = QAction("&Quit", self)
206
225
  act_quit.setShortcut("Ctrl+Q")
@@ -210,21 +229,21 @@ class MainWindow(QMainWindow):
210
229
  # Navigate menu with next/previous/today
211
230
  nav_menu = mb.addMenu("&Navigate")
212
231
  act_prev = QAction("Previous Day", self)
213
- act_prev.setShortcut("Ctrl+P")
232
+ act_prev.setShortcut("Ctrl+Shift+P")
214
233
  act_prev.setShortcutContext(Qt.ApplicationShortcut)
215
234
  act_prev.triggered.connect(lambda: self._adjust_day(-1))
216
235
  nav_menu.addAction(act_prev)
217
236
  self.addAction(act_prev)
218
237
 
219
238
  act_next = QAction("Next Day", self)
220
- act_next.setShortcut("Ctrl+N")
239
+ act_next.setShortcut("Ctrl+Shift+N")
221
240
  act_next.setShortcutContext(Qt.ApplicationShortcut)
222
241
  act_next.triggered.connect(lambda: self._adjust_day(1))
223
242
  nav_menu.addAction(act_next)
224
243
  self.addAction(act_next)
225
244
 
226
245
  act_today = QAction("Today", self)
227
- act_today.setShortcut("Ctrl+T")
246
+ act_today.setShortcut("Ctrl+Shift+T")
228
247
  act_today.setShortcutContext(Qt.ApplicationShortcut)
229
248
  act_today.triggered.connect(self._adjust_today)
230
249
  nav_menu.addAction(act_today)
@@ -315,6 +334,61 @@ class MainWindow(QMainWindow):
315
334
  pass
316
335
 
317
336
  # --- UI handlers ---------------------------------------------------------
337
+
338
+ def _sync_toolbar(self):
339
+ fmt = self.editor.currentCharFormat()
340
+ c = self.editor.textCursor()
341
+ bf = c.blockFormat()
342
+
343
+ # Block signals so setChecked() doesn't re-trigger actions
344
+ blocker1 = QSignalBlocker(self.toolBar.actBold)
345
+ blocker2 = QSignalBlocker(self.toolBar.actItalic)
346
+ blocker3 = QSignalBlocker(self.toolBar.actUnderline)
347
+ blocker4 = QSignalBlocker(self.toolBar.actStrike)
348
+
349
+ self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold)
350
+ self.toolBar.actItalic.setChecked(fmt.fontItalic())
351
+ self.toolBar.actUnderline.setChecked(fmt.fontUnderline())
352
+ self.toolBar.actStrike.setChecked(fmt.fontStrikeOut())
353
+
354
+ # Headings: decide which to check by current point size
355
+ def _approx(a, b, eps=0.5): # small float tolerance
356
+ return abs(float(a) - float(b)) <= eps
357
+
358
+ cur_size = fmt.fontPointSize() or self.editor.font().pointSizeF()
359
+
360
+ bH1 = _approx(cur_size, 24)
361
+ bH2 = _approx(cur_size, 18)
362
+ bH3 = _approx(cur_size, 14)
363
+
364
+ b1 = QSignalBlocker(self.toolBar.actH1)
365
+ b2 = QSignalBlocker(self.toolBar.actH2)
366
+ b3 = QSignalBlocker(self.toolBar.actH3)
367
+ bN = QSignalBlocker(self.toolBar.actNormal)
368
+
369
+ self.toolBar.actH1.setChecked(bH1)
370
+ self.toolBar.actH2.setChecked(bH2)
371
+ self.toolBar.actH3.setChecked(bH3)
372
+ self.toolBar.actNormal.setChecked(not (bH1 or bH2 or bH3))
373
+
374
+ # Lists
375
+ lst = c.currentList()
376
+ bullets_on = lst and lst.format().style() == QTextListFormat.Style.ListDisc
377
+ numbers_on = lst and lst.format().style() == QTextListFormat.Style.ListDecimal
378
+ QSignalBlocker(self.toolBar.actBullets)
379
+ QSignalBlocker(self.toolBar.actNumbers)
380
+ self.toolBar.actBullets.setChecked(bool(bullets_on))
381
+ self.toolBar.actNumbers.setChecked(bool(numbers_on))
382
+
383
+ # Alignment
384
+ align = bf.alignment() & Qt.AlignHorizontal_Mask
385
+ QSignalBlocker(self.toolBar.actAlignL)
386
+ self.toolBar.actAlignL.setChecked(align == Qt.AlignLeft)
387
+ QSignalBlocker(self.toolBar.actAlignC)
388
+ self.toolBar.actAlignC.setChecked(align == Qt.AlignHCenter)
389
+ QSignalBlocker(self.toolBar.actAlignR)
390
+ self.toolBar.actAlignR.setChecked(align == Qt.AlignRight)
391
+
318
392
  def _current_date_iso(self) -> str:
319
393
  d = self.calendar.selectedDate()
320
394
  return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
@@ -373,7 +447,7 @@ class MainWindow(QMainWindow):
373
447
  """
374
448
  if not self._dirty and not explicit:
375
449
  return
376
- text = self.editor.toHtml()
450
+ text = self.editor.to_html_with_embedded_images()
377
451
  try:
378
452
  self.db.save_new_version(date_iso, text, note)
379
453
  except Exception as e:
@@ -416,6 +490,18 @@ class MainWindow(QMainWindow):
416
490
  self._load_selected_date(date_iso)
417
491
  self._refresh_calendar_marks()
418
492
 
493
+ def _on_insert_image(self):
494
+ # Let the user pick one or many images
495
+ paths, _ = QFileDialog.getOpenFileNames(
496
+ self,
497
+ "Insert image(s)",
498
+ "",
499
+ "Images (*.png *.jpg *.jpeg *.bmp *.gif *.webp)",
500
+ )
501
+ if not paths:
502
+ return
503
+ self.editor.insert_images(paths) # call into the editor
504
+
419
505
  # ----------- Settings handler ------------#
420
506
  def _open_settings(self):
421
507
  dlg = SettingsDialog(self.cfg, self.db, self)
@@ -484,13 +570,31 @@ class MainWindow(QMainWindow):
484
570
  # ----------------- Export handler ----------------- #
485
571
  @Slot()
486
572
  def _export(self):
487
- try:
488
- self.export_dialog()
489
- except Exception as e:
490
- QMessageBox.critical(self, "Export failed", str(e))
573
+ warning_title = "Unencrypted export"
574
+ warning_message = """
575
+ Exporting the database will be unencrypted!
576
+
577
+ Are you sure you want to continue?
578
+
579
+ If you want an encrypted backup, choose Backup instead of Export.
580
+ """
581
+ dlg = QMessageBox()
582
+ dlg.setWindowTitle(warning_title)
583
+ dlg.setText(warning_message)
584
+ dlg.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
585
+ dlg.setIcon(QMessageBox.Warning)
586
+ dlg.show()
587
+ dlg.adjustSize()
588
+ if dlg.exec() != QMessageBox.Yes:
589
+ return False
491
590
 
492
- def export_dialog(self) -> None:
493
- filters = "Text (*.txt);;" "JSON (*.json);;" "CSV (*.csv);;" "HTML (*.html);;"
591
+ filters = (
592
+ "Text (*.txt);;"
593
+ "JSON (*.json);;"
594
+ "CSV (*.csv);;"
595
+ "HTML (*.html);;"
596
+ "SQL (*.sql);;"
597
+ )
494
598
 
495
599
  start_dir = os.path.join(os.path.expanduser("~"), "Documents")
496
600
  filename, selected_filter = QFileDialog.getSaveFileName(
@@ -504,6 +608,7 @@ class MainWindow(QMainWindow):
504
608
  "JSON (*.json)": ".json",
505
609
  "CSV (*.csv)": ".csv",
506
610
  "HTML (*.html)": ".html",
611
+ "SQL (*.sql)": ".sql",
507
612
  }.get(selected_filter, ".txt")
508
613
 
509
614
  if not Path(filename).suffix:
@@ -518,14 +623,49 @@ class MainWindow(QMainWindow):
518
623
  elif selected_filter.startswith("CSV"):
519
624
  self.db.export_csv(entries, filename)
520
625
  elif selected_filter.startswith("HTML"):
521
- self.bd.export_html(entries, filename)
626
+ self.db.export_html(entries, filename)
627
+ elif selected_filter.startswith("SQL"):
628
+ self.db.export_sql(filename)
522
629
  else:
523
- self.bd.export_by_extension(entries, filename)
630
+ self.db.export_by_extension(filename)
524
631
 
525
632
  QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}")
526
633
  except Exception as e:
527
634
  QMessageBox.critical(self, "Export failed", str(e))
528
635
 
636
+ # ----------------- Backup handler ----------------- #
637
+ @Slot()
638
+ def _backup(self):
639
+ filters = "SQLCipher (*.db);;"
640
+
641
+ now = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
642
+ start_dir = os.path.join(
643
+ os.path.expanduser("~"), "Documents", f"bouquin_backup_{now}.db"
644
+ )
645
+ filename, selected_filter = QFileDialog.getSaveFileName(
646
+ self, "Backup encrypted notebook", start_dir, filters
647
+ )
648
+ if not filename:
649
+ return # user cancelled
650
+
651
+ default_ext = {
652
+ "SQLCipher (*.db)": ".db",
653
+ }.get(selected_filter, ".db")
654
+
655
+ if not Path(filename).suffix:
656
+ filename += default_ext
657
+
658
+ try:
659
+ if selected_filter.startswith("SQL"):
660
+ self.db.export_sqlcipher(filename)
661
+ QMessageBox.information(
662
+ self, "Backup complete", f"Saved to:\n{filename}"
663
+ )
664
+ except Exception as e:
665
+ QMessageBox.critical(self, "Backup failed", str(e))
666
+
667
+ # ----------------- Help handlers ----------------- #
668
+
529
669
  def _open_docs(self):
530
670
  url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help"
531
671
  url = QUrl.fromUserInput(url_str)
@@ -542,7 +682,7 @@ class MainWindow(QMainWindow):
542
682
  self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
543
683
  )
544
684
 
545
- # Idle handlers
685
+ # ----------------- Idle handlers ----------------- #
546
686
  def _apply_idle_minutes(self, minutes: int):
547
687
  minutes = max(0, int(minutes))
548
688
  if not hasattr(self, "_idle_timer"):
@@ -600,13 +740,14 @@ class MainWindow(QMainWindow):
600
740
  tb.setEnabled(True)
601
741
  self._idle_timer.start()
602
742
 
603
- # Close app handler - save window position and database
743
+ # ----------------- Close handlers ----------------- #
604
744
  def closeEvent(self, event):
605
745
  try:
606
746
  # Save window position
607
747
  self.settings.setValue("main/geometry", self.saveGeometry())
608
748
  self.settings.setValue("main/windowState", self.saveState())
609
749
  self.settings.setValue("main/maximized", self.isMaximized())
750
+
610
751
  # Ensure we save any last pending edits to the db
611
752
  self._save_current()
612
753
  self.db.close()
@@ -57,7 +57,7 @@ class SettingsDialog(QDialog):
57
57
  form.addRow("Database path", path_row)
58
58
 
59
59
  # Encryption settings
60
- enc_group = QGroupBox("Encryption and Privacy")
60
+ enc_group = QGroupBox("Encryption")
61
61
  enc = QVBoxLayout(enc_group)
62
62
  enc.setContentsMargins(12, 8, 12, 12)
63
63
  enc.setSpacing(6)
@@ -68,7 +68,7 @@ class SettingsDialog(QDialog):
68
68
  self.key = current_settings.key or ""
69
69
  self.save_key_btn.setChecked(bool(self.key))
70
70
  self.save_key_btn.setCursor(Qt.PointingHandCursor)
71
- self.save_key_btn.toggled.connect(self.save_key_btn_clicked)
71
+ self.save_key_btn.toggled.connect(self._save_key_btn_clicked)
72
72
  enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
73
73
 
74
74
  # Explanation for remembering key
@@ -94,11 +94,20 @@ class SettingsDialog(QDialog):
94
94
  enc.addWidget(line)
95
95
 
96
96
  # Change key button
97
- self.rekey_btn = QPushButton("Change key")
97
+ self.rekey_btn = QPushButton("Change encryption key")
98
98
  self.rekey_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
99
99
  self.rekey_btn.clicked.connect(self._change_key)
100
+
100
101
  enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
101
102
 
103
+ form.addRow(enc_group)
104
+
105
+ # Privacy settings
106
+ priv_group = QGroupBox("Lock screen when idle")
107
+ priv = QVBoxLayout(priv_group)
108
+ priv.setContentsMargins(12, 8, 12, 12)
109
+ priv.setSpacing(6)
110
+
102
111
  self.idle_spin = QSpinBox()
103
112
  self.idle_spin.setRange(0, 240)
104
113
  self.idle_spin.setSingleStep(1)
@@ -106,7 +115,7 @@ class SettingsDialog(QDialog):
106
115
  self.idle_spin.setSuffix(" min")
107
116
  self.idle_spin.setSpecialValueText("Never")
108
117
  self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15))
109
- enc.addWidget(self.idle_spin, 0, Qt.AlignLeft)
118
+ priv.addWidget(self.idle_spin, 0, Qt.AlignLeft)
110
119
  # Explanation for idle option (autolock)
111
120
  self.idle_spin_label = QLabel(
112
121
  "Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it. "
@@ -122,10 +131,39 @@ class SettingsDialog(QDialog):
122
131
  spin_row = QHBoxLayout()
123
132
  spin_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the spinbox
124
133
  spin_row.addWidget(self.idle_spin_label)
125
- enc.addLayout(spin_row)
134
+ priv.addLayout(spin_row)
126
135
 
127
- # Put the group into the form so it spans the full width nicely
128
- form.addRow(enc_group)
136
+ form.addRow(priv_group)
137
+
138
+ # Maintenance settings
139
+ maint_group = QGroupBox("Database maintenance")
140
+ maint = QVBoxLayout(maint_group)
141
+ maint.setContentsMargins(12, 8, 12, 12)
142
+ maint.setSpacing(6)
143
+
144
+ self.compact_btn = QPushButton("Compact database")
145
+ self.compact_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
146
+ self.compact_btn.clicked.connect(self._compact_btn_clicked)
147
+
148
+ maint.addWidget(self.compact_btn, 0, Qt.AlignLeft)
149
+
150
+ # Explanation for compating button
151
+ self.compact_label = QLabel(
152
+ "Compacting runs VACUUM on the database. This can help reduce its size."
153
+ )
154
+ self.compact_label.setWordWrap(True)
155
+ self.compact_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
156
+ # make it look secondary
157
+ cpal = self.compact_label.palette()
158
+ cpal.setColor(self.compact_label.foregroundRole(), cpal.color(QPalette.Mid))
159
+ self.compact_label.setPalette(cpal)
160
+
161
+ maint_row = QHBoxLayout()
162
+ maint_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the button
163
+ maint_row.addWidget(self.compact_label)
164
+ maint.addLayout(maint_row)
165
+
166
+ form.addRow(maint_group)
129
167
 
130
168
  # Buttons
131
169
  bb = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
@@ -174,6 +212,7 @@ class SettingsDialog(QDialog):
174
212
  QMessageBox.warning(self, "Empty key", "Key cannot be empty.")
175
213
  return
176
214
  try:
215
+ self.key = new_key
177
216
  self._db.rekey(new_key)
178
217
  QMessageBox.information(
179
218
  self, "Key changed", "The notebook was re-encrypted with the new key!"
@@ -182,7 +221,7 @@ class SettingsDialog(QDialog):
182
221
  QMessageBox.critical(self, "Error", f"Could not change key:\n{e}")
183
222
 
184
223
  @Slot(bool)
185
- def save_key_btn_clicked(self, checked: bool):
224
+ def _save_key_btn_clicked(self, checked: bool):
186
225
  if checked:
187
226
  if not self.key:
188
227
  p1 = KeyPrompt(
@@ -193,10 +232,20 @@ class SettingsDialog(QDialog):
193
232
  self.save_key_btn.setChecked(False)
194
233
  self.save_key_btn.blockSignals(False)
195
234
  return
196
- self.key = p1.key() or ""
235
+ self.key = p1.key() or ""
197
236
  else:
198
237
  self.key = ""
199
238
 
239
+ @Slot(bool)
240
+ def _compact_btn_clicked(self):
241
+ try:
242
+ self._db.compact()
243
+ QMessageBox.information(
244
+ self, "Compact complete", "Database compacted successfully!"
245
+ )
246
+ except Exception as e:
247
+ QMessageBox.critical(self, "Error", f"Could not compact database:\n{e}")
248
+
200
249
  @property
201
250
  def config(self) -> DBConfig:
202
251
  return self._cfg
bouquin/toolbar.py CHANGED
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from PySide6.QtCore import Signal, Qt
4
- from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase
4
+ from PySide6.QtGui import QAction, QKeySequence, QFont, QFontDatabase, QActionGroup
5
5
  from PySide6.QtWidgets import QToolBar
6
6
 
7
7
 
@@ -16,6 +16,7 @@ class ToolBar(QToolBar):
16
16
  numbersRequested = Signal()
17
17
  alignRequested = Signal(Qt.AlignmentFlag)
18
18
  historyRequested = Signal()
19
+ insertImageRequested = Signal()
19
20
 
20
21
  def __init__(self, parent=None):
21
22
  super().__init__("Format", parent)
@@ -25,54 +26,87 @@ class ToolBar(QToolBar):
25
26
  self._apply_toolbar_styles()
26
27
 
27
28
  def _build_actions(self):
28
- self.actBold = QAction("Bold", self)
29
+ self.actBold = QAction("B", self)
30
+ self.actBold.setToolTip("Bold")
31
+ self.actBold.setCheckable(True)
29
32
  self.actBold.setShortcut(QKeySequence.Bold)
30
33
  self.actBold.triggered.connect(self.boldRequested)
31
34
 
32
- self.actItalic = QAction("Italic", self)
35
+ self.actItalic = QAction("I", self)
36
+ self.actItalic.setToolTip("Italic")
37
+ self.actItalic.setCheckable(True)
33
38
  self.actItalic.setShortcut(QKeySequence.Italic)
34
39
  self.actItalic.triggered.connect(self.italicRequested)
35
40
 
36
- self.actUnderline = QAction("Underline", self)
41
+ self.actUnderline = QAction("U", self)
42
+ self.actUnderline.setToolTip("Underline")
43
+ self.actUnderline.setCheckable(True)
37
44
  self.actUnderline.setShortcut(QKeySequence.Underline)
38
45
  self.actUnderline.triggered.connect(self.underlineRequested)
39
46
 
40
- self.actStrike = QAction("Strikethrough", self)
47
+ self.actStrike = QAction("S", self)
48
+ self.actStrike.setToolTip("Strikethrough")
49
+ self.actStrike.setCheckable(True)
41
50
  self.actStrike.setShortcut("Ctrl+-")
42
51
  self.actStrike.triggered.connect(self.strikeRequested)
43
52
 
44
- self.actCode = QAction("Inline code", self)
53
+ self.actCode = QAction("</>", self)
54
+ self.actCode.setToolTip("Code block")
45
55
  self.actCode.setShortcut("Ctrl+`")
46
56
  self.actCode.triggered.connect(self.codeRequested)
47
57
 
48
58
  # Headings
49
- self.actH1 = QAction("Heading 1", self)
50
- self.actH2 = QAction("Heading 2", self)
51
- self.actH3 = QAction("Heading 3", self)
52
- self.actNormal = QAction("Normal text", self)
59
+ self.actH1 = QAction("H1", self)
60
+ self.actH1.setToolTip("Heading 1")
61
+ self.actH1.setCheckable(True)
53
62
  self.actH1.setShortcut("Ctrl+1")
54
- self.actH2.setShortcut("Ctrl+2")
55
- self.actH3.setShortcut("Ctrl+3")
56
- self.actNormal.setShortcut("Ctrl+N")
57
63
  self.actH1.triggered.connect(lambda: self.headingRequested.emit(24))
64
+ self.actH2 = QAction("H2", self)
65
+ self.actH2.setToolTip("Heading 2")
66
+ self.actH2.setCheckable(True)
67
+ self.actH2.setShortcut("Ctrl+2")
58
68
  self.actH2.triggered.connect(lambda: self.headingRequested.emit(18))
69
+ self.actH3 = QAction("H3", self)
70
+ self.actH3.setToolTip("Heading 3")
71
+ self.actH3.setCheckable(True)
72
+ self.actH3.setShortcut("Ctrl+3")
59
73
  self.actH3.triggered.connect(lambda: self.headingRequested.emit(14))
74
+ self.actNormal = QAction("N", self)
75
+ self.actNormal.setToolTip("Normal paragraph text")
76
+ self.actNormal.setCheckable(True)
77
+ self.actNormal.setShortcut("Ctrl+N")
60
78
  self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0))
61
79
 
62
80
  # Lists
63
- self.actBullets = QAction("Bulleted list", self)
81
+ self.actBullets = QAction("", self)
82
+ self.actBullets.setToolTip("Bulleted list")
83
+ self.actBullets.setCheckable(True)
64
84
  self.actBullets.triggered.connect(self.bulletsRequested)
65
- self.actNumbers = QAction("Numbered list", self)
85
+ self.actNumbers = QAction("1.", self)
86
+ self.actNumbers.setToolTip("Numbered list")
87
+ self.actNumbers.setCheckable(True)
66
88
  self.actNumbers.triggered.connect(self.numbersRequested)
67
89
 
90
+ # Images
91
+ self.actInsertImg = QAction("Image", self)
92
+ self.actInsertImg.setToolTip("Insert image")
93
+ self.actInsertImg.setShortcut("Ctrl+Shift+I")
94
+ self.actInsertImg.triggered.connect(self.insertImageRequested)
95
+
68
96
  # Alignment
69
- self.actAlignL = QAction("Align left", self)
70
- self.actAlignC = QAction("Align center", self)
71
- self.actAlignR = QAction("Align right", self)
97
+ self.actAlignL = QAction("L", self)
98
+ self.actAlignL.setToolTip("Align Left")
99
+ self.actAlignL.setCheckable(True)
72
100
  self.actAlignL.triggered.connect(lambda: self.alignRequested.emit(Qt.AlignLeft))
101
+ self.actAlignC = QAction("C", self)
102
+ self.actAlignC.setToolTip("Align Center")
103
+ self.actAlignC.setCheckable(True)
73
104
  self.actAlignC.triggered.connect(
74
105
  lambda: self.alignRequested.emit(Qt.AlignHCenter)
75
106
  )
107
+ self.actAlignR = QAction("R", self)
108
+ self.actAlignR.setToolTip("Align Right")
109
+ self.actAlignR.setCheckable(True)
76
110
  self.actAlignR.triggered.connect(
77
111
  lambda: self.alignRequested.emit(Qt.AlignRight)
78
112
  )
@@ -81,6 +115,28 @@ class ToolBar(QToolBar):
81
115
  self.actHistory = QAction("History", self)
82
116
  self.actHistory.triggered.connect(self.historyRequested)
83
117
 
118
+ # Set exclusive buttons in QActionGroups
119
+ self.grpHeadings = QActionGroup(self)
120
+ self.grpHeadings.setExclusive(True)
121
+ for a in (
122
+ self.actBold,
123
+ self.actItalic,
124
+ self.actUnderline,
125
+ self.actStrike,
126
+ self.actH1,
127
+ self.actH2,
128
+ self.actH3,
129
+ self.actNormal,
130
+ ):
131
+ a.setCheckable(True)
132
+ a.setActionGroup(self.grpHeadings)
133
+
134
+ self.grpAlign = QActionGroup(self)
135
+ self.grpAlign.setExclusive(True)
136
+ for a in (self.actAlignL, self.actAlignC, self.actAlignR):
137
+ a.setActionGroup(self.grpAlign)
138
+
139
+ # Add actions
84
140
  self.addActions(
85
141
  [
86
142
  self.actBold,
@@ -94,6 +150,7 @@ class ToolBar(QToolBar):
94
150
  self.actNormal,
95
151
  self.actBullets,
96
152
  self.actNumbers,
153
+ self.actInsertImg,
97
154
  self.actAlignL,
98
155
  self.actAlignC,
99
156
  self.actAlignR,
@@ -106,7 +163,6 @@ class ToolBar(QToolBar):
106
163
  self._style_letter_button(self.actItalic, "I", italic=True)
107
164
  self._style_letter_button(self.actUnderline, "U", underline=True)
108
165
  self._style_letter_button(self.actStrike, "S", strike=True)
109
-
110
166
  # Monospace look for code; use a fixed font
111
167
  code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
112
168
  self._style_letter_button(self.actCode, "</>", custom_font=code_font)
@@ -139,11 +195,13 @@ class ToolBar(QToolBar):
139
195
  underline: bool = False,
140
196
  strike: bool = False,
141
197
  custom_font: QFont | None = None,
198
+ tooltip: str | None = None,
142
199
  ):
143
200
  btn = self.widgetForAction(action)
144
201
  if not btn:
145
202
  return
146
203
  btn.setText(text)
204
+
147
205
  f = custom_font if custom_font is not None else QFont(btn.font())
148
206
  if custom_font is None:
149
207
  f.setBold(bold)
@@ -153,5 +211,6 @@ class ToolBar(QToolBar):
153
211
  btn.setFont(f)
154
212
 
155
213
  # Keep accessibility/tooltip readable
156
- btn.setToolTip(action.text())
157
- btn.setAccessibleName(action.text())
214
+ if tooltip:
215
+ btn.setToolTip(tooltip)
216
+ btn.setAccessibleName(tooltip)