1 /*
2  note_editor_view.cpp     MindForger thinking notebook
3 
4  Copyright (C) 2016-2020 Martin Dvorak <martin.dvorak@mindforger.com>
5 
6  This program is free software; you can redistribute it and/or
7  modify it under the terms of the GNU General Public License
8  as published by the Free Software Foundation; either version 2
9  of the License, or (at your option) any later version.
10 
11  This program is distributed in the hope that it will be useful,
12  but WITHOUT ANY WARRANTY; without even the implied warranty of
13  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14  GNU General Public License for more details.
15 
16  You should have received a copy of the GNU General Public License
17  along with this program. If not, see <http://www.gnu.org/licenses/>.
18 */
19 #include "note_editor_view.h"
20 
21 namespace m8r {
22 
23 using namespace std;
24 
caseInsensitiveLessThan(const QString & a,const QString & b)25 inline bool caseInsensitiveLessThan(const QString &a, const QString &b)
26 {
27     return a.compare(b, Qt::CaseInsensitive) < 0;
28 }
29 
NoteEditorView(QWidget * parent)30 NoteEditorView::NoteEditorView(QWidget* parent)
31     : QPlainTextEdit(parent),
32       parent(parent),
33       completedAndSelected(false)
34 {
35     hitCounter = 0;
36 
37     setEditorFont(Configuration::getInstance().getEditorFont());
38     setEditorTabWidth(Configuration::getInstance().getUiEditorTabWidth());
39 
40     // widgets
41     highlighter = new NoteEditHighlight{document()};
42     enableSyntaxHighlighting = Configuration::getInstance().isUiEditorEnableSyntaxHighlighting();
43     tabsAsSpaces = Configuration::getInstance().isUiEditorTabsAsSpaces();
44     tabWidth = Configuration::getInstance().getUiEditorTabWidth();
45     highlighter->setEnabled(enableSyntaxHighlighting);
46     // line numbers
47     lineNumberPanel = new LineNumberPanel{this};
48     lineNumberPanel->setVisible(showLineNumbers);
49     // autocomplete
50     model = new QStringListModel{this};
51     completer = new QCompleter{this};
52     completer->setWidget(this);
53     // must be in unfiltered mode to show links
54     completer->setCompletionMode(QCompleter::UnfilteredPopupCompletion);
55     completer->setModel(model);
56     completer->setModelSorting(QCompleter::CaseInsensitivelySortedModel);
57     completer->setCaseSensitivity(Qt::CaseInsensitive);
58     completer->setWrapAround(true);
59 
60     // signals
61     QObject::connect(this, SIGNAL(blockCountChanged(int)), this, SLOT(updateLineNumberPanelWidth(int)));
62     QObject::connect(this, SIGNAL(updateRequest(QRect,int)), this, SLOT(updateLineNumberPanel(QRect,int)));
63     QObject::connect(this, SIGNAL(cursorPositionChanged()), this, SLOT(highlightCurrentLine()));
64     QObject::connect(completer, SIGNAL(activated(const QString&)), this, SLOT(insertCompletion(const QString&)));
65     // shortcut signals
66     new QShortcut(
67         QKeySequence(QKeySequence(Qt::CTRL+Qt::Key_Slash)),
68         this, SLOT(slotStartLinkCompletion()));
69 
70     // capabilities
71     setAcceptDrops(true);
72 
73     // show
74     highlightCurrentLine();
75     updateLineNumberPanelWidth(0);
76 }
77 
78 /*
79  * Configuration
80  */
81 
setShowLineNumbers(bool show)82 void NoteEditorView::setShowLineNumbers(bool show)
83 {
84     showLineNumbers = show;
85     lineNumberPanel->setVisible(showLineNumbers);
86 }
87 
setEditorTabWidth(int tabWidth)88 void NoteEditorView::setEditorTabWidth(int tabWidth)
89 {
90     // tab width: 4 or 8
91     QFontMetrics metrics(f);
92     this->tabWidth = tabWidth;
93     setTabStopWidth(tabWidth * metrics.width(' '));
94 }
95 
setEditorTabsAsSpacesPolicy(bool tabsAsSpaces)96 void NoteEditorView::setEditorTabsAsSpacesPolicy(bool tabsAsSpaces)
97 {
98     this->tabsAsSpaces = tabsAsSpaces;
99 }
100 
setEditorFont(std::string fontName)101 void NoteEditorView::setEditorFont(std::string fontName)
102 {
103     QFont editorFont;
104     QString qFontName = QString::fromStdString(fontName);
105     if(QString::compare(qFontName, "")==0) { // No font defined, set to default
106         editorFont = QFontDatabase::systemFont(QFontDatabase::FixedFont);
107         Configuration::getInstance().setEditorFont(editorFont.toString().toUtf8().constData());
108     } else {
109         editorFont.fromString(qFontName);
110         setFont(editorFont);
111     }
112 
113 }
114 
slotConfigurationUpdated()115 void NoteEditorView::slotConfigurationUpdated()
116 {
117     enableSyntaxHighlighting = Configuration::getInstance().isUiEditorEnableSyntaxHighlighting();
118     highlighter->setEnabled(enableSyntaxHighlighting);
119 
120     setEditorTabWidth(Configuration::getInstance().getUiEditorTabWidth());
121     setEditorTabsAsSpacesPolicy(Configuration::getInstance().isUiEditorTabsAsSpaces());
122     setEditorFont(Configuration::getInstance().getEditorFont());
123 }
124 
125 /**
126   * Drag & drop
127   */
128 
dropEvent(QDropEvent * event)129 void NoteEditorView::dropEvent(QDropEvent* event)
130 {
131 #if defined(__APPLE__) || defined(_WIN32)
132     if(event->mimeData()->text().size())
133     {
134         MF_DEBUG("D&D drop event: '" << event->mimeData()->text().toStdString() << "'" << endl);
135         signalDnDropUrl(event->mimeData()->text().replace("file:///",""));
136     }
137 #else
138     if(event->mimeData()->hasUrls()
139          &&
140        event->mimeData()->hasFormat("text/plain")
141          &&
142        event->mimeData()->urls().size())
143     {
144         MF_DEBUG("D&D drop: '" << event->mimeData()->urls().first().url().trimmed().toStdString() << "'" << endl);
145         signalDnDropUrl(event->mimeData()->urls().first().url().replace("file://",""));
146     }
147 #endif
148 
149     event->acceptProposedAction();
150 }
151 
dragMoveEvent(QDragMoveEvent * event)152 void NoteEditorView::dragMoveEvent(QDragMoveEvent* event)
153 {
154     // needed to protect text cursor functionality after drop
155     event->acceptProposedAction();
156 }
157 
158 /*
159  * Formatting
160  */
161 
wrapSelectedText(const QString & tag,const QString & endTag)162 void NoteEditorView::wrapSelectedText(const QString &tag, const QString &endTag)
163 {
164     QTextCursor cursor = textCursor();
165     QTextDocument *doc = document();
166     int start = cursor.selectionStart();
167     int end = cursor.selectionEnd();
168     if(cursor.hasSelection() && doc->findBlock(start) == doc->findBlock(end)) {
169         cursor.beginEditBlock();
170         QString text = cursor.selectedText();
171         text.prepend(tag);
172         if(endTag.size()) text.append(endTag); else text.append(tag);
173         cursor.insertText(text);
174         cursor.endEditBlock();
175         cursor.setPosition(start + tag.length());
176         cursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, end - start);
177         setTextCursor(cursor);
178     } else if(!cursor.hasSelection()) {
179         if(endTag.size()) {
180             cursor.insertText(tag+endTag);
181             cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor, endTag.length());
182         } else {
183             cursor.insertText(tag+tag);
184             cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor, tag.length());
185         }
186         setTextCursor(cursor);
187     }
188 
189     setFocus();
190 }
191 
insertMarkdownText(const QString & text,bool newLine,int offset)192 void NoteEditorView::insertMarkdownText(const QString &text, bool newLine, int offset)
193 {
194     QTextCursor cursor = textCursor();
195     if(cursor.hasSelection()) {
196         cursor.clearSelection();
197     }
198     if(newLine) {
199         cursor.movePosition(QTextCursor::StartOfLine);
200         cursor.movePosition(QTextCursor::Down);
201     }
202     cursor.insertText(text);
203     cursor.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor, text.length()-offset);
204     setTextCursor(cursor);
205 
206     setFocus();
207 }
208 
209 /*
210  * Associations
211  */
212 
getRelevantWords() const213 QString NoteEditorView::getRelevantWords() const
214 {
215     // IMPROVE get whole line and cut word on which is curser and it before/after siblings: return textCursor().block().text(); ...
216     QString result{};
217     if(textCursor().block().text().size()) {
218         QString t = textCursor().block().text();
219         int c = textCursor().positionInBlock();
220         if(t[c]!=' ') {
221             // extend c to LEFT and to RIGHT
222             for(int i=c-1; i>=0; i--) {
223                 if(t[i]==' ') break;
224                 result.prepend(t[i]);
225             }
226             for(int i=c; i<t.size(); i++) {
227                 if(t[i]==' ') break;
228                 result += t[i];
229             }
230         }
231     }
232     return result;
233     //return textCursor().block().text();
234 }
235 
236 /*
237  * Autocomplete
238  */
239 
keyPressEvent(QKeyEvent * event)240 void NoteEditorView::keyPressEvent(QKeyEvent* event)
241 {
242     hitCounter++;
243 
244     // TODO Linux paste
245 
246     if(event->modifiers() & Qt::ControlModifier) {
247         switch (event->key()) {
248         case Qt::Key_V: {
249             // TODO make this private function
250             QClipboard* clip = QApplication::clipboard();
251             const QMimeData* mime = clip->mimeData();
252             if(mime->hasImage()) {
253                 MF_DEBUG("Image PASTED to editor" << endl);
254                 QImage image = qvariant_cast<QImage>(mime->imageData());
255                 emit signalPasteImageData(image);
256                 return;
257             }
258             break;
259         }
260         case Qt::Key_F: {
261             findStringAgain();
262             return; // exit to override default key binding
263         }
264         }
265     }
266 
267     // IMPROVE get configuration reference and editor mode setting - this must be fast
268     if(Configuration::getInstance().getEditorKeyBinding()==Configuration::EditorKeyBindingMode::EMACS) {
269         if(event->modifiers() & Qt::ControlModifier){
270             switch (event->key()) {
271             case Qt::Key_A:
272                 moveCursor(QTextCursor::StartOfLine);
273                 return; // exit to override default key binding
274             }
275         }
276     }
277 
278     if(completedAndSelected && handledCompletedAndSelected(event)) {
279         return;
280     } else {
281         completedAndSelected = false;
282     }
283 
284     if(completer->popup()->isVisible()) {
285         switch(event->key()) {
286             case Qt::Key_Up:
287             case Qt::Key_Down:
288             case Qt::Key_Enter:
289             case Qt::Key_Return:
290             case Qt::Key_Escape:
291                 event->ignore();
292                 return;
293             default:
294                 completer->popup()->hide();
295                 break;
296         }
297     } else {
298         switch(event->key()) {
299             case Qt::Key_Tab:
300             if(tabsAsSpaces) {
301                 insertTab();
302                 return;
303             }
304             break;
305         }
306     }
307 
308     QPlainTextEdit::keyPressEvent(event);
309 
310     // completion: letter must be handled~inserted first - now it's time to autocomplete
311     if(Configuration::getInstance().isUiEditorEnableAutocomplete()) {
312         if(!completer->popup()->isVisible()) {
313             if(blockCount() < Configuration::EDITOR_MAX_AUTOCOMPLETE_LINES) {
314                 QChar k{event->key()};
315                 if(k.isLetter()) {
316                     if(performTextCompletion()) {
317                         event->ignore();
318                     }
319                 }
320             }
321         }
322     }
323 }
324 
mousePressEvent(QMouseEvent * event)325 void NoteEditorView::mousePressEvent(QMouseEvent* event)
326 {
327     if(completedAndSelected) {
328         completedAndSelected = false;
329         QTextCursor cursor = textCursor();
330         cursor.removeSelectedText();
331         setTextCursor(cursor);
332     }
333     QPlainTextEdit::mousePressEvent(event);
334 }
335 
handledCompletedAndSelected(QKeyEvent * event)336 bool NoteEditorView::handledCompletedAndSelected(QKeyEvent *event)
337 {
338     completedAndSelected = false;
339 
340     QTextCursor cursor = textCursor();
341     switch(event->key()) {
342     case Qt::Key_Space:
343         case Qt::Key_Enter:
344         case Qt::Key_Return:
345             cursor.clearSelection();
346             break;
347         case Qt::Key_Escape:
348             cursor.removeSelectedText();
349             break;
350         default:
351             return false;
352     }
353 
354     setTextCursor(cursor);
355     event->accept();
356     return true;
357 }
358 
getCompletionPrefix()359 const QString NoteEditorView::getCompletionPrefix()
360 {
361     QTextCursor cursor = textCursor();
362     cursor.select(QTextCursor::WordUnderCursor);
363     const QString completionPrefix = cursor.selectedText();
364     if(!completionPrefix.isEmpty() && completionPrefix.at(completionPrefix.length()-1).isLetter()) {
365         return completionPrefix;
366     } else {
367         return QString{};
368     }
369 }
370 
performTextCompletion()371 bool NoteEditorView::performTextCompletion()
372 {
373     const QString completionPrefix = getCompletionPrefix();
374     if(!completionPrefix.isEmpty()) {
375         performTextCompletion(completionPrefix);
376         return true;
377     }
378 
379     return false;
380 }
381 
performTextCompletion(const QString & completionPrefix)382 void NoteEditorView::performTextCompletion(const QString& completionPrefix)
383 {
384     MF_DEBUG("Completing prefix: '" << completionPrefix.toStdString() << "'" << endl);
385 
386     // TODO model population is SLOW, don't do it after each hit, but e.g. when user does NOT write
387     populateModel(completionPrefix);
388 
389     completer->setCompletionMode(QCompleter::PopupCompletion);
390     if(completionPrefix != completer->completionPrefix()) {
391         completer->setCompletionPrefix(completionPrefix);
392         completer->popup()->setCurrentIndex(completer->completionModel()->index(0, 0));
393     }
394 
395     // do NOT complete inline - it completes what user doesn't know and is bothering
396     //if(completer->completionCount() == 1) {
397     //    insertCompletion(completer->currentCompletion(), true);
398     //} else {
399         QRect rect = cursorRect();
400         rect.setWidth(
401             completer->popup()->sizeHintForColumn(0) +
402             completer->popup()->verticalScrollBar()->sizeHint().width());
403         completer->complete(rect);
404     //}
405 }
406 
slotStartLinkCompletion()407 void NoteEditorView::slotStartLinkCompletion()
408 {
409     const QString completionPrefix = getCompletionPrefix();
410     if(!completionPrefix.isEmpty()) {
411         // ask mind (via Orloj) for links > editor gets signal whose handlings opens completion dialog
412         emit signalGetLinksForPattern(completionPrefix);
413     }
414 }
415 
slotPerformLinkCompletion(const QString & completionPrefix,vector<string> * links)416 void NoteEditorView::slotPerformLinkCompletion(
417     const QString& completionPrefix,
418     vector<string>* links)
419 {
420     MF_DEBUG("Completing prefix: '" << completionPrefix.toStdString() << "' w/ " << links->size() << " links" << endl);
421 
422     if(!links->empty()) {
423         // populate model for links
424         QStringList linksAsStrings{};
425         for(string& s:*links) {
426             linksAsStrings.append(QString::fromStdString(s));
427         }
428         // IMPROVE sort links so that they are the most relevant
429         model->setStringList(linksAsStrings);
430         delete links;
431 
432         // perform completion
433         int COMPLETER_POPUP_WIDTH=fontMetrics().averageCharWidth()*50;
434         completer->setCompletionMode(QCompleter::UnfilteredPopupCompletion);
435         completer->popup()->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOn);
436         completer->popup()->setMaximumWidth(COMPLETER_POPUP_WIDTH);
437         completer->popup()->setMinimumHeight(fontMetrics().height()*3);
438         if(completionPrefix != completer->completionPrefix()) {
439             completer->setCompletionPrefix(completionPrefix);
440             completer->popup()->setCurrentIndex(completer->completionModel()->index(0, 0));
441         }
442         QRect rect = cursorRect();
443         rect.setWidth(COMPLETER_POPUP_WIDTH);
444         completer->complete(rect);
445     }
446 }
447 
populateModel(const QString & completionPrefix)448 void NoteEditorView::populateModel(const QString& completionPrefix)
449 {
450     QStringList strings = toPlainText().split(QRegExp{"\\W+"});
451     strings.removeAll(completionPrefix);
452     strings.removeDuplicates();
453     qSort(strings.begin(), strings.end(), caseInsensitiveLessThan);
454     model->setStringList(strings);
455 }
456 
insertCompletion(const QString & completion,bool singleWord)457 void NoteEditorView::insertCompletion(const QString& completion, bool singleWord)
458 {
459     QTextCursor cursor = textCursor();
460 
461     int insertionPosition;
462     if(completion.startsWith("[")) {
463         for(int i=0; i<completer->completionPrefix().length(); i++) {
464             cursor.deletePreviousChar();
465         }
466         // single word completion to be removed (not used, but migth be useful)
467         insertionPosition = cursor.position();
468         cursor.insertText(completion.right(completion.length()));
469     } else {
470         int numberOfCharsToComplete
471             = completion.length() - completer->completionPrefix().length();
472         // single word completion to be removed (not used, but migth be useful)
473         insertionPosition = cursor.position();
474         cursor.insertText(completion.right(numberOfCharsToComplete));
475     }
476 
477     if(singleWord) {
478         cursor.setPosition(insertionPosition);
479         cursor.movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor);
480         completedAndSelected = true;
481     }
482     setTextCursor(cursor);
483 }
484 
insertTab()485 void NoteEditorView::insertTab()
486 {
487     QString completion{};
488     if(tabWidth == 8) {
489         completion.append("        ");
490     } else {
491         completion.append("    ");
492     }
493     QTextCursor cursor = textCursor();
494     cursor.insertText(completion);
495 }
496 
497 /*
498  * L&F
499  */
500 
highlightCurrentLine()501 void NoteEditorView::highlightCurrentLine()
502 {
503     QList<QTextEdit::ExtraSelection> extraSelections;
504     if(!isReadOnly()) {
505         QTextEdit::ExtraSelection selection;
506         QBrush highlightColor = palette().alternateBase();
507         selection.format.setBackground(highlightColor);
508         selection.format.setProperty(QTextFormat::FullWidthSelection, true);
509         selection.cursor = textCursor();
510         selection.cursor.clearSelection();
511         extraSelections += selection;
512 
513         if(isVisible()) {
514             QString m{"  ("};
515             m += QString::number(textCursor().blockNumber());
516             m += ":";
517             m += QString::number(textCursor().positionInBlock());
518             m += ")";
519             statusBar->showInfo(m);
520         }
521     }
522     setExtraSelections(extraSelections);
523 }
524 
525 /*
526  * Line number panel
527  */
528 
lineNumberPanelWidth()529 int NoteEditorView::lineNumberPanelWidth()
530 {
531     if(showLineNumbers) {
532         int digits = 1;
533         int max = qMax(1, blockCount());
534         while(max >= 10) {
535             max /= 10;
536             ++digits;
537         }
538         int space = 3 + fontMetrics().width(QLatin1Char{'9'}) * digits;
539         return space;
540     } else {
541         return 0;
542     }
543 }
544 
updateLineNumberPanelWidth(int newBlockCount)545 void NoteEditorView::updateLineNumberPanelWidth(int newBlockCount)
546 {
547     UNUSED_ARG(newBlockCount);
548 
549     // IMPROVE comment to parameter and ignore macro
550     setViewportMargins(lineNumberPanelWidth(), 0, 0, 0);
551 }
552 
updateLineNumberPanel(const QRect & r,int deltaY)553 void NoteEditorView::updateLineNumberPanel(const QRect& r, int deltaY)
554 {
555     if(showLineNumbers) {
556         if(deltaY) {
557             lineNumberPanel->scroll(0, deltaY);
558         } else {
559             lineNumberPanel->update(0, r.y(), lineNumberPanel->width(), r.height());
560         }
561 
562         if (r.contains(viewport()->rect())) {
563             updateLineNumberPanelWidth(0);
564         }
565     }
566 }
567 
resizeEvent(QResizeEvent * e)568 void NoteEditorView::resizeEvent(QResizeEvent* e)
569 {
570     QPlainTextEdit::resizeEvent(e);
571     QRect contents = contentsRect();
572     lineNumberPanel->setGeometry(QRect(contents.left(), contents.top(), lineNumberPanelWidth(), contents.height()));
573 }
574 
lineNumberPanelPaintEvent(QPaintEvent * event)575 void NoteEditorView::lineNumberPanelPaintEvent(QPaintEvent* event)
576 {
577     QPainter painter(lineNumberPanel);
578     if(!LookAndFeels::getInstance().isThemeNative()) {
579         painter.fillRect(event->rect(), LookAndFeels::getInstance().getEditorLineNumbersBackgroundColor());
580     }
581 
582     QTextBlock block = firstVisibleBlock();
583     int blockNumber = block.blockNumber();
584     int top = static_cast<int>(blockBoundingGeometry(block).translated(contentOffset()).top());
585     int bottom = top + static_cast<int>(blockBoundingRect(block).height());
586 
587     int currentLine = textCursor().blockNumber();
588     while(block.isValid() && top <= event->rect().bottom()) {
589         if (block.isVisible() && bottom >= event->rect().top()) {
590             QString number = QString::number(blockNumber + 1);
591             if(blockNumber == currentLine) {
592                 painter.setPen(QString{"#AA0000"});
593             } else {
594                 painter.setPen(LookAndFeels::getInstance().getEditorLineNumbersForegroundColor());
595             }
596             painter.drawText(0, top, lineNumberPanel->width(), fontMetrics().height(), Qt::AlignCenter, number);
597         }
598 
599         block = block.next();
600         top = bottom;
601         bottom = top + static_cast<int>(blockBoundingRect(block).height());
602         ++blockNumber;
603     }
604 }
605 
606 /*
607  * Search
608  */
609 
findString(const QString s,bool reverse,bool caseSensitive,bool wholeWords)610 void NoteEditorView::findString(const QString s, bool reverse, bool caseSensitive, bool wholeWords)
611 {
612     lastFindString = s;
613     lastFindReverse = reverse;
614     lastCaseSensitive = caseSensitive;
615     lastWholeWords = wholeWords;
616 
617     QTextDocument::FindFlags flag;
618     if(reverse) flag |= QTextDocument::FindBackward;
619     if(caseSensitive) flag |= QTextDocument::FindCaseSensitively;
620     if(wholeWords) flag |= QTextDocument::FindWholeWords;
621 
622     QTextCursor cursor = this->textCursor();
623     QTextCursor cursorSaved = cursor;
624 
625     if(!find(s, flag)) {
626         // nothing is found > jump to start/end
627         cursor.movePosition(reverse?QTextCursor::End:QTextCursor::Start);
628 
629         // the cursor is set at the beginning/end of the document (if search is reverse or not),
630         // in the next "find", if the word is found, now you will change the cursor position
631         setTextCursor(cursor);
632 
633         if(!find(s, flag)) {
634             QMessageBox::information(
635                         this,
636                         tr("Full-text Search Result"),
637                         tr("No matching text found."),
638                         QMessageBox::Ok,
639                         QMessageBox::Ok);
640 
641             // set the cursor back to its initial position
642             setTextCursor(cursorSaved);
643         }
644     }
645 }
646 
findStringAgain()647 void NoteEditorView::findStringAgain()
648 {
649     if(!lastFindString.isEmpty()) {
650         findString(lastFindString, lastFindReverse, lastCaseSensitive, lastWholeWords);
651     }
652 }
653 
654 } // m8r namespace
655