b72b8ad459775b724a5ee94983a64475ba16eea5
[quassel.git] / src / qtui / chatitem.cpp
1 /***************************************************************************
2  *   Copyright (C) 2005-08 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  *   59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.             *
19  ***************************************************************************/
20
21 #include <QApplication>
22 #include <QClipboard>
23 #include <QDesktopServices>
24 #include <QFontMetrics>
25 #include <QGraphicsSceneMouseEvent>
26 #include <QPainter>
27 #include <QPalette>
28 #include <QTextLayout>
29 #include <QMenu>
30
31 #include "buffermodel.h"
32 #include "bufferview.h"
33 #include "chatitem.h"
34 #include "chatlinemodel.h"
35 #include "iconloader.h"
36 #include "mainwin.h"
37 #include "qtui.h"
38 #include "qtuistyle.h"
39
40 ChatItem::ChatItem(const qreal &width, const qreal &height, const QPointF &pos, QGraphicsItem *parent)
41   : QGraphicsItem(parent),
42     _data(0),
43     _boundingRect(0, 0, width, height),
44     _selectionMode(NoSelection),
45     _selectionStart(-1)
46 {
47   setAcceptHoverEvents(true);
48   setZValue(20);
49   setPos(pos);
50 }
51
52 ChatItem::~ChatItem() {
53   delete _data;
54 }
55
56 QVariant ChatItem::data(int role) const {
57   QModelIndex index = model()->index(row(), column());
58   if(!index.isValid()) {
59     qWarning() << "ChatItem::data(): model index is invalid!" << index;
60     return QVariant();
61   }
62   return model()->data(index, role);
63 }
64
65 QTextLayout *ChatItem::createLayout(QTextOption::WrapMode wrapMode, Qt::Alignment alignment) const {
66   QTextLayout *layout = new QTextLayout(data(MessageModel::DisplayRole).toString());
67
68   QTextOption option;
69   option.setWrapMode(wrapMode);
70   option.setAlignment(alignment);
71   layout->setTextOption(option);
72
73   QList<QTextLayout::FormatRange> formatRanges
74          = QtUi::style()->toTextLayoutList(data(MessageModel::FormatRole).value<UiStyle::FormatList>(), layout->text().length());
75   layout->setAdditionalFormats(formatRanges);
76   return layout;
77 }
78
79 void ChatItem::doLayout() {
80   QTextLayout *layout_ = layout();
81   layout_->beginLayout();
82   QTextLine line = layout_->createLine();
83   if(line.isValid()) {
84     line.setLineWidth(width());
85     line.setPosition(QPointF(0,0));
86   }
87   layout_->endLayout();
88 }
89
90 void ChatItem::clearLayout() {
91   delete _data;
92   _data = 0;
93 }
94
95 ChatItemPrivate *ChatItem::privateData() const {
96   if(!_data) {
97     ChatItem *that = const_cast<ChatItem *>(this);
98     that->_data = that->newPrivateData();
99     that->doLayout();
100   }
101   return _data;
102 }
103
104 // NOTE: This is not the most time-efficient implementation, but it saves space by not caching unnecessary data
105 //       This is a deliberate trade-off. (-> selectFmt creation, data() call)
106 void ChatItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) {
107   Q_UNUSED(option); Q_UNUSED(widget);
108   painter->setClipRect(boundingRect()); // no idea why QGraphicsItem clipping won't work
109   QVector<QTextLayout::FormatRange> formats = additionalFormats();
110   QTextLayout::FormatRange selectFmt = selectionFormat();
111   if(selectFmt.format.isValid()) formats.append(selectFmt);
112   layout()->draw(painter, QPointF(0,0), formats, boundingRect());
113
114   // Debuging Stuff
115   // uncomment partially or all of the following stuff:
116   //
117   // 0) alternativ painter color for debug stuff
118 //   if(row() % 2)
119 //     painter->setPen(Qt::red);
120 //   else
121 //     painter->setPen(Qt::blue);
122   // 1) draw wordwrap points in the first line
123 //   if(column() == 2) {
124 //     ChatLineModel::WrapList wrapList = data(ChatLineModel::WrapListRole).value<ChatLineModel::WrapList>();
125 //     foreach(ChatLineModel::Word word, wrapList) {
126 //       if(word.endX > width())
127 //      break;
128 //       painter->drawLine(word.endX, 0, word.endX, height());
129 //     }
130 //   }
131   // 2) draw MsgId over the time column
132 //   if(column() == 0) {
133 //     QString msgIdString = QString::number(data(MessageModel::MsgIdRole).value<MsgId>().toInt());
134 //     QPointF bottomPoint = boundingRect().bottomLeft();
135 //     bottomPoint.ry() -= 2;
136 //     painter->drawText(bottomPoint, msgIdString);
137 //   }
138   // 3) draw bounding rect
139 //   painter->drawRect(_boundingRect.adjusted(0, 0, -1, -1));
140 }
141
142 qint16 ChatItem::posToCursor(const QPointF &pos) const {
143   if(pos.y() > height()) return data(MessageModel::DisplayRole).toString().length();
144   if(pos.y() < 0) return 0;
145   for(int l = layout()->lineCount() - 1; l >= 0; l--) {
146     QTextLine line = layout()->lineAt(l);
147     if(pos.y() >= line.y()) {
148       return line.xToCursor(pos.x(), QTextLine::CursorOnCharacter);
149     }
150   }
151   return 0;
152 }
153
154 bool ChatItem::hasSelection() const {
155   if(_selectionMode == NoSelection)
156     return false;
157   if(_selectionMode == FullSelection)
158     return true;
159   // partial
160   return _selectionStart != _selectionEnd;
161 }
162
163 QString ChatItem::selection() const {
164   if(_selectionMode == FullSelection)
165     return data(MessageModel::DisplayRole).toString();
166   if(_selectionMode == PartialSelection)
167     return data(MessageModel::DisplayRole).toString().mid(qMin(_selectionStart, _selectionEnd), qAbs(_selectionStart - _selectionEnd));
168   return QString();
169 }
170
171 void ChatItem::setSelection(SelectionMode mode, qint16 start, qint16 end) {
172   _selectionMode = mode;
173   _selectionStart = start;
174   _selectionEnd = end;
175   update();
176 }
177
178 void ChatItem::setFullSelection() {
179   if(_selectionMode != FullSelection) {
180     _selectionMode = FullSelection;
181     update();
182   }
183 }
184
185 void ChatItem::clearSelection() {
186   if(_selectionMode != NoSelection) {
187     _selectionMode = NoSelection;
188     update();
189   }
190 }
191
192 void ChatItem::continueSelecting(const QPointF &pos) {
193   _selectionMode = PartialSelection;
194   _selectionEnd = posToCursor(pos);
195   update();
196 }
197
198 bool ChatItem::isPosOverSelection(const QPointF &pos) const {
199   if(_selectionMode == FullSelection)
200     return true;
201   if(_selectionMode == PartialSelection) {
202     int cursor = posToCursor(pos);
203     return cursor >= qMin(_selectionStart, _selectionEnd) && cursor <= qMax(_selectionStart, _selectionEnd);
204   }
205   return false;
206 }
207
208 QTextLayout::FormatRange ChatItem::selectionFormat() const {
209   QTextLayout::FormatRange selectFmt;
210   if(_selectionMode != NoSelection) {
211     selectFmt.format.setForeground(QApplication::palette().brush(QPalette::HighlightedText));
212     selectFmt.format.setBackground(QApplication::palette().brush(QPalette::Highlight));
213     if(_selectionMode == PartialSelection) {
214       selectFmt.start = qMin(_selectionStart, _selectionEnd);
215       selectFmt.length = qAbs(_selectionStart - _selectionEnd);
216     } else { // FullSelection
217       selectFmt.start = 0;
218       selectFmt.length = data(MessageModel::DisplayRole).toString().length();
219     }
220   } else {
221     selectFmt.start = -1;
222     selectFmt.length = 0;
223   }
224   return selectFmt;
225 }
226
227 QList<QRectF> ChatItem::findWords(const QString &searchWord, Qt::CaseSensitivity caseSensitive) {
228   QList<QRectF> resultList;
229   const QAbstractItemModel *model_ = model();
230   if(!model_)
231     return resultList;
232
233   QString plainText = model_->data(model_->index(row(), column()), MessageModel::DisplayRole).toString();
234   QList<int> indexList;
235   int searchIdx = plainText.indexOf(searchWord, 0, caseSensitive);
236   while(searchIdx != -1) {
237     indexList << searchIdx;
238     searchIdx = plainText.indexOf(searchWord, searchIdx + 1, caseSensitive);
239   }
240
241   bool hadPrivateData = hasPrivateData();
242
243   foreach(int idx, indexList) {
244     QTextLine line = layout()->lineForTextPosition(idx);
245     qreal x = line.cursorToX(idx);
246     qreal width = line.cursorToX(idx + searchWord.count()) - x;
247     qreal height = line.height();
248     qreal y = height * line.lineNumber();
249     resultList << QRectF(x, y, width, height);
250   }
251
252   if(!hadPrivateData)
253     clearLayout();
254   return resultList;
255 }
256
257 void ChatItem::handleClick(const QPointF &pos, ChatScene::ClickMode clickMode) {
258   // single clicks are already handled by the scene (for clearing the selection)
259   if(clickMode == ChatScene::DragStartClick) {
260     chatScene()->setSelectingItem(this);
261     _selectionStart = _selectionEnd = posToCursor(pos);
262     _selectionMode = NoSelection; // will be set to PartialSelection by mouseMoveEvent
263     update();
264   }
265 }
266
267 void ChatItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) {
268   if(event->buttons() == Qt::LeftButton) {
269     if(contains(event->pos())) {
270       qint16 end = posToCursor(event->pos());
271       if(end != _selectionEnd) {
272         _selectionEnd = end;
273         _selectionMode = (_selectionStart != _selectionEnd ? PartialSelection : NoSelection);
274         update();
275       }
276     } else {
277       setFullSelection();
278       chatScene()->startGlobalSelection(this, event->pos());
279     }
280     event->accept();
281   } else {
282     event->ignore();
283   }
284 }
285
286 void ChatItem::mousePressEvent(QGraphicsSceneMouseEvent *event) {
287   if(event->buttons() == Qt::LeftButton)
288     event->accept();
289   else
290     event->ignore();
291 }
292
293 void ChatItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) {
294   if(_selectionMode != NoSelection && !event->buttons() & Qt::LeftButton) {
295     chatScene()->selectionToClipboard(QClipboard::Selection);
296     event->accept();
297   } else
298     event->ignore();
299 }
300
301 void ChatItem::addActionsToMenu(QMenu *menu, const QPointF &pos) {
302   Q_UNUSED(menu);
303   Q_UNUSED(pos);
304
305 }
306
307 // ************************************************************
308 // SenderChatItem
309 // ************************************************************
310
311 void SenderChatItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) {
312   Q_UNUSED(option); Q_UNUSED(widget);
313
314   painter->setClipRect(boundingRect()); // no idea why QGraphicsItem clipping won't work
315   qreal layoutWidth = layout()->minimumWidth();
316   qreal offset = 0;
317   if(chatScene()->senderCutoffMode() == ChatScene::CutoffLeft)
318     offset = qMin(width() - layoutWidth, (qreal)0);
319   else
320     offset = qMax(layoutWidth - width(), (qreal)0);
321
322   QTextLayout::FormatRange selectFmt = selectionFormat();
323
324   if(layoutWidth > width()) {
325     // Draw a nice gradient for longer items
326     // Qt's text drawing with a gradient brush sucks, so we use an alpha-channeled pixmap instead
327     QPixmap pixmap(layout()->boundingRect().toRect().size());
328     pixmap.fill(Qt::transparent);
329     QPainter pixPainter(&pixmap);
330     layout()->draw(&pixPainter, QPointF(qMax(offset, (qreal)0), 0), QVector<QTextLayout::FormatRange>() << selectFmt);
331     pixPainter.end();
332
333     // Create alpha channel mask
334     QPixmap mask(pixmap.size());
335     QPainter maskPainter(&mask);
336     QLinearGradient gradient;
337     if(offset < 0) {
338       gradient.setStart(0, 0);
339       gradient.setFinalStop(12, 0);
340       gradient.setColorAt(0, Qt::black);
341       gradient.setColorAt(1, Qt::white);
342     } else {
343       gradient.setStart(width()-10, 0);
344       gradient.setFinalStop(width(), 0);
345       gradient.setColorAt(0, Qt::white);
346       gradient.setColorAt(1, Qt::black);
347     }
348     maskPainter.fillRect(boundingRect(), gradient);
349     pixmap.setAlphaChannel(mask);
350     painter->drawPixmap(0, 0, pixmap);
351   } else {
352     layout()->draw(painter, QPointF(0,0), QVector<QTextLayout::FormatRange>() << selectFmt, boundingRect());
353   }
354 }
355
356 // ************************************************************
357 // ContentsChatItem
358 // ************************************************************
359
360 ContentsChatItem::ActionProxy ContentsChatItem::_actionProxy;
361
362 ContentsChatItem::ContentsChatItem(const qreal &width, const QPointF &pos, QGraphicsItem *parent)
363   : ChatItem(0, 0, pos, parent)
364 {
365   const QAbstractItemModel *model_ = model();
366   QModelIndex index = model_->index(row(), column());
367   _fontMetrics = QtUi::style()->fontMetrics(model_->data(index, ChatLineModel::FormatRole).value<UiStyle::FormatList>().at(0).second);
368
369   setGeometryByWidth(width);
370 }
371
372 qreal ContentsChatItem::setGeometryByWidth(qreal w) {
373   if(w != width()) {
374     prepareGeometryChange();
375     setWidth(w);
376     // compute height
377     int lines = 1;
378     WrapColumnFinder finder(this);
379     while(finder.nextWrapColumn() > 0)
380       lines++;
381     setHeight(lines * fontMetrics()->lineSpacing());
382   }
383   return height();
384 }
385
386 void ContentsChatItem::doLayout() {
387   ChatLineModel::WrapList wrapList = data(ChatLineModel::WrapListRole).value<ChatLineModel::WrapList>();
388   if(!wrapList.count()) return; // empty chatitem
389
390   qreal h = 0;
391   WrapColumnFinder finder(this);
392   layout()->beginLayout();
393   forever {
394     QTextLine line = layout()->createLine();
395     if(!line.isValid())
396       break;
397
398     int col = finder.nextWrapColumn();
399     line.setNumColumns(col >= 0 ? col - line.textStart() : layout()->text().length());
400     line.setPosition(QPointF(0, h));
401     h += fontMetrics()->lineSpacing();
402   }
403   layout()->endLayout();
404 }
405
406 // NOTE: This method is not threadsafe and not reentrant!
407 //       (RegExps are not constant while matching, and they are static here for efficiency)
408 QList<ContentsChatItem::Clickable> ContentsChatItem::findClickables() const {
409   // For matching URLs
410   static QString urlEnd("(?:>|[,.;:\"]*\\s|\\b|$)");
411   static QString urlChars("(?:[,.;:]*[\\w\\-~@/?&=+$()!%#*|{}\\[\\]])");
412
413   static QRegExp regExp[] = {
414     // URL
415     // QRegExp(QString("((?:https?://|s?ftp://|irc://|mailto:|www\\.)%1+|%1+\\.[a-z]{2,4}(?:?=/%1+|\\b))%2").arg(urlChars, urlEnd)),
416     QRegExp(QString("((?:(?:https?://|s?ftp://|irc://|gopher://|mailto:)|www)%1+)%2").arg(urlChars, urlEnd), Qt::CaseInsensitive),
417
418     // Channel name
419     // We don't match for channel names starting with + or &, because that gives us a lot of false positives.
420     QRegExp("((?:#|![A-Z0-9]{5})[^,:\\s]+(?::[^,:\\s]+)?)\\b", Qt::CaseInsensitive)
421
422     // TODO: Nicks, we'll need a filtering for only matching known nicknames further down if we do this
423   };
424
425   static const int regExpCount = 2;  // number of regexps in the array above
426
427   qint16 matches[] = { 0, 0, 0 };
428   qint16 matchEnd[] = { 0, 0, 0 };
429
430   QString str = data(ChatLineModel::DisplayRole).toString();
431
432   QList<Clickable> result;
433   qint16 idx = 0;
434   qint16 minidx;
435   int type = -1;
436
437   do {
438     type = -1;
439     minidx = str.length();
440     for(int i = 0; i < regExpCount; i++) {
441       if(matches[i] < 0 || matchEnd[i] > str.length()) continue;
442       if(idx >= matchEnd[i]) {
443         matches[i] = str.indexOf(regExp[i], qMax(matchEnd[i], idx));
444         if(matches[i] >= 0) matchEnd[i] = matches[i] + regExp[i].cap(1).length();
445       }
446       if(matches[i] >= 0 && matches[i] < minidx) {
447         minidx = matches[i];
448         type = i;
449       }
450     }
451     if(type >= 0) {
452       idx = matchEnd[type];
453       if(type == Clickable::Url && str.at(idx-1) == ')') {  // special case: closing paren only matches if we had an open one
454         if(!str.mid(matches[type], matchEnd[type]-matches[type]).contains('(')) matchEnd[type]--;
455       }
456       result.append(Clickable((Clickable::Type)type, matches[type], matchEnd[type] - matches[type]));
457     }
458   } while(type >= 0);
459
460   /* testing
461   if(!result.isEmpty()) qDebug() << str;
462   foreach(Clickable click, result) {
463     qDebug() << str.mid(click.start, click.length);
464   }
465   */
466   return result;
467 }
468
469 ContentsChatItem::Clickable ContentsChatItem::clickableAt(const QPointF &pos) const {
470   qint16 idx = posToCursor(pos);
471   for(int i = 0; i < privateData()->clickables.count(); i++) {
472     Clickable click = privateData()->clickables.at(i);
473     if(idx >= click.start && idx < click.start + click.length)
474       return click;
475   }
476   return Clickable();
477 }
478
479 QVector<QTextLayout::FormatRange> ContentsChatItem::additionalFormats() const {
480   // mark a clickable if hovered upon
481   QVector<QTextLayout::FormatRange> fmt;
482   if(privateData()->currentClickable.isValid()) {
483     Clickable click = privateData()->currentClickable;
484     QTextLayout::FormatRange f;
485     f.start = click.start;
486     f.length = click.length;
487     f.format.setFontUnderline(true);
488     fmt.append(f);
489   }
490   return fmt;
491 }
492
493 void ContentsChatItem::endHoverMode() {
494   if(hasPrivateData()) {
495     if(privateData()->currentClickable.isValid()) {
496       setCursor(Qt::ArrowCursor);
497       privateData()->currentClickable = Clickable();
498     }
499     clearWebPreview();
500     update();
501   }
502 }
503
504 void ContentsChatItem::handleClick(const QPointF &pos, ChatScene::ClickMode clickMode) {
505   if(clickMode == ChatScene::SingleClick) {
506     Clickable click = clickableAt(pos);
507     if(click.isValid()) {
508       QString str = data(ChatLineModel::DisplayRole).toString().mid(click.start, click.length);
509       switch(click.type) {
510         case Clickable::Url:
511           if(!str.contains("://"))
512             str = "http://" + str;
513           QDesktopServices::openUrl(QUrl::fromEncoded(str.toUtf8(), QUrl::TolerantMode));
514           break;
515         case Clickable::Channel: {
516           NetworkId networkId = Client::networkModel()->networkId(data(MessageModel::BufferIdRole).value<BufferId>());
517           BufferId bufId = Client::networkModel()->bufferId(networkId, str);
518           if(bufId.isValid()) {
519             QModelIndex targetIdx = Client::networkModel()->bufferIndex(bufId);
520             Client::bufferModel()->switchToBuffer(bufId);
521             if(!targetIdx.data(NetworkModel::ItemActiveRole).toBool())
522               Client::userInput(BufferInfo::fakeStatusBuffer(networkId), QString("/JOIN %1").arg(str));
523           } else
524               Client::userInput(BufferInfo::fakeStatusBuffer(networkId), QString("/JOIN %1").arg(str));
525           break;
526         }
527         default:
528           break;
529       }
530     }
531   } else if(clickMode == ChatScene::DoubleClick) {
532     chatScene()->setSelectingItem(this);
533     setSelectionMode(PartialSelection);
534     Clickable click = clickableAt(pos);
535     if(click.isValid()) {
536       setSelectionStart(click.start);
537       setSelectionEnd(click.start + click.length);
538     } else {
539       // find word boundary
540       QString str = data(ChatLineModel::DisplayRole).toString();
541       qint16 cursor = posToCursor(pos);
542       qint16 start = str.lastIndexOf(QRegExp("\\W"), cursor) + 1;
543       qint16 end = qMin(str.indexOf(QRegExp("\\W"), cursor), str.length());
544       if(end < 0) end = str.length();
545       setSelectionStart(start);
546       setSelectionEnd(end);
547     }
548     update();
549   } else if(clickMode == ChatScene::TripleClick) {
550     setSelection(PartialSelection, 0, data(ChatLineModel::DisplayRole).toString().length());
551   }
552   ChatItem::handleClick(pos, clickMode);
553 }
554
555 void ContentsChatItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) {
556   // mouse move events always mean we're not hovering anymore...
557   endHoverMode();
558   ChatItem::mouseMoveEvent(event);
559 }
560
561 void ContentsChatItem::hoverLeaveEvent(QGraphicsSceneHoverEvent *event) {
562   endHoverMode();
563   event->accept();
564 }
565
566 void ContentsChatItem::hoverMoveEvent(QGraphicsSceneHoverEvent *event) {
567   bool onClickable = false;
568   Clickable click = clickableAt(event->pos());
569   if(click.isValid()) {
570     if(click.type == Clickable::Url) {
571       onClickable = true;
572       showWebPreview(click);
573     } else if(click.type == Clickable::Channel) {
574       QString name = data(ChatLineModel::DisplayRole).toString().mid(click.start, click.length);
575       // don't make clickable if it could be a #number
576       if(!QRegExp("^#\\d+$").exactMatch(name)) {
577       // don't make clickable if it's our own name
578         BufferId myId = data(MessageModel::BufferIdRole).value<BufferId>();
579         if(Client::networkModel()->bufferName(myId) != name)
580           onClickable = true;
581       }
582     }
583     if(onClickable) {
584       setCursor(Qt::PointingHandCursor);
585       privateData()->currentClickable = click;
586       update();
587       return;
588     }
589   }
590   if(!onClickable) endHoverMode();
591   event->accept();
592 }
593
594 void ContentsChatItem::addActionsToMenu(QMenu *menu, const QPointF &pos) {
595   Q_UNUSED(pos); // we assume that the current mouse cursor pos is the point of invocation
596
597   if(privateData()->currentClickable.isValid()) {
598     Clickable click = privateData()->currentClickable;
599     switch(click.type) {
600       case Clickable::Url:
601         privateData()->activeClickable = click;
602         menu->addAction(SmallIcon("edit-copy"), tr("Copy Link Address"),
603                          &_actionProxy, SLOT(copyLinkToClipboard()))->setData(QVariant::fromValue<void *>(this));
604         break;
605       case Clickable::Channel: {
606         // Hide existing menu actions, they confuse us when right-clicking on a clickable
607         foreach(QAction *action, menu->actions())
608           action->setVisible(false);
609         QString name = data(ChatLineModel::DisplayRole).toString().mid(click.start, click.length);
610         Client::mainUi()->actionProvider()->addActions(menu, chatScene()->filter(), data(MessageModel::BufferIdRole).value<BufferId>(), name);
611         break;
612       }
613       default:
614         break;
615     }
616   } else {
617
618     // Buffer-specific actions
619     Client::mainUi()->actionProvider()->addActions(menu, chatScene()->filter(), data(MessageModel::BufferIdRole).value<BufferId>());
620   }
621 }
622
623 void ContentsChatItem::copyLinkToClipboard() {
624   Clickable click = privateData()->activeClickable;
625   if(click.isValid() && click.type == Clickable::Url) {
626     QString url = data(ChatLineModel::DisplayRole).toString().mid(click.start, click.length);
627     if(!url.contains("://"))
628       url = "http://" + url;
629     chatScene()->stringToClipboard(url);
630   }
631 }
632
633 /******** WEB PREVIEW *****************************************************************************/
634
635 void ContentsChatItem::showWebPreview(const Clickable &click) {
636 #ifndef HAVE_WEBKIT
637   Q_UNUSED(click);
638 #else
639   QTextLine line = layout()->lineForTextPosition(click.start);
640   qreal x = line.cursorToX(click.start);
641   qreal width = line.cursorToX(click.start + click.length) - x;
642   qreal height = line.height();
643   qreal y = height * line.lineNumber();
644
645   QPointF topLeft = scenePos() + QPointF(x, y);
646   QRectF urlRect = QRectF(topLeft.x(), topLeft.y(), width, height);
647
648   QString url = data(ChatLineModel::DisplayRole).toString().mid(click.start, click.length);
649   if(!url.contains("://"))
650     url = "http://" + url;
651   chatScene()->loadWebPreview(this, url, urlRect);
652 #endif
653 }
654
655 void ContentsChatItem::clearWebPreview() {
656 #ifdef HAVE_WEBKIT
657   chatScene()->clearWebPreview(this);
658 #endif
659 }
660
661 /*************************************************************************************************/
662
663 ContentsChatItem::WrapColumnFinder::WrapColumnFinder(ChatItem *_item)
664   : item(_item),
665     layout(0),
666     wrapList(item->data(ChatLineModel::WrapListRole).value<ChatLineModel::WrapList>()),
667     wordidx(0),
668     lineCount(0),
669     choppedTrailing(0)
670 {
671 }
672
673 ContentsChatItem::WrapColumnFinder::~WrapColumnFinder() {
674   delete layout;
675 }
676
677 qint16 ContentsChatItem::WrapColumnFinder::nextWrapColumn() {
678   if(wordidx >= wrapList.count())
679     return -1;
680
681   lineCount++;
682   qreal targetWidth = lineCount * item->width() + choppedTrailing;
683
684   qint16 start = wordidx;
685   qint16 end = wrapList.count() - 1;
686
687   // check if the whole line fits
688   if(wrapList.at(end).endX <= targetWidth) //  || start == end)
689     return -1;
690
691   // check if we have a very long word that needs inter word wrap
692   if(wrapList.at(start).endX > targetWidth) {
693     if(!line.isValid()) {
694       layout = item->createLayout(QTextOption::NoWrap);
695       layout->beginLayout();
696       line = layout->createLine();
697       layout->endLayout();
698     }
699     return line.xToCursor(targetWidth, QTextLine::CursorOnCharacter);
700   }
701
702   while(true) {
703     if(start + 1 == end) {
704       wordidx = end;
705       const ChatLineModel::Word &lastWord = wrapList.at(start); // the last word we were able to squeeze in
706
707       // both cases should be cought preliminary
708       Q_ASSERT(lastWord.endX <= targetWidth); // ensure that "start" really fits in
709       Q_ASSERT(end < wrapList.count()); // ensure that start isn't the last word
710
711       choppedTrailing += lastWord.trailing - (targetWidth - lastWord.endX);
712       return wrapList.at(wordidx).start;
713     }
714
715     qint16 pivot = (end + start) / 2;
716     if(wrapList.at(pivot).endX > targetWidth) {
717       end = pivot;
718     } else {
719       start = pivot;
720     }
721   }
722   Q_ASSERT(false);
723   return -1;
724 }
725
726 /*************************************************************************************************/
727