bouquin 0.1.12__py3-none-any.whl → 0.2.1.3__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 DELETED
@@ -1,1009 +0,0 @@
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
- self._apply_code_theme() # set initial code colors
72
- # Refresh on theme change
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
- )
77
-
78
- self._linkifying = False
79
- self.textChanged.connect(self._linkify_document)
80
- self.viewport().setMouseTracking(True)
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
-
226
- def _approx(self, a: float, b: float, eps: float = 0.5) -> bool:
227
- return abs(float(a) - float(b)) <= eps
228
-
229
- def _is_heading_typing(self) -> bool:
230
- """Is the current *insertion* format using a heading size?"""
231
- bf = self.textCursor().blockFormat()
232
- if bf.headingLevel() > 0:
233
- return True
234
-
235
- def _apply_normal_typing(self):
236
- """Switch the *insertion* format to Normal (default size, normal weight)."""
237
- nf = QTextCharFormat()
238
- nf.setFontPointSize(self.font().pointSizeF())
239
- nf.setFontWeight(QFont.Weight.Normal)
240
- self.mergeCurrentCharFormat(nf)
241
-
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()
271
-
272
- def _trim_url_end(self, url: str) -> str:
273
- # strip common trailing punctuation not part of the URL
274
- trimmed = url.rstrip(".,;:!?\"'")
275
- # drop an unmatched closing ) or ] at the very end
276
- if trimmed.endswith(")") and trimmed.count("(") < trimmed.count(")"):
277
- trimmed = trimmed[:-1]
278
- if trimmed.endswith("]") and trimmed.count("[") < trimmed.count("]"):
279
- trimmed = trimmed[:-1]
280
- return trimmed
281
-
282
- def _linkify_document(self):
283
- if self._linkifying:
284
- return
285
- self._linkifying = True
286
-
287
- try:
288
- block = self.textCursor().block()
289
- start_pos = block.position()
290
- text = block.text()
291
-
292
- cur = QTextCursor(self.document())
293
- cur.beginEditBlock()
294
-
295
- it = self._URL_RX.globalMatch(text)
296
- while it.hasNext():
297
- m = it.next()
298
- s = start_pos + m.capturedStart()
299
- raw = m.captured(0)
300
- url = self._trim_url_end(raw)
301
- if not url:
302
- continue
303
-
304
- e = s + len(url)
305
- cur.setPosition(s)
306
- cur.setPosition(e, QTextCursor.KeepAnchor)
307
-
308
- if url.startswith("www."):
309
- href = "https://" + url
310
- else:
311
- href = url
312
-
313
- fmt = QTextCharFormat()
314
- fmt.setAnchor(True)
315
- fmt.setAnchorHref(href) # always refresh to the latest full URL
316
- fmt.setFontUnderline(True)
317
- fmt.setForeground(self.palette().brush(QPalette.Link))
318
-
319
- cur.mergeCharFormat(fmt) # merge so we don't clobber other styling
320
-
321
- cur.endEditBlock()
322
- finally:
323
- self._linkifying = False
324
-
325
- def _to_qimage(self, obj) -> QImage | None:
326
- if isinstance(obj, QImage):
327
- return None if obj.isNull() else obj
328
- if isinstance(obj, QPixmap):
329
- qi = obj.toImage()
330
- return None if qi.isNull() else qi
331
- if isinstance(obj, (bytes, bytearray)):
332
- qi = QImage.fromData(obj)
333
- return None if qi.isNull() else qi
334
- return None
335
-
336
- def _qimage_to_data_url(self, img: QImage, fmt: str = "PNG") -> str:
337
- ba = QByteArray()
338
- buf = QBuffer(ba)
339
- buf.open(QIODevice.WriteOnly)
340
- img.save(buf, fmt.upper())
341
- b64 = base64.b64encode(bytes(ba)).decode("ascii")
342
- mime = "image/png" if fmt.upper() == "PNG" else f"image/{fmt.lower()}"
343
- return f"data:{mime};base64,{b64}"
344
-
345
- def _image_name_to_qimage(self, name: str) -> QImage | None:
346
- res = self.document().resource(QTextDocument.ImageResource, QUrl(name))
347
- return res if isinstance(res, QImage) and not res.isNull() else None
348
-
349
- def to_html_with_embedded_images(self) -> str:
350
- """
351
- Return the document HTML with all image src's replaced by data: URLs,
352
- so it is self-contained for storage in the DB.
353
- """
354
- # 1) Walk the document collecting name -> data: URL
355
- name_to_data = {}
356
- cur = QTextCursor(self.document())
357
- cur.movePosition(QTextCursor.Start)
358
- while True:
359
- cur.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
360
- fmt = cur.charFormat()
361
- if fmt.isImageFormat():
362
- imgfmt = QTextImageFormat(fmt)
363
- name = imgfmt.name()
364
- if name and name not in name_to_data:
365
- img = self._image_name_to_qimage(name)
366
- if img:
367
- name_to_data[name] = self._qimage_to_data_url(img, "PNG")
368
- if cur.atEnd():
369
- break
370
- cur.clearSelection()
371
-
372
- # 2) Serialize and replace names with data URLs
373
- html = self.document().toHtml()
374
- for old, data_url in name_to_data.items():
375
- html = html.replace(f'src="{old}"', f'src="{data_url}"')
376
- html = html.replace(f"src='{old}'", f"src='{data_url}'")
377
- return html
378
-
379
- # ---------------- Image insertion & sizing (DRY’d) ---------------- #
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)
385
- c.insertImage(img)
386
- c.insertBlock() # one blank line after the image
387
-
388
- def _image_info_at_cursor(self):
389
- """
390
- Returns (cursorSelectingImageChar, QTextImageFormat, originalQImage) or (None, None, None)
391
- """
392
- # Try current position (select 1 char forward)
393
- tc = QTextCursor(self.textCursor())
394
- tc.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
395
- fmt = tc.charFormat()
396
- if fmt.isImageFormat():
397
- imgfmt = QTextImageFormat(fmt)
398
- img = self._resolve_image_resource(imgfmt)
399
- return tc, imgfmt, img
400
-
401
- # Try previous char (if caret is just after the image)
402
- tc = QTextCursor(self.textCursor())
403
- if tc.position() > 0:
404
- tc.movePosition(QTextCursor.Left, QTextCursor.MoveAnchor, 1)
405
- tc.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
406
- fmt = tc.charFormat()
407
- if fmt.isImageFormat():
408
- imgfmt = QTextImageFormat(fmt)
409
- img = self._resolve_image_resource(imgfmt)
410
- return tc, imgfmt, img
411
-
412
- return None, None, None
413
-
414
- def _resolve_image_resource(self, imgfmt: QTextImageFormat) -> QImage | None:
415
- """
416
- Fetch the original QImage backing the inline image, if available.
417
- """
418
- name = imgfmt.name()
419
- if name:
420
- try:
421
- img = self.document().resource(QTextDocument.ImageResource, QUrl(name))
422
- if isinstance(img, QImage) and not img.isNull():
423
- return img
424
- except Exception:
425
- pass
426
- return None # fallback handled by callers
427
-
428
- def _apply_image_size(
429
- self,
430
- tc: QTextCursor,
431
- imgfmt: QTextImageFormat,
432
- new_w: float,
433
- orig_img: QImage | None,
434
- ):
435
- # compute height proportionally
436
- if orig_img and orig_img.width() > 0:
437
- ratio = new_w / orig_img.width()
438
- new_h = max(1.0, orig_img.height() * ratio)
439
- else:
440
- # fallback: keep current aspect ratio if we have it
441
- cur_w = imgfmt.width() if imgfmt.width() > 0 else new_w
442
- cur_h = imgfmt.height() if imgfmt.height() > 0 else new_w
443
- ratio = new_w / max(1.0, cur_w)
444
- new_h = max(1.0, cur_h * ratio)
445
-
446
- imgfmt.setWidth(max(1.0, new_w))
447
- imgfmt.setHeight(max(1.0, new_h))
448
- tc.mergeCharFormat(imgfmt)
449
-
450
- def _scale_image_at_cursor(self, factor: float):
451
- tc, imgfmt, orig = self._image_info_at_cursor()
452
- if not imgfmt:
453
- return
454
- base_w = imgfmt.width()
455
- if base_w <= 0 and orig:
456
- base_w = orig.width()
457
- if base_w <= 0:
458
- return
459
- self._apply_image_size(tc, imgfmt, base_w * factor, orig)
460
-
461
- def _fit_image_to_editor_width(self):
462
- tc, imgfmt, orig = self._image_info_at_cursor()
463
- if not imgfmt:
464
- return
465
- if not self.viewport():
466
- return
467
- target = int(self.viewport().width() * 0.92)
468
- self._apply_image_size(tc, imgfmt, target, orig)
469
-
470
- def _set_image_width_dialog(self):
471
- from PySide6.QtWidgets import QInputDialog
472
-
473
- tc, imgfmt, orig = self._image_info_at_cursor()
474
- if not imgfmt:
475
- return
476
- # propose current display width or original width
477
- cur_w = (
478
- int(imgfmt.width())
479
- if imgfmt.width() > 0
480
- else (orig.width() if orig else 400)
481
- )
482
- w, ok = QInputDialog.getInt(
483
- self, "Set image width", "Width (px):", cur_w, 1, 10000, 10
484
- )
485
- if ok:
486
- self._apply_image_size(tc, imgfmt, float(w), orig)
487
-
488
- def _reset_image_size(self):
489
- tc, imgfmt, orig = self._image_info_at_cursor()
490
- if not imgfmt or not orig:
491
- return
492
- self._apply_image_size(tc, imgfmt, float(orig.width()), orig)
493
-
494
- # ---------------- Context menu ---------------- #
495
-
496
- def contextMenuEvent(self, e):
497
- menu = self.createStandardContextMenu()
498
- tc, imgfmt, orig = self._image_info_at_cursor()
499
- if imgfmt:
500
- menu.addSeparator()
501
- sub = menu.addMenu("Image size")
502
- sub.addAction("Shrink 10%", lambda: self._scale_image_at_cursor(0.9))
503
- sub.addAction("Grow 10%", lambda: self._scale_image_at_cursor(1.1))
504
- sub.addAction("Fit to editor width", self._fit_image_to_editor_width)
505
- sub.addAction("Set width…", self._set_image_width_dialog)
506
- sub.addAction("Reset to original", self._reset_image_size)
507
- menu.exec(e.globalPos())
508
-
509
- # ---------------- Clipboard / DnD ---------------- #
510
-
511
- def insertFromMimeData(self, source):
512
- # 1) Direct image from clipboard
513
- if source.hasImage():
514
- img = self._to_qimage(source.imageData())
515
- if img is not None:
516
- self._insert_qimage_at_cursor(img, autoscale=True)
517
- return
518
-
519
- # 2) File URLs (drag/drop or paste)
520
- if source.hasUrls():
521
- paths = []
522
- non_local_urls = []
523
- for url in source.urls():
524
- if url.isLocalFile():
525
- path = url.toLocalFile()
526
- if path.lower().endswith(self._IMAGE_EXTS):
527
- paths.append(path)
528
- else:
529
- # Non-image file: insert as link
530
- self.textCursor().insertHtml(
531
- f'<a href="{url.toString()}">{Path(path).name}</a>'
532
- )
533
- self.textCursor().insertBlock()
534
- else:
535
- non_local_urls.append(url)
536
-
537
- if paths:
538
- self.insert_images(paths)
539
-
540
- for url in non_local_urls:
541
- self.textCursor().insertHtml(
542
- f'<a href="{url.toString()}">{url.toString()}</a>'
543
- )
544
- self.textCursor().insertBlock()
545
-
546
- if paths or non_local_urls:
547
- return
548
-
549
- # 3) HTML with data: image
550
- if source.hasHtml():
551
- html = source.html()
552
- m = self._DATA_IMG_RX.search(html or "")
553
- if m:
554
- try:
555
- data = base64.b64decode(m.group(1))
556
- img = QImage.fromData(data)
557
- if not img.isNull():
558
- self._insert_qimage_at_cursor(img, autoscale=True)
559
- return
560
- except Exception:
561
- pass # fall through
562
-
563
- # 4) Everything else → default behavior
564
- super().insertFromMimeData(source)
565
-
566
- @Slot(list)
567
- def insert_images(self, paths: list[str], autoscale=True):
568
- """
569
- Insert one or more images at the cursor. Large images can be auto-scaled
570
- to fit the viewport width while preserving aspect ratio.
571
- """
572
- c = self._safe_block_insertion_cursor()
573
-
574
- for path in paths:
575
- reader = QImageReader(path)
576
- img = reader.read()
577
- if img.isNull():
578
- continue
579
-
580
- if autoscale:
581
- img = self._scale_to_viewport(img)
582
-
583
- c.insertImage(img)
584
- c.insertBlock() # put each image on its own line
585
-
586
- # ---------------- Mouse & key handling ---------------- #
587
-
588
- def mouseReleaseEvent(self, e):
589
- if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier):
590
- href = self.anchorAt(e.pos())
591
- if href:
592
- QDesktopServices.openUrl(QUrl.fromUserInput(href))
593
- self.linkActivated.emit(href)
594
- return
595
- super().mouseReleaseEvent(e)
596
-
597
- def mouseMoveEvent(self, e):
598
- if (e.modifiers() & Qt.ControlModifier) and self.anchorAt(e.pos()):
599
- self.viewport().setCursor(Qt.PointingHandCursor)
600
- else:
601
- self.viewport().setCursor(Qt.IBeamCursor)
602
- super().mouseMoveEvent(e)
603
-
604
- def mousePressEvent(self, e):
605
- if e.button() == Qt.LeftButton and not (e.modifiers() & Qt.ControlModifier):
606
- cur = self.cursorForPosition(e.pos())
607
- b = cur.block()
608
- state, pref = self._checkbox_info_for_block(b)
609
- if state is not None:
610
- col = cur.position() - b.position()
611
- if col <= max(1, pref): # clicked on ☐/☑ (and the following space)
612
- self._set_block_checkbox_state(b, not state)
613
- return
614
- return super().mousePressEvent(e)
615
-
616
- def keyPressEvent(self, e):
617
- key = e.key()
618
-
619
- if key in (Qt.Key_Space, Qt.Key_Tab):
620
- c = self.textCursor()
621
- b = c.block()
622
- pos_in_block = c.position() - b.position()
623
-
624
- if (
625
- pos_in_block >= 4
626
- and b.text().startswith("TODO")
627
- and b.text()[:pos_in_block] == "TODO"
628
- and self._checkbox_info_for_block(b)[0] is None
629
- ):
630
- tcur = QTextCursor(self.document())
631
- tcur.setPosition(b.position()) # start of block
632
- tcur.setPosition(
633
- b.position() + 4, QTextCursor.KeepAnchor
634
- ) # select "TODO"
635
- tcur.beginEditBlock()
636
- tcur.removeSelectedText()
637
- tcur.insertText(self._CHECK_UNCHECKED + " ") # insert "☐ "
638
- tcur.endEditBlock()
639
-
640
- # visuals: size bump
641
- if hasattr(self, "_style_checkbox_glyph"):
642
- self._style_checkbox_glyph(b)
643
-
644
- # caret after the inserted prefix; swallow the key (we already added a space)
645
- c.setPosition(b.position() + 2)
646
- self.setTextCursor(c)
647
- return
648
-
649
- # not a TODO-at-start case
650
- self._break_anchor_for_next_char()
651
- return super().keyPressEvent(e)
652
-
653
- if key in (Qt.Key_Return, Qt.Key_Enter):
654
- c = self.textCursor()
655
-
656
- # If we're on an empty line inside a code frame, consume Enter and jump out
657
- if c.block().length() == 1:
658
- frame = self._nearest_code_frame(c, tolerant=False)
659
- if frame:
660
- out = QTextCursor(self.document())
661
- out.setPosition(frame.lastPosition()) # after the frame's contents
662
- self.setTextCursor(out)
663
- super().insertPlainText("\n") # start a normal paragraph
664
- return
665
-
666
- # --- CHECKBOX handling: continue on Enter; "escape" on second Enter ---
667
- b = c.block()
668
- state, pref = self._checkbox_info_for_block(b)
669
- if state is not None and not c.hasSelection():
670
- text_after = b.text()[pref:].strip()
671
- if c.atBlockEnd() and text_after == "":
672
- # Empty checkbox item -> remove the prefix and insert a plain new line
673
- cur = QTextCursor(self.document())
674
- cur.setPosition(b.position())
675
- cur.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, pref)
676
- cur.removeSelectedText()
677
- return super().keyPressEvent(e)
678
- else:
679
- # Normal continuation: new checkbox on the next line
680
- super().keyPressEvent(e) # make the new block
681
- super().insertPlainText(self._CHECK_UNCHECKED + " ")
682
- if hasattr(self, "_style_checkbox_glyph"):
683
- self._style_checkbox_glyph(self.textCursor().block())
684
- return
685
-
686
- # Follow-on style: if we typed a heading and press Enter at end of block,
687
- # new paragraph should revert to Normal.
688
- if not c.hasSelection() and c.atBlockEnd() and self._is_heading_typing():
689
- super().keyPressEvent(e) # insert the new paragraph
690
- self._apply_normal_typing() # make the *new* paragraph Normal for typing
691
- return
692
-
693
- # otherwise default handling
694
- return super().keyPressEvent(e)
695
-
696
- def _break_anchor_for_next_char(self):
697
- """
698
- Ensure the *next* typed character is not part of a hyperlink.
699
- Only strips link-specific attributes; leaves bold/italic/underline etc intact.
700
- """
701
- # What we're about to type with
702
- ins_fmt = self.currentCharFormat()
703
- # What the cursor is sitting on
704
- cur_fmt = self.textCursor().charFormat()
705
-
706
- # Do nothing unless either side indicates we're in/propagating an anchor
707
- if not (
708
- ins_fmt.isAnchor()
709
- or cur_fmt.isAnchor()
710
- or ins_fmt.fontUnderline()
711
- or ins_fmt.foreground().style() != Qt.NoBrush
712
- ):
713
- return
714
-
715
- nf = QTextCharFormat(ins_fmt)
716
- # stop the link itself
717
- nf.setAnchor(False)
718
- nf.setAnchorHref("")
719
- # also stop the link *styling*
720
- nf.setFontUnderline(False)
721
- nf.clearForeground()
722
-
723
- self.setCurrentCharFormat(nf)
724
-
725
- def merge_on_sel(self, fmt):
726
- """
727
- Sets the styling on the selected characters or the insertion position.
728
- """
729
- cursor = self.textCursor()
730
- if cursor.hasSelection():
731
- cursor.mergeCharFormat(fmt)
732
- self.mergeCurrentCharFormat(fmt)
733
-
734
- # ====== Checkbox core ======
735
- def _base_point_size_for_block(self, block) -> float:
736
- # Try the block's char format, then editor font
737
- sz = block.charFormat().fontPointSize()
738
- if sz <= 0:
739
- sz = self.fontPointSize()
740
- if sz <= 0:
741
- sz = self.font().pointSizeF() or 12.0
742
- return float(sz)
743
-
744
- def _style_checkbox_glyph(self, block):
745
- """Apply larger size (and optional symbol font) to the single ☐/☑ char."""
746
- state, _ = self._checkbox_info_for_block(block)
747
- if state is None:
748
- return
749
- doc = self.document()
750
- c = QTextCursor(doc)
751
- c.setPosition(block.position())
752
- c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) # select ☐/☑ only
753
-
754
- base = self._base_point_size_for_block(block)
755
- fmt = QTextCharFormat()
756
- fmt.setFontPointSize(base * self._CHECKBOX_SCALE)
757
- # keep the glyph centered on the text baseline
758
- fmt.setVerticalAlignment(QTextCharFormat.AlignMiddle)
759
-
760
- c.mergeCharFormat(fmt)
761
-
762
- def _checkbox_info_for_block(self, block):
763
- """Return (state, prefix_len): state in {None, False, True}, prefix_len in chars."""
764
- text = block.text()
765
- m = self._CHECK_RX.match(text)
766
- if not m:
767
- return None, 0
768
- ch = m.group(1)
769
- state = True if ch == self._CHECK_CHECKED else False
770
- return state, m.end()
771
-
772
- def _set_block_checkbox_present(self, block, present: bool):
773
- state, pref = self._checkbox_info_for_block(block)
774
- doc = self.document()
775
- c = QTextCursor(doc)
776
- c.setPosition(block.position())
777
- c.beginEditBlock()
778
- try:
779
- if present and state is None:
780
- c.insertText(self._CHECK_UNCHECKED + " ")
781
- state = False
782
- self._style_checkbox_glyph(block)
783
- else:
784
- if state is not None:
785
- c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, pref)
786
- c.removeSelectedText()
787
- state = None
788
- finally:
789
- c.endEditBlock()
790
-
791
- return state
792
-
793
- def _set_block_checkbox_state(self, block, checked: bool):
794
- """Switch ☐/☑ at the start of the block."""
795
- state, pref = self._checkbox_info_for_block(block)
796
- if state is None:
797
- return
798
- doc = self.document()
799
- c = QTextCursor(doc)
800
- c.setPosition(block.position())
801
- c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) # just the symbol
802
- c.beginEditBlock()
803
- try:
804
- c.removeSelectedText()
805
- c.insertText(self._CHECK_CHECKED if checked else self._CHECK_UNCHECKED)
806
- self._style_checkbox_glyph(block)
807
- finally:
808
- c.endEditBlock()
809
-
810
- # Public API used by toolbar
811
- def toggle_checkboxes(self):
812
- """
813
- Toggle checkbox prefix on/off for the current block(s).
814
- If all targeted blocks already have a checkbox, remove them; otherwise add.
815
- """
816
- c = self.textCursor()
817
- doc = self.document()
818
-
819
- if c.hasSelection():
820
- start = doc.findBlock(c.selectionStart())
821
- end = doc.findBlock(c.selectionEnd() - 1)
822
- else:
823
- start = end = c.block()
824
-
825
- # Decide intent: add or remove?
826
- b = start
827
- all_have = True
828
- while True:
829
- state, _ = self._checkbox_info_for_block(b)
830
- if state is None:
831
- all_have = False
832
- break
833
- if b == end:
834
- break
835
- b = b.next()
836
-
837
- # Apply
838
- b = start
839
- while True:
840
- self._set_block_checkbox_present(b, present=not all_have)
841
- if b == end:
842
- break
843
- b = b.next()
844
-
845
- @Slot()
846
- def apply_weight(self):
847
- cur = self.currentCharFormat()
848
- fmt = QTextCharFormat()
849
- weight = (
850
- QFont.Weight.Normal
851
- if cur.fontWeight() == QFont.Weight.Bold
852
- else QFont.Weight.Bold
853
- )
854
- fmt.setFontWeight(weight)
855
- self.merge_on_sel(fmt)
856
-
857
- @Slot()
858
- def apply_italic(self):
859
- cur = self.currentCharFormat()
860
- fmt = QTextCharFormat()
861
- fmt.setFontItalic(not cur.fontItalic())
862
- self.merge_on_sel(fmt)
863
-
864
- @Slot()
865
- def apply_underline(self):
866
- cur = self.currentCharFormat()
867
- fmt = QTextCharFormat()
868
- fmt.setFontUnderline(not cur.fontUnderline())
869
- self.merge_on_sel(fmt)
870
-
871
- @Slot()
872
- def apply_strikethrough(self):
873
- cur = self.currentCharFormat()
874
- fmt = QTextCharFormat()
875
- fmt.setFontStrikeOut(not cur.fontStrikeOut())
876
- self.merge_on_sel(fmt)
877
-
878
- @Slot()
879
- def apply_code(self):
880
- c = self.textCursor()
881
- if not c.hasSelection():
882
- c.select(QTextCursor.BlockUnderCursor)
883
-
884
- ff = self._new_code_frame_format(self._CODE_BG)
885
-
886
- c.beginEditBlock()
887
- try:
888
- c.insertFrame(ff) # with a selection, this wraps the selection
889
-
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)
894
- finally:
895
- c.endEditBlock()
896
-
897
- @Slot(int)
898
- def apply_heading(self, size: int):
899
- """
900
- Set heading point size for typing. If there's a selection, also apply bold
901
- to that selection (for H1..H3). "Normal" clears bold on the selection.
902
- """
903
- # Map toolbar's sizes to heading levels
904
- level = 1 if size >= 24 else 2 if size >= 18 else 3 if size >= 14 else 0
905
-
906
- c = self.textCursor()
907
-
908
- # On-screen look
909
- ins = QTextCharFormat()
910
- if size:
911
- ins.setFontPointSize(float(size))
912
- ins.setFontWeight(QFont.Weight.Bold)
913
- else:
914
- ins.setFontPointSize(self.font().pointSizeF())
915
- ins.setFontWeight(QFont.Weight.Normal)
916
- self.mergeCurrentCharFormat(ins)
917
-
918
- # Apply heading level to affected block(s)
919
- def set_level_for_block(cur):
920
- bf = cur.blockFormat()
921
- if hasattr(bf, "setHeadingLevel"):
922
- bf.setHeadingLevel(level) # 0 clears heading
923
- cur.mergeBlockFormat(bf)
924
-
925
- if c.hasSelection():
926
- start, end = c.selectionStart(), c.selectionEnd()
927
- bc = QTextCursor(self.document())
928
- bc.setPosition(start)
929
- while True:
930
- set_level_for_block(bc)
931
- if bc.position() >= end:
932
- break
933
- bc.movePosition(QTextCursor.EndOfBlock)
934
- if bc.position() >= end:
935
- break
936
- bc.movePosition(QTextCursor.NextBlock)
937
- else:
938
- bc = QTextCursor(c)
939
- set_level_for_block(bc)
940
-
941
- def toggle_bullets(self):
942
- c = self.textCursor()
943
- lst = c.currentList()
944
- if lst and lst.format().style() == QTextListFormat.Style.ListDisc:
945
- lst.remove(c.block())
946
- return
947
- fmt = QTextListFormat()
948
- fmt.setStyle(QTextListFormat.Style.ListDisc)
949
- c.createList(fmt)
950
-
951
- def toggle_numbers(self):
952
- c = self.textCursor()
953
- lst = c.currentList()
954
- if lst and lst.format().style() == QTextListFormat.Style.ListDecimal:
955
- lst.remove(c.block())
956
- return
957
- fmt = QTextListFormat()
958
- fmt.setStyle(QTextListFormat.Style.ListDecimal)
959
- c.createList(fmt)
960
-
961
- @Slot(Theme)
962
- def _on_theme_changed(self, _theme: Theme):
963
- # Defer one event-loop tick so widgets have the new palette
964
- QTimer.singleShot(0, self._retint_anchors_to_palette)
965
- QTimer.singleShot(0, self._apply_code_theme)
966
-
967
- @Slot()
968
- def _retint_anchors_to_palette(self, *_):
969
- # Always read from the *application* palette to avoid stale widget palette
970
- app = QApplication.instance()
971
- link_brush = app.palette().brush(QPalette.Link)
972
- doc = self.document()
973
- cur = QTextCursor(doc)
974
- cur.beginEditBlock()
975
- block = doc.firstBlock()
976
- while block.isValid():
977
- it = block.begin()
978
- while not it.atEnd():
979
- frag = it.fragment()
980
- if frag.isValid():
981
- fmt = frag.charFormat()
982
- if fmt.isAnchor():
983
- new_fmt = QTextCharFormat(fmt)
984
- new_fmt.setForeground(link_brush) # force palette link color
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
992
- cur.setCharFormat(new_fmt)
993
- it += 1
994
- block = block.next()
995
- cur.endEditBlock()
996
- self.viewport().update()
997
-
998
- def setHtml(self, html: str) -> None:
999
- super().setHtml(html)
1000
-
1001
- doc = self.document()
1002
- block = doc.firstBlock()
1003
- while block.isValid():
1004
- self._style_checkbox_glyph(block) # Apply checkbox styling to each block
1005
- block = block.next()
1006
-
1007
- # Ensure anchors adopt the palette color on startup
1008
- self._retint_anchors_to_palette()
1009
- self._apply_code_theme()