bouquin/bouquin/markdown_editor.py
Miguel Jacq c1c95ca0ca
All checks were successful
CI / test (push) Successful in 9m34s
Lint / test (push) Successful in 41s
Trivy / test (push) Successful in 23s
Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter)
2025-12-12 14:10:43 +11:00

1845 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import base64
import re
from pathlib import Path
from typing import Optional, Tuple
from PySide6.QtCore import QRect, Qt, QTimer, QUrl
from PySide6.QtGui import (
QDesktopServices,
QFont,
QFontDatabase,
QFontMetrics,
QImage,
QMouseEvent,
QTextBlock,
QTextBlockFormat,
QTextCharFormat,
QTextCursor,
QTextDocument,
QTextFormat,
QTextImageFormat,
)
from PySide6.QtWidgets import QDialog, QTextEdit
from . import strings
from .code_block_editor_dialog import CodeBlockEditorDialog
from .markdown_highlighter import MarkdownHighlighter
from .theme import ThemeManager
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
# Track when the current selection is being created via mouse drag,
# so we can treat it differently from triple-click / keyboard selections.
self._mouse_drag_selecting = 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):
# Recreate the highlighter for the new document
# (the old one gets deleted with the old document)
if doc is None:
return
super().setDocument(doc)
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
if not hasattr(self, "highlighter") or self.highlighter 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"![image]({img_name})"
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).
"""
# When the user is actively dragging with the mouse, we *do* want the
# checkbox/bullet to be part of the selection (for deleting whole rows).
# So don't rewrite the selection in that case.
if getattr(self, "_mouse_drag_selecting", False):
return
# 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 = True
# 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):
# If the left button is down while the mouse moves, we consider this
# a drag selection (as opposed to a simple click).
if event.buttons() & Qt.LeftButton:
self._mouse_drag_selecting = True
else:
self._mouse_drag_selecting = False
# 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:
# At this point the drag (if any) has finished and the final
# selection is already in place (and selectionChanged has fired).
# Clear the drag flag for future interactions.
self._mouse_drag_selecting = False
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
# Fresh left-button press starts with "no drag" yet.
if event.button() == Qt.LeftButton:
self._mouse_drag_selecting = False
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_icon = char_rect_at(doc_pos, icon)
# --- Find where the first non-space "real text" starts ---
first_idx = i + len(icon) + 1 # skip icon + trailing space
while first_idx < len(text) and text[first_idx].isspace():
first_idx += 1
# Start with some padding around the icon itself
left_pad = r_icon.width() // 2
right_pad = r_icon.width() // 2
hit_left = r_icon.left() - left_pad
# If there's actual text after the checkbox, clamp the
# clickable area so it stops *before* the first letter.
if first_idx < len(text):
first_doc_pos = block.position() + first_idx
c_first = QTextCursor(self.document())
c_first.setPosition(first_doc_pos)
first_x = self.cursorRect(c_first).x()
expanded_right = r_icon.right() + right_pad
hit_right = min(expanded_right, first_x)
else:
# No text after the checkbox on this line
hit_right = r_icon.right() + right_pad
# Make sure the rect is at least 1px wide
if hit_right <= hit_left:
hit_right = r_icon.right()
hit_rect = QRect(
hit_left,
r_icon.top(),
max(1, hit_right - hit_left),
r_icon.height(),
)
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()