convert to markdown (#1)

Reviewed-on: #1
This commit is contained in:
Miguel Jacq 2025-11-08 00:30:46 -06:00
parent 31604a0cd2
commit 39576ac7f3
54 changed files with 1616 additions and 4012 deletions

View file

@ -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:
"""

File diff suppressed because it is too large Load diff

View file

@ -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"]))

View file

@ -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
View 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"![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 (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

View file

@ -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()

View file

@ -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")