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 icon; in property 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 glyph; in property fg: Theme.text-secondary; in property 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 label; in-out property value; in property missing: label != "" && value == ""; property 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 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 selected-category; in-out property selected-command; in-out property category-draft; in-out property edit-id; in-out property edit-name; in-out property edit-command; in-out property edit-append-cr: true; in-out property param1-name; in-out property param1-value; in-out property param2-name; in-out property param2-value; in-out property param3-name; in-out property param3-value; in-out property param4-name; in-out property param4-value; in-out property param5-name; in-out property 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 editor-open; in-out property category-editor-open; in-out property category-edit-id; in-out property category-can-delete; property params-missing: (param1-name != "" && param1-value == "") || (param2-name != "" && param2-value == "") || (param3-name != "" && param3-value == "") || (param4-name != "" && param4-value == "") || (param5-name != "" && param5-value == ""); property ctx-x; property 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 is-open; in property <[CommandCategory]> command-categories: []; in-out property selected-category; in-out property edit-id; in-out property edit-name; in-out property edit-command; in-out property 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 is-open; in-out property edit-id; in-out property draft; in property 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 tab-id; in property status-line: @tr("Not connected"); in property <[TermSpan]> spans; // coloured runs of the visible screen in property cursor-row; // cursor cell (grid coords) in property cursor-col; in property rows-used; // content rows, for viewport sizing in property is-alt-screen; in property mouse-tracking: false; in property <[TermMatch]> find-matches; // search-highlight rectangles in property <[TermMatch]> selection; // drag-selection highlight rectangles in property sftp-path: "/"; in property <[SftpEntry]> sftp-entries; in property sftp-status: ""; in property sftp-loading: false; in property <[SftpTreeNode]> sftp-tree-nodes: []; in property terminal-font-size: 13px; in property click-debug-enabled: false; in property background-path; in property background-mode: 1; in property background-image; in property app-background-active: false; in property terminal-bg-opacity: 0.88; in property ui-opacity: 0.72; in property sftp-file-font-size: 16px; in-out property sftp-name-column-width: 180px; in-out property sftp-name-width-manual: false; in property <[CommandCategory]> command-categories: []; in property <[CommandItem]> command-items: []; in-out property command-selected-category; in-out property command-selected-id; in-out property command-category-draft; in-out property command-edit-id; in-out property command-edit-name; in-out property command-edit-command; in-out property command-edit-append-cr: true; in-out property command-param1-name; in-out property command-param1-value; in-out property command-param2-name; in-out property command-param2-value; in-out property command-param3-name; in-out property command-param3-value; in-out property command-param4-name; in-out property command-param4-value; in-out property command-param5-name; in-out property command-param5-value; property command-editor-open: false; property command-category-editor-open: false; property command-category-edit-id; property command-category-can-delete: false; in-out property 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 ctx-x; property ctx-y; property find-active: false; property selecting: false; property mouse-drag-button: -1; property mouse-drag-start-x: 0px; property mouse-drag-start-y: 0px; property 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 autoscroll-dir: 0; property vis-y: 0px; // scratch: cursor Y within the visible viewport property tool-tab: 0; // 0 files / 1 commands property sent-cols: -1; property 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 sftp-dragging: false; property 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 cell-w: max(1px, cell-probe.preferred-width / 50); // Guard band avoids clipping glyph ascenders/descenders during repaint. property 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 key-capture-h: max( 0px, root.height - 24px - 4px - root.sftp-panel-height ); property terminal-viewport-h: root.key-capture-h; property terminal-pad-y: 4px; property terminal-grid-h: max(0px, root.terminal-viewport-h - root.terminal-pad-y * 2); property 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 terminal-scroll-h: root.terminal-content-h + root.terminal-pad-y * 2; property term-cols: max(10, floor((root.width - 20px) / cell-w)); property 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 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); } } }