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