aboutsummaryrefslogtreecommitdiffstats
path: root/src
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
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')
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/formattoolbar.cpp182
-rw-r--r--src/formattoolbar.h76
3 files changed, 259 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index a462ba3..501c276 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -16,6 +16,7 @@ add_library(qtmaildir_lib STATIC
draftstore.cpp
messagesender.cpp
composecontext.cpp
+ formattoolbar.cpp
tagchip.cpp
tagcolors.cpp
savequerydialog.cpp
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;
+}
diff --git a/src/formattoolbar.h b/src/formattoolbar.h
new file mode 100644
index 0000000..d820a88
--- /dev/null
+++ b/src/formattoolbar.h
@@ -0,0 +1,76 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <QString>
+
+/// The markdown transformations behind the composer's formatting toolbar.
+///
+/// Free functions over text and a selection, with no widget anywhere, so the
+/// grammar is tested without a painter. Each one is a transformation over the
+/// SOURCE: nothing about the buffer changes, it stays markdown the user can
+/// also type by hand.
+///
+/// Every function takes the selection as the widget reports it, which means
+/// the anchor may sit AFTER the cursor. Each one normalises with qMin/qMax
+/// rather than requiring the caller to, since a backwards drag is an ordinary
+/// gesture and a caller that forgets would corrupt the buffer silently.
+/// Out-of-range positions are clamped to the text, so a stale selection
+/// cannot index past the end, and a boundary landing INSIDE a surrogate pair
+/// is nudged off it, so a position computed arithmetically cannot split a
+/// character in half.
+///
+/// Neither wrap() nor quote() TOGGLES. A second press stacks another level:
+/// `**this**` becomes `***this***` and `> one` becomes `> > one`. That is the
+/// design, not an omission. The selection is preserved precisely so a second
+/// press can apply a SECOND token to the same words, bold then italic without
+/// reselecting, and a toggle would make that gesture unreachable. A toggle is
+/// wanted eventually and is a spec change rather than a fix; see item 135 in
+/// the backlog for the states it has to distinguish.
+namespace MarkdownFormat {
+
+/// The result of a transformation: the new text and where the selection
+/// should end up.
+struct Edit
+{
+ QString text;
+ int selectionStart = 0;
+ int selectionEnd = 0;
+};
+
+/// Wraps the selection in \p token, or inserts an empty pair with the cursor
+/// BETWEEN the tokens when there is no selection.
+///
+/// The cursor landing between the tokens is the property a user notices
+/// immediately when it is wrong, and it is invisible to a test that only
+/// compares the resulting text.
+Edit wrap(const QString &text, int start, int end, const QString &token);
+
+/// `[text](url)`. With a selection the selected text becomes the label and
+/// the cursor lands inside the empty parentheses, which is where the user has
+/// to type next. With none the cursor lands inside the brackets, since the
+/// label is then what gets typed first.
+Edit link(const QString &text, int start, int end);
+
+/// `> ` on every line the selection touches, including a line the selection
+/// only starts or ends on. Line-based rather than a wrap, so it cannot be
+/// expressed with wrap().
+Edit quote(const QString &text, int start, int end);
+
+} // namespace MarkdownFormat