Files
meatshell/ui/terminal_view.slint
2026-06-19 03:09:53 +08:00

2299 lines
103 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Theme } from "theme.slint";
import { SftpPanel, SftpEntry, SftpTreeNode } from "sftp_panel.slint";
// Embed the terminal fonts here too so the cell-probe Text below measures
// with the CORRECT font (Cascadia Mono), not a system fallback. Font imports
// in app.slint are globally available in theory, but some Slint builds only
// register them in the entry-point file's component tree; importing them here
// guarantees the probe uses Cascadia Mono for its preferred-width calculation.
import "fonts/CascadiaMono-Regular.ttf";
import "fonts/CascadiaMono-Bold.ttf";
// One coloured run of text on the terminal grid. The Rust side groups
// consecutive vt100 cells that share fg + bg + bold into a single span and
// tags it with its grid position (row, col) and width in cells, so the UI can
// place both a background fill and the text with absolute monospace coordinates.
// `bg` is transparent (alpha 0) when the cell uses the default background.
export struct TermSpan {
text: string,
fg: color,
bg: color,
bold: bool,
use-fallback-font: bool,
row: int,
col: int,
cells: int,
}
// A find-match highlight rectangle on the terminal grid.
export struct TermMatch {
row: int,
col: int,
len: int,
}
export struct CommandCategory {
id: string,
name: string,
builtin: bool,
}
export struct CommandItem {
id: string,
category-id: string,
name: string,
command: string,
append-cr: bool,
}
// --- MenuItem --------------------------------------------------------------
// One row of the terminal right-click context menu (MDL2 icon + label).
component MenuItem inherits Rectangle {
in property <string> icon;
in property <string> label;
callback clicked();
height: 28px;
border-radius: Theme.radius-sm;
background: ta.has-hover ? Theme.bg-hover : transparent;
ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
HorizontalLayout {
height: parent.height;
padding-left: 8px;
padding-right: 8px;
spacing: 8px;
Text {
width: 18px;
height: parent.height;
text: root.icon;
font-family: "Material Icons";
color: Theme.text-secondary;
font-size: Theme.fs-sm;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
text: root.label;
color: Theme.text-primary;
font-size: Theme.fs-md;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
}
component CommandIconButton inherits Rectangle {
in property <string> glyph;
in property <brush> fg: Theme.text-secondary;
in property <brush> fg-hover: Theme.text-primary;
callback clicked();
width: 26px;
height: 24px;
border-radius: Theme.radius-sm;
background: touch.has-hover ? Theme.bg-hover : transparent;
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
Text {
width: parent.width;
height: parent.height;
text: root.glyph;
font-family: "Material Icons";
color: touch.has-hover ? root.fg-hover : root.fg;
font-size: Theme.fs-sm;
horizontal-alignment: center;
vertical-alignment: center;
}
}
component CommandParamField inherits Rectangle {
in property <string> label;
in-out property <string> value;
in property <bool> missing: label != "" && value == "";
property <string> label-text: label + ":";
height: 30px;
vertical-stretch: 0;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: missing ? Theme.danger : (input.has-focus ? Theme.accent : Theme.border-subtle);
background: Theme.bg-root;
label-probe := Text {
text: root.label-text;
font-size: Theme.fs-xs;
opacity: 0;
x: -1000px;
y: -1000px;
}
label-hover := TouchArea {
width: parent.width;
height: parent.height;
}
input := TextInput {
x: 8px;
y: (parent.height - self.height) / 2;
width: parent.width - 16px;
height: 24px;
text <=> root.value;
color: Theme.text-primary;
font-size: Theme.fs-sm;
single-line: true;
vertical-alignment: center;
}
if root.value == "" : Text {
x: 10px;
y: (parent.height - self.height) / 2;
width: parent.width - 20px;
height: 22px;
text: root.label-text;
color: Theme.text-muted;
font-size: Theme.fs-xs;
vertical-alignment: center;
overflow: elide;
}
if label-hover.has-hover && label-probe.preferred-width > root.width - 20px : Rectangle {
x: 0px;
y: -28px;
width: label-probe.preferred-width + 16px;
height: 24px;
z: 100;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.border-strong;
background: Theme.bg-panel;
drop-shadow-blur: 8px;
drop-shadow-color: #00000050;
Text {
x: 8px;
width: parent.width - 16px;
height: parent.height;
text: root.label-text;
color: Theme.text-primary;
font-size: Theme.fs-xs;
vertical-alignment: center;
overflow: elide;
}
}
}
component CommandParamInsertButton inherits Rectangle {
in property <string> label;
callback clicked();
width: 44px;
height: 24px;
border-radius: Theme.radius-sm;
background: touch.has-hover ? Theme.bg-hover : Theme.bg-elevated;
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
Text {
width: parent.width;
height: parent.height;
text: root.label;
color: Theme.text-secondary;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// --- CommandManager --------------------------------------------------------
// Bottom dock for FinalShell-style custom commands.
component CommandManager inherits Rectangle {
in property <[CommandCategory]> command-categories: [];
in property <[CommandItem]> command-items: [];
in-out property <string> selected-category;
in-out property <string> selected-command;
in-out property <string> category-draft;
in-out property <string> edit-id;
in-out property <string> edit-name;
in-out property <string> edit-command;
in-out property <bool> edit-append-cr: true;
in-out property <string> param1-name;
in-out property <string> param1-value;
in-out property <string> param2-name;
in-out property <string> param2-value;
in-out property <string> param3-name;
in-out property <string> param3-value;
in-out property <string> param4-name;
in-out property <string> param4-value;
in-out property <string> param5-name;
in-out property <string> param5-value;
callback category-select(string);
callback category-add(string);
callback category-rename(string, string);
callback category-delete(string);
callback command-new(string);
callback command-select(string);
callback command-save(string, string, string, string, bool);
callback command-delete(string);
callback command-run(string, string, string, string, string, string);
callback command-move(string, string);
in-out property <bool> editor-open;
in-out property <bool> category-editor-open;
in-out property <string> category-edit-id;
in-out property <bool> category-can-delete;
property <bool> params-missing:
(param1-name != "" && param1-value == "")
|| (param2-name != "" && param2-value == "")
|| (param3-name != "" && param3-value == "")
|| (param4-name != "" && param4-value == "")
|| (param5-name != "" && param5-value == "");
property <length> ctx-x;
property <length> ctx-y;
background: Theme.bg-panel;
selected-name-probe := Text {
text: root.edit-name;
font-size: Theme.fs-sm;
opacity: 0;
x: -1000px;
y: -1000px;
}
HorizontalLayout {
width: parent.width;
height: parent.height;
spacing: 0;
// Categories --------------------------------------------------------
Rectangle {
width: 178px;
background: Theme.bg-panel;
VerticalLayout {
spacing: 0;
Rectangle {
height: 28px;
background: Theme.bg-root;
header-ta := TouchArea {
mouse-cursor: default;
pointer-event(e) => {
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
root.ctx-x = self.mouse-x;
root.ctx-y = self.mouse-y;
root.category-edit-id = "";
root.category-draft = "";
root.category-can-delete = false;
cat-new-menu.show();
}
}
}
HorizontalLayout {
padding-left: 8px;
padding-right: 4px;
spacing: 4px;
Text {
text: @tr("Categories");
color: Theme.text-muted;
font-size: Theme.fs-xs;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
cat-new-menu := PopupWindow {
x: root.ctx-x;
y: root.ctx-y;
width: 132px;
height: 36px;
Rectangle {
background: Theme.bg-panel;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.border-strong;
drop-shadow-blur: 10px;
drop-shadow-color: #00000050;
VerticalLayout {
padding: 4px;
MenuItem {
icon: "\u{E145}";
label: @tr("New category");
clicked => {
cat-new-menu.close();
root.category-editor-open = true;
}
}
}
}
}
}
Flickable {
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: max(self.height, categories-col.preferred-height);
categories-col := VerticalLayout {
spacing: 0;
for cat[i] in root.command-categories : Rectangle {
height: 30px;
background: root.selected-category == cat.id
? Theme.bg-active
: cat-ta.has-hover
? Theme.bg-hover
: (mod(i, 2) == 0 ? transparent : #ffffff08);
cat-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.category-select(cat.id); }
pointer-event(e) => {
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
root.ctx-x = self.mouse-x;
root.ctx-y = self.mouse-y;
root.category-edit-id = cat.id;
root.category-draft = cat.name;
root.category-can-delete = !cat.builtin;
cat-menu.show();
}
}
}
cat-menu := PopupWindow {
x: root.ctx-x;
y: root.ctx-y;
width: 132px;
height: cat.builtin ? 64px : 92px;
Rectangle {
background: Theme.bg-panel;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.border-strong;
drop-shadow-blur: 10px;
drop-shadow-color: #00000050;
VerticalLayout {
padding: 4px;
spacing: 2px;
MenuItem {
icon: "\u{E145}";
label: @tr("New category");
clicked => {
cat-menu.close();
root.category-edit-id = "";
root.category-draft = "";
root.category-can-delete = false;
root.category-editor-open = true;
}
}
MenuItem {
icon: "\u{E3C9}";
label: @tr("Rename");
clicked => {
cat-menu.close();
root.category-editor-open = true;
}
}
if !cat.builtin : MenuItem {
icon: "\u{E872}";
label: @tr("Delete");
clicked => {
cat-menu.close();
root.category-delete(cat.id);
}
}
}
}
}
HorizontalLayout {
height: parent.height;
padding-left: 8px;
padding-right: 4px;
spacing: 6px;
Text {
width: 16px;
height: parent.height;
text: "\u{E2C7}";
font-family: "Material Icons";
color: Theme.text-secondary;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
text: cat.name;
color: root.selected-category == cat.id ? Theme.text-primary : Theme.text-secondary;
font-size: Theme.fs-sm;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
}
}
}
}
}
}
Rectangle { width: 1px; background: Theme.border-subtle; }
// Commands + parameter runner --------------------------------------
VerticalLayout {
horizontal-stretch: 1;
spacing: 0;
Rectangle {
height: 38px;
background: Theme.bg-root;
HorizontalLayout {
height: parent.height;
padding-left: 8px;
padding-right: 8px;
spacing: 8px;
alignment: center;
if root.selected-command != "" : Text {
height: parent.height;
text: root.edit-name;
color: Theme.text-primary;
font-size: Theme.fs-sm;
font-weight: 600;
vertical-alignment: center;
width: min(160px, max(52px, selected-name-probe.preferred-width));
overflow: elide;
}
if root.param1-name != "" : CommandParamField {
y: (parent.height - self.height) / 2;
width: 132px;
label: root.param1-name;
value <=> root.param1-value;
}
if root.param2-name != "" : CommandParamField {
y: (parent.height - self.height) / 2;
width: 132px;
label: root.param2-name;
value <=> root.param2-value;
}
if root.param3-name != "" : CommandParamField {
y: (parent.height - self.height) / 2;
width: 132px;
label: root.param3-name;
value <=> root.param3-value;
}
if root.param4-name != "" : CommandParamField {
y: (parent.height - self.height) / 2;
width: 132px;
label: root.param4-name;
value <=> root.param4-value;
}
if root.param5-name != "" : CommandParamField {
y: (parent.height - self.height) / 2;
width: 132px;
label: root.param5-name;
value <=> root.param5-value;
}
if root.params-missing : Text {
height: parent.height;
text: @tr("Missing parameters");
color: Theme.danger;
font-size: Theme.fs-xs;
font-weight: 600;
vertical-alignment: center;
}
Rectangle { horizontal-stretch: 1; }
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
Rectangle {
vertical-stretch: 1;
background: Theme.bg-panel;
cmd-bg-ta := TouchArea {
pointer-event(e) => {
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
root.ctx-x = self.mouse-x;
root.ctx-y = self.mouse-y;
cmd-new-menu.show();
}
}
}
Flickable {
width: parent.width;
height: parent.height;
horizontal-stretch: 1;
vertical-stretch: 1;
viewport-width: max(self.width, commands-row.preferred-width);
viewport-height: self.height;
commands-row := HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
padding-top: 8px;
spacing: 6px;
for cmd[i] in root.command-items : Rectangle {
visible: cmd.category-id == root.selected-category;
width: cmd.category-id == root.selected-category
? min(168px, max(48px, cmd-name-probe.preferred-width + 24px))
: 0px;
height: cmd.category-id == root.selected-category ? 28px : 0px;
border-radius: Theme.radius-sm;
background: root.selected-command == cmd.id
? Theme.bg-active
: cmd-row-ta.has-hover
? Theme.bg-hover
: Theme.bg-elevated;
cmd-row-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
if (root.selected-command != cmd.id) {
root.command-select(cmd.id);
}
}
pointer-event(e) => {
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
root.ctx-x = self.mouse-x;
root.ctx-y = self.mouse-y;
if (root.selected-command != cmd.id) {
root.command-select(cmd.id);
}
cmd-menu.show();
}
}
double-clicked => {
root.command-run(cmd.id, root.param1-value, root.param2-value, root.param3-value, root.param4-value, root.param5-value);
}
}
cmd-menu := PopupWindow {
x: root.ctx-x;
y: root.ctx-y;
width: 132px;
height: 92px;
Rectangle {
background: Theme.bg-panel;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.border-strong;
drop-shadow-blur: 10px;
drop-shadow-color: #00000050;
VerticalLayout {
padding: 4px;
spacing: 2px;
MenuItem {
icon: "\u{E037}";
label: @tr("Run");
clicked => {
cmd-menu.close();
root.command-run(cmd.id, root.param1-value, root.param2-value, root.param3-value, root.param4-value, root.param5-value);
}
}
MenuItem {
icon: "\u{E3C9}";
label: @tr("Edit");
clicked => {
cmd-menu.close();
root.editor-open = true;
}
}
MenuItem {
icon: "\u{E872}";
label: @tr("Delete");
clicked => {
cmd-menu.close();
root.command-delete(cmd.id);
}
}
}
}
}
cmd-name-probe := Text {
text: cmd.name;
font-size: Theme.fs-sm;
opacity: 0;
x: -1000px;
y: -1000px;
}
Text {
x: 10px;
width: parent.width - 20px;
height: parent.height;
text: cmd.name;
color: Theme.text-primary;
font-size: Theme.fs-sm;
vertical-alignment: center;
horizontal-alignment: center;
overflow: elide;
}
if cmd.append-cr : Rectangle {
x: parent.width - 7px;
y: 5px;
width: 4px;
height: 4px;
border-radius: 2px;
background: Theme.success;
}
}
}
}
cmd-new-menu := PopupWindow {
x: root.ctx-x;
y: root.ctx-y;
width: 132px;
height: 36px;
Rectangle {
background: Theme.bg-panel;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.border-strong;
drop-shadow-blur: 10px;
drop-shadow-color: #00000050;
VerticalLayout {
padding: 4px;
MenuItem {
icon: "\u{E145}";
label: @tr("New command");
clicked => {
cmd-new-menu.close();
root.command-new(root.selected-category);
root.editor-open = true;
}
}
}
}
}
}
}
}
if false : Rectangle {
width: parent.width;
height: parent.height;
background: #00000088;
close-backdrop := TouchArea {
clicked => { root.editor-open = false; }
}
Rectangle {
x: 24px;
y: 8px;
width: parent.width - 48px;
height: parent.height - 16px;
border-radius: Theme.radius-md;
border-width: 1px;
border-color: Theme.border-strong;
background: Theme.bg-panel;
drop-shadow-blur: 16px;
drop-shadow-color: #00000070;
swallow := TouchArea { clicked => {} }
VerticalLayout {
padding: 10px;
spacing: 8px;
HorizontalLayout {
height: 26px;
spacing: 8px;
Text {
text: root.edit-id == "" ? @tr("New command") : @tr("Command detail");
color: Theme.text-primary;
font-size: Theme.fs-sm;
font-weight: 600;
vertical-alignment: center;
horizontal-stretch: 1;
}
CommandIconButton {
y: (parent.height - self.height) / 2;
glyph: "\u{E5CD}";
clicked => { root.editor-open = false; }
}
}
HorizontalLayout {
height: 28px;
spacing: 8px;
Rectangle {
horizontal-stretch: 1;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: name-input.has-focus ? Theme.accent : Theme.border-subtle;
background: Theme.bg-root;
name-input := TextInput {
x: 8px;
width: parent.width - 16px;
height: parent.height;
text <=> root.edit-name;
color: Theme.text-primary;
font-size: Theme.fs-sm;
single-line: true;
vertical-alignment: center;
}
}
Rectangle {
width: 118px;
height: parent.height;
border-radius: Theme.radius-sm;
background: root.edit-append-cr ? Theme.bg-active : Theme.bg-elevated;
cr-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.edit-append-cr = !root.edit-append-cr; }
}
HorizontalLayout {
height: parent.height;
padding-left: 8px;
padding-right: 8px;
spacing: 6px;
Text {
width: 16px;
height: parent.height;
text: root.edit-append-cr ? "\u{E5CA}" : "";
font-family: "Material Icons";
color: Theme.accent;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
height: parent.height;
text: @tr("Append CR");
color: Theme.text-secondary;
font-size: Theme.fs-xs;
vertical-alignment: center;
}
}
}
}
Rectangle {
height: 52px;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: command-input.has-focus ? Theme.accent : Theme.border-subtle;
background: Theme.bg-root;
command-input := TextInput {
x: 8px;
width: parent.width - 16px;
height: parent.height;
text <=> root.edit-command;
color: Theme.text-primary;
font-size: Theme.fs-sm;
vertical-alignment: center;
}
}
HorizontalLayout {
height: 24px;
spacing: 6px;
CommandParamInsertButton {
label: "p#1";
clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#1 参数1]" : " [p#1 参数1]"); }
}
CommandParamInsertButton {
label: "p#2";
clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#2 参数2]" : " [p#2 参数2]"); }
}
CommandParamInsertButton {
label: "p#3";
clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#3 参数3]" : " [p#3 参数3]"); }
}
CommandParamInsertButton {
label: "p#4";
clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#4 参数4]" : " [p#4 参数4]"); }
}
CommandParamInsertButton {
label: "p#5";
clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#5 参数5]" : " [p#5 参数5]"); }
}
Rectangle { horizontal-stretch: 1; }
}
if root.command-categories.length > 1 && root.edit-id != "" : HorizontalLayout {
height: 24px;
spacing: 4px;
for cat in root.command-categories : Rectangle {
y: (parent.height - self.height) / 2;
width: 72px;
height: 22px;
border-radius: Theme.radius-sm;
background: cat.id == root.selected-category
? Theme.bg-active
: (move-cat-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated);
move-cat-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.command-move(root.edit-id, cat.id); }
}
Text {
width: parent.width;
height: parent.height;
text: cat.name;
color: Theme.text-secondary;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
overflow: elide;
}
}
}
Rectangle { vertical-stretch: 1; }
HorizontalLayout {
height: 28px;
spacing: 8px;
if root.edit-id != "" : Rectangle {
width: 72px;
height: parent.height;
border-radius: Theme.radius-sm;
background: del-cmd-ta.has-hover ? #cc333320 : Theme.bg-elevated;
del-cmd-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.command-delete(root.edit-id);
root.editor-open = false;
}
}
HorizontalLayout {
height: parent.height;
padding-left: 10px;
padding-right: 10px;
spacing: 6px;
Text {
width: 16px;
height: parent.height;
text: "\u{E872}";
font-family: "Material Icons";
color: Theme.danger;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
height: parent.height;
text: @tr("Delete");
color: Theme.danger;
font-size: Theme.fs-xs;
vertical-alignment: center;
}
}
}
Rectangle { horizontal-stretch: 1; }
Rectangle {
width: 72px;
height: parent.height;
border-radius: Theme.radius-sm;
background: cancel-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated;
cancel-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.editor-open = false; }
}
Text {
width: parent.width;
height: parent.height;
text: @tr("Cancel");
color: Theme.text-secondary;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Rectangle {
width: 72px;
height: parent.height;
border-radius: Theme.radius-sm;
background: save-cmd-ta.has-hover ? Theme.accent-hover : Theme.accent;
save-cmd-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.command-save(root.edit-id, root.selected-category, root.edit-name, root.edit-command, root.edit-append-cr);
root.editor-open = false;
}
}
Text {
width: parent.width;
height: parent.height;
text: @tr("Save");
color: #ffffff;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
}
}
}
component CommandEditorModal inherits Rectangle {
in-out property <bool> is-open;
in property <[CommandCategory]> command-categories: [];
in-out property <string> selected-category;
in-out property <string> edit-id;
in-out property <string> edit-name;
in-out property <string> edit-command;
in-out property <bool> edit-append-cr: true;
callback command-save(string, string, string, string, bool);
callback command-delete(string);
callback command-move(string, string);
visible: is-open;
background: #00000088;
TouchArea {
clicked => { root.is-open = false; }
}
Rectangle {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(540px, parent.width - 48px);
height: 260px;
border-radius: Theme.radius-md;
border-width: 1px;
border-color: Theme.border-strong;
background: Theme.bg-panel;
drop-shadow-blur: 18px;
drop-shadow-color: #00000080;
TouchArea {}
VerticalLayout {
padding: 12px;
spacing: 8px;
HorizontalLayout {
height: 26px;
spacing: 8px;
Text {
text: root.edit-id == "" ? @tr("New command") : @tr("Command detail");
color: Theme.text-primary;
font-size: Theme.fs-sm;
font-weight: 600;
vertical-alignment: center;
horizontal-stretch: 1;
}
CommandIconButton {
y: (parent.height - self.height) / 2;
glyph: "\u{E5CD}";
clicked => { root.is-open = false; }
}
}
HorizontalLayout {
height: 28px;
spacing: 8px;
Rectangle {
horizontal-stretch: 1;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: name-input.has-focus ? Theme.accent : Theme.border-subtle;
background: Theme.bg-root;
name-input := TextInput {
x: 8px;
width: parent.width - 16px;
height: parent.height;
text <=> root.edit-name;
color: Theme.text-primary;
font-size: Theme.fs-sm;
single-line: true;
vertical-alignment: center;
}
}
Rectangle {
width: 118px;
height: parent.height;
border-radius: Theme.radius-sm;
background: root.edit-append-cr ? Theme.bg-active : Theme.bg-elevated;
cr-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.edit-append-cr = !root.edit-append-cr; }
}
HorizontalLayout {
height: parent.height;
padding-left: 8px;
padding-right: 8px;
spacing: 6px;
Text {
width: 16px;
height: parent.height;
text: root.edit-append-cr ? "\u{E5CA}" : "";
font-family: "Material Icons";
color: Theme.accent;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
height: parent.height;
text: @tr("Append CR");
color: Theme.text-secondary;
font-size: Theme.fs-xs;
vertical-alignment: center;
}
}
}
}
Rectangle {
height: 52px;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: command-input.has-focus ? Theme.accent : Theme.border-subtle;
background: Theme.bg-root;
command-input := TextInput {
x: 8px;
width: parent.width - 16px;
height: parent.height;
text <=> root.edit-command;
color: Theme.text-primary;
font-size: Theme.fs-sm;
vertical-alignment: center;
}
}
HorizontalLayout {
height: 24px;
spacing: 6px;
CommandParamInsertButton {
label: "p#1";
clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#1 参数1]" : " [p#1 参数1]"); }
}
CommandParamInsertButton {
label: "p#2";
clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#2 参数2]" : " [p#2 参数2]"); }
}
CommandParamInsertButton {
label: "p#3";
clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#3 参数3]" : " [p#3 参数3]"); }
}
CommandParamInsertButton {
label: "p#4";
clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#4 参数4]" : " [p#4 参数4]"); }
}
CommandParamInsertButton {
label: "p#5";
clicked => { root.edit-command = root.edit-command + (root.edit-command == "" ? "[p#5 参数5]" : " [p#5 参数5]"); }
}
Rectangle { horizontal-stretch: 1; }
}
if root.command-categories.length > 1 && root.edit-id != "" : HorizontalLayout {
height: 24px;
spacing: 4px;
for cat in root.command-categories : Rectangle {
y: (parent.height - self.height) / 2;
width: 72px;
height: 22px;
border-radius: Theme.radius-sm;
background: cat.id == root.selected-category
? Theme.bg-active
: (move-cat-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated);
move-cat-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.command-move(root.edit-id, cat.id); }
}
Text {
width: parent.width;
height: parent.height;
text: cat.name;
color: Theme.text-secondary;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
overflow: elide;
}
}
}
Rectangle { vertical-stretch: 1; }
HorizontalLayout {
height: 28px;
spacing: 8px;
if root.edit-id != "" : Rectangle {
width: 72px;
height: parent.height;
border-radius: Theme.radius-sm;
background: del-cmd-ta.has-hover ? #cc333320 : Theme.bg-elevated;
del-cmd-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.command-delete(root.edit-id);
root.is-open = false;
}
}
Text {
width: parent.width;
height: parent.height;
text: @tr("Delete");
color: Theme.danger;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Rectangle { horizontal-stretch: 1; }
Rectangle {
width: 72px;
height: parent.height;
border-radius: Theme.radius-sm;
background: cancel-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated;
cancel-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.is-open = false; }
}
Text {
width: parent.width;
height: parent.height;
text: @tr("Cancel");
color: Theme.text-secondary;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Rectangle {
width: 72px;
height: parent.height;
border-radius: Theme.radius-sm;
background: save-cmd-ta.has-hover ? Theme.accent-hover : Theme.accent;
save-cmd-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.command-save(root.edit-id, root.selected-category, root.edit-name, root.edit-command, root.edit-append-cr);
root.is-open = false;
}
}
Text {
width: parent.width;
height: parent.height;
text: @tr("Save");
color: #ffffff;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
}
}
component CategoryEditorModal inherits Rectangle {
in-out property <bool> is-open;
in-out property <string> edit-id;
in-out property <string> draft;
in property <bool> can-delete: false;
callback category-add(string);
callback category-rename(string, string);
callback category-delete(string);
visible: is-open;
background: #00000088;
TouchArea {
clicked => { root.is-open = false; }
}
Rectangle {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(360px, parent.width - 48px);
height: 148px;
border-radius: Theme.radius-md;
border-width: 1px;
border-color: Theme.border-strong;
background: Theme.bg-panel;
drop-shadow-blur: 18px;
drop-shadow-color: #00000080;
TouchArea {}
VerticalLayout {
padding: 12px;
spacing: 10px;
HorizontalLayout {
height: 24px;
Text {
text: root.edit-id == "" ? @tr("New category") : @tr("Rename category");
color: Theme.text-primary;
font-size: Theme.fs-sm;
font-weight: 600;
vertical-alignment: center;
horizontal-stretch: 1;
}
CommandIconButton {
y: (parent.height - self.height) / 2;
glyph: "\u{E5CD}";
clicked => { root.is-open = false; }
}
}
Rectangle {
height: 28px;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: category-input.has-focus ? Theme.accent : Theme.border-subtle;
background: Theme.bg-root;
category-input := TextInput {
x: 8px;
width: parent.width - 16px;
height: parent.height;
text <=> root.draft;
color: Theme.text-primary;
font-size: Theme.fs-sm;
single-line: true;
vertical-alignment: center;
}
}
HorizontalLayout {
height: 28px;
spacing: 8px;
if root.can-delete : Rectangle {
width: 72px;
height: parent.height;
border-radius: Theme.radius-sm;
background: cat-delete-ta.has-hover ? #cc333320 : Theme.bg-elevated;
cat-delete-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.category-delete(root.edit-id);
root.is-open = false;
}
}
Text {
width: parent.width;
height: parent.height;
text: @tr("Delete");
color: Theme.danger;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Rectangle { horizontal-stretch: 1; }
Rectangle {
width: 72px;
height: parent.height;
border-radius: Theme.radius-sm;
background: cat-cancel-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated;
cat-cancel-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.is-open = false; }
}
Text {
width: parent.width;
height: parent.height;
text: @tr("Cancel");
color: Theme.text-secondary;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Rectangle {
width: 72px;
height: parent.height;
border-radius: Theme.radius-sm;
background: cat-save-ta.has-hover ? Theme.accent-hover : Theme.accent;
cat-save-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
if (root.edit-id == "") {
root.category-add(root.draft);
} else {
root.category-rename(root.edit-id, root.draft);
}
root.is-open = false;
}
}
Text {
width: parent.width;
height: parent.height;
text: @tr("Save");
color: #ffffff;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
}
}
// --- TerminalView ----------------------------------------------------------
export component TerminalView inherits Rectangle {
in property <string> tab-id;
in property <string> status-line: @tr("Not connected");
in property <[TermSpan]> spans; // coloured runs of the visible screen
in property <int> cursor-row; // cursor cell (grid coords)
in property <int> cursor-col;
in property <int> rows-used; // content rows, for viewport sizing
in property <bool> is-alt-screen;
in property <bool> mouse-tracking: false;
in property <[TermMatch]> find-matches; // search-highlight rectangles
in property <[TermMatch]> selection; // drag-selection highlight rectangles
in property <string> sftp-path: "/";
in property <[SftpEntry]> sftp-entries;
in property <string> sftp-status: "";
in property <bool> sftp-loading: false;
in property <[SftpTreeNode]> sftp-tree-nodes: [];
in property <length> terminal-font-size: 13px;
in property <bool> click-debug-enabled: false;
in property <string> background-path;
in property <int> background-mode: 1;
in property <image> background-image;
in property <bool> app-background-active: false;
in property <float> terminal-bg-opacity: 0.88;
in property <float> ui-opacity: 0.72;
in property <length> sftp-file-font-size: 16px;
in-out property <length> sftp-name-column-width: 180px;
in-out property <bool> sftp-name-width-manual: false;
in property <[CommandCategory]> command-categories: [];
in property <[CommandItem]> command-items: [];
in-out property <string> command-selected-category;
in-out property <string> command-selected-id;
in-out property <string> command-category-draft;
in-out property <string> command-edit-id;
in-out property <string> command-edit-name;
in-out property <string> command-edit-command;
in-out property <bool> command-edit-append-cr: true;
in-out property <string> command-param1-name;
in-out property <string> command-param1-value;
in-out property <string> command-param2-name;
in-out property <string> command-param2-value;
in-out property <string> command-param3-name;
in-out property <string> command-param3-value;
in-out property <string> command-param4-name;
in-out property <string> command-param4-value;
in-out property <string> command-param5-name;
in-out property <string> command-param5-value;
property <bool> command-editor-open: false;
property <bool> command-category-editor-open: false;
property <string> command-category-edit-id;
property <bool> command-category-can-delete: false;
in-out property <length> sftp-panel-height: 220px;
callback send-key(string, bool, bool, bool);
callback terminal-resize(float, float);
callback sftp-navigate(string);
callback sftp-download(string);
callback sftp-upload-clicked(string);
callback sftp-refresh(string);
callback sftp-tree-expand(string);
callback sftp-delete(string);
callback sftp-rename(string, string);
callback sftp-edit(string);
callback sftp-name-column-width-changed(float);
callback sftp-name-column-auto();
callback paste-from-clipboard();
callback copy-terminal-text();
callback clear-terminal(); // wipe this session's screen buffer
callback find-query-changed(string); // recompute search highlights
callback terminal-scroll(int); // scroll history by N lines (+ = up)
callback select-start(int, int); // begin drag-selection at (row, col)
callback select-update(int, int); // extend drag-selection to (row, col)
callback select-end(); // finish selection → copy to clipboard
callback select-autoscroll(int); // auto-scroll while dragging past edge (dir: -1 up / +1 down)
callback command-category-select(string);
callback command-category-add(string);
callback command-category-rename(string, string);
callback command-category-delete(string);
callback command-new(string);
callback command-select(string);
callback command-save(string, string, string, string, bool);
callback command-delete(string);
callback command-run(string, string, string, string, string, string);
callback command-move(string, string);
callback terminal-mouse-event(string, int, int, int);
callback debug-click(string, string);
// Right-click context-menu anchor + find-bar visibility (private UI state).
property <length> ctx-x;
property <length> ctx-y;
property <bool> find-active: false;
property <bool> selecting: false;
property <int> mouse-drag-button: -1;
property <length> mouse-drag-start-x: 0px;
property <length> mouse-drag-start-y: 0px;
property <bool> mouse-drag-started: false;
// Auto-scroll direction while drag-selecting past the visible edge.
// 0 = none, -1 = above top (scroll to older), +1 = below bottom (newer).
property <int> autoscroll-dir: 0;
property <length> vis-y: 0px; // scratch: cursor Y within the visible viewport
property <int> tool-tab: 0; // 0 files / 1 commands
property <float> sent-cols: -1;
property <float> sent-rows: -1;
background: root.app-background-active
? Theme.term-bg.with-alpha(root.terminal-bg-opacity)
: root.background-mode == 1 && root.background-path != ""
? Theme.term-bg.with-alpha(root.terminal-bg-opacity)
: Theme.term-bg;
forward-focus: ime-input;
if root.background-mode == 1 && root.background-path != "" : Image {
width: parent.width;
height: parent.height;
source: root.background-image;
image-fit: cover;
opacity: 0.34;
}
// Drag state (private)
property <bool> sftp-dragging: false;
property <length> drag-start-mouse-y: 0px;
// --- Real cell-size measurement ----------------------------------------
// A hidden Text rendered with the exact terminal font reports the true
// Consolas cell width/height for the current font size. We derive the PTY
// column/row counts from these measured cells instead of guessing 8×16px,
// so full-screen programs (nano/vim) get an accurate row count and their
// bottom shortcut bar is no longer clipped off the visible area.
// Measure a 50-char run, not a single glyph: a single Text's preferred-width
// includes left/right side bearings and is wider than the real per-character
// advance, which would make absolutely-positioned spans drift apart. The
// average advance over a long run (side bearings amortised) is accurate.
// Cell-size probe: must be RENDERED (opacity:0, not visible:false) so Slint
// resolves the correct embedded font before computing preferred-width/height.
// visible:false can skip font loading and fall back to a system font (e.g.
// Consolas ~6.5 px/char instead of Cascadia Mono ~7.6 px/char), giving a
// cell-w that is too narrow and a term-cols that is too large.
cell-probe := Text {
text: "MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM"; // exactly 50×M
font-family: "Cascadia Mono";
font-size: root.terminal-font-size;
// Keep it in layout but invisible: opacity:0 forces full font loading.
opacity: 0;
// Park it at y<0 so it never overlaps terminal content.
y: -200px;
width: 0px; // zero out layout contribution
height: 0px;
}
// cell-w / cell-h come straight from the probe so they ALWAYS match the
// font the spans below are actually rendered with. (Forcing a hard-coded
// minimum here is dangerous: if the spans fall back to a system font, a
// mismatched cell-w garbles the grid. Keep them derived from one source.)
property <length> cell-w: max(1px, cell-probe.preferred-width / 50);
// Guard band avoids clipping glyph ascenders/descenders during repaint.
property <length> cell-h: max(1px, cell-probe.preferred-height + 2px);
// Usable text box = root.width minus the 10px left/right padding of the
// terminal grid. Derive the usable height from TerminalView's outer size and
// fixed chrome only. Reading key-capture.height or flickable.viewport-height
// here can feed Slint's preferred-size calculation back into itself when a
// terminal tab becomes visible, which trips "Recursion detected".
property <length> key-capture-h: max(
0px,
root.height - 24px - 4px - root.sftp-panel-height
);
property <length> terminal-viewport-h: root.key-capture-h;
property <length> terminal-pad-y: 4px;
property <length> terminal-grid-h: max(0px, root.terminal-viewport-h - root.terminal-pad-y * 2);
property <length> terminal-content-h: root.is-alt-screen
? (root.selecting ? max(root.terminal-grid-h, root.rows-used * root.cell-h) : root.terminal-grid-h)
: max(root.terminal-grid-h, root.rows-used * root.cell-h);
property <length> terminal-scroll-h: root.terminal-content-h + root.terminal-pad-y * 2;
property <float> term-cols: max(10, floor((root.width - 20px) / cell-w));
property <float> term-rows: max(5, floor(root.terminal-grid-h / cell-h));
// Only resize the PTY while this tab is visible. Inactive tabs are laid out
// at width 0 (-> term-cols collapses to 10); resizing the remote to 10
// columns reflows the vt100 screen and destroys the scrollback, so hidden
// sessions stay frozen at their last real size.
//
// Do not emit resize from init/changed term-cols/changed term-rows. Those
// handlers run while Slint is still solving the parent layout and force
// term-rows -> root.height -> parent layout-cache, which recursively asks
// this TerminalView for layout info. The timer fires after layout has
// settled and only forwards actual size changes.
Timer {
running: root.visible;
interval: 200ms;
triggered => {
if (root.term-cols != root.sent-cols || root.term-rows != root.sent-rows) {
root.sent-cols = root.term-cols;
root.sent-rows = root.term-rows;
root.terminal-resize(root.term-cols, root.term-rows);
}
}
}
changed spans => {
// Bash: keep the latest output pinned to the bottom.
// Alt-screen (nano/vim/htop/btop): the program owns a fixed full screen,
// so the viewport must stay top-aligned (viewport-y = 0). Scrolling to
// the bottom would push the title bar + content off the top, leaving only
// the program's status bar — exactly the "nano shows nothing" symptom.
if (!root.is-alt-screen && !root.selecting) {
flickable.viewport-y = flickable.height - flickable.viewport-height;
} else if (!root.selecting) {
flickable.viewport-y = 0;
}
}
changed is-alt-screen => {
// Reset scroll on every mode switch (entering/leaving nano, vim, btop…).
// Snapping to top on alt-screen entry ensures the full-screen program's
// frame is shown from row 0, not from wherever history left off.
if (root.is-alt-screen) {
flickable.viewport-y = 0;
} else {
// Return to normal mode: pin to the most recent output.
flickable.viewport-y = flickable.height - flickable.viewport-height;
}
}
changed visible => {
if self.visible {
// Force the timer to re-sync the PTY size on return.
root.sent-cols = -1;
root.sent-rows = -1;
if (!root.command-editor-open && !root.command-category-editor-open) {
ime-input.focus();
}
}
}
VerticalLayout {
spacing: 0;
// --- Status strip ---------------------------------------------------
Rectangle {
height: 24px;
background: root.app-background-active ? Theme.bg-panel-alt.with-alpha(root.ui-opacity) : Theme.bg-panel-alt;
HorizontalLayout {
padding-left: 10px; padding-right: 10px;
Text {
text: root.is-alt-screen ? @tr("Running in alternate screen") : "";
color: Theme.text-secondary;
font-size: Theme.fs-xs;
vertical-alignment: center;
horizontal-stretch: 1;
}
// Alt-screen indicator only. IMPORTANT: never put cell-w / cell-h
// (a Text's preferred-size) inside another Text's `text` — doing so
// makes Slint compute one text layout *inside* another, which
// re-enters parley's FontContext RefCell and panics ("already
// borrowed"). A plain bool flag is safe.
if root.is-alt-screen : Text {
text: "[ALT]";
color: Theme.success;
font-size: Theme.fs-xs;
vertical-alignment: center;
}
}
}
// --- Key-capture scope + scrollable output --------------------------
key-capture := FocusScope {
vertical-stretch: 1;
// Any click inside this scope (e.g. the middle-click paste strip)
// can grab focus to the FocusScope itself. Key forwarding lives on
// the hidden `ime-input` TextInput, so a bare FocusScope focus means
// the cursor stops blinking and typing stops working. Forwarding
// focus to ime-input guarantees the text input always ends up with
// the keyboard focus, no matter what grabbed it.
forward-focus: ime-input;
property <bool> cursor-blink: true;
Timer {
running: ime-input.has-focus;
interval: 530ms;
triggered => { key-capture.cursor-blink = !key-capture.cursor-blink; }
}
// Auto-scroll while the drag-selection is held past the top/bottom
// edge: ticks repeatedly so the scrollback keeps moving even when the
// mouse is held still beyond the edge.
Timer {
running: root.selecting && root.autoscroll-dir != 0;
interval: 50ms;
triggered => { root.select-autoscroll(root.autoscroll-dir); }
}
VerticalLayout {
spacing: 0;
flickable := Flickable {
vertical-stretch: 1;
viewport-width: self.width;
// Alt-screen: lock the viewport to exactly the visible height
// so the full-screen program (nano/vim) renders top-aligned
// with no internal scroll. Bash: let the viewport grow with
// the content so we can scroll back through history.
viewport-height: root.terminal-scroll-h;
// The terminal grid. Each coloured run is placed with
// absolute coordinates derived from its (row, col) and the
// measured Consolas cell size — no per-line layout needed,
// which keeps the model flat (a single `for`).
Rectangle {
x: 10px;
y: root.terminal-pad-y;
width: parent.width - 20px;
height: root.terminal-content-h;
// Click-to-focus over the whole terminal body. Without
// this, clicking anywhere in the output area blurs the
// hidden ime-input (Slint clears focus on a click that
// lands on a non-focusable element), which makes the
// cursor vanish and keystrokes stop — recoverable only by
// reopening the tab. This TouchArea sits *below* the text
// (Text never grabs pointer events, so clicks fall through
// to it) and restores focus on every click. In mouse-
// tracking apps such as tmux, unmodified mouse input is
// forwarded; Shift+drag/Shift+right-click stay local.
body-touch := TouchArea {
mouse-cursor: text;
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
// Any button press re-takes keyboard focus.
ime-input.focus();
if (root.mouse-tracking && !ev.modifiers.shift) {
root.mouse-drag-button =
ev.button == PointerEventButton.left ? 0
: ev.button == PointerEventButton.middle ? 1
: 2;
root.mouse-drag-start-x = self.mouse-x;
root.mouse-drag-start-y = self.mouse-y;
root.mouse-drag-started = false;
root.terminal-mouse-event(
"down",
root.mouse-drag-button,
floor(self.mouse-y / root.cell-h),
floor(self.mouse-x / root.cell-w));
} else if (ev.button == PointerEventButton.right) {
root.ctx-x = self.mouse-x;
root.ctx-y = self.mouse-y;
ctx-menu.show();
} else if (ev.button == PointerEventButton.left
&& (!root.mouse-tracking || ev.modifiers.shift)) {
root.selecting = true;
root.select-start(
floor(self.mouse-y / root.cell-h),
floor(self.mouse-x / root.cell-w));
}
} else if (ev.kind == PointerEventKind.up) {
if (ev.button == PointerEventButton.middle) {
if (root.mouse-tracking && !ev.modifiers.shift) {
root.terminal-mouse-event(
"up", 1,
floor(self.mouse-y / root.cell-h),
floor(self.mouse-x / root.cell-w));
root.mouse-drag-button = -1;
root.mouse-drag-started = false;
} else {
root.paste-from-clipboard();
}
ime-input.focus();
} else if (ev.button == PointerEventButton.left
&& root.selecting) {
root.selecting = false;
root.autoscroll-dir = 0;
root.select-end();
} else if (root.mouse-tracking && !ev.modifiers.shift) {
root.terminal-mouse-event(
"up",
ev.button == PointerEventButton.left ? 0
: ev.button == PointerEventButton.middle ? 1
: 2,
floor(self.mouse-y / root.cell-h),
floor(self.mouse-x / root.cell-w));
root.mouse-drag-button = -1;
root.mouse-drag-started = false;
}
}
}
moved => {
if (root.mouse-tracking && !self.pressed && root.mouse-drag-button < 0) {
root.terminal-mouse-event(
"move",
3,
floor(self.mouse-y / root.cell-h),
floor(self.mouse-x / root.cell-w));
} else if (root.mouse-tracking && root.mouse-drag-button >= 0) {
if (!root.mouse-drag-started
&& abs(self.mouse-x - root.mouse-drag-start-x) < 2px
&& abs(self.mouse-y - root.mouse-drag-start-y) < 2px) {
return;
}
root.mouse-drag-started = true;
root.terminal-mouse-event(
"drag",
root.mouse-drag-button,
floor(self.mouse-y / root.cell-h),
floor(self.mouse-x / root.cell-w));
} else if (root.selecting) {
// Position of the cursor within the *visible*
// viewport (body-touch sits on the grid Rectangle
// at content y=8px; viewport-y offsets the scroll).
root.select-update(
floor(self.mouse-y / root.cell-h),
floor(self.mouse-x / root.cell-w));
// vy ∈ [0, flickable.height] = where the mouse
// is inside the visible window. Shift+drag in
// tmux still uses local selection, so allow
// edge auto-scroll even on the alt screen.
root.vis-y = self.mouse-y + root.terminal-pad-y + flickable.viewport-y;
if (root.vis-y < root.cell-h) {
root.autoscroll-dir = -1; // past top → older
} else if (root.vis-y > flickable.height - root.cell-h) {
root.autoscroll-dir = 1; // past bottom → newer
} else {
root.autoscroll-dir = 0;
}
}
}
scroll-event(ev) => {
if (root.mouse-tracking) {
root.terminal-mouse-event(
"wheel",
ev.delta-y > 0 ? 64 : 65,
floor(self.mouse-y / root.cell-h),
floor(self.mouse-x / root.cell-w));
} else {
// Wheel scrolls local scrollback (~3 lines per
// notch). Mouse-tracking apps such as tmux own
// their history, so wheel input is forwarded.
root.terminal-scroll(ev.delta-y > 0 ? 3 : -3);
flickable.viewport-y = clamp(
flickable.viewport-y + (ev.delta-y > 0 ? 3 * root.cell-h : -3 * root.cell-h),
0px,
max(0px, flickable.viewport-height - flickable.height)
);
}
accept
}
}
// Drag-selection highlights (under the text, blue tint).
for s in root.selection : Rectangle {
x: s.col * root.cell-w;
y: s.row * root.cell-h;
width: s.len * root.cell-w;
height: root.cell-h;
background: Theme.accent.with-alpha(0.40);
}
// Search-match highlights (behind the text).
for m in root.find-matches : Rectangle {
x: m.col * root.cell-w;
y: m.row * root.cell-h;
width: m.len * root.cell-w;
height: root.cell-h;
background: Theme.warning.with-alpha(0.45);
border-radius: 2px;
}
// Each span = a background fill (transparent for the
// default bg) with its text on top. Wrapping the Text in
// the bg Rectangle keeps one element per span and renders
// btop/htop/mc panels, meter bars and graph fills.
for span in root.spans : Rectangle {
x: span.col * root.cell-w;
y: span.row * root.cell-h;
width: span.cells * root.cell-w;
height: root.cell-h;
background: span.bg;
// Keep text constrained to the exact terminal cells
// it occupies. Letting Text auto-size can overpaint
// the following absolute-positioned cells when the
// font fallback/wide-glyph metrics differ slightly.
Text {
x: 0px;
y: 0px;
width: parent.width;
height: parent.height;
text: span.text;
color: span.fg;
font-family: span.use-fallback-font ? Theme.font-ui : "Cascadia Mono";
font-size: root.terminal-font-size;
font-weight: span.bold ? 700 : 400;
vertical-alignment: center;
}
}
// Blinking block cursor (overlay, decoupled from text).
Rectangle {
x: root.cursor-col * root.cell-w;
y: root.cursor-row * root.cell-h;
width: root.cell-w;
height: root.cell-h;
background: Theme.term-fg;
visible: ime-input.has-focus && key-capture.cursor-blink;
}
// --- Right-click context menu ---------------------
ctx-menu := PopupWindow {
x: root.ctx-x;
y: root.ctx-y;
width: 132px;
height: 132px;
Rectangle {
background: Theme.bg-panel;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.border-strong;
drop-shadow-blur: 12px;
drop-shadow-color: #00000060;
VerticalLayout {
padding: 4px;
spacing: 1px;
MenuItem {
icon: "\u{E14D}"; label: @tr("Copy");
clicked => {
if root.click-debug-enabled { root.debug-click("terminal.context-menu", "copy"); }
root.copy-terminal-text();
ime-input.focus();
}
}
MenuItem {
icon: "\u{E14F}"; label: @tr("Paste");
clicked => {
if root.click-debug-enabled { root.debug-click("terminal.context-menu", "paste"); }
root.paste-from-clipboard();
ime-input.focus();
}
}
MenuItem {
icon: "\u{E872}"; label: @tr("Clear buffer");
clicked => {
if root.click-debug-enabled { root.debug-click("terminal.context-menu", "clear-buffer"); }
root.clear-terminal();
ime-input.focus();
}
}
MenuItem {
icon: "\u{E8B6}"; label: @tr("Find");
clicked => {
if root.click-debug-enabled { root.debug-click("terminal.context-menu", "find"); }
root.find-active = true;
}
}
}
}
}
}
}
}
// --- Hidden TextInput — keyboard + Chinese IME capture -----------
//
// Key design decision: return `accept` for EVERY key and forward
// them all via send-key. Returning `reject` makes Slint bubble the
// event without inserting the character into `text`, so `edited`
// never fires for regular keystrokes — only IME-committed chars
// (which go through a separate WM_IME_CHAR path) reach `edited`.
//
// By forwarding everything in key-pressed we guarantee English and
// all direct-mode input reaches the PTY. `edited` is kept as a
// safety net for IME commits that Windows delivers via WM_IME_CHAR
// rather than WM_CHAR (and therefore don't fire key-pressed).
ime-input := TextInput {
x: 0;
y: 0;
width: key-capture.width;
height: 1px;
opacity: 0;
single-line: true;
// Reset blink phase on focus gain so cursor appears immediately.
changed has-focus => {
if (self.has-focus) { key-capture.cursor-blink = true; }
}
key-pressed(event) => {
// ── Ctrl+Shift+C: copy terminal buffer ───────────────────
if (event.modifiers.control && event.modifiers.shift
&& (event.text == "c" || event.text == "C"
|| event.text == "\u{0003}")) {
root.copy-terminal-text();
accept
// ── Ctrl+V or Ctrl+Shift+V: paste from clipboard ─────────
// We accept Ctrl+V (with or without Shift) so the familiar
// Windows paste shortcut works in addition to Ctrl+Shift+V.
} else if (event.modifiers.control
&& (event.text == "v" || event.text == "V"
|| event.text == "\u{0016}")) {
root.paste-from-clipboard();
// Clipboard access on Windows can deliver WM_KILLFOCUS.
// Restore focus explicitly so the cursor keeps blinking.
ime-input.focus();
accept
// ── All other keys (printable, Ctrl+X, arrows, …) ────────
// Return `accept` for everything: `reject` bubbles without
// inserting the char into TextInput, so `edited` never fires
// for regular keystrokes — the character would be lost.
} else {
root.send-key(event.text, event.modifiers.control,
event.modifiers.alt, event.modifiers.shift);
accept
}
}
// Safety-net for IME commits delivered via WM_IME_CHAR (which
// may not fire key-pressed). Guard with preedit-text == "" to
// skip in-progress compositions.
edited => {
if (self.preedit-text == "" && self.text != "") {
root.send-key(self.text, false, false, false);
self.text = "";
}
}
}
// --- Find bar (overlay, top) ------------------------------------
if root.find-active : Rectangle {
x: 0;
y: 0;
width: parent.width;
height: 34px;
background: Theme.bg-panel;
border-width: 1px;
border-color: Theme.border-strong;
border-radius: Theme.radius-sm;
HorizontalLayout {
height: parent.height;
padding: 6px;
spacing: 6px;
Text {
y: (parent.height - self.height) / 2;
width: 18px;
height: 22px;
text: "\u{E8B6}";
font-family: "Material Icons";
color: Theme.text-secondary;
font-size: Theme.fs-sm;
vertical-alignment: center;
}
Rectangle {
y: (parent.height - self.height) / 2;
height: 22px;
background: Theme.bg-root;
border-radius: 3px;
horizontal-stretch: 1;
find-input := TextInput {
x: 6px;
width: parent.width - 12px;
height: parent.height;
color: Theme.text-primary;
font-size: Theme.fs-md;
vertical-alignment: center;
single-line: true;
// Grab focus as soon as the bar appears.
init => { self.focus(); }
edited => { root.find-query-changed(self.text); }
key-pressed(e) => {
if (e.text == "\u{001b}") { // Esc closes find
root.find-active = false;
root.find-query-changed("");
self.text = "";
ime-input.focus();
accept
} else {
reject
}
}
}
}
Rectangle {
y: (parent.height - self.height) / 2;
width: 24px;
height: 22px;
close-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.find-active = false;
root.find-query-changed("");
find-input.text = "";
ime-input.focus();
}
}
Text {
width: parent.width;
height: parent.height;
text: "\u{E5CD}";
font-family: "Material Icons";
color: close-ta.has-hover ? Theme.text-primary : Theme.text-secondary;
font-size: Theme.fs-sm;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
}
// --- Drag handle ----------------------------------------------------
Rectangle {
height: 4px;
background: divider-ta.has-hover || root.sftp-dragging
? Theme.accent
: Theme.border-subtle;
divider-ta := TouchArea {
mouse-cursor: row-resize;
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
root.sftp-dragging = true;
root.drag-start-mouse-y = self.mouse-y;
} else if (ev.kind == PointerEventKind.up) {
root.sftp-dragging = false;
}
}
moved => {
if (root.sftp-dragging) {
root.sftp-panel-height = clamp(
root.sftp-panel-height + root.drag-start-mouse-y - self.mouse-y,
60px,
root.height - 24px - 4px - 16px - 80px
);
}
}
}
}
// --- Bottom tools: files / commands --------------------------------
Rectangle {
height: root.sftp-panel-height;
background: root.app-background-active ? Theme.bg-panel.with-alpha(root.ui-opacity) : Theme.bg-panel;
clip: true;
VerticalLayout {
spacing: 0;
Rectangle {
height: 30px;
background: root.app-background-active ? Theme.bg-panel-alt.with-alpha(root.ui-opacity) : Theme.bg-panel-alt;
border-width: 1px;
border-color: Theme.border-subtle;
HorizontalLayout {
height: parent.height;
padding-left: 8px;
padding-right: 8px;
spacing: 4px;
Rectangle {
y: (parent.height - self.height) / 2;
width: 96px;
height: 24px;
border-radius: Theme.radius-sm;
background: root.tool-tab == 0
? Theme.bg-active
: (files-tab-ta.has-hover ? Theme.bg-hover : transparent);
files-tab-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
if root.click-debug-enabled { root.debug-click("terminal.bottom-tools", "files-tab"); }
root.tool-tab = 0;
}
}
HorizontalLayout {
height: parent.height;
padding-left: 8px;
padding-right: 8px;
spacing: 6px;
Text {
height: parent.height;
text: "\u{E2C7}";
font-family: "Material Icons";
color: root.tool-tab == 0 ? Theme.text-primary : Theme.text-secondary;
font-size: Theme.fs-sm;
vertical-alignment: center;
}
Text {
height: parent.height;
text: @tr("File management");
color: root.tool-tab == 0 ? Theme.text-primary : Theme.text-secondary;
font-size: Theme.fs-sm;
vertical-alignment: center;
}
}
}
Rectangle {
y: (parent.height - self.height) / 2;
width: 96px;
height: 24px;
border-radius: Theme.radius-sm;
background: root.tool-tab == 1
? Theme.bg-active
: (commands-tab-ta.has-hover ? Theme.bg-hover : transparent);
commands-tab-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
if root.click-debug-enabled { root.debug-click("terminal.bottom-tools", "commands-tab"); }
root.tool-tab = 1;
}
}
HorizontalLayout {
height: parent.height;
padding-left: 8px;
padding-right: 8px;
spacing: 6px;
Text {
height: parent.height;
text: "\u{E8F4}";
font-family: "Material Icons";
color: root.tool-tab == 1 ? Theme.text-primary : Theme.text-secondary;
font-size: Theme.fs-sm;
vertical-alignment: center;
}
Text {
height: parent.height;
text: @tr("Command management");
color: root.tool-tab == 1 ? Theme.text-primary : Theme.text-secondary;
font-size: Theme.fs-sm;
vertical-alignment: center;
}
}
}
Rectangle { horizontal-stretch: 1; }
}
}
Rectangle {
vertical-stretch: 1;
background: root.app-background-active ? Theme.bg-panel.with-alpha(max(0.30, root.ui-opacity - 0.08)) : Theme.bg-panel;
clip: true;
if root.tool-tab == 0 : SftpPanel {
width: parent.width;
height: parent.height;
current-path: root.sftp-path;
entries: root.sftp-entries;
status: root.sftp-status;
loading: root.sftp-loading;
tree-nodes: root.sftp-tree-nodes;
app-background-active: root.app-background-active;
ui-opacity: root.ui-opacity;
file-font-size: root.sftp-file-font-size;
name-column-width <=> root.sftp-name-column-width;
name-column-manual <=> root.sftp-name-width-manual;
navigate(path) => { root.sftp-navigate(path); }
download(path) => { root.sftp-download(path); }
upload-clicked(dir) => { root.sftp-upload-clicked(dir); }
refresh(path) => { root.sftp-refresh(path); }
tree-expand(path) => { root.sftp-tree-expand(path); }
delete(path) => { root.sftp-delete(path); }
rename(path, name) => { root.sftp-rename(path, name); }
edit(path) => { root.sftp-edit(path); }
name-column-width-changed(px) => { root.sftp-name-column-width-changed(px); }
name-column-auto() => { root.sftp-name-column-auto(); }
}
if root.tool-tab == 1 : CommandManager {
width: parent.width;
height: parent.height;
command-categories: root.command-categories;
command-items: root.command-items;
selected-category <=> root.command-selected-category;
selected-command <=> root.command-selected-id;
category-draft <=> root.command-category-draft;
edit-id <=> root.command-edit-id;
edit-name <=> root.command-edit-name;
edit-command <=> root.command-edit-command;
edit-append-cr <=> root.command-edit-append-cr;
param1-name <=> root.command-param1-name;
param1-value <=> root.command-param1-value;
param2-name <=> root.command-param2-name;
param2-value <=> root.command-param2-value;
param3-name <=> root.command-param3-name;
param3-value <=> root.command-param3-value;
param4-name <=> root.command-param4-name;
param4-value <=> root.command-param4-value;
param5-name <=> root.command-param5-name;
param5-value <=> root.command-param5-value;
editor-open <=> root.command-editor-open;
category-editor-open <=> root.command-category-editor-open;
category-edit-id <=> root.command-category-edit-id;
category-can-delete <=> root.command-category-can-delete;
category-select(id) => { root.command-category-select(id); }
category-add(name) => { root.command-category-add(name); }
category-rename(id, name) => { root.command-category-rename(id, name); }
category-delete(id) => { root.command-category-delete(id); }
command-new(category) => { root.command-new(category); }
command-select(id) => { root.command-select(id); }
command-save(id, category, name, command, append_cr) => {
root.command-save(id, category, name, command, append_cr);
}
command-delete(id) => { root.command-delete(id); }
command-run(id, p1, p2, p3, p4, p5) => {
root.command-run(id, p1, p2, p3, p4, p5);
ime-input.focus();
}
command-move(id, category) => { root.command-move(id, category); }
}
}
}
}
}
CommandEditorModal {
width: parent.width;
height: parent.height;
is-open <=> root.command-editor-open;
command-categories: root.command-categories;
selected-category <=> root.command-selected-category;
edit-id <=> root.command-edit-id;
edit-name <=> root.command-edit-name;
edit-command <=> root.command-edit-command;
edit-append-cr <=> root.command-edit-append-cr;
command-save(id, category, name, command, append_cr) => {
root.command-save(id, category, name, command, append_cr);
}
command-delete(id) => { root.command-delete(id); }
command-move(id, category) => { root.command-move(id, category); }
}
CategoryEditorModal {
width: parent.width;
height: parent.height;
is-open <=> root.command-category-editor-open;
edit-id <=> root.command-category-edit-id;
draft <=> root.command-category-draft;
can-delete: root.command-category-can-delete;
category-add(name) => { root.command-category-add(name); }
category-rename(id, name) => { root.command-category-rename(id, name); }
category-delete(id) => { root.command-category-delete(id); }
}
}