diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-21 16:13:28 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-21 16:13:28 +0200 |
| commit | 95ae5dfe2df7858ad957b353dc0ae1d7af3b4832 (patch) | |
| tree | c996833dd3ec82f599607a0168a180169e8e94a0 /src/formattoolbar.cpp | |
| parent | 2b32350204dfb49089c465856464a043018ca3c6 (diff) | |
| download | qtmaildir-95ae5dfe2df7858ad957b353dc0ae1d7af3b4832.tar.gz qtmaildir-95ae5dfe2df7858ad957b353dc0ae1d7af3b4832.zip | |
feat(compose): transform the markdown buffer for the toolbar, item 123
MarkdownFormat, task 8 of the compose-and-send plan. Three free functions
over (text, selection start, selection end) returning the new text and the
selection that follows it, so the grammar is tested without a widget.
Three gaps in the plan's draft, each now pinned by a test checked against the
mutation that breaks it:
- QString::lastIndexOf INCLUDES the position it is given, so quoting with the
cursor at the end of a line found that line's newline and quoted the
FOLLOWING one. The draft's fixtures never placed a cursor there.
- A backwards selection was normalised but never tested, so the swap was
unguarded; a right-to-left drag is an ordinary gesture and Qt reports the
anchor after the cursor. normalise() now swaps and clamps in one place.
- A blank line inside a quoted range produced "> " with trailing whitespace,
which editors and mail clients strip anyway. It is written bare.
Two further defects came out of review:
- quote()'s selectionStart was unasserted for any block not starting at line
zero. Hardcoding it to 0 passed all nineteen tests, because the one test
naming the property quoted the first line, where right and wrong coincide.
A wrong selection there means a second press quotes a line the user never
selected, and a following Bold bolds the wrong text.
- A selection splitting a surrogate pair split the character across the
inserted tokens, leaving invalid UTF-16. Not reachable from the toolbar,
where arrow keys and mouse hit-testing both move in whole clusters, but
reachable by any code computing a position arithmetically. normalise()
nudges off a low surrogate; a collapsed cursor moves back on both ends,
since widening would turn "insert an empty pair here" into "wrap the emoji".
The buttons stack rather than toggle: a second Bold press gives ****this****,
and a second Quote press nests. That is what the spec specifies, and the
preserved selection exists so a second press can apply a SECOND token. A
toggle was built during this task at the user's request and reverted on
finding it contradicts the spec at two sites; it is recorded as backlog item
135, where the unanswered question is what replaces bold-then-italic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoaLBowZ6w1JNx6SEhDP1L
Diffstat (limited to 'src/formattoolbar.cpp')
| -rw-r--r-- | src/formattoolbar.cpp | 182 |
1 files changed, 182 insertions, 0 deletions
diff --git a/src/formattoolbar.cpp b/src/formattoolbar.cpp new file mode 100644 index 0000000..565d4af --- /dev/null +++ b/src/formattoolbar.cpp @@ -0,0 +1,182 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. <danix@danix.xyz> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#include "formattoolbar.h" + +#include <QStringList> + + +namespace { + +/// Normalises the selection a widget reports into an ordered, in-range pair +/// that does not split a character. +/// +/// Three hazards, handled once rather than per function. A backwards drag +/// reports the anchor AFTER the cursor; a selection can outlive the edit that +/// shortened the buffer under it; and a boundary can land in the middle of a +/// surrogate pair, where inserting a token splits one character into two +/// halves and the result is not valid UTF-16 at all. +/// +/// The surrogate case is not reachable with an arrow key or the mouse, which +/// move in whole clusters, but QTextCursor::setPosition accepts such a +/// position, so any caller computing one arithmetically can produce it: a +/// draft restore, a find/replace, a template insertion. A boundary sitting on +/// a LOW surrogate is inside a pair, and moving it back by one puts it before +/// the whole character. +/// +/// A COLLAPSED cursor moves back, not outward: nudging the two ends in +/// opposite directions would turn an empty selection into a two-unit one and +/// wrap a character the user never selected. A real selection widens, so that +/// touching any part of a character covers the whole of it. +void normalise(const QString &text, int &from, int &to) +{ + from = qBound(0, from, int(text.size())); + to = qBound(0, to, int(text.size())); + if (from > to) + qSwap(from, to); + + const auto insidePair = [&text](int at) { + return at < text.size() && text.at(at).isLowSurrogate(); + }; + + if (from == to) { + if (insidePair(from)) { + --from; + to = from; + } + return; + } + + if (insidePair(from)) + --from; + if (insidePair(to)) + ++to; +} + +} // namespace + +MarkdownFormat::Edit MarkdownFormat::wrap(const QString &text, int start, + int end, const QString &token) +{ + Edit edit; + int from = start; + int to = end; + normalise(text, from, to); + + edit.text = text; + // The closing token first: inserting at `from` would shift `to`. + edit.text.insert(to, token); + edit.text.insert(from, token); + + if (from == to) { + // No selection: the cursor goes BETWEEN the two tokens so typing + // continues inside them. Landing after the closing token instead is + // the mistake a user notices on the first keystroke. + edit.selectionStart = from + token.size(); + edit.selectionEnd = edit.selectionStart; + } else { + // The selection is preserved so a second press applies a second token + // to the same words without reselecting: bold then italic. + edit.selectionStart = from + token.size(); + edit.selectionEnd = to + token.size(); + } + + return edit; +} + +MarkdownFormat::Edit MarkdownFormat::link(const QString &text, int start, int end) +{ + Edit edit; + int from = start; + int to = end; + normalise(text, from, to); + + const QString label = text.mid(from, to - from); + + edit.text = text; + edit.text.replace(from, to - from, QStringLiteral("[%1]()").arg(label)); + + if (label.isEmpty()) { + // Nothing selected: the label is what gets typed first, so the cursor + // goes inside the brackets, one past the '['. + edit.selectionStart = from + 1; + } else { + // The label is written; the URL is what remains, so the cursor goes + // inside the parentheses: past '[', the label, ']' and '('. + edit.selectionStart = from + label.size() + 3; + } + edit.selectionEnd = edit.selectionStart; + + return edit; +} + +MarkdownFormat::Edit MarkdownFormat::quote(const QString &text, int start, int end) +{ + Edit edit; + int from = start; + int to = end; + normalise(text, from, to); + + // Line-based, not a wrap. The selection is widened to whole lines first: + // quoting half a line produces markdown that means something else. + // + // The backwards search starts at `from - 1`, not at `from`. QString's + // lastIndexOf INCLUDES the position it is given, so a cursor sitting at + // the end of a line, immediately before its newline, would find that + // newline and quote the FOLLOWING line instead of the one the cursor is + // on. The guard against a negative position matters too, since -1 means + // "search from the end" and would find the last newline in the buffer. + const int firstLineStart = + from > 0 ? text.lastIndexOf(QLatin1Char('\n'), from - 1) + 1 : 0; + + // No newline after the last line, so the end of the text is the end of + // the block. Without this the whole tail would be dropped. + int lastLineEnd = text.indexOf(QLatin1Char('\n'), to); + if (lastLineEnd < 0) + lastLineEnd = text.size(); + + const QString before = text.left(firstLineStart); + const QString middle = text.mid(firstLineStart, lastLineEnd - firstLineStart); + const QString after = text.mid(lastLineEnd); + + const QStringList lines = middle.split(QLatin1Char('\n')); + + // Nesting rather than toggling, per the spec: a second press deepens the + // quote. There is deliberately no live toggle here, because tracking "my + // text" and "the quote" as separate pieces to make one reversible is + // machinery for a case the user answers by closing the composer. + QStringList result; + result.reserve(lines.size()); + for (const QString &line : lines) { + // A blank line keeps the marker, since that is what continues a quote + // block in markdown, but WITHOUT the trailing space: several editors + // and mail clients strip trailing whitespace, and stripping it from + // "> " leaves ">" anyway, so writing it bare is the same result + // reached deliberately. + result.append(line.isEmpty() ? QStringLiteral(">") + : QStringLiteral("> ") + line); + } + + const QString replacement = result.join(QLatin1Char('\n')); + edit.text = before + replacement + after; + // The quoted block stays selected, so a second press nests it. + edit.selectionStart = firstLineStart; + edit.selectionEnd = firstLineStart + replacement.size(); + + return edit; +} |
