1 /** @file abstractlineeditor.cpp  Abstract line editor.
2  *
3  * @authors Copyright (c) 2013-2017 Jaakko Keränen <jaakko.keranen@iki.fi>
4  *
5  * @par License
6  * LGPL: http://www.gnu.org/licenses/lgpl.html
7  *
8  * <small>This program is free software; you can redistribute it and/or modify
9  * it under the terms of the GNU Lesser General Public License as published by
10  * the Free Software Foundation; either version 3 of the License, or (at your
11  * option) any later version. This program is distributed in the hope that it
12  * will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
13  * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
14  * General Public License for more details. You should have received a copy of
15  * the GNU Lesser General Public License along with this program; if not, see:
16  * http://www.gnu.org/licenses</small>
17  */
18 
19 #include "de/shell/AbstractLineEditor"
20 #include "de/shell/Lexicon"
21 #include <de/ConstantRule>
22 
23 #include <QList>
24 #include <QStringList>
25 #include <QScopedPointer>
26 
27 namespace de { namespace shell {
28 
DENG2_PIMPL(AbstractLineEditor)29 DENG2_PIMPL(AbstractLineEditor)
30 {
31     String   prompt;
32     String   text;
33     int      cursor; ///< Index in range [0...text.size()]
34     Lexicon  lexicon;
35     EchoMode echoMode;
36 
37     QScopedPointer<ILineWrapping> wraps;
38 
39     struct Completion {
40         int pos;
41         int size;
42         int ordinal; ///< Ordinal within list of possible completions.
43 
44         void reset() {
45             pos = size = ordinal = 0;
46         }
47         Rangei range() const {
48             return Rangei(pos, pos + size);
49         }
50     };
51     Completion  completion;
52     QStringList suggestions;
53     bool        suggesting;
54     bool        completionNotified;
55 
56     Impl(Public *i, ILineWrapping *lineWraps)
57         : Base(i),
58           cursor(0),
59           echoMode(NormalEchoMode),
60           wraps(lineWraps),
61           suggesting(false),
62           completionNotified(false)
63     {
64         // Initialize line wrapping.
65         completion.reset();
66     }
67 
68     WrappedLine lineSpan(int line) const
69     {
70         DENG2_ASSERT(line < wraps->height());
71         return wraps->line(line);
72     }
73 
74     void rewrapLater()
75     {
76         wraps->clear();
77         self().contentChanged();
78     }
79 
80     void rewrapNow()
81     {
82         updateWraps();
83         self().contentChanged();
84     }
85 
86     /**
87      * Determines where word wrapping needs to occur and updates the height of
88      * the widget to accommodate all the needed lines.
89      */
90     void updateWraps()
91     {
92         wraps->wrapTextToWidth(text, de::max(1, self().maximumWidth()));
93 
94         if (wraps->height() > 0)
95         {
96             self().numberOfLinesChanged(wraps->height());
97         }
98         else
99         {
100             self().numberOfLinesChanged(1);
101         }
102     }
103 
104     de::Vector2i lineCursorPos() const
105     {
106         return linePos(cursor);
107     }
108 
109     de::Vector2i linePos(int mark) const
110     {
111         de::Vector2i pos(mark);
112         for (pos.y = 0; pos.y < wraps->height(); ++pos.y)
113         {
114             WrappedLine span = lineSpan(pos.y);
115             if (!span.isFinal) span.range.end--;
116             if (mark >= span.range.start && mark <= span.range.end)
117             {
118                 // Stop here. Mark is on this line.
119                 break;
120             }
121             pos.x -= span.range.end - span.range.start + 1;
122         }
123         return pos;
124     }
125 
126     /**
127      * Attemps to move the cursor up or down by a line.
128      *
129      * @return @c true, if cursor was moved. @c false, if there were no more
130      * lines available in that direction.
131      */
132     bool moveCursorByLine(int lineOff)
133     {
134         acceptCompletion();
135 
136         DENG2_ASSERT(lineOff == 1 || lineOff == -1);
137 
138         Vector2i const linePos = lineCursorPos();
139         int const destWidth = wraps->rangeWidth(Rangei(lineSpan(linePos.y).range.start, cursor));
140 
141         // Check for no room.
142         if (!linePos.y && lineOff < 0) return false;
143         if (linePos.y == wraps->height() - 1 && lineOff > 0) return false;
144 
145         // Move cursor onto the adjacent line.
146         WrappedLine span = lineSpan(linePos.y + lineOff);
147         cursor = wraps->indexAtWidth(span.range, destWidth);
148         if (!span.isFinal) span.range.end--;
149         if (cursor > span.range.end) cursor = span.range.end;
150 
151         self().cursorMoved();
152         return true;
153     }
154 
155     void insert(String const &str)
156     {
157         acceptCompletion();
158         text.insert(cursor, str);
159         cursor += str.size();
160         rewrapNow();
161     }
162 
163     void doBackspace()
164     {
165         if (rejectCompletion())
166             return;
167 
168         if (!text.isEmpty() && cursor > 0)
169         {
170             text.remove(--cursor, 1);
171             rewrapNow();
172         }
173     }
174 
175     void doWordBackspace()
176     {
177         rejectCompletion();
178 
179         if (!text.isEmpty() && cursor > 0)
180         {
181             int to = wordJumpLeft(cursor);
182             text.remove(to, cursor - to);
183             cursor = to;
184             rewrapNow();
185         }
186     }
187 
188     void doDelete()
189     {
190         if (text.size() > cursor)
191         {
192             text.remove(cursor, 1);
193             rewrapNow();
194         }
195     }
196 
197     bool doLeft()
198     {
199         acceptCompletion();
200 
201         if (cursor > 0)
202         {
203             --cursor;
204             self().cursorMoved();
205             return true;
206         }
207         return false;
208     }
209 
210     bool doRight()
211     {
212         acceptCompletion();
213 
214         if (cursor < text.size())
215         {
216             ++cursor;
217             self().cursorMoved();
218             return true;
219         }
220         return false;
221     }
222 
223     int wordJumpLeft(int pos) const
224     {
225         pos = de::min(pos, text.size() - 1);
226 
227         // First jump over any non-word chars.
228         while (pos > 0 && !text[pos].isLetterOrNumber()) pos--;
229 
230         // At least move one character.
231         if (pos > 0) pos--;
232 
233         // We're inside a word, jump to its beginning.
234         while (pos > 0 && text[pos - 1].isLetterOrNumber()) pos--;
235 
236         return pos;
237     }
238 
239     void doWordLeft()
240     {
241         acceptCompletion();
242         cursor = wordJumpLeft(cursor);
243         self().cursorMoved();
244     }
245 
246     void doWordRight()
247     {
248         int const last = text.size() - 1;
249 
250         acceptCompletion();
251 
252         // If inside a word, jump to its end.
253         while (cursor <= last && text[de::min(last, cursor)].isLetterOrNumber())
254         {
255             cursor++;
256         }
257 
258         // Jump over any non-word chars.
259         while (cursor <= last && !text[de::min(last, cursor)].isLetterOrNumber())
260         {
261             cursor++;
262         }
263 
264         self().cursorMoved();
265     }
266 
267     void doHome()
268     {
269         acceptCompletion();
270 
271         cursor = lineSpan(lineCursorPos().y).range.start;
272         self().cursorMoved();
273     }
274 
275     void doEnd()
276     {
277         acceptCompletion();
278 
279         WrappedLine const span = lineSpan(lineCursorPos().y);
280         cursor = span.range.end - (span.isFinal? 0 : 1);
281         self().cursorMoved();
282     }
283 
284     void killEndOfLine()
285     {
286         text.remove(cursor, lineSpan(lineCursorPos().y).range.end - cursor);
287         rewrapNow();
288     }
289 
290     bool suggestingCompletion() const
291     {
292         return suggesting;
293         //return completion.size > 0;
294     }
295 
296     String wordBehindPos(int pos) const
297     {
298         String word;
299         int i = pos - 1;
300         while (i >= 0 && lexicon.isWordChar(text[i])) word.prepend(text[i--]);
301         return word;
302     }
303 
304     String wordBehindCursor() const
305     {
306         return wordBehindPos(cursor);
307     }
308 
309     QStringList completionsForBase(String base, String &commonPrefix) const
310     {
311         String::CaseSensitivity const sensitivity =
312                 lexicon.isCaseSensitive()? String::CaseSensitive : String::CaseInsensitive;
313 
314         bool first = true;
315         QStringList sugs;
316 
317         foreach (String term, lexicon.terms())
318         {
319             if (term.beginsWith(base, sensitivity) && term.size() > base.size())
320             {
321                 sugs << term;
322 
323                 // Determine if all the suggestions have a common prefix.
324                 if (first)
325                 {
326                     commonPrefix = term;
327                     first = false;
328                 }
329                 else if (!commonPrefix.isEmpty())
330                 {
331                     int len = commonPrefix.commonPrefixLength(term, sensitivity);
332                     commonPrefix = commonPrefix.left(len);
333                 }
334             }
335         }
336         qSort(sugs);
337         return sugs;
338     }
339 
340     bool doCompletion(bool forwardCycle)
341     {
342         if (!suggestingCompletion())
343         {
344             completionNotified = false;
345             String const base = wordBehindCursor();
346             if (!base.isEmpty())
347             {
348                 // Find all the possible completions and apply the first one.
349                 String commonPrefix;
350                 suggestions = completionsForBase(base, commonPrefix);
351                 if (!commonPrefix.isEmpty() && commonPrefix != base)
352                 {
353                     // Insert the common prefix.
354                     completion.ordinal = -1;
355                     commonPrefix.remove(0, base.size());
356                     completion.pos = cursor;
357                     completion.size = commonPrefix.size();
358                     text.insert(cursor, commonPrefix);
359                     cursor += completion.size;
360                     rewrapNow();
361                     suggesting = true;
362                     return true;
363                 }
364                 if (!suggestions.isEmpty())
365                 {
366                     completion.ordinal = -1; //(forwardCycle? 0 : suggestions.size() - 1);
367                     /*String comp = suggestions[completion.ordinal];
368                     comp.remove(0, base.size());*/
369                     completion.pos = cursor;
370                     completion.size = 0; //comp.size();
371                     //text.insert(cursor, comp);
372                     //cursor += completion.size;
373                     //rewrapNow();
374                     suggesting = true;
375                     // Notify immediately.
376                     self().autoCompletionBegan(base);
377                     completionNotified = true;
378                     return true;
379                 }
380             }
381         }
382         else
383         {
384             if (!completionNotified)
385             {
386                 // Time to notify now.
387                 self().autoCompletionBegan(wordBehindPos(completion.pos));
388                 completionNotified = true;
389                 return true;
390             }
391 
392             // Replace the current completion with another suggestion.
393             cursor = completion.pos;
394             String const base = wordBehindCursor();
395 
396             if (completion.ordinal < 0)
397             {
398                 // This occurs after a common prefix is inserted rather than
399                 // a full suggestion.
400                 completion.ordinal = (forwardCycle? 0 : suggestions.size() - 1);
401 
402                 if (base + text.mid(completion.pos, completion.size) == suggestions[completion.ordinal])
403                 {
404                     // We already had this one, skip it.
405                     cycleCompletion(forwardCycle);
406                 }
407             }
408             else
409             {
410                 cycleCompletion(forwardCycle);
411             }
412 
413             String comp = suggestions[completion.ordinal];
414             comp.remove(0, base.size());
415 
416             text.remove(completion.pos, completion.size);
417             text.insert(completion.pos, comp);
418             completion.size = comp.size();
419             cursor = completion.pos + completion.size;
420             rewrapNow();
421 
422             return true;
423         }
424         return false;
425     }
426 
427     void cycleCompletion(bool forwardCycle)
428     {
429         completion.ordinal = de::wrap(completion.ordinal + (forwardCycle? 1 : -1),
430                                       0, suggestions.size());
431     }
432 
433     void resetCompletion()
434     {
435         completion.reset();
436         suggestions.clear();
437         suggesting = false;
438         completionNotified = false;
439     }
440 
441     void acceptCompletion()
442     {
443         if (!suggestingCompletion()) return;
444 
445         resetCompletion();
446 
447         self().autoCompletionEnded(true);
448     }
449 
450     bool rejectCompletion()
451     {
452         if (!suggestingCompletion()) return false;
453 
454         int oldCursor = cursor;
455 
456         text.remove(completion.pos, completion.size);
457         cursor = completion.pos;
458         resetCompletion();
459         rewrapNow();
460 
461         self().autoCompletionEnded(false);
462 
463         return cursor != oldCursor; // cursor was moved as part of the rejection
464     }
465 };
466 
AbstractLineEditor(ILineWrapping * lineWraps)467 AbstractLineEditor::AbstractLineEditor(ILineWrapping *lineWraps) : d(new Impl(this, lineWraps))
468 {}
469 
lineWraps() const470 ILineWrapping const &AbstractLineEditor::lineWraps() const
471 {
472     return *d->wraps;
473 }
474 
setPrompt(String const & promptText)475 void AbstractLineEditor::setPrompt(String const &promptText)
476 {
477     d->prompt = promptText;
478     d->rewrapLater();
479 }
480 
prompt() const481 String AbstractLineEditor::prompt() const
482 {
483     return d->prompt;
484 }
485 
setText(String const & contents)486 void AbstractLineEditor::setText(String const &contents)
487 {
488     d->completion.reset();
489     d->text = contents;
490     d->cursor = contents.size();
491     d->rewrapLater();
492 }
493 
text() const494 String AbstractLineEditor::text() const
495 {
496     return d->text;
497 }
498 
setCursor(int index)499 void AbstractLineEditor::setCursor(int index)
500 {
501     d->completion.reset();
502     d->cursor = index;
503     cursorMoved();
504 }
505 
cursor() const506 int AbstractLineEditor::cursor() const
507 {
508     return d->cursor;
509 }
510 
linePos(int index) const511 Vector2i AbstractLineEditor::linePos(int index) const
512 {
513     return d->linePos(index);
514 }
515 
isSuggestingCompletion() const516 bool AbstractLineEditor::isSuggestingCompletion() const
517 {
518     return d->suggestingCompletion();
519 }
520 
completionRange() const521 Rangei AbstractLineEditor::completionRange() const
522 {
523     return d->completion.range();
524 }
525 
suggestedCompletions() const526 QStringList AbstractLineEditor::suggestedCompletions() const
527 {
528     if (!isSuggestingCompletion()) return QStringList();
529 
530     return d->suggestions;
531 }
532 
acceptCompletion()533 void shell::AbstractLineEditor::acceptCompletion()
534 {
535     d->acceptCompletion();
536 }
537 
setLexicon(Lexicon const & lexicon)538 void AbstractLineEditor::setLexicon(Lexicon const &lexicon)
539 {
540     d->lexicon = lexicon;
541 }
542 
lexicon() const543 Lexicon const &AbstractLineEditor::lexicon() const
544 {
545     return d->lexicon;
546 }
547 
setEchoMode(EchoMode mode)548 void AbstractLineEditor::setEchoMode(EchoMode mode)
549 {
550     d->echoMode = mode;
551 }
552 
echoMode() const553 AbstractLineEditor::EchoMode AbstractLineEditor::echoMode() const
554 {
555     return d->echoMode;
556 }
557 
handleControlKey(int qtKey,KeyModifiers const & mods)558 bool AbstractLineEditor::handleControlKey(int qtKey, KeyModifiers const &mods)
559 {
560 #ifdef MACOSX
561 #  define WORD_JUMP_MODIFIER    Alt
562 #else
563 #  define WORD_JUMP_MODIFIER    Control
564 #endif
565 
566     switch (qtKey)
567     {
568     case Qt::Key_Backspace:
569         if (mods.testFlag(WORD_JUMP_MODIFIER))
570         {
571             d->doWordBackspace();
572         }
573         else
574         {
575             d->doBackspace();
576         }
577         return true;
578 
579     case Qt::Key_Delete:
580         d->doDelete();
581         return true;
582 
583     case Qt::Key_Left:
584 #ifdef MACOSX
585         if (mods.testFlag(Control))
586         {
587             d->doHome();
588             return true;
589         }
590 #endif
591         if (mods.testFlag(WORD_JUMP_MODIFIER))
592         {
593             d->doWordLeft();
594         }
595         else
596         {
597             return d->doLeft();
598         }
599         return true;
600 
601     case Qt::Key_Right:
602 #ifdef MACOSX
603         if (mods.testFlag(Control))
604         {
605             d->doEnd();
606             return true;
607         }
608 #endif
609         if (mods.testFlag(WORD_JUMP_MODIFIER))
610         {
611             d->doWordRight();
612         }
613         else
614         {
615             return d->doRight();
616         }
617         return true;
618 
619     case Qt::Key_Home:
620         d->doHome();
621         return true;
622 
623     case Qt::Key_End:
624         d->doEnd();
625         return true;
626 
627     case Qt::Key_Tab:
628     case Qt::Key_Backtab:
629         if (d->doCompletion(qtKey == Qt::Key_Tab))
630         {
631             return true;
632         }
633         break;
634 
635     case Qt::Key_K:
636         if (mods.testFlag(Control))
637         {
638             d->killEndOfLine();
639             return true;
640         }
641         break;
642 
643     case Qt::Key_Up:
644         // First try moving within the current command.
645         if (!d->moveCursorByLine(-1)) return false; // not eaten
646         return true;
647 
648     case Qt::Key_Down:
649         // First try moving within the current command.
650         if (!d->moveCursorByLine(+1)) return false; // not eaten
651         return true;
652 
653     case Qt::Key_Enter:
654     case Qt::Key_Return:
655         d->acceptCompletion();
656         return true;
657 
658     default:
659         break;
660     }
661 
662     return false;
663 }
664 
insert(String const & text)665 void AbstractLineEditor::insert(String const &text)
666 {
667     return d->insert(text);
668 }
669 
lineWraps()670 ILineWrapping &AbstractLineEditor::lineWraps()
671 {
672     return *d->wraps;
673 }
674 
autoCompletionBegan(String const &)675 void AbstractLineEditor::autoCompletionBegan(String const &)
676 {}
677 
autoCompletionEnded(bool)678 void AbstractLineEditor::autoCompletionEnded(bool /*accepted*/)
679 {}
680 
updateLineWraps(LineWrapUpdateBehavior behavior)681 void AbstractLineEditor::updateLineWraps(LineWrapUpdateBehavior behavior)
682 {
683     if (behavior == WrapUnlessWrappedAlready && !d->wraps->isEmpty())
684         return; // Already wrapped.
685 
686     d->updateWraps();
687 }
688 
689 }} // namespace de::shell
690