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