From bc9fa86281b007c88bf43b3af4c11123021966f3 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 10 Nov 2025 08:25:51 +1100 Subject: [PATCH 1/2] Ensure checkbox only can get checked on/off if it is clicked right on its block position, not any click on the whole line --- bouquin/markdown_editor.py | 92 +++++++++++++++++++++++++++----------- 1 file changed, 66 insertions(+), 26 deletions(-) diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index d864770..496f81c 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -8,6 +8,7 @@ from PySide6.QtGui import ( QColor, QFont, QFontDatabase, + QFontMetrics, QImage, QPalette, QGuiApplication, @@ -17,7 +18,7 @@ from PySide6.QtGui import ( QSyntaxHighlighter, QTextImageFormat, ) -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, QRect from PySide6.QtWidgets import QTextEdit from .theme import ThemeManager, Theme @@ -525,34 +526,73 @@ class MarkdownEditor(QTextEdit): super().keyPressEvent(event) def mousePressEvent(self, event): - """Handle mouse clicks - check for checkbox clicking.""" - if event.button() == Qt.MouseButton.LeftButton: - cursor = self.cursorForPosition(event.pos()) - cursor.select(QTextCursor.SelectionType.LineUnderCursor) - line = cursor.selectedText() + """Toggle a checkbox only when the click lands on its icon.""" + if event.button() == Qt.LeftButton: + pt = event.pos() - # Check if clicking on a checkbox line - if ( - f"{self._CHECK_UNCHECKED_DISPLAY} " in line - or f"{self._CHECK_CHECKED_DISPLAY} " in line - ): - # Toggle the checkbox - if f"{self._CHECK_UNCHECKED_DISPLAY} " in line: - new_line = line.replace( - f"{self._CHECK_UNCHECKED_DISPLAY} ", - f"{self._CHECK_CHECKED_DISPLAY} ", - ) + # Cursor and block under the mouse + cur = self.cursorForPosition(pt) + block = cur.block() + text = block.text() + + # The display tokens, e.g. "☐ " / "☑ " (icon + trailing space) + unchecked = f"{self._CHECK_UNCHECKED_DISPLAY} " + checked = f"{self._CHECK_CHECKED_DISPLAY} " + + # Helper: rect for a single character at a given doc position + def char_rect_at(doc_pos, ch): + c = QTextCursor(self.document()) + c.setPosition(doc_pos) + start_rect = self.cursorRect( + c + ) # caret rect at char start (viewport coords) + + # Use the actual font at this position for an accurate width + fmt_font = ( + c.charFormat().font() if c.charFormat().isValid() else self.font() + ) + fm = QFontMetrics(fmt_font) + w = max(1, fm.horizontalAdvance(ch)) + return QRect(start_rect.x(), start_rect.y(), w, start_rect.height()) + + # Scan the line for any checkbox icons; toggle the one we clicked + i = 0 + while i < len(text): + icon = None + if text.startswith(unchecked, i): + icon = self._CHECK_UNCHECKED_DISPLAY + elif text.startswith(checked, i): + icon = self._CHECK_CHECKED_DISPLAY + + if icon: + doc_pos = ( + block.position() + i + ) # absolute document position of the icon + r = char_rect_at(doc_pos, icon) + + if r.contains(pt): + # Build the replacement: swap ☐ <-> ☑ (keep trailing space) + new_icon = ( + self._CHECK_CHECKED_DISPLAY + if icon == self._CHECK_UNCHECKED_DISPLAY + else self._CHECK_UNCHECKED_DISPLAY + ) + edit = QTextCursor(self.document()) + edit.beginEditBlock() + edit.setPosition(doc_pos) + edit.movePosition( + QTextCursor.Right, QTextCursor.KeepAnchor, len(icon) + 1 + ) # icon + space + edit.insertText(f"{new_icon} ") + edit.endEditBlock() + return # handled + + # advance past this token + i += len(icon) + 1 else: - new_line = line.replace( - f"{self._CHECK_CHECKED_DISPLAY} ", - f"{self._CHECK_UNCHECKED_DISPLAY} ", - ) + i += 1 - cursor.insertText(new_line) - # Don't call super() - we handled the click - return - - # Default handling for non-checkbox clicks + # Default handling for anything else super().mousePressEvent(event) # ------------------------ Toolbar action handlers ------------------------ From 4f4735cfb6805135a85ff250871dfaedc0f91943 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 10 Nov 2025 08:40:09 +1100 Subject: [PATCH 2/2] Fix code backticks to not show but still be able to type code easily --- CHANGELOG.md | 5 +++ bouquin/markdown_editor.py | 75 ++++++++++++++++++++++++++++++++++++-- pyproject.toml | 2 +- 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2faf357..63af14a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 0.2.1.3 + + * Ensure checkbox only can get checked on/off if it is clicked right on its block position, not any click on the whole line + * Fix code backticks to not show but still be able to type code easily + # 0.2.1.2 * Ensure tabs are ordered by calendar date diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index 496f81c..baea055 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -34,6 +34,7 @@ class MarkdownHighlighter(QSyntaxHighlighter): # Recompute formats whenever the app theme changes try: self.theme_manager.themeChanged.connect(self._on_theme_changed) + self.textChanged.connect(self._refresh_codeblock_margins) except Exception: pass @@ -65,6 +66,7 @@ class MarkdownHighlighter(QSyntaxHighlighter): 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 @@ -97,6 +99,36 @@ class MarkdownHighlighter(QSyntaxHighlighter): # Also make them very faint in case they still show self.syntax_format.setForeground(QColor(250, 250, 250)) + def _refresh_codeblock_margins(self): + """Give code blocks a small left/right margin to separate them visually.""" + doc = self.document() + block = doc.begin() + in_code = False + while block.isValid(): + txt = block.text().strip() + cursor = QTextCursor(block) + fmt = block.blockFormat() + + if txt.startswith("```"): + # fence lines: small vertical spacing, same left indent + need = (12, 6, 6) # left, top, bottom (px-like) + if (fmt.leftMargin(), fmt.topMargin(), fmt.bottomMargin()) != need: + fmt.setLeftMargin(12) + fmt.setRightMargin(6) + fmt.setTopMargin(6) + fmt.setBottomMargin(6) + cursor.setBlockFormat(fmt) + in_code = not in_code + + elif in_code: + # inside the code block + if fmt.leftMargin() != 12 or fmt.rightMargin() != 6: + fmt.setLeftMargin(12) + fmt.setRightMargin(6) + cursor.setBlockFormat(fmt) + + block = block.next() + def highlightBlock(self, text: str): """Apply formatting to a block of text based on markdown syntax.""" if not text: @@ -108,12 +140,17 @@ class MarkdownHighlighter(QSyntaxHighlighter): # Check for code block fences if text.strip().startswith("```"): - # Toggle code block state + # 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) - # Format the fence markers - but keep them somewhat visible for editing - # Use code format instead of syntax format so cursor is visible - self.setFormat(0, len(text), self.code_format) return if in_code_block: @@ -448,6 +485,36 @@ class MarkdownEditor(QTextEdit): def keyPressEvent(self, event): """Handle special key events for markdown editing.""" + # --- Auto-close code fences when typing the 3rd backtick at line start --- + if event.text() == "`": + c = self.textCursor() + block = c.block() + line = block.text() + pos_in_block = c.position() - block.position() + + # text before caret on this line + before = line[:pos_in_block] + + # If we've typed exactly two backticks at line start (or after whitespace), + # treat this backtick as the "third" and expand to a full fenced block. + if before.endswith("``") and before.strip() == "``": + start = ( + block.position() + pos_in_block - 2 + ) # start of the two backticks + + edit = QTextCursor(self.document()) + edit.beginEditBlock() + edit.setPosition(start) + edit.setPosition(start + 2, QTextCursor.KeepAnchor) + edit.insertText("```\n\n```") + edit.endEditBlock() + + # place caret on the blank line between the fences + new_pos = start + 4 # after "```\n" + c.setPosition(new_pos) + self.setTextCursor(c) + 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: cursor = self.textCursor() diff --git a/pyproject.toml b/pyproject.toml index 5de25b5..df93682 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.2.1.2" +version = "0.2.1.3" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md"