1 #include "widgets/splits/SplitInput.hpp"
2 
3 #include "Application.hpp"
4 #include "controllers/commands/CommandController.hpp"
5 #include "messages/Link.hpp"
6 #include "providers/twitch/TwitchChannel.hpp"
7 #include "providers/twitch/TwitchIrcServer.hpp"
8 #include "singletons/Settings.hpp"
9 #include "singletons/Theme.hpp"
10 #include "util/Clamp.hpp"
11 #include "util/Helpers.hpp"
12 #include "util/LayoutCreator.hpp"
13 #include "widgets/Notebook.hpp"
14 #include "widgets/Scrollbar.hpp"
15 #include "widgets/dialogs/EmotePopup.hpp"
16 #include "widgets/helper/ChannelView.hpp"
17 #include "widgets/helper/EffectLabel.hpp"
18 #include "widgets/helper/ResizingTextEdit.hpp"
19 #include "widgets/splits/InputCompletionPopup.hpp"
20 #include "widgets/splits/Split.hpp"
21 #include "widgets/splits/SplitContainer.hpp"
22 #include "widgets/splits/SplitInput.hpp"
23 
24 #include <QCompleter>
25 #include <QPainter>
26 
27 namespace chatterino {
28 const int TWITCH_MESSAGE_LIMIT = 500;
29 
SplitInput(Split * _chatWidget)30 SplitInput::SplitInput(Split *_chatWidget)
31     : BaseWidget(_chatWidget)
32     , split_(_chatWidget)
33 {
34     this->initLayout();
35 
36     auto completer =
37         new QCompleter(&this->split_->getChannel().get()->completionModel);
38     this->ui_.textEdit->setCompleter(completer);
39 
40     this->split_->channelChanged.connect([this] {
41         auto completer =
42             new QCompleter(&this->split_->getChannel()->completionModel);
43         this->ui_.textEdit->setCompleter(completer);
44     });
45 
46     // misc
47     this->installKeyPressedEvent();
48     this->ui_.textEdit->focusLost.connect([this] {
49         this->hideCompletionPopup();
50     });
51     this->scaleChangedEvent(this->scale());
52 }
53 
initLayout()54 void SplitInput::initLayout()
55 {
56     auto app = getApp();
57     LayoutCreator<SplitInput> layoutCreator(this);
58 
59     auto layout =
60         layoutCreator.setLayoutType<QHBoxLayout>().withoutMargin().assign(
61             &this->ui_.hbox);
62 
63     // input
64     auto textEdit =
65         layout.emplace<ResizingTextEdit>().assign(&this->ui_.textEdit);
66     connect(textEdit.getElement(), &ResizingTextEdit::textChanged, this,
67             &SplitInput::editTextChanged);
68 
69     // right box
70     auto box = layout.emplace<QVBoxLayout>().withoutMargin();
71     box->setSpacing(0);
72     {
73         auto textEditLength =
74             box.emplace<QLabel>().assign(&this->ui_.textEditLength);
75         textEditLength->setAlignment(Qt::AlignRight);
76 
77         box->addStretch(1);
78         box.emplace<EffectLabel>().assign(&this->ui_.emoteButton);
79     }
80 
81     this->ui_.emoteButton->getLabel().setTextFormat(Qt::RichText);
82 
83     // ---- misc
84 
85     // set edit font
86     this->ui_.textEdit->setFont(
87         app->fonts->getFont(FontStyle::ChatMedium, this->scale()));
88     QObject::connect(this->ui_.textEdit, &QTextEdit::cursorPositionChanged,
89                      this, &SplitInput::onCursorPositionChanged);
90     QObject::connect(this->ui_.textEdit, &QTextEdit::textChanged, this,
91                      &SplitInput::onTextChanged);
92 
93     this->managedConnections_.push_back(app->fonts->fontChanged.connect([=]() {
94         this->ui_.textEdit->setFont(
95             app->fonts->getFont(FontStyle::ChatMedium, this->scale()));
96     }));
97 
98     // open emote popup
99     QObject::connect(this->ui_.emoteButton, &EffectLabel::leftClicked, [=] {
100         this->openEmotePopup();
101     });
102 
103     // clear channelview selection when selecting in the input
104     QObject::connect(this->ui_.textEdit, &QTextEdit::copyAvailable,
105                      [this](bool available) {
106                          if (available)
107                          {
108                              this->split_->view_->clearSelection();
109                          }
110                      });
111 
112     // textEditLength visibility
113     getSettings()->showMessageLength.connect(
114         [this](const bool &value, auto) {
115             // this->ui_.textEditLength->setHidden(!value);
116             this->editTextChanged();
117         },
118         this->managedConnections_);
119 }
120 
scaleChangedEvent(float scale)121 void SplitInput::scaleChangedEvent(float scale)
122 {
123     // update the icon size of the emote button
124     this->updateEmoteButton();
125 
126     // set maximum height
127     this->setMaximumHeight(int(150 * this->scale()));
128     this->ui_.textEdit->setFont(
129         getApp()->fonts->getFont(FontStyle::ChatMedium, this->scale()));
130 }
131 
themeChangedEvent()132 void SplitInput::themeChangedEvent()
133 {
134     QPalette palette, placeholderPalette;
135 
136     palette.setColor(QPalette::WindowText, this->theme->splits.input.text);
137     placeholderPalette.setColor(
138         QPalette::PlaceholderText,
139         this->theme->messages.textColors.chatPlaceholder);
140 
141     this->updateEmoteButton();
142     this->ui_.textEditLength->setPalette(palette);
143 
144     this->ui_.textEdit->setPalette(placeholderPalette);
145     this->ui_.textEdit->setStyleSheet(this->theme->splits.input.styleSheet);
146 
147     this->ui_.hbox->setMargin(
148         int((this->theme->isLightTheme() ? 4 : 2) * this->scale()));
149 
150     this->ui_.emoteButton->getLabel().setStyleSheet("color: #000");
151 }
152 
updateEmoteButton()153 void SplitInput::updateEmoteButton()
154 {
155     float scale = this->scale();
156 
157     QString text = "<img src=':/buttons/emote.svg' width='xD' height='xD' />";
158     text.replace("xD", QString::number(int(12 * scale)));
159 
160     if (this->theme->isLightTheme())
161     {
162         text.replace("emote", "emoteDark");
163     }
164 
165     this->ui_.emoteButton->getLabel().setText(text);
166     this->ui_.emoteButton->setFixedHeight(int(18 * scale));
167 }
168 
openEmotePopup()169 void SplitInput::openEmotePopup()
170 {
171     if (!this->emotePopup_)
172     {
173         this->emotePopup_ = new EmotePopup(this);
174         this->emotePopup_->setAttribute(Qt::WA_DeleteOnClose);
175 
176         this->emotePopup_->linkClicked.connect([this](const Link &link) {
177             if (link.type == Link::InsertText)
178             {
179                 QTextCursor cursor = this->ui_.textEdit->textCursor();
180                 QString textToInsert(link.value + " ");
181 
182                 // If symbol before cursor isn't space or empty
183                 // Then insert space before emote.
184                 if (cursor.position() > 0 &&
185                     !this->getInputText()[cursor.position() - 1].isSpace())
186                 {
187                     textToInsert = " " + textToInsert;
188                 }
189                 this->insertText(textToInsert);
190             }
191         });
192     }
193 
194     this->emotePopup_->resize(int(300 * this->emotePopup_->scale()),
195                               int(500 * this->emotePopup_->scale()));
196     this->emotePopup_->loadChannel(this->split_->getChannel());
197     this->emotePopup_->show();
198     this->emotePopup_->activateWindow();
199 }
200 
installKeyPressedEvent()201 void SplitInput::installKeyPressedEvent()
202 {
203     auto app = getApp();
204 
205     this->ui_.textEdit->keyPressed.connect([this, app](QKeyEvent *event) {
206         if (auto popup = this->inputCompletionPopup_.get())
207         {
208             if (popup->isVisible())
209             {
210                 if (popup->eventFilter(nullptr, event))
211                 {
212                     event->accept();
213                     return;
214                 }
215             }
216         }
217 
218         if (event->key() == Qt::Key_Enter || event->key() == Qt::Key_Return)
219         {
220             auto c = this->split_->getChannel();
221             if (c == nullptr)
222                 return;
223 
224             QString message = ui_.textEdit->toPlainText();
225 
226             message = message.replace('\n', ' ');
227             QString sendMessage = app->commands->execCommand(message, c, false);
228 
229             c->sendMessage(sendMessage);
230             // don't add duplicate messages and empty message to message history
231             if ((this->prevMsg_.isEmpty() ||
232                  !this->prevMsg_.endsWith(message)) &&
233                 !message.trimmed().isEmpty())
234             {
235                 this->prevMsg_.append(message);
236             }
237 
238             event->accept();
239             if (!(event->modifiers() & Qt::ControlModifier))
240             {
241                 this->currMsg_ = QString();
242                 this->ui_.textEdit->setPlainText(QString());
243             }
244             this->prevIndex_ = this->prevMsg_.size();
245         }
246         else if (event->key() == Qt::Key_Up)
247         {
248             if ((event->modifiers() & Qt::ShiftModifier) != 0)
249             {
250                 return;
251             }
252             if (event->modifiers() == Qt::AltModifier)
253             {
254                 this->split_->actionRequested.invoke(
255                     Split::Action::SelectSplitAbove);
256             }
257             else
258             {
259                 if (this->prevMsg_.size() && this->prevIndex_)
260                 {
261                     if (this->prevIndex_ == (this->prevMsg_.size()))
262                     {
263                         this->currMsg_ = ui_.textEdit->toPlainText();
264                     }
265 
266                     this->prevIndex_--;
267                     this->ui_.textEdit->setPlainText(
268                         this->prevMsg_.at(this->prevIndex_));
269 
270                     QTextCursor cursor = this->ui_.textEdit->textCursor();
271                     cursor.movePosition(QTextCursor::End);
272                     this->ui_.textEdit->setTextCursor(cursor);
273 
274                     // Don't let the keyboard event propagate further, we've
275                     // handled it
276                     event->accept();
277                 }
278             }
279         }
280         else if (event->key() == Qt::Key_Home)
281         {
282             QTextCursor cursor = this->ui_.textEdit->textCursor();
283             cursor.movePosition(
284                 QTextCursor::Start,
285                 event->modifiers() & Qt::KeyboardModifier::ShiftModifier
286                     ? QTextCursor::MoveMode::KeepAnchor
287                     : QTextCursor::MoveMode::MoveAnchor);
288             this->ui_.textEdit->setTextCursor(cursor);
289 
290             event->accept();
291         }
292         else if (event->key() == Qt::Key_End)
293         {
294             if (event->modifiers() == Qt::ControlModifier)
295             {
296                 this->split_->getChannelView().getScrollBar().scrollToBottom(
297                     getSettings()->enableSmoothScrollingNewMessages.getValue());
298             }
299             else
300             {
301                 QTextCursor cursor = this->ui_.textEdit->textCursor();
302                 cursor.movePosition(
303                     QTextCursor::End,
304                     event->modifiers() & Qt::KeyboardModifier::ShiftModifier
305                         ? QTextCursor::MoveMode::KeepAnchor
306                         : QTextCursor::MoveMode::MoveAnchor);
307                 this->ui_.textEdit->setTextCursor(cursor);
308             }
309             event->accept();
310         }
311         else if (event->key() == Qt::Key_H &&
312                  event->modifiers() == Qt::AltModifier)
313         {
314             // h: vim binding for left
315             this->split_->actionRequested.invoke(
316                 Split::Action::SelectSplitLeft);
317 
318             event->accept();
319         }
320         else if (event->key() == Qt::Key_J &&
321                  event->modifiers() == Qt::AltModifier)
322         {
323             // j: vim binding for down
324             this->split_->actionRequested.invoke(
325                 Split::Action::SelectSplitBelow);
326 
327             event->accept();
328         }
329         else if (event->key() == Qt::Key_K &&
330                  event->modifiers() == Qt::AltModifier)
331         {
332             // k: vim binding for up
333             this->split_->actionRequested.invoke(
334                 Split::Action::SelectSplitAbove);
335 
336             event->accept();
337         }
338         else if (event->key() == Qt::Key_L &&
339                  event->modifiers() == Qt::AltModifier)
340         {
341             // l: vim binding for right
342             this->split_->actionRequested.invoke(
343                 Split::Action::SelectSplitRight);
344 
345             event->accept();
346         }
347         else if (event->key() == Qt::Key_Down)
348         {
349             if ((event->modifiers() & Qt::ShiftModifier) != 0)
350             {
351                 return;
352             }
353             if (event->modifiers() == Qt::AltModifier)
354             {
355                 this->split_->actionRequested.invoke(
356                     Split::Action::SelectSplitBelow);
357             }
358             else
359             {
360                 // If user did not write anything before then just do nothing.
361                 if (this->prevMsg_.isEmpty())
362                 {
363                     return;
364                 }
365                 bool cursorToEnd = true;
366                 QString message = ui_.textEdit->toPlainText();
367 
368                 if (this->prevIndex_ != (this->prevMsg_.size() - 1) &&
369                     this->prevIndex_ != this->prevMsg_.size())
370                 {
371                     this->prevIndex_++;
372                     this->ui_.textEdit->setPlainText(
373                         this->prevMsg_.at(this->prevIndex_));
374                 }
375                 else
376                 {
377                     this->prevIndex_ = this->prevMsg_.size();
378                     if (message == this->prevMsg_.at(this->prevIndex_ - 1))
379                     {
380                         // If user has just come from a message history
381                         // Then simply get currMsg_.
382                         this->ui_.textEdit->setPlainText(this->currMsg_);
383                     }
384                     else if (message != this->currMsg_)
385                     {
386                         // If user are already in current message
387                         // And type something new
388                         // Then replace currMsg_ with new one.
389                         this->currMsg_ = message;
390                     }
391                     // If user is already in current message
392                     // Then don't touch cursos.
393                     cursorToEnd =
394                         (message == this->prevMsg_.at(this->prevIndex_ - 1));
395                 }
396 
397                 if (cursorToEnd)
398                 {
399                     QTextCursor cursor = this->ui_.textEdit->textCursor();
400                     cursor.movePosition(QTextCursor::End);
401                     this->ui_.textEdit->setTextCursor(cursor);
402                 }
403             }
404         }
405         else if (event->key() == Qt::Key_Left)
406         {
407             if (event->modifiers() == Qt::AltModifier)
408             {
409                 this->split_->actionRequested.invoke(
410                     Split::Action::SelectSplitLeft);
411             }
412         }
413         else if (event->key() == Qt::Key_Right)
414         {
415             if (event->modifiers() == Qt::AltModifier)
416             {
417                 this->split_->actionRequested.invoke(
418                     Split::Action::SelectSplitRight);
419             }
420         }
421         else if ((event->key() == Qt::Key_C ||
422                   event->key() == Qt::Key_Insert) &&
423                  event->modifiers() == Qt::ControlModifier)
424         {
425             if (this->split_->view_->hasSelection())
426             {
427                 this->split_->copyToClipboard();
428                 event->accept();
429             }
430         }
431         else if (event->key() == Qt::Key_E &&
432                  event->modifiers() == Qt::ControlModifier)
433         {
434             this->openEmotePopup();
435         }
436         else if (event->key() == Qt::Key_PageUp)
437         {
438             auto &scrollbar = this->split_->getChannelView().getScrollBar();
439             scrollbar.offset(-scrollbar.getLargeChange());
440 
441             event->accept();
442         }
443         else if (event->key() == Qt::Key_PageDown)
444         {
445             auto &scrollbar = this->split_->getChannelView().getScrollBar();
446             scrollbar.offset(scrollbar.getLargeChange());
447 
448             event->accept();
449         }
450     });
451 }
452 
onTextChanged()453 void SplitInput::onTextChanged()
454 {
455     this->updateCompletionPopup();
456 }
457 
onCursorPositionChanged()458 void SplitInput::onCursorPositionChanged()
459 {
460     this->updateCompletionPopup();
461 }
462 
updateCompletionPopup()463 void SplitInput::updateCompletionPopup()
464 {
465     auto channel = this->split_->getChannel().get();
466     auto tc = dynamic_cast<TwitchChannel *>(channel);
467     bool showEmoteCompletion =
468         channel->isTwitchChannel() && getSettings()->emoteCompletionWithColon;
469     bool showUsernameCompletion =
470         tc && getSettings()->showUsernameCompletionMenu;
471     if (!showEmoteCompletion && !showUsernameCompletion)
472     {
473         this->hideCompletionPopup();
474         return;
475     }
476 
477     // check if in completion prefix
478     auto &edit = *this->ui_.textEdit;
479 
480     auto text = edit.toPlainText();
481     auto position = edit.textCursor().position() - 1;
482 
483     if (text.length() == 0 || position == -1)
484     {
485         this->hideCompletionPopup();
486         return;
487     }
488 
489     for (int i = clamp(position, 0, text.length() - 1); i >= 0; i--)
490     {
491         if (text[i] == ' ')
492         {
493             this->hideCompletionPopup();
494             return;
495         }
496         else if (text[i] == ':' && showEmoteCompletion)
497         {
498             if (i == 0 || text[i - 1].isSpace())
499                 this->showCompletionPopup(text.mid(i, position - i + 1).mid(1),
500                                           true);
501             else
502                 this->hideCompletionPopup();
503             return;
504         }
505         else if (text[i] == '@' && showUsernameCompletion)
506         {
507             if (i == 0 || text[i - 1].isSpace())
508                 this->showCompletionPopup(text.mid(i, position - i + 1).mid(1),
509                                           false);
510             else
511                 this->hideCompletionPopup();
512             return;
513         }
514     }
515 
516     this->hideCompletionPopup();
517 }
518 
showCompletionPopup(const QString & text,bool emoteCompletion)519 void SplitInput::showCompletionPopup(const QString &text, bool emoteCompletion)
520 {
521     if (!this->inputCompletionPopup_.get())
522     {
523         this->inputCompletionPopup_ = new InputCompletionPopup(this);
524         this->inputCompletionPopup_->setInputAction(
525             [that = QObjectRef(this)](const QString &text) mutable {
526                 if (auto this2 = that.get())
527                 {
528                     this2->insertCompletionText(text);
529                     this2->hideCompletionPopup();
530                 }
531             });
532     }
533 
534     auto popup = this->inputCompletionPopup_.get();
535     assert(popup);
536 
537     if (emoteCompletion)  // autocomplete emotes
538         popup->updateEmotes(text, this->split_->getChannel());
539     else  // autocomplete usernames
540         popup->updateUsers(text, this->split_->getChannel());
541 
542     auto pos = this->mapToGlobal({0, 0}) - QPoint(0, popup->height()) +
543                QPoint((this->width() - popup->width()) / 2, 0);
544 
545     popup->move(pos);
546     popup->show();
547 }
548 
hideCompletionPopup()549 void SplitInput::hideCompletionPopup()
550 {
551     if (auto popup = this->inputCompletionPopup_.get())
552         popup->hide();
553 }
554 
insertCompletionText(const QString & input_)555 void SplitInput::insertCompletionText(const QString &input_)
556 {
557     auto &edit = *this->ui_.textEdit;
558     auto input = input_ + ' ';
559 
560     auto text = edit.toPlainText();
561     auto position = edit.textCursor().position();
562 
563     for (int i = clamp(position, 0, text.length() - 1); i >= 0; i--)
564     {
565         bool done = false;
566         if (text[i] == ':')
567         {
568             done = true;
569         }
570         else if (text[i] == '@')
571         {
572             const auto userMention =
573                 formatUserMention(input_, edit.isFirstWord(),
574                                   getSettings()->mentionUsersWithComma);
575             input = "@" + userMention + " ";
576             done = true;
577         }
578 
579         if (done)
580         {
581             auto cursor = edit.textCursor();
582             edit.setText(text.remove(i, position - i).insert(i, input));
583 
584             cursor.setPosition(i + input.size());
585             edit.setTextCursor(cursor);
586             break;
587         }
588     }
589 }
590 
clearSelection()591 void SplitInput::clearSelection()
592 {
593     QTextCursor c = this->ui_.textEdit->textCursor();
594 
595     c.setPosition(c.position());
596     c.setPosition(c.position(), QTextCursor::KeepAnchor);
597 
598     this->ui_.textEdit->setTextCursor(c);
599 }
600 
isEditFirstWord() const601 bool SplitInput::isEditFirstWord() const
602 {
603     return this->ui_.textEdit->isFirstWord();
604 }
605 
getInputText() const606 QString SplitInput::getInputText() const
607 {
608     return this->ui_.textEdit->toPlainText();
609 }
610 
insertText(const QString & text)611 void SplitInput::insertText(const QString &text)
612 {
613     this->ui_.textEdit->insertPlainText(text);
614 }
615 
editTextChanged()616 void SplitInput::editTextChanged()
617 {
618     auto app = getApp();
619 
620     // set textLengthLabel value
621     QString text = this->ui_.textEdit->toPlainText();
622 
623     if (text.startsWith("/r ", Qt::CaseInsensitive) &&
624         this->split_->getChannel()->isTwitchChannel())
625     {
626         QString lastUser = app->twitch.server->lastUserThatWhisperedMe.get();
627         if (!lastUser.isEmpty())
628         {
629             this->ui_.textEdit->setPlainText("/w " + lastUser + text.mid(2));
630             this->ui_.textEdit->moveCursor(QTextCursor::EndOfBlock);
631         }
632     }
633     else
634     {
635         this->textChanged.invoke(text);
636 
637         text = text.trimmed();
638         static QRegularExpression spaceRegex("\\s\\s+");
639         text = text.replace(spaceRegex, " ");
640 
641         text =
642             app->commands->execCommand(text, this->split_->getChannel(), true);
643     }
644 
645     QString labelText;
646 
647     if (text.length() > 0 && getSettings()->showMessageLength)
648     {
649         labelText = QString::number(text.length());
650         if (text.length() > TWITCH_MESSAGE_LIMIT)
651         {
652             this->ui_.textEditLength->setStyleSheet("color: red");
653         }
654         else
655         {
656             this->ui_.textEditLength->setStyleSheet("");
657         }
658     }
659     else
660     {
661         labelText = "";
662     }
663 
664     this->ui_.textEditLength->setText(labelText);
665 }
666 
paintEvent(QPaintEvent *)667 void SplitInput::paintEvent(QPaintEvent *)
668 {
669     QPainter painter(this);
670 
671     if (this->theme->isLightTheme())
672     {
673         int s = int(3 * this->scale());
674         QRect rect = this->rect().marginsRemoved(QMargins(s - 1, s - 1, s, s));
675 
676         painter.fillRect(rect, this->theme->splits.input.background);
677 
678         painter.setPen(QColor("#ccc"));
679         painter.drawRect(rect);
680     }
681     else
682     {
683         int s = int(1 * this->scale());
684         QRect rect = this->rect().marginsRemoved(QMargins(s - 1, s - 1, s, s));
685 
686         painter.fillRect(rect, this->theme->splits.input.background);
687 
688         painter.setPen(QColor("#333"));
689         painter.drawRect(rect);
690     }
691 
692     //    int offset = 2;
693     //    painter.fillRect(offset, this->height() - offset, this->width() - 2 *
694     //    offset, 1,
695     //                     getApp()->themes->splits.input.focusedLine);
696 }
697 
resizeEvent(QResizeEvent *)698 void SplitInput::resizeEvent(QResizeEvent *)
699 {
700     if (this->height() == this->maximumHeight())
701     {
702         this->ui_.textEdit->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
703     }
704     else
705     {
706         this->ui_.textEdit->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
707     }
708 }
709 
mousePressEvent(QMouseEvent *)710 void SplitInput::mousePressEvent(QMouseEvent *)
711 {
712     this->split_->giveFocus(Qt::MouseFocusReason);
713 }
714 
715 }  // namespace chatterino
716