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
|
||||
* 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
|
||||
|
|
|
|||
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
|
||||
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
|
|||
</div>
|
||||
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -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__(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
# <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):
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue