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