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