parent
31604a0cd2
commit
39576ac7f3
54 changed files with 1616 additions and 4012 deletions
|
|
@ -6,7 +6,6 @@ import json
|
|||
import os
|
||||
|
||||
from dataclasses import dataclass
|
||||
from markdownify import markdownify as md
|
||||
from pathlib import Path
|
||||
from sqlcipher3 import dbapi2 as sqlite
|
||||
from typing import List, Sequence, Tuple
|
||||
|
|
@ -401,25 +400,13 @@ class DBManager:
|
|||
Export to HTML, similar to export_html, but then convert to Markdown
|
||||
using markdownify, and finally save to file.
|
||||
"""
|
||||
parts = [
|
||||
"<!doctype html>",
|
||||
'<html lang="en">',
|
||||
"<body>",
|
||||
f"<h1>{html.escape(title)}</h1>",
|
||||
]
|
||||
parts = []
|
||||
for d, c in entries:
|
||||
parts.append(
|
||||
f"<article><header><time>{html.escape(d)}</time></header><section>{c}</section></article>"
|
||||
)
|
||||
parts.append("</body></html>")
|
||||
|
||||
# Convert html to markdown
|
||||
md_items = []
|
||||
for item in parts:
|
||||
md_items.append(md(item, heading_style="ATX"))
|
||||
parts.append(f"# {d}")
|
||||
parts.append(c)
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(md_items))
|
||||
f.write("\n".join(parts))
|
||||
|
||||
def export_sql(self, file_path: str) -> None:
|
||||
"""
|
||||
|
|
|
|||
1015
bouquin/editor.py
1015
bouquin/editor.py
File diff suppressed because it is too large
Load diff
|
|
@ -16,31 +16,33 @@ from PySide6.QtWidgets import (
|
|||
)
|
||||
|
||||
|
||||
def _html_to_text(s: str) -> str:
|
||||
"""Lightweight HTML→text for diff (keeps paragraphs/line breaks)."""
|
||||
IMG_RE = re.compile(r"(?is)<img\b[^>]*>")
|
||||
STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
|
||||
COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
|
||||
BR_RE = re.compile(r"(?i)<br\s*/?>")
|
||||
BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\s*>")
|
||||
TAG_RE = re.compile(r"<[^>]+>")
|
||||
MULTINL_RE = re.compile(r"\n{3,}")
|
||||
|
||||
s = IMG_RE.sub("[ Image changed - see Preview pane ]", s)
|
||||
s = STYLE_SCRIPT_RE.sub("", s)
|
||||
s = COMMENT_RE.sub("", s)
|
||||
s = BR_RE.sub("\n", s)
|
||||
s = BLOCK_END_RE.sub("\n", s)
|
||||
s = TAG_RE.sub("", s)
|
||||
s = _html.unescape(s)
|
||||
s = MULTINL_RE.sub("\n\n", s)
|
||||
def _markdown_to_text(s: str) -> str:
|
||||
"""Convert markdown to plain text for diff comparison."""
|
||||
# Remove images
|
||||
s = re.sub(r"!\[.*?\]\(.*?\)", "[ Image ]", s)
|
||||
# Remove inline code formatting
|
||||
s = re.sub(r"`([^`]+)`", r"\1", s)
|
||||
# Remove bold/italic markers
|
||||
s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s)
|
||||
s = re.sub(r"__([^_]+)__", r"\1", s)
|
||||
s = re.sub(r"\*([^*]+)\*", r"\1", s)
|
||||
s = re.sub(r"_([^_]+)_", r"\1", s)
|
||||
# Remove strikethrough
|
||||
s = re.sub(r"~~([^~]+)~~", r"\1", s)
|
||||
# Remove heading markers
|
||||
s = re.sub(r"^#{1,6}\s+", "", s, flags=re.MULTILINE)
|
||||
# Remove list markers
|
||||
s = re.sub(r"^\s*[-*+]\s+", "", s, flags=re.MULTILINE)
|
||||
s = re.sub(r"^\s*\d+\.\s+", "", s, flags=re.MULTILINE)
|
||||
# Remove checkbox markers
|
||||
s = re.sub(r"^\s*-\s*\[[x ☐☑]\]\s+", "", s, flags=re.MULTILINE)
|
||||
return s.strip()
|
||||
|
||||
|
||||
def _colored_unified_diff_html(old_html: str, new_html: str) -> str:
|
||||
def _colored_unified_diff_html(old_md: str, new_md: str) -> str:
|
||||
"""Return HTML with colored unified diff (+ green, - red, context gray)."""
|
||||
a = _html_to_text(old_html).splitlines()
|
||||
b = _html_to_text(new_html).splitlines()
|
||||
a = _markdown_to_text(old_md).splitlines()
|
||||
b = _markdown_to_text(new_md).splitlines()
|
||||
ud = difflib.unified_diff(a, b, fromfile="current", tofile="selected", lineterm="")
|
||||
lines = []
|
||||
for line in ud:
|
||||
|
|
@ -150,9 +152,13 @@ class HistoryDialog(QDialog):
|
|||
self.btn_revert.setEnabled(False)
|
||||
return
|
||||
sel_id = item.data(Qt.UserRole)
|
||||
# Preview selected as HTML
|
||||
# Preview selected as plain text (markdown)
|
||||
sel = self._db.get_version(version_id=sel_id)
|
||||
self.preview.setHtml(sel["content"])
|
||||
# Show markdown as plain text with monospace font for better readability
|
||||
self.preview.setPlainText(sel["content"])
|
||||
self.preview.setStyleSheet(
|
||||
"font-family: Consolas, Menlo, Monaco, monospace; font-size: 13px;"
|
||||
)
|
||||
# Diff vs current (textual diff)
|
||||
cur = self._db.get_version(version_id=self._current_id)
|
||||
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ from PySide6.QtWidgets import (
|
|||
)
|
||||
|
||||
from .db import DBManager
|
||||
from .editor import Editor
|
||||
from .markdown_editor import MarkdownEditor
|
||||
from .find_bar import FindBar
|
||||
from .history_dialog import HistoryDialog
|
||||
from .key_prompt import KeyPrompt
|
||||
|
|
@ -99,7 +99,7 @@ class MainWindow(QMainWindow):
|
|||
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
|
||||
|
||||
# This is the note-taking editor
|
||||
self.editor = Editor(self.themes)
|
||||
self.editor = MarkdownEditor(self.themes)
|
||||
|
||||
# Toolbar for controlling styling
|
||||
self.toolBar = ToolBar()
|
||||
|
|
@ -107,14 +107,14 @@ class MainWindow(QMainWindow):
|
|||
# Wire toolbar intents to editor methods
|
||||
self.toolBar.boldRequested.connect(self.editor.apply_weight)
|
||||
self.toolBar.italicRequested.connect(self.editor.apply_italic)
|
||||
self.toolBar.underlineRequested.connect(self.editor.apply_underline)
|
||||
# Note: Markdown doesn't support underline, so we skip underlineRequested
|
||||
self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough)
|
||||
self.toolBar.codeRequested.connect(self.editor.apply_code)
|
||||
self.toolBar.headingRequested.connect(self.editor.apply_heading)
|
||||
self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets)
|
||||
self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
|
||||
self.toolBar.checkboxesRequested.connect(self.editor.toggle_checkboxes)
|
||||
self.toolBar.alignRequested.connect(self.editor.setAlignment)
|
||||
# Note: Markdown doesn't natively support alignment, removing alignRequested
|
||||
self.toolBar.historyRequested.connect(self._open_history)
|
||||
self.toolBar.insertImageRequested.connect(self._on_insert_image)
|
||||
|
||||
|
|
@ -450,17 +450,14 @@ class MainWindow(QMainWindow):
|
|||
def _sync_toolbar(self):
|
||||
fmt = self.editor.currentCharFormat()
|
||||
c = self.editor.textCursor()
|
||||
bf = c.blockFormat()
|
||||
|
||||
# Block signals so setChecked() doesn't re-trigger actions
|
||||
QSignalBlocker(self.toolBar.actBold)
|
||||
QSignalBlocker(self.toolBar.actItalic)
|
||||
QSignalBlocker(self.toolBar.actUnderline)
|
||||
QSignalBlocker(self.toolBar.actStrike)
|
||||
|
||||
self.toolBar.actBold.setChecked(fmt.fontWeight() == QFont.Weight.Bold)
|
||||
self.toolBar.actItalic.setChecked(fmt.fontItalic())
|
||||
self.toolBar.actUnderline.setChecked(fmt.fontUnderline())
|
||||
self.toolBar.actStrike.setChecked(fmt.fontStrikeOut())
|
||||
|
||||
# Headings: decide which to check by current point size
|
||||
|
|
@ -492,15 +489,6 @@ class MainWindow(QMainWindow):
|
|||
self.toolBar.actBullets.setChecked(bool(bullets_on))
|
||||
self.toolBar.actNumbers.setChecked(bool(numbers_on))
|
||||
|
||||
# Alignment
|
||||
align = bf.alignment() & Qt.AlignHorizontal_Mask
|
||||
QSignalBlocker(self.toolBar.actAlignL)
|
||||
self.toolBar.actAlignL.setChecked(align == Qt.AlignLeft)
|
||||
QSignalBlocker(self.toolBar.actAlignC)
|
||||
self.toolBar.actAlignC.setChecked(align == Qt.AlignHCenter)
|
||||
QSignalBlocker(self.toolBar.actAlignR)
|
||||
self.toolBar.actAlignR.setChecked(align == Qt.AlignRight)
|
||||
|
||||
def _current_date_iso(self) -> str:
|
||||
d = self.calendar.selectedDate()
|
||||
return f"{d.year():04d}-{d.month():02d}-{d.day():02d}"
|
||||
|
|
@ -511,14 +499,12 @@ class MainWindow(QMainWindow):
|
|||
try:
|
||||
text = self.db.get_entry(date_iso)
|
||||
if extra_data:
|
||||
# Wrap extra_data in a <p> tag for HTML rendering
|
||||
extra_data_html = f"<p>{extra_data}</p>"
|
||||
|
||||
# Inject the extra_data before the closing </body></html>
|
||||
modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text)
|
||||
text = modified
|
||||
# Append extra data as markdown
|
||||
if text and not text.endswith("\n"):
|
||||
text += "\n"
|
||||
text += extra_data
|
||||
# Force a save now so we don't lose it.
|
||||
self._set_editor_html_preserve_view(text)
|
||||
self._set_editor_markdown_preserve_view(text)
|
||||
self._dirty = True
|
||||
self._save_date(date_iso, True)
|
||||
|
||||
|
|
@ -526,7 +512,7 @@ class MainWindow(QMainWindow):
|
|||
QMessageBox.critical(self, "Read Error", str(e))
|
||||
return
|
||||
|
||||
self._set_editor_html_preserve_view(text)
|
||||
self._set_editor_markdown_preserve_view(text)
|
||||
|
||||
self._dirty = False
|
||||
# track which date the editor currently represents
|
||||
|
|
@ -556,39 +542,33 @@ class MainWindow(QMainWindow):
|
|||
text = self.db.get_entry(yesterday_str)
|
||||
unchecked_items = []
|
||||
|
||||
# Regex to match the unchecked checkboxes and their associated text
|
||||
checkbox_pattern = re.compile(
|
||||
r"<span[^>]*>(☐)</span>\s*(.*?)</p>", re.DOTALL
|
||||
)
|
||||
# Split into lines and find unchecked checkbox items
|
||||
lines = text.split("\n")
|
||||
remaining_lines = []
|
||||
|
||||
# Find unchecked items and store them
|
||||
for match in checkbox_pattern.finditer(text):
|
||||
checkbox = match.group(1) # Either ☐ or ☑
|
||||
item_text = match.group(2).strip() # The text after the checkbox
|
||||
if checkbox == "☐": # If it's an unchecked checkbox (☐)
|
||||
unchecked_items.append("☐ " + item_text) # Store the unchecked item
|
||||
for line in lines:
|
||||
# Check for unchecked markdown checkboxes: - [ ] or - [☐]
|
||||
if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match(
|
||||
r"^\s*-\s*\[☐\]\s+", line
|
||||
):
|
||||
# Extract the text after the checkbox
|
||||
item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line)
|
||||
unchecked_items.append(f"- [ ] {item_text}")
|
||||
else:
|
||||
# Keep all other lines
|
||||
remaining_lines.append(line)
|
||||
|
||||
# Remove the unchecked items from yesterday's HTML content
|
||||
# Save modified content back if we moved items
|
||||
if unchecked_items:
|
||||
# This regex will find the entire checkbox line and remove it from the HTML content
|
||||
uncheckbox_pattern = re.compile(
|
||||
r"<span[^>]*>☐</span>\s*(.*?)</p>", re.DOTALL
|
||||
)
|
||||
modified_text = re.sub(
|
||||
uncheckbox_pattern, "", text
|
||||
) # Remove the checkbox lines
|
||||
|
||||
# Save the modified HTML back to the database
|
||||
modified_text = "\n".join(remaining_lines)
|
||||
self.db.save_new_version(
|
||||
yesterday_str,
|
||||
modified_text,
|
||||
"Unchecked checkbox items moved to next day",
|
||||
)
|
||||
|
||||
# Join unchecked items into a formatted string
|
||||
unchecked_str = "\n".join(
|
||||
[f"<p>{item}</p>" for item in unchecked_items]
|
||||
)
|
||||
# Join unchecked items into markdown format
|
||||
unchecked_str = "\n".join(unchecked_items) + "\n"
|
||||
|
||||
# Load the unchecked items into the current editor
|
||||
self._load_selected_date(False, unchecked_str)
|
||||
|
|
@ -621,7 +601,7 @@ class MainWindow(QMainWindow):
|
|||
"""
|
||||
if not self._dirty and not explicit:
|
||||
return
|
||||
text = self.editor.to_html_with_embedded_images()
|
||||
text = self.editor.to_markdown()
|
||||
try:
|
||||
self.db.save_new_version(date_iso, text, note)
|
||||
except Exception as e:
|
||||
|
|
@ -674,7 +654,9 @@ class MainWindow(QMainWindow):
|
|||
)
|
||||
if not paths:
|
||||
return
|
||||
self.editor.insert_images(paths) # call into the editor
|
||||
# Insert each image
|
||||
for path_str in paths:
|
||||
self.editor.insert_image_from_path(Path(path_str))
|
||||
|
||||
# ----------- Settings handler ------------#
|
||||
def _open_settings(self):
|
||||
|
|
@ -975,7 +957,7 @@ If you want an encrypted backup, choose Backup instead of Export.
|
|||
if ev.type() == QEvent.ActivationChange and self.isActiveWindow():
|
||||
QTimer.singleShot(0, self._focus_editor_now)
|
||||
|
||||
def _set_editor_html_preserve_view(self, html: str):
|
||||
def _set_editor_markdown_preserve_view(self, markdown: str):
|
||||
ed = self.editor
|
||||
|
||||
# Save caret/selection and scroll
|
||||
|
|
@ -986,15 +968,19 @@ If you want an encrypted backup, choose Backup instead of Export.
|
|||
|
||||
# Only touch the doc if it actually changed
|
||||
ed.blockSignals(True)
|
||||
if ed.toHtml() != html:
|
||||
ed.setHtml(html)
|
||||
if ed.to_markdown() != markdown:
|
||||
ed.from_markdown(markdown)
|
||||
ed.blockSignals(False)
|
||||
|
||||
# Restore scroll first
|
||||
ed.verticalScrollBar().setValue(v)
|
||||
ed.horizontalScrollBar().setValue(h)
|
||||
|
||||
# Restore caret/selection
|
||||
# Restore caret/selection (bounded to new doc length)
|
||||
doc_length = ed.document().characterCount() - 1
|
||||
old_pos = min(old_pos, doc_length)
|
||||
old_anchor = min(old_anchor, doc_length)
|
||||
|
||||
cur = ed.textCursor()
|
||||
cur.setPosition(old_anchor)
|
||||
mode = (
|
||||
|
|
|
|||
795
bouquin/markdown_editor.py
Normal file
795
bouquin/markdown_editor.py
Normal file
|
|
@ -0,0 +1,795 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
QFontDatabase,
|
||||
QImage,
|
||||
QPalette,
|
||||
QGuiApplication,
|
||||
QTextCharFormat,
|
||||
QTextCursor,
|
||||
QTextDocument,
|
||||
QSyntaxHighlighter,
|
||||
QTextImageFormat,
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QTextEdit
|
||||
|
||||
from .theme import ThemeManager, Theme
|
||||
|
||||
|
||||
class MarkdownHighlighter(QSyntaxHighlighter):
|
||||
"""Live syntax highlighter for markdown that applies formatting as you type."""
|
||||
|
||||
def __init__(self, document: QTextDocument, theme_manager: ThemeManager):
|
||||
super().__init__(document)
|
||||
self.theme_manager = theme_manager
|
||||
self._setup_formats()
|
||||
# Recompute formats whenever the app theme changes
|
||||
try:
|
||||
self.theme_manager.themeChanged.connect(self._on_theme_changed)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_theme_changed(self, *_):
|
||||
self._setup_formats()
|
||||
self.rehighlight()
|
||||
|
||||
def _setup_formats(self):
|
||||
"""Setup text formats for different markdown elements."""
|
||||
# Bold: **text** or __text__
|
||||
self.bold_format = QTextCharFormat()
|
||||
self.bold_format.setFontWeight(QFont.Weight.Bold)
|
||||
|
||||
# Italic: *text* or _text_
|
||||
self.italic_format = QTextCharFormat()
|
||||
self.italic_format.setFontItalic(True)
|
||||
|
||||
# Strikethrough: ~~text~~
|
||||
self.strike_format = QTextCharFormat()
|
||||
self.strike_format.setFontStrikeOut(True)
|
||||
|
||||
# Code: `code`
|
||||
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
||||
self.code_format = QTextCharFormat()
|
||||
self.code_format.setFont(mono)
|
||||
self.code_format.setFontFixedPitch(True)
|
||||
|
||||
# Code block: ```
|
||||
self.code_block_format = QTextCharFormat()
|
||||
self.code_block_format.setFont(mono)
|
||||
self.code_block_format.setFontFixedPitch(True)
|
||||
pal = QGuiApplication.palette()
|
||||
if self.theme_manager.current() == Theme.DARK:
|
||||
# In dark mode, use a darker panel-like background
|
||||
bg = pal.color(QPalette.AlternateBase)
|
||||
fg = pal.color(QPalette.Text)
|
||||
else:
|
||||
# Light mode: keep the existing light gray
|
||||
bg = QColor(245, 245, 245)
|
||||
fg = pal.color(QPalette.Text)
|
||||
self.code_block_format.setBackground(bg)
|
||||
self.code_block_format.setForeground(fg)
|
||||
|
||||
# Headings
|
||||
self.h1_format = QTextCharFormat()
|
||||
self.h1_format.setFontPointSize(24.0)
|
||||
self.h1_format.setFontWeight(QFont.Weight.Bold)
|
||||
|
||||
self.h2_format = QTextCharFormat()
|
||||
self.h2_format.setFontPointSize(18.0)
|
||||
self.h2_format.setFontWeight(QFont.Weight.Bold)
|
||||
|
||||
self.h3_format = QTextCharFormat()
|
||||
self.h3_format.setFontPointSize(14.0)
|
||||
self.h3_format.setFontWeight(QFont.Weight.Bold)
|
||||
|
||||
# Markdown syntax (the markers themselves) - make invisible
|
||||
self.syntax_format = QTextCharFormat()
|
||||
# Make the markers invisible by setting font size to 0.1 points
|
||||
self.syntax_format.setFontPointSize(0.1)
|
||||
# Also make them very faint in case they still show
|
||||
self.syntax_format.setForeground(QColor(250, 250, 250))
|
||||
|
||||
def highlightBlock(self, text: str):
|
||||
"""Apply formatting to a block of text based on markdown syntax."""
|
||||
if not text:
|
||||
return
|
||||
|
||||
# Track if we're in a code block (multiline)
|
||||
prev_state = self.previousBlockState()
|
||||
in_code_block = prev_state == 1
|
||||
|
||||
# Check for code block fences
|
||||
if text.strip().startswith("```"):
|
||||
# Toggle code block state
|
||||
in_code_block = not in_code_block
|
||||
self.setCurrentBlockState(1 if in_code_block else 0)
|
||||
# Format the fence markers - but keep them somewhat visible for editing
|
||||
# Use code format instead of syntax format so cursor is visible
|
||||
self.setFormat(0, len(text), self.code_block_format)
|
||||
return
|
||||
|
||||
if in_code_block:
|
||||
# Format entire line as code
|
||||
self.setFormat(0, len(text), self.code_block_format)
|
||||
self.setCurrentBlockState(1)
|
||||
return
|
||||
|
||||
self.setCurrentBlockState(0)
|
||||
|
||||
# Headings (must be at start of line)
|
||||
heading_match = re.match(r"^(#{1,3})\s+", text)
|
||||
if heading_match:
|
||||
level = len(heading_match.group(1))
|
||||
marker_len = len(heading_match.group(0))
|
||||
|
||||
# Format the # markers
|
||||
self.setFormat(0, marker_len, self.syntax_format)
|
||||
|
||||
# Format the heading text
|
||||
heading_fmt = (
|
||||
self.h1_format
|
||||
if level == 1
|
||||
else self.h2_format if level == 2 else self.h3_format
|
||||
)
|
||||
self.setFormat(marker_len, len(text) - marker_len, heading_fmt)
|
||||
return
|
||||
|
||||
# Bold: **text** or __text__
|
||||
for match in re.finditer(r"\*\*(.+?)\*\*|__(.+?)__", text):
|
||||
start, end = match.span()
|
||||
content_start = start + 2
|
||||
content_end = end - 2
|
||||
|
||||
# Gray out the markers
|
||||
self.setFormat(start, 2, self.syntax_format)
|
||||
self.setFormat(end - 2, 2, self.syntax_format)
|
||||
|
||||
# Bold the content
|
||||
self.setFormat(content_start, content_end - content_start, self.bold_format)
|
||||
|
||||
# Italic: *text* or _text_ (but not part of bold)
|
||||
for match in re.finditer(
|
||||
r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", text
|
||||
):
|
||||
start, end = match.span()
|
||||
# Skip if this is part of a bold pattern
|
||||
if start > 0 and text[start - 1 : start + 1] in ("**", "__"):
|
||||
continue
|
||||
if end < len(text) and text[end : end + 1] in ("*", "_"):
|
||||
continue
|
||||
|
||||
content_start = start + 1
|
||||
content_end = end - 1
|
||||
|
||||
# Gray out markers
|
||||
self.setFormat(start, 1, self.syntax_format)
|
||||
self.setFormat(end - 1, 1, self.syntax_format)
|
||||
|
||||
# Italicize content
|
||||
self.setFormat(
|
||||
content_start, content_end - content_start, self.italic_format
|
||||
)
|
||||
|
||||
# Strikethrough: ~~text~~
|
||||
for match in re.finditer(r"~~(.+?)~~", text):
|
||||
start, end = match.span()
|
||||
content_start = start + 2
|
||||
content_end = end - 2
|
||||
|
||||
self.setFormat(start, 2, self.syntax_format)
|
||||
self.setFormat(end - 2, 2, self.syntax_format)
|
||||
self.setFormat(
|
||||
content_start, content_end - content_start, self.strike_format
|
||||
)
|
||||
|
||||
# Inline code: `code`
|
||||
for match in re.finditer(r"`([^`]+)`", text):
|
||||
start, end = match.span()
|
||||
content_start = start + 1
|
||||
content_end = end - 1
|
||||
|
||||
self.setFormat(start, 1, self.syntax_format)
|
||||
self.setFormat(end - 1, 1, self.syntax_format)
|
||||
self.setFormat(content_start, content_end - content_start, self.code_format)
|
||||
|
||||
|
||||
class MarkdownEditor(QTextEdit):
|
||||
"""A QTextEdit that stores/loads markdown and provides live rendering."""
|
||||
|
||||
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
|
||||
|
||||
# Checkbox characters (Unicode for display, markdown for storage)
|
||||
_CHECK_UNCHECKED_DISPLAY = "☐"
|
||||
_CHECK_CHECKED_DISPLAY = "☑"
|
||||
_CHECK_UNCHECKED_STORAGE = "[ ]"
|
||||
_CHECK_CHECKED_STORAGE = "[x]"
|
||||
|
||||
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)
|
||||
|
||||
# Install syntax highlighter
|
||||
self.highlighter = MarkdownHighlighter(self.document(), theme_manager)
|
||||
|
||||
# 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)
|
||||
|
||||
# Enable mouse tracking for checkbox clicking
|
||||
self.viewport().setMouseTracking(True)
|
||||
|
||||
def _on_text_changed(self):
|
||||
"""Handle live formatting updates - convert checkbox markdown to Unicode."""
|
||||
if self._updating:
|
||||
return
|
||||
|
||||
self._updating = True
|
||||
try:
|
||||
# Convert checkbox markdown to Unicode for display
|
||||
cursor = self.textCursor()
|
||||
pos = cursor.position()
|
||||
|
||||
text = self.toPlainText()
|
||||
|
||||
# Convert lines that START with "TODO " into an unchecked checkbox.
|
||||
# Keeps any leading indentation.
|
||||
todo_re = re.compile(r"(?m)^([ \t]*)TODO\s")
|
||||
if todo_re.search(text):
|
||||
modified_text = todo_re.sub(
|
||||
lambda m: f"{m.group(1)}- {self._CHECK_UNCHECKED_DISPLAY} ",
|
||||
text,
|
||||
)
|
||||
else:
|
||||
modified_text = text
|
||||
|
||||
# Replace checkbox markdown with Unicode (for display only)
|
||||
modified_text = modified_text.replace(
|
||||
"- [x] ", f"- {self._CHECK_CHECKED_DISPLAY} "
|
||||
)
|
||||
modified_text = modified_text.replace(
|
||||
"- [ ] ", f"- {self._CHECK_UNCHECKED_DISPLAY} "
|
||||
)
|
||||
|
||||
if modified_text != text:
|
||||
# Count replacements before cursor to adjust position
|
||||
text_before = text[:pos]
|
||||
x_count = text_before.count("- [x] ")
|
||||
space_count = text_before.count("- [ ] ")
|
||||
# Each markdown checkbox -> unicode shortens by 2 chars ([x]/[ ] -> ☑/☐)
|
||||
checkbox_delta = (x_count + space_count) * 2
|
||||
# Each "TODO " -> "- ☐ " shortens by 1 char
|
||||
todo_count = len(list(todo_re.finditer(text_before)))
|
||||
todo_delta = todo_count * 1
|
||||
new_pos = pos - checkbox_delta - todo_delta
|
||||
|
||||
# Update the text
|
||||
self.blockSignals(True)
|
||||
self.setPlainText(modified_text)
|
||||
self.blockSignals(False)
|
||||
|
||||
# Restore cursor position
|
||||
cursor = self.textCursor()
|
||||
cursor.setPosition(max(0, min(new_pos, len(modified_text))))
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
finally:
|
||||
self._updating = False
|
||||
|
||||
def to_markdown(self) -> str:
|
||||
"""Export current content as markdown (convert Unicode checkboxes back to 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} ", "- [x] ")
|
||||
text = text.replace(f"- {self._CHECK_UNCHECKED_DISPLAY} ", "- [ ] ")
|
||||
|
||||
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 (convert markdown checkboxes to Unicode)."""
|
||||
# Convert markdown checkboxes to Unicode for display
|
||||
display_text = markdown_text.replace(
|
||||
"- [x] ", f"- {self._CHECK_CHECKED_DISPLAY} "
|
||||
)
|
||||
display_text = display_text.replace(
|
||||
"- [ ] ", 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)}- {self._CHECK_UNCHECKED_DISPLAY} ",
|
||||
display_text,
|
||||
)
|
||||
|
||||
self._updating = True
|
||||
try:
|
||||
self.setPlainText(display_text)
|
||||
finally:
|
||||
self._updating = False
|
||||
|
||||
# Render any embedded images
|
||||
self._render_images()
|
||||
|
||||
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)
|
||||
|
||||
try:
|
||||
# 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)
|
||||
|
||||
except Exception as e:
|
||||
# If image fails to render, leave the markdown as-is
|
||||
print(f"Failed to render image: {e}")
|
||||
continue
|
||||
|
||||
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 _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
|
||||
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 keyPressEvent(self, event):
|
||||
"""Handle special key events for markdown editing."""
|
||||
|
||||
# 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()
|
||||
block_state = current_block.userState()
|
||||
|
||||
# If current line is opening code fence, or we're inside a code block
|
||||
if current_line.strip().startswith("```") or block_state == 1:
|
||||
# Just insert a regular newline - the highlighter will format it as code
|
||||
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 - remember this
|
||||
self._last_enter_was_empty = True
|
||||
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 mousePressEvent(self, event):
|
||||
"""Handle mouse clicks - check for checkbox clicking."""
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
cursor = self.cursorForPosition(event.pos())
|
||||
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
|
||||
line = cursor.selectedText()
|
||||
|
||||
# Check if clicking on a checkbox line
|
||||
if (
|
||||
f"- {self._CHECK_UNCHECKED_DISPLAY} " in line
|
||||
or f"- {self._CHECK_CHECKED_DISPLAY} " in line
|
||||
):
|
||||
# Toggle the checkbox
|
||||
if f"- {self._CHECK_UNCHECKED_DISPLAY} " in line:
|
||||
new_line = line.replace(
|
||||
f"- {self._CHECK_UNCHECKED_DISPLAY} ",
|
||||
f"- {self._CHECK_CHECKED_DISPLAY} ",
|
||||
)
|
||||
else:
|
||||
new_line = line.replace(
|
||||
f"- {self._CHECK_CHECKED_DISPLAY} ",
|
||||
f"- {self._CHECK_UNCHECKED_DISPLAY} ",
|
||||
)
|
||||
|
||||
cursor.insertText(new_line)
|
||||
# Don't call super() - we handled the click
|
||||
return
|
||||
|
||||
# Default handling for non-checkbox clicks
|
||||
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 or toggle code block."""
|
||||
cursor = self.textCursor()
|
||||
|
||||
if cursor.hasSelection():
|
||||
# Wrap selection in code fence
|
||||
selected = cursor.selectedText()
|
||||
# Note: selectedText() uses Unicode paragraph separator, replace with newline
|
||||
selected = selected.replace("\u2029", "\n")
|
||||
new_text = f"```\n{selected}\n```"
|
||||
cursor.insertText(new_text)
|
||||
else:
|
||||
# Insert code block template
|
||||
cursor.insertText("```\n\n```")
|
||||
cursor.movePosition(
|
||||
QTextCursor.MoveOperation.Up, QTextCursor.MoveMode.MoveAnchor, 1
|
||||
)
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
# Return focus to editor
|
||||
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()
|
||||
|
||||
# Check if already a bullet
|
||||
if line.lstrip().startswith("- ") or line.lstrip().startswith("* "):
|
||||
# Remove bullet
|
||||
new_line = re.sub(r"^\s*[-*]\s+", "", line)
|
||||
else:
|
||||
# Add bullet
|
||||
new_line = "- " + line.lstrip()
|
||||
|
||||
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*-\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
|
||||
|
||||
# Use ORIGINAL 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 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
|
||||
|
|
@ -4,7 +4,6 @@ import re
|
|||
from typing import Iterable, Tuple
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QFont, QTextCharFormat, QTextCursor, QTextDocument
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QLabel,
|
||||
|
|
@ -149,10 +148,12 @@ class Search(QWidget):
|
|||
self.results.setItemWidget(item, container)
|
||||
|
||||
# --- Snippet/highlight helpers -----------------------------------------
|
||||
def _make_html_snippet(self, html_src: str, query: str, *, radius=60, maxlen=180):
|
||||
doc = QTextDocument()
|
||||
doc.setHtml(html_src)
|
||||
plain = doc.toPlainText()
|
||||
def _make_html_snippet(
|
||||
self, markdown_src: str, query: str, *, radius=60, maxlen=180
|
||||
):
|
||||
# For markdown, we can work directly with the text
|
||||
# Strip markdown formatting for display
|
||||
plain = self._strip_markdown(markdown_src)
|
||||
if not plain:
|
||||
return "", False, False
|
||||
|
||||
|
|
@ -179,30 +180,45 @@ class Search(QWidget):
|
|||
start = max(0, min(idx - radius, max(0, L - maxlen)))
|
||||
end = min(L, max(idx + mlen + radius, start + maxlen))
|
||||
|
||||
# Bold all token matches that fall inside [start, end)
|
||||
# Extract snippet and highlight matches
|
||||
snippet = plain[start:end]
|
||||
|
||||
# Escape HTML and bold matches
|
||||
import html as _html
|
||||
|
||||
snippet_html = _html.escape(snippet)
|
||||
if tokens:
|
||||
lower = plain.lower()
|
||||
fmt = QTextCharFormat()
|
||||
fmt.setFontWeight(QFont.Weight.Bold)
|
||||
for t in tokens:
|
||||
t_low = t.lower()
|
||||
pos = start
|
||||
while True:
|
||||
k = lower.find(t_low, pos)
|
||||
if k == -1 or k >= end:
|
||||
break
|
||||
c = QTextCursor(doc)
|
||||
c.setPosition(k)
|
||||
c.setPosition(k + len(t), QTextCursor.MoveMode.KeepAnchor)
|
||||
c.mergeCharFormat(fmt)
|
||||
pos = k + len(t)
|
||||
# Case-insensitive replacement
|
||||
pattern = re.compile(re.escape(t), re.IGNORECASE)
|
||||
snippet_html = pattern.sub(
|
||||
lambda m: f"<b>{m.group(0)}</b>", snippet_html
|
||||
)
|
||||
|
||||
# Select the window and export as HTML fragment
|
||||
c = QTextCursor(doc)
|
||||
c.setPosition(start)
|
||||
c.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
|
||||
fragment_html = (
|
||||
c.selection().toHtml()
|
||||
) # preserves original styles + our bolding
|
||||
return snippet_html, start > 0, end < L
|
||||
|
||||
return fragment_html, start > 0, end < L
|
||||
def _strip_markdown(self, markdown: str) -> str:
|
||||
"""Strip markdown formatting for plain text display."""
|
||||
# Remove images
|
||||
text = re.sub(r"!\[.*?\]\(.*?\)", "[Image]", markdown)
|
||||
# Remove links but keep text
|
||||
text = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", text)
|
||||
# Remove inline code backticks
|
||||
text = re.sub(r"`([^`]+)`", r"\1", text)
|
||||
# Remove bold/italic markers
|
||||
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
|
||||
text = re.sub(r"__([^_]+)__", r"\1", text)
|
||||
text = re.sub(r"\*([^*]+)\*", r"\1", text)
|
||||
text = re.sub(r"_([^_]+)_", r"\1", text)
|
||||
# Remove strikethrough
|
||||
text = re.sub(r"~~([^~]+)~~", r"\1", text)
|
||||
# Remove heading markers
|
||||
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
|
||||
# Remove list markers
|
||||
text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.MULTILINE)
|
||||
text = re.sub(r"^\s*\d+\.\s+", "", text, flags=re.MULTILINE)
|
||||
# Remove checkbox markers
|
||||
text = re.sub(r"^\s*-\s*\[[x ☐☑]\]\s+", "", text, flags=re.MULTILINE)
|
||||
# Remove code block fences
|
||||
text = re.sub(r"```[^\n]*\n", "", text)
|
||||
return text.strip()
|
||||
|
|
|
|||
|
|
@ -8,14 +8,12 @@ from PySide6.QtWidgets import QToolBar
|
|||
class ToolBar(QToolBar):
|
||||
boldRequested = Signal()
|
||||
italicRequested = Signal()
|
||||
underlineRequested = Signal()
|
||||
strikeRequested = Signal()
|
||||
codeRequested = Signal()
|
||||
headingRequested = Signal(int)
|
||||
bulletsRequested = Signal()
|
||||
numbersRequested = Signal()
|
||||
checkboxesRequested = Signal()
|
||||
alignRequested = Signal(Qt.AlignmentFlag)
|
||||
historyRequested = Signal()
|
||||
insertImageRequested = Signal()
|
||||
|
||||
|
|
@ -39,12 +37,6 @@ class ToolBar(QToolBar):
|
|||
self.actItalic.setShortcut(QKeySequence.Italic)
|
||||
self.actItalic.triggered.connect(self.italicRequested)
|
||||
|
||||
self.actUnderline = QAction("U", self)
|
||||
self.actUnderline.setToolTip("Underline")
|
||||
self.actUnderline.setCheckable(True)
|
||||
self.actUnderline.setShortcut(QKeySequence.Underline)
|
||||
self.actUnderline.triggered.connect(self.underlineRequested)
|
||||
|
||||
self.actStrike = QAction("S", self)
|
||||
self.actStrike.setToolTip("Strikethrough")
|
||||
self.actStrike.setCheckable(True)
|
||||
|
|
@ -97,24 +89,6 @@ class ToolBar(QToolBar):
|
|||
self.actInsertImg.setShortcut("Ctrl+Shift+I")
|
||||
self.actInsertImg.triggered.connect(self.insertImageRequested)
|
||||
|
||||
# Alignment
|
||||
self.actAlignL = QAction("L", self)
|
||||
self.actAlignL.setToolTip("Align Left")
|
||||
self.actAlignL.setCheckable(True)
|
||||
self.actAlignL.triggered.connect(lambda: self.alignRequested.emit(Qt.AlignLeft))
|
||||
self.actAlignC = QAction("C", self)
|
||||
self.actAlignC.setToolTip("Align Center")
|
||||
self.actAlignC.setCheckable(True)
|
||||
self.actAlignC.triggered.connect(
|
||||
lambda: self.alignRequested.emit(Qt.AlignHCenter)
|
||||
)
|
||||
self.actAlignR = QAction("R", self)
|
||||
self.actAlignR.setToolTip("Align Right")
|
||||
self.actAlignR.setCheckable(True)
|
||||
self.actAlignR.triggered.connect(
|
||||
lambda: self.alignRequested.emit(Qt.AlignRight)
|
||||
)
|
||||
|
||||
# History button
|
||||
self.actHistory = QAction("History", self)
|
||||
self.actHistory.triggered.connect(self.historyRequested)
|
||||
|
|
@ -125,7 +99,6 @@ class ToolBar(QToolBar):
|
|||
for a in (
|
||||
self.actBold,
|
||||
self.actItalic,
|
||||
self.actUnderline,
|
||||
self.actStrike,
|
||||
self.actH1,
|
||||
self.actH2,
|
||||
|
|
@ -135,11 +108,6 @@ class ToolBar(QToolBar):
|
|||
a.setCheckable(True)
|
||||
a.setActionGroup(self.grpHeadings)
|
||||
|
||||
self.grpAlign = QActionGroup(self)
|
||||
self.grpAlign.setExclusive(True)
|
||||
for a in (self.actAlignL, self.actAlignC, self.actAlignR):
|
||||
a.setActionGroup(self.grpAlign)
|
||||
|
||||
self.grpLists = QActionGroup(self)
|
||||
self.grpLists.setExclusive(True)
|
||||
for a in (self.actBullets, self.actNumbers, self.actCheckboxes):
|
||||
|
|
@ -150,7 +118,6 @@ class ToolBar(QToolBar):
|
|||
[
|
||||
self.actBold,
|
||||
self.actItalic,
|
||||
self.actUnderline,
|
||||
self.actStrike,
|
||||
self.actCode,
|
||||
self.actH1,
|
||||
|
|
@ -161,9 +128,6 @@ class ToolBar(QToolBar):
|
|||
self.actNumbers,
|
||||
self.actCheckboxes,
|
||||
self.actInsertImg,
|
||||
self.actAlignL,
|
||||
self.actAlignC,
|
||||
self.actAlignR,
|
||||
self.actHistory,
|
||||
]
|
||||
)
|
||||
|
|
@ -171,7 +135,6 @@ class ToolBar(QToolBar):
|
|||
def _apply_toolbar_styles(self):
|
||||
self._style_letter_button(self.actBold, "B", bold=True)
|
||||
self._style_letter_button(self.actItalic, "I", italic=True)
|
||||
self._style_letter_button(self.actUnderline, "U", underline=True)
|
||||
self._style_letter_button(self.actStrike, "S", strike=True)
|
||||
# Monospace look for code; use a fixed font
|
||||
code_font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
||||
|
|
@ -187,11 +150,6 @@ class ToolBar(QToolBar):
|
|||
self._style_letter_button(self.actBullets, "•")
|
||||
self._style_letter_button(self.actNumbers, "1.")
|
||||
|
||||
# Alignment
|
||||
self._style_letter_button(self.actAlignL, "L")
|
||||
self._style_letter_button(self.actAlignC, "C")
|
||||
self._style_letter_button(self.actAlignR, "R")
|
||||
|
||||
# History
|
||||
self._style_letter_button(self.actHistory, "View History")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue