Revamping ChatView/ChatScene's mouse handling
[quassel.git] / src / qtui / chatitem.cpp
index 758de8d..0900c7e 100644 (file)
@@ -26,8 +26,8 @@
 #include <QPainter>
 #include <QPalette>
 #include <QTextLayout>
-#include <QWebView>
-#include <QGraphicsProxyWidget>
+#include <QMenu>
+
 
 #include "chatitem.h"
 #include "chatlinemodel.h"
@@ -59,7 +59,7 @@ QVariant ChatItem::data(int role) const {
   return model()->data(index, role);
 }
 
-QTextLayout *ChatItem::createLayout(QTextOption::WrapMode wrapMode, Qt::Alignment alignment) {
+QTextLayout *ChatItem::createLayout(QTextOption::WrapMode wrapMode, Qt::Alignment alignment) const {
   QTextLayout *layout = new QTextLayout(data(MessageModel::DisplayRole).toString());
 
   QTextOption option;
@@ -73,10 +73,7 @@ QTextLayout *ChatItem::createLayout(QTextOption::WrapMode wrapMode, Qt::Alignmen
   return layout;
 }
 
-void ChatItem::updateLayout() {
-  if(!privateData()) {
-    setPrivateData(new ChatItemPrivate(createLayout()));
-  }
+void ChatItem::doLayout() {
   QTextLayout *layout_ = layout();
   layout_->beginLayout();
   QTextLine line = layout_->createLine();
@@ -92,56 +89,56 @@ void ChatItem::clearLayout() {
   _data = 0;
 }
 
+ChatItemPrivate *ChatItem::privateData() const {
+  if(!_data) {
+    ChatItem *that = const_cast<ChatItem *>(this);
+    that->_data = that->newPrivateData();
+    that->doLayout();
+  }
+  return _data;
+}
+
 // NOTE: This is not the most time-efficient implementation, but it saves space by not caching unnecessary data
 //       This is a deliberate trade-off. (-> selectFmt creation, data() call)
 void ChatItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) {
   Q_UNUSED(option); Q_UNUSED(widget);
-  if(!hasLayout())
-    updateLayout();
   painter->setClipRect(boundingRect()); // no idea why QGraphicsItem clipping won't work
-  //if(_selectionMode == FullSelection) {
-    //painter->save();
-    //painter->fillRect(boundingRect(), QApplication::palette().brush(QPalette::Highlight));
-    //painter->restore();
-  //}
   QVector<QTextLayout::FormatRange> formats = additionalFormats();
-  if(_selectionMode != NoSelection) {
-    QTextLayout::FormatRange selectFmt;
-    selectFmt.format.setForeground(QApplication::palette().brush(QPalette::HighlightedText));
-    selectFmt.format.setBackground(QApplication::palette().brush(QPalette::Highlight));
-    if(_selectionMode == PartialSelection) {
-      selectFmt.start = qMin(_selectionStart, _selectionEnd);
-      selectFmt.length = qAbs(_selectionStart - _selectionEnd);
-    } else { // FullSelection
-      selectFmt.start = 0;
-      selectFmt.length = data(MessageModel::DisplayRole).toString().length();
-    }
-    formats.append(selectFmt);
-  }
+  QTextLayout::FormatRange selectFmt = selectionFormat();
+  if(selectFmt.format.isValid()) formats.append(selectFmt);
   layout()->draw(painter, QPointF(0,0), formats, boundingRect());
 
   // Debuging Stuff
-  // uncomment the following lines to draw the bounding rect and the row number in alternating colors
+  // uncomment partially or all of the following stuff:
+  //
+  // 0) alternativ painter color for debug stuff
 //   if(row() % 2)
 //     painter->setPen(Qt::red);
 //   else
 //     painter->setPen(Qt::blue);
-//   QString rowString = QString::number(row());
-//   QRect rowRect = painter->fontMetrics().boundingRect(rowString);
-//   QPointF topPoint = _boundingRect.topLeft();
-//   topPoint.ry() += rowRect.height();
-//   painter->drawText(topPoint, rowString);
-//   QPointF bottomPoint = _boundingRect.bottomRight();
-//   bottomPoint.rx() -= rowRect.width();
-//   painter->drawText(bottomPoint, rowString);
+  // 1) draw wordwrap points in the first line
+//   if(column() == 2) {
+//     ChatLineModel::WrapList wrapList = data(ChatLineModel::WrapListRole).value<ChatLineModel::WrapList>();
+//     foreach(ChatLineModel::Word word, wrapList) {
+//       if(word.endX > width())
+//     break;
+//       painter->drawLine(word.endX, 0, word.endX, height());
+//     }
+//   }
+  // 2) draw MsgId over the time column
+//   if(column() == 0) {
+//     QString msgIdString = QString::number(data(MessageModel::MsgIdRole).value<MsgId>().toInt());
+//     QPointF bottomPoint = boundingRect().bottomLeft();
+//     bottomPoint.ry() -= 2;
+//     painter->drawText(bottomPoint, msgIdString);
+//   }
+  // 3) draw bounding rect
 //   painter->drawRect(_boundingRect.adjusted(0, 0, -1, -1));
 }
 
-qint16 ChatItem::posToCursor(const QPointF &pos) {
+qint16 ChatItem::posToCursor(const QPointF &pos) const {
   if(pos.y() > height()) return data(MessageModel::DisplayRole).toString().length();
   if(pos.y() < 0) return 0;
-  if(!hasLayout())
-    updateLayout();
   for(int l = layout()->lineCount() - 1; l >= 0; l--) {
     QTextLine line = layout()->lineAt(l);
     if(pos.y() >= line.y()) {
@@ -151,6 +148,23 @@ qint16 ChatItem::posToCursor(const QPointF &pos) {
   return 0;
 }
 
+bool ChatItem::hasSelection() const {
+  if(_selectionMode == NoSelection)
+    return false;
+  if(_selectionMode == FullSelection)
+    return true;
+  // partial
+  return _selectionStart != _selectionEnd;
+}
+
+QString ChatItem::selection() const {
+  if(_selectionMode == FullSelection)
+    return data(MessageModel::DisplayRole).toString();
+  if(_selectionMode == PartialSelection)
+    return data(MessageModel::DisplayRole).toString().mid(qMin(_selectionStart, _selectionEnd), qAbs(_selectionStart - _selectionEnd));
+  return QString();
+}
+
 void ChatItem::setFullSelection() {
   if(_selectionMode != FullSelection) {
     _selectionMode = FullSelection;
@@ -169,6 +183,35 @@ void ChatItem::continueSelecting(const QPointF &pos) {
   update();
 }
 
+bool ChatItem::isPosOverSelection(const QPointF &pos) const {
+  if(_selectionMode == FullSelection)
+    return true;
+  if(_selectionMode == PartialSelection) {
+    int cursor = posToCursor(pos); qDebug() << cursor << _selectionStart << _selectionEnd;
+    return cursor >= qMin(_selectionStart, _selectionEnd) && cursor <= qMax(_selectionStart, _selectionEnd);
+  }
+  return false;
+}
+
+QTextLayout::FormatRange ChatItem::selectionFormat() const {
+  QTextLayout::FormatRange selectFmt;
+  if(_selectionMode != NoSelection) {
+    selectFmt.format.setForeground(QApplication::palette().brush(QPalette::HighlightedText));
+    selectFmt.format.setBackground(QApplication::palette().brush(QPalette::Highlight));
+    if(_selectionMode == PartialSelection) {
+      selectFmt.start = qMin(_selectionStart, _selectionEnd);
+      selectFmt.length = qAbs(_selectionStart - _selectionEnd);
+    } else { // FullSelection
+      selectFmt.start = 0;
+      selectFmt.length = data(MessageModel::DisplayRole).toString().length();
+    }
+  } else {
+    selectFmt.start = -1;
+    selectFmt.length = 0;
+  }
+  return selectFmt;
+}
+
 QList<QRectF> ChatItem::findWords(const QString &searchWord, Qt::CaseSensitivity caseSensitive) {
   QList<QRectF> resultList;
   const QAbstractItemModel *model_ = model();
@@ -183,9 +226,7 @@ QList<QRectF> ChatItem::findWords(const QString &searchWord, Qt::CaseSensitivity
     searchIdx = plainText.indexOf(searchWord, searchIdx + 1, caseSensitive);
   }
 
-  bool hadLayout = hasLayout();
-  if(!hadLayout)
-    updateLayout();
+  bool hadPrivateData = hasPrivateData();
 
   foreach(int idx, indexList) {
     QTextLine line = layout()->lineForTextPosition(idx);
@@ -196,20 +237,17 @@ QList<QRectF> ChatItem::findWords(const QString &searchWord, Qt::CaseSensitivity
     resultList << QRectF(x, y, width, height);
   }
 
-  if(!hadLayout)
+  if(!hadPrivateData)
     clearLayout();
   return resultList;
 }
 
-void ChatItem::mousePressEvent(QGraphicsSceneMouseEvent *event) {
-  if(event->buttons() == Qt::LeftButton) {
+void ChatItem::handleClick(const QPointF &pos, ChatScene::ClickMode clickMode) {
+  if(clickMode == ChatScene::SingleClick) {
     chatScene()->setSelectingItem(this);
-    _selectionStart = _selectionEnd = posToCursor(event->pos());
+    _selectionStart = _selectionEnd = posToCursor(pos);
     _selectionMode = NoSelection; // will be set to PartialSelection by mouseMoveEvent
     update();
-    event->accept();
-  } else {
-    event->ignore();
   }
 }
 
@@ -232,6 +270,14 @@ void ChatItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) {
   }
 }
 
+void ChatItem::mousePressEvent(QGraphicsSceneMouseEvent *event) {
+  if(event->buttons() == Qt::LeftButton) {
+    event->accept();
+  } else {
+    event->ignore();
+  }
+}
+
 void ChatItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) {
   if(_selectionMode != NoSelection && !event->buttons() & Qt::LeftButton) {
     _selectionEnd = posToCursor(event->pos());
@@ -248,6 +294,51 @@ void ChatItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) {
 // SenderChatItem
 // ************************************************************
 
+void SenderChatItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) {
+  Q_UNUSED(option); Q_UNUSED(widget);
+
+  painter->setClipRect(boundingRect()); // no idea why QGraphicsItem clipping won't work
+  qreal layoutWidth = layout()->minimumWidth();
+  qreal offset = 0;
+  if(chatScene()->senderCutoffMode() == ChatScene::CutoffLeft)
+    offset = qMin(width() - layoutWidth, (qreal)0);
+  else
+    offset = qMax(layoutWidth - width(), (qreal)0);
+
+  QTextLayout::FormatRange selectFmt = selectionFormat();
+
+  if(layoutWidth > width()) {
+    // Draw a nice gradient for longer items
+    // Qt's text drawing with a gradient brush sucks, so we use an alpha-channeled pixmap instead
+    QPixmap pixmap(layout()->boundingRect().toRect().size());
+    pixmap.fill(Qt::transparent);
+    QPainter pixPainter(&pixmap);
+    layout()->draw(&pixPainter, QPointF(qMax(offset, (qreal)0), 0), QVector<QTextLayout::FormatRange>() << selectFmt);
+    pixPainter.end();
+
+    // Create alpha channel mask
+    QPixmap mask(pixmap.size());
+    QPainter maskPainter(&mask);
+    QLinearGradient gradient;
+    if(offset < 0) {
+      gradient.setStart(0, 0);
+      gradient.setFinalStop(12, 0);
+      gradient.setColorAt(0, Qt::black);
+      gradient.setColorAt(1, Qt::white);
+    } else {
+      gradient.setStart(width()-10, 0);
+      gradient.setFinalStop(width(), 0);
+      gradient.setColorAt(0, Qt::white);
+      gradient.setColorAt(1, Qt::black);
+    }
+    maskPainter.fillRect(boundingRect(), gradient);
+    pixmap.setAlphaChannel(mask);
+    painter->drawPixmap(0, 0, pixmap);
+  } else {
+    layout()->draw(painter, QPointF(0,0), QVector<QTextLayout::FormatRange>() << selectFmt, boundingRect());
+  }
+}
+
 // ************************************************************
 // ContentsChatItem
 // ************************************************************
@@ -263,6 +354,7 @@ ContentsChatItem::ContentsChatItem(const qreal &width, const QPointF &pos, QGrap
 
 qreal ContentsChatItem::setGeometryByWidth(qreal w) {
   if(w != width()) {
+    prepareGeometryChange();
     setWidth(w);
     // compute height
     int lines = 1;
@@ -274,13 +366,7 @@ qreal ContentsChatItem::setGeometryByWidth(qreal w) {
   return height();
 }
 
-void ContentsChatItem::updateLayout() {
-  if(!privateData()) {
-    ContentsChatItemPrivate *data = new ContentsChatItemPrivate(createLayout(QTextOption::WrapAnywhere), findClickables(), this);
-    setPrivateData(data);
-  }
-
-  // Now layout
+void ContentsChatItem::doLayout() {
   ChatLineModel::WrapList wrapList = data(ChatLineModel::WrapListRole).value<ChatLineModel::WrapList>();
   if(!wrapList.count()) return; // empty chatitem
 
@@ -302,19 +388,19 @@ void ContentsChatItem::updateLayout() {
 
 // NOTE: This method is not threadsafe and not reentrant!
 //       (RegExps are not constant while matching, and they are static here for efficiency)
-QList<ContentsChatItem::Clickable> ContentsChatItem::findClickables() {
+QList<ContentsChatItem::Clickable> ContentsChatItem::findClickables() const {
   // For matching URLs
   static QString urlEnd("(?:>|[,.;:\"]*\\s|\\b|$)");
-  static QString urlChars("(?:[\\w\\-~@/?&=+$()!%#]|[,.;:]\\w)");
+  static QString urlChars("(?:[,.;:]*[\\w\\-~@/?&=+$()!%#*|{}\\[\\]])");
 
   static QRegExp regExp[] = {
     // URL
     // QRegExp(QString("((?:https?://|s?ftp://|irc://|mailto:|www\\.)%1+|%1+\\.[a-z]{2,4}(?:?=/%1+|\\b))%2").arg(urlChars, urlEnd)),
-    QRegExp(QString("((?:(?:https?://|s?ftp://|irc://|mailto:)|www)%1+)%2").arg(urlChars, urlEnd)),
+    QRegExp(QString("((?:(?:https?://|s?ftp://|irc://|mailto:)|www)%1+)%2").arg(urlChars, urlEnd), Qt::CaseInsensitive),
 
     // Channel name
     // We don't match for channel names starting with + or &, because that gives us a lot of false positives.
-    QRegExp("((?:#|![A-Z0-9]{5})[^,:\\s]+(?::[^,:\\s]+)?)\\b")
+    QRegExp("((?:#|![A-Z0-9]{5})[^,:\\s]+(?::[^,:\\s]+)?)\\b", Qt::CaseInsensitive)
 
     // TODO: Nicks, we'll need a filtering for only matching known nicknames further down if we do this
   };
@@ -378,10 +464,12 @@ QVector<QTextLayout::FormatRange> ContentsChatItem::additionalFormats() const {
 }
 
 void ContentsChatItem::endHoverMode() {
-  if(hasLayout() && privateData()->currentClickable.isValid()) {
-    setCursor(Qt::ArrowCursor);
-    privateData()->currentClickable = Clickable();
-    privateData()->clearPreview();
+  if(hasPrivateData()) {
+    if(privateData()->currentClickable.isValid()) {
+      setCursor(Qt::ArrowCursor);
+      privateData()->currentClickable = Clickable();
+    }
+    clearWebPreview();
     update();
   }
 }
@@ -401,7 +489,7 @@ void ContentsChatItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) {
         case Clickable::Url:
          if(!str.contains("://"))
            str = "http://" + str;
-          QDesktopServices::openUrl(str);
+          QDesktopServices::openUrl(QUrl::fromEncoded(str.toAscii()));
           break;
         case Clickable::Channel:
           // TODO join or whatever...
@@ -418,7 +506,7 @@ void ContentsChatItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) {
   // mouse move events always mean we're not hovering anymore...
   endHoverMode();
   // also, check if we have dragged the mouse
-  if(hasLayout() && !privateData()->hasDragged && event->buttons() & Qt::LeftButton
+  if(hasPrivateData() && !privateData()->hasDragged && event->buttons() & Qt::LeftButton
     && (event->buttonDownScreenPos(Qt::LeftButton) - event->screenPos()).manhattanLength() >= QApplication::startDragDistance())
     privateData()->hasDragged = true;
   ChatItem::mouseMoveEvent(event);
@@ -437,20 +525,7 @@ void ContentsChatItem::hoverMoveEvent(QGraphicsSceneHoverEvent *event) {
     if(idx >= click.start && idx < click.start + click.length) {
       if(click.type == Clickable::Url) {
         onClickable = true;
-
-       if(!hasLayout())
-         updateLayout();
-
-       QTextLine line = layout()->lineForTextPosition(click.start);
-       qreal x = line.cursorToX(click.start);
-       qreal width = line.cursorToX(click.start + click.length) - x;
-       qreal height = line.height();
-       qreal y = height * line.lineNumber();
-       QRectF urlRect(x, y, width, height);
-       QString url = data(ChatLineModel::DisplayRole).toString().mid(click.start, click.length);
-       if(!url.contains("://"))
-         url = "http://" + url;
-       privateData()->loadWebPreview(url, urlRect);
+       showWebPreview(click);
       } else if(click.type == Clickable::Channel) {
         // TODO: don't make clickable if it's our own name
         //onClickable = true; //FIXME disabled for now
@@ -467,87 +542,53 @@ void ContentsChatItem::hoverMoveEvent(QGraphicsSceneHoverEvent *event) {
   event->accept();
 }
 
-// ****************************************
-// ContentsChatItemPrivate
-// ****************************************
-ContentsChatItemPrivate::~ContentsChatItemPrivate() {
-  clearPreview();
-}
-
-void ContentsChatItemPrivate::loadWebPreview(const QString &url, const QRectF &urlRect) {
-  if(!controller)
-    controller = new PreviewController(contentsItem);
-  controller->loadPage(url, urlRect);
-}
-
-void ContentsChatItemPrivate::clearPreview() {
-  delete controller;
-  controller = 0;
-}
-
-ContentsChatItemPrivate::PreviewController::~PreviewController() {
-  if(previewItem) {
-    contentsItem->scene()->removeItem(previewItem);
-    delete previewItem;
+void ContentsChatItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event) {
+  qint16 idx = posToCursor(event->pos());
+  for(int i = 0; i < privateData()->clickables.count(); i++) {
+    Clickable click = privateData()->clickables.at(i);
+    if(idx >= click.start && idx < click.start + click.length) {
+      if(click.type == Clickable::Url) {
+        QMenu menu;
+        QAction *copyToClipboard = menu.addAction(QObject::tr("Copy to Clipboard"));
+        QAction *selected = menu.exec(event->screenPos());
+        if(selected == copyToClipboard) {
+          QString url = data(ChatLineModel::DisplayRole).toString().mid(click.start, click.length);
+#   ifdef Q_WS_X11
+          QApplication::clipboard()->setText(url, QClipboard::Selection);
+#   endif
+//# else
+          QApplication::clipboard()->setText(url);
+//# endif
+        }
+      }
+    }
   }
 }
 
-void ContentsChatItemPrivate::PreviewController::loadPage(const QString &newUrl, const QRectF &urlRect) {
-  if(newUrl.isEmpty() || newUrl == url)
-    return;
+void ContentsChatItem::showWebPreview(const Clickable &click) {
+#ifndef HAVE_WEBKIT
+  Q_UNUSED(click);
+#else
+  QTextLine line = layout()->lineForTextPosition(click.start);
+  qreal x = line.cursorToX(click.start);
+  qreal width = line.cursorToX(click.start + click.length) - x;
+  qreal height = line.height();
+  qreal y = height * line.lineNumber();
 
-  url = newUrl;
-  QWebView *view = new QWebView;
-  connect(view, SIGNAL(loadFinished(bool)), this, SLOT(pageLoaded(bool)));
-  view->load(url);
-  previewItem = new ContentsChatItemPrivate::PreviewItem(view);
+  QPointF topLeft = scenePos() + QPointF(x, y);
+  QRectF urlRect = QRectF(topLeft.x(), topLeft.y(), width, height);
 
-  QPointF sPos = contentsItem->scenePos();
-  qreal previewY = sPos.y() + urlRect.y() + urlRect.height(); // bottom of url;
-  qreal previewX = sPos.x() + urlRect.x();
-  if(previewY + previewItem->boundingRect().height() > contentsItem->scene()->sceneRect().bottom())
-    previewY = sPos.y() + urlRect.y() - previewItem->boundingRect().height();
-
-  if(previewX + previewItem->boundingRect().width() > contentsItem->scene()->sceneRect().width())
-    previewX = contentsItem->scene()->sceneRect().right() - previewItem->boundingRect().width();
-
-  previewItem->setPos(previewX, previewY);
-  contentsItem->scene()->addItem(previewItem);
-}
-
-void ContentsChatItemPrivate::PreviewController::pageLoaded(bool success) {
-  Q_UNUSED(success)
-}
-
-ContentsChatItemPrivate::PreviewItem::PreviewItem(QWebView *webView)
-  : QGraphicsItem(0), // needs to be a top level item as we otherwise cannot guarantee that it's on top of other chatlines
-    _boundingRect(0, 0, 400, 300)
-{
-  qreal frameWidth = 5;
-  webView->resize(1000, 750);
-  QGraphicsProxyWidget *proxyItem = new QGraphicsProxyWidget(this);
-  proxyItem->setWidget(webView);
-  proxyItem->setAcceptHoverEvents(false);
-
-  qreal xScale = (_boundingRect.width() - 2 * frameWidth) / webView->width();
-  qreal yScale = (_boundingRect.height() - 2 * frameWidth) / webView->height();
-  proxyItem->scale(xScale, yScale);
-  proxyItem->setPos(frameWidth, frameWidth);
-
-  setZValue(30);
+  QString url = data(ChatLineModel::DisplayRole).toString().mid(click.start, click.length);
+  if(!url.contains("://"))
+    url = "http://" + url;
+  chatScene()->loadWebPreview(this, url, urlRect);
+#endif
 }
 
-void ContentsChatItemPrivate::PreviewItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) {
-  Q_UNUSED(option); Q_UNUSED(widget);
-  painter->setClipRect(boundingRect());
-  painter->setPen(QPen(Qt::black, 5));
-  painter->setBrush(Qt::black);
-  painter->setRenderHints(QPainter::Antialiasing);
-  painter->drawRoundedRect(boundingRect(), 10, 10);
-
-  painter->setPen(QPen(Qt::green));
-  QString text = QString::number(zValue());
-  painter->drawText(_boundingRect.center(), text);
+void ContentsChatItem::clearWebPreview() {
+#ifdef HAVE_WEBKIT
+  chatScene()->clearWebPreview(this);
+#endif
 }
 
 /*************************************************************************************************/