summaryrefslogtreecommitdiffstats
path: root/src/formattoolbar.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-21 16:13:28 +0200
committerDanilo M. <danix@danix.xyz>2026-08-21 16:13:28 +0200
commit95ae5dfe2df7858ad957b353dc0ae1d7af3b4832 (patch)
treec996833dd3ec82f599607a0168a180169e8e94a0 /src/formattoolbar.cpp
parent2b32350204dfb49089c465856464a043018ca3c6 (diff)
downloadqtmaildir-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.cpp182
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;
+}