gui: fix flickering, add code exec button, fix bubble width, improve scroll
- Replace Qt.callLater scroll debounce with Timer (16ms) to coalesce rapid contentHeight changes and eliminate scroll flickering - Add execute button (play icon) on code blocks for scripted languages (bash, python, perl, ruby, etc.) — launches in terminal - Fix assistant bubble width: always use 95% of available width - Add minimum width (280px) to code fence blocks - Move emoji popup into prompt RowLayout so it appears near input - Disable wheel scroll inertia (direct contentY) while keeping flick inertia intact - Apply all fixes to both KF5 and KF6 QML files
This commit is contained in:
parent
2929bb6b04
commit
1d646f7fab
|
|
@ -12,6 +12,9 @@
|
|||
#include <QJsonDocument>
|
||||
#include <QJsonParseError>
|
||||
#include <QPalette>
|
||||
#include <QTemporaryFile>
|
||||
#include <QProcess>
|
||||
#include <QStandardPaths>
|
||||
|
||||
static KSyntaxHighlighting::Repository *s_repo = nullptr;
|
||||
|
||||
|
|
@ -465,3 +468,75 @@ void ChatBlockModel::copyToClipboard(const QString &text) const
|
|||
{
|
||||
QGuiApplication::clipboard()->setText(text);
|
||||
}
|
||||
|
||||
void ChatBlockModel::executeInTerminal(const QString &code, const QString &language) const
|
||||
{
|
||||
// Determine interpreter/shebang
|
||||
QString shebang = QStringLiteral("#!/bin/bash");
|
||||
QString ext = QStringLiteral(".sh");
|
||||
|
||||
QString lang = language.toLower().trimmed();
|
||||
if (lang == "python" || lang == "python3" || lang == "py") {
|
||||
shebang = QStringLiteral("#!/usr/bin/env python3");
|
||||
ext = QStringLiteral(".py");
|
||||
} else if (lang == "perl") {
|
||||
shebang = QStringLiteral("#!/usr/bin/env perl");
|
||||
ext = QStringLiteral(".pl");
|
||||
} else if (lang == "ruby" || lang == "rb") {
|
||||
shebang = QStringLiteral("#!/usr/bin/env ruby");
|
||||
ext = QStringLiteral(".rb");
|
||||
} else if (lang == "lua") {
|
||||
shebang = QStringLiteral("#!/usr/bin/env lua");
|
||||
ext = QStringLiteral(".lua");
|
||||
} else if (lang == "node" || lang == "javascript" || lang == "js") {
|
||||
shebang = QStringLiteral("#!/usr/bin/env node");
|
||||
ext = QStringLiteral(".js");
|
||||
} else if (lang == "php") {
|
||||
shebang = QStringLiteral("#!/usr/bin/env php");
|
||||
ext = QStringLiteral(".php");
|
||||
} else if (lang == "zsh") {
|
||||
shebang = QStringLiteral("#!/usr/bin/env zsh");
|
||||
ext = QStringLiteral(".zsh");
|
||||
} else if (lang == "fish") {
|
||||
shebang = QStringLiteral("#!/usr/bin/env fish");
|
||||
ext = QStringLiteral(".fish");
|
||||
}
|
||||
|
||||
// Write temp script
|
||||
QTemporaryFile tmp;
|
||||
tmp.setFileTemplate(QStringLiteral("/tmp/ollie-exec-XXXXXX") + ext);
|
||||
tmp.setAutoRemove(false); // keep it so terminal can read it
|
||||
if (!tmp.open())
|
||||
return;
|
||||
|
||||
QString script = shebang + QStringLiteral("\n") + code + QStringLiteral("\n");
|
||||
tmp.write(script.toUtf8());
|
||||
tmp.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner);
|
||||
QString scriptPath = tmp.fileName();
|
||||
tmp.close();
|
||||
|
||||
// Launch in terminal — try konsole, then x-terminal-emulator, then xterm
|
||||
QStringList terminals = {
|
||||
QStringLiteral("konsole"),
|
||||
QStringLiteral("x-terminal-emulator"),
|
||||
QStringLiteral("xterm")
|
||||
};
|
||||
|
||||
for (const QString &term : terminals) {
|
||||
QString exe = QStandardPaths::findExecutable(term);
|
||||
if (exe.isEmpty())
|
||||
continue;
|
||||
|
||||
QStringList args;
|
||||
if (term == "konsole") {
|
||||
args << QStringLiteral("--hold") << QStringLiteral("-e") << scriptPath;
|
||||
} else if (term == "xterm") {
|
||||
args << QStringLiteral("-hold") << QStringLiteral("-e") << scriptPath;
|
||||
} else {
|
||||
args << QStringLiteral("-e") << scriptPath;
|
||||
}
|
||||
|
||||
QProcess::startDetached(exe, args);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ public:
|
|||
Q_INVOKABLE QString highlightedHtml(const QString &code, const QString &language) const;
|
||||
Q_INVOKABLE QString prettyJson(const QString &text) const;
|
||||
Q_INVOKABLE void copyToClipboard(const QString &text) const;
|
||||
Q_INVOKABLE void executeInTerminal(const QString &code, const QString &language) const;
|
||||
Q_INVOKABLE bool loadEarlier(); // returns true if more blocks were loaded
|
||||
|
||||
signals:
|
||||
|
|
|
|||
160
gui/main-kf5.qml
160
gui/main-kf5.qml
|
|
@ -385,6 +385,8 @@ ApplicationWindow {
|
|||
|
||||
// Guard: true while we're programmatically setting contentY
|
||||
property bool _settingY: false
|
||||
// Guard: true while content is streaming (height growing)
|
||||
property bool _contentGrowing: false
|
||||
|
||||
onContentYChanged: {
|
||||
if (_settingY) return
|
||||
|
|
@ -392,8 +394,8 @@ ApplicationWindow {
|
|||
parent.atBottom = (bottom <= 0 || contentY >= bottom - 4)
|
||||
|
||||
// Load earlier messages when scrolled near top
|
||||
// Only if user actually scrolled (not during content updates)
|
||||
if (contentY < 100 && contentHeight > height && !parent.atBottom) {
|
||||
// Only on user scroll — not during content growth or programmatic scroll
|
||||
if (!_contentGrowing && contentY < 100 && contentHeight > height && !parent.atBottom) {
|
||||
var oldHeight = contentHeight
|
||||
if (chatModel.loadEarlier()) {
|
||||
_settingY = true
|
||||
|
|
@ -404,19 +406,45 @@ ApplicationWindow {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Debounce scroll-to-bottom: batch rapid contentHeight changes
|
||||
property bool _scrollPending: false
|
||||
|
||||
// Timer-based debounce: coalesce all contentHeight changes within
|
||||
// a single frame (~16ms) to avoid rapid scroll/restore flickering
|
||||
Timer {
|
||||
id: scrollTimer
|
||||
interval: 16
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
chatFlick._contentGrowing = false
|
||||
if (chatFlick.parent.atBottom) {
|
||||
chatFlick._settingY = true
|
||||
chatFlick.contentY = Math.max(0, chatFlick.contentHeight - chatFlick.height)
|
||||
chatFlick._settingY = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onContentHeightChanged: {
|
||||
if (parent.atBottom && !_scrollPending) {
|
||||
_scrollPending = true
|
||||
Qt.callLater(function() {
|
||||
chatFlick._scrollPending = false
|
||||
if (chatFlick.parent.atBottom) {
|
||||
chatFlick._settingY = true
|
||||
chatFlick.contentY = Math.max(0, chatFlick.contentHeight - chatFlick.height)
|
||||
chatFlick._settingY = false
|
||||
}
|
||||
})
|
||||
if (parent.atBottom) {
|
||||
_contentGrowing = true
|
||||
scrollTimer.restart()
|
||||
}
|
||||
}
|
||||
|
||||
// Direct wheel scroll without inertia
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.NoButton
|
||||
onWheel: {
|
||||
var dy = wheel.angleDelta.y
|
||||
chatFlick._settingY = true
|
||||
chatFlick.contentY = Math.max(0,
|
||||
Math.min(chatFlick.contentY - dy,
|
||||
chatFlick.contentHeight - chatFlick.height))
|
||||
chatFlick._settingY = false
|
||||
// Update atBottom
|
||||
var bottom = chatFlick.contentHeight - chatFlick.height
|
||||
chatFlick.parent.atBottom = (bottom <= 0 || chatFlick.contentY >= bottom - 4)
|
||||
wheel.accepted = true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -624,56 +652,56 @@ ApplicationWindow {
|
|||
ToolTip.text: "Interrupt"
|
||||
ToolTip.visible: hovered
|
||||
}
|
||||
}
|
||||
|
||||
// Emoji completion popup
|
||||
ListModel { id: emojiModel }
|
||||
// Emoji completion popup
|
||||
ListModel { id: emojiModel }
|
||||
|
||||
Popup {
|
||||
id: emojiPopup
|
||||
y: -height - 4
|
||||
x: 8
|
||||
width: 280
|
||||
height: Math.min(emojiList.contentHeight + 12, 240)
|
||||
padding: 6
|
||||
visible: false
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
Popup {
|
||||
id: emojiPopup
|
||||
y: -height - 4
|
||||
x: 8
|
||||
width: 280
|
||||
height: Math.min(emojiList.contentHeight + 12, 240)
|
||||
padding: 6
|
||||
visible: false
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
background: Rectangle {
|
||||
color: palette.base
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 6
|
||||
}
|
||||
background: Rectangle {
|
||||
color: palette.base
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 6
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: emojiList
|
||||
anchors.fill: parent
|
||||
model: emojiModel
|
||||
clip: true
|
||||
currentIndex: 0
|
||||
ListView {
|
||||
id: emojiList
|
||||
anchors.fill: parent
|
||||
model: emojiModel
|
||||
clip: true
|
||||
currentIndex: 0
|
||||
|
||||
delegate: ItemDelegate {
|
||||
width: emojiList.width
|
||||
height: 32
|
||||
highlighted: index === emojiList.currentIndex
|
||||
delegate: ItemDelegate {
|
||||
width: emojiList.width
|
||||
height: 32
|
||||
highlighted: index === emojiList.currentIndex
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: 8
|
||||
Label {
|
||||
text: model.emoji
|
||||
font.pixelSize: 18
|
||||
contentItem: RowLayout {
|
||||
spacing: 8
|
||||
Label {
|
||||
text: model.emoji
|
||||
font.pixelSize: 18
|
||||
}
|
||||
Label {
|
||||
text: ":" + model.code + ":"
|
||||
Layout.fillWidth: true
|
||||
font.family: "monospace"
|
||||
opacity: 0.8
|
||||
}
|
||||
}
|
||||
Label {
|
||||
text: ":" + model.code + ":"
|
||||
Layout.fillWidth: true
|
||||
font.family: "monospace"
|
||||
opacity: 0.8
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
promptInput.applyEmoji(model.emoji, model.code)
|
||||
onClicked: {
|
||||
promptInput.applyEmoji(model.emoji, model.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -770,7 +798,7 @@ ApplicationWindow {
|
|||
id: assistantBubble
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
width: Math.min(assistantContent.implicitWidth + 24, parent.width * 0.95)
|
||||
width: parent.width * 0.95
|
||||
implicitHeight: assistantContent.implicitHeight + 16
|
||||
color: Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.06)
|
||||
radius: 12
|
||||
|
|
@ -1046,6 +1074,7 @@ ApplicationWindow {
|
|||
id: codeSubDelegate
|
||||
Rectangle {
|
||||
implicitHeight: codeCol.implicitHeight
|
||||
implicitWidth: Math.max(280, codeCol.implicitWidth)
|
||||
color: Qt.rgba(palette.base.r, palette.base.g, palette.base.b, 0.6)
|
||||
radius: 4
|
||||
border.width: 1
|
||||
|
|
@ -1056,7 +1085,7 @@ ApplicationWindow {
|
|||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
// Header with language + copy button
|
||||
// Header with language + copy/exec buttons
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 4
|
||||
|
|
@ -1068,6 +1097,21 @@ ApplicationWindow {
|
|||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
icon.name: "media-playback-start"
|
||||
implicitWidth: 32
|
||||
implicitHeight: 32
|
||||
visible: {
|
||||
var lang = (subBlock.language || "").toLowerCase()
|
||||
return ["bash", "sh", "zsh", "fish", "python", "python3",
|
||||
"py", "perl", "ruby", "rb", "lua", "node",
|
||||
"javascript", "js", "php"].indexOf(lang) >= 0
|
||||
}
|
||||
onClicked: chatModel.executeInTerminal(subBlock.content, subBlock.language)
|
||||
ToolTip.text: "Execute in terminal"
|
||||
ToolTip.visible: hovered
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
icon.name: "edit-copy"
|
||||
implicitWidth: 32
|
||||
|
|
|
|||
163
gui/main.qml
163
gui/main.qml
|
|
@ -387,6 +387,8 @@ ApplicationWindow {
|
|||
|
||||
// Guard: true while we're programmatically setting contentY
|
||||
property bool _settingY: false
|
||||
// Guard: true while content is streaming (height growing)
|
||||
property bool _contentGrowing: false
|
||||
|
||||
onContentYChanged: {
|
||||
if (_settingY) return
|
||||
|
|
@ -394,8 +396,8 @@ ApplicationWindow {
|
|||
parent.atBottom = (bottom <= 0 || contentY >= bottom - 4)
|
||||
|
||||
// Load earlier messages when scrolled near top
|
||||
// Only if user actually scrolled (not during content updates)
|
||||
if (contentY < 100 && contentHeight > height && !parent.atBottom) {
|
||||
// Only on user scroll — not during content growth or programmatic scroll
|
||||
if (!_contentGrowing && contentY < 100 && contentHeight > height && !parent.atBottom) {
|
||||
let oldHeight = contentHeight
|
||||
if (chatModel.loadEarlier()) {
|
||||
_settingY = true
|
||||
|
|
@ -406,19 +408,45 @@ ApplicationWindow {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Debounce scroll-to-bottom: batch rapid contentHeight changes
|
||||
property bool _scrollPending: false
|
||||
|
||||
// Timer-based debounce: coalesce all contentHeight changes within
|
||||
// a single frame (~16ms) to avoid rapid scroll/restore flickering
|
||||
Timer {
|
||||
id: scrollTimer
|
||||
interval: 16
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
chatFlick._contentGrowing = false
|
||||
if (chatFlick.parent.atBottom) {
|
||||
chatFlick._settingY = true
|
||||
chatFlick.contentY = Math.max(0, chatFlick.contentHeight - chatFlick.height)
|
||||
chatFlick._settingY = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onContentHeightChanged: {
|
||||
if (parent.atBottom && !_scrollPending) {
|
||||
_scrollPending = true
|
||||
Qt.callLater(function() {
|
||||
chatFlick._scrollPending = false
|
||||
if (chatFlick.parent.atBottom) {
|
||||
chatFlick._settingY = true
|
||||
chatFlick.contentY = Math.max(0, chatFlick.contentHeight - chatFlick.height)
|
||||
chatFlick._settingY = false
|
||||
}
|
||||
})
|
||||
if (parent.atBottom) {
|
||||
_contentGrowing = true
|
||||
scrollTimer.restart()
|
||||
}
|
||||
}
|
||||
|
||||
// Direct wheel scroll without inertia
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.NoButton
|
||||
onWheel: function(wheel) {
|
||||
let dy = wheel.angleDelta.y
|
||||
chatFlick._settingY = true
|
||||
chatFlick.contentY = Math.max(0,
|
||||
Math.min(chatFlick.contentY - dy,
|
||||
chatFlick.contentHeight - chatFlick.height))
|
||||
chatFlick._settingY = false
|
||||
// Update atBottom
|
||||
let bottom = chatFlick.contentHeight - chatFlick.height
|
||||
chatFlick.parent.atBottom = (bottom <= 0 || chatFlick.contentY >= bottom - 4)
|
||||
wheel.accepted = true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -628,56 +656,55 @@ ApplicationWindow {
|
|||
ToolTip.text: "Interrupt"
|
||||
ToolTip.visible: hovered
|
||||
}
|
||||
}
|
||||
// Emoji completion popup
|
||||
ListModel { id: emojiModel }
|
||||
|
||||
// Emoji completion popup
|
||||
ListModel { id: emojiModel }
|
||||
Popup {
|
||||
id: emojiPopup
|
||||
y: -height - 4
|
||||
x: 8
|
||||
width: 280
|
||||
height: Math.min(emojiList.contentHeight + 12, 240)
|
||||
padding: 6
|
||||
visible: false
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
Popup {
|
||||
id: emojiPopup
|
||||
y: -height - 4
|
||||
x: 8
|
||||
width: 280
|
||||
height: Math.min(emojiList.contentHeight + 12, 240)
|
||||
padding: 6
|
||||
visible: false
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
background: Rectangle {
|
||||
color: palette.base
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 6
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: palette.base
|
||||
border.color: palette.mid
|
||||
border.width: 1
|
||||
radius: 6
|
||||
}
|
||||
ListView {
|
||||
id: emojiList
|
||||
anchors.fill: parent
|
||||
model: emojiModel
|
||||
clip: true
|
||||
currentIndex: 0
|
||||
|
||||
ListView {
|
||||
id: emojiList
|
||||
anchors.fill: parent
|
||||
model: emojiModel
|
||||
clip: true
|
||||
currentIndex: 0
|
||||
delegate: ItemDelegate {
|
||||
width: emojiList.width
|
||||
height: 32
|
||||
highlighted: index === emojiList.currentIndex
|
||||
|
||||
delegate: ItemDelegate {
|
||||
width: emojiList.width
|
||||
height: 32
|
||||
highlighted: index === emojiList.currentIndex
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: 8
|
||||
Label {
|
||||
text: model.emoji
|
||||
font.pixelSize: 18
|
||||
contentItem: RowLayout {
|
||||
spacing: 8
|
||||
Label {
|
||||
text: model.emoji
|
||||
font.pixelSize: 18
|
||||
}
|
||||
Label {
|
||||
text: ":" + model.code + ":"
|
||||
Layout.fillWidth: true
|
||||
font.family: "monospace"
|
||||
opacity: 0.8
|
||||
}
|
||||
}
|
||||
Label {
|
||||
text: ":" + model.code + ":"
|
||||
Layout.fillWidth: true
|
||||
font.family: "monospace"
|
||||
opacity: 0.8
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
promptInput.applyEmoji(model.emoji, model.code)
|
||||
onClicked: {
|
||||
promptInput.applyEmoji(model.emoji, model.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -713,7 +740,7 @@ ApplicationWindow {
|
|||
id: userBubble
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
width: Math.min(userContent.implicitWidth + 24, parent.width * 0.85)
|
||||
width: Math.max(160, Math.min(userContent.implicitWidth + 24, parent.width * 0.85))
|
||||
implicitHeight: userContent.implicitHeight + 16
|
||||
color: Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.15)
|
||||
radius: 12
|
||||
|
|
@ -776,7 +803,7 @@ ApplicationWindow {
|
|||
id: assistantBubble
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
width: Math.min(assistantContent.implicitWidth + 24, parent.width * 0.95)
|
||||
width: parent.width * 0.95
|
||||
implicitHeight: assistantContent.implicitHeight + 16
|
||||
color: Qt.rgba(palette.text.r, palette.text.g, palette.text.b, 0.06)
|
||||
radius: 12
|
||||
|
|
@ -1054,6 +1081,7 @@ ApplicationWindow {
|
|||
id: codeSubDelegate
|
||||
Rectangle {
|
||||
implicitHeight: codeCol.implicitHeight
|
||||
implicitWidth: Math.max(280, codeCol.implicitWidth)
|
||||
color: Qt.rgba(palette.base.r, palette.base.g, palette.base.b, 0.6)
|
||||
radius: 4
|
||||
border.width: 1
|
||||
|
|
@ -1064,7 +1092,7 @@ ApplicationWindow {
|
|||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
// Header with language + copy button
|
||||
// Header with language + copy/exec buttons
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: 4
|
||||
|
|
@ -1076,6 +1104,21 @@ ApplicationWindow {
|
|||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
icon.name: "media-playback-start"
|
||||
implicitWidth: 32
|
||||
implicitHeight: 32
|
||||
visible: {
|
||||
let lang = (subBlock.language || "").toLowerCase()
|
||||
return ["bash", "sh", "zsh", "fish", "python", "python3",
|
||||
"py", "perl", "ruby", "rb", "lua", "node",
|
||||
"javascript", "js", "php"].indexOf(lang) >= 0
|
||||
}
|
||||
onClicked: chatModel.executeInTerminal(subBlock.content, subBlock.language)
|
||||
ToolTip.text: "Execute in terminal"
|
||||
ToolTip.visible: hovered
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
icon.name: "edit-copy"
|
||||
implicitWidth: 32
|
||||
|
|
|
|||
Reference in New Issue