913c81e72784bcf06688ea5b8bf7b7fb979286ab
[quassel.git] / src / qtui / chatitem.cpp
1 /***************************************************************************
2  *   Copyright (C) 2005-2019 by the Quassel Project                        *
3  *   devel@quassel-irc.org                                                 *
4  *                                                                         *
5  *   This program is free software; you can redistribute it and/or modify  *
6  *   it under the terms of the GNU General Public License as published by  *
7  *   the Free Software Foundation; either version 2 of the License, or     *
8  *   (at your option) version 3.                                           *
9  *                                                                         *
10  *   This program is distributed in the hope that it will be useful,       *
11  *   but WITHOUT ANY WARRANTY; without even the implied warranty of        *
12  *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the         *
13  *   GNU General Public License for more details.                          *
14  *                                                                         *
15  *   You should have received a copy of the GNU General Public License     *
16  *   along with this program; if not, write to the                         *
17  *   Free Software Foundation, Inc.,                                       *
18  *   51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.         *
19  ***************************************************************************/
20
21 #include "chatitem.h"
22
23 #include <algorithm>
24 #include <iterator>
25
26 #include <QApplication>
27 #include <QClipboard>
28 #include <QDesktopServices>
29 #include <QFontMetrics>
30 #include <QGraphicsSceneMouseEvent>
31 #include <QMenu>
32 #include <QPainter>
33 #include <QPalette>
34 #include <QTextLayout>
35
36 #include "action.h"
37 #include "buffermodel.h"
38 #include "bufferview.h"
39 #include "chatline.h"
40 #include "chatlinemodel.h"
41 #include "chatview.h"
42 #include "contextmenuactionprovider.h"
43 #include "icon.h"
44 #include "mainwin.h"
45 #include "qtui.h"
46 #include "qtuistyle.h"
47
48 ChatItem::ChatItem(const QRectF& boundingRect, ChatLine* parent)
49     : _parent(parent)
50     , _boundingRect(boundingRect)
51     , _selectionMode(NoSelection)
52     , _selectionStart(-1)
53     , _cachedLayout(nullptr)
54 {}
55
56 ChatItem::~ChatItem()
57 {
58     delete _cachedLayout;
59 }
60
61 ChatLine* ChatItem::chatLine() const
62 {
63     return _parent;
64 }
65
66 ChatScene* ChatItem::chatScene() const
67 {
68     return chatLine()->chatScene();
69 }
70
71 ChatView* ChatItem::chatView() const
72 {
73     return chatScene()->chatView();
74 }
75
76 const QAbstractItemModel* ChatItem::model() const
77 {
78     return chatLine()->model();
79 }
80
81 int ChatItem::row() const
82 {
83     return chatLine()->row();
84 }
85
86 QPointF ChatItem::mapToLine(const QPointF& p) const
87 {
88     return p + pos();
89 }
90
91 QPointF ChatItem::mapFromLine(const QPointF& p) const
92 {
93     return p - pos();
94 }
95
96 // relative to the ChatLine
97 QPointF ChatItem::mapToScene(const QPointF& p) const
98 {
99     return chatLine()->mapToScene(p /* + pos() */);
100 }
101
102 QPointF ChatItem::mapFromScene(const QPointF& p) const
103 {
104     return chatLine()->mapFromScene(p) /* - pos() */;
105 }
106
107 QVariant ChatItem::data(int role) const
108 {
109     QModelIndex index = model()->index(row(), column());
110     if (!index.isValid()) {
111         qWarning() << "ChatItem::data(): model index is invalid!" << index;
112         return QVariant();
113     }
114     return model()->data(index, role);
115 }
116
117 QTextLayout* ChatItem::layout() const
118 {
119     if (_cachedLayout)
120         return _cachedLayout;
121
122     _cachedLayout = new QTextLayout;
123     initLayout(_cachedLayout);
124     chatView()->setHasCache(chatLine());
125     return _cachedLayout;
126 }
127
128 void ChatItem::clearCache()
129 {
130     delete _cachedLayout;
131     _cachedLayout = nullptr;
132 }
133
134 void ChatItem::initLayoutHelper(QTextLayout* layout, QTextOption::WrapMode wrapMode, Qt::Alignment alignment) const
135 {
136     Q_ASSERT(layout);
137
138     layout->setText(data(MessageModel::DisplayRole).toString());
139
140     QTextOption option;
141     option.setWrapMode(wrapMode);
142     option.setAlignment(alignment);
143     layout->setTextOption(option);
144
145     UiStyle::FormatContainer formatRanges = QtUi::style()->toTextLayoutList(
146         formatList(),
147         layout->text().length(),
148         data(ChatLineModel::MsgLabelRole).value<UiStyle::MessageLabel>()
149     );
150     UiStyle::setTextLayoutFormats(*layout, formatRanges);
151 }
152
153 void ChatItem::initLayout(QTextLayout* layout) const
154 {
155     initLayoutHelper(layout, QTextOption::NoWrap);
156     doLayout(layout);
157 }
158
159 void ChatItem::doLayout(QTextLayout* layout) const
160 {
161     layout->beginLayout();
162     QTextLine line = layout->createLine();
163     if (line.isValid()) {
164         line.setLineWidth(width());
165         line.setPosition(QPointF(0, 0));
166     }
167     layout->endLayout();
168 }
169
170 UiStyle::FormatList ChatItem::formatList() const
171 {
172     return data(MessageModel::FormatRole).value<UiStyle::FormatList>();
173 }
174
175 qint16 ChatItem::posToCursor(const QPointF& posInLine) const
176 {
177     QPointF pos = mapFromLine(posInLine);
178     if (pos.y() > height())
179         return data(MessageModel::DisplayRole).toString().length();
180     if (pos.y() < 0)
181         return 0;
182
183     for (int l = layout()->lineCount() - 1; l >= 0; l--) {
184         QTextLine line = layout()->lineAt(l);
185         if (pos.y() >= line.y()) {
186             return line.xToCursor(pos.x(), QTextLine::CursorOnCharacter);
187         }
188     }
189     return 0;
190 }
191
192 void ChatItem::paintBackground(QPainter* painter)
193 {
194     QVariant bgBrush;
195     if (_selectionMode == FullSelection)
196         bgBrush = data(ChatLineModel::SelectedBackgroundRole);
197     else
198         bgBrush = data(ChatLineModel::BackgroundRole);
199     if (bgBrush.isValid())
200         painter->fillRect(boundingRect(), bgBrush.value<QBrush>());
201 }
202
203 // NOTE: This is not the most time-efficient implementation, but it saves space by not caching unnecessary data
204 //       This is a deliberate trade-off. (-> selectFmt creation, data() call)
205 void ChatItem::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget)
206 {
207     Q_UNUSED(option);
208     Q_UNUSED(widget);
209     painter->save();
210     painter->setClipRect(boundingRect());
211     paintBackground(painter);
212
213     layout()->draw(painter, pos(), additionalFormats(), boundingRect());
214
215     //  layout()->draw(painter, QPointF(0,0), formats, boundingRect());
216
217     // Debuging Stuff
218     // uncomment partially or all of the following stuff:
219     //
220     // 0) alternativ painter color for debug stuff
221     //   if(row() % 2)
222     //     painter->setPen(Qt::red);
223     //   else
224     //     painter->setPen(Qt::blue);
225     // 1) draw wordwrap points in the first line
226     //   if(column() == 2) {
227     //     ChatLineModel::WrapList wrapList = data(ChatLineModel::WrapListRole).value<ChatLineModel::WrapList>();
228     //     foreach(ChatLineModel::Word word, wrapList) {
229     //       if(word.endX > width())
230     //      break;
231     //       painter->drawLine(word.endX, 0, word.endX, height());
232     //     }
233     //   }
234     // 2) draw MsgId over the time column
235     //   if(column() == 0) {
236     //     QString msgIdString = QString::number(data(MessageModel::MsgIdRole).value<MsgId>().toLongLong());
237     //     QPointF bottomPoint = boundingRect().bottomLeft();
238     //     bottomPoint.ry() -= 2;
239     //     painter->drawText(bottomPoint, msgIdString);
240     //   }
241     // 3) draw bounding rect
242     //   painter->drawRect(_boundingRect.adjusted(0, 0, -1, -1));
243
244     painter->restore();
245 }
246
247 void ChatItem::overlayFormat(UiStyle::FormatList& fmtList, quint16 start, quint16 end, UiStyle::FormatType overlayFmt) const
248 {
249     for (size_t i = 0; i < fmtList.size(); i++) {
250         int fmtStart = fmtList.at(i).first;
251         int fmtEnd = (i < fmtList.size() - 1 ? fmtList.at(i + 1).first : data(MessageModel::DisplayRole).toString().length());
252
253         if (fmtEnd <= start)
254             continue;
255         if (fmtStart >= end)
256             break;
257
258         // split the format if necessary
259         if (fmtStart < start) {
260             fmtList.insert(fmtList.begin() + i, fmtList.at(i));
261             fmtList[++i].first = start;
262         }
263         if (end < fmtEnd) {
264             fmtList.insert(fmtList.begin() + i, fmtList.at(i));
265             fmtList[i + 1].first = end;
266         }
267
268         fmtList[i].second.type |= overlayFmt;
269     }
270 }
271
272 QVector<QTextLayout::FormatRange> ChatItem::additionalFormats() const
273 {
274     // Calculate formats to overlay (only) if there's a selection, and/or a hovered clickable
275     if (!hasSelection() && !hasActiveClickable()) {
276         return {};
277     }
278
279     using Label = UiStyle::MessageLabel;
280     using Format = UiStyle::Format;
281
282     auto itemLabel = data(ChatLineModel::MsgLabelRole).value<Label>();
283     const auto& fmtList = formatList();
284
285     struct LabelFormat
286     {
287         quint16 offset;
288         Format format;
289         Label label;
290     };
291
292     // Transform formatList() into an extended list of LabelFormats
293     std::vector<LabelFormat> labelFmtList;
294     std::transform(fmtList.cbegin(), fmtList.cend(), std::back_inserter(labelFmtList), [itemLabel](const std::pair<quint16, Format>& f) {
295         return LabelFormat{f.first, f.second, itemLabel};
296     });
297     // Append dummy element to avoid special-casing handling the last real format
298     labelFmtList.push_back(LabelFormat{quint16(data(MessageModel::DisplayRole).toString().length()), Format(), itemLabel});
299
300     // Apply the given label to the given range in the format list, splitting formats as necessary
301     auto applyLabel = [&labelFmtList](quint16 start, quint16 end, Label label) {
302         size_t i = 0;
303
304         // Skip unaffected formats
305         for (; i < labelFmtList.size() - 1; ++i) {
306             if (labelFmtList[i + 1].offset > start)
307                 break;
308         }
309         // Range start doesn't align; split affected format and let the index point to the newly inserted copy
310         if (labelFmtList[i].offset < start) {
311             labelFmtList.insert(labelFmtList.begin() + i, labelFmtList[i]);
312             labelFmtList[++i].offset = start;
313         }
314
315         // Apply label to formats fully affected
316         for (; i < labelFmtList.size() - 1; ++i) {
317             if (labelFmtList[i + 1].offset <= end) {
318                 labelFmtList[i].label |= label;
319                 continue;
320             }
321             // Last affected format, split if end of range doesn't align
322             if (labelFmtList[i + 1].offset > end) {
323                 labelFmtList.insert(labelFmtList.begin() + i, labelFmtList[i]);
324                 labelFmtList[i].label |= label;
325                 labelFmtList[i + 1].offset = end;
326             }
327             break;
328         }
329     };
330
331     // Apply selection label
332     if (hasSelection()) {
333         quint16 start, end;
334         if (_selectionMode == FullSelection) {
335             start = 0;
336             end = data(MessageModel::DisplayRole).toString().length();
337         }
338         else {
339             start = qMin(_selectionStart, _selectionEnd);
340             end = qMax(_selectionStart, _selectionEnd);
341         }
342         applyLabel(start, end, Label::Selected);
343     }
344
345     // Apply hovered label
346     if (hasActiveClickable()) {
347         applyLabel(activeClickableRange().first, activeClickableRange().second, Label::Hovered);
348     }
349
350     // Add all formats that have an extra label to the additionalFormats list
351     UiStyle::FormatContainer additionalFormats;
352     for (size_t i = 0; i < labelFmtList.size() - 1; ++i) {
353         if (labelFmtList[i].label != itemLabel) {
354             additionalFormats << QtUi::style()->toTextLayoutList({std::make_pair(labelFmtList[i].offset, labelFmtList[i].format)},
355                                                                    labelFmtList[i + 1].offset,
356                                                                    labelFmtList[i].label);
357         }
358     }
359
360     return UiStyle::containerToVector(additionalFormats);
361 }
362
363 bool ChatItem::hasSelection() const
364 {
365     if (_selectionMode == NoSelection)
366         return false;
367     if (_selectionMode == FullSelection)
368         return true;
369     // partial
370     return _selectionStart != _selectionEnd;
371 }
372
373 QString ChatItem::selection() const
374 {
375     if (_selectionMode == FullSelection)
376         return data(MessageModel::DisplayRole).toString();
377     if (_selectionMode == PartialSelection)
378         return data(MessageModel::DisplayRole).toString().mid(qMin(_selectionStart, _selectionEnd), qAbs(_selectionStart - _selectionEnd));
379     return QString();
380 }
381
382 void ChatItem::setSelection(SelectionMode mode, qint16 start, qint16 end)
383 {
384     _selectionMode = mode;
385     _selectionStart = start;
386     _selectionEnd = end;
387     chatLine()->update();
388 }
389
390 void ChatItem::setFullSelection()
391 {
392     if (_selectionMode != FullSelection) {
393         _selectionMode = FullSelection;
394         chatLine()->update();
395     }
396 }
397
398 void ChatItem::clearSelection()
399 {
400     if (_selectionMode != NoSelection) {
401         _selectionMode = NoSelection;
402         chatLine()->update();
403     }
404 }
405
406 void ChatItem::continueSelecting(const QPointF& pos)
407 {
408     _selectionMode = PartialSelection;
409     _selectionEnd = posToCursor(pos);
410     chatLine()->update();
411 }
412
413 bool ChatItem::isPosOverSelection(const QPointF& pos) const
414 {
415     if (_selectionMode == FullSelection)
416         return true;
417     if (_selectionMode == PartialSelection) {
418         int cursor = posToCursor(pos);
419         return cursor >= qMin(_selectionStart, _selectionEnd) && cursor <= qMax(_selectionStart, _selectionEnd);
420     }
421     return false;
422 }
423
424 bool ChatItem::hasActiveClickable() const
425 {
426     return false;
427 }
428
429 std::pair<quint16, quint16> ChatItem::activeClickableRange() const
430 {
431     return {};
432 }
433
434 QList<QRectF> ChatItem::findWords(const QString& searchWord, Qt::CaseSensitivity caseSensitive)
435 {
436     QList<QRectF> resultList;
437     const QAbstractItemModel* model_ = model();
438     if (!model_)
439         return resultList;
440
441     QString plainText = model_->data(model_->index(row(), column()), MessageModel::DisplayRole).toString();
442     QList<int> indexList;
443     int searchIdx = plainText.indexOf(searchWord, 0, caseSensitive);
444     while (searchIdx != -1) {
445         indexList << searchIdx;
446         searchIdx = plainText.indexOf(searchWord, searchIdx + 1, caseSensitive);
447     }
448
449     foreach (int idx, indexList) {
450         QTextLine line = layout()->lineForTextPosition(idx);
451         qreal x = line.cursorToX(idx);
452         qreal width = line.cursorToX(idx + searchWord.count()) - x;
453         qreal height = line.height();
454         qreal y = height * line.lineNumber();
455         resultList << QRectF(x, y, width, height);
456     }
457
458     return resultList;
459 }
460
461 void ChatItem::handleClick(const QPointF& pos, ChatScene::ClickMode clickMode)
462 {
463     // single clicks are already handled by the scene (for clearing the selection)
464     if (clickMode == ChatScene::DragStartClick) {
465         chatScene()->setSelectingItem(this);
466         _selectionStart = _selectionEnd = posToCursor(pos);
467         _selectionMode = NoSelection;  // will be set to PartialSelection by mouseMoveEvent
468         chatLine()->update();
469     }
470 }
471
472 void ChatItem::mouseMoveEvent(QGraphicsSceneMouseEvent* event)
473 {
474     if (event->buttons() == Qt::LeftButton) {
475         if (boundingRect().contains(event->pos())) {
476             qint16 end = posToCursor(event->pos());
477             if (end != _selectionEnd) {
478                 _selectionEnd = end;
479                 _selectionMode = (_selectionStart != _selectionEnd ? PartialSelection : NoSelection);
480                 chatLine()->update();
481             }
482         }
483         else {
484             setFullSelection();
485             chatScene()->startGlobalSelection(this, event->pos());
486         }
487         event->accept();
488     }
489     else {
490         event->ignore();
491     }
492 }
493
494 void ChatItem::mousePressEvent(QGraphicsSceneMouseEvent* event)
495 {
496     if (event->buttons() == Qt::LeftButton)
497         event->accept();
498     else
499         event->ignore();
500 }
501
502 void ChatItem::mouseReleaseEvent(QGraphicsSceneMouseEvent* event)
503 {
504     if (_selectionMode != NoSelection && event->button() == Qt::LeftButton) {
505         chatScene()->selectionToClipboard(QClipboard::Selection);
506         event->accept();
507     }
508     else
509         event->ignore();
510 }
511
512 void ChatItem::addActionsToMenu(QMenu* menu, const QPointF& pos)
513 {
514     Q_UNUSED(pos);
515
516     GraphicalUi::contextMenuActionProvider()->addActions(menu, chatScene()->filter(), data(MessageModel::BufferIdRole).value<BufferId>());
517 }
518
519 // ************************************************************
520 // SenderChatItem
521 // ************************************************************
522
523 void SenderChatItem::initLayout(QTextLayout* layout) const
524 {
525     initLayoutHelper(layout, QTextOption::ManualWrap, Qt::AlignRight);
526     doLayout(layout);
527 }
528
529 void SenderChatItem::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget)
530 {
531     Q_UNUSED(option);
532     Q_UNUSED(widget);
533     painter->save();
534     painter->setClipRect(boundingRect());
535     paintBackground(painter);
536
537     qreal layoutWidth = layout()->minimumWidth();
538     qreal offset = 0;
539     if (chatScene()->senderCutoffMode() == ChatScene::CutoffLeft)
540         offset = qMin(width() - layoutWidth, (qreal)0);
541     else
542         offset = qMax(layoutWidth - width(), (qreal)0);
543
544     if (layoutWidth > width()) {
545         // Draw a nice gradient for longer items
546         // Qt's text drawing with a gradient brush sucks, so we use compositing instead
547         QPixmap pixmap(layout()->boundingRect().toRect().size());
548         pixmap.fill(Qt::transparent);
549
550         QPainter pixPainter(&pixmap);
551         layout()->draw(&pixPainter, QPointF(qMax(offset, (qreal)0), 0), additionalFormats());
552
553         // Create alpha channel mask
554         QLinearGradient gradient;
555         if (offset < 0) {
556             gradient.setStart(0, 0);
557             gradient.setFinalStop(12, 0);
558             gradient.setColorAt(0, Qt::transparent);
559             gradient.setColorAt(1, Qt::white);
560         }
561         else {
562             gradient.setStart(width() - 10, 0);
563             gradient.setFinalStop(width(), 0);
564             gradient.setColorAt(0, Qt::white);
565             gradient.setColorAt(1, Qt::transparent);
566         }
567         pixPainter.setCompositionMode(QPainter::CompositionMode_DestinationIn);  // gradient's alpha gets applied to the pixmap
568         pixPainter.fillRect(pixmap.rect(), gradient);
569         painter->drawPixmap(pos(), pixmap);
570     }
571     else {
572         layout()->draw(painter, pos(), additionalFormats(), boundingRect());
573     }
574     painter->restore();
575 }
576
577 void SenderChatItem::handleClick(const QPointF& pos, ChatScene::ClickMode clickMode)
578 {
579     if (clickMode == ChatScene::DoubleClick) {
580         BufferInfo curBufInfo = Client::networkModel()->bufferInfo(data(MessageModel::BufferIdRole).value<BufferId>());
581         QString nick = data(MessageModel::EditRole).toString();
582         // check if the nick is a valid ircUser
583         if (!nick.isEmpty() && Client::network(curBufInfo.networkId())->ircUser(nick))
584             Client::bufferModel()->switchToOrStartQuery(curBufInfo.networkId(), nick);
585     }
586     else
587         ChatItem::handleClick(pos, clickMode);
588 }
589
590 // ************************************************************
591 // ContentsChatItem
592 // ************************************************************
593
594 ContentsChatItem::ActionProxy ContentsChatItem::_actionProxy;
595
596 ContentsChatItem::ContentsChatItem(const QPointF& pos, const qreal& width, ChatLine* parent)
597     : ChatItem(QRectF(pos, QSizeF(width, 0)), parent)
598     , _data(nullptr)
599 {
600     setPos(pos);
601     setGeometryByWidth(width);
602 }
603
604 QFontMetricsF* ContentsChatItem::fontMetrics() const
605 {
606     return QtUi::style()->fontMetrics(data(ChatLineModel::FormatRole).value<UiStyle::FormatList>().at(0).second.type,
607                                       UiStyle::MessageLabel::None);
608 }
609
610 ContentsChatItem::~ContentsChatItem()
611 {
612     delete _data;
613 }
614
615 void ContentsChatItem::clearCache()
616 {
617     delete _data;
618     _data = nullptr;
619     ChatItem::clearCache();
620 }
621
622 ContentsChatItemPrivate* ContentsChatItem::privateData() const
623 {
624     if (!_data) {
625         auto* that = const_cast<ContentsChatItem*>(this);
626         that->_data = new ContentsChatItemPrivate(ClickableList::fromString(data(ChatLineModel::DisplayRole).toString()), that);
627     }
628     return _data;
629 }
630
631 qreal ContentsChatItem::setGeometryByWidth(qreal w)
632 {
633     // We use this for reloading layout info as well, so we can't bail out if the width doesn't change
634
635     // compute height
636     int lines = 1;
637     WrapColumnFinder finder(this);
638     while (finder.nextWrapColumn(w) > 0)
639         lines++;
640     qreal spacing = qMax(fontMetrics()->lineSpacing(), fontMetrics()->height());  // cope with negative leading()
641     qreal h = lines * spacing;
642     delete _data;
643     _data = nullptr;
644
645     if (w != width() || h != height())
646         setGeometry(w, h);
647
648     return h;
649 }
650
651 void ContentsChatItem::initLayout(QTextLayout* layout) const
652 {
653     initLayoutHelper(layout, QTextOption::WrapAtWordBoundaryOrAnywhere);
654     doLayout(layout);
655 }
656
657 void ContentsChatItem::doLayout(QTextLayout* layout) const
658 {
659     ChatLineModel::WrapList wrapList = data(ChatLineModel::WrapListRole).value<ChatLineModel::WrapList>();
660     if (!wrapList.count())
661         return;  // empty chatitem
662
663     qreal h = 0;
664     qreal spacing = qMax(fontMetrics()->lineSpacing(), fontMetrics()->height());  // cope with negative leading()
665     WrapColumnFinder finder(this);
666     layout->beginLayout();
667     forever
668     {
669         QTextLine line = layout->createLine();
670         if (!line.isValid())
671             break;
672
673         int col = finder.nextWrapColumn(width());
674         if (col < 0)
675             col = layout->text().length();
676         int num = col - line.textStart();
677
678         line.setNumColumns(num);
679
680         // Sometimes, setNumColumns will create a line that's too long (cf. Qt bug 238249)
681         // We verify this and try setting the width again, making it shorter each time until the lengths match.
682         // Dead fugly, but seems to work…
683         for (int i = line.textLength() - 1; i >= 0 && line.textLength() > num; i--) {
684             line.setNumColumns(i);
685         }
686         if (num != line.textLength()) {
687             qWarning() << "WARNING: Layout engine couldn't workaround Qt bug 238249, please report!";
688             // qDebug() << num << line.textLength() << t.mid(line.textStart(), line.textLength()) << t.mid(line.textStart() + line.textLength());
689         }
690
691         line.setPosition(QPointF(0, h));
692         h += spacing;
693     }
694     layout->endLayout();
695 }
696
697 bool ContentsChatItem::hasActiveClickable() const
698 {
699     return privateData()->currentClickable.isValid();
700 }
701
702 std::pair<quint16, quint16> ContentsChatItem::activeClickableRange() const
703 {
704     const auto& clickable = privateData()->currentClickable;
705     if (clickable.isValid()) {
706         return {clickable.start(), clickable.start() + clickable.length()};
707     }
708     return {};
709 }
710
711 Clickable ContentsChatItem::clickableAt(const QPointF& pos) const
712 {
713     return privateData()->clickables.atCursorPos(posToCursor(pos));
714 }
715
716 UiStyle::FormatList ContentsChatItem::formatList() const
717 {
718     UiStyle::FormatList fmtList = ChatItem::formatList();
719     for (size_t i = 0; i < privateData()->clickables.size(); i++) {
720         Clickable click = privateData()->clickables.at(i);
721         if (click.type() == Clickable::Url) {
722             overlayFormat(fmtList, click.start(), click.start() + click.length(), UiStyle::FormatType::Url);
723         }
724     }
725     return fmtList;
726 }
727
728 void ContentsChatItem::endHoverMode()
729 {
730     if (privateData()) {
731         if (privateData()->currentClickable.isValid()) {
732             chatLine()->unsetCursor();
733             privateData()->currentClickable = Clickable();
734         }
735         clearWebPreview();
736         chatLine()->update();
737     }
738 }
739
740 void ContentsChatItem::handleClick(const QPointF& pos, ChatScene::ClickMode clickMode)
741 {
742     if (clickMode == ChatScene::SingleClick) {
743         qint16 idx = posToCursor(pos);
744         Clickable foo = privateData()->clickables.atCursorPos(idx);
745         if (foo.isValid()) {
746             NetworkId networkId = Client::networkModel()->networkId(data(MessageModel::BufferIdRole).value<BufferId>());
747             QString text = data(ChatLineModel::DisplayRole).toString();
748             foo.activate(networkId, text);
749         }
750     }
751     else if (clickMode == ChatScene::DoubleClick) {
752         chatScene()->setSelectingItem(this);
753         setSelectionMode(PartialSelection);
754         Clickable click = clickableAt(pos);
755         if (click.isValid()) {
756             setSelectionStart(click.start());
757             setSelectionEnd(click.start() + click.length());
758         }
759         else {
760             // find word boundary
761             QString str = data(ChatLineModel::DisplayRole).toString();
762             qint16 cursor = posToCursor(pos);
763             qint16 start = str.lastIndexOf(QRegExp("\\W"), cursor) + 1;
764             qint16 end = qMin(str.indexOf(QRegExp("\\W"), cursor), str.length());
765             if (end < 0)
766                 end = str.length();
767             setSelectionStart(start);
768             setSelectionEnd(end);
769         }
770         chatLine()->update();
771     }
772     else if (clickMode == ChatScene::TripleClick) {
773         setSelection(PartialSelection, 0, data(ChatLineModel::DisplayRole).toString().length());
774     }
775     ChatItem::handleClick(pos, clickMode);
776 }
777
778 void ContentsChatItem::mouseMoveEvent(QGraphicsSceneMouseEvent* event)
779 {
780     // mouse move events always mean we're not hovering anymore...
781     endHoverMode();
782     ChatItem::mouseMoveEvent(event);
783 }
784
785 void ContentsChatItem::hoverLeaveEvent(QGraphicsSceneHoverEvent* event)
786 {
787     endHoverMode();
788     event->accept();
789 }
790
791 void ContentsChatItem::hoverMoveEvent(QGraphicsSceneHoverEvent* event)
792 {
793     bool onClickable = false;
794     Clickable click = clickableAt(event->pos());
795     if (click.isValid()) {
796         if (click.type() == Clickable::Url) {
797             onClickable = true;
798             showWebPreview(click);
799         }
800         else if (click.type() == Clickable::Channel) {
801             QString name = data(ChatLineModel::DisplayRole).toString().mid(click.start(), click.length());
802             // don't make clickable if it's our own name
803             BufferId myId = data(MessageModel::BufferIdRole).value<BufferId>();
804             if (Client::networkModel()->bufferName(myId) != name)
805                 onClickable = true;
806         }
807         if (onClickable) {
808             chatLine()->setCursor(Qt::PointingHandCursor);
809             privateData()->currentClickable = click;
810             chatLine()->update();
811             return;
812         }
813     }
814     if (!onClickable)
815         endHoverMode();
816     event->accept();
817 }
818
819 void ContentsChatItem::addActionsToMenu(QMenu* menu, const QPointF& pos)
820 {
821     if (privateData()->currentClickable.isValid()) {
822         Clickable click = privateData()->currentClickable;
823         switch (click.type()) {
824         case Clickable::Url: {
825             privateData()->activeClickable = click;
826             auto action = new Action{icon::get("edit-copy"), tr("Copy Link Address"), menu, &_actionProxy, &ActionProxy::copyLinkToClipboard};
827             action->setData(QVariant::fromValue<void*>(this));
828             menu->addAction(action);
829             break;
830         }
831         case Clickable::Channel: {
832             // Remove existing menu actions, they confuse us when right-clicking on a clickable
833             menu->clear();
834             QString name = data(ChatLineModel::DisplayRole).toString().mid(click.start(), click.length());
835             GraphicalUi::contextMenuActionProvider()->addActions(menu,
836                                                                  chatScene()->filter(),
837                                                                  data(MessageModel::BufferIdRole).value<BufferId>(),
838                                                                  name);
839             break;
840         }
841         default:
842             break;
843         }
844     }
845     else {
846         // Buffer-specific actions
847         ChatItem::addActionsToMenu(menu, pos);
848     }
849 }
850
851 void ContentsChatItem::copyLinkToClipboard()
852 {
853     Clickable click = privateData()->activeClickable;
854     if (click.isValid() && click.type() == Clickable::Url) {
855         QString url = data(ChatLineModel::DisplayRole).toString().mid(click.start(), click.length());
856         if (!url.contains("://"))
857             url = "http://" + url;
858         chatScene()->stringToClipboard(url);
859     }
860 }
861
862 /******** WEB PREVIEW *****************************************************************************/
863
864 void ContentsChatItem::showWebPreview(const Clickable& click)
865 {
866 #if !defined HAVE_WEBKIT && !defined HAVE_WEBENGINE
867     Q_UNUSED(click);
868 #else
869     QTextLine line = layout()->lineForTextPosition(click.start());
870     qreal x = line.cursorToX(click.start());
871     qreal width = line.cursorToX(click.start() + click.length()) - x;
872     qreal height = line.height();
873     qreal y = height * line.lineNumber();
874
875     QPointF topLeft = mapToScene(pos()) + QPointF(x, y);
876     QRectF urlRect = QRectF(topLeft.x(), topLeft.y(), width, height);
877
878     QString urlstr = data(ChatLineModel::DisplayRole).toString().mid(click.start(), click.length());
879     if (!urlstr.contains("://"))
880         urlstr = "http://" + urlstr;
881     QUrl url = QUrl::fromEncoded(urlstr.toUtf8(), QUrl::TolerantMode);
882     chatScene()->loadWebPreview(this, url, urlRect);
883 #endif
884 }
885
886 void ContentsChatItem::clearWebPreview()
887 {
888 #if defined HAVE_WEBKIT || defined HAVE_WEBENGINE
889     chatScene()->clearWebPreview(this);
890 #endif
891 }
892
893 /*************************************************************************************************/
894
895 ContentsChatItem::WrapColumnFinder::WrapColumnFinder(const ChatItem* _item)
896     : item(_item)
897     , wrapList(item->data(ChatLineModel::WrapListRole).value<ChatLineModel::WrapList>())
898     , wordidx(0)
899     , lineCount(0)
900     , choppedTrailing(0)
901 {}
902
903 qint16 ContentsChatItem::WrapColumnFinder::nextWrapColumn(qreal width)
904 {
905     if (wordidx >= wrapList.count())
906         return -1;
907
908     lineCount++;
909     qreal targetWidth = lineCount * width + choppedTrailing;
910
911     qint16 start = wordidx;
912     qint16 end = wrapList.count() - 1;
913
914     // check if the whole line fits
915     if (wrapList.at(end).endX <= targetWidth)  //  || start == end)
916         return -1;
917
918     // check if we have a very long word that needs inter word wrap
919     if (wrapList.at(start).endX > targetWidth) {
920         if (!line.isValid()) {
921             item->initLayoutHelper(&layout, QTextOption::NoWrap);
922             layout.beginLayout();
923             line = layout.createLine();
924             layout.endLayout();
925         }
926         return line.xToCursor(targetWidth, QTextLine::CursorOnCharacter);
927     }
928
929     while (true) {
930         if (start + 1 == end) {
931             wordidx = end;
932             const ChatLineModel::Word& lastWord = wrapList.at(start);  // the last word we were able to squeeze in
933
934             // both cases should be cought preliminary
935             Q_ASSERT(lastWord.endX <= targetWidth);  // ensure that "start" really fits in
936             Q_ASSERT(end < wrapList.count());        // ensure that start isn't the last word
937
938             choppedTrailing += lastWord.trailing - (targetWidth - lastWord.endX);
939             return wrapList.at(wordidx).start;
940         }
941
942         qint16 pivot = (end + start) / 2;
943         if (wrapList.at(pivot).endX > targetWidth) {
944             end = pivot;
945         }
946         else {
947             start = pivot;
948         }
949     }
950     Q_ASSERT(false);
951     return -1;
952 }
953
954 /*************************************************************************************************/