new chatlistview component, file attachments, performance & primitive test

This commit is contained in:
2026-09-30 15:01:28 +02:00
parent 51ed270774
commit d8a3d5732e
36 changed files with 4368 additions and 310 deletions
@@ -1,4 +1,7 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import QtQuick.Layouts
import ZShell.Components
import ZShell.Config
@@ -11,7 +14,6 @@ Item {
id: root
property ChatSession chatData
property bool following: true
// qmlformat off
readonly property var keys: [
Qt.Key_A,
@@ -87,12 +89,33 @@ Item {
signal requestClose
readonly property bool atNewestEnd: !listLoader.item || listLoader.item.contentY < 4
property var pendingAttachments: []
function attachFiles(): void {
Files.pickFiles().then(files => {
if (!files || files.length === 0)
return;
const added = chatData.addAttachments(files);
root.pendingAttachments = root.pendingAttachments.concat(added);
});
}
function send(text: string): void {
if (text.trim() === "")
if (text.trim() === "" && root.pendingAttachments.length === 0)
return;
following = true;
chatData.sendMessage(text);
root.scrollToLatest();
chatData.sendMessage(text, root.pendingAttachments);
input.text = "";
root.pendingAttachments = [];
}
function scrollToLatest(): void {
if (!listLoader.item || root.atNewestEnd)
return;
scrollAnim.stop();
scrollAnim.start();
}
function focusInput(): void {
@@ -110,6 +133,18 @@ Item {
}
}
Anim {
id: scrollAnim
target: listLoader.item
property: "contentY"
to: 0
type: Anim.DefaultEffects
onFinished: if (target)
target.contentY = 0
}
RowLayout {
id: header
@@ -151,41 +186,112 @@ Item {
anchors.topMargin: Tokens.spacing.medium
active: root.chatData && root.chatData.loaded
sourceComponent: VerticalFadeListView {
id: list
sourceComponent: Flickable {
id: flick
property bool userScrolledUp: false
property real lastCHeight: 0.0
anchors.fill: parent
clip: true
contentWidth: width
contentHeight: list.layoutHeight
function scrollToBottom(): void {
scrollAnim.start();
transform: Rotation {
origin.y: flick.height / 2
origin.x: flick.width / 2
angle: 180
}
cacheBuffer: Math.max(height * 20, 0)
clip: true
fadeAmount: 0.05
fadeThreshold: Tokens.padding.medium
model: root.chatData.messagesModel
spacing: 0
rotation: 180
add: Transition {
Anim {
from: -100
property: "y"
WheelInverter {
target: flick
}
Anim {
id: restoreAnim
target: flick
type: Anim.DefaultEffects
property: "contentY"
}
ChatListView {
id: list
width: flick.width
height: flick.height
yAnimDuration: Tokens.anim.durations.expressiveDefaultEffects
asynchronous: true
cacheBuffer: 50
estimatedHeight: 100
readyDelay: 5
removeDuration: Tokens.anim.durations.normal
spacing: 0
model: root.chatData.messagesModel
useCustomViewport: true
viewport: {
tWatcher.transform;
return Qt.rect(0, flick.contentY, width, flick.height);
}
onViewportAdjustNeeded: delta => {
restoreAnim.stop();
flick.contentY += delta;
}
onViewportRestoreRequested: target => {
restoreAnim.to = target;
restoreAnim.start();
}
delegate: MessageDelegate {
id: message
rootParent: flick
chatList: list
ChatListView.preferredHeight: implicitHeight
ChatListView.trackViewport: true
ChatListView.visibleHeight: ChatListView.removing ? 0 : implicitHeight
opacity: ChatListView.removing || ChatListView.adding ? 0 : 1
transform: Rotation {
origin.y: message.height / 2
origin.x: message.width / 2
angle: 180
}
Behavior on y {
enabled: message.ChatListView.ready
Anim {
id: yAnim
type: Anim.DefaultEffects
}
}
Behavior on x {
Anim {
type: Anim.DefaultEffects
}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
}
CustomScrollBar.vertical: CustomScrollBar {
id: scrollBar
flickable: list
parent: list.parent
anchors.top: list.top
anchors.right: list.right
anchors.bottom: list.bottom
flickable: flick
parent: flick.parent
anchors.top: flick.top
anchors.right: flick.right
anchors.bottom: flick.bottom
inverted: true
transform: Rotation {
origin.y: list.height / 2
origin.y: flick.height / 2
origin.x: scrollBar.width / 2
angle: 180
@@ -196,57 +302,15 @@ Item {
}
}
}
delegate: MessageDelegate {
rotation: 180
rootParent: list
}
displaced: Transition {
Anim {
property: "y"
}
}
move: Transition {
Anim {
property: "y"
}
}
TransformWatcher {
id: tWatcher
Component.onCompleted: {
positionViewAtBeginning();
}
onAtYBeginningChanged: {
if (atYBeginning)
userScrolledUp = false;
}
onContentHeightChanged: {
if (userScrolledUp && Chat.busy) {
const delta = contentHeight - lastCHeight;
contentY += delta;
}
lastCHeight = contentHeight;
}
onMovingChanged: {
if (moving)
userScrolledUp = !atYBeginning;
}
Anim {
id: scrollAnim
property: "contentY"
target: list
type: Anim.DefaultEffects
to: list.originY
}
WheelInverter {
target: list
a: flick.contentItem
b: list
}
}
// Trigger the load even before we're ready to show the list.
Component.onCompleted: if (root.chatData)
root.chatData.messagesModel
}
@@ -265,7 +329,7 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter
implicitHeight: scrollToBottom.implicitHeight
implicitWidth: scrollToBottom.implicitWidth
scale: !listLoader.item.atYBeginning && listLoader.item.contentHeight > listLoader.item.height ? 1 : 0
scale: root.atNewestEnd || !listLoader.item || listLoader.item.contentHeight <= listLoader.item.height ? 0 : 1
visible: scale > 0
Behavior on scale {
@@ -289,7 +353,180 @@ Item {
type: IconButton.Tonal
onClicked: {
listLoader.item.scrollToBottom();
root.scrollToLatest();
}
}
}
GridView {
id: attachments
readonly property int columns: ChatState.isWindow ? 5 : 3
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: input.top
anchors.bottomMargin: Tokens.spacing.medium
flow: GridView.LeftToRight
verticalLayoutDirection: GridView.BottomToTop
cellWidth: width / columns
cellHeight: cellWidth
implicitHeight: Math.ceil(count / columns) * cellHeight
displaced: Transition {
Anim {
property: "x"
}
Anim {
property: "y"
}
}
add: Transition {
id: addTrans
Anim {
property: "y"
from: addTrans.ViewTransition.destination.y + attachments.cellHeight / 4
}
Anim {
property: "opacity"
from: 0
to: 1
}
}
remove: Transition {
id: removeTrans
Anim {
property: "y"
to: removeTrans.ViewTransition.destination.y + attachments.cellHeight / 4
}
Anim {
property: "opacity"
from: 1
to: 0
}
}
model: ScriptModel {
objectProp: "path"
values: root.pendingAttachments
}
delegate: Item {
id: thumb
required property var modelData
function fileIcon(mime): string {
if (mime.startsWith("archive/") || mime === "application/zip")
return "folder_zip";
if (mime === "application/pdf")
return "picture_as_pdf";
if (mime.startsWith("audio/"))
return "audio_file";
if (mime.startsWith("video/"))
return "video_file";
if (mime.startsWith("text/"))
return "description";
return "insert_drive_file";
}
width: attachments.cellWidth
height: width
opacity: modelData ? 1 : 0
CustomClippingRect {
anchors.fill: parent
anchors.margins: Tokens.spacing.extraSmall
radius: Tokens.rounding.medium
color: Colors.tPalette.m3surfaceContainer
FadeImage {
id: attImage
anchors.fill: parent
fillMode: FadeImage.PreserveAspectCrop
asynchronous: true
source: visible ? thumb.modelData?.path ?? "" : ""
visible: thumb.modelData?.isImage ?? false
}
MaterialIcon {
anchors.centerIn: parent
font.pointSize: Tokens.font.size.extraLarge
text: thumb.fileIcon(thumb.modelData?.mimeType ?? "")
visible: !thumb.modelData?.isImage ?? false
}
Loader {
id: attIndicatorLoader
anchors.centerIn: parent
opacity: 0
active: opacity > 0 && (thumb.modelData?.isImage ?? false)
sourceComponent: CustomRect {
implicitWidth: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2
implicitHeight: attLoadingIndicator.implicitSize + Tokens.padding.largeIncreased * 2
color: Colors.palette.m3primaryContainer
radius: Tokens.rounding.full
LoadingIndicator {
id: attLoadingIndicator
anchors.centerIn: parent
containsIcon: true
implicitSize: thumb.implicitWidth * 0.4
}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
Timer {
id: attLoadDebounceTimer
interval: 100
onTriggered: {
if (attImage.status !== Image.Ready)
attIndicatorLoader.opacity = 1;
}
}
MouseArea {
id: hover
anchors.fill: parent
hoverEnabled: true
cursorShape: undefined
IconButton {
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Tokens.padding.medium
icon: "close"
visible: opacity > 0
enabled: thumb.opacity > 0
opacity: hover.containsMouse ? 1 : 0
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
onClicked: root.pendingAttachments = root.pendingAttachments.filter(a => a !== thumb.modelData)
}
}
}
}
}
@@ -310,7 +547,9 @@ Item {
sendIcon.icon: "arrow_upward"
sendIcon.padding: Tokens.padding.extraSmall
Component.onCompleted: root.focusInput()
Component.onCompleted: {
root.focusInput();
}
Keys.onPressed: e => {
if (e.key == Qt.Key_Return) {
if (!(e.modifiers & Qt.ShiftModifier)) {
@@ -318,6 +557,14 @@ Item {
e.accepted = true;
}
}
if (e.key == Qt.Key_V && e.modifiers & Qt.ControlModifier) {
if (root.chatData.clipboardHasAttachment()) {
const att = root.chatData.pasteAttachments();
root.pendingAttachments = [...root.pendingAttachments, ...att];
e.accepted = true;
}
}
}
onSendPressed: root.send(text)
}