bouquin 0.1.10__py3-none-any.whl → 0.2.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bouquin/db.py +34 -91
- bouquin/find_bar.py +208 -0
- bouquin/history_dialog.py +29 -28
- bouquin/key_prompt.py +6 -0
- bouquin/lock_overlay.py +8 -2
- bouquin/main_window.py +598 -119
- bouquin/markdown_editor.py +813 -0
- bouquin/save_dialog.py +3 -0
- bouquin/search.py +46 -31
- bouquin/settings_dialog.py +1 -1
- bouquin/theme.py +1 -2
- bouquin/toolbar.py +4 -41
- {bouquin-0.1.10.dist-info → bouquin-0.2.1.2.dist-info}/METADATA +10 -7
- bouquin-0.2.1.2.dist-info/RECORD +21 -0
- bouquin/editor.py +0 -897
- bouquin-0.1.10.dist-info/RECORD +0 -20
- {bouquin-0.1.10.dist-info → bouquin-0.2.1.2.dist-info}/LICENSE +0 -0
- {bouquin-0.1.10.dist-info → bouquin-0.2.1.2.dist-info}/WHEEL +0 -0
- {bouquin-0.1.10.dist-info → bouquin-0.2.1.2.dist-info}/entry_points.txt +0 -0
bouquin/editor.py
DELETED
|
@@ -1,897 +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
|
-
# 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()
|