fixing merge aftermath
[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 <QWebView>
30 #include <QGraphicsProxyWidget>
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) {
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::updateLayout() {
77   if(!privateData()) {
78     setPrivateData(new ChatItemPrivate(createLayout()));
79   }
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 // NOTE: This is not the most time-efficient implementation, but it saves space by not caching unnecessary data
96 //       This is a deliberate trade-off. (-> selectFmt creation, data() call)
97 void ChatItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) {
98   Q_UNUSED(option); Q_UNUSED(widget);
99   if(!hasLayout())
100     updateLayout();
101   painter->setClipRect(boundingRect()); // no idea why QGraphicsItem clipping won't work
102   //if(_selectionMode == FullSelection) {
103     //painter->save();
104     //painter->fillRect(boundingRect(), QApplication::palette().brush(QPalette::Highlight));
105     //painter->restore();
106   //}
107   QVector<QTextLayout::FormatRange> formats = additionalFormats();
108   if(_selectionMode != NoSelection) {
109     QTextLayout::FormatRange selectFmt;
110     selectFmt.format.setForeground(QApplication::palette().brush(QPalette::HighlightedText));
111     selectFmt.format.setBackground(QApplication::palette().brush(QPalette::Highlight));
112     if(_selectionMode == PartialSelection) {
113       selectFmt.start = qMin(_selectionStart, _selectionEnd);
114       selectFmt.length = qAbs(_selectionStart - _selectionEnd);
115     } else { // FullSelection
116       selectFmt.start = 0;
117       selectFmt.length = data(MessageModel::DisplayRole).toString().length();
118     }
119     formats.append(selectFmt);
120   }
121   layout()->draw(painter, QPointF(0,0), formats, boundingRect());
122
123   // Debuging Stuff
124   // uncomment the following lines to draw the bounding rect and the row number in alternating colors
125 //   if(row() % 2)
126 //     painter->setPen(Qt::red);
127 //   else
128 //     painter->setPen(Qt::blue);
129 //   QString rowString = QString::number(row());
130 //   QRect rowRect = painter->fontMetrics().boundingRect(rowString);
131 //   QPointF topPoint = _boundingRect.topLeft();
132 //   topPoint.ry() += rowRect.height();
133 //   painter->drawText(topPoint, rowString);
134 //   QPointF bottomPoint = _boundingRect.bottomRight();
135 //   bottomPoint.rx() -= rowRect.width();
136 //   painter->drawText(bottomPoint, rowString);
137 //   painter->drawRect(_boundingRect.adjusted(0, 0, -1, -1));
138 }
139
140 qint16 ChatItem::posToCursor(const QPointF &pos) {
141   if(pos.y() > height()) return data(MessageModel::DisplayRole).toString().length();
142   if(pos.y() < 0) return 0;
143   if(!hasLayout())
144     updateLayout();
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 void ChatItem::setFullSelection() {
155   if(_selectionMode != FullSelection) {
156     _selectionMode = FullSelection;
157     update();
158   }
159 }
160
161 void ChatItem::clearSelection() {
162   _selectionMode = NoSelection;
163   update();
164 }
165
166 void ChatItem::continueSelecting(const QPointF &pos) {
167   _selectionMode = PartialSelection;
168   _selectionEnd = posToCursor(pos);
169   update();
170 }
171
172 QList<QRectF> ChatItem::findWords(const QString &searchWord, Qt::CaseSensitivity caseSensitive) {
173   QList<QRectF> resultList;
174   const QAbstractItemModel *model_ = model();
175   if(!model_)
176     return resultList;
177
178   QString plainText = model_->data(model_->index(row(), column()), MessageModel::DisplayRole).toString();
179   QList<int> indexList;
180   int searchIdx = plainText.indexOf(searchWord, 0, caseSensitive);
181   while(searchIdx != -1) {
182     indexList << searchIdx;
183     searchIdx = plainText.indexOf(searchWord, searchIdx + 1, caseSensitive);
184   }
185
186   bool hadLayout = hasLayout();
187   if(!hadLayout)
188     updateLayout();
189
190   foreach(int idx, indexList) {
191     QTextLine line = layout()->lineForTextPosition(idx);
192     qreal x = line.cursorToX(idx);
193     qreal width = line.cursorToX(idx + searchWord.count()) - x;
194     qreal height = line.height();
195     qreal y = height * line.lineNumber();
196     resultList << QRectF(x, y, width, height);
197   }
198
199   if(!hadLayout)
200     clearLayout();
201   return resultList;
202 }
203
204 void ChatItem::mousePressEvent(QGraphicsSceneMouseEvent *event) {
205   if(event->buttons() == Qt::LeftButton) {
206     chatScene()->setSelectingItem(this);
207     _selectionStart = _selectionEnd = posToCursor(event->pos());
208     _selectionMode = NoSelection; // will be set to PartialSelection by mouseMoveEvent
209     update();
210     event->accept();
211   } else {
212     event->ignore();
213   }
214 }
215
216 void ChatItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) {
217   if(event->buttons() == Qt::LeftButton) {
218     if(contains(event->pos())) {
219       qint16 end = posToCursor(event->pos());
220       if(end != _selectionEnd) {
221         _selectionEnd = end;
222         _selectionMode = (_selectionStart != _selectionEnd ? PartialSelection : NoSelection);
223         update();
224       }
225     } else {
226       setFullSelection();
227       chatScene()->startGlobalSelection(this, event->pos());
228     }
229     event->accept();
230   } else {
231     event->ignore();
232   }
233 }
234
235 void ChatItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) {
236   if(_selectionMode != NoSelection && !event->buttons() & Qt::LeftButton) {
237     _selectionEnd = posToCursor(event->pos());
238     QString selection
239         = data(MessageModel::DisplayRole).toString().mid(qMin(_selectionStart, _selectionEnd), qAbs(_selectionStart - _selectionEnd));
240     chatScene()->putToClipboard(selection);
241     event->accept();
242   } else {
243     event->ignore();
244   }
245 }
246
247 // ************************************************************
248 // SenderChatItem
249 // ************************************************************
250
251 // ************************************************************
252 // ContentsChatItem
253 // ************************************************************
254 ContentsChatItem::ContentsChatItem(const qreal &width, const QPointF &pos, QGraphicsItem *parent)
255   : ChatItem(0, 0, pos, parent)
256 {
257   const QAbstractItemModel *model_ = model();
258   QModelIndex index = model_->index(row(), column());
259   _fontMetrics = QtUi::style()->fontMetrics(model_->data(index, ChatLineModel::FormatRole).value<UiStyle::FormatList>().at(0).second);
260
261   setGeometryByWidth(width);
262 }
263
264 qreal ContentsChatItem::setGeometryByWidth(qreal w) {
265   if(w != width()) {
266     setWidth(w);
267     // compute height
268     int lines = 1;
269     WrapColumnFinder finder(this);
270     while(finder.nextWrapColumn() > 0)
271       lines++;
272     setHeight(lines * fontMetrics()->lineSpacing());
273   }
274   return height();
275 }
276
277 void ContentsChatItem::updateLayout() {
278   if(!privateData()) {
279     ContentsChatItemPrivate *data = new ContentsChatItemPrivate(createLayout(QTextOption::WrapAnywhere), findClickables(), this);
280     setPrivateData(data);
281   }
282
283   // Now layout
284   ChatLineModel::WrapList wrapList = data(ChatLineModel::WrapListRole).value<ChatLineModel::WrapList>();
285   if(!wrapList.count()) return; // empty chatitem
286
287   qreal h = 0;
288   WrapColumnFinder finder(this);
289   layout()->beginLayout();
290   forever {
291     QTextLine line = layout()->createLine();
292     if(!line.isValid())
293       break;
294
295     int col = finder.nextWrapColumn();
296     line.setNumColumns(col >= 0 ? col - line.textStart() : layout()->text().length());
297     line.setPosition(QPointF(0, h));
298     h += fontMetrics()->lineSpacing();
299   }
300   layout()->endLayout();
301 }
302
303 // NOTE: This method is not threadsafe and not reentrant!
304 //       (RegExps are not constant while matching, and they are static here for efficiency)
305 QList<ContentsChatItem::Clickable> ContentsChatItem::findClickables() {
306   // For matching URLs
307   static QString urlEnd("(?:>|[,.;:\"]*\\s|\\b|$)");
308   static QString urlChars("(?:[\\w\\-~@/?&=+$()!%#]|[,.;:]\\w)");
309
310   static QRegExp regExp[] = {
311     // URL
312     // QRegExp(QString("((?:https?://|s?ftp://|irc://|mailto:|www\\.)%1+|%1+\\.[a-z]{2,4}(?:?=/%1+|\\b))%2").arg(urlChars, urlEnd)),
313     QRegExp(QString("((?:(?:https?://|s?ftp://|irc://|mailto:)|www)%1+)%2").arg(urlChars, urlEnd)),
314
315     // Channel name
316     // We don't match for channel names starting with + or &, because that gives us a lot of false positives.
317     QRegExp("((?:#|![A-Z0-9]{5})[^,:\\s]+(?::[^,:\\s]+)?)\\b")
318
319     // TODO: Nicks, we'll need a filtering for only matching known nicknames further down if we do this
320   };
321
322   static const int regExpCount = 2;  // number of regexps in the array above
323
324   qint16 matches[] = { 0, 0, 0 };
325   qint16 matchEnd[] = { 0, 0, 0 };
326
327   QString str = data(ChatLineModel::DisplayRole).toString();
328
329   QList<Clickable> result;
330   qint16 idx = 0;
331   qint16 minidx;
332   int type = -1;
333
334   do {
335     type = -1;
336     minidx = str.length();
337     for(int i = 0; i < regExpCount; i++) {
338       if(matches[i] < 0 || matchEnd[i] > str.length()) continue;
339       if(idx >= matchEnd[i]) {
340         matches[i] = str.indexOf(regExp[i], qMax(matchEnd[i], idx));
341         if(matches[i] >= 0) matchEnd[i] = matches[i] + regExp[i].cap(1).length();
342       }
343       if(matches[i] >= 0 && matches[i] < minidx) {
344         minidx = matches[i];
345         type = i;
346       }
347     }
348     if(type >= 0) {
349       idx = matchEnd[type];
350       if(type == Clickable::Url && str.at(idx-1) == ')') {  // special case: closing paren only matches if we had an open one
351         if(!str.mid(matches[type], matchEnd[type]-matches[type]).contains('(')) matchEnd[type]--;
352       }
353       result.append(Clickable((Clickable::Type)type, matches[type], matchEnd[type] - matches[type]));
354     }
355   } while(type >= 0);
356
357   /* testing
358   if(!result.isEmpty()) qDebug() << str;
359   foreach(Clickable click, result) {
360     qDebug() << str.mid(click.start, click.length);
361   }
362   */
363   return result;
364 }
365
366 QVector<QTextLayout::FormatRange> ContentsChatItem::additionalFormats() const {
367   // mark a clickable if hovered upon
368   QVector<QTextLayout::FormatRange> fmt;
369   if(privateData()->currentClickable.isValid()) {
370     Clickable click = privateData()->currentClickable;
371     QTextLayout::FormatRange f;
372     f.start = click.start;
373     f.length = click.length;
374     f.format.setFontUnderline(true);
375     fmt.append(f);
376   }
377   return fmt;
378 }
379
380 void ContentsChatItem::endHoverMode() {
381   if(hasLayout() && privateData()->currentClickable.isValid()) {
382     setCursor(Qt::ArrowCursor);
383     privateData()->currentClickable = Clickable();
384     privateData()->clearPreview();
385     update();
386   }
387 }
388
389 void ContentsChatItem::mousePressEvent(QGraphicsSceneMouseEvent *event) {
390   privateData()->hasDragged = false;
391   ChatItem::mousePressEvent(event);
392 }
393
394 void ContentsChatItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) {
395   if(!event->buttons() && !privateData()->hasDragged) {
396     // got a click
397     Clickable click = privateData()->currentClickable;
398     if(click.isValid()) {
399       QString str = data(ChatLineModel::DisplayRole).toString().mid(click.start, click.length);
400       switch(click.type) {
401         case Clickable::Url:
402           if(!str.contains("://"))
403             str = "http://" + str;
404           QDesktopServices::openUrl(str);
405           break;
406         case Clickable::Channel:
407           // TODO join or whatever...
408           break;
409         default:
410           break;
411       }
412     }
413   }
414   ChatItem::mouseReleaseEvent(event);
415 }
416
417 void ContentsChatItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) {
418   // mouse move events always mean we're not hovering anymore...
419   endHoverMode();
420   // also, check if we have dragged the mouse
421   if(hasLayout() && !privateData()->hasDragged && event->buttons() & Qt::LeftButton
422     && (event->buttonDownScreenPos(Qt::LeftButton) - event->screenPos()).manhattanLength() >= QApplication::startDragDistance())
423     privateData()->hasDragged = true;
424   ChatItem::mouseMoveEvent(event);
425 }
426
427 void ContentsChatItem::hoverLeaveEvent(QGraphicsSceneHoverEvent *event) {
428   endHoverMode();
429   event->accept();
430 }
431
432 void ContentsChatItem::hoverMoveEvent(QGraphicsSceneHoverEvent *event) {
433   bool onClickable = false;
434   qint16 idx = posToCursor(event->pos());
435   for(int i = 0; i < privateData()->clickables.count(); i++) {
436     Clickable click = privateData()->clickables.at(i);
437     if(idx >= click.start && idx < click.start + click.length) {
438       if(click.type == Clickable::Url) {
439         onClickable = true;
440
441         if(!hasLayout())
442           updateLayout();
443
444         QTextLine line = layout()->lineForTextPosition(click.start);
445         qreal x = line.cursorToX(click.start);
446         qreal width = line.cursorToX(click.start + click.length) - x;
447         qreal height = line.height();
448         qreal y = height * line.lineNumber();
449         QRectF urlRect(x, y, width, height);
450         QString url = data(ChatLineModel::DisplayRole).toString().mid(click.start, click.length);
451         if(!url.contains("://"))
452           url = "http://" + url;
453         privateData()->loadWebPreview(url, urlRect);
454       } else if(click.type == Clickable::Channel) {
455         // TODO: don't make clickable if it's our own name
456         //onClickable = true; //FIXME disabled for now
457       }
458       if(onClickable) {
459         setCursor(Qt::PointingHandCursor);
460         privateData()->currentClickable = click;
461         update();
462         break;
463       }
464     }
465   }
466   if(!onClickable) endHoverMode();
467   event->accept();
468 }
469
470 // ****************************************
471 // ContentsChatItemPrivate
472 // ****************************************
473 ContentsChatItemPrivate::~ContentsChatItemPrivate() {
474   clearPreview();
475 }
476
477 void ContentsChatItemPrivate::loadWebPreview(const QString &url, const QRectF &urlRect) {
478   if(!controller)
479     controller = new PreviewController(contentsItem);
480   controller->loadPage(url, urlRect);
481 }
482
483 void ContentsChatItemPrivate::clearPreview() {
484   delete controller;
485   controller = 0;
486 }
487
488 ContentsChatItemPrivate::PreviewController::~PreviewController() {
489   if(previewItem) {
490     contentsItem->scene()->removeItem(previewItem);
491     delete previewItem;
492   }
493 }
494
495 void ContentsChatItemPrivate::PreviewController::loadPage(const QString &newUrl, const QRectF &urlRect) {
496   if(newUrl.isEmpty() || newUrl == url)
497     return;
498
499   url = newUrl;
500   QWebView *view = new QWebView;
501   connect(view, SIGNAL(loadFinished(bool)), this, SLOT(pageLoaded(bool)));
502   view->load(url);
503   previewItem = new ContentsChatItemPrivate::PreviewItem(view);
504
505   QPointF sPos = contentsItem->scenePos();
506   qreal previewY = sPos.y() + urlRect.y() + urlRect.height(); // bottom of url;
507   qreal previewX = sPos.x() + urlRect.x();
508   if(previewY + previewItem->boundingRect().height() > contentsItem->scene()->sceneRect().bottom())
509     previewY = sPos.y() + urlRect.y() - previewItem->boundingRect().height();
510
511   if(previewX + previewItem->boundingRect().width() > contentsItem->scene()->sceneRect().width())
512     previewX = contentsItem->scene()->sceneRect().right() - previewItem->boundingRect().width();
513
514   previewItem->setPos(previewX, previewY);
515   contentsItem->scene()->addItem(previewItem);
516 }
517
518 void ContentsChatItemPrivate::PreviewController::pageLoaded(bool success) {
519   Q_UNUSED(success)
520 }
521
522 ContentsChatItemPrivate::PreviewItem::PreviewItem(QWebView *webView)
523   : QGraphicsItem(0), // needs to be a top level item as we otherwise cannot guarantee that it's on top of other chatlines
524     _boundingRect(0, 0, 400, 300)
525 {
526   qreal frameWidth = 5;
527   webView->resize(1000, 750);
528   QGraphicsProxyWidget *proxyItem = new QGraphicsProxyWidget(this);
529   proxyItem->setWidget(webView);
530   proxyItem->setAcceptHoverEvents(false);
531
532   qreal xScale = (_boundingRect.width() - 2 * frameWidth) / webView->width();
533   qreal yScale = (_boundingRect.height() - 2 * frameWidth) / webView->height();
534   proxyItem->scale(xScale, yScale);
535   proxyItem->setPos(frameWidth, frameWidth);
536
537   setZValue(30);
538 }
539
540 void ContentsChatItemPrivate::PreviewItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) {
541   Q_UNUSED(option); Q_UNUSED(widget);
542   painter->setClipRect(boundingRect());
543   painter->setPen(QPen(Qt::black, 5));
544   painter->setBrush(Qt::black);
545   painter->setRenderHints(QPainter::Antialiasing);
546   painter->drawRoundedRect(boundingRect(), 10, 10);
547
548   painter->setPen(QPen(Qt::green));
549   QString text = QString::number(zValue());
550   painter->drawText(_boundingRect.center(), text);
551 }
552
553 /*************************************************************************************************/
554
555 ContentsChatItem::WrapColumnFinder::WrapColumnFinder(ChatItem *_item)
556   : item(_item),
557     layout(0),
558     wrapList(item->data(ChatLineModel::WrapListRole).value<ChatLineModel::WrapList>()),
559     wordidx(0),
560     lineCount(0),
561     choppedTrailing(0)
562 {
563 }
564
565 ContentsChatItem::WrapColumnFinder::~WrapColumnFinder() {
566   delete layout;
567 }
568
569 qint16 ContentsChatItem::WrapColumnFinder::nextWrapColumn() {
570   if(wordidx >= wrapList.count())
571     return -1;
572
573   lineCount++;
574   qreal targetWidth = lineCount * item->width() + choppedTrailing;
575
576   qint16 start = wordidx;
577   qint16 end = wrapList.count() - 1;
578
579   // check if the whole line fits
580   if(wrapList.at(end).endX <= targetWidth) //  || start == end)
581     return -1;
582
583   // check if we have a very long word that needs inter word wrap
584   if(wrapList.at(start).endX > targetWidth) {
585     if(!line.isValid()) {
586       layout = item->createLayout(QTextOption::NoWrap);
587       layout->beginLayout();
588       line = layout->createLine();
589       layout->endLayout();
590     }
591     return line.xToCursor(targetWidth, QTextLine::CursorOnCharacter);
592   }
593
594   while(true) {
595     if(start + 1 == end) {
596       wordidx = end;
597       const ChatLineModel::Word &lastWord = wrapList.at(start); // the last word we were able to squeeze in
598
599       // both cases should be cought preliminary
600       Q_ASSERT(lastWord.endX <= targetWidth); // ensure that "start" really fits in
601       Q_ASSERT(end < wrapList.count()); // ensure that start isn't the last word
602
603       choppedTrailing += lastWord.trailing - (targetWidth - lastWord.endX);
604       return wrapList.at(wordidx).start;
605     }
606
607     qint16 pivot = (end + start) / 2;
608     if(wrapList.at(pivot).endX > targetWidth) {
609       end = pivot;
610     } else {
611       start = pivot;
612     }
613   }
614   Q_ASSERT(false);
615   return -1;
616 }
617