297 lines
7.0 KiB
QML
297 lines
7.0 KiB
QML
import QtQuick
|
|
import QtQuick.Effects
|
|
import QtQuick.Shapes
|
|
import QtQuick.Layouts
|
|
import Quickshell
|
|
import ZShell.Config
|
|
import ZShell.Llm
|
|
import qs.Components
|
|
import qs.Services
|
|
|
|
CustomRect {
|
|
id: root
|
|
|
|
required property string language
|
|
required property string code
|
|
property bool copied: false
|
|
property color codeBackgroundColor: Colors.palette.m3surfaceContainerHigh
|
|
property color codeHeaderColor: Colors.palette.m3outline
|
|
property string html: ""
|
|
property bool colored: false
|
|
property string requestedCode: ""
|
|
property string requestedLanguage: ""
|
|
property int highlightToken: 0
|
|
|
|
function refresh() {
|
|
requestedCode = root.code;
|
|
requestedLanguage = root.language;
|
|
const token = ++root.highlightToken;
|
|
CodeHighlighter.highlight(root.code, root.language, root, token);
|
|
}
|
|
|
|
function onHighlightSpans(token, spans) {
|
|
if (token !== root.highlightToken)
|
|
return;
|
|
if (root.code !== root.requestedCode || root.language !== root.requestedLanguage)
|
|
return;
|
|
html = spans.length ? root.highlightedHtml(root.code, spans) : "";
|
|
colored = root.visible && html.length > 0;
|
|
}
|
|
|
|
function roleColor(kind) {
|
|
var s = CodeColors.active;
|
|
switch (kind) {
|
|
case "comment":
|
|
return s.comment;
|
|
case "string":
|
|
return s.string;
|
|
case "string.key":
|
|
return s.stringKey;
|
|
case "number":
|
|
case "constant":
|
|
return s.number;
|
|
case "keyword":
|
|
return s.keyword;
|
|
case "type":
|
|
return s.type;
|
|
case "function":
|
|
return s.functions;
|
|
case "method":
|
|
return s.method ?? s.functions;
|
|
case "macro":
|
|
return s.macro;
|
|
case "preproc":
|
|
return s.preproc ?? s.macro;
|
|
case "operator":
|
|
return s.operator ?? s.normal;
|
|
case "property":
|
|
return s.property ?? s.normal;
|
|
case "label":
|
|
return s.label;
|
|
case "attribute":
|
|
return s.attribute ?? s.label;
|
|
default:
|
|
return s.normal;
|
|
}
|
|
}
|
|
|
|
function escapeHtml(text): string {
|
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
// Mirrors the whitespace rules of the rich text rendering below (leading
|
|
// whitespace kept, mid-line runs collapsed, tabs as single spaces) so the
|
|
// plain and colored layouts are identical.
|
|
function plainForDisplay(code): string {
|
|
return code.split("\n").map(line => {
|
|
const i = line.search(/\S/);
|
|
if (i < 0)
|
|
return "";
|
|
const lead = line.slice(0, i).replace(/\t/g, " ");
|
|
return lead + line.slice(i).replace(/\s+/g, " ").replace(/\s+$/, "");
|
|
}).join("\n");
|
|
}
|
|
|
|
function highlightedHtml(code, spans): string {
|
|
let out;
|
|
if (!spans.length) {
|
|
out = escapeHtml(code);
|
|
} else {
|
|
out = "";
|
|
let pos = 0;
|
|
for (let i = 0; i < spans.length; i++) {
|
|
const span = spans[i];
|
|
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));
|
|
}
|
|
return out.replace(/(^|\n)[ \t]+/g, ws => ws.replace(/[ \t]/g, " ")).replace(/\n/g, "<br>");
|
|
}
|
|
|
|
implicitWidth: headerRow.implicitWidth + headerRow.anchors.leftMargin + headerRow.anchors.rightMargin
|
|
implicitHeight: headerRow.anchors.topMargin + headerRow.implicitHeight + codeRect.implicitHeight + codeRect.anchors.margins + codeRect.anchors.topMargin
|
|
color: root.codeBackgroundColor
|
|
radius: Tokens.rounding.medium
|
|
|
|
onLanguageChanged: {
|
|
html = "";
|
|
colored = false;
|
|
if (root.visible)
|
|
refreshTimer.restart();
|
|
}
|
|
onCodeChanged: {
|
|
colored = false;
|
|
if (root.visible)
|
|
refreshTimer.restart();
|
|
}
|
|
onVisibleChanged: if (root.visible)
|
|
refreshTimer.restart()
|
|
|
|
Timer {
|
|
id: refreshTimer
|
|
|
|
interval: 200
|
|
repeat: false
|
|
onTriggered: refresh()
|
|
}
|
|
|
|
RowLayout {
|
|
id: headerRow
|
|
|
|
anchors.top: parent.top
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.topMargin: Tokens.padding.small
|
|
anchors.leftMargin: Tokens.padding.small
|
|
anchors.rightMargin: Tokens.padding.small
|
|
spacing: Tokens.spacing.small
|
|
|
|
Item {
|
|
id: iconItem
|
|
|
|
readonly property string iconPath: CodeIcons.path(root.language)
|
|
|
|
Layout.fillHeight: true
|
|
implicitWidth: height
|
|
|
|
Shape {
|
|
id: shape
|
|
|
|
anchors.centerIn: parent
|
|
visible: iconItem.iconPath !== ""
|
|
preferredRendererType: Shape.CurveRenderer
|
|
scale: Math.min(langText.implicitHeight / height, langText.implicitHeight / width)
|
|
|
|
ShapePath {
|
|
strokeColor: "transparent"
|
|
fillColor: Colors.palette.m3tertiary
|
|
fillRule: ShapePath.WindingFill
|
|
|
|
PathSvg {
|
|
path: iconItem.iconPath
|
|
}
|
|
}
|
|
}
|
|
|
|
Shape {
|
|
id: fallbackShape
|
|
|
|
anchors.centerIn: parent
|
|
anchors.verticalCenterOffset: -1
|
|
visible: iconItem.iconPath === ""
|
|
preferredRendererType: Shape.CurveRenderer
|
|
scale: Math.min(langText.implicitHeight / height, langText.implicitHeight / width)
|
|
|
|
ShapePath {
|
|
strokeColor: Colors.palette.m3tertiary
|
|
strokeWidth: 16
|
|
capStyle: ShapePath.RoundCap
|
|
joinStyle: ShapePath.RoundJoin
|
|
fillColor: "transparent"
|
|
|
|
PathSvg {
|
|
path: "M 40 64 L 112 128 L 40 192"
|
|
}
|
|
}
|
|
|
|
ShapePath {
|
|
strokeColor: Colors.palette.m3tertiary
|
|
strokeWidth: 16
|
|
capStyle: ShapePath.RoundCap
|
|
fillColor: "transparent"
|
|
|
|
PathSvg {
|
|
path: "M 120 192 L 216 192"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
CustomText {
|
|
id: langText
|
|
|
|
Layout.alignment: Qt.AlignVCenter
|
|
color: root.codeHeaderColor
|
|
font: Tokens.font.label.small
|
|
text: CodeIcons.name(root.language)
|
|
visible: root.language.length > 0
|
|
}
|
|
|
|
Item {
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
IconButton {
|
|
icon: root.copied ? "check" : "content_copy"
|
|
inactiveColor: "transparent"
|
|
inactiveOnColor: root.codeHeaderColor
|
|
label.animate: true
|
|
type: IconButton.Text
|
|
|
|
onClicked: {
|
|
Quickshell.clipboardText = root.code;
|
|
root.copied = true;
|
|
copyResetTimer.restart();
|
|
}
|
|
|
|
Timer {
|
|
id: copyResetTimer
|
|
|
|
interval: 1500
|
|
|
|
onTriggered: root.copied = false
|
|
}
|
|
}
|
|
}
|
|
|
|
CustomClippingRect {
|
|
id: codeRect
|
|
|
|
anchors.left: parent.left
|
|
anchors.top: headerRow.bottom
|
|
anchors.right: parent.right
|
|
anchors.bottom: parent.bottom
|
|
anchors.topMargin: Tokens.padding.small
|
|
radius: root.radius - anchors.margins
|
|
anchors.margins: Tokens.padding.extraSmall
|
|
implicitWidth: codeText.implicitWidth + codeFlick.anchors.margins * 2
|
|
implicitHeight: codeText.implicitHeight + codeFlick.anchors.margins * 2
|
|
color: CodeColors.active.bg
|
|
|
|
Flickable {
|
|
id: codeFlick
|
|
|
|
anchors.fill: parent
|
|
anchors.margins: Tokens.padding.small
|
|
|
|
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
|
|
clip: false
|
|
font: Tokens.font.mono.small
|
|
textFormat: root.colored ? Text.RichText : Text.PlainText
|
|
text: root.colored ? root.html : root.plainForDisplay(root.code)
|
|
readOnly: true
|
|
}
|
|
}
|
|
}
|
|
}
|