Compare commits
No commits in common. "43c31a1d9706d4f870fe75678afb2d0c0265e9cf" and "bfd031410927512ba8e97b9f96844baf0e575f75" have entirely different histories.
43c31a1d97
...
bfd0314109
7 changed files with 247 additions and 549 deletions
|
|
@ -1,8 +1,3 @@
|
||||||
# 0.2.1.6
|
|
||||||
|
|
||||||
* Some code cleanup and more coverage
|
|
||||||
* Improve code block styling / escaping out of the block in various scenarios
|
|
||||||
|
|
||||||
# 0.2.1.5
|
# 0.2.1.5
|
||||||
|
|
||||||
* Go back to font size 10 (I might add a switcher later)
|
* Go back to font size 10 (I might add a switcher later)
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,204 @@ import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
|
QColor,
|
||||||
QFont,
|
QFont,
|
||||||
|
QFontDatabase,
|
||||||
QFontMetrics,
|
QFontMetrics,
|
||||||
QImage,
|
QImage,
|
||||||
|
QPalette,
|
||||||
|
QGuiApplication,
|
||||||
QTextCharFormat,
|
QTextCharFormat,
|
||||||
QTextCursor,
|
QTextCursor,
|
||||||
QTextDocument,
|
QTextDocument,
|
||||||
QTextFormat,
|
QSyntaxHighlighter,
|
||||||
QTextImageFormat,
|
QTextImageFormat,
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Qt, QRect, QTimer
|
from PySide6.QtCore import Qt, QRect
|
||||||
from PySide6.QtWidgets import QTextEdit
|
from PySide6.QtWidgets import QTextEdit
|
||||||
|
|
||||||
from .theme import ThemeManager
|
from .theme import ThemeManager, Theme
|
||||||
from .markdown_highlighter import MarkdownHighlighter
|
|
||||||
|
|
||||||
|
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
|
||||||
|
self.theme_manager.themeChanged.connect(self._on_theme_changed)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Inline 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("```"):
|
||||||
|
# background for the whole fence line (so block looks continuous)
|
||||||
|
self.setFormat(0, len(text), self.code_block_format)
|
||||||
|
|
||||||
|
# hide the three backticks themselves
|
||||||
|
idx = text.find("```")
|
||||||
|
if idx != -1:
|
||||||
|
self.setFormat(idx, 3, self.syntax_format)
|
||||||
|
|
||||||
|
# toggle code-block state and stop; next line picks up state
|
||||||
|
in_code_block = not in_code_block
|
||||||
|
self.setCurrentBlockState(1 if in_code_block else 0)
|
||||||
|
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):
|
class MarkdownEditor(QTextEdit):
|
||||||
|
|
@ -60,10 +244,6 @@ class MarkdownEditor(QTextEdit):
|
||||||
|
|
||||||
# Connect to text changes for smart formatting
|
# Connect to text changes for smart formatting
|
||||||
self.textChanged.connect(self._on_text_changed)
|
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
|
# Enable mouse tracking for checkbox clicking
|
||||||
self.viewport().setMouseTracking(True)
|
self.viewport().setMouseTracking(True)
|
||||||
|
|
@ -73,12 +253,6 @@ class MarkdownEditor(QTextEdit):
|
||||||
# reattach the highlighter to the new document
|
# reattach the highlighter to the new document
|
||||||
if hasattr(self, "highlighter") and self.highlighter:
|
if hasattr(self, "highlighter") and self.highlighter:
|
||||||
self.highlighter.setDocument(self.document())
|
self.highlighter.setDocument(self.document())
|
||||||
QTimer.singleShot(0, self._update_code_block_row_backgrounds)
|
|
||||||
|
|
||||||
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):
|
def _on_text_changed(self):
|
||||||
"""Handle live formatting updates - convert checkbox markdown to Unicode."""
|
"""Handle live formatting updates - convert checkbox markdown to Unicode."""
|
||||||
|
|
@ -127,61 +301,6 @@ class MarkdownEditor(QTextEdit):
|
||||||
finally:
|
finally:
|
||||||
self._updating = False
|
self._updating = False
|
||||||
|
|
||||||
def _is_inside_code_block(self, block):
|
|
||||||
"""Return True if 'block' is inside a fenced code block (based on fences above)."""
|
|
||||||
inside = False
|
|
||||||
b = block.previous()
|
|
||||||
while b.isValid():
|
|
||||||
if b.text().strip().startswith("```"):
|
|
||||||
inside = not inside
|
|
||||||
b = b.previous()
|
|
||||||
return inside
|
|
||||||
|
|
||||||
def _update_code_block_row_backgrounds(self):
|
|
||||||
"""Paint a full-width background for each line that is in a fenced code block."""
|
|
||||||
doc = self.document()
|
|
||||||
sels = []
|
|
||||||
|
|
||||||
# Use the same bg color as the highlighter's code block
|
|
||||||
bg_brush = self.highlighter.code_block_format.background()
|
|
||||||
|
|
||||||
inside = False
|
|
||||||
block = doc.begin()
|
|
||||||
while block.isValid():
|
|
||||||
text = block.text()
|
|
||||||
stripped = text.strip()
|
|
||||||
is_fence = stripped.startswith("```")
|
|
||||||
|
|
||||||
paint_this_line = is_fence or inside
|
|
||||||
if paint_this_line:
|
|
||||||
sel = QTextEdit.ExtraSelection()
|
|
||||||
fmt = QTextCharFormat()
|
|
||||||
fmt.setBackground(bg_brush)
|
|
||||||
fmt.setProperty(QTextFormat.FullWidthSelection, True)
|
|
||||||
# mark so we can merge with other selections safely
|
|
||||||
fmt.setProperty(QTextFormat.UserProperty, "codeblock_bg")
|
|
||||||
sel.format = fmt
|
|
||||||
|
|
||||||
cur = QTextCursor(doc)
|
|
||||||
cur.setPosition(
|
|
||||||
block.position()
|
|
||||||
) # collapsed cursor = whole line when FullWidthSelection
|
|
||||||
sel.cursor = cur
|
|
||||||
|
|
||||||
sels.append(sel)
|
|
||||||
|
|
||||||
if is_fence:
|
|
||||||
inside = not inside
|
|
||||||
|
|
||||||
block = block.next()
|
|
||||||
|
|
||||||
others = [
|
|
||||||
s
|
|
||||||
for s in self.extraSelections()
|
|
||||||
if s.format.property(QTextFormat.UserProperty) != "codeblock_bg"
|
|
||||||
]
|
|
||||||
self.setExtraSelections(others + sels)
|
|
||||||
|
|
||||||
def to_markdown(self) -> str:
|
def to_markdown(self) -> str:
|
||||||
"""Export current content as markdown."""
|
"""Export current content as markdown."""
|
||||||
# First, extract any embedded images and convert to markdown
|
# First, extract any embedded images and convert to markdown
|
||||||
|
|
@ -258,9 +377,6 @@ class MarkdownEditor(QTextEdit):
|
||||||
# Render any embedded images
|
# Render any embedded images
|
||||||
self._render_images()
|
self._render_images()
|
||||||
|
|
||||||
self._update_code_block_row_backgrounds()
|
|
||||||
QTimer.singleShot(0, self._update_code_block_row_backgrounds)
|
|
||||||
|
|
||||||
def _render_images(self):
|
def _render_images(self):
|
||||||
"""Find and render base64 images in the document."""
|
"""Find and render base64 images in the document."""
|
||||||
text = self.toPlainText()
|
text = self.toPlainText()
|
||||||
|
|
@ -364,7 +480,7 @@ class MarkdownEditor(QTextEdit):
|
||||||
edit.beginEditBlock()
|
edit.beginEditBlock()
|
||||||
edit.setPosition(start)
|
edit.setPosition(start)
|
||||||
edit.setPosition(start + 2, QTextCursor.KeepAnchor)
|
edit.setPosition(start + 2, QTextCursor.KeepAnchor)
|
||||||
edit.insertText("```\n\n```\n")
|
edit.insertText("```\n\n```")
|
||||||
edit.endEditBlock()
|
edit.endEditBlock()
|
||||||
|
|
||||||
# place caret on the blank line between the fences
|
# place caret on the blank line between the fences
|
||||||
|
|
@ -372,52 +488,6 @@ class MarkdownEditor(QTextEdit):
|
||||||
c.setPosition(new_pos)
|
c.setPosition(new_pos)
|
||||||
self.setTextCursor(c)
|
self.setTextCursor(c)
|
||||||
return
|
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 Enter key for smart list continuation AND code blocks
|
# Handle Enter key for smart list continuation AND code blocks
|
||||||
if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
|
if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter:
|
||||||
|
|
@ -636,80 +706,25 @@ class MarkdownEditor(QTextEdit):
|
||||||
self.setFocus()
|
self.setFocus()
|
||||||
|
|
||||||
def apply_code(self):
|
def apply_code(self):
|
||||||
"""Insert a fenced code block, or navigate fences without creating inline backticks."""
|
"""Insert or toggle code block."""
|
||||||
c = self.textCursor()
|
cursor = self.textCursor()
|
||||||
doc = self.document()
|
|
||||||
|
|
||||||
if c.hasSelection():
|
if cursor.hasSelection():
|
||||||
# Wrap selection and ensure exactly one newline after the closing fence
|
# Wrap selection in code fence
|
||||||
selected = c.selectedText().replace("\u2029", "\n")
|
selected = cursor.selectedText()
|
||||||
c.insertText(f"```\n{selected.rstrip()}\n```\n")
|
# selectedText() uses Unicode paragraph separator, replace with newline
|
||||||
if hasattr(self, "_update_code_block_row_backgrounds"):
|
selected = selected.replace("\u2029", "\n")
|
||||||
self._update_code_block_row_backgrounds()
|
new_text = f"```\n{selected}\n```"
|
||||||
self.setFocus()
|
cursor.insertText(new_text)
|
||||||
return
|
|
||||||
|
|
||||||
block = c.block()
|
|
||||||
line = block.text()
|
|
||||||
pos_in_block = c.position() - block.position()
|
|
||||||
stripped = line.strip()
|
|
||||||
|
|
||||||
# If we're on a fence line, be helpful but never insert inline fences
|
|
||||||
if stripped.startswith("```"):
|
|
||||||
# Is this fence opening or closing? (look at blocks above)
|
|
||||||
inside_before = self._is_inside_code_block(block.previous())
|
|
||||||
if inside_before:
|
|
||||||
# This fence closes the block → ensure a line after, then move there
|
|
||||||
endpos = block.position() + len(line)
|
|
||||||
edit = QTextCursor(doc)
|
|
||||||
edit.setPosition(endpos)
|
|
||||||
if not block.next().isValid():
|
|
||||||
edit.insertText("\n")
|
|
||||||
c.setPosition(endpos + 1)
|
|
||||||
self.setTextCursor(c)
|
|
||||||
if hasattr(self, "_update_code_block_row_backgrounds"):
|
|
||||||
self._update_code_block_row_backgrounds()
|
|
||||||
self.setFocus()
|
|
||||||
return
|
|
||||||
else:
|
else:
|
||||||
# Opening fence → move caret to the next line (inside the block)
|
# Insert code block template
|
||||||
nb = block.next()
|
cursor.insertText("```\n\n```")
|
||||||
if not nb.isValid():
|
cursor.movePosition(
|
||||||
e = QTextCursor(doc)
|
QTextCursor.MoveOperation.Up, QTextCursor.MoveMode.MoveAnchor, 1
|
||||||
e.setPosition(block.position() + len(line))
|
)
|
||||||
e.insertText("\n")
|
self.setTextCursor(cursor)
|
||||||
nb = block.next()
|
|
||||||
c.setPosition(nb.position())
|
|
||||||
self.setTextCursor(c)
|
|
||||||
self.setFocus()
|
|
||||||
return
|
|
||||||
|
|
||||||
# If we're inside a block (but not on a fence), don't mutate text
|
# Return focus to editor
|
||||||
if self._is_inside_code_block(block):
|
|
||||||
self.setFocus()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Outside any block → create a clean template on its own lines (never inline)
|
|
||||||
start_pos = c.position()
|
|
||||||
before = line[:pos_in_block]
|
|
||||||
|
|
||||||
edit = QTextCursor(doc)
|
|
||||||
edit.beginEditBlock()
|
|
||||||
|
|
||||||
# If there is text before the caret on the line, start the block on a new line
|
|
||||||
lead_break = "\n" if before else ""
|
|
||||||
# Insert the block; trailing newline guarantees you can Down-arrow out later
|
|
||||||
insert = f"{lead_break}```\n\n```\n"
|
|
||||||
edit.setPosition(start_pos)
|
|
||||||
edit.insertText(insert)
|
|
||||||
edit.endEditBlock()
|
|
||||||
|
|
||||||
# Put caret on the blank line inside the block
|
|
||||||
c.setPosition(start_pos + len(lead_break) + 4) # after "```\n"
|
|
||||||
self.setTextCursor(c)
|
|
||||||
|
|
||||||
if hasattr(self, "_update_code_block_row_backgrounds"):
|
|
||||||
self._update_code_block_row_backgrounds()
|
|
||||||
self.setFocus()
|
self.setFocus()
|
||||||
|
|
||||||
def apply_heading(self, size: int):
|
def apply_heading(self, size: int):
|
||||||
|
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
from PySide6.QtGui import (
|
|
||||||
QColor,
|
|
||||||
QFont,
|
|
||||||
QFontDatabase,
|
|
||||||
QGuiApplication,
|
|
||||||
QPalette,
|
|
||||||
QSyntaxHighlighter,
|
|
||||||
QTextCharFormat,
|
|
||||||
QTextDocument,
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
self.theme_manager.themeChanged.connect(self._on_theme_changed)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Inline 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."""
|
|
||||||
|
|
||||||
# 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("```"):
|
|
||||||
# background for the whole fence line (so block looks continuous)
|
|
||||||
self.setFormat(0, len(text), self.code_block_format)
|
|
||||||
|
|
||||||
# hide the three backticks themselves
|
|
||||||
idx = text.find("```")
|
|
||||||
if idx != -1:
|
|
||||||
self.setFormat(idx, 3, self.syntax_format)
|
|
||||||
|
|
||||||
# toggle code-block state and stop; next line picks up state
|
|
||||||
in_code_block = not in_code_block
|
|
||||||
self.setCurrentBlockState(1 if in_code_block else 0)
|
|
||||||
return
|
|
||||||
|
|
||||||
if in_code_block:
|
|
||||||
# inside code: apply block bg and language rules
|
|
||||||
self.setFormat(0, len(text), self.code_block_format)
|
|
||||||
self.setCurrentBlockState(1)
|
|
||||||
return
|
|
||||||
|
|
||||||
# ---- Normal markdown (outside code)
|
|
||||||
self.setCurrentBlockState(0)
|
|
||||||
|
|
||||||
# If the line is empty and not in a code block, nothing else to do
|
|
||||||
if not text:
|
|
||||||
return
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
@ -83,7 +83,10 @@ class Search(QWidget):
|
||||||
|
|
||||||
for date_str, content in rows:
|
for date_str, content in rows:
|
||||||
# Build an HTML fragment around the match and whether to show ellipses
|
# Build an HTML fragment around the match and whether to show ellipses
|
||||||
frag_html = self._make_html_snippet(content, query, radius=30, maxlen=90)
|
frag_html, left_ell, right_ell = self._make_html_snippet(
|
||||||
|
content, query, radius=30, maxlen=90
|
||||||
|
)
|
||||||
|
|
||||||
# ---- Per-item widget: date on top, preview row below (with ellipses) ----
|
# ---- Per-item widget: date on top, preview row below (with ellipses) ----
|
||||||
container = QWidget()
|
container = QWidget()
|
||||||
outer = QVBoxLayout(container)
|
outer = QVBoxLayout(container)
|
||||||
|
|
@ -105,6 +108,11 @@ class Search(QWidget):
|
||||||
h.setContentsMargins(0, 0, 0, 0)
|
h.setContentsMargins(0, 0, 0, 0)
|
||||||
h.setSpacing(4)
|
h.setSpacing(4)
|
||||||
|
|
||||||
|
if left_ell:
|
||||||
|
left = QLabel("…")
|
||||||
|
left.setStyleSheet("color:#888;")
|
||||||
|
h.addWidget(left, 0, Qt.AlignmentFlag.AlignTop)
|
||||||
|
|
||||||
preview = QLabel()
|
preview = QLabel()
|
||||||
preview.setTextFormat(Qt.TextFormat.RichText)
|
preview.setTextFormat(Qt.TextFormat.RichText)
|
||||||
preview.setWordWrap(True)
|
preview.setWordWrap(True)
|
||||||
|
|
@ -116,6 +124,11 @@ class Search(QWidget):
|
||||||
)
|
)
|
||||||
h.addWidget(preview, 1)
|
h.addWidget(preview, 1)
|
||||||
|
|
||||||
|
if right_ell:
|
||||||
|
right = QLabel("…")
|
||||||
|
right.setStyleSheet("color:#888;")
|
||||||
|
h.addWidget(right, 0, Qt.AlignmentFlag.AlignBottom)
|
||||||
|
|
||||||
outer.addWidget(row)
|
outer.addWidget(row)
|
||||||
|
|
||||||
line = QFrame()
|
line = QFrame()
|
||||||
|
|
@ -132,7 +145,9 @@ class Search(QWidget):
|
||||||
self.results.setItemWidget(item, container)
|
self.results.setItemWidget(item, container)
|
||||||
|
|
||||||
# --- Snippet/highlight helpers -----------------------------------------
|
# --- Snippet/highlight helpers -----------------------------------------
|
||||||
def _make_html_snippet(self, markdown_src: str, query: str, radius=60, maxlen=180):
|
def _make_html_snippet(
|
||||||
|
self, markdown_src: str, query: str, *, radius=60, maxlen=180
|
||||||
|
):
|
||||||
# For markdown, we can work directly with the text
|
# For markdown, we can work directly with the text
|
||||||
# Strip markdown formatting for display
|
# Strip markdown formatting for display
|
||||||
plain = self._strip_markdown(markdown_src)
|
plain = self._strip_markdown(markdown_src)
|
||||||
|
|
@ -177,7 +192,7 @@ class Search(QWidget):
|
||||||
lambda m: f"<b>{m.group(0)}</b>", snippet_html
|
lambda m: f"<b>{m.group(0)}</b>", snippet_html
|
||||||
)
|
)
|
||||||
|
|
||||||
return snippet_html
|
return snippet_html, start > 0, end < L
|
||||||
|
|
||||||
def _strip_markdown(self, markdown: str) -> str:
|
def _strip_markdown(self, markdown: str) -> str:
|
||||||
"""Strip markdown formatting for plain text display."""
|
"""Strip markdown formatting for plain text display."""
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "bouquin"
|
name = "bouquin"
|
||||||
version = "0.2.1.6"
|
version = "0.2.1.5"
|
||||||
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
||||||
authors = ["Miguel Jacq <mig@mig5.net>"]
|
authors = ["Miguel Jacq <mig@mig5.net>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
|
||||||
|
|
@ -6,29 +6,12 @@ from bouquin.markdown_editor import MarkdownEditor
|
||||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||||
|
|
||||||
|
|
||||||
def text(editor) -> str:
|
|
||||||
return editor.toPlainText()
|
|
||||||
|
|
||||||
|
|
||||||
def lines_keep(editor):
|
|
||||||
"""Split preserving a trailing empty line if the text ends with '\\n'."""
|
|
||||||
return text(editor).split("\n")
|
|
||||||
|
|
||||||
|
|
||||||
def press_backtick(qtbot, widget, n=1):
|
|
||||||
"""Send physical backtick key events (avoid IME/dead-key issues)."""
|
|
||||||
for _ in range(n):
|
|
||||||
qtbot.keyClick(widget, Qt.Key_QuoteLeft)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def editor(app, qtbot):
|
def editor(app, qtbot):
|
||||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
ed = MarkdownEditor(themes)
|
ed = MarkdownEditor(themes)
|
||||||
qtbot.addWidget(ed)
|
qtbot.addWidget(ed)
|
||||||
ed.show()
|
ed.show()
|
||||||
qtbot.waitExposed(ed)
|
|
||||||
ed.setFocus()
|
|
||||||
return ed
|
return ed
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -73,6 +56,24 @@ def test_insert_image_from_path(editor, tmp_path):
|
||||||
assert "data:image/image/png;base64" in md
|
assert "data:image/image/png;base64" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_code_inline(editor):
|
||||||
|
editor.from_markdown("alpha beta")
|
||||||
|
editor.selectAll()
|
||||||
|
editor.apply_code()
|
||||||
|
md = editor.to_markdown()
|
||||||
|
assert ("`" in md) or ("```" in md)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gui
|
||||||
|
def test_auto_close_code_fence(editor, qtbot):
|
||||||
|
# Place caret at start and type exactly `` then ` to trigger expansion
|
||||||
|
editor.setPlainText("")
|
||||||
|
qtbot.keyClicks(editor, "``")
|
||||||
|
qtbot.keyClicks(editor, "`") # third backtick triggers fence insertion
|
||||||
|
txt = editor.toPlainText()
|
||||||
|
assert "```" in txt and txt.count("```") >= 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
@pytest.mark.gui
|
||||||
def test_checkbox_toggle_by_click(editor, qtbot):
|
def test_checkbox_toggle_by_click(editor, qtbot):
|
||||||
# Load a markdown checkbox
|
# Load a markdown checkbox
|
||||||
|
|
@ -142,131 +143,3 @@ def test_enter_on_empty_list_marks_empty(qtbot, editor):
|
||||||
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
|
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
|
||||||
editor.keyPressEvent(ev)
|
editor.keyPressEvent(ev)
|
||||||
assert editor.toPlainText().startswith("- \n")
|
assert editor.toPlainText().startswith("- \n")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
|
||||||
def test_triple_backtick_autoexpands(editor, qtbot):
|
|
||||||
editor.from_markdown("")
|
|
||||||
press_backtick(qtbot, editor, 2)
|
|
||||||
press_backtick(qtbot, editor, 1) # triggers expansion
|
|
||||||
qtbot.wait(0)
|
|
||||||
|
|
||||||
t = text(editor)
|
|
||||||
assert t.count("```") == 2
|
|
||||||
assert t.startswith("```\n\n```")
|
|
||||||
assert t.endswith("\n")
|
|
||||||
# caret is on the blank line inside the block
|
|
||||||
assert editor.textCursor().blockNumber() == 1
|
|
||||||
assert lines_keep(editor)[1] == ""
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
|
||||||
def test_toolbar_inserts_block_on_own_lines(editor, qtbot):
|
|
||||||
editor.from_markdown("hello")
|
|
||||||
editor.moveCursor(QTextCursor.End)
|
|
||||||
editor.apply_code() # </> action
|
|
||||||
qtbot.wait(0)
|
|
||||||
|
|
||||||
t = text(editor)
|
|
||||||
assert "hello```" not in t # never inline
|
|
||||||
assert t.startswith("hello\n```")
|
|
||||||
assert t.endswith("```\n")
|
|
||||||
# caret inside block (blank line)
|
|
||||||
assert editor.textCursor().blockNumber() == 2
|
|
||||||
assert lines_keep(editor)[2] == ""
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
|
||||||
def test_toolbar_inside_block_does_not_insert_inline_fences(editor, qtbot):
|
|
||||||
editor.from_markdown("")
|
|
||||||
editor.apply_code() # create a block (caret now on blank line inside)
|
|
||||||
qtbot.wait(0)
|
|
||||||
|
|
||||||
pos_before = editor.textCursor().position()
|
|
||||||
t_before = text(editor)
|
|
||||||
|
|
||||||
editor.apply_code() # pressing </> inside should be a no-op
|
|
||||||
qtbot.wait(0)
|
|
||||||
|
|
||||||
assert text(editor) == t_before
|
|
||||||
assert editor.textCursor().position() == pos_before
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
|
||||||
def test_toolbar_on_opening_fence_jumps_inside(editor, qtbot):
|
|
||||||
editor.from_markdown("")
|
|
||||||
editor.apply_code()
|
|
||||||
qtbot.wait(0)
|
|
||||||
|
|
||||||
# Go to opening fence (line 0)
|
|
||||||
editor.moveCursor(QTextCursor.Start)
|
|
||||||
editor.apply_code() # should jump inside the block
|
|
||||||
qtbot.wait(0)
|
|
||||||
|
|
||||||
assert editor.textCursor().blockNumber() == 1
|
|
||||||
assert lines_keep(editor)[1] == ""
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
|
||||||
def test_toolbar_on_closing_fence_jumps_out(editor, qtbot):
|
|
||||||
editor.from_markdown("")
|
|
||||||
editor.apply_code()
|
|
||||||
qtbot.wait(0)
|
|
||||||
|
|
||||||
# Go to closing fence line (template: 0 fence, 1 blank, 2 fence, 3 blank-after)
|
|
||||||
editor.moveCursor(QTextCursor.End) # blank-after
|
|
||||||
editor.moveCursor(QTextCursor.Up) # closing fence
|
|
||||||
editor.moveCursor(QTextCursor.StartOfLine)
|
|
||||||
|
|
||||||
editor.apply_code() # jump to the line after the fence
|
|
||||||
qtbot.wait(0)
|
|
||||||
|
|
||||||
# Now on the blank line after the block
|
|
||||||
assert editor.textCursor().block().text() == ""
|
|
||||||
assert editor.textCursor().block().previous().text().strip() == "```"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
|
||||||
def test_down_escapes_from_last_code_line(editor, qtbot):
|
|
||||||
editor.from_markdown("```\nLINE\n```\n")
|
|
||||||
# Put caret at end of "LINE"
|
|
||||||
editor.moveCursor(QTextCursor.Start)
|
|
||||||
editor.moveCursor(QTextCursor.Down) # "LINE"
|
|
||||||
editor.moveCursor(QTextCursor.EndOfLine)
|
|
||||||
|
|
||||||
qtbot.keyPress(editor, Qt.Key_Down) # hop after closing fence
|
|
||||||
qtbot.wait(0)
|
|
||||||
|
|
||||||
# caret now on the blank line after the fence
|
|
||||||
assert editor.textCursor().block().text() == ""
|
|
||||||
assert editor.textCursor().block().previous().text().strip() == "```"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
|
||||||
def test_down_on_closing_fence_at_eof_creates_line(editor, qtbot):
|
|
||||||
editor.from_markdown("```\ncode\n```") # no trailing newline
|
|
||||||
# caret on closing fence line
|
|
||||||
editor.moveCursor(QTextCursor.End)
|
|
||||||
editor.moveCursor(QTextCursor.StartOfLine)
|
|
||||||
|
|
||||||
qtbot.keyPress(editor, Qt.Key_Down) # should append newline and move there
|
|
||||||
qtbot.wait(0)
|
|
||||||
|
|
||||||
# Do NOT use splitlines() here—preserve trailing blank line
|
|
||||||
assert text(editor).endswith("\n")
|
|
||||||
assert editor.textCursor().block().text() == "" # on the new blank line
|
|
||||||
assert editor.textCursor().block().previous().text().strip() == "```"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
|
||||||
def test_no_orphan_two_backticks_lines_after_edits(editor, qtbot):
|
|
||||||
editor.from_markdown("")
|
|
||||||
# create a block via typing
|
|
||||||
press_backtick(qtbot, editor, 3)
|
|
||||||
qtbot.keyClicks(editor, "x")
|
|
||||||
qtbot.keyPress(editor, Qt.Key_Down) # escape
|
|
||||||
editor.apply_code() # add second block via toolbar
|
|
||||||
qtbot.wait(0)
|
|
||||||
|
|
||||||
# ensure there are no stray "``" lines
|
|
||||||
assert not any(ln.strip() == "``" for ln in lines_keep(editor))
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ def test_make_html_snippet_and_strip_markdown(qtbot, fresh_db):
|
||||||
long = (
|
long = (
|
||||||
"This is **bold** text with alpha in the middle and some more trailing content."
|
"This is **bold** text with alpha in the middle and some more trailing content."
|
||||||
)
|
)
|
||||||
frag = s._make_html_snippet(long, "alpha", radius=10, maxlen=40)
|
frag, left, right = s._make_html_snippet(long, "alpha", radius=10, maxlen=40)
|
||||||
assert "alpha" in frag
|
assert "alpha" in frag
|
||||||
s._strip_markdown("**bold** _italic_ ~~strike~~ 1. item - [x] check")
|
s._strip_markdown("**bold** _italic_ ~~strike~~ 1. item - [x] check")
|
||||||
|
|
||||||
|
|
@ -70,12 +70,12 @@ def test_make_html_snippet_variants(qtbot, fresh_db):
|
||||||
|
|
||||||
# Case: query tokens not found -> idx < 0 path; expect right ellipsis when longer than maxlen
|
# Case: query tokens not found -> idx < 0 path; expect right ellipsis when longer than maxlen
|
||||||
src = " ".join(["word"] * 200)
|
src = " ".join(["word"] * 200)
|
||||||
frag = s._make_html_snippet(src, "nomatch", radius=3, maxlen=30)
|
frag, left, right = s._make_html_snippet(src, "nomatch", radius=3, maxlen=30)
|
||||||
assert frag
|
assert frag and not left and right
|
||||||
|
|
||||||
# Case: multiple tokens highlighted
|
# Case: multiple tokens highlighted
|
||||||
src = "Alpha bravo charlie delta echo"
|
src = "Alpha bravo charlie delta echo"
|
||||||
frag = s._make_html_snippet(src, "alpha delta", radius=2, maxlen=50)
|
frag, left, right = s._make_html_snippet(src, "alpha delta", radius=2, maxlen=50)
|
||||||
assert "<b>Alpha</b>" in frag or "<b>alpha</b>" in frag
|
assert "<b>Alpha</b>" in frag or "<b>alpha</b>" in frag
|
||||||
assert "<b>delta</b>" in frag
|
assert "<b>delta</b>" in frag
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue