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