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