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.
@@ -0,0 +1,920 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from PySide6.QtGui import (
8
+ QColor,
9
+ QFont,
10
+ QFontDatabase,
11
+ QFontMetrics,
12
+ QImage,
13
+ QPalette,
14
+ QGuiApplication,
15
+ QTextCharFormat,
16
+ QTextCursor,
17
+ QTextDocument,
18
+ QSyntaxHighlighter,
19
+ QTextImageFormat,
20
+ )
21
+ from PySide6.QtCore import Qt, QRect
22
+ from PySide6.QtWidgets import QTextEdit
23
+
24
+ from .theme import ThemeManager, Theme
25
+
26
+
27
+ class MarkdownHighlighter(QSyntaxHighlighter):
28
+ """Live syntax highlighter for markdown that applies formatting as you type."""
29
+
30
+ def __init__(self, document: QTextDocument, theme_manager: ThemeManager):
31
+ super().__init__(document)
32
+ self.theme_manager = theme_manager
33
+ self._setup_formats()
34
+ # Recompute formats whenever the app theme changes
35
+ try:
36
+ self.theme_manager.themeChanged.connect(self._on_theme_changed)
37
+ self.textChanged.connect(self._refresh_codeblock_margins)
38
+ except Exception:
39
+ pass
40
+
41
+ def _on_theme_changed(self, *_):
42
+ self._setup_formats()
43
+ self.rehighlight()
44
+
45
+ def _setup_formats(self):
46
+ """Setup text formats for different markdown elements."""
47
+ # Bold: **text** or __text__
48
+ self.bold_format = QTextCharFormat()
49
+ self.bold_format.setFontWeight(QFont.Weight.Bold)
50
+
51
+ # Italic: *text* or _text_
52
+ self.italic_format = QTextCharFormat()
53
+ self.italic_format.setFontItalic(True)
54
+
55
+ # Strikethrough: ~~text~~
56
+ self.strike_format = QTextCharFormat()
57
+ self.strike_format.setFontStrikeOut(True)
58
+
59
+ # Code: `code`
60
+ mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
61
+ self.code_format = QTextCharFormat()
62
+ self.code_format.setFont(mono)
63
+ self.code_format.setFontFixedPitch(True)
64
+
65
+ # Code block: ```
66
+ self.code_block_format = QTextCharFormat()
67
+ self.code_block_format.setFont(mono)
68
+ self.code_block_format.setFontFixedPitch(True)
69
+
70
+ pal = QGuiApplication.palette()
71
+ if self.theme_manager.current() == Theme.DARK:
72
+ # In dark mode, use a darker panel-like background
73
+ bg = pal.color(QPalette.AlternateBase)
74
+ fg = pal.color(QPalette.Text)
75
+ else:
76
+ # Light mode: keep the existing light gray
77
+ bg = QColor(245, 245, 245)
78
+ fg = pal.color(QPalette.Text)
79
+ self.code_block_format.setBackground(bg)
80
+ self.code_block_format.setForeground(fg)
81
+
82
+ # Headings
83
+ self.h1_format = QTextCharFormat()
84
+ self.h1_format.setFontPointSize(24.0)
85
+ self.h1_format.setFontWeight(QFont.Weight.Bold)
86
+
87
+ self.h2_format = QTextCharFormat()
88
+ self.h2_format.setFontPointSize(18.0)
89
+ self.h2_format.setFontWeight(QFont.Weight.Bold)
90
+
91
+ self.h3_format = QTextCharFormat()
92
+ self.h3_format.setFontPointSize(14.0)
93
+ self.h3_format.setFontWeight(QFont.Weight.Bold)
94
+
95
+ # Markdown syntax (the markers themselves) - make invisible
96
+ self.syntax_format = QTextCharFormat()
97
+ # Make the markers invisible by setting font size to 0.1 points
98
+ self.syntax_format.setFontPointSize(0.1)
99
+ # Also make them very faint in case they still show
100
+ self.syntax_format.setForeground(QColor(250, 250, 250))
101
+
102
+ def _refresh_codeblock_margins(self):
103
+ """Give code blocks a small left/right margin to separate them visually."""
104
+ doc = self.document()
105
+ block = doc.begin()
106
+ in_code = False
107
+ while block.isValid():
108
+ txt = block.text().strip()
109
+ cursor = QTextCursor(block)
110
+ fmt = block.blockFormat()
111
+
112
+ if txt.startswith("```"):
113
+ # fence lines: small vertical spacing, same left indent
114
+ need = (12, 6, 6) # left, top, bottom (px-like)
115
+ if (fmt.leftMargin(), fmt.topMargin(), fmt.bottomMargin()) != need:
116
+ fmt.setLeftMargin(12)
117
+ fmt.setRightMargin(6)
118
+ fmt.setTopMargin(6)
119
+ fmt.setBottomMargin(6)
120
+ cursor.setBlockFormat(fmt)
121
+ in_code = not in_code
122
+
123
+ elif in_code:
124
+ # inside the code block
125
+ if fmt.leftMargin() != 12 or fmt.rightMargin() != 6:
126
+ fmt.setLeftMargin(12)
127
+ fmt.setRightMargin(6)
128
+ cursor.setBlockFormat(fmt)
129
+
130
+ block = block.next()
131
+
132
+ def highlightBlock(self, text: str):
133
+ """Apply formatting to a block of text based on markdown syntax."""
134
+ if not text:
135
+ return
136
+
137
+ # Track if we're in a code block (multiline)
138
+ prev_state = self.previousBlockState()
139
+ in_code_block = prev_state == 1
140
+
141
+ # Check for code block fences
142
+ if text.strip().startswith("```"):
143
+ # background for the whole fence line (so block looks continuous)
144
+ self.setFormat(0, len(text), self.code_block_format)
145
+
146
+ # hide the three backticks themselves
147
+ idx = text.find("```")
148
+ if idx != -1:
149
+ self.setFormat(idx, 3, self.syntax_format)
150
+
151
+ # toggle code-block state and stop; next line picks up state
152
+ in_code_block = not in_code_block
153
+ self.setCurrentBlockState(1 if in_code_block else 0)
154
+ return
155
+
156
+ if in_code_block:
157
+ # Format entire line as code
158
+ self.setFormat(0, len(text), self.code_block_format)
159
+ self.setCurrentBlockState(1)
160
+ return
161
+
162
+ self.setCurrentBlockState(0)
163
+
164
+ # Headings (must be at start of line)
165
+ heading_match = re.match(r"^(#{1,3})\s+", text)
166
+ if heading_match:
167
+ level = len(heading_match.group(1))
168
+ marker_len = len(heading_match.group(0))
169
+
170
+ # Format the # markers
171
+ self.setFormat(0, marker_len, self.syntax_format)
172
+
173
+ # Format the heading text
174
+ heading_fmt = (
175
+ self.h1_format
176
+ if level == 1
177
+ else self.h2_format if level == 2 else self.h3_format
178
+ )
179
+ self.setFormat(marker_len, len(text) - marker_len, heading_fmt)
180
+ return
181
+
182
+ # Bold: **text** or __text__
183
+ for match in re.finditer(r"\*\*(.+?)\*\*|__(.+?)__", text):
184
+ start, end = match.span()
185
+ content_start = start + 2
186
+ content_end = end - 2
187
+
188
+ # Gray out the markers
189
+ self.setFormat(start, 2, self.syntax_format)
190
+ self.setFormat(end - 2, 2, self.syntax_format)
191
+
192
+ # Bold the content
193
+ self.setFormat(content_start, content_end - content_start, self.bold_format)
194
+
195
+ # Italic: *text* or _text_ (but not part of bold)
196
+ for match in re.finditer(
197
+ r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", text
198
+ ):
199
+ start, end = match.span()
200
+ # Skip if this is part of a bold pattern
201
+ if start > 0 and text[start - 1 : start + 1] in ("**", "__"):
202
+ continue
203
+ if end < len(text) and text[end : end + 1] in ("*", "_"):
204
+ continue
205
+
206
+ content_start = start + 1
207
+ content_end = end - 1
208
+
209
+ # Gray out markers
210
+ self.setFormat(start, 1, self.syntax_format)
211
+ self.setFormat(end - 1, 1, self.syntax_format)
212
+
213
+ # Italicize content
214
+ self.setFormat(
215
+ content_start, content_end - content_start, self.italic_format
216
+ )
217
+
218
+ # Strikethrough: ~~text~~
219
+ for match in re.finditer(r"~~(.+?)~~", text):
220
+ start, end = match.span()
221
+ content_start = start + 2
222
+ content_end = end - 2
223
+
224
+ self.setFormat(start, 2, self.syntax_format)
225
+ self.setFormat(end - 2, 2, self.syntax_format)
226
+ self.setFormat(
227
+ content_start, content_end - content_start, self.strike_format
228
+ )
229
+
230
+ # Inline code: `code`
231
+ for match in re.finditer(r"`([^`]+)`", text):
232
+ start, end = match.span()
233
+ content_start = start + 1
234
+ content_end = end - 1
235
+
236
+ self.setFormat(start, 1, self.syntax_format)
237
+ self.setFormat(end - 1, 1, self.syntax_format)
238
+ self.setFormat(content_start, content_end - content_start, self.code_format)
239
+
240
+
241
+ class MarkdownEditor(QTextEdit):
242
+ """A QTextEdit that stores/loads markdown and provides live rendering."""
243
+
244
+ _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
245
+
246
+ # Checkbox characters (Unicode for display, markdown for storage)
247
+ _CHECK_UNCHECKED_DISPLAY = "☐"
248
+ _CHECK_CHECKED_DISPLAY = "☑"
249
+ _CHECK_UNCHECKED_STORAGE = "[ ]"
250
+ _CHECK_CHECKED_STORAGE = "[x]"
251
+
252
+ def __init__(self, theme_manager: ThemeManager, *args, **kwargs):
253
+ super().__init__(*args, **kwargs)
254
+
255
+ self.theme_manager = theme_manager
256
+
257
+ # Setup tab width
258
+ tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
259
+ self.setTabStopDistance(tab_w)
260
+
261
+ # We accept plain text, not rich text (markdown is plain text)
262
+ self.setAcceptRichText(False)
263
+
264
+ # Install syntax highlighter
265
+ self.highlighter = MarkdownHighlighter(self.document(), theme_manager)
266
+
267
+ # Track current list type for smart enter handling
268
+ self._last_enter_was_empty = False
269
+
270
+ # Track if we're currently updating text programmatically
271
+ self._updating = False
272
+
273
+ # Connect to text changes for smart formatting
274
+ self.textChanged.connect(self._on_text_changed)
275
+
276
+ # Enable mouse tracking for checkbox clicking
277
+ self.viewport().setMouseTracking(True)
278
+
279
+ def setDocument(self, doc):
280
+ super().setDocument(doc)
281
+ # reattach the highlighter to the new document
282
+ if hasattr(self, "highlighter") and self.highlighter:
283
+ self.highlighter.setDocument(self.document())
284
+
285
+ def _on_text_changed(self):
286
+ """Handle live formatting updates - convert checkbox markdown to Unicode."""
287
+ if self._updating:
288
+ return
289
+
290
+ self._updating = True
291
+ try:
292
+ c = self.textCursor()
293
+ block = c.block()
294
+ line = block.text()
295
+ pos_in_block = c.position() - block.position()
296
+
297
+ # Transform only this line:
298
+ # - "TODO " at start (with optional indent) -> "- ☐ "
299
+ # - "- [ ] " -> " ☐ " and "- [x] " -> " ☑ "
300
+ def transform_line(s: str) -> str:
301
+ s = s.replace("- [x] ", f"{self._CHECK_CHECKED_DISPLAY} ")
302
+ s = s.replace("- [ ] ", f"{self._CHECK_UNCHECKED_DISPLAY} ")
303
+ s = re.sub(
304
+ r"^([ \t]*)TODO\b[:\-]?\s+",
305
+ lambda m: f"{m.group(1)}\n{self._CHECK_UNCHECKED_DISPLAY} ",
306
+ s,
307
+ )
308
+ return s
309
+
310
+ new_line = transform_line(line)
311
+ if new_line != line:
312
+ # Replace just the current block
313
+ bc = QTextCursor(block)
314
+ bc.beginEditBlock()
315
+ bc.select(QTextCursor.BlockUnderCursor)
316
+ bc.insertText(new_line)
317
+ bc.endEditBlock()
318
+
319
+ # Restore cursor near its original visual position in the edited line
320
+ new_pos = min(
321
+ block.position() + len(new_line), block.position() + pos_in_block
322
+ )
323
+ c.setPosition(new_pos)
324
+ self.setTextCursor(c)
325
+ finally:
326
+ self._updating = False
327
+
328
+ def to_markdown(self) -> str:
329
+ """Export current content as markdown (convert Unicode checkboxes back to markdown)."""
330
+ # First, extract any embedded images and convert to markdown
331
+ text = self._extract_images_to_markdown()
332
+
333
+ # Convert Unicode checkboxes back to markdown syntax
334
+ text = text.replace(f"{self._CHECK_CHECKED_DISPLAY} ", "- [x] ")
335
+ text = text.replace(f"{self._CHECK_UNCHECKED_DISPLAY} ", "- [ ] ")
336
+
337
+ return text
338
+
339
+ def _extract_images_to_markdown(self) -> str:
340
+ """Extract embedded images and convert them back to markdown format."""
341
+ doc = self.document()
342
+ cursor = QTextCursor(doc)
343
+
344
+ # Build the output text with images as markdown
345
+ result = []
346
+ cursor.movePosition(QTextCursor.MoveOperation.Start)
347
+
348
+ block = doc.begin()
349
+ while block.isValid():
350
+ it = block.begin()
351
+ block_text = ""
352
+
353
+ while not it.atEnd():
354
+ fragment = it.fragment()
355
+ if fragment.isValid():
356
+ if fragment.charFormat().isImageFormat():
357
+ # This is an image - convert to markdown
358
+ img_format = fragment.charFormat().toImageFormat()
359
+ img_name = img_format.name()
360
+ # The name contains the data URI
361
+ if img_name.startswith("data:image/"):
362
+ block_text += f"![image]({img_name})"
363
+ else:
364
+ # Regular text
365
+ block_text += fragment.text()
366
+ it += 1
367
+
368
+ result.append(block_text)
369
+ block = block.next()
370
+
371
+ return "\n".join(result)
372
+
373
+ def from_markdown(self, markdown_text: str):
374
+ """Load markdown text into the editor (convert markdown checkboxes to Unicode)."""
375
+ # Convert markdown checkboxes to Unicode for display
376
+ display_text = markdown_text.replace(
377
+ "- [x] ", f"{self._CHECK_CHECKED_DISPLAY} "
378
+ )
379
+ display_text = display_text.replace(
380
+ "- [ ] ", f"{self._CHECK_UNCHECKED_DISPLAY} "
381
+ )
382
+ # Also convert any plain 'TODO ' at the start of a line to an unchecked checkbox
383
+ display_text = re.sub(
384
+ r"(?m)^([ \t]*)TODO\s",
385
+ lambda m: f"{m.group(1)}\n{self._CHECK_UNCHECKED_DISPLAY} ",
386
+ display_text,
387
+ )
388
+
389
+ self._updating = True
390
+ try:
391
+ self.setPlainText(display_text)
392
+ if hasattr(self, "highlighter") and self.highlighter:
393
+ self.highlighter.rehighlight()
394
+ finally:
395
+ self._updating = False
396
+
397
+ # Render any embedded images
398
+ self._render_images()
399
+
400
+ def _render_images(self):
401
+ """Find and render base64 images in the document."""
402
+ text = self.toPlainText()
403
+
404
+ # Pattern for markdown images with base64 data
405
+ img_pattern = r"!\[([^\]]*)\]\(data:image/([^;]+);base64,([^\)]+)\)"
406
+
407
+ matches = list(re.finditer(img_pattern, text))
408
+
409
+ if not matches:
410
+ return
411
+
412
+ # Process matches in reverse to preserve positions
413
+ for match in reversed(matches):
414
+ mime_type = match.group(2)
415
+ b64_data = match.group(3)
416
+
417
+ try:
418
+ # Decode base64 to image
419
+ img_bytes = base64.b64decode(b64_data)
420
+ image = QImage.fromData(img_bytes)
421
+
422
+ if image.isNull():
423
+ continue
424
+
425
+ # Use original image size - no scaling
426
+ original_width = image.width()
427
+ original_height = image.height()
428
+
429
+ # Create image format with original base64
430
+ img_format = QTextImageFormat()
431
+ img_format.setName(f"data:image/{mime_type};base64,{b64_data}")
432
+ img_format.setWidth(original_width)
433
+ img_format.setHeight(original_height)
434
+
435
+ # Add image to document resources
436
+ self.document().addResource(
437
+ QTextDocument.ResourceType.ImageResource, img_format.name(), image
438
+ )
439
+
440
+ # Replace markdown with rendered image
441
+ cursor = QTextCursor(self.document())
442
+ cursor.setPosition(match.start())
443
+ cursor.setPosition(match.end(), QTextCursor.MoveMode.KeepAnchor)
444
+ cursor.insertImage(img_format)
445
+
446
+ except Exception as e:
447
+ # If image fails to render, leave the markdown as-is
448
+ print(f"Failed to render image: {e}")
449
+ continue
450
+
451
+ def _get_current_line(self) -> str:
452
+ """Get the text of the current line."""
453
+ cursor = self.textCursor()
454
+ cursor.select(QTextCursor.SelectionType.LineUnderCursor)
455
+ return cursor.selectedText()
456
+
457
+ def _detect_list_type(self, line: str) -> tuple[str | None, str]:
458
+ """
459
+ Detect if line is a list item. Returns (list_type, prefix).
460
+ list_type: 'bullet', 'number', 'checkbox', or None
461
+ prefix: the actual prefix string to use (e.g., '- ', '1. ', '- ☐ ')
462
+ """
463
+ line = line.lstrip()
464
+
465
+ # Checkbox list (Unicode display format)
466
+ if line.startswith(f"{self._CHECK_UNCHECKED_DISPLAY} ") or line.startswith(
467
+ f"{self._CHECK_CHECKED_DISPLAY} "
468
+ ):
469
+ return ("checkbox", f"{self._CHECK_UNCHECKED_DISPLAY} ")
470
+
471
+ # Bullet list
472
+ if re.match(r"^[-*+]\s", line):
473
+ match = re.match(r"^([-*+]\s)", line)
474
+ return ("bullet", match.group(1))
475
+
476
+ # Numbered list
477
+ if re.match(r"^\d+\.\s", line):
478
+ # Extract the number and increment
479
+ match = re.match(r"^(\d+)\.\s", line)
480
+ num = int(match.group(1))
481
+ return ("number", f"{num + 1}. ")
482
+
483
+ return (None, "")
484
+
485
+ def keyPressEvent(self, event):
486
+ """Handle special key events for markdown editing."""
487
+
488
+ # --- Auto-close code fences when typing the 3rd backtick at line start ---
489
+ if event.text() == "`":
490
+ c = self.textCursor()
491
+ block = c.block()
492
+ line = block.text()
493
+ pos_in_block = c.position() - block.position()
494
+
495
+ # text before caret on this line
496
+ before = line[:pos_in_block]
497
+
498
+ # If we've typed exactly two backticks at line start (or after whitespace),
499
+ # treat this backtick as the "third" and expand to a full fenced block.
500
+ if before.endswith("``") and before.strip() == "``":
501
+ start = (
502
+ block.position() + pos_in_block - 2
503
+ ) # start of the two backticks
504
+
505
+ edit = QTextCursor(self.document())
506
+ edit.beginEditBlock()
507
+ edit.setPosition(start)
508
+ edit.setPosition(start + 2, QTextCursor.KeepAnchor)
509
+ edit.insertText("```\n\n```")
510
+ edit.endEditBlock()
511
+
512
+ # place caret on the blank line between the fences
513
+ new_pos = start + 4 # after "```\n"
514
+ c.setPosition(new_pos)
515
+ self.setTextCursor(c)
516
+ return
517
+
518
+ # Handle Enter key for smart list continuation AND code blocks
519
+ if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
520
+ cursor = self.textCursor()
521
+ current_line = self._get_current_line()
522
+
523
+ # Check if we're in a code block
524
+ current_block = cursor.block()
525
+ line_text = current_block.text()
526
+ pos_in_block = cursor.position() - current_block.position()
527
+
528
+ moved = False
529
+ i = 0
530
+ patterns = ["**", "__", "~~", "`", "*", "_"] # bold, italic, strike, code
531
+ # Consume stacked markers like **` if present
532
+ while True:
533
+ matched = False
534
+ for pat in patterns:
535
+ L = len(pat)
536
+ if line_text[pos_in_block + i : pos_in_block + i + L] == pat:
537
+ i += L
538
+ matched = True
539
+ moved = True
540
+ break
541
+ if not matched:
542
+ break
543
+ if moved:
544
+ cursor.movePosition(
545
+ QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, i
546
+ )
547
+ self.setTextCursor(cursor)
548
+
549
+ block_state = current_block.userState()
550
+
551
+ # If current line is opening code fence, or we're inside a code block
552
+ if current_line.strip().startswith("```") or block_state == 1:
553
+ # Just insert a regular newline - the highlighter will format it as code
554
+ super().keyPressEvent(event)
555
+ return
556
+
557
+ # Check for list continuation
558
+ list_type, prefix = self._detect_list_type(current_line)
559
+
560
+ if list_type:
561
+ # Check if the line is empty (just the prefix)
562
+ content = current_line.lstrip()
563
+ is_empty = (
564
+ content == prefix.strip() or not content.replace(prefix, "").strip()
565
+ )
566
+
567
+ if is_empty and self._last_enter_was_empty:
568
+ # Second enter on empty list item - remove the list formatting
569
+ cursor.select(QTextCursor.SelectionType.LineUnderCursor)
570
+ cursor.removeSelectedText()
571
+ cursor.insertText("\n")
572
+ self._last_enter_was_empty = False
573
+ return
574
+ elif is_empty:
575
+ # First enter on empty list item - remember this
576
+ self._last_enter_was_empty = True
577
+ else:
578
+ # Not empty - continue the list
579
+ self._last_enter_was_empty = False
580
+
581
+ # Insert newline and continue the list
582
+ super().keyPressEvent(event)
583
+ cursor = self.textCursor()
584
+ cursor.insertText(prefix)
585
+ return
586
+ else:
587
+ self._last_enter_was_empty = False
588
+ else:
589
+ # Any other key resets the empty enter flag
590
+ self._last_enter_was_empty = False
591
+
592
+ # Default handling
593
+ super().keyPressEvent(event)
594
+
595
+ def mousePressEvent(self, event):
596
+ """Toggle a checkbox only when the click lands on its icon."""
597
+ if event.button() == Qt.LeftButton:
598
+ pt = event.pos()
599
+
600
+ # Cursor and block under the mouse
601
+ cur = self.cursorForPosition(pt)
602
+ block = cur.block()
603
+ text = block.text()
604
+
605
+ # The display tokens, e.g. "☐ " / "☑ " (icon + trailing space)
606
+ unchecked = f"{self._CHECK_UNCHECKED_DISPLAY} "
607
+ checked = f"{self._CHECK_CHECKED_DISPLAY} "
608
+
609
+ # Helper: rect for a single character at a given doc position
610
+ def char_rect_at(doc_pos, ch):
611
+ c = QTextCursor(self.document())
612
+ c.setPosition(doc_pos)
613
+ start_rect = self.cursorRect(
614
+ c
615
+ ) # caret rect at char start (viewport coords)
616
+
617
+ # Use the actual font at this position for an accurate width
618
+ fmt_font = (
619
+ c.charFormat().font() if c.charFormat().isValid() else self.font()
620
+ )
621
+ fm = QFontMetrics(fmt_font)
622
+ w = max(1, fm.horizontalAdvance(ch))
623
+ return QRect(start_rect.x(), start_rect.y(), w, start_rect.height())
624
+
625
+ # Scan the line for any checkbox icons; toggle the one we clicked
626
+ i = 0
627
+ while i < len(text):
628
+ icon = None
629
+ if text.startswith(unchecked, i):
630
+ icon = self._CHECK_UNCHECKED_DISPLAY
631
+ elif text.startswith(checked, i):
632
+ icon = self._CHECK_CHECKED_DISPLAY
633
+
634
+ if icon:
635
+ doc_pos = (
636
+ block.position() + i
637
+ ) # absolute document position of the icon
638
+ r = char_rect_at(doc_pos, icon)
639
+
640
+ if r.contains(pt):
641
+ # Build the replacement: swap ☐ <-> ☑ (keep trailing space)
642
+ new_icon = (
643
+ self._CHECK_CHECKED_DISPLAY
644
+ if icon == self._CHECK_UNCHECKED_DISPLAY
645
+ else self._CHECK_UNCHECKED_DISPLAY
646
+ )
647
+ edit = QTextCursor(self.document())
648
+ edit.beginEditBlock()
649
+ edit.setPosition(doc_pos)
650
+ edit.movePosition(
651
+ QTextCursor.Right, QTextCursor.KeepAnchor, len(icon) + 1
652
+ ) # icon + space
653
+ edit.insertText(f"{new_icon} ")
654
+ edit.endEditBlock()
655
+ return # handled
656
+
657
+ # advance past this token
658
+ i += len(icon) + 1
659
+ else:
660
+ i += 1
661
+
662
+ # Default handling for anything else
663
+ super().mousePressEvent(event)
664
+
665
+ # ------------------------ Toolbar action handlers ------------------------
666
+
667
+ def apply_weight(self):
668
+ """Toggle bold formatting."""
669
+ cursor = self.textCursor()
670
+ if cursor.hasSelection():
671
+ selected = cursor.selectedText()
672
+ # Check if already bold
673
+ if selected.startswith("**") and selected.endswith("**"):
674
+ # Remove bold
675
+ new_text = selected[2:-2]
676
+ else:
677
+ # Add bold
678
+ new_text = f"**{selected}**"
679
+ cursor.insertText(new_text)
680
+ else:
681
+ # No selection - just insert markers
682
+ cursor.insertText("****")
683
+ cursor.movePosition(
684
+ QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 2
685
+ )
686
+ self.setTextCursor(cursor)
687
+
688
+ # Return focus to editor
689
+ self.setFocus()
690
+
691
+ def apply_italic(self):
692
+ """Toggle italic formatting."""
693
+ cursor = self.textCursor()
694
+ if cursor.hasSelection():
695
+ selected = cursor.selectedText()
696
+ if (
697
+ selected.startswith("*")
698
+ and selected.endswith("*")
699
+ and not selected.startswith("**")
700
+ ):
701
+ new_text = selected[1:-1]
702
+ else:
703
+ new_text = f"*{selected}*"
704
+ cursor.insertText(new_text)
705
+ else:
706
+ cursor.insertText("**")
707
+ cursor.movePosition(
708
+ QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 1
709
+ )
710
+ self.setTextCursor(cursor)
711
+
712
+ # Return focus to editor
713
+ self.setFocus()
714
+
715
+ def apply_strikethrough(self):
716
+ """Toggle strikethrough formatting."""
717
+ cursor = self.textCursor()
718
+ if cursor.hasSelection():
719
+ selected = cursor.selectedText()
720
+ if selected.startswith("~~") and selected.endswith("~~"):
721
+ new_text = selected[2:-2]
722
+ else:
723
+ new_text = f"~~{selected}~~"
724
+ cursor.insertText(new_text)
725
+ else:
726
+ cursor.insertText("~~~~")
727
+ cursor.movePosition(
728
+ QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 2
729
+ )
730
+ self.setTextCursor(cursor)
731
+
732
+ # Return focus to editor
733
+ self.setFocus()
734
+
735
+ def apply_code(self):
736
+ """Insert or toggle code block."""
737
+ cursor = self.textCursor()
738
+
739
+ if cursor.hasSelection():
740
+ # Wrap selection in code fence
741
+ selected = cursor.selectedText()
742
+ # Note: selectedText() uses Unicode paragraph separator, replace with newline
743
+ selected = selected.replace("\u2029", "\n")
744
+ new_text = f"```\n{selected}\n```"
745
+ cursor.insertText(new_text)
746
+ else:
747
+ # Insert code block template
748
+ cursor.insertText("```\n\n```")
749
+ cursor.movePosition(
750
+ QTextCursor.MoveOperation.Up, QTextCursor.MoveMode.MoveAnchor, 1
751
+ )
752
+ self.setTextCursor(cursor)
753
+
754
+ # Return focus to editor
755
+ self.setFocus()
756
+
757
+ def apply_heading(self, size: int):
758
+ """Apply heading formatting to current line."""
759
+ cursor = self.textCursor()
760
+
761
+ # Determine heading level from size
762
+ if size >= 24:
763
+ level = 1
764
+ elif size >= 18:
765
+ level = 2
766
+ elif size >= 14:
767
+ level = 3
768
+ else:
769
+ level = 0 # Normal text
770
+
771
+ # Get current line
772
+ cursor.movePosition(
773
+ QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
774
+ )
775
+ cursor.movePosition(
776
+ QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
777
+ )
778
+ line = cursor.selectedText()
779
+
780
+ # Remove existing heading markers
781
+ line = re.sub(r"^#{1,6}\s+", "", line)
782
+
783
+ # Add new heading markers if not normal
784
+ if level > 0:
785
+ new_line = "#" * level + " " + line
786
+ else:
787
+ new_line = line
788
+
789
+ cursor.insertText(new_line)
790
+
791
+ # Return focus to editor
792
+ self.setFocus()
793
+
794
+ def toggle_bullets(self):
795
+ """Toggle bullet list on current line."""
796
+ cursor = self.textCursor()
797
+ cursor.movePosition(
798
+ QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
799
+ )
800
+ cursor.movePosition(
801
+ QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
802
+ )
803
+ line = cursor.selectedText()
804
+
805
+ # Check if already a bullet
806
+ if line.lstrip().startswith("- ") or line.lstrip().startswith("* "):
807
+ # Remove bullet
808
+ new_line = re.sub(r"^\s*[-*]\s+", "", line)
809
+ else:
810
+ # Add bullet
811
+ new_line = "- " + line.lstrip()
812
+
813
+ cursor.insertText(new_line)
814
+
815
+ # Return focus to editor
816
+ self.setFocus()
817
+
818
+ def toggle_numbers(self):
819
+ """Toggle numbered list on current line."""
820
+ cursor = self.textCursor()
821
+ cursor.movePosition(
822
+ QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
823
+ )
824
+ cursor.movePosition(
825
+ QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
826
+ )
827
+ line = cursor.selectedText()
828
+
829
+ # Check if already numbered
830
+ if re.match(r"^\s*\d+\.\s", line):
831
+ # Remove number
832
+ new_line = re.sub(r"^\s*\d+\.\s+", "", line)
833
+ else:
834
+ # Add number
835
+ new_line = "1. " + line.lstrip()
836
+
837
+ cursor.insertText(new_line)
838
+
839
+ # Return focus to editor
840
+ self.setFocus()
841
+
842
+ def toggle_checkboxes(self):
843
+ """Toggle checkbox on current line."""
844
+ cursor = self.textCursor()
845
+ cursor.movePosition(
846
+ QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
847
+ )
848
+ cursor.movePosition(
849
+ QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
850
+ )
851
+ line = cursor.selectedText()
852
+
853
+ # Check if already has checkbox (Unicode display format)
854
+ if (
855
+ f"{self._CHECK_UNCHECKED_DISPLAY} " in line
856
+ or f"{self._CHECK_CHECKED_DISPLAY} " in line
857
+ ):
858
+ # Remove checkbox - use raw string to avoid escape sequence warning
859
+ new_line = re.sub(
860
+ rf"^\s*[{self._CHECK_UNCHECKED_DISPLAY}{self._CHECK_CHECKED_DISPLAY}]\s+",
861
+ "",
862
+ line,
863
+ )
864
+ else:
865
+ # Add checkbox (Unicode display format)
866
+ new_line = f"{self._CHECK_UNCHECKED_DISPLAY} " + line.lstrip()
867
+
868
+ cursor.insertText(new_line)
869
+
870
+ # Return focus to editor
871
+ self.setFocus()
872
+
873
+ def insert_image_from_path(self, path: Path):
874
+ """Insert an image as rendered image (but save as base64 markdown)."""
875
+ if not path.exists():
876
+ return
877
+
878
+ # Read the ORIGINAL image file bytes for base64 encoding
879
+ with open(path, "rb") as f:
880
+ img_data = f.read()
881
+
882
+ # Encode ORIGINAL file bytes to base64
883
+ b64_data = base64.b64encode(img_data).decode("ascii")
884
+
885
+ # Determine mime type
886
+ ext = path.suffix.lower()
887
+ mime_map = {
888
+ ".png": "image/png",
889
+ ".jpg": "image/jpeg",
890
+ ".jpeg": "image/jpeg",
891
+ ".gif": "image/gif",
892
+ ".bmp": "image/bmp",
893
+ ".webp": "image/webp",
894
+ }
895
+ mime_type = mime_map.get(ext, "image/png")
896
+
897
+ # Load the image
898
+ image = QImage(str(path))
899
+ if image.isNull():
900
+ return
901
+
902
+ # Use ORIGINAL size - no scaling!
903
+ original_width = image.width()
904
+ original_height = image.height()
905
+
906
+ # Create image format with original base64
907
+ img_format = QTextImageFormat()
908
+ img_format.setName(f"data:image/{mime_type};base64,{b64_data}")
909
+ img_format.setWidth(original_width)
910
+ img_format.setHeight(original_height)
911
+
912
+ # Add ORIGINAL image to document resources
913
+ self.document().addResource(
914
+ QTextDocument.ResourceType.ImageResource, img_format.name(), image
915
+ )
916
+
917
+ # Insert the image at original size
918
+ cursor = self.textCursor()
919
+ cursor.insertImage(img_format)
920
+ cursor.insertText("\n") # Add newline after image