forked from Shinonome/dots-hyprland
474 lines
20 KiB
QML
474 lines
20 KiB
QML
import "root:/"
|
|
import "root:/services"
|
|
import "root:/modules/common"
|
|
import "root:/modules/common/widgets"
|
|
import "../"
|
|
import "root:/modules/common/functions/string_utils.js" as StringUtils
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Layouts
|
|
import Quickshell.Io
|
|
import Quickshell
|
|
import Quickshell.Widgets
|
|
import Quickshell.Wayland
|
|
import Quickshell.Hyprland
|
|
import Qt5Compat.GraphicalEffects
|
|
import org.kde.syntaxhighlighting
|
|
|
|
Rectangle {
|
|
id: root
|
|
property int messageIndex
|
|
property var messageData
|
|
property var messageInputField
|
|
|
|
property real messagePadding: 7
|
|
property real contentSpacing: 3
|
|
property real codeBlockBackgroundRounding: Appearance.rounding.small
|
|
property real codeBlockHeaderPadding: 3
|
|
property real codeBlockComponentSpacing: 2
|
|
|
|
property bool renderMarkdown: true
|
|
property bool editing: false
|
|
|
|
anchors.left: parent?.left
|
|
anchors.right: parent?.right
|
|
implicitHeight: columnLayout.implicitHeight + root.messagePadding * 2
|
|
|
|
radius: Appearance.rounding.normal
|
|
color: Appearance.colors.colLayer1
|
|
|
|
function saveMessage() {
|
|
if (!root.editing) return;
|
|
// Get all Loader children (each represents a segment)
|
|
const segments = messageContentColumnLayout.children
|
|
.map(child => child.segment)
|
|
.filter(segment => (segment));
|
|
// console.log("Segments: " + JSON.stringify(segments))
|
|
|
|
// Reconstruct markdown
|
|
const newContent = segments.map(segment => {
|
|
if (segment.type === "code") {
|
|
const lang = segment.lang ? segment.lang : "";
|
|
// Remove trailing newlines
|
|
const code = segment.content.replace(/\n+$/, "");
|
|
return "```" + lang + "\n" + code + "\n```";
|
|
} else {
|
|
return segment.content;
|
|
}
|
|
}).join("");
|
|
|
|
root.editing = false
|
|
root.messageData.content = newContent;
|
|
}
|
|
|
|
Keys.onPressed: (event) => {
|
|
if ( // Prevent de-select
|
|
event.key === Qt.Key_Control ||
|
|
event.key == Qt.Key_Shift ||
|
|
event.key == Qt.Key_Alt ||
|
|
event.key == Qt.Key_Meta
|
|
) {
|
|
event.accepted = true
|
|
}
|
|
// Ctrl + S to save
|
|
if ((event.key === Qt.Key_S) && event.modifiers == Qt.ControlModifier) {
|
|
root.saveMessage();
|
|
event.accepted = true;
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
id: columnLayout
|
|
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.margins: messagePadding
|
|
spacing: root.contentSpacing
|
|
|
|
RowLayout { // Header
|
|
spacing: 15
|
|
|
|
Rectangle { // Name
|
|
id: nameWrapper
|
|
color: Appearance.m3colors.m3secondaryContainer
|
|
radius: Appearance.rounding.small
|
|
implicitWidth: nameRowLayout.implicitWidth + 10 * 2
|
|
implicitHeight: Math.max(nameRowLayout.implicitHeight + 5 * 2, 30)
|
|
Layout.alignment: Qt.AlignVCenter
|
|
|
|
RowLayout {
|
|
id: nameRowLayout
|
|
anchors.centerIn: parent
|
|
spacing: 5
|
|
|
|
Item {
|
|
Layout.alignment: Qt.AlignVCenter
|
|
Layout.fillHeight: true
|
|
implicitWidth: messageData.role == 'assistant' ? modelIcon.width : roleIcon.implicitWidth
|
|
implicitHeight: messageData.role == 'assistant' ? modelIcon.height : roleIcon.implicitHeight
|
|
|
|
CustomIcon {
|
|
id: modelIcon
|
|
anchors.centerIn: parent
|
|
visible: messageData.role == 'assistant' && Ai.models[messageData.model].icon
|
|
width: Appearance.font.pixelSize.large
|
|
height: Appearance.font.pixelSize.large
|
|
source: messageData.role == 'assistant' ? Ai.models[messageData.model].icon :
|
|
messageData.role == 'user' ? 'linux-symbolic' : 'desktop-symbolic'
|
|
}
|
|
ColorOverlay {
|
|
visible: modelIcon.visible
|
|
anchors.fill: modelIcon
|
|
source: modelIcon
|
|
color: Appearance.m3colors.m3onSecondaryContainer
|
|
}
|
|
|
|
MaterialSymbol {
|
|
id: roleIcon
|
|
anchors.centerIn: parent
|
|
visible: !modelIcon.visible
|
|
iconSize: Appearance.font.pixelSize.larger
|
|
color: Appearance.m3colors.m3onSecondaryContainer
|
|
text: messageData.role == 'user' ? 'person' :
|
|
messageData.role == 'interface' ? 'settings' :
|
|
messageData.role == 'assistant' ? 'neurology' :
|
|
'computer'
|
|
}
|
|
}
|
|
|
|
StyledText {
|
|
id: providerName
|
|
Layout.alignment: Qt.AlignVCenter
|
|
font.pixelSize: Appearance.font.pixelSize.normal
|
|
font.weight: Font.DemiBold
|
|
color: Appearance.m3colors.m3onSecondaryContainer
|
|
text: messageData.role == 'assistant' ? Ai.models[messageData.model].name :
|
|
(messageData.role == 'user' && SystemInfo.username) ? SystemInfo.username :
|
|
(messageData.role == 'interface') ? qsTr("Interface") : qsTr("Unknown")
|
|
}
|
|
}
|
|
}
|
|
|
|
Button { // Not visible to model
|
|
id: modelVisibilityIndicator
|
|
visible: messageData.role == 'interface'
|
|
implicitWidth: 16
|
|
implicitHeight: 30
|
|
Layout.alignment: Qt.AlignVCenter
|
|
|
|
background: Item
|
|
|
|
MaterialSymbol {
|
|
id: notVisibleToModelText
|
|
anchors.centerIn: parent
|
|
iconSize: Appearance.font.pixelSize.larger
|
|
color: Appearance.colors.colSubtext
|
|
text: "visibility_off"
|
|
}
|
|
StyledToolTip {
|
|
content: qsTr("Not visible to model")
|
|
}
|
|
}
|
|
|
|
Item { Layout.fillWidth: true }
|
|
|
|
RowLayout {
|
|
spacing: 5
|
|
|
|
AiMessageControlButton {
|
|
id: copyButton
|
|
buttonIcon: "content_copy"
|
|
onClicked: {
|
|
Hyprland.dispatch(`exec wl-copy '${StringUtils.shellSingleQuoteEscape(root.messageData.content)}'`)
|
|
}
|
|
StyledToolTip {
|
|
content: qsTr("Copy")
|
|
}
|
|
}
|
|
AiMessageControlButton {
|
|
id: editButton
|
|
activated: root.editing
|
|
enabled: root.messageData.done
|
|
buttonIcon: "edit"
|
|
onClicked: {
|
|
root.editing = !root.editing
|
|
if (!root.editing) { // Save changes
|
|
root.saveMessage()
|
|
}
|
|
}
|
|
StyledToolTip {
|
|
content: root.editing ? qsTr("Save") : qsTr("Edit")
|
|
}
|
|
}
|
|
AiMessageControlButton {
|
|
id: toggleMarkdownButton
|
|
activated: !root.renderMarkdown
|
|
buttonIcon: "code"
|
|
onClicked: {
|
|
root.renderMarkdown = !root.renderMarkdown
|
|
}
|
|
StyledToolTip {
|
|
content: qsTr("View Markdown source")
|
|
}
|
|
}
|
|
AiMessageControlButton {
|
|
id: deleteButton
|
|
buttonIcon: "close"
|
|
onClicked: {
|
|
Ai.removeMessage(root.messageIndex)
|
|
}
|
|
StyledToolTip {
|
|
content: qsTr("Delete")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
id: messageContentColumnLayout
|
|
|
|
spacing: 0
|
|
Repeater {
|
|
model: ScriptModel {
|
|
values: {
|
|
const result = StringUtils.splitMarkdownBlocks(root.messageData.content)
|
|
// console.log(JSON.stringify(result))
|
|
return result
|
|
}
|
|
}
|
|
delegate: Loader {
|
|
Layout.fillWidth: true
|
|
property var segment: modelData
|
|
sourceComponent: modelData.type === "code" ? codeBlockComponent : textBlockComponent
|
|
}
|
|
}
|
|
}
|
|
|
|
Component { // Text block
|
|
id: textBlockComponent
|
|
TextArea {
|
|
Layout.fillWidth: true
|
|
readOnly: !root.editing
|
|
renderType: Text.NativeRendering
|
|
font.family: Appearance.font.family.reading
|
|
font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text
|
|
font.pixelSize: Appearance.font.pixelSize.small
|
|
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
|
|
selectionColor: Appearance.m3colors.m3secondaryContainer
|
|
wrapMode: TextEdit.Wrap
|
|
color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1
|
|
textFormat: root.renderMarkdown ? TextEdit.MarkdownText : TextEdit.PlainText
|
|
text: messageData.thinking ? qsTr("Waiting for response...") : segment.content
|
|
|
|
onTextChanged: {
|
|
segment.content = text
|
|
}
|
|
|
|
Keys.onPressed: (event) => {
|
|
if ((event.key === Qt.Key_C) && event.modifiers == Qt.ControlModifier) {
|
|
messageText.copy()
|
|
event.accepted = true
|
|
}
|
|
}
|
|
|
|
onLinkActivated: (link) => {
|
|
Qt.openUrlExternally(link)
|
|
Hyprland.dispatch("global quickshell:sidebarLeftClose")
|
|
}
|
|
|
|
MouseArea { // Pointing hand for links
|
|
anchors.fill: parent
|
|
acceptedButtons: Qt.NoButton // Only for hover
|
|
hoverEnabled: true
|
|
cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.IBeamCursor
|
|
}
|
|
}
|
|
}
|
|
|
|
Component { // Code block
|
|
id: codeBlockComponent
|
|
ColumnLayout {
|
|
spacing: codeBlockComponentSpacing
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
|
|
Rectangle { // Code background
|
|
Layout.fillWidth: true
|
|
topLeftRadius: codeBlockBackgroundRounding
|
|
topRightRadius: codeBlockBackgroundRounding
|
|
bottomLeftRadius: Appearance.rounding.unsharpen
|
|
bottomRightRadius: Appearance.rounding.unsharpen
|
|
color: Appearance.m3colors.m3surfaceContainerHighest
|
|
implicitHeight: codeBlockTitleBarRowLayout.implicitHeight + codeBlockHeaderPadding * 2
|
|
|
|
RowLayout { // Language and buttons
|
|
id: codeBlockTitleBarRowLayout
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.leftMargin: codeBlockHeaderPadding
|
|
anchors.rightMargin: codeBlockHeaderPadding
|
|
spacing: 5
|
|
|
|
StyledText {
|
|
id: codeBlockLanguage
|
|
Layout.alignment: Qt.AlignLeft
|
|
Layout.fillWidth: false
|
|
Layout.topMargin: 7
|
|
Layout.bottomMargin: 7
|
|
Layout.leftMargin: 10
|
|
font.pixelSize: Appearance.font.pixelSize.small
|
|
font.weight: Font.DemiBold
|
|
color: Appearance.colors.colOnLayer2
|
|
text: segment.lang ? Repository.definitionForName(segment.lang).name : "plain"
|
|
}
|
|
|
|
Item { Layout.fillWidth: true }
|
|
|
|
AiMessageControlButton {
|
|
id: copyCodeButton
|
|
buttonIcon: "content_copy"
|
|
onClicked: {
|
|
Hyprland.dispatch(`exec wl-copy '${StringUtils.unEscapeBackslashes(
|
|
StringUtils.shellSingleQuoteEscape(segment.content)
|
|
)}'`)
|
|
}
|
|
StyledToolTip {
|
|
content: qsTr("Copy code")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
RowLayout { // Line numbers and code
|
|
spacing: codeBlockComponentSpacing
|
|
|
|
Rectangle { // Line numbers
|
|
implicitWidth: 40
|
|
Layout.fillHeight: true
|
|
Layout.fillWidth: false
|
|
topLeftRadius: Appearance.rounding.unsharpen
|
|
bottomLeftRadius: codeBlockBackgroundRounding
|
|
topRightRadius: Appearance.rounding.unsharpen
|
|
bottomRightRadius: Appearance.rounding.unsharpen
|
|
color: Appearance.colors.colLayer2
|
|
|
|
ColumnLayout {
|
|
id: lineNumberColumnLayout
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 5
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 0
|
|
|
|
Repeater {
|
|
model: codeTextArea.text.split("\n").length
|
|
Text {
|
|
Layout.fillWidth: true
|
|
Layout.alignment: Qt.AlignRight
|
|
font.family: Appearance.font.family.monospace
|
|
font.pixelSize: Appearance.font.pixelSize.small
|
|
color: Appearance.colors.colSubtext
|
|
horizontalAlignment: Text.AlignRight
|
|
text: index + 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle { // Code background
|
|
Layout.fillWidth: true
|
|
topLeftRadius: Appearance.rounding.unsharpen
|
|
bottomLeftRadius: Appearance.rounding.unsharpen
|
|
topRightRadius: Appearance.rounding.unsharpen
|
|
bottomRightRadius: codeBlockBackgroundRounding
|
|
color: Appearance.colors.colLayer2
|
|
implicitHeight: codeTextArea.implicitHeight
|
|
|
|
ScrollView {
|
|
id: codeScrollView
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
implicitWidth: parent.width
|
|
implicitHeight: codeTextArea.implicitHeight + 1
|
|
contentWidth: codeTextArea.width - 1
|
|
// contentHeight: codeTextArea.contentHeight
|
|
clip: true
|
|
ScrollBar.vertical.policy: ScrollBar.AlwaysOff
|
|
|
|
ScrollBar.horizontal: ScrollBar {
|
|
anchors.bottom: parent.bottom
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
padding: 5
|
|
policy: ScrollBar.AsNeeded
|
|
opacity: visualSize == 1 ? 0 : 1
|
|
visible: opacity > 0
|
|
|
|
Behavior on opacity {
|
|
NumberAnimation {
|
|
duration: Appearance.animation.elementMoveFast.duration
|
|
easing.type: Appearance.animation.elementMoveFast.type
|
|
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
|
|
}
|
|
}
|
|
|
|
contentItem: Rectangle {
|
|
implicitHeight: 6
|
|
radius: Appearance.rounding.small
|
|
color: Appearance.colors.colLayer2Active
|
|
}
|
|
}
|
|
|
|
TextArea { // Code
|
|
|
|
id: codeTextArea
|
|
Layout.fillWidth: true
|
|
readOnly: !root.editing
|
|
renderType: Text.NativeRendering
|
|
font.family: Appearance.font.family.monospace
|
|
font.hintingPreference: Font.PreferNoHinting // Prevent weird bold text
|
|
font.pixelSize: Appearance.font.pixelSize.small
|
|
selectedTextColor: Appearance.m3colors.m3onSecondaryContainer
|
|
selectionColor: Appearance.m3colors.m3secondaryContainer
|
|
// wrapMode: TextEdit.Wrap
|
|
color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1
|
|
|
|
text: segment.content
|
|
onTextChanged: {
|
|
segment.content = text
|
|
}
|
|
|
|
Keys.onPressed: (event) => {
|
|
if (event.key === Qt.Key_Tab) {
|
|
// Insert 4 spaces at cursor
|
|
const cursor = codeTextArea.cursorPosition;
|
|
codeTextArea.insert(cursor, " ");
|
|
codeTextArea.cursorPosition = cursor + 4;
|
|
event.accepted = true;
|
|
} else if ((event.key === Qt.Key_C) && event.modifiers == Qt.ControlModifier) {
|
|
messageText.copy();
|
|
event.accepted = true;
|
|
}
|
|
}
|
|
|
|
SyntaxHighlighter {
|
|
id: highlighter
|
|
textEdit: codeTextArea
|
|
repository: Repository
|
|
definition: Repository.definitionForName(segment.lang || "plaintext")
|
|
// definition: Repository.definitionForName("cpp")
|
|
theme: Appearance.syntaxHighlightingTheme
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|