1 /*
2 * Copyright (C) 2017 Elvis Angelaccio <elvis.angelaccio@kde.org>
3 *
4 * This program is free software; you can redistribute it and/or
5 * modify it under the terms of the GNU General Public License
6 * as published by the Free Software Foundation; either version 3
7 * of the License, or (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <http://www.gnu.org/licenses/>.
16 */
17
18 #include "kchatedit.h"
19
20 #include <QtCore/QDebug>
21 #include <QtGui/QGuiApplication>
22 #include <QtGui/QKeyEvent>
23
24 class KChatEdit::KChatEditPrivate
25 {
26 public:
27 QString getDocumentText(QTextDocument* doc) const;
28 void updateAndMoveInHistory(int increment);
29 void saveInput();
30
makeDocument()31 QTextDocument* makeDocument()
32 {
33 Q_ASSERT(contextKey);
34 return new QTextDocument(contextKey);
35 }
36
setContext(QObject * newContextKey)37 void setContext(QObject* newContextKey)
38 {
39 contextKey = newContextKey;
40 auto& context = contexts[contextKey]; // Create if needed
41 auto& history = context.history;
42 // History always ends with a placeholder that is initially empty
43 // but may be filled with tentative input when the user entered
44 // something and then went out for history.
45 if (history.isEmpty() || !history.last()->isEmpty())
46 history.push_back(makeDocument());
47
48 while (history.size() > maxHistorySize)
49 delete history.takeFirst();
50 index = history.size() - 1;
51
52 // QTextDocuments are parented to the context object, so are destroyed
53 // automatically along with it; but the hashmap should be cleaned up
54 if (newContextKey != q)
55 QObject::connect(newContextKey, &QObject::destroyed, q,
56 [this, newContextKey] {
57 contexts.remove(newContextKey);
58 });
59 Q_ASSERT(contexts.contains(newContextKey) && !history.empty());
60 }
61
62 KChatEdit* q = nullptr;
63 QObject* contextKey = nullptr;
64
65 struct Context {
66 QVector<QTextDocument*> history;
67 QTextDocument* cachedInput = nullptr;
68 };
69 QHash<QObject*, Context> contexts;
70
71 int index = 0;
72 int maxHistorySize = 100;
73 };
74
getDocumentText(QTextDocument * doc) const75 QString KChatEdit::KChatEditPrivate::getDocumentText(QTextDocument* doc) const
76 {
77 Q_ASSERT(doc);
78 return q->acceptRichText() ? doc->toHtml() : doc->toPlainText();
79 }
80
updateAndMoveInHistory(int increment)81 void KChatEdit::KChatEditPrivate::updateAndMoveInHistory(int increment)
82 {
83 Q_ASSERT(contexts.contains(contextKey));
84 auto& history = contexts.find(contextKey)->history;
85 Q_ASSERT(index >= 0 && index < history.size());
86 if (index + increment < 0 || index + increment >= history.size())
87 return; // Prevent stepping out of bounds
88 auto& historyItem = history[index];
89
90 // Only save input if different from the latest one.
91 if (q->document() != historyItem /* shortcut expensive getDocumentText() */
92 && getDocumentText(q->document()) != getDocumentText(historyItem))
93 historyItem = q->document();
94
95 // Fill the input with a copy of the history entry at a new index
96 q->setDocument(history.at(index += increment)->clone(contextKey));
97 q->moveCursor(QTextCursor::End);
98 }
99
saveInput()100 void KChatEdit::KChatEditPrivate::saveInput()
101 {
102 if (q->document()->isEmpty())
103 return;
104
105 Q_ASSERT(contexts.contains(contextKey));
106 auto& history = contexts.find(contextKey)->history;
107 // Only save input if different from the latest one or from the history.
108 const auto input = getDocumentText(q->document());
109 if (index < history.size() - 1
110 && input == getDocumentText(history[index])) {
111 // Take the history entry and move it to the most recent position (but
112 // before the placeholder).
113 history.move(index, history.size() - 2);
114 emit q->savedInputChanged();
115 } else if (input != getDocumentText(q->savedInput())) {
116 // Insert a copy of the edited text just before the placeholder
117 history.insert(history.end() - 1, q->document());
118 q->setDocument(makeDocument());
119
120 if (history.size() >= maxHistorySize) {
121 delete history.takeFirst();
122 }
123 emit q->savedInputChanged();
124 }
125
126 index = history.size() - 1;
127 q->clear();
128 q->setCurrentCharFormat({});
129 }
130
KChatEdit(QWidget * parent)131 KChatEdit::KChatEdit(QWidget *parent)
132 : QTextEdit(parent), d(new KChatEditPrivate)
133 {
134 setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum);
135 connect(this, &QTextEdit::textChanged, this, &QWidget::updateGeometry);
136 d->q = this; // KChatEdit initialization complete, pimpl can use it
137
138 d->setContext(this); // A special context that always exists
139 setDocument(d->makeDocument());
140 }
141
142 KChatEdit::~KChatEdit() = default;
143
savedInput() const144 QTextDocument* KChatEdit::savedInput() const
145 {
146 Q_ASSERT(d->contexts.contains(d->contextKey));
147 auto& history = d->contexts.find(d->contextKey)->history;
148 if (history.size() >= 2)
149 return history.at(history.size() - 2);
150
151 Q_ASSERT(history.size() == 1);
152 return history.front();
153 }
154
saveInput()155 void KChatEdit::saveInput()
156 {
157 d->saveInput();
158 }
159
history() const160 QVector<QTextDocument*> KChatEdit::history() const
161 {
162 Q_ASSERT(d->contexts.contains(d->contextKey));
163 return d->contexts.value(d->contextKey).history;
164 }
165
maxHistorySize() const166 int KChatEdit::maxHistorySize() const
167 {
168 return d->maxHistorySize;
169 }
170
setMaxHistorySize(int newMaxSize)171 void KChatEdit::setMaxHistorySize(int newMaxSize)
172 {
173 if (d->maxHistorySize != newMaxSize) {
174 d->maxHistorySize = newMaxSize;
175 emit maxHistorySizeChanged();
176 }
177 }
178
switchContext(QObject * contextKey)179 void KChatEdit::switchContext(QObject* contextKey)
180 {
181 if (!contextKey)
182 contextKey = this;
183 if (d->contextKey == contextKey)
184 return;
185
186 Q_ASSERT(d->contexts.contains(d->contextKey));
187 d->contexts.find(d->contextKey)->cachedInput =
188 document()->isEmpty() ? nullptr : document();
189 d->setContext(contextKey);
190 auto& cachedInput = d->contexts.find(d->contextKey)->cachedInput;
191 setDocument(cachedInput ? cachedInput : d->makeDocument());
192 moveCursor(QTextCursor::End);
193 emit contextSwitched();
194 }
195
minimumSizeHint() const196 QSize KChatEdit::minimumSizeHint() const
197 {
198 QSize minimumSizeHint = QTextEdit::minimumSizeHint();
199 QMargins margins;
200 margins += static_cast<int>(document()->documentMargin());
201 margins += contentsMargins();
202
203 if (!placeholderText().isEmpty()) {
204 minimumSizeHint.setWidth(int(
205 fontMetrics().boundingRect(placeholderText()).width()
206 + margins.left()*2.5));
207 }
208 if (document()->isEmpty()) {
209 minimumSizeHint.setHeight(fontMetrics().lineSpacing() + margins.top() + margins.bottom());
210 } else {
211 minimumSizeHint.setHeight(int(document()->size().height()));
212 }
213
214 return minimumSizeHint;
215 }
216
sizeHint() const217 QSize KChatEdit::sizeHint() const
218 {
219 ensurePolished();
220
221 if (document()->isEmpty()) {
222 return minimumSizeHint();
223 }
224
225 QMargins margins;
226 margins += static_cast<int>(document()->documentMargin());
227 margins += contentsMargins();
228
229 QSize size = document()->size().toSize();
230 size.rwidth() += margins.left() + margins.right();
231 size.rheight() += margins.top() + margins.bottom();
232
233 // Be consistent with minimumSizeHint().
234 if (document()->lineCount() == 1 && !toPlainText().contains(QLatin1Char('\n'))) {
235 size.setHeight(fontMetrics().lineSpacing() + margins.top() + margins.bottom());
236 }
237
238 return size;
239 }
240
keyPressEvent(QKeyEvent * event)241 void KChatEdit::keyPressEvent(QKeyEvent *event)
242 {
243 if (event->matches(QKeySequence::Copy)) {
244 emit copyRequested();
245 return;
246 }
247
248 switch (event->key()) {
249 case Qt::Key_Enter:
250 case Qt::Key_Return:
251 if (!(QGuiApplication::keyboardModifiers() & Qt::ShiftModifier)) {
252 emit returnPressed();
253 return;
254 }
255 break;
256 case Qt::Key_Up:
257 if (!textCursor().movePosition(QTextCursor::Up)) {
258 d->updateAndMoveInHistory(-1);
259 }
260 break;
261 case Qt::Key_Down:
262 if (!textCursor().movePosition(QTextCursor::Down)) {
263 d->updateAndMoveInHistory(+1);
264 }
265 break;
266 default:
267 break;
268 }
269
270 QTextEdit::keyPressEvent(event);
271 }
272
273