From af898bd255f6dfe51f2c18a518041bb65c86358a Mon Sep 17 00:00:00 2001 From: Petr Mironychev <9195189+Palm1r@users.noreply.github.com> Date: Wed, 27 May 2026 23:03:10 +0200 Subject: [PATCH] feat: Add preview to message navigator --- ChatView/ChatModel.cpp | 18 +- ChatView/ChatModel.hpp | 2 +- ChatView/qml/RootItem.qml | 8 + ChatView/qml/controls/MessageNavigator.qml | 198 +++++++++++++++------ 4 files changed, 165 insertions(+), 61 deletions(-) diff --git a/ChatView/ChatModel.cpp b/ChatView/ChatModel.cpp index 218ee7d..19a28b1 100644 --- a/ChatView/ChatModel.cpp +++ b/ChatView/ChatModel.cpp @@ -335,12 +335,24 @@ void ChatModel::resetModelTo(int index) } } -QVariantList ChatModel::userMessageIndices() const +QVariantList ChatModel::userMessagePreviews(int maxLength) const { QVariantList result; + const int limit = maxLength > 4 ? maxLength : 80; for (int i = 0; i < m_messages.size(); ++i) { - if (m_messages[i].role == ChatRole::User) - result.append(i); + if (m_messages[i].role != ChatRole::User) + continue; + QString preview = m_messages[i].content; + preview.replace(QLatin1Char('\n'), QLatin1Char(' ')); + preview.replace(QLatin1Char('\r'), QLatin1Char(' ')); + preview.replace(QLatin1Char('\t'), QLatin1Char(' ')); + preview = preview.simplified(); + if (preview.size() > limit) + preview = preview.left(limit - 1).trimmed() + QChar(0x2026); + QVariantMap entry; + entry[QStringLiteral("messageIndex")] = i; + entry[QStringLiteral("preview")] = preview; + result.append(entry); } return result; } diff --git a/ChatView/ChatModel.hpp b/ChatView/ChatModel.hpp index a8acc5f..62ed794 100644 --- a/ChatView/ChatModel.hpp +++ b/ChatView/ChatModel.hpp @@ -94,7 +94,7 @@ public: QString lastMessageId() const; Q_INVOKABLE void resetModelTo(int index); - Q_INVOKABLE QVariantList userMessageIndices() const; + Q_INVOKABLE QVariantList userMessagePreviews(int maxLength = 80) const; void addToolExecutionStatus( const QString &requestId, diff --git a/ChatView/qml/RootItem.qml b/ChatView/qml/RootItem.qml index 3a34766..46d0a0f 100644 --- a/ChatView/qml/RootItem.qml +++ b/ChatView/qml/RootItem.qml @@ -201,6 +201,11 @@ ChatRootView { property bool userScrolledUp: false + function syncNavigatorCurrent() { + const top = indexAt(10, contentY + 4) + messageNavigator.updateCurrentFromModelIndex(top) + } + Layout.fillWidth: true Layout.fillHeight: true leftMargin: 3 @@ -210,6 +215,8 @@ ChatRootView { boundsBehavior: Flickable.StopAtBounds cacheBuffer: 2000 + onContentYChanged: Qt.callLater(syncNavigatorCurrent) + onMovingChanged: { if (moving) { userScrolledUp = !atYEnd @@ -296,6 +303,7 @@ ChatRootView { if (!userScrolledUp) { root.scrollToBottom() } + Qt.callLater(syncNavigatorCurrent) } onContentHeightChanged: { diff --git a/ChatView/qml/controls/MessageNavigator.qml b/ChatView/qml/controls/MessageNavigator.qml index a559375..5b7390e 100644 --- a/ChatView/qml/controls/MessageNavigator.qml +++ b/ChatView/qml/controls/MessageNavigator.qml @@ -4,27 +4,91 @@ import QtQuick import QtQuick.Controls import ChatView +import UIControls Item { id: nav property var chatModel - property var userIndices: [] - property int hoveredDotIndex: -1 + property var entries: [] property color dotColor: "#92BD6C" + property int currentMessageIndex: -1 + + readonly property int dotCount: entries.length + readonly property int verticalPadding: 8 + readonly property int minDotSpacing: 18 + readonly property real availableHeight: Math.max(0, height - 2 * verticalPadding) + readonly property real naturalHeight: dotCount > 1 ? (dotCount - 1) * minDotSpacing : 0 + readonly property bool needsScrolling: naturalHeight > availableHeight + readonly property real contentHeight: needsScrolling + ? naturalHeight + 2 * verticalPadding + : Math.max(height, 2 * verticalPadding) signal messageClicked(int messageIndex) implicitWidth: 16 function rebuild() { - if (chatModel) - userIndices = chatModel.userMessageIndices() - else - userIndices = [] + entries = chatModel ? chatModel.userMessagePreviews(80) : [] + Qt.callLater(scrollCurrentIntoView) + } + + function updateCurrentFromModelIndex(modelIdx) { + if (modelIdx < 0) { + currentMessageIndex = -1 + return + } + let best = -1 + for (let i = 0; i < entries.length; ++i) { + const e = entries[i] + if (!e) + continue + const mi = e.messageIndex + if (mi <= modelIdx) + best = mi + else + break + } + currentMessageIndex = best + } + + function uiIndexOf(messageIndex) { + for (let i = 0; i < entries.length; ++i) { + const e = entries[i] + if (e && e.messageIndex === messageIndex) + return i + } + return -1 + } + + function dotCenterY(uiIndex) { + const count = dotCount + if (count <= 1) + return contentHeight / 2 + const spacing = needsScrolling + ? minDotSpacing + : availableHeight / (count - 1) + return verticalPadding + spacing * uiIndex + } + + function scrollCurrentIntoView() { + if (!needsScrolling || currentMessageIndex < 0) + return + const ui = uiIndexOf(currentMessageIndex) + if (ui < 0) + return + const y = dotCenterY(ui) + const margin = 24 + if (y < flick.contentY + margin) + flick.contentY = Math.max(0, y - margin) + else if (y > flick.contentY + flick.height - margin) + flick.contentY = Math.min( + Math.max(0, flick.contentHeight - flick.height), + y - flick.height + margin) } onChatModelChanged: rebuild() + onCurrentMessageIndexChanged: scrollCurrentIntoView() Component.onCompleted: rebuild() Connections { @@ -34,70 +98,90 @@ Item { function onRowsRemoved() { nav.rebuild() } function onModelReset() { nav.rebuild() } function onModelReseted() { nav.rebuild() } - function onLayoutChanged() { nav.rebuild() } + function onDataChanged() { nav.rebuild() } } - Rectangle { - id: spine + Flickable { + id: flick - visible: nav.userIndices.length > 1 - x: nav.width / 2 - width / 2 - y: 14 - width: 1 - height: Math.max(0, nav.height - 28) - color: palette.mid - opacity: 0.4 - } + anchors.fill: parent + contentWidth: width + contentHeight: nav.contentHeight + interactive: nav.needsScrolling + clip: true + boundsBehavior: Flickable.StopAtBounds - Repeater { - model: nav.userIndices + Rectangle { + id: spine - delegate: Item { - id: dotItem + visible: nav.dotCount > 1 + anchors.horizontalCenter: parent.horizontalCenter + y: nav.verticalPadding + width: 1 + height: Math.max(0, flick.contentHeight - 2 * nav.verticalPadding) + color: palette.mid + opacity: 0.4 + } - required property var modelData - required property int index + Repeater { + model: nav.entries - width: nav.width - height: 14 - x: 0 - y: { - const count = nav.userIndices.length - const dotH = height - if (count <= 1) - return (nav.height - dotH) / 2 - const top = 7 - const bottom = nav.height - dotH - 7 - return top + (bottom - top) * index / (count - 1) - } + delegate: Item { + id: dotItem - Rectangle { - id: dot - anchors.centerIn: parent - width: dotArea.containsMouse ? 10 : 7 - height: width - radius: width / 2 - color: dotArea.containsMouse ? Qt.lighter(nav.dotColor, 1.15) : nav.dotColor - border.color: Qt.darker(nav.dotColor, 1.4) - border.width: 1 - opacity: dotArea.containsMouse ? 1.0 : 0.9 + required property var modelData + required property int index - Behavior on width { NumberAnimation { duration: 120 } } - Behavior on color { ColorAnimation { duration: 120 } } - } + readonly property int msgIndex: modelData && modelData.messageIndex !== undefined + ? modelData.messageIndex : -1 + readonly property string preview: modelData && modelData.preview !== undefined + ? modelData.preview : "" + readonly property bool isCurrent: nav.currentMessageIndex === msgIndex - MouseArea { - id: dotArea + width: 16 + height: 14 + anchors.horizontalCenter: parent.horizontalCenter + y: nav.dotCenterY(index) - height / 2 - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: nav.messageClicked(dotItem.modelData) + Rectangle { + id: dot - ToolTip.visible: containsMouse - ToolTip.delay: 400 - ToolTip.text: qsTr("Jump to message #%1").arg(dotItem.index + 1) + anchors.centerIn: parent + width: dotItem.isCurrent ? 11 : (dotArea.containsMouse ? 10 : 7) + height: width + radius: width / 2 + color: dotArea.containsMouse + ? Qt.lighter(nav.dotColor, 1.2) + : nav.dotColor + border.color: dotItem.isCurrent + ? Qt.darker(nav.dotColor, 1.7) + : Qt.darker(nav.dotColor, 1.4) + border.width: dotItem.isCurrent ? 2 : 1 + opacity: dotItem.isCurrent || dotArea.containsMouse ? 1.0 : 0.55 + + Behavior on width { NumberAnimation { duration: 120 } } + Behavior on opacity { NumberAnimation { duration: 120 } } + Behavior on color { ColorAnimation { duration: 120 } } + } + + MouseArea { + id: dotArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: nav.messageClicked(dotItem.msgIndex) + + QoAToolTip { + visible: dotArea.containsMouse + delay: 350 + text: dotItem.preview.length > 0 + ? qsTr("#%1 ยท %2").arg(dotItem.index + 1).arg(dotItem.preview) + : qsTr("Jump to message #%1").arg(dotItem.index + 1) + } + } } } } + }