diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d4b854..7839225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,3 @@ -# 0.5.3 - - * Prevent triple-click select from selecting the list item (e.g checkbox, bullet) - * Use DejaVu Sans font for regular text instead of heavier Noto - might help with the freeze issues. - * Change History icon (again) - * Make it easier to check on or off the checkbox by adding some buffer (instead of having to precisely click inside it) - * Prevent double-click of checkbox leading to selecting/highlighting it - * Slightly fade the text of a checkbox line if the checkbox is checked. - * Fix weekend date colours being incorrect on theme change while app is running - * Avoid capturing checkbox/bullet etc in the task text that would get offered as the 'note' when Pomodoro timer stops - * Code Blocks are now their own QDialog to try and reduce risk of getting trapped in / bleeding in/out of text in code blocks. - # 0.5.2 * Update icon again to remove background diff --git a/bouquin/code_block_editor_dialog.py b/bouquin/code_block_editor_dialog.py deleted file mode 100644 index a5e3832..0000000 --- a/bouquin/code_block_editor_dialog.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -from PySide6.QtWidgets import ( - QDialog, - QVBoxLayout, - QPlainTextEdit, - QDialogButtonBox, - QComboBox, - QLabel, -) - -from . import strings - - -class CodeBlockEditorDialog(QDialog): - def __init__(self, code: str, language: str | None, parent=None): - super().__init__(parent) - self.setWindowTitle(strings._("edit_code_block")) - - self.setMinimumSize(650, 650) - self._code_edit = QPlainTextEdit(self) - self._code_edit.setPlainText(code) - - # Language selector (optional) - self._lang_combo = QComboBox(self) - languages = [ - "", - "bash", - "css", - "html", - "javascript", - "php", - "python", - ] - self._lang_combo.addItems(languages) - if language and language in languages: - self._lang_combo.setCurrentText(language) - - # Buttons - buttons = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, - parent=self, - ) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - - layout = QVBoxLayout(self) - layout.addWidget(QLabel(strings._("locale") + ":", self)) - layout.addWidget(self._lang_combo) - layout.addWidget(self._code_edit) - layout.addWidget(buttons) - - def code(self) -> str: - return self._code_edit.toPlainText() - - def language(self) -> str | None: - text = self._lang_combo.currentText().strip() - return text or None diff --git a/bouquin/code_highlighter.py b/bouquin/code_highlighter.py index 2689b9d..e462574 100644 --- a/bouquin/code_highlighter.py +++ b/bouquin/code_highlighter.py @@ -348,7 +348,7 @@ class CodeBlockMetadata: return "" items = [f"{k}:{v}" for k, v in sorted(self._block_languages.items())] - return "\n" + return "" def deserialize(self, text: str): """Deserialize metadata from text.""" diff --git a/bouquin/fonts/DejaVu.license b/bouquin/fonts/DejaVu.license deleted file mode 100644 index df52c17..0000000 --- a/bouquin/fonts/DejaVu.license +++ /dev/null @@ -1,187 +0,0 @@ -Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. -Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below) - - -Bitstream Vera Fonts Copyright ------------------------------- - -Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is -a trademark of Bitstream, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of the fonts accompanying this license ("Fonts") and associated -documentation files (the "Font Software"), to reproduce and distribute the -Font Software, including without limitation the rights to use, copy, merge, -publish, distribute, and/or sell copies of the Font Software, and to permit -persons to whom the Font Software is furnished to do so, subject to the -following conditions: - -The above copyright and trademark notices and this permission notice shall -be included in all copies of one or more of the Font Software typefaces. - -The Font Software may be modified, altered, or added to, and in particular -the designs of glyphs or characters in the Fonts may be modified and -additional glyphs or characters may be added to the Fonts, only if the fonts -are renamed to names not containing either the words "Bitstream" or the word -"Vera". - -This License becomes null and void to the extent applicable to Fonts or Font -Software that has been modified and is distributed under the "Bitstream -Vera" names. - -The Font Software may be sold as part of a larger software package but no -copy of one or more of the Font Software typefaces may be sold by itself. - -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, -TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME -FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING -ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF -THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE -FONT SOFTWARE. - -Except as contained in this notice, the names of Gnome, the Gnome -Foundation, and Bitstream Inc., shall not be used in advertising or -otherwise to promote the sale, use or other dealings in this Font Software -without prior written authorization from the Gnome Foundation or Bitstream -Inc., respectively. For further information, contact: fonts at gnome dot -org. - -Arev Fonts Copyright ------------------------------- - -Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. - -Permission is hereby granted, free of charge, to any person obtaining -a copy of the fonts accompanying this license ("Fonts") and -associated documentation files (the "Font Software"), to reproduce -and distribute the modifications to the Bitstream Vera Font Software, -including without limitation the rights to use, copy, merge, publish, -distribute, and/or sell copies of the Font Software, and to permit -persons to whom the Font Software is furnished to do so, subject to -the following conditions: - -The above copyright and trademark notices and this permission notice -shall be included in all copies of one or more of the Font Software -typefaces. - -The Font Software may be modified, altered, or added to, and in -particular the designs of glyphs or characters in the Fonts may be -modified and additional glyphs or characters may be added to the -Fonts, only if the fonts are renamed to names not containing either -the words "Tavmjong Bah" or the word "Arev". - -This License becomes null and void to the extent applicable to Fonts -or Font Software that has been modified and is distributed under the -"Tavmjong Bah Arev" names. - -The Font Software may be sold as part of a larger software package but -no copy of one or more of the Font Software typefaces may be sold by -itself. - -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL -TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. - -Except as contained in this notice, the name of Tavmjong Bah shall not -be used in advertising or otherwise to promote the sale, use or other -dealings in this Font Software without prior written authorization -from Tavmjong Bah. For further information, contact: tavmjong @ free -. fr. - -TeX Gyre DJV Math ------------------ -Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. - -Math extensions done by B. Jackowski, P. Strzelczyk and P. Pianowski -(on behalf of TeX users groups) are in public domain. - -Letters imported from Euler Fraktur from AMSfonts are (c) American -Mathematical Society (see below). -Bitstream Vera Fonts Copyright -Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera -is a trademark of Bitstream, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of the fonts accompanying this license (“Fonts”) and associated -documentation -files (the “Font Software”), to reproduce and distribute the Font Software, -including without limitation the rights to use, copy, merge, publish, -distribute, -and/or sell copies of the Font Software, and to permit persons to whom -the Font Software is furnished to do so, subject to the following -conditions: - -The above copyright and trademark notices and this permission notice -shall be -included in all copies of one or more of the Font Software typefaces. - -The Font Software may be modified, altered, or added to, and in particular -the designs of glyphs or characters in the Fonts may be modified and -additional -glyphs or characters may be added to the Fonts, only if the fonts are -renamed -to names not containing either the words “Bitstream” or the word “Vera”. - -This License becomes null and void to the extent applicable to Fonts or -Font Software -that has been modified and is distributed under the “Bitstream Vera” -names. - -The Font Software may be sold as part of a larger software package but -no copy -of one or more of the Font Software typefaces may be sold by itself. - -THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, -TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME -FOUNDATION -BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, -SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN -ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR -INABILITY TO USE -THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. -Except as contained in this notice, the names of GNOME, the GNOME -Foundation, -and Bitstream Inc., shall not be used in advertising or otherwise to promote -the sale, use or other dealings in this Font Software without prior written -authorization from the GNOME Foundation or Bitstream Inc., respectively. -For further information, contact: fonts at gnome dot org. - -AMSFonts (v. 2.2) copyright - -The PostScript Type 1 implementation of the AMSFonts produced by and -previously distributed by Blue Sky Research and Y&Y, Inc. are now freely -available for general use. This has been accomplished through the -cooperation -of a consortium of scientific publishers with Blue Sky Research and Y&Y. -Members of this consortium include: - -Elsevier Science IBM Corporation Society for Industrial and Applied -Mathematics (SIAM) Springer-Verlag American Mathematical Society (AMS) - -In order to assure the authenticity of these fonts, copyright will be -held by -the American Mathematical Society. This is not meant to restrict in any way -the legitimate use of the fonts, such as (but not limited to) electronic -distribution of documents containing these fonts, inclusion of these fonts -into other public domain or commercial font collections or computer -applications, use of the outline data to create derivative fonts and/or -faces, etc. However, the AMS does require that the AMS copyright notice be -removed from any derivative versions of the fonts which have been altered in -any way. In addition, to ensure the fidelity of TeX documents using Computer -Modern fonts, Professor Donald Knuth, creator of the Computer Modern faces, -has requested that any alterations which yield different font metrics be -given a different name. - -$Id$ diff --git a/bouquin/fonts/DejaVuSans.ttf b/bouquin/fonts/DejaVuSans.ttf deleted file mode 100644 index e5f7eec..0000000 Binary files a/bouquin/fonts/DejaVuSans.ttf and /dev/null differ diff --git a/bouquin/fonts/NotoSans-Regular.ttf b/bouquin/fonts/NotoSans-Regular.ttf new file mode 100644 index 0000000..4bac02f Binary files /dev/null and b/bouquin/fonts/NotoSans-Regular.ttf differ diff --git a/bouquin/fonts/Noto.license b/bouquin/fonts/OFL.txt similarity index 100% rename from bouquin/fonts/Noto.license rename to bouquin/fonts/OFL.txt diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index f6149d8..eb5dd83 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -289,6 +289,5 @@ "friday": "Friday", "saturday": "Saturday", "sunday": "Sunday", - "day": "Day", - "edit_code_block": "Edit code block" + "day": "Day" } diff --git a/bouquin/main_window.py b/bouquin/main_window.py index b52ff0f..0e5e454 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -1026,12 +1026,10 @@ class MainWindow(QMainWindow): self.editor.viewport().update() def _apply_calendar_text_colors(self): - pal = QApplication.instance().palette() + pal = self.palette() txt = pal.windowText().color() - fmt = QTextCharFormat() fmt.setForeground(txt) - # Use normal text color for weekends self.calendar.setWeekdayTextFormat(Qt.Saturday, fmt) self.calendar.setWeekdayTextFormat(Qt.Sunday, fmt) @@ -1206,8 +1204,7 @@ class MainWindow(QMainWindow): return # Get the current line text - line_text = editor.get_current_line_task_text() - + line_text = editor.get_current_line_text().strip() if not line_text: line_text = strings._("pomodoro_time_log_default_text") diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index 490a595..9f48858 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -3,15 +3,12 @@ from __future__ import annotations import base64 import re from pathlib import Path -from typing import Optional, Tuple from PySide6.QtGui import ( QFont, QFontDatabase, QFontMetrics, QImage, - QMouseEvent, - QTextBlock, QTextCharFormat, QTextCursor, QTextDocument, @@ -21,11 +18,10 @@ from PySide6.QtGui import ( QDesktopServices, ) from PySide6.QtCore import Qt, QRect, QTimer, QUrl -from PySide6.QtWidgets import QDialog, QTextEdit +from PySide6.QtWidgets import QTextEdit from .theme import ThemeManager from .markdown_highlighter import MarkdownHighlighter -from .code_block_editor_dialog import CodeBlockEditorDialog from . import strings @@ -50,14 +46,18 @@ class MarkdownEditor(QTextEdit): base_dir = Path(__file__).resolve().parent # Load regular text font (primary) - regular_font_path = base_dir / "fonts" / "DejaVuSans.ttf" + regular_font_path = base_dir / "fonts" / "NotoSans-Regular.ttf" regular_font_id = QFontDatabase.addApplicationFont(str(regular_font_path)) + if regular_font_id == -1: + print("Failed to load NotoSans-Regular.ttf") # Load Symbols font (fallback) symbols_font_path = base_dir / "fonts" / "NotoSansSymbols2-Regular.ttf" symbols_font_id = QFontDatabase.addApplicationFont(str(symbols_font_path)) symbols_families = QFontDatabase.applicationFontFamilies(symbols_font_id) self.symbols_font_family = symbols_families[0] + if symbols_font_id == -1: + print("Failed to load NotoSansSymbols2-Regular.ttf") # Use the regular Noto Sans family as the editor font regular_families = QFontDatabase.applicationFontFamilies(regular_font_id) @@ -92,16 +92,6 @@ class MarkdownEditor(QTextEdit): # Track if we're currently updating text programmatically self._updating = False - # Help avoid double-click selecting of checkbox - self._suppress_next_checkbox_double_click = False - - # Guard to avoid recursive selection tweaks - self._adjusting_selection = False - - # After selections change, trim list prefixes from full-line selections - # (e.g. after triple-clicking a list item to select the line). - self.selectionChanged.connect(self._maybe_trim_list_prefix_from_line_selection) - # Connect to text changes for smart formatting self.textChanged.connect(self._on_text_changed) self.textChanged.connect(self._update_code_block_row_backgrounds) @@ -249,153 +239,6 @@ class MarkdownEditor(QTextEdit): ] self.setExtraSelections(others + sels) - def _find_code_block_bounds( - self, block: QTextBlock - ) -> Optional[Tuple[QTextBlock, QTextBlock]]: - """ - Given a block that is either inside a fenced code block or on a fence, - return (opening_fence_block, closing_fence_block). - Returns None if we can't find a proper pair. - """ - if not block.isValid(): - return None - - def is_fence(b: QTextBlock) -> bool: - return b.isValid() and b.text().strip().startswith("```") - - # If we're on a fence line, decide if it's opening or closing - if is_fence(block): - # If we're "inside" just before this fence, this one closes. - if self._is_inside_code_block(block.previous()): - close_block = block - open_block = block.previous() - while open_block.isValid() and not is_fence(open_block): - open_block = open_block.previous() - if not is_fence(open_block): - return None - return open_block, close_block - else: - # Treat as opening fence; search downward for the closing one. - open_block = block - close_block = open_block.next() - while close_block.isValid() and not is_fence(close_block): - close_block = close_block.next() - if not is_fence(close_block): - return None - return open_block, close_block - - # Normal interior line: search up for opening fence, down for closing. - open_block = block.previous() - while open_block.isValid() and not is_fence(open_block): - open_block = open_block.previous() - if not is_fence(open_block): - return None - - close_block = open_block.next() - while close_block.isValid() and not is_fence(close_block): - close_block = close_block.next() - if not is_fence(close_block): - return None - - return open_block, close_block - - def _get_code_block_text( - self, open_block: QTextBlock, close_block: QTextBlock - ) -> str: - """Return the inner text (between fences) as a normal '\\n'-joined string.""" - lines = [] - b = open_block.next() - while b.isValid() and b != close_block: - lines.append(b.text()) - b = b.next() - return "\n".join(lines) - - def _replace_code_block_text( - self, open_block: QTextBlock, close_block: QTextBlock, new_text: str - ) -> None: - """ - Replace everything between the two fences with `new_text`. - Fences themselves are left untouched. - """ - doc = self.document() - if doc is None: - return - - cursor = QTextCursor(doc) - - # Start just after the opening fence's newline - start_pos = open_block.position() + len(open_block.text()) - # End at the start of the closing fence - end_pos = close_block.position() - - cursor.setPosition(start_pos) - cursor.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor) - - cursor.beginEditBlock() - # Normalise trailing newline(s) - new_text = new_text.rstrip("\n") - if new_text: - cursor.removeSelectedText() - cursor.insertText("\n" + new_text + "\n") - else: - # Empty block – keep one blank line inside the fences - cursor.removeSelectedText() - cursor.insertText("\n\n") - cursor.endEditBlock() - - # Re-apply spacing and backgrounds - if hasattr(self, "_apply_code_block_spacing"): - self._apply_code_block_spacing() - if hasattr(self, "_update_code_block_row_backgrounds"): - self._update_code_block_row_backgrounds() - - # Trigger rehighlight - if hasattr(self, "highlighter"): - self.highlighter.rehighlight() - - def _edit_code_block(self, block: QTextBlock) -> bool: - """Open a popup editor for the code block containing `block`. - - Returns True if a dialog was shown (regardless of OK/Cancel), - False if no well-formed fenced block was found. - """ - bounds = self._find_code_block_bounds(block) - if not bounds: - return False - - open_block, close_block = bounds - - # Current language from metadata (if any) - lang = None - if hasattr(self, "_code_metadata"): - lang = self._code_metadata.get_language(open_block.blockNumber()) - - code_text = self._get_code_block_text(open_block, close_block) - - dlg = CodeBlockEditorDialog(code_text, lang, parent=self) - result = dlg.exec() - if result != QDialog.DialogCode.Accepted: - # Dialog was shown but user cancelled; event is "handled". - return True - - new_code = dlg.code() - new_lang = dlg.language() - - # Update document text but keep fences - self._replace_code_block_text(open_block, close_block, new_code) - - # Update metadata language if changed - if new_lang is not None: - if not hasattr(self, "_code_metadata"): - from .code_highlighter import CodeBlockMetadata - - self._code_metadata = CodeBlockMetadata() - self._code_metadata.set_language(open_block.blockNumber(), new_lang) - if hasattr(self, "highlighter"): - self.highlighter.rehighlight() - - return True - def _apply_line_spacing(self, height: float = 125.0): """Apply proportional line spacing to the whole document.""" doc = self.document() @@ -450,30 +293,6 @@ class MarkdownEditor(QTextEdit): cursor.endEditBlock() - def _ensure_escape_line_after_closing_fence(self, fence_block: QTextBlock) -> None: - """ - Ensure there is at least one block *after* the given closing fence line. - - If the fence is the last block in the document, we append a newline, - so the caret can always move outside the code block. - """ - doc = self.document() - if doc is None or not fence_block.isValid(): - return - - after = fence_block.next() - if after.isValid(): - # There's already a block after the fence; nothing to do. - return - - # No block after fence → create a blank line - cursor = QTextCursor(doc) - cursor.beginEditBlock() - endpos = fence_block.position() + len(fence_block.text()) - cursor.setPosition(endpos) - cursor.insertText("\n") - cursor.endEditBlock() - def to_markdown(self) -> str: """Export current content as markdown.""" # First, extract any embedded images and convert to markdown @@ -667,69 +486,6 @@ class MarkdownEditor(QTextEdit): return 0 - def _maybe_trim_list_prefix_from_line_selection(self) -> None: - """ - If the current selection looks like a full-line selection on a list item - (for example, from a triple-click), trim the selection so that it starts - just *after* the visual list prefix (checkbox / bullet / number), and - ends at the end of the text on that line (not on the next line's newline). - """ - # Avoid re-entry when we move the cursor ourselves. - if getattr(self, "_adjusting_selection", False): - return - - cursor = self.textCursor() - if not cursor.hasSelection(): - return - - start = cursor.selectionStart() - end = cursor.selectionEnd() - if start == end: - return - - doc = self.document() - # 'end' is exclusive; use end - 1 so we land in the last selected block. - start_block = doc.findBlock(start) - end_block = doc.findBlock(end - 1) - if not start_block.isValid() or start_block != end_block: - # Only adjust single-line selections. - return - - # How much list prefix (indent + checkbox/bullet/number) this block has - prefix_len = self._list_prefix_length_for_block(start_block) - if prefix_len <= 0: - return - - block_start = start_block.position() - prefix_end = block_start + prefix_len - - # If the selection already starts after the prefix, nothing to do. - if start >= prefix_end: - return - - line_text = start_block.text() - line_end = block_start + len(line_text) # end of visible text on this line - - # Only treat it as a "full line" selection if it reaches the end of the - # visible text. Triple-click usually selects to at least here (often +1 for - # the newline). - if end < line_end: - return - - # Clamp the selection so that it ends at the end of this line's text, - # *not* at the newline / start of the next block. This keeps the caret - # blinking on the selected line instead of the next line. - visual_end = line_end - - self._adjusting_selection = True - try: - new_cursor = self.textCursor() - new_cursor.setPosition(prefix_end) - new_cursor.setPosition(visual_end, QTextCursor.KeepAnchor) - self.setTextCursor(new_cursor) - finally: - self._adjusting_selection = False - def _detect_list_type(self, line: str) -> tuple[str | None, str]: """ Detect if line is a list item. Returns (list_type, prefix). @@ -786,77 +542,38 @@ class MarkdownEditor(QTextEdit): def keyPressEvent(self, event): """Handle special key events for markdown editing.""" - c = self.textCursor() - block = c.block() - in_code = self._is_inside_code_block(block) - is_fence_line = block.text().strip().startswith("```") - - # --- NEW: 3rd backtick shortcut → open code block dialog --- - # Only when we're *not* already in a code block or on a fence line. - if event.text() == "`" and not (in_code or is_fence_line): + # --- 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] - # "before" currently contains whatever's before the *third* backtick. - # We trigger only when the line is (whitespace + "``") before the caret. + # 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() == "``": - doc = self.document() - if doc is not None: - # Remove the two backticks that were already typed - start = block.position() + pos_in_block - 2 - edit = QTextCursor(doc) - edit.beginEditBlock() - edit.setPosition(start) - edit.setPosition(start + 2, QTextCursor.KeepAnchor) - edit.removeSelectedText() - edit.endEditBlock() + start = ( + block.position() + pos_in_block - 2 + ) # start of the two backticks - # Move caret to where the code block should start - c.setPosition(start) - self.setTextCursor(c) + edit = QTextCursor(self.document()) + edit.beginEditBlock() + edit.setPosition(start) + edit.setPosition(start + 2, QTextCursor.KeepAnchor) + edit.insertText("```\n\n```\n") + edit.endEditBlock() - # Now behave exactly like the toolbar button - self.apply_code() - return - # ------------------------------------------------------------ - - # If we're anywhere in a fenced code block (including the fences), - # treat the text as read-only and route edits through the dialog. - if in_code or is_fence_line: - key = event.key() - - # Navigation keys that are safe to pass through. - nav_keys_no_down = ( - Qt.Key.Key_Left, - Qt.Key.Key_Right, - Qt.Key.Key_Up, - Qt.Key.Key_Home, - Qt.Key.Key_End, - Qt.Key.Key_PageUp, - Qt.Key.Key_PageDown, - ) - - # Let these through: - # - pure navigation (except Down, which we handle specially later) - # - Enter/Return and Down, which are handled by dedicated logic below - if key in nav_keys_no_down: - super().keyPressEvent(event) + # place caret on the blank line between the fences + new_pos = start + 4 # after "```\n" + c.setPosition(new_pos) + self.setTextCursor(c) return - if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Down): - # Let the existing Enter/Down code see these. - pass - else: - # Any other key (Backspace, Delete, characters, Tab, etc.) - # opens the code-block editor instead of editing inline. - if not self._edit_code_block(block): - # Fallback if bounds couldn't be found for some reason. - super().keyPressEvent(event) - return - - # --- Step out of a code block with Down at EOF --- + # Step out of a code block with Down at EOF if event.key() == Qt.Key.Key_Down: c = self.textCursor() b = c.block() @@ -867,8 +584,7 @@ class MarkdownEditor(QTextEdit): 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 + # 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) @@ -889,8 +605,7 @@ class MarkdownEditor(QTextEdit): 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 + # 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) @@ -1060,11 +775,9 @@ class MarkdownEditor(QTextEdit): return - # Inside a code block (but not on a fence): open the popup editor + # Inside a code block (but not on a fence): newline stays code-style if block_state == 1: - if not self._edit_code_block(current_block): - # Fallback if something is malformed - super().keyPressEvent(event) + super().keyPressEvent(event) return # Check for list continuation @@ -1140,9 +853,6 @@ class MarkdownEditor(QTextEdit): def mousePressEvent(self, event): """Toggle a checkbox only when the click lands on its icon.""" - # default: don't suppress any upcoming double-click - self._suppress_next_checkbox_double_click = False - if event.button() == Qt.LeftButton: pt = event.pos() @@ -1184,15 +894,7 @@ class MarkdownEditor(QTextEdit): doc_pos = block.position() + i r = char_rect_at(doc_pos, icon) - # ---------- Relax the hit area here ---------- - # Expand the clickable area horizontally so you don't have to - # land exactly on the glyph. This makes the "checkbox zone" - # roughly 3× the glyph width, centered on it. - pad = r.width() # one glyph width on each side - hit_rect = r.adjusted(-pad, 0, pad, 0) - # --------------------------------------------- - - if hit_rect.contains(pt): + if r.contains(pt): # Build the replacement: swap ☐ <-> ☑ (keep trailing space) new_icon = ( self._CHECK_CHECKED_DISPLAY @@ -1208,9 +910,6 @@ class MarkdownEditor(QTextEdit): ) edit.insertText(f"{new_icon} ") edit.endEditBlock() - - # if a double-click comes next, ignore it - self._suppress_next_checkbox_double_click = True return # handled # advance past this token @@ -1221,27 +920,6 @@ class MarkdownEditor(QTextEdit): # Default handling for anything else super().mousePressEvent(event) - def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: - # If the previous press toggled a checkbox, swallow this double-click - # so the base class does NOT turn it into a selection. - if getattr(self, "_suppress_next_checkbox_double_click", False): - self._suppress_next_checkbox_double_click = False - event.accept() - return - - cursor = self.cursorForPosition(event.pos()) - block = cursor.block() - - # If we’re on or inside a code block, open the editor instead - if self._is_inside_code_block(block) or block.text().strip().startswith("```"): - # Only swallow the double-click if we actually opened a dialog. - if not self._edit_code_block(block): - super().mouseDoubleClickEvent(event) - return - - # Otherwise, let normal double-click behaviour happen - super().mouseDoubleClickEvent(event) - # ------------------------ Toolbar action handlers ------------------------ def apply_weight(self): @@ -1313,106 +991,86 @@ class MarkdownEditor(QTextEdit): self.setFocus() def apply_code(self): - """ - Toolbar handler for the button. - - - If the caret is on / inside an existing fenced block, open the editor for it. - - Otherwise open the editor prefilled with any selected text, then insert a new - fenced block containing whatever the user typed. - """ - cursor = self.textCursor() + """Insert a fenced code block, or navigate fences without creating inline backticks.""" + c = self.textCursor() doc = self.document() - if doc is None: + + 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() + # tighten spacing for the new code block + self._apply_code_block_spacing() + + self.setFocus() return - block = cursor.block() + block = c.block() + line = block.text() + pos_in_block = c.position() - block.position() + stripped = line.strip() - # --- Case 1: already in a code block -> just edit that block --- - if self._is_inside_code_block(block) or block.text().strip().startswith("```"): - self._edit_code_block(block) + # 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 - # --- Case 2: creating a new block (optional selection) --- - if cursor.hasSelection(): - start_pos = cursor.selectionStart() - end_pos = cursor.selectionEnd() - # QTextEdit joins lines with U+2029 in selectedText() - initial_code = cursor.selectedText().replace("\u2029", "\n") - else: - start_pos = cursor.position() - end_pos = start_pos - initial_code = "" - - # Let the user type/edit the code in the popup first - dlg = CodeBlockEditorDialog(initial_code, language=None, parent=self) - if dlg.exec() != QDialog.DialogCode.Accepted: - return - - code_text = dlg.code() - language = dlg.language() - - # Don't insert an entirely empty block - if not code_text.strip(): - return - - code_text = code_text.rstrip("\n") + # 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() - # Remove selection (if any) so we can insert the new fenced block - edit.setPosition(start_pos) - edit.setPosition(end_pos, QTextCursor.MoveMode.KeepAnchor) - edit.removeSelectedText() - - # Work out whether we're mid-line and need to break before the fence - block = doc.findBlock(start_pos) - line = block.text() - pos_in_block = start_pos - block.position() - before = line[:pos_in_block] - - # If there's text before the caret on this line, put the fence on a new line + # If there is text before the caret on the line, start the block on a new line lead_break = "\n" if before else "" - insert_str = f"{lead_break}```\n{code_text}\n```\n" - + # 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_str) + edit.insertText(insert) edit.endEditBlock() - # Find the opening fence block we just inserted - open_block = doc.findBlock(start_pos + len(lead_break)) + # Put caret on the blank line inside the block + c.setPosition(start_pos + len(lead_break) + 4) # after "```\n" + self.setTextCursor(c) - # Find the closing fence block - close_block = open_block.next() - while close_block.isValid() and not close_block.text().strip().startswith( - "```" - ): - close_block = close_block.next() + if hasattr(self, "_update_code_block_row_backgrounds"): + self._update_code_block_row_backgrounds() - if close_block.isValid(): - # Make sure there's always at least one line *after* the block - self._ensure_escape_line_after_closing_fence(close_block) - - # Store language metadata if the user chose one - if language is not None: - if not hasattr(self, "_code_metadata"): - from .code_highlighter import CodeBlockMetadata - - self._code_metadata = CodeBlockMetadata() - self._code_metadata.set_language(open_block.blockNumber(), language) - - # Refresh visuals + # tighten spacing for the new code block self._apply_code_block_spacing() - self._update_code_block_row_backgrounds() - if hasattr(self, "highlighter"): - self.highlighter.rehighlight() - - # Put caret just after the code block so the user can keep writing normal text - after_block = close_block.next() if close_block.isValid() else None - if after_block and after_block.isValid(): - cursor = self.textCursor() - cursor.setPosition(after_block.position()) - self.setTextCursor(cursor) self.setFocus() @@ -1599,12 +1257,15 @@ class MarkdownEditor(QTextEdit): lang_menu = menu.addMenu(strings._("set_code_language")) languages = [ - "bash", - "css", - "html", - "javascript", - "php", "python", + "bash", + "php", + "javascript", + "html", + "css", + "sql", + "java", + "go", ] for lang in languages: action = QAction(lang.capitalize(), self) @@ -1615,12 +1276,6 @@ class MarkdownEditor(QTextEdit): menu.addSeparator() - edit_action = QAction(strings._("edit_code_block"), self) - edit_action.triggered.connect(lambda: self._edit_code_block(block)) - menu.addAction(edit_action) - - menu.addSeparator() - # Add standard context menu actions if self.textCursor().hasSelection(): menu.addAction(strings._("cut"), self.cut) @@ -1654,23 +1309,3 @@ class MarkdownEditor(QTextEdit): cursor = self.textCursor() block = cursor.block() return block.text() - - def get_current_line_task_text(self) -> str: - """ - Like get_current_line_text(), but with list / checkbox / number - prefixes stripped off for use in Pomodoro notes, etc. - """ - line = self.get_current_line_text() - - text = re.sub( - r"^\s*(?:" - r"-\s\[(?: |x|X)\]\s+" # markdown checkbox - r"|[☐☑]\s+" # Unicode checkbox - r"|•\s+" # Unicode bullet - r"|[-*+]\s+" # markdown bullets - r"|\d+\.\s+" # numbered 1. 2. etc - r")", - "", - line, - ) - return text.strip() diff --git a/bouquin/markdown_highlighter.py b/bouquin/markdown_highlighter.py index 7489953..f9826ff 100644 --- a/bouquin/markdown_highlighter.py +++ b/bouquin/markdown_highlighter.py @@ -78,18 +78,17 @@ class MarkdownHighlighter(QSyntaxHighlighter): self.theme_manager.current() == Theme.DARK or self.theme_manager._is_system_dark ): - # In dark mode, use a darker panel-like background for codeblocks - code_bg = pal.color(QPalette.AlternateBase) - code_fg = pal.color(QPalette.Text) + # 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 for code blocks - code_bg = QColor(245, 245, 245) - code_fg = QColor( # pragma: no cover + # Light mode: keep the existing light gray + bg = QColor(245, 245, 245) + fg = QColor( # pragma: no cover 0, 0, 0 ) # avoiding using QPalette.Text as it can be white on macOS - - self.code_block_format.setBackground(code_bg) - self.code_block_format.setForeground(code_fg) + self.code_block_format.setBackground(bg) + self.code_block_format.setForeground(fg) # Headings self.h1_format = QTextCharFormat() @@ -111,23 +110,6 @@ class MarkdownHighlighter(QSyntaxHighlighter): self.link_format.setFontUnderline(True) self.link_format.setAnchor(True) - # ---- Completed-task text (for checked checkboxes) ---- - # Use the app palette so this works in both light and dark themes. - text_fg = pal.color(QPalette.Text) - text_bg = pal.color(QPalette.Base) - - # Blend the text colour towards the background to "fade" it. - # t closer to 1.0 = closer to background / more faded. - t = 0.55 - faded = QColor( - int(text_fg.red() * (1.0 - t) + text_bg.red() * t), - int(text_fg.green() * (1.0 - t) + text_bg.green() * t), - int(text_fg.blue() * (1.0 - t) + text_bg.blue() * t), - ) - - self.completed_task_format = QTextCharFormat() - self.completed_task_format.setForeground(faded) - # Checkboxes self.checkbox_format = QTextCharFormat() self.checkbox_format.setVerticalAlignment(QTextCharFormat.AlignMiddle) @@ -158,7 +140,8 @@ class MarkdownHighlighter(QSyntaxHighlighter): # Markdown syntax (the markers themselves) - make invisible self.syntax_format = QTextCharFormat() # Use the editor background color so they blend in - hidden = QColor(text_bg) + bg = pal.color(QPalette.Base) + hidden = QColor(bg) hidden.setAlpha(0) self.syntax_format.setForeground(hidden) # Make the markers invisible by setting font size to 0.1 points @@ -359,13 +342,3 @@ class MarkdownHighlighter(QSyntaxHighlighter): # (If you add Unicode bullets later…) for m in re.finditer(r"•", text): self._overlay_range(m.start(), 1, self.bullet_format) - - # Completed checkbox lines: fade the text after the checkbox. - m = re.match(r"^(\s*☑\s+)(.+)$", text) - if m and hasattr(self, "completed_task_format"): - prefix = m.group(1) - content = m.group(2) - start = len(prefix) - length = len(content) - if length > 0: - self._overlay_range(start, length, self.completed_task_format) diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index 18d0fa7..8873ffd 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -106,7 +106,7 @@ class ToolBar(QToolBar): self.actInsertImg.triggered.connect(self.insertImageRequested) # History button - self.actHistory = QAction("↺", self) + self.actHistory = QAction("🔁", self) self.actHistory.setToolTip(strings._("history")) self.actHistory.triggered.connect(self.historyRequested) @@ -187,7 +187,7 @@ class ToolBar(QToolBar): self._style_letter_button(self.actTimer, "⌛") # History - self._style_letter_button(self.actHistory, "↺") + self._style_letter_button(self.actHistory, "🔁") def _style_letter_button( self, diff --git a/tests/conftest.py b/tests/conftest.py index 9c3d095..658b7e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,38 +58,3 @@ def fresh_db(tmp_db_cfg): assert ok, "DB connect() should succeed" yield db db.close() - - -@pytest.fixture(autouse=True) -def _stub_code_block_editor_dialog(monkeypatch): - """ - In tests, replace the interactive CodeBlockEditorDialog with a tiny stub - that never shows a real QDialog and never blocks on exec(). - """ - import bouquin.markdown_editor as markdown_editor - from PySide6.QtWidgets import QDialog - - class _TestCodeBlockEditorDialog: - def __init__(self, code: str, language: str | None, parent=None): - # Simulate what the real dialog would “start with” - self._code = code - self._language = language - - def exec(self) -> int: - # Pretend the user clicked OK immediately. - # (If you prefer “Cancel by default”, return Rejected instead.) - return QDialog.DialogCode.Accepted - - def code(self) -> str: - # In tests we just return the initial code unchanged. - return self._code - - def language(self) -> str | None: - # Ditto for language. - return self._language - - # MarkdownEditor imported CodeBlockEditorDialog into its own module, - # so patch that name – everything in MarkdownEditor will use this stub. - monkeypatch.setattr( - markdown_editor, "CodeBlockEditorDialog", _TestCodeBlockEditorDialog - ) diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py index a4025ea..cc02ad8 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -164,22 +164,81 @@ def test_enter_on_empty_list_marks_empty(qtbot, editor): assert editor.toPlainText().startswith("\u2022 \n") -def test_triple_backtick_triggers_code_dialog_but_no_block_on_empty_code(editor, qtbot): - # Start empty +def test_triple_backtick_autoexpands(editor, qtbot): editor.from_markdown("") press_backtick(qtbot, editor, 2) - press_backtick(qtbot, editor, 1) # triggers the 3rd-backtick shortcut + 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] == "" - # The two typed backticks should have been removed - assert "`" not in t - # With the new dialog-based implementation, and our test stub that accepts - # the dialog with empty code, no fenced code block is inserted. - assert "```" not in t - assert t == "" +def test_toolbar_inserts_block_on_own_lines(editor, qtbot): + editor.from_markdown("hello") + editor.moveCursor(QTextCursor.End) + editor.apply_code() # action inserts fenced code block + 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] == "" + + +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 + + +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] == "" + + +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() == "```" def test_down_escapes_from_last_code_line(editor, qtbot): @@ -463,6 +522,25 @@ def test_apply_italic_and_strike(editor): assert editor.textCursor().position() == len(editor.toPlainText()) - 2 +def test_apply_code_inline_block_navigation(editor): + # Selection case -> fenced block around selection + editor.setPlainText("code") + c = editor.textCursor() + c.select(QTextCursor.SelectionType.Document) + editor.setTextCursor(c) + editor.apply_code() + assert "```\ncode\n```\n" in editor.toPlainText() + + # No selection, at EOF with no following block -> creates block and extra newline path + editor.setPlainText("before") + editor.moveCursor(QTextCursor.MoveOperation.End) + editor.apply_code() + t = editor.toPlainText() + assert t.endswith("before\n```\n\n```\n") + # Caret should be inside the code block blank line + assert editor.textCursor().position() == len("before\n") + 4 + + def test_insert_image_from_path_invalid_returns(editor_hello, tmp_path): # Non-existent path should just return (early exit) bad = tmp_path / "missing.png"