bouquin 0.1.5__py3-none-any.whl → 0.1.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of bouquin might be problematic. Click here for more details.
- bouquin/db.py +31 -2
- bouquin/editor.py +482 -92
- bouquin/history_dialog.py +12 -15
- bouquin/main_window.py +158 -17
- bouquin/settings_dialog.py +58 -9
- bouquin/toolbar.py +80 -21
- {bouquin-0.1.5.dist-info → bouquin-0.1.9.dist-info}/METADATA +7 -7
- bouquin-0.1.9.dist-info/RECORD +18 -0
- bouquin-0.1.5.dist-info/RECORD +0 -18
- {bouquin-0.1.5.dist-info → bouquin-0.1.9.dist-info}/LICENSE +0 -0
- {bouquin-0.1.5.dist-info → bouquin-0.1.9.dist-info}/WHEEL +0 -0
- {bouquin-0.1.5.dist-info → bouquin-0.1.9.dist-info}/entry_points.txt +0 -0
bouquin/editor.py
CHANGED
|
@@ -1,23 +1,46 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import base64, re
|
|
5
|
+
|
|
3
6
|
from PySide6.QtGui import (
|
|
4
7
|
QColor,
|
|
5
8
|
QDesktopServices,
|
|
6
9
|
QFont,
|
|
7
10
|
QFontDatabase,
|
|
11
|
+
QImage,
|
|
12
|
+
QImageReader,
|
|
13
|
+
QPixmap,
|
|
8
14
|
QTextCharFormat,
|
|
9
15
|
QTextCursor,
|
|
16
|
+
QTextFrameFormat,
|
|
10
17
|
QTextListFormat,
|
|
11
18
|
QTextBlockFormat,
|
|
19
|
+
QTextImageFormat,
|
|
20
|
+
QTextDocument,
|
|
21
|
+
)
|
|
22
|
+
from PySide6.QtCore import (
|
|
23
|
+
Qt,
|
|
24
|
+
QUrl,
|
|
25
|
+
Signal,
|
|
26
|
+
Slot,
|
|
27
|
+
QRegularExpression,
|
|
28
|
+
QBuffer,
|
|
29
|
+
QByteArray,
|
|
30
|
+
QIODevice,
|
|
12
31
|
)
|
|
13
|
-
from PySide6.QtCore import Qt, QUrl, Signal, Slot, QRegularExpression
|
|
14
32
|
from PySide6.QtWidgets import QTextEdit
|
|
15
33
|
|
|
16
34
|
|
|
17
35
|
class Editor(QTextEdit):
|
|
18
36
|
linkActivated = Signal(str)
|
|
19
37
|
|
|
20
|
-
_URL_RX = QRegularExpression(r
|
|
38
|
+
_URL_RX = QRegularExpression(r'((?:https?://|www\.)[^\s<>"\'<>]+)')
|
|
39
|
+
_CODE_BG = QColor(245, 245, 245)
|
|
40
|
+
_CODE_FRAME_PROP = int(QTextFrameFormat.UserProperty) + 100 # marker for our frames
|
|
41
|
+
_HEADING_SIZES = (24.0, 18.0, 14.0)
|
|
42
|
+
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
|
|
43
|
+
_DATA_IMG_RX = re.compile(r'src=["\']data:image/[^;]+;base64,([^"\']+)["\']', re.I)
|
|
21
44
|
|
|
22
45
|
def __init__(self, *args, **kwargs):
|
|
23
46
|
super().__init__(*args, **kwargs)
|
|
@@ -37,50 +60,378 @@ class Editor(QTextEdit):
|
|
|
37
60
|
self.textChanged.connect(self._linkify_document)
|
|
38
61
|
self.viewport().setMouseTracking(True)
|
|
39
62
|
|
|
63
|
+
def _approx(self, a: float, b: float, eps: float = 0.5) -> bool:
|
|
64
|
+
return abs(float(a) - float(b)) <= eps
|
|
65
|
+
|
|
66
|
+
def _is_heading_typing(self) -> bool:
|
|
67
|
+
"""Is the current *insertion* format using a heading size?"""
|
|
68
|
+
s = self.currentCharFormat().fontPointSize() or self.font().pointSizeF()
|
|
69
|
+
return any(self._approx(s, h) for h in self._HEADING_SIZES)
|
|
70
|
+
|
|
71
|
+
def _apply_normal_typing(self):
|
|
72
|
+
"""Switch the *insertion* format to Normal (default size, normal weight)."""
|
|
73
|
+
nf = QTextCharFormat()
|
|
74
|
+
nf.setFontPointSize(self.font().pointSizeF())
|
|
75
|
+
nf.setFontWeight(QFont.Weight.Normal)
|
|
76
|
+
self.mergeCurrentCharFormat(nf)
|
|
77
|
+
|
|
78
|
+
def _find_code_frame(self, cursor=None):
|
|
79
|
+
"""Return the nearest ancestor frame that's one of our code frames, else None."""
|
|
80
|
+
if cursor is None:
|
|
81
|
+
cursor = self.textCursor()
|
|
82
|
+
f = cursor.currentFrame()
|
|
83
|
+
while f:
|
|
84
|
+
if f.frameFormat().property(self._CODE_FRAME_PROP):
|
|
85
|
+
return f
|
|
86
|
+
f = f.parentFrame()
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def _is_code_block(self, block) -> bool:
|
|
90
|
+
if not block.isValid():
|
|
91
|
+
return False
|
|
92
|
+
bf = block.blockFormat()
|
|
93
|
+
return bool(
|
|
94
|
+
bf.nonBreakableLines()
|
|
95
|
+
and bf.background().color().rgb() == self._CODE_BG.rgb()
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def _trim_url_end(self, url: str) -> str:
|
|
99
|
+
# strip common trailing punctuation not part of the URL
|
|
100
|
+
trimmed = url.rstrip(".,;:!?\"'")
|
|
101
|
+
# drop an unmatched closing ) or ] at the very end
|
|
102
|
+
if trimmed.endswith(")") and trimmed.count("(") < trimmed.count(")"):
|
|
103
|
+
trimmed = trimmed[:-1]
|
|
104
|
+
if trimmed.endswith("]") and trimmed.count("[") < trimmed.count("]"):
|
|
105
|
+
trimmed = trimmed[:-1]
|
|
106
|
+
return trimmed
|
|
107
|
+
|
|
40
108
|
def _linkify_document(self):
|
|
41
109
|
if self._linkifying:
|
|
42
110
|
return
|
|
43
111
|
self._linkifying = True
|
|
44
112
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
block = doc.begin()
|
|
50
|
-
while block.isValid():
|
|
113
|
+
try:
|
|
114
|
+
block = self.textCursor().block()
|
|
115
|
+
start_pos = block.position()
|
|
51
116
|
text = block.text()
|
|
117
|
+
|
|
118
|
+
cur = QTextCursor(self.document())
|
|
119
|
+
cur.beginEditBlock()
|
|
120
|
+
|
|
52
121
|
it = self._URL_RX.globalMatch(text)
|
|
53
122
|
while it.hasNext():
|
|
54
123
|
m = it.next()
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
cur.setPosition(end, QTextCursor.KeepAnchor)
|
|
60
|
-
|
|
61
|
-
fmt = cur.charFormat()
|
|
62
|
-
if fmt.isAnchor(): # already linkified; skip
|
|
124
|
+
s = start_pos + m.capturedStart()
|
|
125
|
+
raw = m.captured(0)
|
|
126
|
+
url = self._trim_url_end(raw)
|
|
127
|
+
if not url:
|
|
63
128
|
continue
|
|
64
129
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
130
|
+
e = s + len(url)
|
|
131
|
+
cur.setPosition(s)
|
|
132
|
+
cur.setPosition(e, QTextCursor.KeepAnchor)
|
|
68
133
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
except AttributeError:
|
|
74
|
-
fmt.setAnchorNames([href])
|
|
134
|
+
if url.startswith("www."):
|
|
135
|
+
href = "https://" + url
|
|
136
|
+
else:
|
|
137
|
+
href = url
|
|
75
138
|
|
|
139
|
+
fmt = QTextCharFormat()
|
|
140
|
+
fmt.setAnchor(True)
|
|
141
|
+
fmt.setAnchorHref(href) # always refresh to the latest full URL
|
|
76
142
|
fmt.setFontUnderline(True)
|
|
77
143
|
fmt.setForeground(Qt.blue)
|
|
78
|
-
cur.setCharFormat(fmt)
|
|
79
144
|
|
|
80
|
-
|
|
145
|
+
cur.mergeCharFormat(fmt) # merge so we don’t clobber other styling
|
|
146
|
+
|
|
147
|
+
cur.endEditBlock()
|
|
148
|
+
finally:
|
|
149
|
+
self._linkifying = False
|
|
150
|
+
|
|
151
|
+
def _to_qimage(self, obj) -> QImage | None:
|
|
152
|
+
if isinstance(obj, QImage):
|
|
153
|
+
return None if obj.isNull() else obj
|
|
154
|
+
if isinstance(obj, QPixmap):
|
|
155
|
+
qi = obj.toImage()
|
|
156
|
+
return None if qi.isNull() else qi
|
|
157
|
+
if isinstance(obj, (bytes, bytearray)):
|
|
158
|
+
qi = QImage.fromData(obj)
|
|
159
|
+
return None if qi.isNull() else qi
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
def _qimage_to_data_url(self, img: QImage, fmt: str = "PNG") -> str:
|
|
163
|
+
ba = QByteArray()
|
|
164
|
+
buf = QBuffer(ba)
|
|
165
|
+
buf.open(QIODevice.WriteOnly)
|
|
166
|
+
img.save(buf, fmt.upper())
|
|
167
|
+
b64 = base64.b64encode(bytes(ba)).decode("ascii")
|
|
168
|
+
mime = "image/png" if fmt.upper() == "PNG" else f"image/{fmt.lower()}"
|
|
169
|
+
return f"data:{mime};base64,{b64}"
|
|
170
|
+
|
|
171
|
+
def _image_name_to_qimage(self, name: str) -> QImage | None:
|
|
172
|
+
res = self.document().resource(QTextDocument.ImageResource, QUrl(name))
|
|
173
|
+
return res if isinstance(res, QImage) and not res.isNull() else None
|
|
174
|
+
|
|
175
|
+
def to_html_with_embedded_images(self) -> str:
|
|
176
|
+
"""
|
|
177
|
+
Return the document HTML with all image src's replaced by data: URLs,
|
|
178
|
+
so it is self-contained for storage in the DB.
|
|
179
|
+
"""
|
|
180
|
+
# 1) Walk the document collecting name -> data: URL
|
|
181
|
+
name_to_data = {}
|
|
182
|
+
cur = QTextCursor(self.document())
|
|
183
|
+
cur.movePosition(QTextCursor.Start)
|
|
184
|
+
while True:
|
|
185
|
+
cur.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
|
|
186
|
+
fmt = cur.charFormat()
|
|
187
|
+
if fmt.isImageFormat():
|
|
188
|
+
imgfmt = QTextImageFormat(fmt)
|
|
189
|
+
name = imgfmt.name()
|
|
190
|
+
if name and name not in name_to_data:
|
|
191
|
+
img = self._image_name_to_qimage(name)
|
|
192
|
+
if img:
|
|
193
|
+
name_to_data[name] = self._qimage_to_data_url(img, "PNG")
|
|
194
|
+
if cur.atEnd():
|
|
195
|
+
break
|
|
196
|
+
cur.clearSelection()
|
|
197
|
+
|
|
198
|
+
# 2) Serialize and replace names with data URLs
|
|
199
|
+
html = self.document().toHtml()
|
|
200
|
+
for old, data_url in name_to_data.items():
|
|
201
|
+
html = html.replace(f'src="{old}"', f'src="{data_url}"')
|
|
202
|
+
html = html.replace(f"src='{old}'", f"src='{data_url}'")
|
|
203
|
+
return html
|
|
204
|
+
|
|
205
|
+
def _insert_qimage_at_cursor(self, img: QImage, autoscale=True):
|
|
206
|
+
c = self.textCursor()
|
|
81
207
|
|
|
82
|
-
|
|
83
|
-
|
|
208
|
+
# Don’t drop inside a code frame
|
|
209
|
+
frame = self._find_code_frame(c)
|
|
210
|
+
if frame:
|
|
211
|
+
out = QTextCursor(self.document())
|
|
212
|
+
out.setPosition(frame.lastPosition())
|
|
213
|
+
self.setTextCursor(out)
|
|
214
|
+
c = self.textCursor()
|
|
215
|
+
|
|
216
|
+
# Start a fresh paragraph if mid-line
|
|
217
|
+
if c.positionInBlock() != 0:
|
|
218
|
+
c.insertBlock()
|
|
219
|
+
|
|
220
|
+
if autoscale and self.viewport():
|
|
221
|
+
max_w = int(self.viewport().width() * 0.92)
|
|
222
|
+
if img.width() > max_w:
|
|
223
|
+
img = img.scaledToWidth(max_w, Qt.SmoothTransformation)
|
|
224
|
+
|
|
225
|
+
c.insertImage(img)
|
|
226
|
+
c.insertBlock() # one blank line after the image
|
|
227
|
+
|
|
228
|
+
def _image_info_at_cursor(self):
|
|
229
|
+
"""
|
|
230
|
+
Returns (cursorSelectingImageChar, QTextImageFormat, originalQImage) or (None, None, None)
|
|
231
|
+
"""
|
|
232
|
+
# Try current position (select 1 char forward)
|
|
233
|
+
tc = QTextCursor(self.textCursor())
|
|
234
|
+
tc.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
|
|
235
|
+
fmt = tc.charFormat()
|
|
236
|
+
if fmt.isImageFormat():
|
|
237
|
+
imgfmt = QTextImageFormat(fmt)
|
|
238
|
+
img = self._resolve_image_resource(imgfmt)
|
|
239
|
+
return tc, imgfmt, img
|
|
240
|
+
|
|
241
|
+
# Try previous char (if caret is just after the image)
|
|
242
|
+
tc = QTextCursor(self.textCursor())
|
|
243
|
+
if tc.position() > 0:
|
|
244
|
+
tc.movePosition(QTextCursor.Left, QTextCursor.MoveAnchor, 1)
|
|
245
|
+
tc.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
|
|
246
|
+
fmt = tc.charFormat()
|
|
247
|
+
if fmt.isImageFormat():
|
|
248
|
+
imgfmt = QTextImageFormat(fmt)
|
|
249
|
+
img = self._resolve_image_resource(imgfmt)
|
|
250
|
+
return tc, imgfmt, img
|
|
251
|
+
|
|
252
|
+
return None, None, None
|
|
253
|
+
|
|
254
|
+
def _resolve_image_resource(self, imgfmt: QTextImageFormat) -> QImage | None:
|
|
255
|
+
"""
|
|
256
|
+
Fetch the original QImage backing the inline image, if available.
|
|
257
|
+
"""
|
|
258
|
+
name = imgfmt.name()
|
|
259
|
+
if name:
|
|
260
|
+
try:
|
|
261
|
+
img = self.document().resource(QTextDocument.ImageResource, QUrl(name))
|
|
262
|
+
if isinstance(img, QImage) and not img.isNull():
|
|
263
|
+
return img
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
return None # fallback handled by callers
|
|
267
|
+
|
|
268
|
+
def _apply_image_size(
|
|
269
|
+
self,
|
|
270
|
+
tc: QTextCursor,
|
|
271
|
+
imgfmt: QTextImageFormat,
|
|
272
|
+
new_w: float,
|
|
273
|
+
orig_img: QImage | None,
|
|
274
|
+
):
|
|
275
|
+
# compute height proportionally
|
|
276
|
+
if orig_img and orig_img.width() > 0:
|
|
277
|
+
ratio = new_w / orig_img.width()
|
|
278
|
+
new_h = max(1.0, orig_img.height() * ratio)
|
|
279
|
+
else:
|
|
280
|
+
# fallback: keep current aspect ratio if we have it
|
|
281
|
+
cur_w = imgfmt.width() if imgfmt.width() > 0 else new_w
|
|
282
|
+
cur_h = imgfmt.height() if imgfmt.height() > 0 else new_w
|
|
283
|
+
ratio = new_w / max(1.0, cur_w)
|
|
284
|
+
new_h = max(1.0, cur_h * ratio)
|
|
285
|
+
|
|
286
|
+
imgfmt.setWidth(max(1.0, new_w))
|
|
287
|
+
imgfmt.setHeight(max(1.0, new_h))
|
|
288
|
+
tc.mergeCharFormat(imgfmt)
|
|
289
|
+
|
|
290
|
+
def _scale_image_at_cursor(self, factor: float):
|
|
291
|
+
tc, imgfmt, orig = self._image_info_at_cursor()
|
|
292
|
+
if not imgfmt:
|
|
293
|
+
return
|
|
294
|
+
base_w = imgfmt.width()
|
|
295
|
+
if base_w <= 0 and orig:
|
|
296
|
+
base_w = orig.width()
|
|
297
|
+
if base_w <= 0:
|
|
298
|
+
return
|
|
299
|
+
self._apply_image_size(tc, imgfmt, base_w * factor, orig)
|
|
300
|
+
|
|
301
|
+
def _fit_image_to_editor_width(self):
|
|
302
|
+
tc, imgfmt, orig = self._image_info_at_cursor()
|
|
303
|
+
if not imgfmt:
|
|
304
|
+
return
|
|
305
|
+
if not self.viewport():
|
|
306
|
+
return
|
|
307
|
+
target = int(self.viewport().width() * 0.92)
|
|
308
|
+
self._apply_image_size(tc, imgfmt, target, orig)
|
|
309
|
+
|
|
310
|
+
def _set_image_width_dialog(self):
|
|
311
|
+
from PySide6.QtWidgets import QInputDialog
|
|
312
|
+
|
|
313
|
+
tc, imgfmt, orig = self._image_info_at_cursor()
|
|
314
|
+
if not imgfmt:
|
|
315
|
+
return
|
|
316
|
+
# propose current display width or original width
|
|
317
|
+
cur_w = (
|
|
318
|
+
int(imgfmt.width())
|
|
319
|
+
if imgfmt.width() > 0
|
|
320
|
+
else (orig.width() if orig else 400)
|
|
321
|
+
)
|
|
322
|
+
w, ok = QInputDialog.getInt(
|
|
323
|
+
self, "Set image width", "Width (px):", cur_w, 1, 10000, 10
|
|
324
|
+
)
|
|
325
|
+
if ok:
|
|
326
|
+
self._apply_image_size(tc, imgfmt, float(w), orig)
|
|
327
|
+
|
|
328
|
+
def _reset_image_size(self):
|
|
329
|
+
tc, imgfmt, orig = self._image_info_at_cursor()
|
|
330
|
+
if not imgfmt or not orig:
|
|
331
|
+
return
|
|
332
|
+
self._apply_image_size(tc, imgfmt, float(orig.width()), orig)
|
|
333
|
+
|
|
334
|
+
def contextMenuEvent(self, e):
|
|
335
|
+
menu = self.createStandardContextMenu()
|
|
336
|
+
tc, imgfmt, orig = self._image_info_at_cursor()
|
|
337
|
+
if imgfmt:
|
|
338
|
+
menu.addSeparator()
|
|
339
|
+
sub = menu.addMenu("Image size")
|
|
340
|
+
sub.addAction("Shrink 10%", lambda: self._scale_image_at_cursor(0.9))
|
|
341
|
+
sub.addAction("Grow 10%", lambda: self._scale_image_at_cursor(1.1))
|
|
342
|
+
sub.addAction("Fit to editor width", self._fit_image_to_editor_width)
|
|
343
|
+
sub.addAction("Set width…", self._set_image_width_dialog)
|
|
344
|
+
sub.addAction("Reset to original", self._reset_image_size)
|
|
345
|
+
menu.exec(e.globalPos())
|
|
346
|
+
|
|
347
|
+
def insertFromMimeData(self, source):
|
|
348
|
+
# 1) Direct image from clipboard
|
|
349
|
+
if source.hasImage():
|
|
350
|
+
img = self._to_qimage(source.imageData())
|
|
351
|
+
if img is not None:
|
|
352
|
+
self._insert_qimage_at_cursor(self, img, autoscale=True)
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
# 2) File URLs (drag/drop or paste)
|
|
356
|
+
if source.hasUrls():
|
|
357
|
+
paths = []
|
|
358
|
+
non_local_urls = []
|
|
359
|
+
for url in source.urls():
|
|
360
|
+
if url.isLocalFile():
|
|
361
|
+
path = url.toLocalFile()
|
|
362
|
+
if path.lower().endswith(self._IMAGE_EXTS):
|
|
363
|
+
paths.append(path)
|
|
364
|
+
else:
|
|
365
|
+
# Non-image file: insert as link
|
|
366
|
+
self.textCursor().insertHtml(
|
|
367
|
+
f'<a href="{url.toString()}">{Path(path).name}</a>'
|
|
368
|
+
)
|
|
369
|
+
self.textCursor().insertBlock()
|
|
370
|
+
else:
|
|
371
|
+
non_local_urls.append(url)
|
|
372
|
+
|
|
373
|
+
if paths:
|
|
374
|
+
self.insert_images(paths)
|
|
375
|
+
|
|
376
|
+
for url in non_local_urls:
|
|
377
|
+
self.textCursor().insertHtml(
|
|
378
|
+
f'<a href="{url.toString()}">{url.toString()}</a>'
|
|
379
|
+
)
|
|
380
|
+
self.textCursor().insertBlock()
|
|
381
|
+
|
|
382
|
+
if paths or non_local_urls:
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
# 3) HTML with data: image
|
|
386
|
+
if source.hasHtml():
|
|
387
|
+
html = source.html()
|
|
388
|
+
m = self._DATA_IMG_RX.search(html or "")
|
|
389
|
+
if m:
|
|
390
|
+
try:
|
|
391
|
+
data = base64.b64decode(m.group(1))
|
|
392
|
+
img = QImage.fromData(data)
|
|
393
|
+
if not img.isNull():
|
|
394
|
+
self._insert_qimage_at_cursor(self, img, autoscale=True)
|
|
395
|
+
return
|
|
396
|
+
except Exception:
|
|
397
|
+
pass # fall through
|
|
398
|
+
|
|
399
|
+
# 4) Everything else → default behavior
|
|
400
|
+
super().insertFromMimeData(source)
|
|
401
|
+
|
|
402
|
+
@Slot(list)
|
|
403
|
+
def insert_images(self, paths: list[str], autoscale=True):
|
|
404
|
+
"""
|
|
405
|
+
Insert one or more images at the cursor. Large images can be auto-scaled
|
|
406
|
+
to fit the viewport width while preserving aspect ratio.
|
|
407
|
+
"""
|
|
408
|
+
c = self.textCursor()
|
|
409
|
+
|
|
410
|
+
# Avoid dropping images inside a code frame
|
|
411
|
+
frame = self._find_code_frame(c)
|
|
412
|
+
if frame:
|
|
413
|
+
out = QTextCursor(self.document())
|
|
414
|
+
out.setPosition(frame.lastPosition())
|
|
415
|
+
self.setTextCursor(out)
|
|
416
|
+
c = self.textCursor()
|
|
417
|
+
|
|
418
|
+
# Ensure there's a paragraph break if we're mid-line
|
|
419
|
+
if c.positionInBlock() != 0:
|
|
420
|
+
c.insertBlock()
|
|
421
|
+
|
|
422
|
+
for path in paths:
|
|
423
|
+
reader = QImageReader(path)
|
|
424
|
+
img = reader.read()
|
|
425
|
+
if img.isNull():
|
|
426
|
+
continue
|
|
427
|
+
|
|
428
|
+
if autoscale and self.viewport():
|
|
429
|
+
max_w = int(self.viewport().width() * 0.92) # ~92% of editor width
|
|
430
|
+
if img.width() > max_w:
|
|
431
|
+
img = img.scaledToWidth(max_w, Qt.SmoothTransformation)
|
|
432
|
+
|
|
433
|
+
c.insertImage(img)
|
|
434
|
+
c.insertBlock() # put each image on its own line
|
|
84
435
|
|
|
85
436
|
def mouseReleaseEvent(self, e):
|
|
86
437
|
if e.button() == Qt.LeftButton and (e.modifiers() & Qt.ControlModifier):
|
|
@@ -106,18 +457,27 @@ class Editor(QTextEdit):
|
|
|
106
457
|
self._break_anchor_for_next_char()
|
|
107
458
|
return super().keyPressEvent(e)
|
|
108
459
|
|
|
109
|
-
# When pressing Enter/return key, insert first, then neutralise the empty block’s inline format
|
|
110
460
|
if key in (Qt.Key_Return, Qt.Key_Enter):
|
|
111
|
-
super().keyPressEvent(e) # create the new (possibly empty) paragraph
|
|
112
|
-
|
|
113
|
-
# If we're on an empty block, clear the insertion char format so the
|
|
114
|
-
# *next* Enter will create another new line (not consume the press to reset formatting).
|
|
115
461
|
c = self.textCursor()
|
|
116
|
-
block = c.block()
|
|
117
|
-
if block.length() == 1:
|
|
118
|
-
self._clear_insertion_char_format()
|
|
119
|
-
return
|
|
120
462
|
|
|
463
|
+
# If we're on an empty line inside a code frame, consume Enter and jump out
|
|
464
|
+
if c.block().length() == 1:
|
|
465
|
+
frame = self._find_code_frame(c)
|
|
466
|
+
if frame:
|
|
467
|
+
out = QTextCursor(self.document())
|
|
468
|
+
out.setPosition(frame.lastPosition()) # after the frame's contents
|
|
469
|
+
self.setTextCursor(out)
|
|
470
|
+
super().insertPlainText("\n") # start a normal paragraph
|
|
471
|
+
return
|
|
472
|
+
|
|
473
|
+
# Follow-on style: if we typed a heading and press Enter at end of block,
|
|
474
|
+
# new paragraph should revert to Normal.
|
|
475
|
+
if not c.hasSelection() and c.atBlockEnd() and self._is_heading_typing():
|
|
476
|
+
super().keyPressEvent(e) # insert the new paragraph
|
|
477
|
+
self._apply_normal_typing() # make the *new* paragraph Normal for typing
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
# otherwise default handling
|
|
121
481
|
return super().keyPressEvent(e)
|
|
122
482
|
|
|
123
483
|
def _clear_insertion_char_format(self):
|
|
@@ -126,28 +486,32 @@ class Editor(QTextEdit):
|
|
|
126
486
|
self.setCurrentCharFormat(nf)
|
|
127
487
|
|
|
128
488
|
def _break_anchor_for_next_char(self):
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
489
|
+
"""
|
|
490
|
+
Ensure the *next* typed character is not part of a hyperlink.
|
|
491
|
+
Only strips link-specific attributes; leaves bold/italic/underline etc intact.
|
|
492
|
+
"""
|
|
493
|
+
# What we're about to type with
|
|
494
|
+
ins_fmt = self.currentCharFormat()
|
|
495
|
+
# What the cursor is sitting on
|
|
496
|
+
cur_fmt = self.textCursor().charFormat()
|
|
497
|
+
|
|
498
|
+
# Do nothing unless either side indicates we're in/propagating an anchor
|
|
499
|
+
if not (ins_fmt.isAnchor() or cur_fmt.isAnchor()):
|
|
500
|
+
return
|
|
501
|
+
|
|
502
|
+
nf = QTextCharFormat(ins_fmt)
|
|
503
|
+
nf.setAnchor(False)
|
|
504
|
+
nf.setAnchorHref("")
|
|
505
|
+
|
|
506
|
+
self.setCurrentCharFormat(nf)
|
|
142
507
|
|
|
143
508
|
def merge_on_sel(self, fmt):
|
|
144
509
|
"""
|
|
145
|
-
Sets the styling on the selected characters.
|
|
510
|
+
Sets the styling on the selected characters or the insertion position.
|
|
146
511
|
"""
|
|
147
512
|
cursor = self.textCursor()
|
|
148
|
-
if
|
|
149
|
-
cursor.
|
|
150
|
-
cursor.mergeCharFormat(fmt)
|
|
513
|
+
if cursor.hasSelection():
|
|
514
|
+
cursor.mergeCharFormat(fmt)
|
|
151
515
|
self.mergeCurrentCharFormat(fmt)
|
|
152
516
|
|
|
153
517
|
@Slot()
|
|
@@ -187,45 +551,71 @@ class Editor(QTextEdit):
|
|
|
187
551
|
def apply_code(self):
|
|
188
552
|
c = self.textCursor()
|
|
189
553
|
if not c.hasSelection():
|
|
190
|
-
c.select(
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
554
|
+
c.select(QTextCursor.BlockUnderCursor)
|
|
555
|
+
|
|
556
|
+
# Wrap the selection in a single frame (no per-block padding/margins).
|
|
557
|
+
ff = QTextFrameFormat()
|
|
558
|
+
ff.setBackground(self._CODE_BG)
|
|
559
|
+
ff.setPadding(6) # visual padding for the WHOLE block
|
|
560
|
+
ff.setBorder(0)
|
|
561
|
+
ff.setLeftMargin(0)
|
|
562
|
+
ff.setRightMargin(0)
|
|
563
|
+
ff.setTopMargin(0)
|
|
564
|
+
ff.setBottomMargin(0)
|
|
565
|
+
ff.setProperty(self._CODE_FRAME_PROP, True)
|
|
566
|
+
|
|
567
|
+
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
|
568
|
+
|
|
569
|
+
c.beginEditBlock()
|
|
570
|
+
try:
|
|
571
|
+
c.insertFrame(ff) # with a selection, this wraps the selection
|
|
572
|
+
|
|
573
|
+
# Format all blocks inside the new frame: zero vertical margins, mono font, no wrapping
|
|
574
|
+
frame = self._find_code_frame(c)
|
|
575
|
+
bc = QTextCursor(self.document())
|
|
576
|
+
bc.setPosition(frame.firstPosition())
|
|
577
|
+
|
|
578
|
+
while bc.position() < frame.lastPosition():
|
|
579
|
+
bc.select(QTextCursor.BlockUnderCursor)
|
|
580
|
+
|
|
581
|
+
bf = QTextBlockFormat()
|
|
582
|
+
bf.setTopMargin(0)
|
|
583
|
+
bf.setBottomMargin(0)
|
|
584
|
+
bf.setLeftMargin(12)
|
|
585
|
+
bf.setRightMargin(12)
|
|
586
|
+
bf.setNonBreakableLines(True)
|
|
587
|
+
|
|
588
|
+
cf = QTextCharFormat()
|
|
589
|
+
cf.setFont(mono)
|
|
590
|
+
cf.setFontFixedPitch(True)
|
|
591
|
+
|
|
592
|
+
bc.mergeBlockFormat(bf)
|
|
593
|
+
bc.mergeBlockCharFormat(cf)
|
|
594
|
+
|
|
595
|
+
bc.setPosition(bc.block().position() + bc.block().length())
|
|
596
|
+
finally:
|
|
597
|
+
c.endEditBlock()
|
|
218
598
|
|
|
219
599
|
@Slot(int)
|
|
220
|
-
def apply_heading(self, size):
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
else
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
600
|
+
def apply_heading(self, size: int):
|
|
601
|
+
"""
|
|
602
|
+
Set heading point size for typing. If there's a selection, also apply bold
|
|
603
|
+
to that selection (for H1..H3). "Normal" clears bold on the selection.
|
|
604
|
+
"""
|
|
605
|
+
base_size = size if size else self.font().pointSizeF()
|
|
606
|
+
c = self.textCursor()
|
|
607
|
+
|
|
608
|
+
# Update the typing (insertion) format to be size only, but don't represent
|
|
609
|
+
# it as if the Bold style has been toggled on
|
|
610
|
+
ins = QTextCharFormat()
|
|
611
|
+
ins.setFontPointSize(base_size)
|
|
612
|
+
self.mergeCurrentCharFormat(ins)
|
|
613
|
+
|
|
614
|
+
# If user selected text, style that text visually as a heading
|
|
615
|
+
if c.hasSelection():
|
|
616
|
+
sel = QTextCharFormat(ins)
|
|
617
|
+
sel.setFontWeight(QFont.Weight.Bold if size else QFont.Weight.Normal)
|
|
618
|
+
c.mergeCharFormat(sel)
|
|
229
619
|
|
|
230
620
|
def toggle_bullets(self):
|
|
231
621
|
c = self.textCursor()
|