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