better scrollbar in codeblocks + enter anim for delegates

This commit is contained in:
2026-08-27 01:47:17 +02:00
parent c33dbc147f
commit 293bf67e09
16 changed files with 458 additions and 229 deletions
@@ -12,6 +12,78 @@ Item {
property ChatSession chatData
property bool following: true
// qmlformat off
readonly property var keys: [
Qt.Key_A,
Qt.Key_B,
Qt.Key_C,
Qt.Key_D,
Qt.Key_E,
Qt.Key_F,
Qt.Key_G,
Qt.Key_H,
Qt.Key_I,
Qt.Key_J,
Qt.Key_K,
Qt.Key_L,
Qt.Key_M,
Qt.Key_N,
Qt.Key_O,
Qt.Key_P,
Qt.Key_Q,
Qt.Key_R,
Qt.Key_S,
Qt.Key_T,
Qt.Key_U,
Qt.Key_V,
Qt.Key_W,
Qt.Key_X,
Qt.Key_Y,
Qt.Key_Z,
Qt.Key_Agrave,
Qt.Key_Aacute,
Qt.Key_Acircumflex,
Qt.Key_Atilde,
Qt.Key_Adiaeresis,
Qt.Key_Aring,
Qt.Key_AE,
Qt.Key_Ccedilla,
Qt.Key_Egrave,
Qt.Key_Eacute,
Qt.Key_Ecircumflex,
Qt.Key_Ediaeresis,
Qt.Key_Igrave,
Qt.Key_Iacute,
Qt.Key_Icircumflex,
Qt.Key_Idiaeresis,
Qt.Key_ETH,
Qt.Key_Ntilde,
Qt.Key_Ograve,
Qt.Key_Oacute,
Qt.Key_Ocircumflex,
Qt.Key_Otilde,
Qt.Key_Odiaeresis,
Qt.Key_Ooblique,
Qt.Key_Ugrave,
Qt.Key_Uacute,
Qt.Key_Ucircumflex,
Qt.Key_Udiaeresis,
Qt.Key_Yacute,
Qt.Key_ydiaeresis,
Qt.Key_THORN,
Qt.Key_ssharp
]
// qmlformat on
signal requestClose
@@ -27,6 +99,21 @@ Item {
input.text = "";
}
function focusInput(): void {
Qt.callLater(() => input.forceActiveFocus());
}
onChatDataChanged: {
if (chatData)
focusInput();
}
Keys.onPressed: e => {
if (root.keys.includes(e.key)) {
input.insert(input.length, e.text);
focusInput();
}
}
RowLayout {
id: header
@@ -68,9 +155,11 @@ Item {
id: list
property bool userScrolledUp: false
property real lastCHeight: 0.0
function scrollToBottom(): void {
Qt.callLater(() => positionViewAtBeginning());
scrollAnim.to = 0;
scrollAnim.start();
}
cacheBuffer: height * 20
@@ -80,47 +169,67 @@ Item {
model: root.chatData.messagesModel
spacing: 0
rotation: 180
add: Transition {
Anim {
from: -100
property: "y"
}
}
// add: Transition {
// Anim {
// from: 10
// property: "y"
// }
// }
CustomScrollBar.vertical: CustomScrollBar {
id: scrollBar
flickable: list
parent: list.parent
anchors.top: list.top
anchors.right: list.right
anchors.bottom: list.bottom
transform: Rotation {
origin.y: list.height / 2
origin.x: scrollBar.width / 2
angle: 180
axis {
y: 0
x: 1
z: 0
}
}
}
delegate: MessageDelegate {
rotation: 180
}
// displaced: Transition {
// Anim {
// property: "y"
// }
// }
// move: Transition {
// Anim {
// property: "y"
// }
// }
displaced: Transition {
Anim {
property: "y"
}
}
move: Transition {
Anim {
property: "y"
}
}
Component.onCompleted: {
positionViewAtBeginning();
forceActiveFocus();
}
onAtYEndChanged: {
if (atYEnd)
onAtYBeginningChanged: {
if (atYBeginning)
userScrolledUp = false;
}
onContentHeightChanged: {
if (!userScrolledUp && atYEnd)
scrollToBottom();
}
onCountChanged: {
if (!userScrolledUp)
scrollToBottom();
if (userScrolledUp && Chat.busy) {
const delta = contentHeight - lastCHeight;
contentY += delta;
}
lastCHeight = contentHeight;
}
onMovingChanged: {
if (moving)
userScrolledUp = !atYEnd;
userScrolledUp = !atYBeginning;
}
Anim {
@@ -179,7 +288,7 @@ Item {
type: IconButton.Tonal
onClicked: {
listLoader.item.positionViewAtBeginning();
listLoader.item.scrollToBottom();
}
}
}
@@ -194,11 +303,13 @@ Item {
bg.color: Colors.tPalette.m3surfaceContainerLowest
font.pointSize: Tokens.font.size.normal
implicitHeight: Math.min(root.height / 5, contentHeight + topPadding + bottomPadding)
focus: true
placeholderText: qsTr("Send a message")
sendIcon.font.pointSize: Tokens.font.size.large
sendIcon.icon: "arrow_upward"
sendIcon.padding: Tokens.padding.extraSmall
Component.onCompleted: root.focusInput()
Keys.onPressed: e => {
if (e.key == Qt.Key_Return) {
if (!(e.modifiers & Qt.ShiftModifier)) {
@@ -31,7 +31,7 @@ TextAreaBase {
enabled: !root.activeFocus
manualPressOverride: tapHandler.pressed
onClicked: root.focus = true
onClicked: root.forceActiveFocus()
}
}
@@ -0,0 +1,28 @@
import Quickshell
import QtQuick
import ZShell.Config
import qs.Components
import qs.Services
TextEditBase {
id: root
color: Colors.palette.m3onSurface
readOnly: true
anchors.margins: Tokens.padding.medium
textFormat: Text.MarkdownText
font.pointSize: Tokens.font.size.smaller
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
onLinkActivated: link => {
Qt.openUrlExternally(link);
}
CustomMouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
cursorShape: root.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.IBeamCursor
preventStealing: false
hoverEnabled: true
}
}
@@ -1,4 +1,5 @@
import QtQuick
import QtQuick.Effects
import QtQuick.Shapes
import QtQuick.Layouts
import Quickshell
@@ -7,7 +8,7 @@ import ZShell.Llm
import qs.Components
import qs.Services
CustomClippingRect {
CustomRect {
id: root
required property string language
@@ -15,16 +16,11 @@ CustomClippingRect {
property bool copied: false
property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh
property color codeHeaderColor: Colors.palette.m3outline
// Highlighter spans for the current code; refreshed when the code or
// its language changes. Highlighting runs off the GUI thread; the
// token drops results that arrive after the code already changed.
property var codeSpans: []
property int highlightToken: 0
function refresh() {
const token = ++root.highlightToken;
codeSpans = [];
CodeHighlighter.highlight(root.code, root.language, root, token);
}
@@ -84,10 +80,14 @@ CustomClippingRect {
let pos = 0;
for (let i = 0; i < spans.length; i++) {
const span = spans[i];
if (span.start > pos)
out += escapeHtml(code.slice(pos, span.start));
out += `<font color="${roleColor(span.kind)}">` + escapeHtml(code.slice(span.start, span.start + span.length)) + "</font>";
pos = span.start + span.length;
const start = Math.min(span.start, code.length);
const end = Math.min(span.start + span.length, code.length);
if (end <= pos)
continue;
if (start > pos)
out += escapeHtml(code.slice(pos, start));
out += `<font color="${roleColor(span.kind)}">` + escapeHtml(code.slice(start, end)) + "</font>";
pos = end;
}
if (pos < code.length)
out += escapeHtml(code.slice(pos));
@@ -100,7 +100,10 @@ CustomClippingRect {
color: root.codeBackgroundColor
radius: Tokens.rounding.medium
onLanguageChanged: refresh()
onLanguageChanged: {
codeSpans = [];
refresh();
}
onCodeChanged: refresh()
RowLayout {
@@ -212,7 +215,7 @@ CustomClippingRect {
}
}
CustomRect {
CustomClippingRect {
id: codeRect
anchors.left: parent.left
@@ -226,6 +229,21 @@ CustomClippingRect {
implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2
color: CodeColors.active.bg
CustomText {
id: code
anchors.top: parent.top
anchors.bottom: parent.bottom
x: implicitWidth * (0 - codeFlick.visibleArea.xPosition) + Tokens.padding.small
anchors.margins: Tokens.padding.small
text: root.highlightedHtml(root.code, root.codeSpans)
textFormat: Text.RichText
color: CodeColors.active.normal
layer.enabled: true
clip: false
font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.small
}
Flickable {
id: codeFlick
@@ -234,15 +252,23 @@ CustomClippingRect {
CustomScrollBar.horizontal: CustomScrollBar {
flickable: codeFlick
parent: codeFlick.parent
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
}
TextAreaBase.flickable: TextAreaBase {
id: codeText
color: CodeColors.active.normal
layer.enabled: true
leftInset: Tokens.padding.small
clip: false
font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.small
textFormat: Text.RichText
text: root.highlightedHtml(root.code, root.codeSpans)
text: code.text
readOnly: true
}
}
@@ -24,7 +24,7 @@ Item {
implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin
CustomRect {
CustomClippingRect {
id: bubble
radius: Tokens.rounding.medium
@@ -33,16 +33,21 @@ Item {
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2
anchors.right: root.isUser ? parent.right : undefined
Behavior on implicitHeight {
// enabled: root.segment.running
Anim {}
}
// Behavior on implicitHeight {
// enabled: !root.segment.running
//
// Anim {
// type: Anim.DefaultEffects
// }
// }
// User messages stay a plain editable text field.
TextEditBase {
id: msgText
property string cachedText: root.segment.text
property bool cancelled: false
visible: root.isUser
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
@@ -65,27 +70,29 @@ Item {
event.accepted = true;
}
} else if (event.key == Qt.Key_Escape) {
text = root.segment.text;
cancelled = true;
readOnly = true;
event.accepted = true;
}
}
onEditingFinished: {
const old = root.segment.text;
if (old !== text)
root.edit(text);
readOnly = true;
}
onReadOnlyChanged: {
if (readOnly) {
animateCursor = false;
root.forceActiveFocus();
textFormat = CustomText.MarkdownText;
if (cancelled) {
text = cachedText;
cancelled = false;
return;
}
root.edit(text);
} else {
var raw = root.segment.text;
textFormat = CustomText.PlainText;
text = raw;
text = cachedText;
forceActiveFocus();
cursorPosition = text.length;
animateCursor = true;
@@ -1,4 +1,5 @@
import QtQuick
import Quickshell
import ZShell.Config
import ZShell.Llm
import qs.Components
@@ -7,7 +8,6 @@ import qs.Services
Column {
id: root
// Top-level markdown blocks (see MarkdownParser / LlmSegment.markdown).
required property var blocks
spacing: Tokens.spacing.small
@@ -15,8 +15,10 @@ Column {
Repeater {
id: blockRep
model: root.blocks
model: ScriptModel {
values: root.blocks
objectProp: "id"
}
delegate: DelegateChooser {
role: "type"
@@ -50,7 +52,7 @@ Column {
DelegateChoice {
roleValue: LlmMarkdown.Type.Heading
delegate: CustomText {
delegate: TextEditBase {
required property var modelData
color: Colors.palette.m3onSurface
@@ -59,10 +61,11 @@ Column {
anchors.margins: Tokens.padding.medium
text: modelData.text
textFormat: Text.MarkdownText
readOnly: true
font.bold: true
font.pointSize: {
if (modelData.level <= 1)
return Tokens.font.size.larger;
return Tokens.font.size.large;
if (modelData.level === 2)
return Tokens.font.size.normal;
return Tokens.font.size.smaller;
@@ -74,17 +77,13 @@ Column {
DelegateChoice {
roleValue: LlmMarkdown.Type.Text
delegate: CustomText {
delegate: BubbleEdit {
required property var modelData
color: Colors.palette.m3onSurface
anchors.right: parent.right
anchors.left: parent.left
anchors.margins: Tokens.padding.medium
text: modelData.text
textFormat: Text.MarkdownText
font.pointSize: Tokens.font.size.smaller
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
}
}
}
@@ -83,8 +83,9 @@ MouseArea {
Anim {
target: root
property: "x"
to: changeAnim.next ? -root.width / 2 : root.width / 2
to: changeAnim.next ? root.width / 4 : -root.width / 4
from: 0
type: Anim.FastEffects
}
Anim {
@@ -92,6 +93,7 @@ MouseArea {
property: "opacity"
from: 1
to: 0
type: Anim.FastEffects
}
}
@@ -101,7 +103,8 @@ MouseArea {
Anim {
target: root
property: "x"
from: changeAnim.next ? root.width / 2 : -root.width / 2
from: changeAnim.next ? -root.width / 4 : root.width / 4
type: Anim.FastEffects
to: 0
}
@@ -109,30 +112,21 @@ MouseArea {
target: root
property: "opacity"
from: 0
type: Anim.FastEffects
to: 1
}
}
}
}
// Behavior on implicitHeight {
// enabled: !root.isUser && root.current.streaming
//
// Anim {}
// }
onCurrentChanged: {
console.log(modelData.generations.indexOf(current));
Binding {
property: "contentY"
restoreMode: Binding.RestoreNone
target: root.ListView.view
value: root.y + root.height + Tokens.padding.large * 2 - root.ListView.view.height
when: root.reasoningExpanded && root.ListView.view && (root.y + root.height + Tokens.padding.large * 2 > root.ListView.view.contentY + root.ListView.view.height)
}
// Binding {
// property: "contentY"
// restoreMode: Binding.RestoreNone
// target: root.ListView.view
// value: root.y - Tokens.padding.large * 2
// when: root.reasoningExpanded && root.ListView.view && ((root.y - Tokens.padding.large * 2) < root.ListView.view.contentY)
// }
Anim {
id: restoreAnim
@@ -148,20 +142,6 @@ MouseArea {
anchors.left: parent.left
anchors.right: parent.right
// onImplicitHeightChanged: console.log("LAYOUT:", layout.implicitHeight)
// add: Transition {
// Anim {
// from: 10
// property: "y"
// }
// }
// move: Transition {
// Anim {
// property: "y"
// }
// }
Repeater {
id: segmentRep
@@ -10,6 +10,7 @@ DialogRowButton {
required property var model
property var selectedItem
property var initialSelect
function keyFor(item: var): string {
return item.id;
@@ -91,7 +92,11 @@ DialogRowButton {
}
onOpenChanged: {
if (open)
selectedItem = null;
if (open) {
if (!initialSelect)
selectedItem = null;
else
selectedItem = initialSelect;
}
}
}
@@ -84,6 +84,8 @@ PageBase {
model: root.schemes
rootParent: root.flickable
initialSelect: Config.llm.appearance.scheme
onAccepted: {
if (!selectedItem)
return;