diff --git a/CHANGELOG.md b/CHANGELOG.md index b486e8c..ce68192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 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 25cee3b..a38ca1f 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -5,204 +5,20 @@ import re from pathlib import Path from PySide6.QtGui import ( - QColor, QFont, - QFontDatabase, QFontMetrics, QImage, - QPalette, - QGuiApplication, QTextCharFormat, QTextCursor, QTextDocument, - QSyntaxHighlighter, + QTextFormat, QTextImageFormat, ) -from PySide6.QtCore import Qt, QRect +from PySide6.QtCore import Qt, QRect, QTimer 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 - 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) +from .theme import ThemeManager +from .markdown_highlighter import MarkdownHighlighter class MarkdownEditor(QTextEdit): @@ -244,6 +60,10 @@ 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) @@ -253,6 +73,12 @@ 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.""" @@ -301,6 +127,61 @@ 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 @@ -377,6 +258,9 @@ 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() @@ -480,7 +364,7 @@ class MarkdownEditor(QTextEdit): edit.beginEditBlock() edit.setPosition(start) edit.setPosition(start + 2, QTextCursor.KeepAnchor) - edit.insertText("```\n\n```") + edit.insertText("```\n\n```\n") edit.endEditBlock() # place caret on the blank line between the fences @@ -488,6 +372,52 @@ 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: @@ -706,25 +636,80 @@ class MarkdownEditor(QTextEdit): self.setFocus() def apply_code(self): - """Insert or toggle code block.""" - cursor = self.textCursor() + """Insert a fenced code block, or navigate fences without creating inline backticks.""" + c = self.textCursor() + doc = self.document() - 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) + 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 - # Return focus to editor + 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() self.setFocus() def apply_heading(self, size: int): diff --git a/bouquin/markdown_highlighter.py b/bouquin/markdown_highlighter.py new file mode 100644 index 0000000..d1e82c7 --- /dev/null +++ b/bouquin/markdown_highlighter.py @@ -0,0 +1,200 @@ +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 26693c4..71329c0 100644 --- a/bouquin/search.py +++ b/bouquin/search.py @@ -83,10 +83,7 @@ class Search(QWidget): for date_str, content in rows: # Build an HTML fragment around the match and whether to show ellipses - frag_html, left_ell, right_ell = self._make_html_snippet( - content, query, radius=30, maxlen=90 - ) - + frag_html = 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) @@ -108,11 +105,6 @@ 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) @@ -124,11 +116,6 @@ 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() @@ -145,9 +132,7 @@ 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) @@ -192,7 +177,7 @@ class Search(QWidget): lambda m: f"{m.group(0)}", snippet_html ) - return snippet_html, start > 0, end < L + return snippet_html def _strip_markdown(self, markdown: str) -> str: """Strip markdown formatting for plain text display.""" diff --git a/pyproject.toml b/pyproject.toml index fdf55ca..81a5cc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.2.1.5" +version = "0.2.1.6" 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 82fde94..002ab63 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -6,12 +6,29 @@ 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 @@ -56,24 +73,6 @@ 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 @@ -143,3 +142,131 @@ 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 5deca48..d71a785 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, left, right = s._make_html_snippet(long, "alpha", radius=10, maxlen=40) + frag = 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, left, right = s._make_html_snippet(src, "nomatch", radius=3, maxlen=30) - assert frag and not left and right + frag = s._make_html_snippet(src, "nomatch", radius=3, maxlen=30) + assert frag # Case: multiple tokens highlighted src = "Alpha bravo charlie delta echo" - frag, left, right = s._make_html_snippet(src, "alpha delta", radius=2, maxlen=50) + frag = s._make_html_snippet(src, "alpha delta", radius=2, maxlen=50) assert "Alpha" in frag or "alpha" in frag assert "delta" in frag