diff --git a/CHANGELOG.md b/CHANGELOG.md index ce68192..b486e8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 * Go back to font size 10 (I might add a switcher later) diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index a38ca1f..25cee3b 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -5,20 +5,204 @@ import re from pathlib import Path from PySide6.QtGui import ( + QColor, QFont, + QFontDatabase, QFontMetrics, QImage, + QPalette, + QGuiApplication, QTextCharFormat, QTextCursor, QTextDocument, - QTextFormat, + QSyntaxHighlighter, QTextImageFormat, ) -from PySide6.QtCore import Qt, QRect, QTimer +from PySide6.QtCore import Qt, QRect from PySide6.QtWidgets import QTextEdit -from .theme import ThemeManager -from .markdown_highlighter import MarkdownHighlighter +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.""" + 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"(? 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): @@ -60,10 +244,6 @@ class MarkdownEditor(QTextEdit): # Connect to text changes for smart formatting self.textChanged.connect(self._on_text_changed) - self.textChanged.connect(self._update_code_block_row_backgrounds) - self.theme_manager.themeChanged.connect( - lambda *_: self._update_code_block_row_backgrounds() - ) # Enable mouse tracking for checkbox clicking self.viewport().setMouseTracking(True) @@ -73,12 +253,6 @@ class MarkdownEditor(QTextEdit): # reattach the highlighter to the new document if hasattr(self, "highlighter") and self.highlighter: 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): """Handle live formatting updates - convert checkbox markdown to Unicode.""" @@ -127,61 +301,6 @@ class MarkdownEditor(QTextEdit): finally: self._updating = False - def _is_inside_code_block(self, block): - """Return True if 'block' is inside a fenced code block (based on fences above).""" - inside = False - b = block.previous() - while b.isValid(): - if b.text().strip().startswith("```"): - inside = not inside - b = b.previous() - return inside - - def _update_code_block_row_backgrounds(self): - """Paint a full-width background for each line that is in a fenced code block.""" - doc = self.document() - 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: """Export current content as markdown.""" # First, extract any embedded images and convert to markdown @@ -258,9 +377,6 @@ class MarkdownEditor(QTextEdit): # Render any embedded images self._render_images() - self._update_code_block_row_backgrounds() - QTimer.singleShot(0, self._update_code_block_row_backgrounds) - def _render_images(self): """Find and render base64 images in the document.""" text = self.toPlainText() @@ -364,7 +480,7 @@ class MarkdownEditor(QTextEdit): edit.beginEditBlock() edit.setPosition(start) edit.setPosition(start + 2, QTextCursor.KeepAnchor) - edit.insertText("```\n\n```\n") + edit.insertText("```\n\n```") edit.endEditBlock() # place caret on the blank line between the fences @@ -372,52 +488,6 @@ class MarkdownEditor(QTextEdit): c.setPosition(new_pos) self.setTextCursor(c) return - # Step out of a code block with Down at EOF - if event.key() == Qt.Key.Key_Down: - c = self.textCursor() - b = c.block() - pos_in_block = c.position() - b.position() - line = b.text() - - def next_is_closing(bb): - nb = bb.next() - return nb.isValid() and nb.text().strip().startswith("```") - - # Case A: caret is on the line BEFORE the closing fence, at EOL → jump after the fence - if ( - self._is_inside_code_block(b) - and pos_in_block >= len(line) - and next_is_closing(b) - ): - fence_block = b.next() - after_fence = fence_block.next() - if not after_fence.isValid(): - # make a line after the fence - edit = QTextCursor(self.document()) - endpos = fence_block.position() + len(fence_block.text()) - edit.setPosition(endpos) - edit.insertText("\n") - after_fence = fence_block.next() - c.setPosition(after_fence.position()) - self.setTextCursor(c) - if hasattr(self, "_update_code_block_row_backgrounds"): - self._update_code_block_row_backgrounds() - return - - # Case B: caret is ON the closing fence, and it's EOF → create a line and move to it - if ( - b.text().strip().startswith("```") - and self._is_inside_code_block(b) - and not b.next().isValid() - ): - edit = QTextCursor(self.document()) - edit.setPosition(b.position() + len(b.text())) - edit.insertText("\n") - c.setPosition(b.position() + len(b.text()) + 1) - self.setTextCursor(c) - if hasattr(self, "_update_code_block_row_backgrounds"): - self._update_code_block_row_backgrounds() - return # Handle Enter key for smart list continuation AND code blocks if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: @@ -636,80 +706,25 @@ class MarkdownEditor(QTextEdit): self.setFocus() def apply_code(self): - """Insert a fenced code block, or navigate fences without creating inline backticks.""" - c = self.textCursor() - doc = self.document() + """Insert or toggle code block.""" + cursor = self.textCursor() - if c.hasSelection(): - # Wrap selection and ensure exactly one newline after the closing fence - selected = c.selectedText().replace("\u2029", "\n") - c.insertText(f"```\n{selected.rstrip()}\n```\n") - if hasattr(self, "_update_code_block_row_backgrounds"): - self._update_code_block_row_backgrounds() - self.setFocus() - return + if cursor.hasSelection(): + # Wrap selection in code fence + selected = cursor.selectedText() + # 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) - block = c.block() - line = block.text() - pos_in_block = c.position() - block.position() - stripped = line.strip() - - # If we're on a fence line, be helpful but never insert inline fences - if stripped.startswith("```"): - # Is this fence opening or closing? (look at blocks above) - inside_before = self._is_inside_code_block(block.previous()) - if inside_before: - # This fence closes the block → ensure a line after, then move there - endpos = block.position() + len(line) - edit = QTextCursor(doc) - edit.setPosition(endpos) - if not block.next().isValid(): - edit.insertText("\n") - c.setPosition(endpos + 1) - self.setTextCursor(c) - if hasattr(self, "_update_code_block_row_backgrounds"): - self._update_code_block_row_backgrounds() - self.setFocus() - return - else: - # Opening fence → move caret to the next line (inside the block) - nb = block.next() - if not nb.isValid(): - e = QTextCursor(doc) - e.setPosition(block.position() + len(line)) - e.insertText("\n") - nb = block.next() - c.setPosition(nb.position()) - self.setTextCursor(c) - self.setFocus() - return - - # If we're inside a block (but not on a fence), don't mutate text - if self._is_inside_code_block(block): - self.setFocus() - return - - # Outside any block → create a clean template on its own lines (never inline) - start_pos = c.position() - before = line[:pos_in_block] - - edit = QTextCursor(doc) - edit.beginEditBlock() - - # If there is text before the caret on the line, start the block on a new line - lead_break = "\n" if before else "" - # Insert the block; trailing newline guarantees you can Down-arrow out later - insert = f"{lead_break}```\n\n```\n" - edit.setPosition(start_pos) - edit.insertText(insert) - edit.endEditBlock() - - # Put caret on the blank line inside the block - c.setPosition(start_pos + len(lead_break) + 4) # after "```\n" - self.setTextCursor(c) - - if hasattr(self, "_update_code_block_row_backgrounds"): - self._update_code_block_row_backgrounds() + # Return focus to editor self.setFocus() def apply_heading(self, size: int): diff --git a/bouquin/markdown_highlighter.py b/bouquin/markdown_highlighter.py deleted file mode 100644 index d1e82c7..0000000 --- a/bouquin/markdown_highlighter.py +++ /dev/null @@ -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"(? 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) diff --git a/bouquin/search.py b/bouquin/search.py index 71329c0..26693c4 100644 --- a/bouquin/search.py +++ b/bouquin/search.py @@ -83,7 +83,10 @@ class Search(QWidget): for date_str, content in rows: # 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) ---- container = QWidget() outer = QVBoxLayout(container) @@ -105,6 +108,11 @@ class Search(QWidget): h.setContentsMargins(0, 0, 0, 0) h.setSpacing(4) + if left_ell: + left = QLabel("…") + left.setStyleSheet("color:#888;") + h.addWidget(left, 0, Qt.AlignmentFlag.AlignTop) + preview = QLabel() preview.setTextFormat(Qt.TextFormat.RichText) preview.setWordWrap(True) @@ -116,6 +124,11 @@ class Search(QWidget): ) h.addWidget(preview, 1) + if right_ell: + right = QLabel("…") + right.setStyleSheet("color:#888;") + h.addWidget(right, 0, Qt.AlignmentFlag.AlignBottom) + outer.addWidget(row) line = QFrame() @@ -132,7 +145,9 @@ class Search(QWidget): self.results.setItemWidget(item, container) # --- 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 # Strip markdown formatting for display plain = self._strip_markdown(markdown_src) @@ -177,7 +192,7 @@ class Search(QWidget): lambda m: f"{m.group(0)}", snippet_html ) - return snippet_html + return snippet_html, start > 0, end < L def _strip_markdown(self, markdown: str) -> str: """Strip markdown formatting for plain text display.""" diff --git a/pyproject.toml b/pyproject.toml index 81a5cc5..fdf55ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] 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." authors = ["Miguel Jacq "] readme = "README.md" diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py index 002ab63..82fde94 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -6,29 +6,12 @@ from bouquin.markdown_editor import MarkdownEditor 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 def editor(app, qtbot): themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) ed = MarkdownEditor(themes) qtbot.addWidget(ed) ed.show() - qtbot.waitExposed(ed) - ed.setFocus() return ed @@ -73,6 +56,24 @@ def test_insert_image_from_path(editor, tmp_path): 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 def test_checkbox_toggle_by_click(editor, qtbot): # 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") editor.keyPressEvent(ev) 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)) diff --git a/tests/test_search.py b/tests/test_search.py index d71a785..5deca48 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -45,7 +45,7 @@ def test_make_html_snippet_and_strip_markdown(qtbot, fresh_db): long = ( "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 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 src = " ".join(["word"] * 200) - frag = s._make_html_snippet(src, "nomatch", radius=3, maxlen=30) - assert frag + frag, left, right = s._make_html_snippet(src, "nomatch", radius=3, maxlen=30) + assert frag and not left and right # Case: multiple tokens highlighted 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 "Alpha" in frag or "alpha" in frag assert "delta" in frag