From 807d11ca754e3e3b574af01a6d6b5ee642972a0a Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Dec 2025 17:18:02 +1100 Subject: [PATCH] Add ability to collapse/expand sections of text --- .forgejo/workflows/build-deb.yml | 80 ++++++ CHANGELOG.md | 5 +- README.md | 32 ++- bouquin/code_block_editor_dialog.py | 52 +++- bouquin/locales/en.json | 4 + bouquin/locales/fr.json | 4 + bouquin/markdown_editor.py | 389 ++++++++++++++++++++++++++++ 7 files changed, 546 insertions(+), 20 deletions(-) create mode 100644 .forgejo/workflows/build-deb.yml diff --git a/.forgejo/workflows/build-deb.yml b/.forgejo/workflows/build-deb.yml new file mode 100644 index 0000000..0f44886 --- /dev/null +++ b/.forgejo/workflows/build-deb.yml @@ -0,0 +1,80 @@ +name: CI + +on: + push: + +jobs: + test: + runs-on: docker + + steps: + - name: Install system dependencies + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends curl gnupg2 ca-certificates + mkdir -p /usr/share/keyrings + curl -fsSL https://mig5.net/static/mig5.asc | gpg --dearmor -o /usr/share/keyrings/mig5.gpg + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/mig5.gpg] https://apt.mig5.net trixie main" | tee /etc/apt/sources.list.d/mig5.list + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + build-essential \ + devscripts \ + debhelper \ + dh-python \ + python3-all-dev \ + python3-setuptools \ + python3-wheel \ + libssl-dev \ + rsync \ + pybuild-plugin-pyproject \ + python3-poetry-core \ + python3-sqlcipher4 \ + python3-pyside6.qtwidgets \ + python3-pyside6.qtcore \ + python3-pyside6.qtgui \ + python3-pyside6.qtsvg \ + python3-pyside6.qtprintsupport \ + python3-requests \ + python3-markdown \ + libxcb-cursor0 \ + fonts-noto-core + + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Build deb + run: | + mkdir /out + + rsync -a --delete \ + --exclude '.git' \ + --exclude '.venv' \ + --exclude 'dist' \ + --exclude 'build' \ + --exclude '__pycache__' \ + --exclude '.pytest_cache' \ + --exclude '.mypy_cache' \ + ./ /out/ + + cd /out/ + export DEBEMAIL="mig@mig5.net" + export DEBFULLNAME="Miguel Jacq" + + dch --distribution "trixie" --local "~trixie" "CI build for trixie" + dpkg-buildpackage -us -uc -b + + # Notify if any previous step in this job failed + - name: Notify on failure + if: ${{ failure() }} + env: + WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }} + REPOSITORY: ${{ forgejo.repository }} + RUN_NUMBER: ${{ forgejo.run_number }} + SERVER_URL: ${{ forgejo.server_url }} + run: | + curl -X POST \ + -H "Content-Type: application/json" \ + -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \ + "$WEBHOOK_URL" diff --git a/CHANGELOG.md b/CHANGELOG.md index dc36f0f..d94ebeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,12 @@ -# 0.7.6 +# 0.8.0 * Add .desktop file for Debian * Fix Pomodoro timer rounding so it rounds up to 0.25, but rounds to closest quarter (up or down) for minutes higher than that, instead of always up to next quarter. * Allow setting a code block on a line that already has text (it will start a newline for the codeblock) * Retain indentation when tab is used to indent a line, unless enter is pressed twice or user deletes the indentation - * Add missing strings (for English and French) + * Add ability to collapse/expand sections of text. * Add 'Last Month' date range for timesheet reports + * Add missing strings (for English and French) * Don't offer to download latest AppImage unless we are running as an AppImage already # 0.7.5 diff --git a/README.md b/README.md index a7b5ec0..1019d48 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ It is designed to treat each day as its own 'page', complete with Markdown rende search, reminders and time logging for those of us who need to keep track of not just TODOs, but also how long we spent on them. +For those who rely on that time logging for work, there is also an Invoicing feature that can +generate invoices of that time spent. + +There is also support for embedding documents in a file manager. + It uses SQLCipher as a drop-in replacement for SQLite3. This means that the underlying database for the notebook is encrypted at rest. @@ -52,16 +57,18 @@ report from within the app, or optionally to check for new versions to upgrade t -## Some of the features +## Features * Data is encrypted at rest * Encryption key is prompted for and never stored, unless user chooses to via Settings * All changes are version controlled, with ability to view/diff versions, revert or delete revisions - * Automatic rendering of basic Markdown syntax * Tabs are supported - right-click on a date from the calendar to open it in a new tab. + * Automatic rendering of basic Markdown syntax + * Basic code block editing/highlighting + * Ability to collapse/expand sections of text + * Ability to increase/decrease font size * Images are supported * Search all pages, or find text on current page - * Add and manage tags * Automatic periodic saving (or explicitly save) * Automatic locking of the app after a period of inactivity (default 15 min) * Rekey the database (change the password) @@ -69,11 +76,12 @@ report from within the app, or optionally to check for new versions to upgrade t * Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin) * Dark and light theme support * Automatically generate checkboxes when typing 'TODO' - * It is possible to automatically move unchecked checkboxes from the last 7 days to the next weekday. + * It is possible to automatically move unchecked checkboxes from the last 7 days to the next day. * English, French and Italian locales provided - * Ability to set reminder alarms (which will be flashed as the reminder) - * Ability to log time per day for different projects/activities, pomodoro-style log timer and timesheet reports + * Ability to set reminder alarms (which will be flashed as the reminder or can be sent as webhooks/email notifications) + * Ability to log time per day for different projects/activities, pomodoro-style log timer, timesheet reports and invoicing of time spent * Ability to store and tag documents (tied to Projects, same as the Time Logging system). The documents are stored embedded in the encrypted database. + * Add and manage tags on pages and documents ## How to install @@ -92,7 +100,6 @@ sudo apt update sudo apt install bouquin ``` - ### From PyPi/pip * `pip install bouquin` @@ -108,13 +115,4 @@ sudo apt install bouquin * Run `poetry install` to install dependencies * Run `poetry run bouquin` to start the application. -### From the releases page - - * Download the whl and run it - -## How to run the tests - - * Clone the repo - * Ensure you have poetry installed - * Run `poetry install --with test` - * Run `./tests.sh` +Alternatively, you can download the source code and wheels from Releases as well. diff --git a/bouquin/code_block_editor_dialog.py b/bouquin/code_block_editor_dialog.py index 8df348d..64bb46b 100644 --- a/bouquin/code_block_editor_dialog.py +++ b/bouquin/code_block_editor_dialog.py @@ -1,7 +1,9 @@ from __future__ import annotations +import re + from PySide6.QtCore import QRect, QSize, Qt -from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette +from PySide6.QtGui import QColor, QFont, QFontMetrics, QPainter, QPalette, QTextCursor from PySide6.QtWidgets import ( QComboBox, QDialog, @@ -32,6 +34,12 @@ class CodeEditorWithLineNumbers(QPlainTextEdit): def __init__(self, parent=None): super().__init__(parent) + # Allow Tab to insert indentation (not move focus between widgets) + self.setTabChangesFocus(False) + + # Track whether we just auto-inserted indentation on Enter + self._last_enter_was_empty_indent = False + self._line_number_area = _LineNumberArea(self) self.blockCountChanged.connect(self._update_line_number_area_width) @@ -140,6 +148,48 @@ class CodeEditorWithLineNumbers(QPlainTextEdit): bottom = top + self.blockBoundingRect(block).height() block_number += 1 + def keyPressEvent(self, event): # type: ignore[override] + """Auto-retain indentation on newlines (Tab/space) like the markdown editor. + + Rules: + - If the current line is indented, Enter inserts a newline + the same indent. + - If the current line contains only indentation, a *second* Enter clears the indent + and starts an unindented line (similar to exiting bullets/checkboxes). + """ + if event.key() in (Qt.Key_Return, Qt.Key_Enter): + cursor = self.textCursor() + block_text = cursor.block().text() + indent = re.match(r"[ \t]*", block_text).group(0) # type: ignore[union-attr] + + if indent: + rest = block_text[len(indent) :] + indent_only = rest.strip() == "" + + if indent_only and self._last_enter_was_empty_indent: + # Second Enter on an indentation-only line: remove that line and + # start a fresh, unindented line. + cursor.select(QTextCursor.SelectionType.LineUnderCursor) + cursor.removeSelectedText() + cursor.insertText("\n") + self.setTextCursor(cursor) + self._last_enter_was_empty_indent = False + return + + # First Enter: keep indentation + super().keyPressEvent(event) + self.textCursor().insertText(indent) + self._last_enter_was_empty_indent = True + return + + # No indent -> normal Enter + self._last_enter_was_empty_indent = False + super().keyPressEvent(event) + return + + # Any other key resets the empty-indent-enter flag + self._last_enter_was_empty_indent = False + super().keyPressEvent(event) + class CodeBlockEditorDialog(QDialog): def __init__( diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index c8784fd..26a4d5c 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -303,6 +303,10 @@ "cut": "Cut", "copy": "Copy", "paste": "Paste", + "collapse": "Collapse", + "expand": "Expand", + "remove_collapse": "Remove collapse", + "collapse_selection": "Collapse selection", "start": "Start", "pause": "Pause", "resume": "Resume", diff --git a/bouquin/locales/fr.json b/bouquin/locales/fr.json index 2b889d7..d82890d 100644 --- a/bouquin/locales/fr.json +++ b/bouquin/locales/fr.json @@ -302,6 +302,10 @@ "cut": "Couper", "copy": "Copier", "paste": "Coller", + "collapse": "Replier", + "expand": "Déplier", + "remove_collapse": "Supprimer le pliage", + "collapse_selection": "Replier la sélection", "start": "Démarrer", "pause": "Pause", "resume": "Reprendre", diff --git a/bouquin/markdown_editor.py b/bouquin/markdown_editor.py index 78af734..849f515 100644 --- a/bouquin/markdown_editor.py +++ b/bouquin/markdown_editor.py @@ -34,6 +34,22 @@ class MarkdownEditor(QTextEdit): _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") + # ===== Collapsible sections (editor-only folding) ===== + # We represent a collapsed region as: + # ▸ collapse + # ... hidden blocks ... + # + # + # The end-marker line is always hidden in the editor but preserved in markdown. + _COLLAPSE_ARROW_COLLAPSED = "▸" + _COLLAPSE_ARROW_EXPANDED = "▾" + _COLLAPSE_LABEL_COLLAPSE = "collapse" + _COLLAPSE_LABEL_EXPAND = "expand" + _COLLAPSE_END_MARKER = "" + # Accept either "collapse" or "expand" in the header text (older files used only "collapse") + _COLLAPSE_HEADER_RE = re.compile(r"^([ \t]*)([▸▾])\s+(?:collapse|expand)\s*$") + _COLLAPSE_END_RE = re.compile(r"^([ \t]*)\s*$") + def __init__(self, theme_manager: ThemeManager, *args, **kwargs): super().__init__(*args, **kwargs) @@ -703,6 +719,9 @@ class MarkdownEditor(QTextEdit): # Render any embedded images self._render_images() + # Apply folding for any collapse regions present in the markdown + self._refresh_collapse_folding() + self._update_code_block_row_backgrounds() QTimer.singleShot(0, self._update_code_block_row_backgrounds) @@ -1328,6 +1347,45 @@ class MarkdownEditor(QTextEdit): block = cur.block() text = block.text() + # Click-to-toggle collapse regions: clicking the arrow on a + # "▸ collapse" / "▾ collapse" line expands/collapses the section. + parsed = self._parse_collapse_header(text) + if parsed: + indent, _is_collapsed = parsed + arrow_idx = len(indent) + if arrow_idx < len(text): + arrow = text[arrow_idx] + if arrow in ( + self._COLLAPSE_ARROW_COLLAPSED, + self._COLLAPSE_ARROW_EXPANDED, + ): + doc_pos = block.position() + arrow_idx + c_arrow = QTextCursor(self.document()) + c_arrow.setPosition( + max( + 0, + min( + doc_pos, + max(0, self.document().characterCount() - 1), + ), + ) + ) + r = self.cursorRect(c_arrow) + + fmt_font = ( + c_arrow.charFormat().font() + if c_arrow.charFormat().isValid() + else self.font() + ) + fm = QFontMetrics(fmt_font) + w = max(1, fm.horizontalAdvance(arrow)) + + # Make the hit area a bit generous. + hit_rect = QRect(r.x(), r.y(), w + (w // 2), r.height()) + if hit_rect.contains(pt): + self._toggle_collapse_at_block(block) + return + # The display tokens, e.g. "☐ " / "☑ " (icon + trailing space) unchecked = f"{self._CHECK_UNCHECKED_DISPLAY} " checked = f"{self._CHECK_CHECKED_DISPLAY} " @@ -1789,6 +1847,307 @@ class MarkdownEditor(QTextEdit): cursor.insertImage(img_format) cursor.insertText("\n") # Add newline after image + # ========== Collapse / Expand (folding) ========== + + def _parse_collapse_header(self, line: str) -> Optional[tuple[str, bool]]: + # If line is a collapse header, return (indent, is_collapsed) + m = self._COLLAPSE_HEADER_RE.match(line) + if not m: + return None + indent = m.group(1) + arrow = m.group(2) + return (indent, arrow == self._COLLAPSE_ARROW_COLLAPSED) + + def _is_collapse_end_marker(self, line: str) -> bool: + return bool(self._COLLAPSE_END_RE.match(line)) + + def _set_block_visible(self, block: QTextBlock, visible: bool) -> None: + """Hide/show a QTextBlock and nudge layout to update. + + When folding, we set lineCount=0 for hidden blocks (standard Qt recipe). + When showing again, we restore a sensible lineCount based on the block's + current layout so the document relayout doesn't glitch. + """ + if not block.isValid(): + return + if block.isVisible() == visible: + return + + block.setVisible(visible) + + try: + if not visible: + # Hidden blocks should contribute no height. + block.setLineCount(0) # type: ignore[attr-defined] + else: + # Restore an accurate lineCount if we can. + layout = block.layout() + lc = 1 + try: + lc = int(layout.lineCount()) if layout is not None else 1 + except Exception: + lc = 1 + block.setLineCount(max(1, lc)) # type: ignore[attr-defined] + except Exception: + pass + + doc = self.document() + if doc is not None: + doc.markContentsDirty(block.position(), block.length()) + + def _find_collapse_end_block( + self, header_block: QTextBlock + ) -> Optional[QTextBlock]: + # Find matching end marker for a header (supports nesting) + if not header_block.isValid(): + return None + + depth = 1 + b = header_block.next() + while b.isValid(): + line = b.text() + if self._COLLAPSE_HEADER_RE.match(line): + depth += 1 + elif self._is_collapse_end_marker(line): + depth -= 1 + if depth == 0: + return b + b = b.next() + return None + + def _set_collapse_header_state( + self, header_block: QTextBlock, collapsed: bool + ) -> None: + parsed = self._parse_collapse_header(header_block.text()) + if not parsed: + return + indent, _ = parsed + arrow = ( + self._COLLAPSE_ARROW_COLLAPSED + if collapsed + else self._COLLAPSE_ARROW_EXPANDED + ) + label = ( + self._COLLAPSE_LABEL_EXPAND if collapsed else self._COLLAPSE_LABEL_COLLAPSE + ) + new_line = f"{indent}{arrow} {label}" + + # Replace *only* the text inside this block (not the paragraph separator), + # to avoid any chance of the header visually "joining" adjacent lines. + doc = self.document() + if doc is None: + return + + cursor = QTextCursor(doc) + cursor.setPosition(header_block.position()) + cursor.beginEditBlock() + cursor.movePosition( + QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor + ) + cursor.insertText(new_line) + cursor.endEditBlock() + + def _toggle_collapse_at_block(self, header_block: QTextBlock) -> None: + parsed = self._parse_collapse_header(header_block.text()) + if not parsed: + return + + doc = self.document() + if doc is None: + return + + block_num = header_block.blockNumber() + _, is_collapsed = parsed + + end_block = self._find_collapse_end_block(header_block) + if end_block is None: + return + + # Flip header arrow + self._set_collapse_header_state(header_block, collapsed=not is_collapsed) + + # Refresh folding so nested regions keep their state + self._refresh_collapse_folding() + + # Re-resolve the header block after edits/layout changes + hb = doc.findBlockByNumber(block_num) + pos = hb.position() if hb.isValid() else header_block.position() + + # Keep caret on the header (start of line) + c = self.textCursor() + c.setPosition(max(0, min(pos, max(0, doc.characterCount() - 1)))) + self.setTextCursor(c) + self.setFocus() + + def _remove_collapse_at_block(self, header_block: QTextBlock) -> None: + # Remove a collapse wrapper (keep content, delete header + end marker) + end_block = self._find_collapse_end_block(header_block) + if end_block is None: + return + + doc = self.document() + if doc is None: + return + + # Ensure content visible + b = header_block.next() + while b.isValid() and b != end_block: + self._set_block_visible(b, True) + b = b.next() + + cur = QTextCursor(doc) + cur.beginEditBlock() + + # Delete header block + cur.setPosition(header_block.position()) + cur.select(QTextCursor.SelectionType.BlockUnderCursor) + cur.removeSelectedText() + cur.deleteChar() # paragraph separator + + # Find and delete the end marker block (scan forward) + probe = doc.findBlock(end_block.position()) + b2 = probe + for _ in range(0, 50): + if not b2.isValid(): + break + if self._is_collapse_end_marker(b2.text()): + cur.setPosition(b2.position()) + cur.select(QTextCursor.SelectionType.BlockUnderCursor) + cur.removeSelectedText() + cur.deleteChar() + break + b2 = b2.next() + + cur.endEditBlock() + + self._refresh_collapse_folding() + + def collapse_selection(self) -> None: + # Wrap the current selection in a collapsible region and collapse it + cursor = self.textCursor() + if not cursor.hasSelection(): + return + + doc = self.document() + if doc is None: + return + + sel_start = min(cursor.selectionStart(), cursor.selectionEnd()) + sel_end = max(cursor.selectionStart(), cursor.selectionEnd()) + + # Defensive clamp (prevents QTextCursor::setPosition out-of-range in edge cases) + doc_end = max(0, doc.characterCount() - 1) + sel_start = max(0, min(sel_start, doc_end)) + sel_end = max(0, min(sel_end, doc_end)) + + c1 = QTextCursor(doc) + c1.setPosition(sel_start) + start_block = c1.block() + + c2 = QTextCursor(doc) + c2.setPosition(sel_end) + end_block = c2.block() + + # If the selection ends exactly at the start of a block, treat the + # previous block as the "end" (Qt selections often report the start + # of the next block as selectionEnd()). + if ( + sel_end > sel_start + and end_block.isValid() + and sel_end == end_block.position() + and sel_end > 0 + ): + c2.setPosition(sel_end - 1) + end_block = c2.block() + + # Expand to whole blocks + start_pos = start_block.position() + end_pos_raw = end_block.position() + end_block.length() + end_pos = min(end_pos_raw, max(0, doc.characterCount() - 1)) + + # Inherit indentation from the first selected line (useful inside lists) + m = re.match(r"^[ \t]*", start_block.text()) + indent = m.group(0) if m else "" + + header_line = ( + f"{indent}{self._COLLAPSE_ARROW_COLLAPSED} {self._COLLAPSE_LABEL_EXPAND}" + ) + end_marker_line = f"{indent}{self._COLLAPSE_END_MARKER}" + + edit = QTextCursor(doc) + edit.beginEditBlock() + + # Insert end marker AFTER selection first (keeps start positions stable) + edit.setPosition(end_pos) + + # If the computed end position fell off the end of the document (common + # when the selection includes the last line without a trailing newline), + # ensure the end marker starts on its own line. + if end_pos_raw > end_pos and edit.position() > 0: + prev = doc.characterAt(edit.position() - 1) + if prev not in ("\n", "\u2029"): + edit.insertText("\n") + + # Also ensure we are not mid-line (marker should be its own block). + if edit.position() > 0: + prev = doc.characterAt(edit.position() - 1) + if prev not in ("\n", "\u2029"): + edit.insertText("\n") + + edit.insertText(end_marker_line + "\n") + + # Insert header BEFORE selection + edit.setPosition(start_pos) + edit.insertText(header_line + "\n") + edit.endEditBlock() + + self._refresh_collapse_folding() + + # Caret on header + header_block = doc.findBlock(start_pos) + c = self.textCursor() + c.setPosition(header_block.position()) + self.setTextCursor(c) + self.setFocus() + + def _refresh_collapse_folding(self) -> None: + # Apply folding to all collapse regions based on their arrow state + doc = self.document() + if doc is None: + return + + # Show everything except end markers (always hidden) + b = doc.begin() + while b.isValid(): + if self._is_collapse_end_marker(b.text()): + self._set_block_visible(b, False) + else: + self._set_block_visible(b, True) + b = b.next() + + # Hide content for any header that is currently collapsed + b = doc.begin() + while b.isValid(): + parsed = self._parse_collapse_header(b.text()) + if parsed and parsed[1] is True: + end_block = self._find_collapse_end_block(b) + if end_block is None: + b = b.next() + continue + + inner = b.next() + while inner.isValid() and inner != end_block: + self._set_block_visible(inner, False) + inner = inner.next() + + self._set_block_visible(end_block, False) + b = end_block + b = b.next() + + # Force a full relayout after visibility changes (prevents visual jitter) + doc.markContentsDirty(0, doc.characterCount()) + self.viewport().update() + # ========== Context Menu Support ========== def contextMenuEvent(self, event): @@ -1832,6 +2191,36 @@ class MarkdownEditor(QTextEdit): menu.addSeparator() + # Collapse / Expand actions + header_parsed = self._parse_collapse_header(block.text()) + if header_parsed: + _indent, is_collapsed = header_parsed + + menu.addSeparator() + + toggle_label = ( + strings._("expand") if is_collapsed else strings._("collapse") + ) + toggle_action = QAction(toggle_label, self) + toggle_action.triggered.connect( + lambda checked=False, b=block: self._toggle_collapse_at_block(b) + ) + menu.addAction(toggle_action) + + remove_action = QAction(strings._("remove_collapse"), self) + remove_action.triggered.connect( + lambda checked=False, b=block: self._remove_collapse_at_block(b) + ) + menu.addAction(remove_action) + + menu.addSeparator() + + if self.textCursor().hasSelection(): + collapse_sel_action = QAction(strings._("collapse_selection"), self) + collapse_sel_action.triggered.connect(self.collapse_selection) + menu.addAction(collapse_sel_action) + menu.addSeparator() + # Add standard context menu actions if self.textCursor().hasSelection(): menu.addAction(strings._("cut"), self.cut)