bouquin 0.1.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bouquin/editor.py ADDED
@@ -0,0 +1,897 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import base64, re
5
+
6
+ from PySide6.QtGui import (
7
+ QColor,
8
+ QDesktopServices,
9
+ QFont,
10
+ QFontDatabase,
11
+ QImage,
12
+ QImageReader,
13
+ QPalette,
14
+ QPixmap,
15
+ QTextCharFormat,
16
+ QTextCursor,
17
+ QTextFrameFormat,
18
+ QTextListFormat,
19
+ QTextBlockFormat,
20
+ QTextImageFormat,
21
+ QTextDocument,
22
+ )
23
+ from PySide6.QtCore import (
24
+ Qt,
25
+ QUrl,
26
+ Signal,
27
+ Slot,
28
+ QRegularExpression,
29
+ QBuffer,
30
+ QByteArray,
31
+ QIODevice,
32
+ QTimer,
33
+ )
34
+ from PySide6.QtWidgets import QTextEdit, QApplication
35
+
36
+ from .theme import Theme, ThemeManager
37
+
38
+
39
+ class Editor(QTextEdit):
40
+ linkActivated = Signal(str)
41
+
42
+ _URL_RX = QRegularExpression(r'((?:https?://|www\.)[^\s<>"\'<>]+)')
43
+ _CODE_BG = QColor(245, 245, 245)
44
+ _CODE_FRAME_PROP = int(QTextFrameFormat.UserProperty) + 100 # marker for our frames
45
+ _HEADING_SIZES = (24.0, 18.0, 14.0)
46
+ _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
47
+ _DATA_IMG_RX = re.compile(r'src=["\']data:image/[^;]+;base64,([^"\']+)["\']', re.I)
48
+ # --- Checkbox hack --- #
49
+ _CHECK_UNCHECKED = "\u2610" # ☐
50
+ _CHECK_CHECKED = "\u2611" # ☑
51
+ _CHECK_RX = re.compile(r"^\s*([\u2610\u2611])\s") # ☐/☑ plus a space
52
+ _CHECKBOX_SCALE = 1.35
53
+
54
+ def __init__(self, theme_manager: ThemeManager, *args, **kwargs):
55
+ super().__init__(*args, **kwargs)
56
+ tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
57
+ self.setTabStopDistance(tab_w)
58
+
59
+ self.setTextInteractionFlags(
60
+ Qt.TextInteractionFlag.TextEditorInteraction
61
+ | Qt.TextInteractionFlag.LinksAccessibleByMouse
62
+ | Qt.TextInteractionFlag.LinksAccessibleByKeyboard
63
+ )
64
+
65
+ self.setAcceptRichText(True)
66
+
67
+ # If older docs have a baked-in color, normalize once:
68
+ self._retint_anchors_to_palette()
69
+
70
+ self._themes = theme_manager
71
+ # Refresh on theme change
72
+ self._themes.themeChanged.connect(self._on_theme_changed)
73
+
74
+ self._linkifying = False
75
+ self.textChanged.connect(self._linkify_document)
76
+ self.viewport().setMouseTracking(True)
77
+
78
+ def _approx(self, a: float, b: float, eps: float = 0.5) -> bool:
79
+ return abs(float(a) - float(b)) <= eps
80
+
81
+ def _is_heading_typing(self) -> bool:
82
+ """Is the current *insertion* format using a heading size?"""
83
+ bf = self.textCursor().blockFormat()
84
+ if bf.headingLevel() > 0:
85
+ return True
86
+
87
+ def _apply_normal_typing(self):
88
+ """Switch the *insertion* format to Normal (default size, normal weight)."""
89
+ nf = QTextCharFormat()
90
+ nf.setFontPointSize(self.font().pointSizeF())
91
+ nf.setFontWeight(QFont.Weight.Normal)
92
+ self.mergeCurrentCharFormat(nf)
93
+
94
+ def _find_code_frame(self, cursor=None):
95
+ """Return the nearest ancestor frame that's one of our code frames, else None."""
96
+ if cursor is None:
97
+ cursor = self.textCursor()
98
+ f = cursor.currentFrame()
99
+ while f:
100
+ if f.frameFormat().property(self._CODE_FRAME_PROP):
101
+ return f
102
+ f = f.parentFrame()
103
+ return None
104
+
105
+ def _trim_url_end(self, url: str) -> str:
106
+ # strip common trailing punctuation not part of the URL
107
+ trimmed = url.rstrip(".,;:!?\"'")
108
+ # drop an unmatched closing ) or ] at the very end
109
+ if trimmed.endswith(")") and trimmed.count("(") < trimmed.count(")"):
110
+ trimmed = trimmed[:-1]
111
+ if trimmed.endswith("]") and trimmed.count("[") < trimmed.count("]"):
112
+ trimmed = trimmed[:-1]
113
+ return trimmed
114
+
115
+ def _linkify_document(self):
116
+ if self._linkifying:
117
+ return
118
+ self._linkifying = True
119
+
120
+ try:
121
+ block = self.textCursor().block()
122
+ start_pos = block.position()
123
+ text = block.text()
124
+
125
+ cur = QTextCursor(self.document())
126
+ cur.beginEditBlock()
127
+
128
+ it = self._URL_RX.globalMatch(text)
129
+ while it.hasNext():
130
+ m = it.next()
131
+ s = start_pos + m.capturedStart()
132
+ raw = m.captured(0)
133
+ url = self._trim_url_end(raw)
134
+ if not url:
135
+ continue
136
+
137
+ e = s + len(url)
138
+ cur.setPosition(s)
139
+ cur.setPosition(e, QTextCursor.KeepAnchor)
140
+
141
+ if url.startswith("www."):
142
+ href = "https://" + url
143
+ else:
144
+ href = url
145
+
146
+ fmt = QTextCharFormat()
147
+ fmt.setAnchor(True)
148
+ fmt.setAnchorHref(href) # always refresh to the latest full URL
149
+ fmt.setFontUnderline(True)
150
+ fmt.setForeground(self.palette().brush(QPalette.Link))
151
+
152
+ cur.mergeCharFormat(fmt) # merge so we don’t clobber other styling
153
+
154
+ cur.endEditBlock()
155
+ finally:
156
+ self._linkifying = False
157
+
158
+ def _to_qimage(self, obj) -> QImage | None:
159
+ if isinstance(obj, QImage):
160
+ return None if obj.isNull() else obj
161
+ if isinstance(obj, QPixmap):
162
+ qi = obj.toImage()
163
+ return None if qi.isNull() else qi
164
+ if isinstance(obj, (bytes, bytearray)):
165
+ qi = QImage.fromData(obj)
166
+ return None if qi.isNull() else qi
167
+ return None
168
+
169
+ def _qimage_to_data_url(self, img: QImage, fmt: str = "PNG") -> str:
170
+ ba = QByteArray()
171
+ buf = QBuffer(ba)
172
+ buf.open(QIODevice.WriteOnly)
173
+ img.save(buf, fmt.upper())
174
+ b64 = base64.b64encode(bytes(ba)).decode("ascii")
175
+ mime = "image/png" if fmt.upper() == "PNG" else f"image/{fmt.lower()}"
176
+ return f"data:{mime};base64,{b64}"
177
+
178
+ def _image_name_to_qimage(self, name: str) -> QImage | None:
179
+ res = self.document().resource(QTextDocument.ImageResource, QUrl(name))
180
+ return res if isinstance(res, QImage) and not res.isNull() else None
181
+
182
+ def to_html_with_embedded_images(self) -> str:
183
+ """
184
+ Return the document HTML with all image src's replaced by data: URLs,
185
+ so it is self-contained for storage in the DB.
186
+ """
187
+ # 1) Walk the document collecting name -> data: URL
188
+ name_to_data = {}
189
+ cur = QTextCursor(self.document())
190
+ cur.movePosition(QTextCursor.Start)
191
+ while True:
192
+ cur.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
193
+ fmt = cur.charFormat()
194
+ if fmt.isImageFormat():
195
+ imgfmt = QTextImageFormat(fmt)
196
+ name = imgfmt.name()
197
+ if name and name not in name_to_data:
198
+ img = self._image_name_to_qimage(name)
199
+ if img:
200
+ name_to_data[name] = self._qimage_to_data_url(img, "PNG")
201
+ if cur.atEnd():
202
+ break
203
+ cur.clearSelection()
204
+
205
+ # 2) Serialize and replace names with data URLs
206
+ html = self.document().toHtml()
207
+ for old, data_url in name_to_data.items():
208
+ html = html.replace(f'src="{old}"', f'src="{data_url}"')
209
+ html = html.replace(f"src='{old}'", f"src='{data_url}'")
210
+ return html
211
+
212
+ def _insert_qimage_at_cursor(self, img: QImage, autoscale=True):
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)
231
+
232
+ c.insertImage(img)
233
+ c.insertBlock() # one blank line after the image
234
+
235
+ def _image_info_at_cursor(self):
236
+ """
237
+ Returns (cursorSelectingImageChar, QTextImageFormat, originalQImage) or (None, None, None)
238
+ """
239
+ # Try current position (select 1 char forward)
240
+ tc = QTextCursor(self.textCursor())
241
+ tc.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
242
+ fmt = tc.charFormat()
243
+ if fmt.isImageFormat():
244
+ imgfmt = QTextImageFormat(fmt)
245
+ img = self._resolve_image_resource(imgfmt)
246
+ return tc, imgfmt, img
247
+
248
+ # Try previous char (if caret is just after the image)
249
+ tc = QTextCursor(self.textCursor())
250
+ if tc.position() > 0:
251
+ tc.movePosition(QTextCursor.Left, QTextCursor.MoveAnchor, 1)
252
+ tc.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
253
+ fmt = tc.charFormat()
254
+ if fmt.isImageFormat():
255
+ imgfmt = QTextImageFormat(fmt)
256
+ img = self._resolve_image_resource(imgfmt)
257
+ return tc, imgfmt, img
258
+
259
+ return None, None, None
260
+
261
+ def _resolve_image_resource(self, imgfmt: QTextImageFormat) -> QImage | None:
262
+ """
263
+ Fetch the original QImage backing the inline image, if available.
264
+ """
265
+ name = imgfmt.name()
266
+ if name:
267
+ try:
268
+ img = self.document().resource(QTextDocument.ImageResource, QUrl(name))
269
+ if isinstance(img, QImage) and not img.isNull():
270
+ return img
271
+ except Exception:
272
+ pass
273
+ return None # fallback handled by callers
274
+
275
+ def _apply_image_size(
276
+ self,
277
+ tc: QTextCursor,
278
+ imgfmt: QTextImageFormat,
279
+ new_w: float,
280
+ orig_img: QImage | None,
281
+ ):
282
+ # compute height proportionally
283
+ if orig_img and orig_img.width() > 0:
284
+ ratio = new_w / orig_img.width()
285
+ new_h = max(1.0, orig_img.height() * ratio)
286
+ else:
287
+ # fallback: keep current aspect ratio if we have it
288
+ cur_w = imgfmt.width() if imgfmt.width() > 0 else new_w
289
+ cur_h = imgfmt.height() if imgfmt.height() > 0 else new_w
290
+ ratio = new_w / max(1.0, cur_w)
291
+ new_h = max(1.0, cur_h * ratio)
292
+
293
+ imgfmt.setWidth(max(1.0, new_w))
294
+ imgfmt.setHeight(max(1.0, new_h))
295
+ tc.mergeCharFormat(imgfmt)
296
+
297
+ def _scale_image_at_cursor(self, factor: float):
298
+ tc, imgfmt, orig = self._image_info_at_cursor()
299
+ if not imgfmt:
300
+ return
301
+ base_w = imgfmt.width()
302
+ if base_w <= 0 and orig:
303
+ base_w = orig.width()
304
+ if base_w <= 0:
305
+ return
306
+ self._apply_image_size(tc, imgfmt, base_w * factor, orig)
307
+
308
+ def _fit_image_to_editor_width(self):
309
+ tc, imgfmt, orig = self._image_info_at_cursor()
310
+ if not imgfmt:
311
+ return
312
+ if not self.viewport():
313
+ return
314
+ target = int(self.viewport().width() * 0.92)
315
+ self._apply_image_size(tc, imgfmt, target, orig)
316
+
317
+ def _set_image_width_dialog(self):
318
+ from PySide6.QtWidgets import QInputDialog
319
+
320
+ tc, imgfmt, orig = self._image_info_at_cursor()
321
+ if not imgfmt:
322
+ return
323
+ # propose current display width or original width
324
+ cur_w = (
325
+ int(imgfmt.width())
326
+ if imgfmt.width() > 0
327
+ else (orig.width() if orig else 400)
328
+ )
329
+ w, ok = QInputDialog.getInt(
330
+ self, "Set image width", "Width (px):", cur_w, 1, 10000, 10
331
+ )
332
+ if ok:
333
+ self._apply_image_size(tc, imgfmt, float(w), orig)
334
+
335
+ def _reset_image_size(self):
336
+ tc, imgfmt, orig = self._image_info_at_cursor()
337
+ if not imgfmt or not orig:
338
+ return
339
+ self._apply_image_size(tc, imgfmt, float(orig.width()), orig)
340
+
341
+ def contextMenuEvent(self, e):
342
+ menu = self.createStandardContextMenu()
343
+ tc, imgfmt, orig = self._image_info_at_cursor()
344
+ if imgfmt:
345
+ menu.addSeparator()
346
+ sub = menu.addMenu("Image size")
347
+ sub.addAction("Shrink 10%", lambda: self._scale_image_at_cursor(0.9))
348
+ sub.addAction("Grow 10%", lambda: self._scale_image_at_cursor(1.1))
349
+ sub.addAction("Fit to editor width", self._fit_image_to_editor_width)
350
+ sub.addAction("Set width…", self._set_image_width_dialog)
351
+ sub.addAction("Reset to original", self._reset_image_size)
352
+ menu.exec(e.globalPos())
353
+
354
+ def insertFromMimeData(self, source):
355
+ # 1) Direct image from clipboard
356
+ if source.hasImage():
357
+ img = self._to_qimage(source.imageData())
358
+ if img is not None:
359
+ self._insert_qimage_at_cursor(img, autoscale=True)
360
+ return
361
+
362
+ # 2) File URLs (drag/drop or paste)
363
+ if source.hasUrls():
364
+ paths = []
365
+ non_local_urls = []
366
+ for url in source.urls():
367
+ if url.isLocalFile():
368
+ path = url.toLocalFile()
369
+ if path.lower().endswith(self._IMAGE_EXTS):
370
+ paths.append(path)
371
+ else:
372
+ # Non-image file: insert as link
373
+ self.textCursor().insertHtml(
374
+ f'<a href="{url.toString()}">{Path(path).name}</a>'
375
+ )
376
+ self.textCursor().insertBlock()
377
+ else:
378
+ non_local_urls.append(url)
379
+
380
+ if paths:
381
+ self.insert_images(paths)
382
+
383
+ for url in non_local_urls:
384
+ self.textCursor().insertHtml(
385
+ f'<a href="{url.toString()}">{url.toString()}</a>'
386
+ )
387
+ self.textCursor().insertBlock()
388
+
389
+ if paths or non_local_urls:
390
+ return
391
+
392
+ # 3) HTML with data: image
393
+ if source.hasHtml():
394
+ html = source.html()
395
+ m = self._DATA_IMG_RX.search(html or "")
396
+ if m:
397
+ try:
398
+ data = base64.b64decode(m.group(1))
399
+ img = QImage.fromData(data)
400
+ if not img.isNull():
401
+ self._insert_qimage_at_cursor(self, img, autoscale=True)
402
+ return
403
+ except Exception:
404
+ pass # fall through
405
+
406
+ # 4) Everything else → default behavior
407
+ super().insertFromMimeData(source)
408
+
409
+ @Slot(list)
410
+ def insert_images(self, paths: list[str], autoscale=True):
411
+ """
412
+ Insert one or more images at the cursor. Large images can be auto-scaled
413
+ to fit the viewport width while preserving aspect ratio.
414
+ """
415
+ c = self.textCursor()
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()
428
+
429
+ for path in paths:
430
+ reader = QImageReader(path)
431
+ img = reader.read()
432
+ if img.isNull():
433
+ continue
434
+
435
+ if autoscale and self.viewport():
436
+ max_w = int(self.viewport().width() * 0.92) # ~92% of editor width
437
+ if img.width() > max_w:
438
+ img = img.scaledToWidth(max_w, Qt.SmoothTransformation)
439
+
440
+ c.insertImage(img)
441
+ c.insertBlock() # put each image on its own line
442
+
443
+ def mouseReleaseEvent(self, e):
444
+ if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier):
445
+ href = self.anchorAt(e.pos())
446
+ if href:
447
+ QDesktopServices.openUrl(QUrl.fromUserInput(href))
448
+ self.linkActivated.emit(href)
449
+ return
450
+ super().mouseReleaseEvent(e)
451
+
452
+ def mouseMoveEvent(self, e):
453
+ if (e.modifiers() & Qt.ControlModifier) and self.anchorAt(e.pos()):
454
+ self.viewport().setCursor(Qt.PointingHandCursor)
455
+ else:
456
+ self.viewport().setCursor(Qt.IBeamCursor)
457
+ super().mouseMoveEvent(e)
458
+
459
+ def mousePressEvent(self, e):
460
+ if e.button() == Qt.LeftButton and not (e.modifiers() & Qt.ControlModifier):
461
+ cur = self.cursorForPosition(e.pos())
462
+ b = cur.block()
463
+ state, pref = self._checkbox_info_for_block(b)
464
+ if state is not None:
465
+ col = cur.position() - b.position()
466
+ if col <= max(1, pref): # clicked on ☐/☑ (and the following space)
467
+ self._set_block_checkbox_state(b, not state)
468
+ return
469
+ return super().mousePressEvent(e)
470
+
471
+ def keyPressEvent(self, e):
472
+ key = e.key()
473
+
474
+ if key in (Qt.Key_Space, Qt.Key_Tab):
475
+ c = self.textCursor()
476
+ b = c.block()
477
+ pos_in_block = c.position() - b.position()
478
+
479
+ if (
480
+ pos_in_block >= 4
481
+ and b.text().startswith("TODO")
482
+ and b.text()[:pos_in_block] == "TODO"
483
+ and self._checkbox_info_for_block(b)[0] is None
484
+ ):
485
+ tcur = QTextCursor(self.document())
486
+ tcur.setPosition(b.position()) # start of block
487
+ tcur.setPosition(
488
+ b.position() + 4, QTextCursor.KeepAnchor
489
+ ) # select "TODO"
490
+ tcur.beginEditBlock()
491
+ tcur.removeSelectedText()
492
+ tcur.insertText(self._CHECK_UNCHECKED + " ") # insert "☐ "
493
+ tcur.endEditBlock()
494
+
495
+ # visuals: size bump
496
+ if hasattr(self, "_style_checkbox_glyph"):
497
+ self._style_checkbox_glyph(b)
498
+
499
+ # caret after the inserted prefix; swallow the key (we already added a space)
500
+ c.setPosition(b.position() + 2)
501
+ self.setTextCursor(c)
502
+ return
503
+
504
+ # not a TODO-at-start case
505
+ self._break_anchor_for_next_char()
506
+ return super().keyPressEvent(e)
507
+
508
+ if key in (Qt.Key_Return, Qt.Key_Enter):
509
+ c = self.textCursor()
510
+
511
+ # If we're on an empty line inside a code frame, consume Enter and jump out
512
+ if c.block().length() == 1:
513
+ frame = self._find_code_frame(c)
514
+ if frame:
515
+ out = QTextCursor(self.document())
516
+ out.setPosition(frame.lastPosition()) # after the frame's contents
517
+ self.setTextCursor(out)
518
+ super().insertPlainText("\n") # start a normal paragraph
519
+ return
520
+
521
+ # --- CHECKBOX handling: continue on Enter; "escape" on second Enter ---
522
+ b = c.block()
523
+ state, pref = self._checkbox_info_for_block(b)
524
+ if state is not None and not c.hasSelection():
525
+ text_after = b.text()[pref:].strip()
526
+ if c.atBlockEnd() and text_after == "":
527
+ # Empty checkbox item -> remove the prefix and insert a plain new line
528
+ cur = QTextCursor(self.document())
529
+ cur.setPosition(b.position())
530
+ cur.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, pref)
531
+ cur.removeSelectedText()
532
+ return super().keyPressEvent(e)
533
+ else:
534
+ # Normal continuation: new checkbox on the next line
535
+ super().keyPressEvent(e) # make the new block
536
+ super().insertPlainText(self._CHECK_UNCHECKED + " ")
537
+ if hasattr(self, "_style_checkbox_glyph"):
538
+ self._style_checkbox_glyph(self.textCursor().block())
539
+ return
540
+
541
+ # Follow-on style: if we typed a heading and press Enter at end of block,
542
+ # new paragraph should revert to Normal.
543
+ if not c.hasSelection() and c.atBlockEnd() and self._is_heading_typing():
544
+ super().keyPressEvent(e) # insert the new paragraph
545
+ self._apply_normal_typing() # make the *new* paragraph Normal for typing
546
+ return
547
+
548
+ # otherwise default handling
549
+ return super().keyPressEvent(e)
550
+
551
+ def _break_anchor_for_next_char(self):
552
+ """
553
+ Ensure the *next* typed character is not part of a hyperlink.
554
+ Only strips link-specific attributes; leaves bold/italic/underline etc intact.
555
+ """
556
+ # What we're about to type with
557
+ ins_fmt = self.currentCharFormat()
558
+ # What the cursor is sitting on
559
+ cur_fmt = self.textCursor().charFormat()
560
+
561
+ # Do nothing unless either side indicates we're in/propagating an anchor
562
+ if not (
563
+ ins_fmt.isAnchor()
564
+ or cur_fmt.isAnchor()
565
+ or ins_fmt.fontUnderline()
566
+ or ins_fmt.foreground().style() != Qt.NoBrush
567
+ ):
568
+ return
569
+
570
+ nf = QTextCharFormat(ins_fmt)
571
+ # stop the link itself
572
+ nf.setAnchor(False)
573
+ nf.setAnchorHref("")
574
+ # also stop the link *styling*
575
+ nf.setFontUnderline(False)
576
+ nf.clearForeground()
577
+
578
+ self.setCurrentCharFormat(nf)
579
+
580
+ def merge_on_sel(self, fmt):
581
+ """
582
+ Sets the styling on the selected characters or the insertion position.
583
+ """
584
+ cursor = self.textCursor()
585
+ if cursor.hasSelection():
586
+ cursor.mergeCharFormat(fmt)
587
+ self.mergeCurrentCharFormat(fmt)
588
+
589
+ # ====== Checkbox core ======
590
+ def _base_point_size_for_block(self, block) -> float:
591
+ # Try the block’s char format, then editor font
592
+ sz = block.charFormat().fontPointSize()
593
+ if sz <= 0:
594
+ sz = self.fontPointSize()
595
+ if sz <= 0:
596
+ sz = self.font().pointSizeF() or 12.0
597
+ return float(sz)
598
+
599
+ def _style_checkbox_glyph(self, block):
600
+ """Apply larger size (and optional symbol font) to the single ☐/☑ char."""
601
+ state, _ = self._checkbox_info_for_block(block)
602
+ if state is None:
603
+ return
604
+ doc = self.document()
605
+ c = QTextCursor(doc)
606
+ c.setPosition(block.position())
607
+ c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) # select ☐/☑ only
608
+
609
+ base = self._base_point_size_for_block(block)
610
+ fmt = QTextCharFormat()
611
+ fmt.setFontPointSize(base * self._CHECKBOX_SCALE)
612
+ # keep the glyph centered on the text baseline
613
+ fmt.setVerticalAlignment(QTextCharFormat.AlignMiddle)
614
+
615
+ c.mergeCharFormat(fmt)
616
+
617
+ def _checkbox_info_for_block(self, block):
618
+ """Return (state, prefix_len): state in {None, False, True}, prefix_len in chars."""
619
+ text = block.text()
620
+ m = self._CHECK_RX.match(text)
621
+ if not m:
622
+ return None, 0
623
+ ch = m.group(1)
624
+ state = True if ch == self._CHECK_CHECKED else False
625
+ return state, m.end()
626
+
627
+ def _set_block_checkbox_present(self, block, present: bool):
628
+ state, pref = self._checkbox_info_for_block(block)
629
+ doc = self.document()
630
+ c = QTextCursor(doc)
631
+ c.setPosition(block.position())
632
+ c.beginEditBlock()
633
+ try:
634
+ if present and state is None:
635
+ c.insertText(self._CHECK_UNCHECKED + " ")
636
+ state = False
637
+ self._style_checkbox_glyph(block)
638
+ else:
639
+ if state is not None:
640
+ c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, pref)
641
+ c.removeSelectedText()
642
+ state = None
643
+ finally:
644
+ c.endEditBlock()
645
+
646
+ return state
647
+
648
+ def _set_block_checkbox_state(self, block, checked: bool):
649
+ """Switch ☐/☑ at the start of the block."""
650
+ state, pref = self._checkbox_info_for_block(block)
651
+ if state is None:
652
+ return
653
+ doc = self.document()
654
+ c = QTextCursor(doc)
655
+ c.setPosition(block.position())
656
+ c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) # just the symbol
657
+ c.beginEditBlock()
658
+ try:
659
+ c.removeSelectedText()
660
+ c.insertText(self._CHECK_CHECKED if checked else self._CHECK_UNCHECKED)
661
+ self._style_checkbox_glyph(block)
662
+ finally:
663
+ c.endEditBlock()
664
+
665
+ # Public API used by toolbar
666
+ def toggle_checkboxes(self):
667
+ """
668
+ Toggle checkbox prefix on/off for the current block(s).
669
+ If all targeted blocks already have a checkbox, remove them; otherwise add.
670
+ """
671
+ c = self.textCursor()
672
+ doc = self.document()
673
+
674
+ if c.hasSelection():
675
+ start = doc.findBlock(c.selectionStart())
676
+ end = doc.findBlock(c.selectionEnd() - 1)
677
+ else:
678
+ start = end = c.block()
679
+
680
+ # Decide intent: add or remove?
681
+ b = start
682
+ all_have = True
683
+ while True:
684
+ state, _ = self._checkbox_info_for_block(b)
685
+ if state is None:
686
+ all_have = False
687
+ break
688
+ if b == end:
689
+ break
690
+ b = b.next()
691
+
692
+ # Apply
693
+ b = start
694
+ while True:
695
+ self._set_block_checkbox_present(b, present=not all_have)
696
+ if b == end:
697
+ break
698
+ b = b.next()
699
+
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
+ @Slot()
709
+ def apply_weight(self):
710
+ cur = self.currentCharFormat()
711
+ fmt = QTextCharFormat()
712
+ weight = (
713
+ QFont.Weight.Normal
714
+ if cur.fontWeight() == QFont.Weight.Bold
715
+ else QFont.Weight.Bold
716
+ )
717
+ fmt.setFontWeight(weight)
718
+ self.merge_on_sel(fmt)
719
+
720
+ @Slot()
721
+ def apply_italic(self):
722
+ cur = self.currentCharFormat()
723
+ fmt = QTextCharFormat()
724
+ fmt.setFontItalic(not cur.fontItalic())
725
+ self.merge_on_sel(fmt)
726
+
727
+ @Slot()
728
+ def apply_underline(self):
729
+ cur = self.currentCharFormat()
730
+ fmt = QTextCharFormat()
731
+ fmt.setFontUnderline(not cur.fontUnderline())
732
+ self.merge_on_sel(fmt)
733
+
734
+ @Slot()
735
+ def apply_strikethrough(self):
736
+ cur = self.currentCharFormat()
737
+ fmt = QTextCharFormat()
738
+ fmt.setFontStrikeOut(not cur.fontStrikeOut())
739
+ self.merge_on_sel(fmt)
740
+
741
+ @Slot()
742
+ def apply_code(self):
743
+ c = self.textCursor()
744
+ if not c.hasSelection():
745
+ c.select(QTextCursor.BlockUnderCursor)
746
+
747
+ # Wrap the selection in a single frame (no per-block padding/margins).
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)
759
+
760
+ c.beginEditBlock()
761
+ try:
762
+ c.insertFrame(ff) # with a selection, this wraps the selection
763
+
764
+ # Format all blocks inside the new frame: zero vertical margins, mono font, no wrapping
765
+ frame = self._find_code_frame(c)
766
+ bc = QTextCursor(self.document())
767
+ bc.setPosition(frame.firstPosition())
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())
787
+ finally:
788
+ c.endEditBlock()
789
+
790
+ @Slot(int)
791
+ def apply_heading(self, size: int):
792
+ """
793
+ Set heading point size for typing. If there's a selection, also apply bold
794
+ to that selection (for H1..H3). "Normal" clears bold on the selection.
795
+ """
796
+ # Map toolbar's sizes to heading levels
797
+ level = 1 if size >= 24 else 2 if size >= 18 else 3 if size >= 14 else 0
798
+
799
+ c = self.textCursor()
800
+
801
+ # On-screen look
802
+ ins = QTextCharFormat()
803
+ if size:
804
+ ins.setFontPointSize(float(size))
805
+ ins.setFontWeight(QFont.Weight.Bold)
806
+ else:
807
+ ins.setFontPointSize(self.font().pointSizeF())
808
+ ins.setFontWeight(QFont.Weight.Normal)
809
+ self.mergeCurrentCharFormat(ins)
810
+
811
+ # Apply heading level to affected block(s)
812
+ def set_level_for_block(cur):
813
+ bf = cur.blockFormat()
814
+ if hasattr(bf, "setHeadingLevel"):
815
+ bf.setHeadingLevel(level) # 0 clears heading
816
+ cur.mergeBlockFormat(bf)
817
+
818
+ if c.hasSelection():
819
+ start, end = c.selectionStart(), c.selectionEnd()
820
+ bc = QTextCursor(self.document())
821
+ bc.setPosition(start)
822
+ while True:
823
+ set_level_for_block(bc)
824
+ if bc.position() >= end:
825
+ break
826
+ bc.movePosition(QTextCursor.EndOfBlock)
827
+ if bc.position() >= end:
828
+ break
829
+ bc.movePosition(QTextCursor.NextBlock)
830
+ else:
831
+ bc = QTextCursor(c)
832
+ set_level_for_block(bc)
833
+
834
+ def toggle_bullets(self):
835
+ c = self.textCursor()
836
+ lst = c.currentList()
837
+ if lst and lst.format().style() == QTextListFormat.Style.ListDisc:
838
+ lst.remove(c.block())
839
+ return
840
+ fmt = QTextListFormat()
841
+ fmt.setStyle(QTextListFormat.Style.ListDisc)
842
+ c.createList(fmt)
843
+
844
+ def toggle_numbers(self):
845
+ c = self.textCursor()
846
+ lst = c.currentList()
847
+ if lst and lst.format().style() == QTextListFormat.Style.ListDecimal:
848
+ lst.remove(c.block())
849
+ return
850
+ fmt = QTextListFormat()
851
+ fmt.setStyle(QTextListFormat.Style.ListDecimal)
852
+ c.createList(fmt)
853
+
854
+ @Slot(Theme)
855
+ def _on_theme_changed(self, _theme: Theme):
856
+ # Defer one event-loop tick so widgets have the new palette
857
+ QTimer.singleShot(0, self._retint_anchors_to_palette)
858
+
859
+ @Slot()
860
+ def _retint_anchors_to_palette(self, *_):
861
+ # Always read from the *application* palette to avoid stale widget palette
862
+ app = QApplication.instance()
863
+ link_brush = app.palette().brush(QPalette.Link)
864
+ doc = self.document()
865
+ cur = QTextCursor(doc)
866
+ cur.beginEditBlock()
867
+ block = doc.firstBlock()
868
+ while block.isValid():
869
+ it = block.begin()
870
+ while not it.atEnd():
871
+ frag = it.fragment()
872
+ if frag.isValid():
873
+ fmt = frag.charFormat()
874
+ if fmt.isAnchor():
875
+ new_fmt = QTextCharFormat(fmt)
876
+ new_fmt.setForeground(link_brush) # force palette link color
877
+ cur.setPosition(frag.position())
878
+ cur.setPosition(
879
+ frag.position() + frag.length(), QTextCursor.KeepAnchor
880
+ )
881
+ cur.setCharFormat(new_fmt)
882
+ it += 1
883
+ block = block.next()
884
+ cur.endEditBlock()
885
+ self.viewport().update()
886
+
887
+ def setHtml(self, html: str) -> None:
888
+ super().setHtml(html)
889
+
890
+ doc = self.document()
891
+ block = doc.firstBlock()
892
+ while block.isValid():
893
+ self._style_checkbox_glyph(block) # Apply checkbox styling to each block
894
+ block = block.next()
895
+
896
+ # Ensure anchors adopt the palette color on startup
897
+ self._retint_anchors_to_palette()