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/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"(https?://[^\s<>\"]+|www\.[^\s<>\"]+)")
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
- doc = self.document()
46
- cur = QTextCursor(doc)
47
- cur.beginEditBlock()
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
- start = block.position() + m.capturedStart()
56
- end = start + m.capturedLength()
57
-
58
- cur.setPosition(start)
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
- href = m.captured(0)
66
- if href.startswith("www."):
67
- href = "https://" + href
130
+ e = s + len(url)
131
+ cur.setPosition(s)
132
+ cur.setPosition(e, QTextCursor.KeepAnchor)
68
133
 
69
- fmt.setAnchor(True)
70
- # Qt 6: use setAnchorHref; for compatibility, also set names.
71
- try:
72
- fmt.setAnchorHref(href)
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
- block = block.next()
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
- cur.endEditBlock()
83
- self._linkifying = False
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
- c = self.textCursor()
130
- fmt = c.charFormat()
131
- if fmt.isAnchor() or fmt.fontUnderline() or fmt.foreground().style() != 0:
132
- # clone, then strip just the link-specific bits so the next char is plain text
133
- nf = QTextCharFormat(fmt)
134
- nf.setAnchor(False)
135
- nf.setFontUnderline(False)
136
- nf.clearForeground()
137
- try:
138
- nf.setAnchorHref("")
139
- except AttributeError:
140
- nf.setAnchorNames([])
141
- self.setCurrentCharFormat(nf)
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 not cursor.hasSelection():
149
- cursor.select(cursor.SelectionType.WordUnderCursor)
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(c.SelectionType.BlockUnderCursor)
191
-
192
- bf = QTextBlockFormat()
193
- bf.setLeftMargin(12)
194
- bf.setRightMargin(12)
195
- bf.setTopMargin(6)
196
- bf.setBottomMargin(6)
197
- bf.setBackground(QColor(245, 245, 245))
198
- bf.setNonBreakableLines(True)
199
-
200
- cf = QTextCharFormat()
201
- mono = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
202
- cf.setFont(mono)
203
- cf.setFontFixedPitch(True)
204
-
205
- # If the current block already looks like a code block, remove styling
206
- cur_bf = c.blockFormat()
207
- is_code = (
208
- cur_bf.nonBreakableLines()
209
- and cur_bf.background().color().rgb() == QColor(245, 245, 245).rgb()
210
- )
211
- if is_code:
212
- # clear: margins/background/wrapping
213
- bf = QTextBlockFormat()
214
- cf = QTextCharFormat()
215
-
216
- c.mergeBlockFormat(bf)
217
- c.mergeBlockCharFormat(cf)
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
- fmt = QTextCharFormat()
222
- if size:
223
- fmt.setFontWeight(QFont.Weight.Bold)
224
- fmt.setFontPointSize(size)
225
- else:
226
- fmt.setFontWeight(QFont.Weight.Normal)
227
- fmt.setFontPointSize(self.font().pointSizeF())
228
- self.merge_on_sel(fmt)
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()