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