aboutsummaryrefslogtreecommitdiffstats
path: root/src/formattoolbar.cpp
blob: 565d4af6b7d4186f1538b08bf324714f77d1ab0f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
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;
}