1311 lines
47 KiB
Python
1311 lines
47 KiB
Python
from __future__ import annotations
|
||
|
||
import base64
|
||
import re
|
||
from pathlib import Path
|
||
|
||
from PySide6.QtGui import (
|
||
QFont,
|
||
QFontDatabase,
|
||
QFontMetrics,
|
||
QImage,
|
||
QTextCharFormat,
|
||
QTextCursor,
|
||
QTextDocument,
|
||
QTextFormat,
|
||
QTextBlockFormat,
|
||
QTextImageFormat,
|
||
QDesktopServices,
|
||
)
|
||
from PySide6.QtCore import Qt, QRect, QTimer, QUrl
|
||
from PySide6.QtWidgets import QTextEdit
|
||
|
||
from .theme import ThemeManager
|
||
from .markdown_highlighter import MarkdownHighlighter
|
||
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" / "NotoSans-Regular.ttf"
|
||
regular_font_id = QFontDatabase.addApplicationFont(str(regular_font_path))
|
||
if regular_font_id == -1:
|
||
print("Failed to load NotoSans-Regular.ttf")
|
||
|
||
# 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]
|
||
if symbols_font_id == -1:
|
||
print("Failed to load NotoSansSymbols2-Regular.ttf")
|
||
|
||
# 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
|
||
|
||
# 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):
|
||
"""Paint a full-width background for each line that is in a fenced code block."""
|
||
doc = self.document()
|
||
if doc is None:
|
||
return
|
||
|
||
sels = []
|
||
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("```")
|
||
|
||
paint_this_line = is_fence or inside
|
||
if paint_this_line:
|
||
sel = QTextEdit.ExtraSelection()
|
||
fmt = QTextCharFormat()
|
||
fmt.setBackground(bg_brush)
|
||
fmt.setProperty(QTextFormat.FullWidthSelection, True)
|
||
fmt.setProperty(QTextFormat.UserProperty, "codeblock_bg")
|
||
sel.format = fmt
|
||
|
||
cur = QTextCursor(doc)
|
||
cur.setPosition(block.position())
|
||
sel.cursor = cur
|
||
sels.append(sel)
|
||
|
||
if is_fence:
|
||
inside = not inside
|
||
|
||
block = block.next()
|
||
|
||
others = [
|
||
s
|
||
for s in self.extraSelections()
|
||
if s.format.property(QTextFormat.UserProperty) != "codeblock_bg"
|
||
]
|
||
self.setExtraSelections(others + sels)
|
||
|
||
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.
|
||
Call this AFTER _apply_line_spacing().
|
||
"""
|
||
doc = self.document()
|
||
if doc is None:
|
||
return
|
||
|
||
cursor = QTextCursor(doc)
|
||
cursor.beginEditBlock()
|
||
|
||
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
|
||
|
||
if is_code_line:
|
||
fmt = block.blockFormat()
|
||
fmt.setLineHeight(
|
||
0.0,
|
||
QTextBlockFormat.LineHeightTypes.SingleHeight.value,
|
||
)
|
||
cursor.setPosition(block.position())
|
||
cursor.setBlockFormat(fmt)
|
||
|
||
if is_fence:
|
||
inside = not inside
|
||
|
||
block = block.next()
|
||
|
||
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 _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."""
|
||
|
||
# --- Auto-close code fences when typing the 3rd backtick at line start ---
|
||
if event.text() == "`":
|
||
c = self.textCursor()
|
||
block = c.block()
|
||
line = block.text()
|
||
pos_in_block = c.position() - block.position()
|
||
|
||
# text before caret on this line
|
||
before = line[:pos_in_block]
|
||
|
||
# If we've typed exactly two backticks at line start (or after whitespace),
|
||
# treat this backtick as the "third" and expand to a full fenced block.
|
||
if before.endswith("``") and before.strip() == "``":
|
||
start = (
|
||
block.position() + pos_in_block - 2
|
||
) # start of the two backticks
|
||
|
||
edit = QTextCursor(self.document())
|
||
edit.beginEditBlock()
|
||
edit.setPosition(start)
|
||
edit.setPosition(start + 2, QTextCursor.KeepAnchor)
|
||
edit.insertText("```\n\n```\n")
|
||
edit.endEditBlock()
|
||
|
||
# place caret on the blank line between the fences
|
||
new_pos = start + 4 # after "```\n"
|
||
c.setPosition(new_pos)
|
||
self.setTextCursor(c)
|
||
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): newline stays code-style
|
||
if block_state == 1:
|
||
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."""
|
||
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)
|
||
|
||
if r.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()
|
||
return # handled
|
||
|
||
# advance past this token
|
||
i += len(icon) + 1
|
||
else:
|
||
i += 1
|
||
|
||
# Default handling for anything else
|
||
super().mousePressEvent(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):
|
||
"""Insert a fenced code block, or navigate fences without creating inline backticks."""
|
||
c = self.textCursor()
|
||
doc = self.document()
|
||
|
||
if c.hasSelection():
|
||
# Wrap selection and ensure exactly one newline after the closing fence
|
||
selected = c.selectedText().replace("\u2029", "\n")
|
||
c.insertText(f"```\n{selected.rstrip()}\n```\n")
|
||
if hasattr(self, "_update_code_block_row_backgrounds"):
|
||
self._update_code_block_row_backgrounds()
|
||
# tighten spacing for the new code block
|
||
self._apply_code_block_spacing()
|
||
|
||
self.setFocus()
|
||
return
|
||
|
||
block = c.block()
|
||
line = block.text()
|
||
pos_in_block = c.position() - block.position()
|
||
stripped = line.strip()
|
||
|
||
# If we're on a fence line, be helpful but never insert inline fences
|
||
if stripped.startswith("```"):
|
||
# Is this fence opening or closing? (look at blocks above)
|
||
inside_before = self._is_inside_code_block(block.previous())
|
||
if inside_before:
|
||
# This fence closes the block → ensure a line after, then move there
|
||
endpos = block.position() + len(line)
|
||
edit = QTextCursor(doc)
|
||
edit.setPosition(endpos)
|
||
if not block.next().isValid():
|
||
edit.insertText("\n")
|
||
c.setPosition(endpos + 1)
|
||
self.setTextCursor(c)
|
||
if hasattr(self, "_update_code_block_row_backgrounds"):
|
||
self._update_code_block_row_backgrounds()
|
||
self.setFocus()
|
||
return
|
||
else:
|
||
# Opening fence → move caret to the next line (inside the block)
|
||
nb = block.next()
|
||
if not nb.isValid():
|
||
e = QTextCursor(doc)
|
||
e.setPosition(block.position() + len(line))
|
||
e.insertText("\n")
|
||
nb = block.next()
|
||
c.setPosition(nb.position())
|
||
self.setTextCursor(c)
|
||
self.setFocus()
|
||
return
|
||
|
||
# If we're inside a block (but not on a fence), don't mutate text
|
||
if self._is_inside_code_block(block):
|
||
self.setFocus()
|
||
return
|
||
|
||
# Outside any block → create a clean template on its own lines (never inline)
|
||
start_pos = c.position()
|
||
before = line[:pos_in_block]
|
||
|
||
edit = QTextCursor(doc)
|
||
edit.beginEditBlock()
|
||
|
||
# If there is text before the caret on the line, start the block on a new line
|
||
lead_break = "\n" if before else ""
|
||
# Insert the block; trailing newline guarantees you can Down-arrow out later
|
||
insert = f"{lead_break}```\n\n```\n"
|
||
edit.setPosition(start_pos)
|
||
edit.insertText(insert)
|
||
edit.endEditBlock()
|
||
|
||
# Put caret on the blank line inside the block
|
||
c.setPosition(start_pos + len(lead_break) + 4) # after "```\n"
|
||
self.setTextCursor(c)
|
||
|
||
if hasattr(self, "_update_code_block_row_backgrounds"):
|
||
self._update_code_block_row_backgrounds()
|
||
|
||
# tighten spacing for the new code block
|
||
self._apply_code_block_spacing()
|
||
|
||
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 = [
|
||
"python",
|
||
"bash",
|
||
"php",
|
||
"javascript",
|
||
"html",
|
||
"css",
|
||
"sql",
|
||
"java",
|
||
"go",
|
||
]
|
||
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()
|
||
|
||
# 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()
|