diff --git a/src/irc/messagemodel.cpp b/src/irc/messagemodel.cpp index 10a101b7..b1cb84ba 100644 --- a/src/irc/messagemodel.cpp +++ b/src/irc/messagemodel.cpp @@ -1,201 +1,321 @@ /* This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. */ /* Copyright (C) 2017 Eike Hein */ #include "messagemodel.h" +#include +#include #include #define MAX_MESSAGES 500000 #define MAX_MESSAGES_TOLERANCE 501000 #define ALLOCATION_BATCH_SIZE 500 FilteredMessageModel::FilteredMessageModel(QObject *parent) : QSortFilterProxyModel(parent) , m_filterView(nullptr) { + m_selectionModel = new QItemSelectionModel(this, this); + connect(m_selectionModel, &QItemSelectionModel::selectionChanged, + this, &FilteredMessageModel::selectionChanged); } FilteredMessageModel::~FilteredMessageModel() { } QObject *FilteredMessageModel::filterView() const { return m_filterView; } void FilteredMessageModel::setFilterView(QObject *view) { if (m_filterView != view) { m_filterView = view; + clearSelection(); invalidateFilter(); emit filterViewChanged(); } } +bool FilteredMessageModel::hasSelection() +{ + return m_selectionModel->hasSelection(); +} + +bool FilteredMessageModel::isSelected(int row) +{ + if (row < 0) { + return false; + } + + return m_selectionModel->isSelected(index(row, 0)); +} + +void FilteredMessageModel::setSelected(int row) +{ + if (row < 0) { + return; + } + + m_selectionModel->select(index(row, 0), QItemSelectionModel::Select); +} + +void FilteredMessageModel::toggleSelected(int row) +{ + if (row < 0) { + return; + } + + m_selectionModel->select(index(row, 0), QItemSelectionModel::Toggle); +} + +void FilteredMessageModel::setRangeSelected(int anchor, int to) +{ + if (anchor < 0 || to < 0) { + return; + } + + QItemSelection selection(index(anchor, 0), index(to, 0)); + m_selectionModel->select(selection, QItemSelectionModel::ClearAndSelect); +} + +void FilteredMessageModel::updateSelection(const QVariantList &rows, bool toggle) +{ + Q_UNUSED(toggle) + + QItemSelection newSelection; + + int iRow = -1; + + foreach (const QVariant &row, rows) { + iRow = row.toInt(); + + if (iRow < 0) { + return; + } + + const QModelIndex &idx = index(iRow, 0); + newSelection.select(idx, idx); + } + + m_selectionModel->select(newSelection, QItemSelectionModel::ClearAndSelect); +} + +void FilteredMessageModel::clearSelection() +{ + if (m_selectionModel->hasSelection()) { + m_selectionModel->clear(); + } +} + +void FilteredMessageModel::selectionChanged(const QItemSelection &selected, const QItemSelection &deselected) +{ + QModelIndexList indices = selected.indexes(); + indices.append(deselected.indexes()); + + foreach(const QModelIndex index, indices) { + emit dataChanged(index, index, QVector{MessageModel::Selected}); + } + + copySelectionToClipboard(QClipboard::Selection); +} + +void FilteredMessageModel::copySelectionToClipboard(QClipboard::Mode mode) +{ + if (!hasSelection()) { + return; + } + + QClipboard *clipboard = QGuiApplication::clipboard(); + + if (!clipboard) { + return; + } + + QStringList selected; + + foreach (const QModelIndex &index, m_selectionModel->selectedIndexes()) + { + selected.append(index.data(MessageModel::ClipboardSerialization).toString()); + } + + clipboard->setText(selected.join('\n'), mode); +} + bool FilteredMessageModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const { Q_UNUSED(sourceParent) if (!m_filterView) { return false; } const QModelIndex &sourceIdx = sourceModel()->index(sourceRow, 0); const QObject *view = qvariant_cast(sourceIdx.data(MessageModel::View)); if (view && view == m_filterView) { return true; } return false; } QVariant FilteredMessageModel::data(const QModelIndex &index, int role) const { - if (role == MessageModel::AuthorMatchesPrecedingMessage) { + if (role == MessageModel::Selected) { + return m_selectionModel->isSelected(index); + } else if (role == MessageModel::AuthorMatchesPrecedingMessage) { const int precedingMessageRow = index.row() - 1; if (precedingMessageRow >= 0) { const QModelIndex &precedingMessage = QSortFilterProxyModel::index(precedingMessageRow, 0); return (index.data(MessageModel::Author) == precedingMessage.data(MessageModel::Author)); } return false; - } - - if (role == MessageModel::TimeStampMatchesPrecedingMessage) { + } else if (role == MessageModel::TimeStampMatchesPrecedingMessage) { const int precedingMessageRow = index.row() - 1; if (precedingMessageRow >= 0) { const QModelIndex &precedingMessage = QSortFilterProxyModel::index(precedingMessageRow, 0); return (index.data(MessageModel::TimeStamp) == precedingMessage.data(MessageModel::TimeStamp)); } return false; } return QSortFilterProxyModel::data(index, role); } MessageModel::MessageModel(QObject *parent) : QAbstractListModel(parent) , m_allocCount(0) { // Pre-allocate batch. m_messages.reserve(ALLOCATION_BATCH_SIZE); } MessageModel::~MessageModel() { } QHash MessageModel::roleNames() const { QHash roles = QAbstractItemModel::roleNames(); QMetaEnum e = metaObject()->enumerator(metaObject()->indexOfEnumerator("AdditionalRoles")); for (int i = 0; i < e.keyCount(); ++i) { roles.insert(e.value(i), e.key(i)); } return roles; } QVariant MessageModel::data(const QModelIndex &index, int role) const { if (!index.isValid() || index.row() >= m_messages.count()) { return QVariant(); } const Message &msg = m_messages.at(index.row()); if (role == Qt::DisplayRole) { return msg.text; } else if (role == Type) { return (msg.action ? ActionMessage : NormalMessage); } else if (role == View) { return qVariantFromValue(msg.view); } else if (role == TimeStamp) { return msg.timeStamp; } else if (role == Author) { return msg.nick; } else if (role == NickColor) { return msg.nickColor; + } else if (role == ClipboardSerialization) { + return clipboardSerialization(msg); } return QVariant(); } int MessageModel::rowCount(const QModelIndex &parent) const { // Limit model to MAX_MESSAGES. return parent.isValid() ? 0 : qMax(MAX_MESSAGES, m_messages.count()); } void MessageModel::appendMessage(QObject *view, const QString &timeStamp, const QString &nick, const QColor &nickColor, const QString &text, const MessageType type) { beginInsertRows(QModelIndex(), m_messages.count(), m_messages.count()); Message msg; msg.view = view; msg.timeStamp = timeStamp; msg.nick = nick; msg.nickColor = nickColor; msg.text = text; msg.action = (type == ActionMessage); m_messages.append(msg); endInsertRows(); ++m_allocCount; // Grow the list in batches to make the insertion a little // faster. if (m_allocCount == ALLOCATION_BATCH_SIZE) { m_allocCount = 0; m_messages.reserve(m_messages.count() + ALLOCATION_BATCH_SIZE); } // Whenever we grow above MAX_MESSAGES_TOLERANCE, cull to // MAX_MESSAGES. I.e. we cull in batches, not on every new // message. if (m_messages.count() > MAX_MESSAGES_TOLERANCE) { m_messages = m_messages.mid(MAX_MESSAGES_TOLERANCE, MAX_MESSAGES); } } void MessageModel::cullMessages(const QObject *view) { int i = 0; while (i < m_messages.count()) { const Message &msg = m_messages.at(i); if (msg.view == view) { beginRemoveRows(QModelIndex(), i, i); m_messages.removeAt(i); endRemoveRows(); } else { ++i; } } } + +QString MessageModel::clipboardSerialization(const Message& msg) const +{ + // WIPQTQUICK TODO: msg.text is preformatted HTML, we need the raw in the + // model to derive this properly. + return QString("[%1] <%2> %3").arg(msg.timeStamp).arg(msg.nick).arg(msg.text); +} diff --git a/src/irc/messagemodel.h b/src/irc/messagemodel.h index 940e03cb..4e637e49 100644 --- a/src/irc/messagemodel.h +++ b/src/irc/messagemodel.h @@ -1,95 +1,115 @@ /* This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. */ /* Copyright (C) 2017 Eike Hein */ #include "chatwindow.h" #include +#include #include #include +class QItemSelectionModel; + struct Message { QObject *view; QString timeStamp; QString nick; QColor nickColor; QString text; bool action; }; class FilteredMessageModel : public QSortFilterProxyModel { Q_OBJECT Q_PROPERTY(QObject* filterView READ filterView WRITE setFilterView NOTIFY filterViewChanged) public: explicit FilteredMessageModel(QObject *parent = 0); virtual ~FilteredMessageModel(); QObject *filterView() const; void setFilterView(QObject *view); + Q_INVOKABLE bool hasSelection(); + Q_INVOKABLE bool isSelected(int row); + Q_INVOKABLE void setSelected(int row); + Q_INVOKABLE void toggleSelected(int row); + Q_INVOKABLE void setRangeSelected(int anchor, int to); + Q_INVOKABLE void updateSelection(const QVariantList &rows, bool toggle); + Q_INVOKABLE void clearSelection(); + Q_INVOKABLE void copySelectionToClipboard(QClipboard::Mode mode = QClipboard::Clipboard); + virtual QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - signals: + Q_SIGNALS: void filterViewChanged() const; protected: bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; + private Q_SLOTS: + void selectionChanged(const QItemSelection &selected, const QItemSelection &deselected); + private: QObject *m_filterView; + QItemSelectionModel *m_selectionModel; }; class MessageModel : public QAbstractListModel { Q_OBJECT public: enum AdditionalRoles { - Type = Qt::UserRole + 1, + Selected = Qt::UserRole + 1, // WIPQTQUICK TODO This is implemented by FMM, maybe I should extend roles there. + Type, View, TimeStamp, TimeStampMatchesPrecedingMessage, // Implemented in FilteredMessageModel for search efficiency. Author, AuthorMatchesPrecedingMessage, // Implemented in FilteredMessageModel for search efficiency. NickColor, + ClipboardSerialization }; Q_ENUM(AdditionalRoles) enum MessageType { NormalMessage = 0, ActionMessage }; Q_ENUM(MessageType) explicit MessageModel(QObject *parent = 0); virtual ~MessageModel(); QHash roleNames() const override; virtual QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; int rowCount(const QModelIndex &parent = QModelIndex()) const override; Q_INVOKABLE void appendMessage(QObject *view, const QString &timeStamp, const QString &nick, const QColor &nickColor, const QString &text, const MessageType type = NormalMessage); void cullMessages(const QObject *view); private: + QString clipboardSerialization(const Message &msg) const; + QVector m_messages; int m_allocCount; }; diff --git a/src/qtquick/uipackages/default/contents/TextView.qml b/src/qtquick/uipackages/default/contents/TextView.qml index 6f4c4da3..8baf3dac 100644 --- a/src/qtquick/uipackages/default/contents/TextView.qml +++ b/src/qtquick/uipackages/default/contents/TextView.qml @@ -1,231 +1,297 @@ /* This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. */ /* Copyright (C) 2017 Eike Hein */ import QtQuick 2.7 import QtQuick.Controls 2.2 as QQC2 import org.kde.kirigami 2.1 as Kirigami import org.kde.konversation 1.0 as Konversation import org.kde.konversation.uicomponents 1.0 as KUIC -QQC2.ScrollView { +Item { id: textView - ListView { - id: textViewList + QQC2.ScrollView { + anchors.fill: parent - anchors.bottom: parent.bottom + ListView { + id: textListView - width: parent.width - height: parent.height + anchors.bottom: parent.bottom - visible: !konvUi.settingsMode + width: parent.width + height: parent.height - QQC2.ScrollBar.vertical: QQC2.ScrollBar {} + visible: !konvUi.settingsMode - readonly property int msgWidth: width - QQC2.ScrollBar.vertical.width + QQC2.ScrollBar.vertical: QQC2.ScrollBar {} - model: messageModel - delegate: msgComponent + readonly property int msgWidth: width - QQC2.ScrollBar.vertical.width - function scrollToEnd() { - var newIndex = (count - 1); - positionViewAtEnd(); - currentIndex = newIndex; - } + model: messageModel + delegate: msgComponent + + function scrollToEnd() { + var newIndex = (count - 1); + positionViewAtEnd(); + currentIndex = newIndex; + } - Connections { - target: textViewList.contentItem + Connections { + target: textListView.contentItem - onHeightChanged: { - if (textViewList.contentItem.height <= textView.height) { - textViewList.height = textViewList.contentItem.height; - } else { - textViewList.height = textView.height; + onHeightChanged: { + if (textListView.contentItem.height <= textView.height) { + textListView.height = textListView.contentItem.height; + } else { + textListView.height = textView.height; + } } } - } - Connections { - target: messageModel + Connections { + target: messageModel - onRowsInserted: scrollDownTimer.restart() - onRowsRemoved: scrollDownTimer.restart() - onModelReset: scrollDownTimer.restart() - } + onRowsInserted: scrollDownTimer.restart() + onRowsRemoved: scrollDownTimer.restart() + onModelReset: scrollDownTimer.restart() + } - Timer { - id: scrollDownTimer + Timer { + id: scrollDownTimer - interval: 0 - repeat: false + interval: 0 + repeat: false - onTriggered: textViewList.scrollToEnd() - } + onTriggered: textListView.scrollToEnd() + } + + Component { + id: msgComponent + + Loader { + id: msg + + width: ListView.view.msgWidth + height: (active ? (Math.max(avatarSize, konvUi.largerFontSize + messageText.height + Kirigami.Units.gridUnit)) + : messageText.height) - Component { - id: msgComponent + property int row: index - Loader { - id: msg + readonly property int avatarSize: konvUi.largerFontSize * 3.3 + property var authorSize: Qt.point(0, 0) - width: ListView.view.msgWidth - height: (active ? (Math.max(avatarSize, konvUi.largerFontSize + messageText.height + Kirigami.Units.gridUnit)) - : messageText.height) + property bool selected: model.Selected === true - readonly property int avatarSize: konvUi.largerFontSize * 3.3 - property var authorSize: Qt.point(0, 0) + readonly property bool showTimeStamp: !model.TimeStampMatchesPrecedingMessage + property Item timeStamp: null - readonly property bool showTimeStamp: !model.TimeStampMatchesPrecedingMessage - property Item timeStamp: null + property Item messageTextArea: messageText - active: !model.AuthorMatchesPrecedingMessage - sourceComponent: metabitsComponent + property Item selectedBackgroundItem: null - onShowTimeStampChanged: { - if (!showTimeStamp) { - if (timeStamp) { - timeStamp.destroy(); + active: !model.AuthorMatchesPrecedingMessage + sourceComponent: metabitsComponent + + onSelectedChanged: { + if (selected && !selectedBackgroundItem) { + selectedBackgroundItem = selectedBackgroundItemComponent.createObject(msg); + } else if (!selected && selectedBackgroundItem) { + selectedBackgroundItem.destroy(); } - } else { - timeStamp = timeStampComponent.createObject(msg); } - } - Component { - id: timeStampComponent + onShowTimeStampChanged: { + if (!showTimeStamp) { + if (timeStamp) { + timeStamp.destroy(); + } + } else { + timeStamp = timeStampComponent.createObject(msg); + } + } - Text { - id: timeStamp + Component { + id: selectedBackgroundItemComponent - readonly property bool collides: (messageText.x - + messageText.implicitWidth - + Kirigami.Units.smallSpacing + width > parent.width) - readonly property int margin: Kirigami.Units.gridUnit / 2 + Rectangle { + anchors.fill: parent - x: messageText.x + margin + (active ? authorSize.x : messageText.contentWidth) + z: 0 - y: { - if (!active) { - return messageText.y + ((largerFontMetrics.height / 2) - (height / 2)); - } else { - return (Kirigami.Units.gridUnit / 2) + ((authorSize.y / 2) - (height / 2)); - } + color: Kirigami.Theme.highlightColor } + } - renderType: Text.NativeRendering - color: "grey" + Component { + id: timeStampComponent + + Text { + id: timeStamp + + z: 1 + + readonly property bool collides: (messageText.x + + messageText.implicitWidth + + Kirigami.Units.smallSpacing + width > parent.width) + readonly property int margin: Kirigami.Units.gridUnit / 2 + + x: messageText.x + margin + (active ? authorSize.x : messageText.contentWidth) + + y: { + if (!active) { + return messageText.y + ((largerFontMetrics.height / 2) - (height / 2)); + } else { + return (Kirigami.Units.gridUnit / 2) + ((authorSize.y / 2) - (height / 2)); + } + } + + renderType: Text.NativeRendering + color: Kirigami.Theme.disabledTextColor - text: model.TimeStamp + text: model.TimeStamp + } } - } - Component { - id: metabitsComponent + Component { + id: metabitsComponent - Item { - anchors.fill: parent + Item { + anchors.fill: parent - Rectangle { - id: avatar + z: 1 - x: Kirigami.Units.gridUnit / 2 - y: Kirigami.Units.gridUnit / 2 + Rectangle { + id: avatar - width: avatarSize - height: avatarSize + x: Kirigami.Units.gridUnit / 2 + y: Kirigami.Units.gridUnit / 2 - color: model.NickColor + width: avatarSize + height: avatarSize - radius: width * 0.5 + color: model.NickColor - Text { - anchors.fill: parent - anchors.margins: Kirigami.Units.devicePixelRatio * 5 + radius: width * 0.5 - renderType: Text.QtRendering - color: "white" + Text { + anchors.fill: parent + anchors.margins: Kirigami.Units.devicePixelRatio * 5 - font.weight: Font.Bold - font.pointSize: 100 - minimumPointSize: theme.defaultFont.pointSize - fontSizeMode: Text.Fit + renderType: Text.QtRendering + color: "white" - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter + font.weight: Font.Bold + font.pointSize: 100 + minimumPointSize: theme.defaultFont.pointSize + fontSizeMode: Text.Fit - text: { - // WIPQTQUICK HACK TODO Probably doesn't work with non-latin1. - var match = model.Author.match(/([a-zA-Z])([a-zA-Z])/); - var abbrev = match[1].toUpperCase(); + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter - if (match.length > 2) { - abbrev += match[2].toLowerCase(); - } + text: { + // WIPQTQUICK HACK TODO Probably doesn't work with non-latin1. + var match = model.Author.match(/([a-zA-Z])([a-zA-Z])/); + var abbrev = match[1].toUpperCase(); - return abbrev; + if (match.length > 2) { + abbrev += match[2].toLowerCase(); + } + + return abbrev; + } } } - } - Text { - id: author + Text { + id: author - y: Kirigami.Units.gridUnit / 2 + y: Kirigami.Units.gridUnit / 2 - anchors.left: parent.left - anchors.leftMargin: avatarSize + Kirigami.Units.gridUnit + anchors.left: parent.left + anchors.leftMargin: avatarSize + Kirigami.Units.gridUnit - renderType: Text.NativeRendering - color: model.NickColor + renderType: Text.NativeRendering + color: model.NickColor - font.weight: Font.Bold - font.pixelSize: konvUi.largerFontSize + font.weight: Font.Bold + font.pixelSize: konvUi.largerFontSize - text: model.Author + text: model.Author - onWidthChanged: msg.authorSize = Qt.point(width, height) + onWidthChanged: msg.authorSize = Qt.point(width, height) + } } } - } - Text { - id: messageText + Text { + id: messageText - anchors.left: parent.left - anchors.leftMargin: avatarSize + Kirigami.Units.gridUnit - anchors.right: parent.right - anchors.rightMargin: (timeStamp && timeStamp.collides - ? timeStamp.margin + timeStamp.width : 0) - anchors.bottom: parent.bottom + z: 1 - renderType: Text.NativeRendering - textFormat: Text.StyledText + anchors.left: parent.left + anchors.leftMargin: avatarSize + Kirigami.Units.gridUnit + anchors.right: parent.right + anchors.rightMargin: (timeStamp && timeStamp.collides + ? timeStamp.margin + timeStamp.width : 0) + anchors.bottom: parent.bottom - font.pixelSize: konvUi.largerFontSize + renderType: Text.NativeRendering + textFormat: Text.StyledText + + font.pixelSize: konvUi.largerFontSize + + wrapMode: Text.Wrap + + color: selected ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor - wrapMode: Text.Wrap + text: (model.Type == Konversation.MessageModel.ActionMessage + ? actionWrap(model.display) : model.display) - text: (model.Type == Konversation.MessageModel.ActionMessage - ? actionWrap(model.display) : model.display) + function actionWrap(text) { + return "" + model.Author + " " + text + ""; + } - function actionWrap(text) { - return "" + model.Author + " " + text + ""; + onLinkActivated: konvApp.openUrl(link) } + } + } - onLinkActivated: konvApp.openUrl(link) + Keys.onPressed: { + if (event.matches(StandardKey.Copy) || event.matches(StandardKey.Cut)) { + event.accepted = true; + messageModel.copySelectionToClipboard(); } } } } + + MouseArea { + anchors.fill: parent + + onClicked: { + mouse.accepted = false; + + var cPos = mapToItem(textListView.contentItem, mouse.x, mouse.y); + var item = textListView.itemAt(cPos.x, cPos.y); + + if (item) { + messageModel.toggleSelected(item.row); + } + + if (messageModel.hasSelection()) { + textListView.forceActiveFocus(); + } + } + } }