code block syntax highlighting

This commit is contained in:
end-4
2025-05-07 12:16:20 +02:00
parent f3e0f14c44
commit 8464f0107c
6 changed files with 262 additions and 52 deletions
@@ -11,6 +11,7 @@ Singleton {
property QtObject rounding property QtObject rounding
property QtObject font property QtObject font
property QtObject sizes property QtObject sizes
property string syntaxHighlightingTheme
function mix(color1, color2, percentage) { function mix(color1, color2, percentage) {
var c1 = Qt.color(color1); var c1 = Qt.color(color1);
@@ -238,4 +239,5 @@ Singleton {
property int fabHoveredShadowRadius: 7 property int fabHoveredShadowRadius: 7
} }
syntaxHighlightingTheme: Appearance.m3colors.darkmode ? "Monokai" : "ayu Light"
} }
@@ -1,17 +1,35 @@
function format(str, ...args) { function format(str, ...args) {
return str.replace(/{(\d+)}/g, (match, index) => return str.replace(/{(\d+)}/g, (match, index) =>
typeof args[index] !== 'undefined' ? args[index] : match typeof args[index] !== 'undefined' ? args[index] : match
); );
} }
function getDomain(url) { function getDomain(url) {
const match = url.match(/^(?:https?:\/\/)?(?:www\.)?([^\/]+)/); const match = url.match(/^(?:https?:\/\/)?(?:www\.)?([^\/]+)/);
return match ? match[1] : null; return match ? match[1] : null;
} }
function shellSingleQuoteEscape(str) { function shellSingleQuoteEscape(str) {
// First escape backslashes, then escape single quotes // First escape backslashes, then escape single quotes
return String(str) return String(str)
.replace(/\\/g, '\\\\') .replace(/\\/g, '\\\\')
.replace(/'/g, "'\\''"); .replace(/'/g, "'\\''");
} }
function splitMarkdownBlocks(markdown) {
const regex = /```(\w+)?\n([\s\S]*?)```/g;
let result = [];
let lastIndex = 0;
let match;
while ((match = regex.exec(markdown)) !== null) {
if (match.index > lastIndex) {
result.push({ type: "text", content: markdown.slice(lastIndex, match.index) });
}
result.push({ type: "code", lang: match[1] || "", content: match[2] });
lastIndex = regex.lastIndex;
}
if (lastIndex < markdown.length) {
result.push({ type: "text", content: markdown.slice(lastIndex) });
}
return result;
}
@@ -83,7 +83,7 @@ Item {
+ "- **Bold**, *Italic*, `Monospace`, [Link](https://example.com)\n\n" + "- **Bold**, *Italic*, `Monospace`, [Link](https://example.com)\n\n"
+ "- Table:\n\n" + "- Table:\n\n"
+ "| | Quickshell | AGS/Astal |\n" + "| | Quickshell | AGS/Astal |\n"
+ "|:-------------------------|:----------------:|:-----------------:|\n" + "|--------------------------|------------------|-------------------|\n"
+ "| UI Toolkit | Qt | Gtk3/Gtk4 |\n" + "| UI Toolkit | Qt | Gtk3/Gtk4 |\n"
+ "| Language | QML | Js/Ts/Lua |\n" + "| Language | QML | Js/Ts/Lua |\n"
+ "| Reactivity | Implied | Needs declaration |\n" + "| Reactivity | Implied | Needs declaration |\n"
@@ -13,6 +13,8 @@ import Quickshell.Widgets
import Quickshell.Wayland import Quickshell.Wayland
import Quickshell.Hyprland import Quickshell.Hyprland
import Qt5Compat.GraphicalEffects import Qt5Compat.GraphicalEffects
import org.kde.syntaxhighlighting
// import org.kde.kirigami as Kirigami
Rectangle { Rectangle {
id: root id: root
@@ -22,6 +24,8 @@ Rectangle {
property real messagePadding: 7 property real messagePadding: 7
property real contentSpacing: 3 property real contentSpacing: 3
property real codeBlockBackgroundRounding: Appearance.rounding.small
property real codeBlockComponentSpacing: 2
property bool renderMarkdown: true property bool renderMarkdown: true
property bool editing: false property bool editing: false
@@ -149,7 +153,25 @@ Rectangle {
onClicked: { onClicked: {
root.editing = !root.editing root.editing = !root.editing
if (!root.editing) { // Save changes if (!root.editing) { // Save changes
root.messageData.content = messageText.text // 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.messageData.content = newContent;
} }
} }
StyledToolTip { StyledToolTip {
@@ -159,15 +181,12 @@ Rectangle {
AiMessageControlButton { AiMessageControlButton {
id: toggleMarkdownButton id: toggleMarkdownButton
activated: !root.renderMarkdown activated: !root.renderMarkdown
buttonIcon: root.renderMarkdown ? "wysiwyg" : "code" buttonIcon: "code"
onClicked: { onClicked: {
root.renderMarkdown = !root.renderMarkdown root.renderMarkdown = !root.renderMarkdown
if (root.renderMarkdown && messageData.finished) {
messageText.text = root.messageData.content
}
} }
StyledToolTip { StyledToolTip {
content: qsTr("Toggle Markdown rendering") content: qsTr("View Markdown source")
} }
} }
AiMessageControlButton { AiMessageControlButton {
@@ -183,45 +202,214 @@ Rectangle {
} }
} }
TextEdit { // Message ColumnLayout {
id: messageText id: messageContentColumnLayout
Layout.fillWidth: true Repeater {
Layout.margins: messagePadding model: ScriptModel {
readOnly: !root.editing values: {
selectByMouse: true const result = StringUtils.splitMarkdownBlocks(root.messageData.content)
// console.log(JSON.stringify(result))
renderType: Text.NativeRendering return result
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: Text.WordWrap
color: messageData.thinking ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer1
textFormat: root.renderMarkdown ? TextEdit.MarkdownText : TextEdit.PlainText
text: messageData.thinking ? qsTr("Waiting for response...") : root.messageData.content
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Control) { // Prevent de-select
event.accepted = true
} }
if ((event.key === Qt.Key_C) && event.modifiers == Qt.ControlModifier) { delegate: Loader {
messageText.copy() Layout.fillWidth: true
event.accepted = true property var segment: modelData
sourceComponent: modelData.type === "code" ? codeBlockComponent : textBlockComponent
} }
} }
onLinkActivated: (link) => {
Qt.openUrlExternally(link)
Hyprland.dispatch("global quickshell:sidebarLeftClose")
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton // Only for hover
hoverEnabled: true
cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.IBeamCursor
}
} }
Component { // Text block
id: textBlockComponent
TextArea {
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_Control || event.key == Qt.Key_Shift || event.key == Qt.Key_Alt || event.key == Qt.Key_Meta) { // Prevent de-select
event.accepted = true
}
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
Layout.fillWidth: true
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
RowLayout { // Language and buttons
id: codeBlockTitleBarRowLayout
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 }
}
}
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
// implicitWidth: codeTextArea.implicitWidth
implicitHeight: codeTextArea.implicitHeight
ScrollView {
id: codeScrollView
Layout.fillWidth: true
Layout.fillHeight: true
implicitWidth: parent.width
implicitHeight: codeTextArea.contentHeight
contentWidth: codeTextArea.contentWidth
contentHeight: codeTextArea.contentHeight
clip: true
ScrollBar.vertical.policy: ScrollBar.AlwaysOff
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_Control ||
event.key == Qt.Key_Shift ||
event.key == Qt.Key_Alt ||
event.key == Qt.Key_Meta
) {
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
}
}
}
}
}
}
}
} }
} }
@@ -25,6 +25,8 @@ Singleton {
Appearance.m3colors[m3Key] = json[key] Appearance.m3colors[m3Key] = json[key]
} }
} }
Appearance.m3colors.darkmode = (Appearance.m3colors.m3background.hslLightness < 0.5)
} }
Timer { Timer {
+1 -1
View File
@@ -2,7 +2,7 @@
## Not ready, but feel free to try it. It's simple: ## Not ready, but feel free to try it. It's simple:
- **Assumption**: You are already using the AGS illogical-impulse - **Assumption**: You are already using the AGS illogical-impulse
- **Install Qt packages** (idk which are actually needed so this is everything I have): `qt5-base qt5-declarative qt5-graphicaleffects qt5-imageformats qt5-quickcontrols qt5-quickcontrols2 qt5-svg qt5-translations qt5-wayland qt5-x11extras qt6-5compat qt6-base qt6-declarative qt6-imageformats qt6-multimedia qt6-positioning qt6-quicktimeline qt6-sensors qt6-svg qt6-tools qt6-translations qt6-virtualkeyboard qt6-wayland qt6-webchannel qt6-webengine qt6-websockets qt6-webview` - **Install Qt packages** (idk which are actually needed so this is everything I have): `qt5-base qt5-declarative qt5-graphicaleffects qt5-imageformats qt5-quickcontrols qt5-quickcontrols2 qt5-svg qt5-translations qt5-wayland qt5-x11extras qt6-5compat qt6-base qt6-declarative qt6-imageformats qt6-multimedia qt6-positioning qt6-quicktimeline qt6-sensors qt6-svg qt6-tools qt6-translations qt6-virtualkeyboard qt6-wayland qt6-webchannel qt6-webengine qt6-websockets qt6-webview syntax-highlighting`
- **Install quickshell and more stuff**: `yay -S quickshell matugen-bin grimblast` - **Install quickshell and more stuff**: `yay -S quickshell matugen-bin grimblast`
- **Copy** `.config/quickshell` folder and hyprland config files in `.config/hypr/hyprland/` (backing up is your responsibility) - **Copy** `.config/quickshell` folder and hyprland config files in `.config/hypr/hyprland/` (backing up is your responsibility)
- **Run quickshell** with `qs` and see how things are - it's not finished for daily use, but **feedback is very welcome** - **Run quickshell** with `qs` and see how things are - it's not finished for daily use, but **feedback is very welcome**