1783 lines
65 KiB
Python
1783 lines
65 KiB
Python
from __future__ import annotations
|
||
|
||
import base64
|
||
import re
|
||
from pathlib import Path
|
||
from typing import Optional, Tuple
|
||
|
||
from PySide6.QtGui import (
|
||
QFont,
|
||
QFontDatabase,
|
||
QFontMetrics,
|
||
QImage,
|
||
QMouseEvent,
|
||
QTextBlock,
|
||
QTextCharFormat,
|
||
QTextCursor,
|
||
QTextDocument,
|
||
QTextFormat,
|
||
QTextBlockFormat,
|
||
QTextImageFormat,
|
||
QDesktopServices,
|
||
)
|
||
from PySide6.QtCore import Qt, QRect, QTimer, QUrl
|
||
from PySide6.QtWidgets import QDialog, QTextEdit
|
||
|
||
from .theme import ThemeManager
|
||
from .markdown_highlighter import MarkdownHighlighter
|
||
from .code_block_editor_dialog import CodeBlockEditorDialog
|
||
from . import strings
|
||
|
||
|
||
class MarkdownEditor(QTextEdit):
|
||
"""A QTextEdit that stores/loads markdown and provides live rendering."""
|
||
|
||
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
|
||
|
||
def __init__(self, theme_manager: ThemeManager, *args, **kwargs):
|
||
super().__init__(*args, **kwargs)
|
||
|
||
self.theme_manager = theme_manager
|
||
|
||
# Setup tab width
|
||
tab_w = 4 * self.fontMetrics().horizontalAdvance(" ")
|
||
self.setTabStopDistance(tab_w)
|
||
|
||
# We accept plain text, not rich text (markdown is plain text)
|
||
self.setAcceptRichText(False)
|
||
|
||
# Load in our preferred fonts
|
||
base_dir = Path(__file__).resolve().parent
|
||
|
||
# Load regular text font (primary)
|
||
regular_font_path = base_dir / "fonts" / "DejaVuSans.ttf"
|
||
regular_font_id = QFontDatabase.addApplicationFont(str(regular_font_path))
|
||
|
||
# Load Symbols font (fallback)
|
||
symbols_font_path = base_dir / "fonts" / "NotoSansSymbols2-Regular.ttf"
|
||
symbols_font_id = QFontDatabase.addApplicationFont(str(symbols_font_path))
|
||
symbols_families = QFontDatabase.applicationFontFamilies(symbols_font_id)
|
||
self.symbols_font_family = symbols_families[0]
|
||
|
||
# Use the regular Noto Sans family as the editor font
|
||
regular_families = QFontDatabase.applicationFontFamilies(regular_font_id)
|
||
if regular_families:
|
||
self.text_font_family = regular_families[0]
|
||
self.qfont = QFont(self.text_font_family, 11)
|
||
self.setFont(self.qfont)
|
||
|
||
self._apply_line_spacing() # 1.25× initial spacing
|
||
|
||
# Checkbox characters (Unicode for display, markdown for storage)
|
||
self._CHECK_UNCHECKED_DISPLAY = "☐"
|
||
self._CHECK_CHECKED_DISPLAY = "☑"
|
||
self._CHECK_UNCHECKED_STORAGE = "[ ]"
|
||
self._CHECK_CHECKED_STORAGE = "[x]"
|
||
|
||
# Bullet character (Unicode for display, "- " for markdown)
|
||
self._BULLET_DISPLAY = "•"
|
||
self._BULLET_STORAGE = "-"
|
||
|
||
# Install syntax highlighter
|
||
self.highlighter = MarkdownHighlighter(self.document(), theme_manager, self)
|
||
|
||
# Initialize code block metadata
|
||
from .code_highlighter import CodeBlockMetadata
|
||
|
||
self._code_metadata = CodeBlockMetadata()
|
||
|
||
# Track current list type for smart enter handling
|
||
self._last_enter_was_empty = False
|
||
|
||
# Track if we're currently updating text programmatically
|
||
self._updating = False
|
||
|
||
# Help avoid double-click selecting of checkbox
|
||
self._suppress_next_checkbox_double_click = False
|
||
|
||
# Guard to avoid recursive selection tweaks
|
||
self._adjusting_selection = False
|
||
|
||
# After selections change, trim list prefixes from full-line selections
|
||
# (e.g. after triple-clicking a list item to select the line).
|
||
self.selectionChanged.connect(self._maybe_trim_list_prefix_from_line_selection)
|
||
|
||
# Connect to text changes for smart formatting
|
||
self.textChanged.connect(self._on_text_changed)
|
||
self.textChanged.connect(self._update_code_block_row_backgrounds)
|
||
self.theme_manager.themeChanged.connect(
|
||
lambda *_: self._update_code_block_row_backgrounds()
|
||
)
|
||
|
||
# Enable mouse tracking for checkbox clicking
|
||
self.viewport().setMouseTracking(True)
|
||
# Also mark links as mouse-accessible
|
||
flags = self.textInteractionFlags()
|
||
self.setTextInteractionFlags(
|
||
flags | Qt.TextInteractionFlag.LinksAccessibleByMouse
|
||
)
|
||
|
||
def setDocument(self, doc):
|
||
super().setDocument(doc)
|
||
# Recreate the highlighter for the new document
|
||
# (the old one gets deleted with the old document)
|
||
if hasattr(self, "highlighter") and hasattr(self, "theme_manager"):
|
||
self.highlighter = MarkdownHighlighter(
|
||
self.document(), self.theme_manager, self
|
||
)
|
||
self._apply_line_spacing()
|
||
self._apply_code_block_spacing()
|
||
QTimer.singleShot(0, self._update_code_block_row_backgrounds)
|
||
|
||
def setFont(self, font: QFont) -> None: # type: ignore[override]
|
||
"""
|
||
Ensure that whenever the base editor font changes, our highlighter
|
||
re-computes checkbox / bullet formats.
|
||
"""
|
||
# Keep qfont in sync
|
||
self.qfont = QFont(font)
|
||
super().setFont(self.qfont)
|
||
|
||
# If the highlighter is already attached, let it rebuild its formats
|
||
highlighter = getattr(self, "highlighter", None)
|
||
if highlighter is not None:
|
||
refresh = getattr(highlighter, "refresh_for_font_change", None)
|
||
if callable(refresh):
|
||
refresh()
|
||
|
||
def showEvent(self, e):
|
||
super().showEvent(e)
|
||
# First time the widget is shown, Qt may rebuild layout once more.
|
||
QTimer.singleShot(0, self._update_code_block_row_backgrounds)
|
||
|
||
def _on_text_changed(self):
|
||
"""Handle live formatting updates - convert checkbox markdown to Unicode."""
|
||
if self._updating:
|
||
return
|
||
|
||
self._updating = True
|
||
try:
|
||
c = self.textCursor()
|
||
block = c.block()
|
||
line = block.text()
|
||
pos_in_block = c.position() - block.position()
|
||
|
||
# Transform markdown checkboxes and 'TODO' to unicode checkboxes
|
||
def transform_line(s: str) -> str:
|
||
s = s.replace(
|
||
f"- {self._CHECK_CHECKED_STORAGE} ",
|
||
f"{self._CHECK_CHECKED_DISPLAY} ",
|
||
)
|
||
s = s.replace(
|
||
f"- {self._CHECK_UNCHECKED_STORAGE} ",
|
||
f"{self._CHECK_UNCHECKED_DISPLAY} ",
|
||
)
|
||
s = re.sub(
|
||
r"^([ \t]*)TODO\b[:\-]?\s+",
|
||
lambda m: f"{m.group(1)}\n{self._CHECK_UNCHECKED_DISPLAY} ",
|
||
s,
|
||
)
|
||
return s
|
||
|
||
new_line = transform_line(line)
|
||
if new_line != line:
|
||
# Replace just the current block
|
||
bc = QTextCursor(block)
|
||
bc.beginEditBlock()
|
||
bc.select(QTextCursor.BlockUnderCursor)
|
||
bc.insertText(new_line)
|
||
bc.endEditBlock()
|
||
|
||
# Restore cursor near its original visual position in the edited line
|
||
new_pos = min(
|
||
block.position() + len(new_line), block.position() + pos_in_block
|
||
)
|
||
c.setPosition(new_pos)
|
||
self.setTextCursor(c)
|
||
finally:
|
||
self._updating = False
|
||
|
||
def _is_inside_code_block(self, block):
|
||
"""Return True if 'block' is inside a fenced code block (based on fences above)."""
|
||
inside = False
|
||
b = block.previous()
|
||
while b.isValid():
|
||
if b.text().strip().startswith("```"):
|
||
inside = not inside
|
||
b = b.previous()
|
||
return inside
|
||
|
||
def _update_code_block_row_backgrounds(self) -> None:
|
||
"""Paint a full-width background behind each fenced ``` code block."""
|
||
|
||
doc = self.document()
|
||
if doc is None:
|
||
return
|
||
|
||
bg_brush = self.highlighter.code_block_format.background()
|
||
|
||
selections: list[QTextEdit.ExtraSelection] = []
|
||
|
||
inside = False
|
||
block = doc.begin()
|
||
block_start_pos: int | None = None
|
||
|
||
while block.isValid():
|
||
text = block.text()
|
||
stripped = text.strip()
|
||
is_fence = stripped.startswith("```")
|
||
|
||
if is_fence:
|
||
if not inside:
|
||
# Opening fence: remember where this block starts
|
||
inside = True
|
||
block_start_pos = block.position()
|
||
else:
|
||
# Closing fence: create ONE selection from opening fence
|
||
# to the end of this closing fence block.
|
||
inside = False
|
||
if block_start_pos is not None:
|
||
sel = QTextEdit.ExtraSelection()
|
||
fmt = QTextCharFormat()
|
||
fmt.setBackground(bg_brush)
|
||
fmt.setProperty(QTextFormat.FullWidthSelection, True)
|
||
fmt.setProperty(QTextFormat.UserProperty, "codeblock_bg")
|
||
sel.format = fmt
|
||
|
||
cursor = QTextCursor(doc)
|
||
cursor.setPosition(block_start_pos)
|
||
# extend to the end of the closing fence block
|
||
cursor.setPosition(
|
||
block.position() + block.length() - 1,
|
||
QTextCursor.MoveMode.KeepAnchor,
|
||
)
|
||
sel.cursor = cursor
|
||
|
||
selections.append(sel)
|
||
block_start_pos = None
|
||
|
||
block = block.next()
|
||
|
||
# If the document ends while we're still inside a code block,
|
||
# extend the selection to the end of the document.
|
||
if inside and block_start_pos is not None:
|
||
sel = QTextEdit.ExtraSelection()
|
||
fmt = QTextCharFormat()
|
||
fmt.setBackground(bg_brush)
|
||
fmt.setProperty(QTextFormat.FullWidthSelection, True)
|
||
fmt.setProperty(QTextFormat.UserProperty, "codeblock_bg")
|
||
sel.format = fmt
|
||
|
||
cursor = QTextCursor(doc)
|
||
cursor.setPosition(block_start_pos)
|
||
cursor.movePosition(QTextCursor.End, QTextCursor.MoveMode.KeepAnchor)
|
||
sel.cursor = cursor
|
||
|
||
selections.append(sel)
|
||
|
||
# Keep any other extraSelections (current-line highlight etc.)
|
||
others = [
|
||
s
|
||
for s in self.extraSelections()
|
||
if s.format.property(QTextFormat.UserProperty) != "codeblock_bg"
|
||
]
|
||
self.setExtraSelections(others + selections)
|
||
|
||
def _find_code_block_bounds(
|
||
self, block: QTextBlock
|
||
) -> Optional[Tuple[QTextBlock, QTextBlock]]:
|
||
"""
|
||
Given a block that is either inside a fenced code block or on a fence,
|
||
return (opening_fence_block, closing_fence_block).
|
||
Returns None if we can't find a proper pair.
|
||
"""
|
||
if not block.isValid():
|
||
return None
|
||
|
||
def is_fence(b: QTextBlock) -> bool:
|
||
return b.isValid() and b.text().strip().startswith("```")
|
||
|
||
# If we're on a fence line, decide if it's opening or closing
|
||
if is_fence(block):
|
||
# If we're "inside" just before this fence, this one closes.
|
||
if self._is_inside_code_block(block.previous()):
|
||
close_block = block
|
||
open_block = block.previous()
|
||
while open_block.isValid() and not is_fence(open_block):
|
||
open_block = open_block.previous()
|
||
if not is_fence(open_block):
|
||
return None
|
||
return open_block, close_block
|
||
else:
|
||
# Treat as opening fence; search downward for the closing one.
|
||
open_block = block
|
||
close_block = open_block.next()
|
||
while close_block.isValid() and not is_fence(close_block):
|
||
close_block = close_block.next()
|
||
if not is_fence(close_block):
|
||
return None
|
||
return open_block, close_block
|
||
|
||
# Normal interior line: search up for opening fence, down for closing.
|
||
open_block = block.previous()
|
||
while open_block.isValid() and not is_fence(open_block):
|
||
open_block = open_block.previous()
|
||
if not is_fence(open_block):
|
||
return None
|
||
|
||
close_block = open_block.next()
|
||
while close_block.isValid() and not is_fence(close_block):
|
||
close_block = close_block.next()
|
||
if not is_fence(close_block):
|
||
return None
|
||
|
||
return open_block, close_block
|
||
|
||
def _get_code_block_text(
|
||
self, open_block: QTextBlock, close_block: QTextBlock
|
||
) -> str:
|
||
"""Return the inner text (between fences) as a normal '\\n'-joined string."""
|
||
lines = []
|
||
b = open_block.next()
|
||
while b.isValid() and b != close_block:
|
||
lines.append(b.text())
|
||
b = b.next()
|
||
return "\n".join(lines)
|
||
|
||
def _replace_code_block_text(
|
||
self, open_block: QTextBlock, close_block: QTextBlock, new_text: str
|
||
) -> None:
|
||
"""
|
||
Replace everything between the two fences with `new_text`.
|
||
Fences themselves are left untouched.
|
||
"""
|
||
doc = self.document()
|
||
if doc is None:
|
||
return
|
||
|
||
cursor = QTextCursor(doc)
|
||
|
||
# Start just after the opening fence's newline
|
||
start_pos = open_block.position() + len(open_block.text())
|
||
# End at the start of the closing fence
|
||
end_pos = close_block.position()
|
||
|
||
cursor.setPosition(start_pos)
|
||
cursor.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor)
|
||
|
||
cursor.beginEditBlock()
|
||
# Normalise trailing newline(s)
|
||
new_text = new_text.rstrip("\n")
|
||
if new_text:
|
||
cursor.removeSelectedText()
|
||
cursor.insertText("\n" + new_text + "\n")
|
||
else:
|
||
# Empty block – keep one blank line inside the fences
|
||
cursor.removeSelectedText()
|
||
cursor.insertText("\n\n")
|
||
cursor.endEditBlock()
|
||
|
||
# Re-apply spacing and backgrounds
|
||
if hasattr(self, "_apply_code_block_spacing"):
|
||
self._apply_code_block_spacing()
|
||
if hasattr(self, "_update_code_block_row_backgrounds"):
|
||
self._update_code_block_row_backgrounds()
|
||
|
||
# Trigger rehighlight
|
||
if hasattr(self, "highlighter"):
|
||
self.highlighter.rehighlight()
|
||
|
||
def _edit_code_block(self, block: QTextBlock) -> bool:
|
||
"""Open a popup editor for the code block containing `block`.
|
||
|
||
Returns True if a dialog was shown (regardless of OK/Cancel),
|
||
False if no well-formed fenced block was found.
|
||
"""
|
||
bounds = self._find_code_block_bounds(block)
|
||
if not bounds:
|
||
return False
|
||
|
||
open_block, close_block = bounds
|
||
|
||
# Current language from metadata (if any)
|
||
lang = None
|
||
if hasattr(self, "_code_metadata"):
|
||
lang = self._code_metadata.get_language(open_block.blockNumber())
|
||
|
||
code_text = self._get_code_block_text(open_block, close_block)
|
||
|
||
dlg = CodeBlockEditorDialog(code_text, lang, parent=self, allow_delete=True)
|
||
result = dlg.exec()
|
||
if result != QDialog.DialogCode.Accepted:
|
||
# Dialog was shown but user cancelled; event is "handled".
|
||
return True
|
||
|
||
# If the user requested deletion, remove the whole block
|
||
if hasattr(dlg, "was_deleted") and dlg.was_deleted():
|
||
self._delete_code_block(open_block)
|
||
return True
|
||
|
||
new_code = dlg.code()
|
||
new_lang = dlg.language()
|
||
|
||
# Update document text but keep fences
|
||
self._replace_code_block_text(open_block, close_block, new_code)
|
||
|
||
# Update metadata language if changed
|
||
if new_lang is not None:
|
||
if not hasattr(self, "_code_metadata"):
|
||
from .code_highlighter import CodeBlockMetadata
|
||
|
||
self._code_metadata = CodeBlockMetadata()
|
||
self._code_metadata.set_language(open_block.blockNumber(), new_lang)
|
||
if hasattr(self, "highlighter"):
|
||
self.highlighter.rehighlight()
|
||
|
||
return True
|
||
|
||
def _delete_code_block(self, block: QTextBlock) -> bool:
|
||
"""Delete the fenced code block containing `block`.
|
||
|
||
Returns True if a block was deleted, False otherwise.
|
||
"""
|
||
bounds = self._find_code_block_bounds(block)
|
||
if not bounds:
|
||
return False
|
||
|
||
open_block, close_block = bounds
|
||
fence_block_num = open_block.blockNumber()
|
||
|
||
doc = self.document()
|
||
if doc is None:
|
||
return False
|
||
|
||
# Remove from the opening fence down to just before the block after
|
||
# the closing fence (so we also remove the trailing blank line).
|
||
start_pos = open_block.position()
|
||
after_block = close_block.next()
|
||
if after_block.isValid():
|
||
end_pos = after_block.position()
|
||
else:
|
||
end_pos = close_block.position() + len(close_block.text())
|
||
|
||
cursor = QTextCursor(doc)
|
||
cursor.beginEditBlock()
|
||
cursor.setPosition(start_pos)
|
||
cursor.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor)
|
||
cursor.removeSelectedText()
|
||
cursor.endEditBlock()
|
||
|
||
# Clear language metadata for this block, if supported
|
||
if hasattr(self, "_code_metadata"):
|
||
clear = getattr(self._code_metadata, "clear_language", None)
|
||
if clear is not None and fence_block_num != -1:
|
||
clear(fence_block_num)
|
||
|
||
# Refresh visuals (spacing + backgrounds + syntax)
|
||
if hasattr(self, "_apply_code_block_spacing"):
|
||
self._apply_code_block_spacing()
|
||
if hasattr(self, "_update_code_block_row_backgrounds"):
|
||
self._update_code_block_row_backgrounds()
|
||
if hasattr(self, "highlighter"):
|
||
self.highlighter.rehighlight()
|
||
|
||
# Move caret to where the block used to be
|
||
cursor = self.textCursor()
|
||
cursor.setPosition(start_pos)
|
||
self.setTextCursor(cursor)
|
||
self.setFocus()
|
||
|
||
return True
|
||
|
||
def _apply_line_spacing(self, height: float = 125.0):
|
||
"""Apply proportional line spacing to the whole document."""
|
||
doc = self.document()
|
||
if doc is None:
|
||
return
|
||
|
||
cursor = QTextCursor(doc)
|
||
cursor.beginEditBlock()
|
||
cursor.select(QTextCursor.Document)
|
||
|
||
fmt = QTextBlockFormat()
|
||
fmt.setLineHeight(
|
||
height, # 125.0 = 1.25×
|
||
QTextBlockFormat.LineHeightTypes.ProportionalHeight.value,
|
||
)
|
||
cursor.mergeBlockFormat(fmt)
|
||
cursor.endEditBlock()
|
||
|
||
def _apply_code_block_spacing(self):
|
||
"""
|
||
Make all fenced code-block lines (including ``` fences) single-spaced
|
||
and give them a solid background.
|
||
"""
|
||
doc = self.document()
|
||
if doc is None:
|
||
return
|
||
|
||
cursor = QTextCursor(doc)
|
||
cursor.beginEditBlock()
|
||
|
||
bg_brush = self.highlighter.code_block_format.background()
|
||
|
||
inside = False
|
||
block = doc.begin()
|
||
while block.isValid():
|
||
text = block.text()
|
||
stripped = text.strip()
|
||
is_fence = stripped.startswith("```")
|
||
is_code_line = is_fence or inside
|
||
|
||
fmt = block.blockFormat()
|
||
|
||
if is_code_line:
|
||
# Single spacing for code lines
|
||
fmt.setLineHeight(
|
||
0.0,
|
||
QTextBlockFormat.LineHeightTypes.SingleHeight.value,
|
||
)
|
||
# Solid background for the whole line (no seams)
|
||
fmt.setBackground(bg_brush)
|
||
else:
|
||
# Not in a code block → clear any stale background
|
||
fmt.clearProperty(QTextFormat.BackgroundBrush)
|
||
|
||
cursor.setPosition(block.position())
|
||
cursor.setBlockFormat(fmt)
|
||
|
||
if is_fence:
|
||
inside = not inside
|
||
|
||
block = block.next()
|
||
|
||
cursor.endEditBlock()
|
||
|
||
def _ensure_escape_line_after_closing_fence(self, fence_block: QTextBlock) -> None:
|
||
"""
|
||
Ensure there is at least one block *after* the given closing fence line.
|
||
|
||
If the fence is the last block in the document, we append a newline,
|
||
so the caret can always move outside the code block.
|
||
"""
|
||
doc = self.document()
|
||
if doc is None or not fence_block.isValid():
|
||
return
|
||
|
||
after = fence_block.next()
|
||
if after.isValid():
|
||
# There's already a block after the fence; nothing to do.
|
||
return
|
||
|
||
# No block after fence → create a blank line
|
||
cursor = QTextCursor(doc)
|
||
cursor.beginEditBlock()
|
||
endpos = fence_block.position() + len(fence_block.text())
|
||
cursor.setPosition(endpos)
|
||
cursor.insertText("\n")
|
||
cursor.endEditBlock()
|
||
|
||
def to_markdown(self) -> str:
|
||
"""Export current content as markdown."""
|
||
# First, extract any embedded images and convert to markdown
|
||
text = self._extract_images_to_markdown()
|
||
|
||
# Convert Unicode checkboxes back to markdown syntax
|
||
text = text.replace(
|
||
f"{self._CHECK_CHECKED_DISPLAY} ", f"- {self._CHECK_CHECKED_STORAGE} "
|
||
)
|
||
text = text.replace(
|
||
f"{self._CHECK_UNCHECKED_DISPLAY} ", f"- {self._CHECK_UNCHECKED_STORAGE} "
|
||
)
|
||
|
||
# Convert Unicode bullets back to "- " at the start of a line
|
||
text = re.sub(
|
||
rf"(?m)^(\s*){re.escape(self._BULLET_DISPLAY)}\s+",
|
||
rf"\1{self._BULLET_STORAGE} ",
|
||
text,
|
||
)
|
||
|
||
# Append code block metadata if present
|
||
if hasattr(self, "_code_metadata"):
|
||
metadata_str = self._code_metadata.serialize()
|
||
if metadata_str:
|
||
text = text.rstrip() + "\n\n" + metadata_str
|
||
|
||
return text
|
||
|
||
def _extract_images_to_markdown(self) -> str:
|
||
"""Extract embedded images and convert them back to markdown format."""
|
||
doc = self.document()
|
||
cursor = QTextCursor(doc)
|
||
|
||
# Build the output text with images as markdown
|
||
result = []
|
||
cursor.movePosition(QTextCursor.MoveOperation.Start)
|
||
|
||
block = doc.begin()
|
||
while block.isValid():
|
||
it = block.begin()
|
||
block_text = ""
|
||
|
||
while not it.atEnd():
|
||
fragment = it.fragment()
|
||
if fragment.isValid():
|
||
if fragment.charFormat().isImageFormat():
|
||
# This is an image - convert to markdown
|
||
img_format = fragment.charFormat().toImageFormat()
|
||
img_name = img_format.name()
|
||
# The name contains the data URI
|
||
if img_name.startswith("data:image/"):
|
||
block_text += f""
|
||
else:
|
||
# Regular text
|
||
block_text += fragment.text()
|
||
it += 1
|
||
|
||
result.append(block_text)
|
||
block = block.next()
|
||
|
||
return "\n".join(result)
|
||
|
||
def from_markdown(self, markdown_text: str):
|
||
"""Load markdown text into the editor."""
|
||
# Extract and load code block metadata if present
|
||
from .code_highlighter import CodeBlockMetadata
|
||
|
||
if not hasattr(self, "_code_metadata"):
|
||
self._code_metadata = CodeBlockMetadata()
|
||
|
||
self._code_metadata.deserialize(markdown_text)
|
||
# Remove metadata comment from displayed text
|
||
markdown_text = re.sub(r"\s*<!-- code-langs: [^>]+ -->\s*$", "", markdown_text)
|
||
|
||
# Convert markdown checkboxes to Unicode for display
|
||
display_text = markdown_text.replace(
|
||
f"- {self._CHECK_CHECKED_STORAGE} ", f"{self._CHECK_CHECKED_DISPLAY} "
|
||
)
|
||
display_text = display_text.replace(
|
||
f"- {self._CHECK_UNCHECKED_STORAGE} ", f"{self._CHECK_UNCHECKED_DISPLAY} "
|
||
)
|
||
# Also convert any plain 'TODO ' at the start of a line to an unchecked checkbox
|
||
display_text = re.sub(
|
||
r"(?m)^([ \t]*)TODO\s",
|
||
lambda m: f"{m.group(1)}\n{self._CHECK_UNCHECKED_DISPLAY} ",
|
||
display_text,
|
||
)
|
||
|
||
# Convert simple markdown bullets ("- ", "* ", "+ ") to Unicode bullets,
|
||
# but skip checkbox lines (- [ ] / - [x])
|
||
display_text = re.sub(
|
||
r"(?m)^([ \t]*)[-*+]\s+(?!\[[ xX]\])",
|
||
rf"\1{self._BULLET_DISPLAY} ",
|
||
display_text,
|
||
)
|
||
|
||
self._updating = True
|
||
try:
|
||
self.setPlainText(display_text)
|
||
if hasattr(self, "highlighter") and self.highlighter:
|
||
self.highlighter.rehighlight()
|
||
finally:
|
||
self._updating = False
|
||
|
||
self._apply_line_spacing()
|
||
self._apply_code_block_spacing()
|
||
|
||
# Render any embedded images
|
||
self._render_images()
|
||
|
||
self._update_code_block_row_backgrounds()
|
||
QTimer.singleShot(0, self._update_code_block_row_backgrounds)
|
||
|
||
def _render_images(self):
|
||
"""Find and render base64 images in the document."""
|
||
text = self.toPlainText()
|
||
|
||
# Pattern for markdown images with base64 data
|
||
img_pattern = r"!\[([^\]]*)\]\(data:image/([^;]+);base64,([^\)]+)\)"
|
||
|
||
matches = list(re.finditer(img_pattern, text))
|
||
|
||
if not matches:
|
||
return
|
||
|
||
# Process matches in reverse to preserve positions
|
||
for match in reversed(matches):
|
||
mime_type = match.group(2)
|
||
b64_data = match.group(3)
|
||
|
||
# Decode base64 to image
|
||
img_bytes = base64.b64decode(b64_data)
|
||
image = QImage.fromData(img_bytes)
|
||
|
||
if image.isNull():
|
||
continue
|
||
|
||
# Use original image size - no scaling
|
||
original_width = image.width()
|
||
original_height = image.height()
|
||
|
||
# Create image format with original base64
|
||
img_format = QTextImageFormat()
|
||
img_format.setName(f"data:image/{mime_type};base64,{b64_data}")
|
||
img_format.setWidth(original_width)
|
||
img_format.setHeight(original_height)
|
||
|
||
# Add image to document resources
|
||
self.document().addResource(
|
||
QTextDocument.ResourceType.ImageResource, img_format.name(), image
|
||
)
|
||
|
||
# Replace markdown with rendered image
|
||
cursor = QTextCursor(self.document())
|
||
cursor.setPosition(match.start())
|
||
cursor.setPosition(match.end(), QTextCursor.MoveMode.KeepAnchor)
|
||
cursor.insertImage(img_format)
|
||
|
||
def _get_current_line(self) -> str:
|
||
"""Get the text of the current line."""
|
||
cursor = self.textCursor()
|
||
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
|
||
return cursor.selectedText()
|
||
|
||
def _list_prefix_length_for_block(self, block) -> int:
|
||
"""Return the length (in chars) of the visual list prefix for the given
|
||
block (including leading indentation), or 0 if it's not a list item.
|
||
"""
|
||
line = block.text()
|
||
stripped = line.lstrip()
|
||
leading_spaces = len(line) - len(stripped)
|
||
|
||
# Checkbox (Unicode display)
|
||
if stripped.startswith(
|
||
f"{self._CHECK_UNCHECKED_DISPLAY} "
|
||
) or stripped.startswith(f"{self._CHECK_CHECKED_DISPLAY} "):
|
||
return leading_spaces + 2 # icon + space
|
||
|
||
# Unicode bullet
|
||
if stripped.startswith(f"{self._BULLET_DISPLAY} "):
|
||
return leading_spaces + 2 # bullet + space
|
||
|
||
# Markdown bullet list (-, *, +)
|
||
if re.match(r"^[-*+]\s", stripped):
|
||
return leading_spaces + 2 # marker + space
|
||
|
||
# Numbered list: e.g. "1. "
|
||
m = re.match(r"^(\d+\.\s)", stripped)
|
||
if m:
|
||
return leading_spaces + leading_spaces + (len(m.group(1)) - leading_spaces)
|
||
|
||
return 0
|
||
|
||
def _maybe_trim_list_prefix_from_line_selection(self) -> None:
|
||
"""
|
||
If the current selection looks like a full-line selection on a list item
|
||
(for example, from a triple-click), trim the selection so that it starts
|
||
just *after* the visual list prefix (checkbox / bullet / number), and
|
||
ends at the end of the text on that line (not on the next line's newline).
|
||
"""
|
||
# Avoid re-entry when we move the cursor ourselves.
|
||
if getattr(self, "_adjusting_selection", False):
|
||
return
|
||
|
||
cursor = self.textCursor()
|
||
if not cursor.hasSelection():
|
||
return
|
||
|
||
start = cursor.selectionStart()
|
||
end = cursor.selectionEnd()
|
||
if start == end:
|
||
return
|
||
|
||
doc = self.document()
|
||
# 'end' is exclusive; use end - 1 so we land in the last selected block.
|
||
start_block = doc.findBlock(start)
|
||
end_block = doc.findBlock(end - 1)
|
||
if not start_block.isValid() or start_block != end_block:
|
||
# Only adjust single-line selections.
|
||
return
|
||
|
||
# How much list prefix (indent + checkbox/bullet/number) this block has
|
||
prefix_len = self._list_prefix_length_for_block(start_block)
|
||
if prefix_len <= 0:
|
||
return
|
||
|
||
block_start = start_block.position()
|
||
prefix_end = block_start + prefix_len
|
||
|
||
# If the selection already starts after the prefix, nothing to do.
|
||
if start >= prefix_end:
|
||
return
|
||
|
||
line_text = start_block.text()
|
||
line_end = block_start + len(line_text) # end of visible text on this line
|
||
|
||
# Only treat it as a "full line" selection if it reaches the end of the
|
||
# visible text. Triple-click usually selects to at least here (often +1 for
|
||
# the newline).
|
||
if end < line_end:
|
||
return
|
||
|
||
# Clamp the selection so that it ends at the end of this line's text,
|
||
# *not* at the newline / start of the next block. This keeps the caret
|
||
# blinking on the selected line instead of the next line.
|
||
visual_end = line_end
|
||
|
||
self._adjusting_selection = True
|
||
try:
|
||
new_cursor = self.textCursor()
|
||
new_cursor.setPosition(prefix_end)
|
||
new_cursor.setPosition(visual_end, QTextCursor.KeepAnchor)
|
||
self.setTextCursor(new_cursor)
|
||
finally:
|
||
self._adjusting_selection = False
|
||
|
||
def _detect_list_type(self, line: str) -> tuple[str | None, str]:
|
||
"""
|
||
Detect if line is a list item. Returns (list_type, prefix).
|
||
list_type: 'bullet', 'number', 'checkbox', or None
|
||
prefix: the actual prefix string to use (e.g., '- ', '1. ', '- ☐ ')
|
||
"""
|
||
line = line.lstrip()
|
||
|
||
# Checkbox list (Unicode display format)
|
||
if line.startswith(f"{self._CHECK_UNCHECKED_DISPLAY} ") or line.startswith(
|
||
f"{self._CHECK_CHECKED_DISPLAY} "
|
||
):
|
||
return ("checkbox", f"{self._CHECK_UNCHECKED_DISPLAY} ")
|
||
|
||
# Bullet list – Unicode bullet
|
||
if line.startswith(f"{self._BULLET_DISPLAY} "):
|
||
return ("bullet", f"{self._BULLET_DISPLAY} ")
|
||
|
||
# Bullet list - markdown bullet
|
||
if re.match(r"^[-*+]\s", line):
|
||
match = re.match(r"^([-*+]\s)", line)
|
||
return ("bullet", match.group(1))
|
||
|
||
# Numbered list
|
||
if re.match(r"^\d+\.\s", line):
|
||
# Extract the number and increment
|
||
match = re.match(r"^(\d+)\.\s", line)
|
||
num = int(match.group(1))
|
||
return ("number", f"{num + 1}. ")
|
||
|
||
return (None, "")
|
||
|
||
def _url_at_pos(self, pos) -> str | None:
|
||
"""
|
||
Return the URL under the given widget position, or None if there isn't one.
|
||
"""
|
||
cursor = self.cursorForPosition(pos)
|
||
block = cursor.block()
|
||
text = block.text()
|
||
if not text:
|
||
return None
|
||
|
||
# Position of the cursor inside this block
|
||
pos_in_block = cursor.position() - block.position()
|
||
|
||
# Same pattern as in MarkdownHighlighter
|
||
url_pattern = re.compile(r"(https?://[^\s<>()]+)")
|
||
for m in url_pattern.finditer(text):
|
||
start, end = m.span(1)
|
||
if start <= pos_in_block < end:
|
||
return m.group(1)
|
||
|
||
return None
|
||
|
||
def keyPressEvent(self, event):
|
||
"""Handle special key events for markdown editing."""
|
||
c = self.textCursor()
|
||
block = c.block()
|
||
|
||
in_code = self._is_inside_code_block(block)
|
||
is_fence_line = block.text().strip().startswith("```")
|
||
|
||
# --- NEW: 3rd backtick shortcut → open code block dialog ---
|
||
# Only when we're *not* already in a code block or on a fence line.
|
||
if event.text() == "`" and not (in_code or is_fence_line):
|
||
line = block.text()
|
||
pos_in_block = c.position() - block.position()
|
||
before = line[:pos_in_block]
|
||
|
||
# "before" currently contains whatever's before the *third* backtick.
|
||
# We trigger only when the line is (whitespace + "``") before the caret.
|
||
if before.endswith("``") and before.strip() == "``":
|
||
doc = self.document()
|
||
if doc is not None:
|
||
# Remove the two backticks that were already typed
|
||
start = block.position() + pos_in_block - 2
|
||
edit = QTextCursor(doc)
|
||
edit.beginEditBlock()
|
||
edit.setPosition(start)
|
||
edit.setPosition(start + 2, QTextCursor.KeepAnchor)
|
||
edit.removeSelectedText()
|
||
edit.endEditBlock()
|
||
|
||
# Move caret to where the code block should start
|
||
c.setPosition(start)
|
||
self.setTextCursor(c)
|
||
|
||
# Now behave exactly like the </> toolbar button
|
||
self.apply_code()
|
||
return
|
||
# ------------------------------------------------------------
|
||
|
||
# If we're anywhere in a fenced code block (including the fences),
|
||
# treat the text as read-only and route edits through the dialog.
|
||
if in_code or is_fence_line:
|
||
key = event.key()
|
||
|
||
# Navigation keys that are safe to pass through.
|
||
nav_keys_no_down = (
|
||
Qt.Key.Key_Left,
|
||
Qt.Key.Key_Right,
|
||
Qt.Key.Key_Up,
|
||
Qt.Key.Key_Home,
|
||
Qt.Key.Key_End,
|
||
Qt.Key.Key_PageUp,
|
||
Qt.Key.Key_PageDown,
|
||
)
|
||
|
||
# Let these through:
|
||
# - pure navigation (except Down, which we handle specially later)
|
||
# - Enter/Return and Down, which are handled by dedicated logic below
|
||
if key in nav_keys_no_down:
|
||
super().keyPressEvent(event)
|
||
return
|
||
|
||
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Down):
|
||
# Let the existing Enter/Down code see these.
|
||
pass
|
||
else:
|
||
# Any other key (Backspace, Delete, characters, Tab, etc.)
|
||
# opens the code-block editor instead of editing inline.
|
||
if not self._edit_code_block(block):
|
||
# Fallback if bounds couldn't be found for some reason.
|
||
super().keyPressEvent(event)
|
||
return
|
||
|
||
# --- Step out of a code block with Down at EOF ---
|
||
if event.key() == Qt.Key.Key_Down:
|
||
c = self.textCursor()
|
||
b = c.block()
|
||
pos_in_block = c.position() - b.position()
|
||
line = b.text()
|
||
|
||
def next_is_closing(bb):
|
||
nb = bb.next()
|
||
return nb.isValid() and nb.text().strip().startswith("```")
|
||
|
||
# Case A: caret is on the line BEFORE the closing fence, at EOL
|
||
# → jump after the fence
|
||
if (
|
||
self._is_inside_code_block(b)
|
||
and pos_in_block >= len(line)
|
||
and next_is_closing(b)
|
||
):
|
||
fence_block = b.next()
|
||
after_fence = fence_block.next()
|
||
if not after_fence.isValid():
|
||
# make a line after the fence
|
||
edit = QTextCursor(self.document())
|
||
endpos = fence_block.position() + len(fence_block.text())
|
||
edit.setPosition(endpos)
|
||
edit.insertText("\n")
|
||
after_fence = fence_block.next()
|
||
c.setPosition(after_fence.position())
|
||
self.setTextCursor(c)
|
||
if hasattr(self, "_update_code_block_row_backgrounds"):
|
||
self._update_code_block_row_backgrounds()
|
||
return
|
||
|
||
# Case B: caret is ON the closing fence, and it's EOF
|
||
# → create a line and move to it
|
||
if (
|
||
b.text().strip().startswith("```")
|
||
and self._is_inside_code_block(b)
|
||
and not b.next().isValid()
|
||
):
|
||
edit = QTextCursor(self.document())
|
||
edit.setPosition(b.position() + len(b.text()))
|
||
edit.insertText("\n")
|
||
c.setPosition(b.position() + len(b.text()) + 1)
|
||
self.setTextCursor(c)
|
||
if hasattr(self, "_update_code_block_row_backgrounds"):
|
||
self._update_code_block_row_backgrounds()
|
||
return
|
||
|
||
# Handle Backspace on empty list items so the marker itself can be deleted
|
||
if event.key() == Qt.Key.Key_Backspace:
|
||
cursor = self.textCursor()
|
||
# Let Backspace behave normally when deleting a selection.
|
||
if not cursor.hasSelection():
|
||
block = cursor.block()
|
||
prefix_len = self._list_prefix_length_for_block(block)
|
||
|
||
if prefix_len > 0:
|
||
block_start = block.position()
|
||
line = block.text()
|
||
pos_in_block = cursor.position() - block_start
|
||
after_text = line[prefix_len:]
|
||
|
||
# If there is no real content after the marker, treat Backspace
|
||
# as "remove the list marker".
|
||
if after_text.strip() == "" and pos_in_block >= prefix_len:
|
||
cursor.beginEditBlock()
|
||
cursor.setPosition(block_start)
|
||
cursor.setPosition(
|
||
block_start + prefix_len, QTextCursor.KeepAnchor
|
||
)
|
||
cursor.removeSelectedText()
|
||
cursor.endEditBlock()
|
||
self.setTextCursor(cursor)
|
||
return
|
||
|
||
# Handle Home and Left arrow keys to keep the caret to the *right*
|
||
# of list prefixes (checkboxes / bullets / numbers).
|
||
if event.key() in (Qt.Key.Key_Home, Qt.Key.Key_Left):
|
||
# Let Ctrl+Home / Ctrl+Left keep their usual meaning (start of
|
||
# document / word-left) – we don't interfere with those.
|
||
if event.modifiers() & Qt.ControlModifier:
|
||
pass
|
||
else:
|
||
cursor = self.textCursor()
|
||
block = cursor.block()
|
||
prefix_len = self._list_prefix_length_for_block(block)
|
||
|
||
if prefix_len > 0:
|
||
block_start = block.position()
|
||
pos_in_block = cursor.position() - block_start
|
||
target = block_start + prefix_len
|
||
|
||
if event.key() == Qt.Key.Key_Home:
|
||
# Home should jump to just after the prefix; with Shift
|
||
# it should *select* back to that position.
|
||
if event.modifiers() & Qt.ShiftModifier:
|
||
cursor.setPosition(target, QTextCursor.KeepAnchor)
|
||
else:
|
||
cursor.setPosition(target)
|
||
self.setTextCursor(cursor)
|
||
return
|
||
|
||
# Left arrow: don't allow the caret to move into the prefix
|
||
# region; snap it to just after the marker instead.
|
||
if event.key() == Qt.Key.Key_Left and pos_in_block <= prefix_len:
|
||
if event.modifiers() & Qt.ShiftModifier:
|
||
cursor.setPosition(target, QTextCursor.KeepAnchor)
|
||
else:
|
||
cursor.setPosition(target)
|
||
self.setTextCursor(cursor)
|
||
return
|
||
|
||
# After moving vertically, make sure we don't land *inside* a list
|
||
# prefix. We let QTextEdit perform the move first and then adjust.
|
||
if event.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down) and not (
|
||
event.modifiers() & Qt.ControlModifier
|
||
):
|
||
super().keyPressEvent(event)
|
||
|
||
cursor = self.textCursor()
|
||
block = cursor.block()
|
||
|
||
# Don't interfere with code blocks (they can contain literal
|
||
# markdown-looking text).
|
||
if self._is_inside_code_block(block):
|
||
return
|
||
|
||
prefix_len = self._list_prefix_length_for_block(block)
|
||
if prefix_len > 0:
|
||
block_start = block.position()
|
||
pos_in_block = cursor.position() - block_start
|
||
if pos_in_block < prefix_len:
|
||
target = block_start + prefix_len
|
||
if event.modifiers() & Qt.ShiftModifier:
|
||
# Preserve the current anchor while snapping the visual
|
||
# caret to just after the marker.
|
||
anchor = cursor.anchor()
|
||
cursor.setPosition(anchor)
|
||
cursor.setPosition(target, QTextCursor.KeepAnchor)
|
||
else:
|
||
cursor.setPosition(target)
|
||
self.setTextCursor(cursor)
|
||
|
||
return
|
||
|
||
# Handle Enter key for smart list continuation AND code blocks
|
||
if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
|
||
cursor = self.textCursor()
|
||
current_line = self._get_current_line()
|
||
|
||
# Check if we're in a code block
|
||
current_block = cursor.block()
|
||
line_text = current_block.text()
|
||
pos_in_block = cursor.position() - current_block.position()
|
||
|
||
moved = False
|
||
i = 0
|
||
patterns = ["**", "__", "~~", "`", "*", "_"] # bold, italic, strike, code
|
||
# Consume stacked markers like **` if present
|
||
while True:
|
||
matched = False
|
||
for pat in patterns:
|
||
L = len(pat)
|
||
if line_text[pos_in_block + i : pos_in_block + i + L] == pat:
|
||
i += L
|
||
matched = True
|
||
moved = True
|
||
break
|
||
if not matched:
|
||
break
|
||
if moved:
|
||
cursor.movePosition(
|
||
QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, i
|
||
)
|
||
self.setTextCursor(cursor)
|
||
|
||
block_state = current_block.userState()
|
||
|
||
stripped = current_line.strip()
|
||
is_fence_line = stripped.startswith("```")
|
||
|
||
if is_fence_line:
|
||
# Work out if this fence is closing (inside block before it)
|
||
inside_before = self._is_inside_code_block(current_block.previous())
|
||
|
||
# Insert the newline as usual
|
||
super().keyPressEvent(event)
|
||
|
||
if inside_before:
|
||
# We were on the *closing* fence; the new line is outside the block.
|
||
# Give that new block normal 1.25× spacing.
|
||
new_block = self.textCursor().block()
|
||
fmt = new_block.blockFormat()
|
||
fmt.setLineHeight(
|
||
125.0,
|
||
QTextBlockFormat.LineHeightTypes.ProportionalHeight.value,
|
||
)
|
||
cur2 = self.textCursor()
|
||
cur2.setBlockFormat(fmt)
|
||
self.setTextCursor(cur2)
|
||
|
||
return
|
||
|
||
# Inside a code block (but not on a fence): open the popup editor
|
||
if block_state == 1:
|
||
if not self._edit_code_block(current_block):
|
||
# Fallback if something is malformed
|
||
super().keyPressEvent(event)
|
||
return
|
||
|
||
# Check for list continuation
|
||
list_type, prefix = self._detect_list_type(current_line)
|
||
|
||
if list_type:
|
||
# Check if the line is empty (just the prefix)
|
||
content = current_line.lstrip()
|
||
is_empty = (
|
||
content == prefix.strip() or not content.replace(prefix, "").strip()
|
||
)
|
||
|
||
if is_empty and self._last_enter_was_empty:
|
||
# Second enter on empty list item - remove the list formatting
|
||
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
|
||
cursor.removeSelectedText()
|
||
cursor.insertText("\n")
|
||
self._last_enter_was_empty = False
|
||
return
|
||
elif is_empty:
|
||
# First enter on empty list item - just insert newline without prefix
|
||
super().keyPressEvent(event)
|
||
self._last_enter_was_empty = True
|
||
return
|
||
else:
|
||
# Not empty - continue the list
|
||
self._last_enter_was_empty = False
|
||
|
||
# Insert newline and continue the list
|
||
super().keyPressEvent(event)
|
||
cursor = self.textCursor()
|
||
cursor.insertText(prefix)
|
||
return
|
||
else:
|
||
self._last_enter_was_empty = False
|
||
else:
|
||
# Any other key resets the empty enter flag
|
||
self._last_enter_was_empty = False
|
||
|
||
# Default handling
|
||
super().keyPressEvent(event)
|
||
|
||
def mouseMoveEvent(self, event):
|
||
# Change cursor when hovering a link
|
||
url = self._url_at_pos(event.pos())
|
||
if url:
|
||
self.viewport().setCursor(Qt.PointingHandCursor)
|
||
else:
|
||
self.viewport().setCursor(Qt.IBeamCursor)
|
||
|
||
super().mouseMoveEvent(event)
|
||
|
||
def mouseReleaseEvent(self, event):
|
||
# Let QTextEdit handle caret/selection first
|
||
super().mouseReleaseEvent(event)
|
||
|
||
if event.button() != Qt.LeftButton:
|
||
return
|
||
|
||
# If the user dragged to select text, don't treat it as a click
|
||
if self.textCursor().hasSelection():
|
||
return
|
||
|
||
url_str = self._url_at_pos(event.pos())
|
||
if not url_str:
|
||
return
|
||
|
||
url = QUrl(url_str)
|
||
if not url.scheme():
|
||
url.setScheme("https")
|
||
|
||
QDesktopServices.openUrl(url)
|
||
|
||
def mousePressEvent(self, event):
|
||
"""Toggle a checkbox only when the click lands on its icon."""
|
||
# default: don't suppress any upcoming double-click
|
||
self._suppress_next_checkbox_double_click = False
|
||
|
||
if event.button() == Qt.LeftButton:
|
||
pt = event.pos()
|
||
|
||
# Cursor and block under the mouse
|
||
cur = self.cursorForPosition(pt)
|
||
block = cur.block()
|
||
text = block.text()
|
||
|
||
# The display tokens, e.g. "☐ " / "☑ " (icon + trailing space)
|
||
unchecked = f"{self._CHECK_UNCHECKED_DISPLAY} "
|
||
checked = f"{self._CHECK_CHECKED_DISPLAY} "
|
||
|
||
# Helper: rect for a single character at a given doc position
|
||
def char_rect_at(doc_pos, ch):
|
||
c = QTextCursor(self.document())
|
||
c.setPosition(doc_pos)
|
||
# caret rect at char start (viewport coords)
|
||
start_rect = self.cursorRect(c)
|
||
|
||
# Use the actual font at this position for an accurate width
|
||
fmt_font = (
|
||
c.charFormat().font() if c.charFormat().isValid() else self.font()
|
||
)
|
||
fm = QFontMetrics(fmt_font)
|
||
w = max(1, fm.horizontalAdvance(ch))
|
||
return QRect(start_rect.x(), start_rect.y(), w, start_rect.height())
|
||
|
||
# Scan the line for any checkbox icons; toggle the one we clicked
|
||
i = 0
|
||
while i < len(text):
|
||
icon = None
|
||
if text.startswith(unchecked, i):
|
||
icon = self._CHECK_UNCHECKED_DISPLAY
|
||
elif text.startswith(checked, i):
|
||
icon = self._CHECK_CHECKED_DISPLAY
|
||
|
||
if icon:
|
||
# absolute document position of the icon
|
||
doc_pos = block.position() + i
|
||
r = char_rect_at(doc_pos, icon)
|
||
|
||
# ---------- Relax the hit area here ----------
|
||
# Expand the clickable area horizontally so you don't have to
|
||
# land exactly on the glyph. This makes the "checkbox zone"
|
||
# roughly 3× the glyph width, centered on it.
|
||
pad = r.width() # one glyph width on each side
|
||
hit_rect = r.adjusted(-pad, 0, pad, 0)
|
||
# ---------------------------------------------
|
||
|
||
if hit_rect.contains(pt):
|
||
# Build the replacement: swap ☐ <-> ☑ (keep trailing space)
|
||
new_icon = (
|
||
self._CHECK_CHECKED_DISPLAY
|
||
if icon == self._CHECK_UNCHECKED_DISPLAY
|
||
else self._CHECK_UNCHECKED_DISPLAY
|
||
)
|
||
edit = QTextCursor(self.document())
|
||
edit.beginEditBlock()
|
||
edit.setPosition(doc_pos)
|
||
# icon + space
|
||
edit.movePosition(
|
||
QTextCursor.Right, QTextCursor.KeepAnchor, len(icon) + 1
|
||
)
|
||
edit.insertText(f"{new_icon} ")
|
||
edit.endEditBlock()
|
||
|
||
# if a double-click comes next, ignore it
|
||
self._suppress_next_checkbox_double_click = True
|
||
return # handled
|
||
|
||
# advance past this token
|
||
i += len(icon) + 1
|
||
else:
|
||
i += 1
|
||
|
||
# Default handling for anything else
|
||
super().mousePressEvent(event)
|
||
|
||
def mouseDoubleClickEvent(self, event: QMouseEvent) -> None:
|
||
# If the previous press toggled a checkbox, swallow this double-click
|
||
# so the base class does NOT turn it into a selection.
|
||
if getattr(self, "_suppress_next_checkbox_double_click", False):
|
||
self._suppress_next_checkbox_double_click = False
|
||
event.accept()
|
||
return
|
||
|
||
cursor = self.cursorForPosition(event.pos())
|
||
block = cursor.block()
|
||
|
||
# If we’re on or inside a code block, open the editor instead
|
||
if self._is_inside_code_block(block) or block.text().strip().startswith("```"):
|
||
# Only swallow the double-click if we actually opened a dialog.
|
||
if not self._edit_code_block(block):
|
||
super().mouseDoubleClickEvent(event)
|
||
return
|
||
|
||
# Otherwise, let normal double-click behaviour happen
|
||
super().mouseDoubleClickEvent(event)
|
||
|
||
# ------------------------ Toolbar action handlers ------------------------
|
||
|
||
def apply_weight(self):
|
||
"""Toggle bold formatting."""
|
||
cursor = self.textCursor()
|
||
if cursor.hasSelection():
|
||
selected = cursor.selectedText()
|
||
# Check if already bold
|
||
if selected.startswith("**") and selected.endswith("**"):
|
||
# Remove bold
|
||
new_text = selected[2:-2]
|
||
else:
|
||
# Add bold
|
||
new_text = f"**{selected}**"
|
||
cursor.insertText(new_text)
|
||
else:
|
||
# No selection - just insert markers
|
||
cursor.insertText("****")
|
||
cursor.movePosition(
|
||
QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 2
|
||
)
|
||
self.setTextCursor(cursor)
|
||
|
||
# Return focus to editor
|
||
self.setFocus()
|
||
|
||
def apply_italic(self):
|
||
"""Toggle italic formatting."""
|
||
cursor = self.textCursor()
|
||
if cursor.hasSelection():
|
||
selected = cursor.selectedText()
|
||
if (
|
||
selected.startswith("*")
|
||
and selected.endswith("*")
|
||
and not selected.startswith("**")
|
||
):
|
||
new_text = selected[1:-1]
|
||
else:
|
||
new_text = f"*{selected}*"
|
||
cursor.insertText(new_text)
|
||
else:
|
||
cursor.insertText("**")
|
||
cursor.movePosition(
|
||
QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 1
|
||
)
|
||
self.setTextCursor(cursor)
|
||
|
||
# Return focus to editor
|
||
self.setFocus()
|
||
|
||
def apply_strikethrough(self):
|
||
"""Toggle strikethrough formatting."""
|
||
cursor = self.textCursor()
|
||
if cursor.hasSelection():
|
||
selected = cursor.selectedText()
|
||
if selected.startswith("~~") and selected.endswith("~~"):
|
||
new_text = selected[2:-2]
|
||
else:
|
||
new_text = f"~~{selected}~~"
|
||
cursor.insertText(new_text)
|
||
else:
|
||
cursor.insertText("~~~~")
|
||
cursor.movePosition(
|
||
QTextCursor.MoveOperation.Left, QTextCursor.MoveMode.MoveAnchor, 2
|
||
)
|
||
self.setTextCursor(cursor)
|
||
|
||
# Return focus to editor
|
||
self.setFocus()
|
||
|
||
def apply_code(self):
|
||
"""
|
||
Toolbar handler for the </> button.
|
||
|
||
- If the caret is on / inside an existing fenced block, open the editor for it.
|
||
- Otherwise open the editor prefilled with any selected text, then insert a new
|
||
fenced block containing whatever the user typed.
|
||
"""
|
||
cursor = self.textCursor()
|
||
doc = self.document()
|
||
if doc is None:
|
||
return
|
||
|
||
block = cursor.block()
|
||
|
||
# --- Case 1: already in a code block -> just edit that block ---
|
||
if self._is_inside_code_block(block) or block.text().strip().startswith("```"):
|
||
self._edit_code_block(block)
|
||
return
|
||
|
||
# --- Case 2: creating a new block (optional selection) ---
|
||
if cursor.hasSelection():
|
||
start_pos = cursor.selectionStart()
|
||
end_pos = cursor.selectionEnd()
|
||
# QTextEdit joins lines with U+2029 in selectedText()
|
||
initial_code = cursor.selectedText().replace("\u2029", "\n")
|
||
else:
|
||
start_pos = cursor.position()
|
||
end_pos = start_pos
|
||
initial_code = ""
|
||
|
||
# Let the user type/edit the code in the popup first
|
||
dlg = CodeBlockEditorDialog(initial_code, language=None, parent=self)
|
||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
|
||
code_text = dlg.code()
|
||
language = dlg.language()
|
||
|
||
# Don't insert an entirely empty block
|
||
if not code_text.strip():
|
||
return
|
||
|
||
code_text = code_text.rstrip("\n")
|
||
|
||
edit = QTextCursor(doc)
|
||
edit.beginEditBlock()
|
||
|
||
# Remove selection (if any) so we can insert the new fenced block
|
||
edit.setPosition(start_pos)
|
||
edit.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor)
|
||
edit.removeSelectedText()
|
||
|
||
# Work out whether we're mid-line and need to break before the fence
|
||
block = doc.findBlock(start_pos)
|
||
line = block.text()
|
||
pos_in_block = start_pos - block.position()
|
||
before = line[:pos_in_block]
|
||
|
||
# If there's text before the caret on this line, put the fence on a new line
|
||
lead_break = "\n" if before else ""
|
||
insert_str = f"{lead_break}```\n{code_text}\n```\n"
|
||
|
||
edit.setPosition(start_pos)
|
||
edit.insertText(insert_str)
|
||
edit.endEditBlock()
|
||
|
||
# Find the opening fence block we just inserted
|
||
open_block = doc.findBlock(start_pos + len(lead_break))
|
||
|
||
# Find the closing fence block
|
||
close_block = open_block.next()
|
||
while close_block.isValid() and not close_block.text().strip().startswith(
|
||
"```"
|
||
):
|
||
close_block = close_block.next()
|
||
|
||
if close_block.isValid():
|
||
# Make sure there's always at least one line *after* the block
|
||
self._ensure_escape_line_after_closing_fence(close_block)
|
||
|
||
# Store language metadata if the user chose one
|
||
if language is not None:
|
||
if not hasattr(self, "_code_metadata"):
|
||
from .code_highlighter import CodeBlockMetadata
|
||
|
||
self._code_metadata = CodeBlockMetadata()
|
||
self._code_metadata.set_language(open_block.blockNumber(), language)
|
||
|
||
# Refresh visuals
|
||
self._apply_code_block_spacing()
|
||
self._update_code_block_row_backgrounds()
|
||
if hasattr(self, "highlighter"):
|
||
self.highlighter.rehighlight()
|
||
|
||
# Put caret just after the code block so the user can keep writing normal text
|
||
after_block = close_block.next() if close_block.isValid() else None
|
||
if after_block and after_block.isValid():
|
||
cursor = self.textCursor()
|
||
cursor.setPosition(after_block.position())
|
||
self.setTextCursor(cursor)
|
||
|
||
self.setFocus()
|
||
|
||
def apply_heading(self, size: int):
|
||
"""Apply heading formatting to current line."""
|
||
cursor = self.textCursor()
|
||
|
||
# Determine heading level from size
|
||
if size >= 24:
|
||
level = 1
|
||
elif size >= 18:
|
||
level = 2
|
||
elif size >= 14:
|
||
level = 3
|
||
else:
|
||
level = 0 # Normal text
|
||
|
||
# Get current line
|
||
cursor.movePosition(
|
||
QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
|
||
)
|
||
cursor.movePosition(
|
||
QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
|
||
)
|
||
line = cursor.selectedText()
|
||
|
||
# Remove existing heading markers
|
||
line = re.sub(r"^#{1,6}\s+", "", line)
|
||
|
||
# Add new heading markers if not normal
|
||
if level > 0:
|
||
new_line = "#" * level + " " + line
|
||
else:
|
||
new_line = line
|
||
|
||
cursor.insertText(new_line)
|
||
|
||
# Return focus to editor
|
||
self.setFocus()
|
||
|
||
def toggle_bullets(self):
|
||
"""Toggle bullet list on current line."""
|
||
cursor = self.textCursor()
|
||
cursor.movePosition(
|
||
QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
|
||
)
|
||
cursor.movePosition(
|
||
QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
|
||
)
|
||
line = cursor.selectedText()
|
||
stripped = line.lstrip()
|
||
|
||
# Consider existing markdown markers OR our Unicode bullet as "a bullet"
|
||
if (
|
||
stripped.startswith(f"{self._BULLET_DISPLAY} ")
|
||
or stripped.startswith("- ")
|
||
or stripped.startswith("* ")
|
||
):
|
||
# Remove any of those bullet markers
|
||
pattern = rf"^\s*([{re.escape(self._BULLET_DISPLAY)}\-*])\s+"
|
||
new_line = re.sub(pattern, "", line)
|
||
else:
|
||
new_line = f"{self._BULLET_DISPLAY} " + stripped
|
||
|
||
cursor.insertText(new_line)
|
||
|
||
# Return focus to editor
|
||
self.setFocus()
|
||
|
||
def toggle_numbers(self):
|
||
"""Toggle numbered list on current line."""
|
||
cursor = self.textCursor()
|
||
cursor.movePosition(
|
||
QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
|
||
)
|
||
cursor.movePosition(
|
||
QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
|
||
)
|
||
line = cursor.selectedText()
|
||
|
||
# Check if already numbered
|
||
if re.match(r"^\s*\d+\.\s", line):
|
||
# Remove number
|
||
new_line = re.sub(r"^\s*\d+\.\s+", "", line)
|
||
else:
|
||
# Add number
|
||
new_line = "1. " + line.lstrip()
|
||
|
||
cursor.insertText(new_line)
|
||
|
||
# Return focus to editor
|
||
self.setFocus()
|
||
|
||
def toggle_checkboxes(self):
|
||
"""Toggle checkbox on current line."""
|
||
cursor = self.textCursor()
|
||
cursor.movePosition(
|
||
QTextCursor.MoveOperation.StartOfLine, QTextCursor.MoveMode.MoveAnchor
|
||
)
|
||
cursor.movePosition(
|
||
QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor
|
||
)
|
||
line = cursor.selectedText()
|
||
|
||
# Check if already has checkbox (Unicode display format)
|
||
if (
|
||
f"{self._CHECK_UNCHECKED_DISPLAY} " in line
|
||
or f"{self._CHECK_CHECKED_DISPLAY} " in line
|
||
):
|
||
# Remove checkbox - use raw string to avoid escape sequence warning
|
||
new_line = re.sub(
|
||
rf"^\s*[{self._CHECK_UNCHECKED_DISPLAY}{self._CHECK_CHECKED_DISPLAY}]\s+",
|
||
"",
|
||
line,
|
||
)
|
||
else:
|
||
# Add checkbox (Unicode display format)
|
||
new_line = f"{self._CHECK_UNCHECKED_DISPLAY} " + line.lstrip()
|
||
|
||
cursor.insertText(new_line)
|
||
|
||
# Return focus to editor
|
||
self.setFocus()
|
||
|
||
def insert_image_from_path(self, path: Path):
|
||
"""Insert an image as rendered image (but save as base64 markdown)."""
|
||
if not path.exists():
|
||
return
|
||
|
||
# Read the original image file bytes for base64 encoding
|
||
with open(path, "rb") as f:
|
||
img_data = f.read()
|
||
|
||
# Encode ORIGINAL file bytes to base64
|
||
b64_data = base64.b64encode(img_data).decode("ascii")
|
||
|
||
# Determine mime type
|
||
ext = path.suffix.lower()
|
||
mime_map = {
|
||
".png": "image/png",
|
||
".jpg": "image/jpeg",
|
||
".jpeg": "image/jpeg",
|
||
".gif": "image/gif",
|
||
".bmp": "image/bmp",
|
||
".webp": "image/webp",
|
||
}
|
||
mime_type = mime_map.get(ext, "image/png")
|
||
|
||
# Load the image
|
||
image = QImage(str(path))
|
||
if image.isNull():
|
||
return
|
||
|
||
# Create image format with original base64
|
||
img_format = QTextImageFormat()
|
||
img_format.setName(f"data:image/{mime_type};base64,{b64_data}")
|
||
img_format.setWidth(image.width())
|
||
img_format.setHeight(image.height())
|
||
|
||
# Add original image to document resources
|
||
self.document().addResource(
|
||
QTextDocument.ResourceType.ImageResource, img_format.name(), image
|
||
)
|
||
|
||
# Insert the image at original size
|
||
cursor = self.textCursor()
|
||
cursor.insertImage(img_format)
|
||
cursor.insertText("\n") # Add newline after image
|
||
|
||
# ========== Context Menu Support ==========
|
||
|
||
def contextMenuEvent(self, event):
|
||
"""Override context menu to add custom actions."""
|
||
from PySide6.QtGui import QAction
|
||
from PySide6.QtWidgets import QMenu
|
||
|
||
menu = QMenu(self)
|
||
cursor = self.cursorForPosition(event.pos())
|
||
|
||
# Check if we're in a code block
|
||
block = cursor.block()
|
||
if self._is_inside_code_block(block):
|
||
# Add language selection submenu
|
||
lang_menu = menu.addMenu(strings._("set_code_language"))
|
||
|
||
languages = [
|
||
"bash",
|
||
"css",
|
||
"html",
|
||
"javascript",
|
||
"php",
|
||
"python",
|
||
]
|
||
for lang in languages:
|
||
action = QAction(lang.capitalize(), self)
|
||
action.triggered.connect(
|
||
lambda checked, l=lang: self._set_code_block_language(block, l)
|
||
)
|
||
lang_menu.addAction(action)
|
||
|
||
menu.addSeparator()
|
||
|
||
edit_action = QAction(strings._("edit_code_block"), self)
|
||
edit_action.triggered.connect(lambda: self._edit_code_block(block))
|
||
menu.addAction(edit_action)
|
||
|
||
delete_action = QAction(strings._("delete_code_block"), self)
|
||
delete_action.triggered.connect(lambda: self._delete_code_block(block))
|
||
menu.addAction(delete_action)
|
||
|
||
menu.addSeparator()
|
||
|
||
# Add standard context menu actions
|
||
if self.textCursor().hasSelection():
|
||
menu.addAction(strings._("cut"), self.cut)
|
||
menu.addAction(strings._("copy"), self.copy)
|
||
|
||
menu.addAction(strings._("paste"), self.paste)
|
||
|
||
menu.exec(event.globalPos())
|
||
|
||
def _set_code_block_language(self, block, language: str):
|
||
"""Set the language for a code block and store metadata."""
|
||
if not hasattr(self, "_code_metadata"):
|
||
from .code_highlighter import CodeBlockMetadata
|
||
|
||
self._code_metadata = CodeBlockMetadata()
|
||
|
||
# Find the opening fence block for this code block
|
||
fence_block = block
|
||
while fence_block.isValid() and not fence_block.text().strip().startswith(
|
||
"```"
|
||
):
|
||
fence_block = fence_block.previous()
|
||
|
||
if fence_block.isValid():
|
||
self._code_metadata.set_language(fence_block.blockNumber(), language)
|
||
# Trigger rehighlight
|
||
self.highlighter.rehighlight()
|
||
|
||
def get_current_line_text(self) -> str:
|
||
"""Get the text of the current line."""
|
||
cursor = self.textCursor()
|
||
block = cursor.block()
|
||
return block.text()
|
||
|
||
def get_current_line_task_text(self) -> str:
|
||
"""
|
||
Like get_current_line_text(), but with list / checkbox / number
|
||
prefixes stripped off for use in Pomodoro notes, etc.
|
||
"""
|
||
line = self.get_current_line_text()
|
||
|
||
text = re.sub(
|
||
r"^\s*(?:"
|
||
r"-\s\[(?: |x|X)\]\s+" # markdown checkbox
|
||
r"|[☐☑]\s+" # Unicode checkbox
|
||
r"|•\s+" # Unicode bullet
|
||
r"|[-*+]\s+" # markdown bullets
|
||
r"|\d+\.\s+" # numbered 1. 2. etc
|
||
r")",
|
||
"",
|
||
line,
|
||
)
|
||
return text.strip()
|