Merge pull request #151 from Tucos/ws-hist
[quassel.git] / src / uisupport / multilineedit.cpp
1 /***************************************************************************
2  *   Copyright (C) 2005-2015 by the Quassel Project                        *
3  *   devel@quassel-irc.org                                                 *
4  *                                                                         *
5  *   This program is free software; you can redistribute it and/or modify  *
6  *   it under the terms of the GNU General Public License as published by  *
7  *   the Free Software Foundation; either version 2 of the License, or     *
8  *   (at your option) version 3.                                           *
9  *                                                                         *
10  *   This program is distributed in the hope that it will be useful,       *
11  *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
12  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
13  *   GNU General Public License for more details.                          *
14  *                                                                         *
15  *   You should have received a copy of the GNU General Public License     *
16  *   along with this program; if not, write to the                         *
17  *   Free Software Foundation, Inc.,                                       *
18  *   51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.         *
19  ***************************************************************************/
20
21 #include <QApplication>
22 #include <QMenu>
23 #include <QMessageBox>
24 #include <QScrollBar>
25
26 #include "actioncollection.h"
27 #include "bufferview.h"
28 #include "graphicalui.h"
29 #include "multilineedit.h"
30 #include "tabcompleter.h"
31
32 const int leftMargin = 3;
33
34 MultiLineEdit::MultiLineEdit(QWidget *parent)
35     : MultiLineEditParent(parent),
36     _idx(0),
37     _mode(SingleLine),
38     _singleLine(true),
39     _minHeight(1),
40     _maxHeight(5),
41     _scrollBarsEnabled(true),
42     _pasteProtectionEnabled(true),
43     _emacsMode(false),
44     _completionSpace(0),
45     _lastDocumentHeight(-1)
46 {
47     document()->setDocumentMargin(0);
48
49     setAcceptRichText(false);
50 #ifdef HAVE_KDE
51     enableFindReplace(false);
52 #endif
53
54     setMode(SingleLine);
55     setLineWrapEnabled(false);
56     reset();
57
58     // Prevent QTextHtmlImporter::appendNodeText from eating whitespace
59     document()->setDefaultStyleSheet("span { white-space: pre-wrap; }");
60
61     connect(this, SIGNAL(textChanged()), this, SLOT(on_textChanged()));
62
63     _mircColorMap["00"] = "#ffffff";
64     _mircColorMap["01"] = "#000000";
65     _mircColorMap["02"] = "#000080";
66     _mircColorMap["03"] = "#008000";
67     _mircColorMap["04"] = "#ff0000";
68     _mircColorMap["05"] = "#800000";
69     _mircColorMap["06"] = "#800080";
70     _mircColorMap["07"] = "#ffa500";
71     _mircColorMap["08"] = "#ffff00";
72     _mircColorMap["09"] = "#00ff00";
73     _mircColorMap["10"] = "#008080";
74     _mircColorMap["11"] = "#00ffff";
75     _mircColorMap["12"] = "#4169e1";
76     _mircColorMap["13"] = "#ff00ff";
77     _mircColorMap["14"] = "#808080";
78     _mircColorMap["15"] = "#c0c0c0";
79 }
80
81
82 MultiLineEdit::~MultiLineEdit()
83 {
84 }
85
86
87 void MultiLineEdit::setCustomFont(const QFont &font)
88 {
89     setFont(font);
90     updateSizeHint();
91 }
92
93
94 void MultiLineEdit::setMode(Mode mode)
95 {
96     if (mode == _mode)
97         return;
98
99     _mode = mode;
100 }
101
102
103 void MultiLineEdit::setLineWrapEnabled(bool enable)
104 {
105     setLineWrapMode(enable ? WidgetWidth : NoWrap);
106     updateSizeHint();
107 }
108
109
110 void MultiLineEdit::setMinHeight(int lines)
111 {
112     if (lines == _minHeight)
113         return;
114
115     _minHeight = lines;
116     updateSizeHint();
117 }
118
119
120 void MultiLineEdit::setMaxHeight(int lines)
121 {
122     if (lines == _maxHeight)
123         return;
124
125     _maxHeight = lines;
126     updateSizeHint();
127 }
128
129
130 void MultiLineEdit::setScrollBarsEnabled(bool enable)
131 {
132     if (_scrollBarsEnabled == enable)
133         return;
134
135     _scrollBarsEnabled = enable;
136     updateScrollBars();
137 }
138
139
140 void MultiLineEdit::updateScrollBars()
141 {
142     QFontMetrics fm(font());
143     int _maxPixelHeight = fm.lineSpacing() * _maxHeight;
144     if (_scrollBarsEnabled && document()->size().height() > _maxPixelHeight)
145         setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn);
146     else
147         setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
148
149     if (!_scrollBarsEnabled || isSingleLine())
150         setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
151     else
152         setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
153 }
154
155
156 void MultiLineEdit::resizeEvent(QResizeEvent *event)
157 {
158     QTextEdit::resizeEvent(event);
159     updateSizeHint();
160     updateScrollBars();
161 }
162
163
164 void MultiLineEdit::updateSizeHint()
165 {
166     QFontMetrics fm(font());
167     int minPixelHeight = fm.lineSpacing() * _minHeight;
168     int maxPixelHeight = fm.lineSpacing() * _maxHeight;
169     int scrollBarHeight = horizontalScrollBar()->isVisible() ? horizontalScrollBar()->height() : 0;
170
171     // use the style to determine a decent size
172     int h = qMin(qMax((int)document()->size().height() + scrollBarHeight, minPixelHeight), maxPixelHeight) + 2 * frameWidth();
173     QStyleOptionFrameV2 opt;
174     opt.initFrom(this);
175     opt.rect = QRect(0, 0, 100, h);
176     opt.lineWidth = lineWidth();
177     opt.midLineWidth = midLineWidth();
178     opt.state |= QStyle::State_Sunken;
179     QSize s = style()->sizeFromContents(QStyle::CT_LineEdit, &opt, QSize(100, h).expandedTo(QApplication::globalStrut()), this);
180     if (s != _sizeHint) {
181         _sizeHint = s;
182         updateGeometry();
183     }
184 }
185
186
187 QSize MultiLineEdit::sizeHint() const
188 {
189     if (!_sizeHint.isValid()) {
190         MultiLineEdit *that = const_cast<MultiLineEdit *>(this);
191         that->updateSizeHint();
192     }
193     return _sizeHint;
194 }
195
196
197 QSize MultiLineEdit::minimumSizeHint() const
198 {
199     return sizeHint();
200 }
201
202
203 void MultiLineEdit::setEmacsMode(bool enable)
204 {
205     _emacsMode = enable;
206 }
207
208
209 void MultiLineEdit::setSpellCheckEnabled(bool enable)
210 {
211 #ifdef HAVE_KDE
212     setCheckSpellingEnabled(enable);
213 #else
214     Q_UNUSED(enable)
215 #endif
216 }
217
218
219 void MultiLineEdit::setPasteProtectionEnabled(bool enable, QWidget *)
220 {
221     _pasteProtectionEnabled = enable;
222 }
223
224
225 void MultiLineEdit::historyMoveBack()
226 {
227     addToHistory(convertRichtextToMircCodes(), true);
228
229     if (_idx > 0) {
230         _idx--;
231         showHistoryEntry();
232     }
233 }
234
235
236 void MultiLineEdit::historyMoveForward()
237 {
238     addToHistory(convertRichtextToMircCodes(), true);
239
240     if (_idx < _history.count()) {
241         _idx++;
242         if (_idx < _history.count() || _tempHistory.contains(_idx)) // tempHistory might have an entry for idx == history.count() + 1
243             showHistoryEntry();
244         else
245             reset();        // equals clear() in this case
246     }
247     else {
248         addToHistory(convertRichtextToMircCodes());
249         reset();
250     }
251 }
252
253
254 bool MultiLineEdit::addToHistory(const QString &text, bool temporary)
255 {
256     if (text.isEmpty())
257         return false;
258
259     Q_ASSERT(0 <= _idx && _idx <= _history.count());
260
261     if (temporary) {
262         // if an entry of the history is changed, we remember it and show it again at this
263         // position until a line was actually sent
264         // sent lines get appended to the history
265         if (_history.isEmpty() || text != _history[_idx - (int)(_idx == _history.count())]) {
266             _tempHistory[_idx] = text;
267             return true;
268         }
269     }
270     else {
271         if (_history.isEmpty() || text != _history.last()) {
272             _history << text;
273             _tempHistory.clear();
274             return true;
275         }
276     }
277     return false;
278 }
279
280
281 bool MultiLineEdit::event(QEvent *e)
282 {
283     // We need to make sure that global shortcuts aren't eaten
284     if (e->type() == QEvent::ShortcutOverride) {
285         QKeyEvent *event = static_cast<QKeyEvent *>(e);
286         QKeySequence key = QKeySequence(event->key() | event->modifiers());
287         foreach(QAction *action, GraphicalUi::actionCollection()->actions()) {
288             if (action->shortcuts().contains(key)) {
289                 e->ignore();
290                 return false;
291             }
292         }
293     }
294
295     return MultiLineEditParent::event(e);
296 }
297
298
299 void MultiLineEdit::keyPressEvent(QKeyEvent *event)
300 {
301     if (event == QKeySequence::InsertLineSeparator) {
302         if (_mode == SingleLine) {
303             event->accept();
304             on_returnPressed();
305             return;
306         }
307         MultiLineEditParent::keyPressEvent(event);
308         return;
309     }
310
311     switch (event->key()) {
312     case Qt::Key_Up:
313         if (event->modifiers() & Qt::ShiftModifier)
314             break;
315         {
316             event->accept();
317             if (!(event->modifiers() & Qt::ControlModifier)) {
318                 int pos = textCursor().position();
319                 moveCursor(QTextCursor::Up);
320                 if (pos == textCursor().position()) // already on top line -> history
321                     historyMoveBack();
322             }
323             else
324                 historyMoveBack();
325             return;
326         }
327
328     case Qt::Key_Down:
329         if (event->modifiers() & Qt::ShiftModifier)
330             break;
331         {
332             event->accept();
333             if (!(event->modifiers() & Qt::ControlModifier)) {
334                 int pos = textCursor().position();
335                 moveCursor(QTextCursor::Down);
336                 if (pos == textCursor().position()) // already on bottom line -> history
337                     historyMoveForward();
338             }
339             else
340                 historyMoveForward();
341             return;
342         }
343
344     case Qt::Key_Return:
345     case Qt::Key_Enter:
346     case Qt::Key_Select:
347         event->accept();
348         on_returnPressed();
349         return;
350
351     // We don't want to have the tab key react even if no completer is installed
352     case Qt::Key_Tab:
353         event->accept();
354         return;
355
356     default:
357         ;
358     }
359
360     if (_emacsMode) {
361         if (event->modifiers() & Qt::ControlModifier) {
362             switch (event->key()) {
363             // move
364             case Qt::Key_A:
365                 moveCursor(QTextCursor::StartOfLine);
366                 return;
367             case Qt::Key_E:
368                 moveCursor(QTextCursor::EndOfLine);
369                 return;
370             case Qt::Key_F:
371                 moveCursor(QTextCursor::Right);
372                 return;
373             case Qt::Key_B:
374                 moveCursor(QTextCursor::Left);
375                 return;
376
377             // modify
378             case Qt::Key_Y:
379                 paste();
380                 return;
381             case Qt::Key_K:
382                 moveCursor(QTextCursor::EndOfLine, QTextCursor::KeepAnchor);
383                 cut();
384                 return;
385
386             default:
387                 break;
388             }
389         }
390         else if (event->modifiers() & Qt::MetaModifier ||
391                  event->modifiers() & Qt::AltModifier)
392         {
393             switch (event->key()) {
394             case Qt::Key_Right:
395                 moveCursor(QTextCursor::WordRight);
396                 return;
397             case Qt::Key_Left:
398                 moveCursor(QTextCursor::WordLeft);
399                 return;
400             case Qt::Key_F:
401                 moveCursor(QTextCursor::WordRight);
402                 return;
403             case Qt::Key_B:
404                 moveCursor(QTextCursor::WordLeft);
405                 return;
406             case Qt::Key_Less:
407                 moveCursor(QTextCursor::Start);
408                 return;
409             case Qt::Key_Greater:
410                 moveCursor(QTextCursor::End);
411                 return;
412
413             // modify
414             case Qt::Key_D:
415                 moveCursor(QTextCursor::WordRight, QTextCursor::KeepAnchor);
416                 cut();
417                 return;
418
419             case Qt::Key_U: // uppercase word
420                 moveCursor(QTextCursor::WordRight, QTextCursor::KeepAnchor);
421                 textCursor().insertText(textCursor().selectedText().toUpper());
422                 return;
423
424             case Qt::Key_L: // lowercase word
425                 moveCursor(QTextCursor::WordRight, QTextCursor::KeepAnchor);
426                 textCursor().insertText(textCursor().selectedText().toLower());
427                 return;
428
429             case Qt::Key_C:
430             {           // capitalize word
431                 moveCursor(QTextCursor::WordRight, QTextCursor::KeepAnchor);
432                 QString const text = textCursor().selectedText();
433                 textCursor().insertText(text.left(1).toUpper() + text.mid(1).toLower());
434                 return;
435             }
436
437             case Qt::Key_T:
438             {           // transpose words
439                 moveCursor(QTextCursor::StartOfWord);
440                 moveCursor(QTextCursor::EndOfWord, QTextCursor::KeepAnchor);
441                 QString const word1 = textCursor().selectedText();
442                 textCursor().clearSelection();
443                 moveCursor(QTextCursor::WordRight);
444                 moveCursor(QTextCursor::EndOfWord, QTextCursor::KeepAnchor);
445                 QString const word2 = textCursor().selectedText();
446                 if (!word2.isEmpty() && !word1.isEmpty()) {
447                     textCursor().insertText(word1);
448                     moveCursor(QTextCursor::WordLeft);
449                     moveCursor(QTextCursor::WordLeft);
450                     moveCursor(QTextCursor::EndOfWord, QTextCursor::KeepAnchor);
451                     textCursor().insertText(word2);
452                     moveCursor(QTextCursor::WordRight);
453                     moveCursor(QTextCursor::EndOfWord);
454                 }
455                 return;
456             }
457
458             default:
459                 break;
460             }
461         }
462     }
463
464 #ifdef HAVE_KDE
465     KTextEdit::keyPressEvent(event);
466 #else
467     QTextEdit::keyPressEvent(event);
468 #endif
469 }
470
471
472 QString MultiLineEdit::convertRichtextToMircCodes()
473 {
474     bool underline, bold, italic, color;
475     QString mircText, mircFgColor, mircBgColor;
476     QTextCursor cursor = textCursor();
477     QTextCursor peekcursor = textCursor();
478     cursor.movePosition(QTextCursor::Start);
479
480     underline = bold = italic = color = false;
481
482     while (cursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor)) {
483         if (cursor.selectedText() == QString(QChar(QChar::LineSeparator))
484             || cursor.selectedText() == QString(QChar(QChar::ParagraphSeparator))) {
485             if (color) {
486                 color = false;
487                 mircText.append('\x03');
488             }
489             if (underline) {
490                 underline = false;
491                 mircText.append('\x1f');
492             }
493             if (italic) {
494                 italic = false;
495                 mircText.append('\x1d');
496             }
497             if (bold) {
498                 bold = false;
499                 mircText.append('\x02');
500             }
501             mircText.append('\n');
502         }
503         else {
504             if (!bold && cursor.charFormat().font().bold()) {
505                 bold = true;
506                 mircText.append('\x02');
507             }
508             if (!italic && cursor.charFormat().fontItalic()) {
509                 italic = true;
510                 mircText.append('\x1d');
511             }
512             if (!underline && cursor.charFormat().fontUnderline()) {
513                 underline = true;
514                 mircText.append('\x1f');
515             }
516             if (!color && (cursor.charFormat().foreground().isOpaque() || cursor.charFormat().background().isOpaque())) {
517                 color = true;
518                 mircText.append('\x03');
519                 mircFgColor = _mircColorMap.key(cursor.charFormat().foreground().color().name());
520                 mircBgColor = _mircColorMap.key(cursor.charFormat().background().color().name());
521
522                 if (mircFgColor.isEmpty()) {
523                     mircFgColor = "01"; //use black if the current foreground color can't be converted
524                 }
525
526                 mircText.append(mircFgColor);
527                 if (cursor.charFormat().background().isOpaque())
528                     mircText.append("," + mircBgColor);
529             }
530
531             mircText.append(cursor.selectedText());
532
533             peekcursor.setPosition(cursor.position());
534             peekcursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor);
535
536             if (mircCodesChanged(cursor, peekcursor)) {
537                 if (color) {
538                     color = false;
539                     mircText.append('\x03');
540                 }
541                 if (underline) {
542                     underline = false;
543                     mircText.append('\x1f');
544                 }
545                 if (italic) {
546                     italic = false;
547                     mircText.append('\x1d');
548                 }
549                 if (bold) {
550                     bold = false;
551                     mircText.append('\x02');
552                 }
553             }
554         }
555
556         cursor.clearSelection();
557     }
558
559     if (color)
560         mircText.append('\x03');
561
562     if (underline)
563         mircText.append('\x1f');
564
565     if (italic)
566         mircText.append('\x1d');
567
568     if (bold)
569         mircText.append('\x02');
570
571     return mircText;
572 }
573
574
575 bool MultiLineEdit::mircCodesChanged(QTextCursor &cursor, QTextCursor &peekcursor)
576 {
577     bool changed = false;
578     if (cursor.charFormat().font().bold() != peekcursor.charFormat().font().bold())
579         changed = true;
580     if (cursor.charFormat().fontItalic() != peekcursor.charFormat().fontItalic())
581         changed = true;
582     if (cursor.charFormat().fontUnderline() != peekcursor.charFormat().fontUnderline())
583         changed = true;
584     if (cursor.charFormat().foreground().color() != peekcursor.charFormat().foreground().color())
585         changed = true;
586     if (cursor.charFormat().background().color() != peekcursor.charFormat().background().color())
587         changed = true;
588     return changed;
589 }
590
591
592 QString MultiLineEdit::convertMircCodesToHtml(const QString &text)
593 {
594     QStringList words;
595     QRegExp mircCode = QRegExp("(\ 2|\1d|\1f|\ 3)", Qt::CaseSensitive);
596
597     int posLeft = 0;
598     int posRight = 0;
599
600     for (;;) {
601         posRight = mircCode.indexIn(text, posLeft);
602
603         if (posRight < 0) {
604             words << text.mid(posLeft);
605             break; // no more mirc color codes
606         }
607
608         if (posLeft < posRight) {
609             words << text.mid(posLeft, posRight - posLeft);
610             posLeft = posRight;
611         }
612
613         posRight = text.indexOf(mircCode.cap(), posRight + 1);
614         words << text.mid(posLeft, posRight + 1 - posLeft);
615         posLeft = posRight + 1;
616     }
617
618     for (int i = 0; i < words.count(); i++) {
619         QString style;
620         if (words[i].contains('\x02')) {
621             style.append(" font-weight:600;");
622             words[i].replace('\x02', "");
623         }
624         if (words[i].contains('\x1d')) {
625             style.append(" font-style:italic;");
626             words[i].replace('\x1d', "");
627         }
628         if (words[i].contains('\x1f')) {
629             style.append(" text-decoration: underline;");
630             words[i].replace('\x1f', "");
631         }
632         if (words[i].contains('\x03')) {
633             int pos = words[i].indexOf('\x03');
634             int len = 3;
635             QString fg = words[i].mid(pos + 1, 2);
636             QString bg;
637             if (words[i][pos+3] == ',')
638                 bg = words[i].mid(pos+4, 2);
639
640             style.append(" color:");
641             style.append(_mircColorMap[fg]);
642             style.append(";");
643
644             if (!bg.isEmpty()) {
645                 style.append(" background-color:");
646                 style.append(_mircColorMap[bg]);
647                 style.append(";");
648                 len = 6;
649             }
650             words[i].replace(pos, len, "");
651             words[i].replace('\x03', "");
652         }
653         words[i].replace("&", "&amp;");
654         words[i].replace("<", "&lt;");
655         words[i].replace(">", "&gt;");
656         words[i].replace("\"", "&quot;");
657         if (style.isEmpty()) {
658             words[i] = "<span>" + words[i] + "</span>";
659         }
660         else {
661             words[i] = "<span style=\"" + style + "\">" + words[i] + "</span>";
662         }
663     }
664     return words.join("").replace("\n", "<br />");
665 }
666
667
668 void MultiLineEdit::on_returnPressed()
669 {
670     on_returnPressed(convertRichtextToMircCodes());
671 }
672
673
674 void MultiLineEdit::on_returnPressed(QString text)
675 {
676     if (_completionSpace && text.endsWith(" ")) {
677         text.chop(1);
678     }
679
680     if (!text.isEmpty()) {
681         foreach(const QString &line, text.split('\n', QString::SkipEmptyParts)) {
682             if (line.isEmpty())
683                 continue;
684             addToHistory(line);
685             emit textEntered(line);
686         }
687         reset();
688         _tempHistory.clear();
689     }
690     else {
691         emit noTextEntered();
692     }
693 }
694
695
696 void MultiLineEdit::on_textChanged()
697 {
698     _completionSpace = qMax(_completionSpace - 1, 0);
699
700     QString newText = text();
701     newText.replace("\r\n", "\n");
702     newText.replace('\r', '\n');
703     if (_mode == SingleLine) {
704         if (!pasteProtectionEnabled())
705             newText.replace('\n', ' ');
706         else if (newText.contains('\n')) {
707             QStringList lines = newText.split('\n', QString::SkipEmptyParts);
708             clear();
709
710             if (lines.count() >= 4) {
711                 QString msg = tr("Do you really want to paste %n line(s)?", "", lines.count());
712                 msg += "<p>";
713                 for (int i = 0; i < 4; i++) {
714 #if QT_VERSION < 0x050000
715                     msg += Qt::escape(lines[i].left(40));
716 #else
717                     msg += lines[i].left(40).toHtmlEscaped();
718 #endif
719                     if (lines[i].count() > 40)
720                         msg += "...";
721                     msg += "<br />";
722                 }
723                 msg += "...</p>";
724                 QMessageBox question(QMessageBox::NoIcon, tr("Paste Protection"), msg, QMessageBox::Yes|QMessageBox::No);
725                 question.setDefaultButton(QMessageBox::No);
726 #ifdef Q_OS_MAC
727                 question.setWindowFlags(question.windowFlags() | Qt::Sheet);
728 #endif
729                 if (question.exec() != QMessageBox::Yes)
730                     return;
731             }
732
733             foreach(QString line, lines) {
734                 clear();
735                 insert(line);
736                 on_returnPressed();
737             }
738         }
739     }
740
741     _singleLine = (newText.indexOf('\n') < 0);
742
743     if (document()->size().height() != _lastDocumentHeight) {
744         _lastDocumentHeight = document()->size().height();
745         on_documentHeightChanged(_lastDocumentHeight);
746     }
747     updateSizeHint();
748     ensureCursorVisible();
749 }
750
751
752 void MultiLineEdit::on_documentHeightChanged(qreal)
753 {
754     updateScrollBars();
755 }
756
757
758 void MultiLineEdit::reset()
759 {
760     // every time the MultiLineEdit is cleared we also reset history index
761     _idx = _history.count();
762     clear();
763     QTextBlockFormat format = textCursor().blockFormat();
764     format.setLeftMargin(leftMargin); // we want a little space between the frame and the contents
765     textCursor().setBlockFormat(format);
766     updateScrollBars();
767 }
768
769
770 void MultiLineEdit::showHistoryEntry()
771 {
772     // if the user changed the history, display the changed line
773     setHtml(convertMircCodesToHtml(_tempHistory.contains(_idx) ? _tempHistory[_idx] : _history[_idx]));
774     QTextCursor cursor = textCursor();
775     QTextBlockFormat format = cursor.blockFormat();
776     format.setLeftMargin(leftMargin); // we want a little space between the frame and the contents
777     cursor.setBlockFormat(format);
778     cursor.movePosition(QTextCursor::End);
779     setTextCursor(cursor);
780     updateScrollBars();
781 }
782
783
784 void MultiLineEdit::addCompletionSpace()
785 {
786     // Inserting the space emits textChanged, which should not disable removal
787     _completionSpace = 2;
788     insertPlainText(" ");
789 }
790