Add ability to collapse/expand sections of text
This commit is contained in:
parent
757517dcc4
commit
807d11ca75
7 changed files with 546 additions and 20 deletions
80
.forgejo/workflows/build-deb.yml
Normal file
80
.forgejo/workflows/build-deb.yml
Normal file
|
|
@ -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"
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
# 0.7.6
|
# 0.8.0
|
||||||
|
|
||||||
* Add .desktop file for Debian
|
* 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.
|
* 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)
|
* 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
|
* 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 '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
|
* Don't offer to download latest AppImage unless we are running as an AppImage already
|
||||||
|
|
||||||
# 0.7.5
|
# 0.7.5
|
||||||
|
|
|
||||||
32
README.md
32
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
|
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.
|
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.
|
It uses SQLCipher as a drop-in replacement for SQLite3.
|
||||||
|
|
||||||
This means that the underlying database for the notebook is encrypted at rest.
|
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
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
## Some of the features
|
## Features
|
||||||
|
|
||||||
* Data is encrypted at rest
|
* Data is encrypted at rest
|
||||||
* Encryption key is prompted for and never stored, unless user chooses to via Settings
|
* 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
|
* 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.
|
* 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
|
* Images are supported
|
||||||
* Search all pages, or find text on current page
|
* Search all pages, or find text on current page
|
||||||
* Add and manage tags
|
|
||||||
* Automatic periodic saving (or explicitly save)
|
* Automatic periodic saving (or explicitly save)
|
||||||
* Automatic locking of the app after a period of inactivity (default 15 min)
|
* Automatic locking of the app after a period of inactivity (default 15 min)
|
||||||
* Rekey the database (change the password)
|
* 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)
|
* Backup the database to encrypted SQLCipher format (which can then be loaded back in to a Bouquin)
|
||||||
* Dark and light theme support
|
* Dark and light theme support
|
||||||
* Automatically generate checkboxes when typing 'TODO'
|
* 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
|
* English, French and Italian locales provided
|
||||||
* Ability to set reminder alarms (which will be flashed as the reminder)
|
* 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 and timesheet reports
|
* 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.
|
* 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
|
## How to install
|
||||||
|
|
@ -92,7 +100,6 @@ sudo apt update
|
||||||
sudo apt install bouquin
|
sudo apt install bouquin
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### From PyPi/pip
|
### From PyPi/pip
|
||||||
|
|
||||||
* `pip install bouquin`
|
* `pip install bouquin`
|
||||||
|
|
@ -108,13 +115,4 @@ sudo apt install bouquin
|
||||||
* Run `poetry install` to install dependencies
|
* Run `poetry install` to install dependencies
|
||||||
* Run `poetry run bouquin` to start the application.
|
* Run `poetry run bouquin` to start the application.
|
||||||
|
|
||||||
### From the releases page
|
Alternatively, you can download the source code and wheels from Releases as well.
|
||||||
|
|
||||||
* 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`
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from PySide6.QtCore import QRect, QSize, Qt
|
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 (
|
from PySide6.QtWidgets import (
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
|
|
@ -32,6 +34,12 @@ class CodeEditorWithLineNumbers(QPlainTextEdit):
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
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._line_number_area = _LineNumberArea(self)
|
||||||
|
|
||||||
self.blockCountChanged.connect(self._update_line_number_area_width)
|
self.blockCountChanged.connect(self._update_line_number_area_width)
|
||||||
|
|
@ -140,6 +148,48 @@ class CodeEditorWithLineNumbers(QPlainTextEdit):
|
||||||
bottom = top + self.blockBoundingRect(block).height()
|
bottom = top + self.blockBoundingRect(block).height()
|
||||||
block_number += 1
|
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):
|
class CodeBlockEditorDialog(QDialog):
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,10 @@
|
||||||
"cut": "Cut",
|
"cut": "Cut",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"paste": "Paste",
|
"paste": "Paste",
|
||||||
|
"collapse": "Collapse",
|
||||||
|
"expand": "Expand",
|
||||||
|
"remove_collapse": "Remove collapse",
|
||||||
|
"collapse_selection": "Collapse selection",
|
||||||
"start": "Start",
|
"start": "Start",
|
||||||
"pause": "Pause",
|
"pause": "Pause",
|
||||||
"resume": "Resume",
|
"resume": "Resume",
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,10 @@
|
||||||
"cut": "Couper",
|
"cut": "Couper",
|
||||||
"copy": "Copier",
|
"copy": "Copier",
|
||||||
"paste": "Coller",
|
"paste": "Coller",
|
||||||
|
"collapse": "Replier",
|
||||||
|
"expand": "Déplier",
|
||||||
|
"remove_collapse": "Supprimer le pliage",
|
||||||
|
"collapse_selection": "Replier la sélection",
|
||||||
"start": "Démarrer",
|
"start": "Démarrer",
|
||||||
"pause": "Pause",
|
"pause": "Pause",
|
||||||
"resume": "Reprendre",
|
"resume": "Reprendre",
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,22 @@ class MarkdownEditor(QTextEdit):
|
||||||
|
|
||||||
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
|
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
|
||||||
|
|
||||||
|
# ===== Collapsible sections (editor-only folding) =====
|
||||||
|
# We represent a collapsed region as:
|
||||||
|
# <indent>▸ collapse
|
||||||
|
# ... hidden blocks ...
|
||||||
|
# <indent><!-- bouquin:collapse:end -->
|
||||||
|
#
|
||||||
|
# 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 = "<!-- bouquin:collapse:end -->"
|
||||||
|
# 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*bouquin:collapse:end\s*-->\s*$")
|
||||||
|
|
||||||
def __init__(self, theme_manager: ThemeManager, *args, **kwargs):
|
def __init__(self, theme_manager: ThemeManager, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
@ -703,6 +719,9 @@ class MarkdownEditor(QTextEdit):
|
||||||
# Render any embedded images
|
# Render any embedded images
|
||||||
self._render_images()
|
self._render_images()
|
||||||
|
|
||||||
|
# Apply folding for any collapse regions present in the markdown
|
||||||
|
self._refresh_collapse_folding()
|
||||||
|
|
||||||
self._update_code_block_row_backgrounds()
|
self._update_code_block_row_backgrounds()
|
||||||
QTimer.singleShot(0, 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()
|
block = cur.block()
|
||||||
text = block.text()
|
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)
|
# The display tokens, e.g. "☐ " / "☑ " (icon + trailing space)
|
||||||
unchecked = f"{self._CHECK_UNCHECKED_DISPLAY} "
|
unchecked = f"{self._CHECK_UNCHECKED_DISPLAY} "
|
||||||
checked = f"{self._CHECK_CHECKED_DISPLAY} "
|
checked = f"{self._CHECK_CHECKED_DISPLAY} "
|
||||||
|
|
@ -1789,6 +1847,307 @@ class MarkdownEditor(QTextEdit):
|
||||||
cursor.insertImage(img_format)
|
cursor.insertImage(img_format)
|
||||||
cursor.insertText("\n") # Add newline after image
|
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 ==========
|
# ========== Context Menu Support ==========
|
||||||
|
|
||||||
def contextMenuEvent(self, event):
|
def contextMenuEvent(self, event):
|
||||||
|
|
@ -1832,6 +2191,36 @@ class MarkdownEditor(QTextEdit):
|
||||||
|
|
||||||
menu.addSeparator()
|
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
|
# Add standard context menu actions
|
||||||
if self.textCursor().hasSelection():
|
if self.textCursor().hasSelection():
|
||||||
menu.addAction(strings._("cut"), self.cut)
|
menu.addAction(strings._("cut"), self.cut)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue