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