This commit is contained in:
2026-06-19 03:09:53 +08:00
Unverified
commit d32882443f
49 changed files with 19399 additions and 0 deletions

1608
ui/app.slint Normal file

File diff suppressed because it is too large Load Diff

123
ui/confirm_dialog.slint Normal file
View File

@@ -0,0 +1,123 @@
import { Theme } from "theme.slint";
import { GhostButton } from "widgets.slint";
// A destructive (red) action button, matching PrimaryButton's shape.
component DangerButton inherits Rectangle {
in property <string> text;
callback clicked();
height: 30px;
min-width: 80px;
border-radius: Theme.radius-sm;
background: touch.pressed ? #c44a4a : (touch.has-hover ? #ec6a6a : Theme.danger);
animate background { duration: 120ms; }
HorizontalLayout {
height: parent.height;
padding-left: 14px;
padding-right: 14px;
alignment: center;
Text {
height: parent.height;
text: root.text;
color: white;
font-size: Theme.fs-md;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
}
}
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
// In-app modal confirmation dialog, styled to match the rest of meatshell
// (a dark card over a dimmed backdrop, like SessionDialog). Purely
// presentational: it emits `confirm` / `cancel` and the caller decides what
// to do. Used for the irreversible SFTP delete (#28).
export component ConfirmDialog inherits Rectangle {
in-out property <bool> is-open: false;
in property <string> title: @tr("Please confirm");
in property <string> message;
in property <string> detail; // optional, e.g. the file path
in property <string> confirm-label: @tr("Confirm");
callback confirm();
callback cancel();
visible: is-open;
background: #000000aa;
// Backdrop click cancels.
TouchArea {
width: 100%;
height: 100%;
clicked => { root.cancel(); }
}
Rectangle {
width: 380px;
height: card.preferred-height;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
background: Theme.bg-panel;
border-radius: Theme.radius-lg;
border-width: 1px;
border-color: Theme.border-strong;
drop-shadow-blur: 24px;
drop-shadow-color: #00000080;
// Swallow clicks inside the card so they don't hit the backdrop.
TouchArea { width: 100%; height: 100%; }
card := VerticalLayout {
padding: 20px;
spacing: 12px;
HorizontalLayout {
spacing: 8px;
Text {
text: "⚠";
color: Theme.danger;
font-size: Theme.fs-xl;
vertical-alignment: center;
}
Text {
text: root.title;
color: Theme.text-primary;
font-size: Theme.fs-xl;
font-weight: 600;
vertical-alignment: center;
}
}
Text {
text: root.message;
color: Theme.text-secondary;
font-size: Theme.fs-md;
wrap: word-wrap;
}
if root.detail != "" : Text {
text: root.detail;
color: Theme.text-primary;
font-family: Theme.font-mono;
font-size: Theme.fs-sm;
wrap: word-wrap;
}
HorizontalLayout {
height: 30px;
alignment: end;
spacing: 8px;
GhostButton {
text: @tr("Cancel");
clicked => { root.cancel(); }
}
DangerButton {
text: root.confirm-label;
clicked => { root.confirm(); }
}
}
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

454
ui/session_dialog.slint Normal file
View File

@@ -0,0 +1,454 @@
import { Theme } from "theme.slint";
import { LabeledInput, PrimaryButton, GhostButton } from "widgets.slint";
export struct SessionDraft {
id: string,
name: string,
group: string,
kind: string, // "ssh" | "serial" | "telnet"
host: string,
port: int,
user: string,
auth: string, // "password" | "key"
password: string,
private-key-path: string,
proxy: string, // "" | "socks5://host:port" | "http://host:port"
// Serial-only (ignored unless kind == "serial")
serial-port: string,
baud-rate: int,
data-bits: int,
stop-bits: int,
parity: string, // "none" | "odd" | "even"
flow-control: string, // "none" | "hardware" | "software"
}
// A single segment in a segmented selector (auth toggle, baud presets, …).
component SegOption inherits Rectangle {
in property <string> label;
in property <bool> active;
callback clicked();
height: 30px;
horizontal-stretch: 1;
border-radius: Theme.radius-sm;
border-width: 1px;
background: active ? Theme.accent : Theme.bg-root;
border-color: active ? Theme.accent : Theme.border-subtle;
Text {
width: parent.width;
height: parent.height;
text: root.label;
color: active ? white : Theme.text-primary;
font-size: Theme.fs-md;
horizontal-alignment: center;
vertical-alignment: center;
}
TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
component FolderOption inherits Rectangle {
in property <string> label;
in property <bool> active;
callback clicked();
height: 30px;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: active ? Theme.accent : Theme.border-subtle;
background: active ? Theme.bg-active : Theme.bg-root;
HorizontalLayout {
height: parent.height;
padding-left: 10px;
padding-right: 10px;
spacing: 8px;
Text {
width: 18px;
height: parent.height;
text: "\u{E2C7}";
font-family: "Material Icons";
color: active ? Theme.accent : Theme.text-muted;
font-size: Theme.fs-sm;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
height: parent.height;
text: root.label;
color: Theme.text-primary;
font-size: Theme.fs-md;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
if active : Text {
width: 18px;
height: parent.height;
text: "\u{E5CA}";
font-family: "Material Icons";
color: Theme.accent;
font-size: Theme.fs-sm;
horizontal-alignment: center;
vertical-alignment: center;
}
}
TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
// Modal dialog for creating / editing a session (SSH / Serial / Telnet).
// The dialog is purely presentational — it stores values in mutable
// properties and emits `submit` / `cancel` callbacks up to the main window.
export component SessionDialog inherits Rectangle {
in-out property <bool> is-open: false;
in-out property <bool> is-editing: false;
in-out property <string> draft-id;
in-out property <string> draft-name;
in-out property <string> draft-group: "Default";
in-out property <string> draft-kind: "ssh";
in-out property <string> draft-host;
in-out property <string> draft-port: "22";
in-out property <string> draft-user: "root";
in-out property <string> draft-auth: "password";
in-out property <string> draft-password;
in-out property <string> draft-key-path;
in-out property <string> draft-proxy;
in-out property <string> draft-serial-port;
in-out property <string> draft-baud: "115200";
in-out property <string> draft-data-bits: "8";
in-out property <string> draft-stop-bits: "1";
in-out property <string> draft-parity: "none";
in-out property <string> draft-flow: "none";
in property <[string]> folders;
callback submit(SessionDraft);
callback cancel();
callback pick-key-file(); // open a native file picker for the private key
visible: is-open;
background: #000000aa;
// Swallow clicks on the backdrop
TouchArea {
width: 100%; height: 100%;
clicked => { root.cancel(); }
}
Rectangle {
width: min(440px, parent.width - 48px);
height: min(620px, parent.height - 48px);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
background: Theme.bg-panel;
border-radius: Theme.radius-lg;
border-width: 1px;
border-color: Theme.border-strong;
drop-shadow-blur: 24px;
drop-shadow-color: #00000080;
// Prevent backdrop click from propagating when user clicks inside the card
TouchArea {
width: 100%; height: 100%;
}
VerticalLayout {
padding: 20px;
spacing: 12px;
Text {
text: is-editing ? @tr("Edit session") : @tr("New session");
color: Theme.text-primary;
font-size: Theme.fs-xl;
font-weight: 600;
}
Flickable {
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: max(self.height, form-col.preferred-height);
form-col := VerticalLayout {
width: parent.width;
alignment: start;
spacing: 12px;
// Connection type selector (always shown).
VerticalLayout {
spacing: 4px;
Text {
text: @tr("Connection type");
color: Theme.text-secondary;
font-size: Theme.fs-sm;
}
HorizontalLayout {
height: 30px;
spacing: 6px;
SegOption {
label: "SSH";
active: root.draft-kind == "ssh";
clicked => { root.draft-kind = "ssh"; }
}
SegOption {
label: @tr("Serial");
active: root.draft-kind == "serial";
clicked => { root.draft-kind = "serial"; }
}
SegOption {
label: "Telnet";
active: root.draft-kind == "telnet";
clicked => { root.draft-kind = "telnet"; }
}
}
}
LabeledInput {
label: @tr("Name");
placeholder: @tr("e.g. production web-01");
value <=> root.draft-name;
}
VerticalLayout {
spacing: 4px;
Text {
text: @tr("Folder");
color: Theme.text-secondary;
font-size: Theme.fs-sm;
}
VerticalLayout {
spacing: 6px;
for folder in root.folders : FolderOption {
label: folder;
active: root.draft-group == folder;
clicked => { root.draft-group = folder; }
}
}
}
// ── SSH / Telnet share host + port ──────────────────────────────
if root.draft-kind != "serial" : HorizontalLayout {
spacing: 10px;
LabeledInput {
label: @tr("Host / IP");
placeholder: "192.168.1.10";
value <=> root.draft-host;
horizontal-stretch: 2;
}
LabeledInput {
label: @tr("Port");
placeholder: root.draft-kind == "telnet" ? "23" : "22";
value <=> root.draft-port;
horizontal-stretch: 1;
}
}
// ── SSH-only: user + auth ───────────────────────────────────────
if root.draft-kind == "ssh" : LabeledInput {
label: @tr("Username");
placeholder: "root";
value <=> root.draft-user;
}
if root.draft-kind == "ssh" : VerticalLayout {
spacing: 4px;
Text {
text: @tr("Authentication");
color: Theme.text-secondary;
font-size: Theme.fs-sm;
}
HorizontalLayout {
height: 30px;
spacing: 6px;
SegOption {
label: @tr("Password");
active: root.draft-auth == "password";
clicked => { root.draft-auth = "password"; }
}
SegOption {
label: @tr("Private key");
active: root.draft-auth == "key";
clicked => { root.draft-auth = "key"; }
}
}
}
if root.draft-kind == "ssh" && root.draft-auth == "password" : LabeledInput {
label: @tr("Password");
placeholder: root.is-editing ? @tr("Leave blank to keep the current password") : "••••••••";
password: true;
value <=> root.draft-password;
}
if root.draft-kind == "ssh" && root.draft-auth == "key" : VerticalLayout {
spacing: 4px;
Text {
text: @tr("Private key file");
color: Theme.text-secondary;
font-size: Theme.fs-sm;
}
HorizontalLayout {
height: 32px;
spacing: 8px;
Rectangle {
height: 32px;
horizontal-stretch: 1;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: keyin.has-focus ? Theme.accent : Theme.border-subtle;
background: Theme.bg-root;
keyin := TextInput {
x: 10px; y: 0px;
width: parent.width - 20px;
height: parent.height;
text <=> root.draft-key-path;
color: Theme.text-primary;
font-size: Theme.fs-md;
vertical-alignment: center;
single-line: true;
}
if root.draft-key-path == "" : Text {
x: 10px; y: 0px;
height: parent.height;
text: "/home/you/.ssh/id_ed25519";
color: Theme.text-muted;
font-size: Theme.fs-md;
vertical-alignment: center;
}
}
Rectangle {
width: 64px; height: 32px;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.border-subtle;
background: browse-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated;
Text {
width: parent.width;
height: parent.height;
text: @tr("Browse…");
color: Theme.text-primary;
font-size: Theme.fs-sm;
horizontal-alignment: center;
vertical-alignment: center;
}
browse-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.pick-key-file(); }
}
}
}
Text {
text: @tr("Pick the private key itself (not the .pub public key)");
color: Theme.text-muted;
font-size: Theme.fs-xs;
}
}
// ── Serial-only fields ──────────────────────────────────────────
if root.draft-kind == "serial" : LabeledInput {
label: @tr("Serial port");
placeholder: "COM3 / /dev/ttyUSB0";
value <=> root.draft-serial-port;
}
if root.draft-kind == "serial" : LabeledInput {
label: @tr("Baud rate");
placeholder: "115200";
value <=> root.draft-baud;
}
if root.draft-kind == "serial" : VerticalLayout {
spacing: 4px;
Text {
text: @tr("Data bits / Stop bits / Parity");
color: Theme.text-secondary;
font-size: Theme.fs-sm;
}
HorizontalLayout {
height: 30px;
spacing: 6px;
SegOption { label: "7"; active: root.draft-data-bits == "7"; clicked => { root.draft-data-bits = "7"; } }
SegOption { label: "8"; active: root.draft-data-bits == "8"; clicked => { root.draft-data-bits = "8"; } }
Rectangle { width: 8px; }
SegOption { label: "1"; active: root.draft-stop-bits == "1"; clicked => { root.draft-stop-bits = "1"; } }
SegOption { label: "2"; active: root.draft-stop-bits == "2"; clicked => { root.draft-stop-bits = "2"; } }
Rectangle { width: 8px; }
SegOption { label: "N"; active: root.draft-parity == "none"; clicked => { root.draft-parity = "none"; } }
SegOption { label: "O"; active: root.draft-parity == "odd"; clicked => { root.draft-parity = "odd"; } }
SegOption { label: "E"; active: root.draft-parity == "even"; clicked => { root.draft-parity = "even"; } }
}
}
if root.draft-kind == "serial" : VerticalLayout {
spacing: 4px;
Text {
text: @tr("Flow control");
color: Theme.text-secondary;
font-size: Theme.fs-sm;
}
HorizontalLayout {
height: 30px;
spacing: 6px;
SegOption { label: @tr("None"); active: root.draft-flow == "none"; clicked => { root.draft-flow = "none"; } }
SegOption { label: @tr("Hardware"); active: root.draft-flow == "hardware"; clicked => { root.draft-flow = "hardware"; } }
SegOption { label: @tr("Software"); active: root.draft-flow == "software"; clicked => { root.draft-flow = "software"; } }
}
}
// ── Optional outbound proxy (SSH / Telnet only) ─────────────────
if root.draft-kind != "serial" : VerticalLayout {
spacing: 4px;
LabeledInput {
label: @tr("Proxy (optional)");
placeholder: "socks5://127.0.0.1:1080";
value <=> root.draft-proxy;
}
Text {
text: @tr("Leave blank to use $ALL_PROXY or connect directly");
color: Theme.text-muted;
font-size: Theme.fs-xs;
}
}
}
}
HorizontalLayout {
height: 30px;
alignment: end;
spacing: 8px;
GhostButton {
text: @tr("Cancel");
clicked => { root.cancel(); }
}
PrimaryButton {
text: is-editing ? @tr("Save") : @tr("Create");
clicked => {
root.submit({
id: root.draft-id,
name: root.draft-name,
group: root.draft-group,
kind: root.draft-kind,
host: root.draft-host,
port: root.draft-port.to-float(),
user: root.draft-user,
auth: root.draft-auth,
password: root.draft-password,
private-key-path: root.draft-key-path,
proxy: root.draft-proxy,
serial-port: root.draft-serial-port,
baud-rate: root.draft-baud.to-float(),
data-bits: root.draft-data-bits.to-float(),
stop-bits: root.draft-stop-bits.to-float(),
parity: root.draft-parity,
flow-control: root.draft-flow,
});
}
}
}
}
}
}

591
ui/sftp_panel.slint Normal file
View File

@@ -0,0 +1,591 @@
import { Theme } from "theme.slint";
import { ScrollView } from "std-widgets.slint";
// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------
export struct SftpEntry {
name: string,
full-path: string,
is-dir: bool,
size: string,
modified: string,
}
export struct SftpTreeNode {
path: string,
name: string,
depth: int,
expanded: bool,
has-children: bool,
}
// One row of the file-list right-click context menu.
component SftpMenuItem inherits Rectangle {
in property <string> label;
in property <brush> tint: Theme.text-primary;
callback clicked();
height: 26px;
border-radius: Theme.radius-sm;
background: ta.has-hover ? Theme.bg-hover : transparent;
ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
Text {
x: 10px;
text: root.label;
color: root.tint;
font-size: Theme.fs-sm;
vertical-alignment: center;
height: parent.height;
}
}
// ---------------------------------------------------------------------------
// SftpPanel — left directory tree + right file listing
// ---------------------------------------------------------------------------
export component SftpPanel inherits Rectangle {
// --- Properties --------------------------------------------------------
in property <string> current-path: "/";
in property <[SftpEntry]> entries;
in property <string> status: @tr("SFTP not connected");
in property <bool> loading: false;
in property <[SftpTreeNode]> tree-nodes: [];
in property <bool> app-background-active: false;
in property <float> ui-opacity: 0.72;
in-out property <string> path-draft: "/";
in property <length> file-font-size: 16px;
in-out property <length> name-column-width: 180px;
in-out property <bool> name-column-manual: false;
property <bool> rename-open: false;
property <string> rename-path;
property <string> rename-draft;
property <bool> name-column-dragging: false;
property <length> name-column-drag-start-x: 0px;
// --- Callbacks ---------------------------------------------------------
callback navigate(string);
callback download(string);
callback upload-clicked(string);
callback refresh(string);
callback tree-expand(string); // toggle expand/collapse + navigate
callback delete(string); // remove a remote file/dir
callback rename(string, string);
callback edit(string); // download to temp + open + auto-reupload
callback name-column-width-changed(float);
callback name-column-auto();
// -----------------------------------------------------------------------
background: root.app-background-active ? Theme.bg-panel.with-alpha(root.ui-opacity) : Theme.bg-panel;
changed current-path => { root.path-draft = root.current-path; }
VerticalLayout {
spacing: 0;
// --- Toolbar (full width) ------------------------------------------
Rectangle {
height: 30px;
background: root.app-background-active ? Theme.bg-panel-alt.with-alpha(root.ui-opacity) : Theme.bg-panel-alt;
HorizontalLayout {
height: parent.height;
padding-left: 6px;
padding-right: 6px;
spacing: 4px;
// Editable path bar fills the left; action buttons sit on the right.
Rectangle {
y: (parent.height - self.height) / 2;
horizontal-stretch: 1;
height: 22px;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: path-input.has-focus ? Theme.accent : Theme.border-subtle;
background: root.app-background-active ? Theme.bg-root.with-alpha(root.ui-opacity) : Theme.bg-root;
path-input := TextInput {
x: 8px;
width: parent.width - 16px;
height: parent.height;
text <=> root.path-draft;
color: Theme.text-primary;
font-size: root.file-font-size;
single-line: true;
vertical-alignment: center;
key-pressed(e) => {
if (e.text == "\n") {
root.navigate(self.text);
accept
} else {
reject
}
}
}
}
Rectangle {
y: (parent.height - self.height) / 2;
width: 24px; height: 22px;
border-radius: Theme.radius-sm;
background: go-ta.has-hover ? Theme.bg-hover : transparent;
go-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.navigate(root.path-draft); }
}
Text { text: "\u{E5C8}"; font-family: "Material Icons";
color: Theme.text-secondary; font-size: Theme.fs-sm;
width: parent.width; height: parent.height;
horizontal-alignment: center; vertical-alignment: center; }
}
up-btn := Rectangle {
y: (parent.height - self.height) / 2;
width: 24px; height: 22px;
border-radius: Theme.radius-sm;
background: up-ta.has-hover ? Theme.bg-hover : transparent;
up-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.navigate(".."); }
}
Text { text: "↑"; color: Theme.text-secondary; font-size: Theme.fs-md;
width: parent.width; height: parent.height;
horizontal-alignment: center; vertical-alignment: center; }
}
Rectangle {
y: (parent.height - self.height) / 2;
width: 52px; height: 22px;
border-radius: Theme.radius-sm;
background: ul-ta.has-hover ? Theme.accent-hover : Theme.accent;
ul-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.upload-clicked(root.current-path); }
}
Text { text: @tr("Upload"); color: #ffffff; font-size: Theme.fs-sm;
width: parent.width; height: parent.height;
horizontal-alignment: center; vertical-alignment: center; }
}
Rectangle {
y: (parent.height - self.height) / 2;
width: 24px; height: 22px;
border-radius: Theme.radius-sm;
background: ref-ta.has-hover ? Theme.bg-hover : transparent;
ref-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.refresh(root.current-path); }
}
Text { text: "\u{E5D5}"; font-family: "Material Icons";
color: Theme.text-secondary; font-size: Theme.fs-sm;
width: parent.width; height: parent.height;
horizontal-alignment: center; vertical-alignment: center; }
}
}
}
// --- Main area: tree (left) + file list (right) --------------------
HorizontalLayout {
vertical-stretch: 1;
spacing: 0;
// ── Left: directory tree ────────────────────────────────────────
VerticalLayout {
width: 160px;
spacing: 0;
// Tree header
Rectangle {
height: 20px;
background: root.app-background-active ? Theme.bg-root.with-alpha(root.ui-opacity) : Theme.bg-root;
Text {
x: 8px;
text: @tr("Directory tree");
color: Theme.text-muted;
font-size: max(10px, root.file-font-size - 2px);
vertical-alignment: center;
height: parent.height;
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
// Tree list with scrollbar
ScrollView {
vertical-stretch: 1;
tree-list := VerticalLayout {
spacing: 0;
for node[i] in root.tree-nodes : Rectangle {
height: max(24px, root.file-font-size + 10px);
background: node-ta.has-hover
? Theme.bg-hover
: (node.path == root.current-path
? #4a90e230
: (mod(i, 2) == 0 ? transparent : #ffffff08));
node-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.tree-expand(node.path); }
}
HorizontalLayout {
height: parent.height;
spacing: 0;
// Depth indent spacer
Rectangle { width: node.depth * 12px; }
// Expand/collapse arrow
Text {
width: 14px;
text: node.has-children
? (node.expanded ? "▼" : "▶")
: " ";
color: Theme.text-muted;
font-size: max(9px, root.file-font-size - 3px);
horizontal-alignment: center;
vertical-alignment: center;
}
// Folder name
Text {
text: node.depth == 0 ? "/" : node.name;
color: node.path == root.current-path
? Theme.accent
: Theme.text-primary;
font-size: root.file-font-size;
horizontal-stretch: 1;
overflow: elide;
vertical-alignment: center;
}
}
}
}
}
}
// Vertical separator
Rectangle { width: 1px; background: Theme.border-subtle; }
// ── Right: file listing ─────────────────────────────────────────
right-pane := VerticalLayout {
horizontal-stretch: 1;
spacing: 0;
// Column headers
Rectangle {
height: 20px;
background: root.app-background-active ? Theme.bg-root.with-alpha(root.ui-opacity) : Theme.bg-root;
HorizontalLayout {
height: parent.height;
padding-left: 8px; padding-right: 8px;
Text { text: @tr("Name"); color: Theme.text-muted; font-size: max(10px, root.file-font-size - 2px);
width: clamp(root.name-column-width, 96px, 360px); vertical-alignment: center; }
Rectangle {
width: 6px;
height: parent.height;
background: root.name-column-dragging || name-resize-ta.has-hover ? Theme.accent.with-alpha(0.45) : transparent;
name-resize-ta := TouchArea {
mouse-cursor: col-resize;
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
root.name-column-dragging = true;
root.name-column-drag-start-x = self.mouse-x;
root.name-column-manual = true;
} else if (ev.kind == PointerEventKind.up) {
root.name-column-dragging = false;
root.name-column-width-changed(root.name-column-width / 1px);
}
}
moved => {
if (root.name-column-dragging) {
root.name-column-width = clamp(
root.name-column-width + self.mouse-x - root.name-column-drag-start-x,
96px,
360px
);
root.name-column-drag-start-x = self.mouse-x;
}
}
}
}
if root.name-column-manual : Rectangle {
y: (parent.height - self.height) / 2;
width: 42px;
height: 18px;
border-radius: Theme.radius-sm;
background: name-auto-ta.has-hover ? Theme.bg-hover : transparent;
name-auto-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.name-column-manual = false;
root.name-column-auto();
root.refresh(root.current-path);
}
}
Text {
width: parent.width;
height: parent.height;
text: @tr("Auto");
color: Theme.accent;
font-size: max(10px, root.file-font-size - 3px);
horizontal-alignment: center;
vertical-alignment: center;
}
}
Rectangle { horizontal-stretch: 1; }
Text { text: @tr("Size"); color: Theme.text-muted; font-size: max(10px, root.file-font-size - 2px);
width: 72px; horizontal-alignment: right; vertical-alignment: center; }
Text { text: @tr("Modified"); color: Theme.text-muted; font-size: max(10px, root.file-font-size - 2px);
width: 116px; horizontal-alignment: right; vertical-alignment: center; }
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
// File list with scrollbar
ScrollView {
vertical-stretch: 1;
file-list := VerticalLayout {
spacing: 0;
if root.loading : Rectangle {
height: 40px;
Text { width: parent.width; height: parent.height;
text: @tr("Loading..."); color: Theme.text-muted; font-size: root.file-font-size;
horizontal-alignment: center; vertical-alignment: center; }
}
if !root.loading && root.entries.length == 0 : Rectangle {
height: 40px;
Text { width: parent.width; height: parent.height;
text: @tr("Empty directory"); color: Theme.text-muted; font-size: root.file-font-size;
horizontal-alignment: center; vertical-alignment: center; }
}
for entry[i] in root.entries : row := Rectangle {
height: max(26px, root.file-font-size + 12px);
background: row-ta.has-hover
? #4a90e225
: (mod(i, 2) == 0 ? transparent : #ffffff08);
// Right-click anchor (relative to this row).
property <length> mx;
property <length> my;
row-ta := TouchArea {
mouse-cursor: pointer;
double-clicked => {
if (entry.is-dir) {
root.navigate(entry.full-path);
} else {
root.edit(entry.full-path);
}
}
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down
&& ev.button == PointerEventButton.right) {
row.mx = self.mouse-x;
row.my = self.mouse-y;
row-menu.show();
}
}
}
// Right-click context menu.
row-menu := PopupWindow {
x: row.mx;
y: row.my;
width: 108px;
height: (entry.is-dir ? 2 : 4) * 27px + 8px;
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;
if !entry.is-dir : SftpMenuItem {
label: @tr("Download");
clicked => { root.download(entry.full-path); }
}
SftpMenuItem {
label: @tr("Rename");
clicked => {
root.rename-path = entry.full-path;
root.rename-draft = entry.name;
root.rename-open = true;
}
}
if !entry.is-dir : SftpMenuItem {
label: @tr("Edit");
clicked => { root.edit(entry.full-path); }
}
SftpMenuItem {
label: @tr("Delete");
tint: #d86c6c;
clicked => { root.delete(entry.full-path); }
}
}
}
}
HorizontalLayout {
height: parent.height;
padding-left: 8px; padding-right: 8px; spacing: 4px;
Text {
text: entry.is-dir ? "📁 " + entry.name : "📄 " + entry.name;
color: entry.is-dir ? Theme.accent : Theme.text-primary;
font-size: root.file-font-size;
width: clamp(root.name-column-width, 96px, 360px);
overflow: elide;
vertical-alignment: center;
}
Rectangle { width: 6px; height: parent.height; }
Rectangle { horizontal-stretch: 1; }
Text {
text: entry.is-dir ? "" : entry.size;
color: Theme.text-secondary; font-size: max(10px, root.file-font-size - 2px);
width: 72px; horizontal-alignment: right; vertical-alignment: center;
}
Text {
text: entry.modified;
color: Theme.text-muted; font-size: max(10px, root.file-font-size - 2px);
width: 116px; horizontal-alignment: right; vertical-alignment: center;
}
}
}
}
}
}
}
if root.rename-open : Rectangle {
width: parent.width;
height: parent.height;
background: #00000040;
TouchArea { clicked => { root.rename-open = false; } }
Rectangle {
x: (parent.width - self.width) / 2;
y: 40px;
width: min(340px, parent.width - 32px);
height: 118px;
border-radius: Theme.radius-md;
border-width: 1px;
border-color: Theme.border-strong;
background: Theme.bg-panel;
drop-shadow-blur: 16px;
drop-shadow-color: #00000080;
TouchArea {}
VerticalLayout {
padding: 12px;
spacing: 8px;
Text {
text: @tr("Rename");
color: Theme.text-primary;
font-size: Theme.fs-sm;
font-weight: 600;
}
Rectangle {
height: 30px;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: rename-input.has-focus ? Theme.accent : Theme.border-subtle;
background: Theme.bg-root;
rename-input := TextInput {
x: 8px;
width: parent.width - 16px;
height: parent.height;
text <=> root.rename-draft;
color: Theme.text-primary;
font-size: Theme.fs-sm;
single-line: true;
vertical-alignment: center;
key-pressed(e) => {
if (e.text == "\n") {
root.rename(root.rename-path, root.rename-draft);
root.rename-open = false;
accept
} else {
reject
}
}
}
}
HorizontalLayout {
height: 28px;
spacing: 8px;
Rectangle { horizontal-stretch: 1; }
Rectangle {
width: 68px;
height: parent.height;
border-radius: Theme.radius-sm;
background: rn-cancel-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated;
rn-cancel-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.rename-open = false; }
}
Text {
width: parent.width;
height: parent.height;
text: @tr("Cancel");
color: Theme.text-primary;
font-size: Theme.fs-sm;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Rectangle {
width: 68px;
height: parent.height;
border-radius: Theme.radius-sm;
background: rn-ok-ta.has-hover ? Theme.accent-hover : Theme.accent;
rn-ok-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.rename(root.rename-path, root.rename-draft);
root.rename-open = false;
}
}
Text {
width: parent.width;
height: parent.height;
text: @tr("Save");
color: #ffffff;
font-size: Theme.fs-sm;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
}
}
// --- Status bar (full width) --------------------------------------
Rectangle {
height: 18px;
background: root.app-background-active ? Theme.bg-panel-alt.with-alpha(root.ui-opacity) : Theme.bg-panel-alt;
Text {
x: 8px;
text: root.status;
color: Theme.text-muted;
font-size: Theme.fs-xs;
vertical-alignment: center;
height: parent.height;
overflow: elide;
width: parent.width - 16px;
}
}
}
}

499
ui/sidebar.slint Normal file
View File

@@ -0,0 +1,499 @@
import { Theme } from "theme.slint";
import { Sparkline } from "widgets.slint";
// One filesystem row for the disk-usage panel.
export struct DiskInfo {
path: string,
detail: string, // "available/total"
percent: float, // used fraction 0.0..1.0
}
export struct ProcessInfo {
mem: string,
cpu: string,
name: string,
}
// --- StatRow ---------------------------------------------------------------
// A single label / percentage / progress-bar row used for CPU / memory / swap.
component StatRow inherits Rectangle {
in property <string> label;
in property <float> percent; // 0.0 .. 1.0
in property <string> detail;
in property <brush> bar-color: Theme.accent;
height: 30px;
border-radius: Theme.radius-sm;
background: Theme.bg-panel-alt.with-alpha(0.38);
HorizontalLayout {
height: parent.height;
padding-left: 6px;
padding-right: 6px;
spacing: 7px;
Text {
width: 32px;
height: parent.height;
text: label;
color: Theme.text-secondary;
font-size: Theme.fs-xs;
vertical-alignment: center;
overflow: elide;
}
Rectangle {
y: (parent.height - self.height) / 2;
height: 20px;
horizontal-stretch: 1;
background: Theme.bg-root.with-alpha(0.56);
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.border-subtle.with-alpha(0.58);
clip: true;
Rectangle {
x: 0;
height: parent.height;
width: parent.width * clamp(percent, 0.0, 1.0);
background: bar-color.with-alpha(0.52);
border-radius: Theme.radius-sm;
animate width { duration: 500ms; easing: ease-out; }
}
HorizontalLayout {
height: parent.height;
padding-left: 7px;
padding-right: 7px;
Text {
height: parent.height;
text: Math.round(percent * 100) + "%";
color: Theme.text-primary;
font-size: Theme.fs-xs;
font-weight: 600;
vertical-alignment: center;
}
Rectangle { horizontal-stretch: 1; }
Text {
height: parent.height;
text: detail;
color: Theme.text-primary;
font-size: Theme.fs-xs;
horizontal-alignment: right;
vertical-alignment: center;
overflow: elide;
}
}
}
}
}
component ProcessTable inherits VerticalLayout {
in property <[ProcessInfo]> processes;
vertical-stretch: 0;
spacing: 1px;
HorizontalLayout {
height: 18px;
padding-left: 6px;
padding-right: 6px;
spacing: 4px;
Text {
width: 42px;
text: @tr("Memory");
color: Theme.text-muted;
font-size: Theme.fs-xs;
vertical-alignment: center;
}
Text {
width: 32px;
text: "CPU";
color: Theme.text-muted;
font-size: Theme.fs-xs;
vertical-alignment: center;
}
Text {
text: @tr("Program");
color: Theme.text-muted;
font-size: Theme.fs-xs;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
for p in root.processes : Rectangle {
height: 18px;
border-radius: 2px;
background: row-ta.has-hover ? Theme.bg-hover.with-alpha(0.78) : Theme.bg-panel-alt.with-alpha(0.22);
row-ta := TouchArea {}
HorizontalLayout {
height: parent.height;
padding-left: 6px;
padding-right: 6px;
spacing: 4px;
Text {
width: 42px;
text: p.mem;
color: Theme.text-secondary;
font-size: Theme.fs-xs;
vertical-alignment: center;
overflow: elide;
}
Text {
width: 32px;
text: p.cpu;
color: Theme.text-secondary;
font-size: Theme.fs-xs;
vertical-alignment: center;
overflow: elide;
}
Text {
text: p.name;
color: Theme.text-primary;
font-size: Theme.fs-xs;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
}
}
}
// --- NetGraph --------------------------------------------------------------
// One network throughput graph: a header row (↑ tx / ↓ rx, plus an optional
// NIC selector dropdown) over an auto-scaled sparkline.
component NetGraph inherits VerticalLayout {
in property <string> up-text;
in property <string> down-text;
in property <[float]> history;
in property <bool> show-selector: false;
in property <[string]> ifaces;
in property <string> selected;
callback iface-selected(string);
// Stay at preferred height; without this the inherited VerticalLayout grabs
// a stretch share of the sidebar and balloons the sparkline into blank space.
vertical-stretch: 0;
spacing: 3px;
HorizontalLayout {
height: 18px;
spacing: 3px;
Text { // ↑ upload (green)
width: 12px;
height: parent.height;
text: "\u{E5D8}";
font-family: "Material Icons";
color: Theme.success;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
height: parent.height;
text: root.up-text;
color: Theme.text-secondary;
font-size: Theme.fs-xs;
vertical-alignment: center;
}
Text { // ↓ download (blue)
width: 12px;
height: parent.height;
text: "\u{E5DB}";
font-family: "Material Icons";
color: Theme.accent;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
height: parent.height;
text: root.down-text;
color: Theme.text-secondary;
font-size: Theme.fs-xs;
vertical-alignment: center;
}
Rectangle { horizontal-stretch: 1; } // spacer pushes selector to the right
if root.show-selector : Rectangle {
width: 64px;
height: 18px;
border-radius: 3px;
background: sel-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated;
HorizontalLayout {
height: parent.height;
padding-left: 5px; padding-right: 4px; spacing: 2px;
Text {
height: parent.height;
text: root.selected;
color: Theme.text-primary;
font-size: Theme.fs-xs;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
Text {
height: parent.height;
text: "\u{E5CF}";
font-family: "Material Icons";
color: Theme.text-muted;
font-size: 8px;
vertical-alignment: center;
}
}
sel-ta := TouchArea {
mouse-cursor: pointer;
clicked => { popup.show(); }
}
popup := PopupWindow {
x: 0; y: parent.height + 2px;
width: 96px;
Rectangle {
background: Theme.bg-panel;
border-radius: 4px;
border-width: 1px;
border-color: Theme.border-strong;
drop-shadow-blur: 10px;
drop-shadow-color: #00000050;
VerticalLayout {
padding: 4px;
spacing: 1px;
for iface in root.ifaces : Rectangle {
height: 22px;
border-radius: 3px;
background: row-ta.has-hover ? Theme.bg-hover : transparent;
row-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.iface-selected(iface); }
}
Text {
x: 6px;
text: iface;
color: Theme.text-primary;
font-size: Theme.fs-xs;
vertical-alignment: center;
height: parent.height;
}
}
}
}
}
}
}
Sparkline {
height: 48px;
values: root.history;
line-color: Theme.accent;
}
}
// --- Sidebar ---------------------------------------------------------------
// Left-hand panel showing local machine health, mimicking FinalShell's
// @tr("Status") column but kept minimal for the v0.1 baseline.
export component Sidebar inherits Rectangle {
in property <bool> app-background-active: false;
in property <float> ui-opacity: 0.72;
in property <string> connection-state: @tr("Not connected");
in property <int> conn-state; // 0 gray / 1 green / 2 yellow
in property <string> resource-title: @tr("Local resources");
in property <float> cpu-percent;
in property <float> mem-percent;
in property <float> swap-percent;
in property <string> cpu-detail;
in property <string> mem-detail;
in property <string> swap-detail;
in property <string> net-top-up;
in property <string> net-top-down;
in property <[float]> net-top-history;
in property <[string]> net-ifaces;
in property <string> net-selected;
in property <bool> net-show-selector;
in property <[DiskInfo]> disks;
in property <[ProcessInfo]> processes;
callback select-net-iface(string);
width: 220px;
background: root.app-background-active ? Theme.bg-panel.with-alpha(root.ui-opacity) : Theme.bg-panel;
border-width: 0;
VerticalLayout {
padding: 10px;
spacing: 10px;
// Status header
VerticalLayout {
spacing: 3px;
Text {
text: @tr("Running status");
color: Theme.text-primary;
font-size: Theme.fs-md;
font-weight: 600;
vertical-alignment: center;
}
Rectangle {
height: 24px;
border-radius: Theme.radius-sm;
background: root.conn-state == 1 ? Theme.success.with-alpha(0.16)
: root.conn-state == 2 ? Theme.warning.with-alpha(0.16)
: Theme.bg-root;
Text {
x: 8px;
width: parent.width - 16px;
height: parent.height;
text: connection-state;
color: root.conn-state == 1 ? Theme.success
: root.conn-state == 2 ? Theme.warning
: Theme.text-secondary;
font-size: Theme.fs-sm;
vertical-alignment: center;
overflow: elide;
}
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
// System stats — local machine on the welcome tab, the remote server
// when a connected session tab is active.
Text {
text: root.resource-title;
color: Theme.text-secondary;
font-size: Theme.fs-sm;
}
VerticalLayout {
spacing: 6px;
StatRow {
label: "CPU";
percent: root.cpu-percent;
detail: root.cpu-detail;
bar-color: Theme.accent;
}
StatRow {
label: @tr("Memory");
percent: root.mem-percent;
detail: root.mem-detail;
bar-color: Theme.success;
}
StatRow {
label: @tr("Swap");
percent: root.swap-percent;
detail: root.swap-detail;
bar-color: Theme.warning;
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
ProcessTable {
processes: root.processes;
}
if root.net-top-up != "" || root.net-top-down != "" || root.net-show-selector : Rectangle {
height: 1px;
background: Theme.border-subtle;
}
// Network — follows the active live session tab.
if root.net-top-up != "" || root.net-top-down != "" || root.net-show-selector : VerticalLayout {
vertical-stretch: 0;
spacing: 6px;
NetGraph {
up-text: root.net-top-up;
down-text: root.net-top-down;
history: root.net-top-history;
show-selector: root.net-show-selector;
ifaces: root.net-ifaces;
selected: root.net-selected;
iface-selected(i) => { root.select-net-iface(i); }
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
// Disk-usage panel (active tab's filesystems; local on the welcome tab).
disk-section := VerticalLayout {
vertical-stretch: 1;
spacing: 3px;
HorizontalLayout {
padding-left: 6px;
padding-right: 6px;
Text {
text: @tr("Path");
color: Theme.text-muted;
font-size: Theme.fs-xs;
horizontal-stretch: 1;
}
Text {
text: @tr("Free/Total");
color: Theme.text-muted;
font-size: Theme.fs-xs;
}
}
disk-flick := Flickable {
vertical-stretch: 1;
viewport-width: self.width;
// Grow only when the list overflows; otherwise match the visible
// height so `alignment: start` keeps rows pinned to the top.
viewport-height: max(self.height, disk-col.preferred-height);
disk-col := VerticalLayout {
alignment: start;
spacing: 1px;
for d[i] in root.disks : Rectangle {
height: 20px;
border-radius: 2px;
clip: true;
// Usage fill: green-ish normally, amber/red when full.
Rectangle {
x: 0;
y: 0;
height: parent.height;
width: parent.width * clamp(d.percent, 0.0, 1.0);
background: d.percent > 0.9 ? Theme.danger.with-alpha(0.32)
: d.percent > 0.75 ? Theme.warning.with-alpha(0.30)
: Theme.accent.with-alpha(0.22);
}
HorizontalLayout {
height: parent.height;
padding-left: 6px;
padding-right: 6px;
spacing: 4px;
Text {
height: parent.height;
text: d.path;
color: Theme.text-secondary;
font-size: Theme.fs-xs;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
Text {
height: parent.height;
text: d.detail;
color: Theme.text-muted;
font-size: Theme.fs-xs;
vertical-alignment: center;
horizontal-alignment: right;
}
}
}
}
}
}
Text {
text: "meatshell v0.2.7";
color: Theme.text-muted;
font-size: Theme.fs-xs;
horizontal-alignment: center;
}
}
}

223
ui/tabs.slint Normal file
View File

@@ -0,0 +1,223 @@
import { Theme } from "theme.slint";
import { IconButton } from "widgets.slint";
export struct TabInfo {
id: string,
title: string,
kind: string, // "welcome" | "terminal"
connected: bool,
}
// --- SingleTab -------------------------------------------------------------
component SingleTab inherits Rectangle {
in property <TabInfo> info;
in property <bool> active;
in property <bool> shown: true;
in property <bool> app-background-active: false;
in property <float> ui-opacity: 0.72;
callback selected();
callback closed();
height: shown ? 30px : 0px;
property <length> title-width: min(128px, max(34px, title-probe.preferred-width));
property <length> tab-width: info.kind == "welcome"
? max(72px, title-width + 34px)
: max(74px, title-width + 62px);
width: shown ? min(176px, tab-width) : 0px;
visible: shown;
horizontal-stretch: 0;
vertical-stretch: 0;
border-radius: Theme.radius-sm;
background: active ? (root.app-background-active ? Theme.bg-panel.with-alpha(root.ui-opacity) : Theme.bg-panel)
: (touch.has-hover ? Theme.bg-hover : transparent);
animate background { duration: 120ms; }
// Declared FIRST so the HorizontalLayout's children (close button) are at
// a higher z-order and receive their own click events.
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.selected(); }
}
title-probe := Text {
text: info.title;
font-size: Theme.fs-sm;
opacity: 0;
x: -1000px;
y: -1000px;
}
HorizontalLayout {
height: parent.height;
padding-left: 10px;
padding-right: 6px;
spacing: 8px;
alignment: center;
// Status indicator
Rectangle {
width: 8px;
height: parent.height;
Rectangle {
y: (parent.height - self.height) / 2;
width: parent.width;
height: 8px;
border-radius: 4px;
background: info.kind == "welcome" ? Theme.text-muted
: (info.connected ? Theme.success : Theme.warning);
}
}
Text {
text: info.title;
height: parent.height;
color: active ? Theme.text-primary : Theme.text-secondary;
font-size: Theme.fs-sm;
vertical-alignment: center;
overflow: elide;
horizontal-stretch: 1;
}
// Only show close button for non-welcome tabs
if info.kind != "welcome" : Rectangle {
width: 20px;
height: parent.height;
IconButton {
y: (parent.height - self.height) / 2;
glyph: "×";
size: 20px;
clicked => { root.closed(); }
}
}
}
}
// --- TabBar ----------------------------------------------------------------
export component TabBar inherits Rectangle {
in property <[TabInfo]> tabs;
in property <string> active-id;
in-out property <int> visible-start: 0;
in property <bool> app-background-active: false;
in property <float> ui-opacity: 0.72;
property <int> visible-count: 5;
callback tab-selected(string);
callback tab-closed(string);
callback new-tab();
callback previous-tab();
callback next-tab();
height: 36px;
background: root.app-background-active ? Theme.bg-panel-alt.with-alpha(root.ui-opacity) : Theme.bg-panel-alt;
HorizontalLayout {
height: parent.height;
padding-left: 3px;
padding-right: 100px; // reserve the overlaid theme/download/settings buttons
padding-top: 3px;
padding-bottom: 3px;
spacing: 2px;
Rectangle {
width: 30px;
height: 30px;
border-radius: Theme.radius-sm;
background: root.active-id == "welcome"
? Theme.bg-panel
: (folder-ta.has-hover ? Theme.bg-hover : transparent);
folder-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.new-tab(); }
}
Text {
width: parent.width;
height: parent.height;
text: "📂";
color: root.active-id == "welcome" ? Theme.text-primary : Theme.text-secondary;
font-size: Theme.fs-md;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Rectangle {
width: 56px;
height: 30px;
vertical-stretch: 0;
HorizontalLayout {
height: parent.height;
spacing: 0;
Rectangle {
width: 28px;
height: parent.height;
border-radius: Theme.radius-sm;
background: prev-ta.has-hover ? Theme.bg-hover : transparent;
prev-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.previous-tab(); }
}
Text {
width: parent.width;
height: parent.height;
text: "[<]";
color: Theme.text-secondary;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Rectangle {
width: 28px;
height: parent.height;
border-radius: Theme.radius-sm;
background: next-ta.has-hover ? Theme.bg-hover : transparent;
next-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.next-tab(); }
}
Text {
width: parent.width;
height: parent.height;
text: "[>]";
color: Theme.text-secondary;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
Rectangle {
horizontal-stretch: 1;
height: 30px;
clip: true;
tabs-flick := Flickable {
width: parent.width;
height: parent.height;
viewport-width: max(self.width, tabs-row.preferred-width);
viewport-height: self.height;
interactive: true;
tabs-row := HorizontalLayout {
height: parent.height;
spacing: 0px;
for tab[i] in root.tabs : SingleTab {
shown: i >= root.visible-start && i < root.visible-start + root.visible-count;
info: tab;
active: tab.id == root.active-id;
app-background-active: root.app-background-active;
ui-opacity: root.ui-opacity;
selected => { root.tab-selected(tab.id); }
closed => { root.tab-closed(tab.id); }
}
}
}
}
}
}

2298
ui/terminal_view.slint Normal file

File diff suppressed because it is too large Load Diff

64
ui/theme.slint Normal file
View File

@@ -0,0 +1,64 @@
// Central design tokens for meatshell.
//
// All colour values are reactive: change `dark` and every widget repaints
// automatically via Slint's property-binding system.
//
// Dark palette — high-contrast charcoal (original meatshell look).
// Light palette — Apple macOS-inspired:
// • Backgrounds use #f5f5f7 (Apple page gray) instead of pure white to
// avoid eye-strain.
// • Primary text is #1d1d1f (Apple near-black) not #000000.
// • Accent is #0071e3 (Apple blue), consistent across both themes.
export global Theme {
// Set by Rust on startup (system detection) and on user toggle.
in-out property <bool> dark: true;
// --- Palette -----------------------------------------------------------
out property <brush> bg-root: dark ? #1b1d23 : #f5f5f7;
out property <brush> bg-panel: dark ? #23262d : #ffffff;
out property <brush> bg-panel-alt: dark ? #2a2d35 : #f2f2f7;
out property <brush> bg-elevated: dark ? #30333c : #e8e8ed;
out property <brush> bg-hover: dark ? #373a44 : #e0e0e6;
out property <brush> bg-active: dark ? #3f4350 : #d8d8de;
out property <brush> border-subtle: dark ? #3a3d46 : #e2e2e8;
out property <brush> border-strong: dark ? #4a4e59 : #c7c7cc;
out property <brush> text-primary: dark ? #e6e8ee : #1d1d1f;
out property <brush> text-secondary: dark ? #b4b9c4 : #6e6e73;
out property <brush> text-muted: dark ? #9196a3 : #aeaeb2;
out property <brush> accent: dark ? #4a90e2 : #0071e3;
out property <brush> accent-hover: dark ? #5aa0f2 : #0077ed;
out property <brush> accent-pressed: dark ? #3a80d2 : #006adb;
out property <brush> success: dark ? #4ec9b0 : #34c759;
out property <brush> warning: dark ? #e2a84a : #ff9f0a;
out property <brush> danger: dark ? #e25c5c : #ff3b30;
// Terminal uses its own background so it stays readable regardless of
// what the shell / remote app outputs via ANSI colour codes.
out property <brush> term-bg: dark ? #0e0f13 : #fafafa;
out property <brush> term-fg: dark ? #d4d4d4 : #2d2d2f;
// --- Typography --------------------------------------------------------
out property <length> fs-xs: 11px;
out property <length> fs-sm: 12px;
out property <length> fs-md: 13px;
out property <length> fs-lg: 15px;
out property <length> fs-xl: 18px;
out property <string> font-ui: "Microsoft YaHei UI";
out property <string> font-mono: "Cascadia Mono";
// --- Geometry ----------------------------------------------------------
out property <length> radius-sm: 4px;
out property <length> radius-md: 6px;
out property <length> radius-lg: 10px;
out property <length> gap-xs: 4px;
out property <length> gap-sm: 8px;
out property <length> gap-md: 12px;
out property <length> gap-lg: 16px;
}

589
ui/welcome.slint Normal file
View File

@@ -0,0 +1,589 @@
import { Theme } from "theme.slint";
import { PrimaryButton } from "widgets.slint";
export struct SessionInfo {
id: string,
name: string,
group: string,
host: string,
port: int,
user: string,
auth: string, // "password" | "key"
kind: string, // "ssh" | "serial" | "telnet"
last-used: string, // ISO timestamp or "never"
}
export struct ConnectionFolderInfo {
name: string,
expanded: bool,
}
// --- SessionRow ------------------------------------------------------------
component SessionRow inherits Rectangle {
in property <SessionInfo> session;
in property <[string]> folder-names;
in property <bool> debug-ids: false;
callback connect();
callback edit();
callback remove();
callback move-folder(string);
height: 34px;
border-radius: Theme.radius-sm;
background: touch.has-hover ? Theme.bg-hover : transparent;
animate background { duration: 120ms; }
// Remember where the right-click landed so the popup can appear there.
property <length> ctx-x;
property <length> ctx-y;
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.connect(); }
pointer-event(e) => {
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
ctx-x = self.mouse-x;
ctx-y = self.mouse-y;
ctx-menu.show();
}
}
}
// Right-click context menu -----------------------------------------------
ctx-menu := PopupWindow {
x: ctx-x;
y: ctx-y;
width: 142px;
height: 112px + root.folder-names.length * 28px;
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;
// Edit
Rectangle {
height: 28px;
border-radius: Theme.radius-sm;
background: edit-ta.has-hover ? Theme.bg-hover : transparent;
edit-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.edit(); }
}
HorizontalLayout {
height: parent.height;
padding-left: 10px;
spacing: 8px;
alignment: center;
Text { width: 16px; height: parent.height; text: "\u{E3C9}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; horizontal-alignment: center; vertical-alignment: center; }
Text { text: @tr("Edit"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; }
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
// Move to category
Rectangle {
height: 28px;
border-radius: Theme.radius-sm;
background: transparent;
HorizontalLayout {
height: parent.height;
padding-left: 10px;
spacing: 8px;
alignment: center;
Text { width: 16px; height: parent.height; text: "\u{E2C8}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; horizontal-alignment: center; vertical-alignment: center; }
Text { text: @tr("Move to category"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; }
}
}
for folder in root.folder-names : Rectangle {
height: 26px;
border-radius: Theme.radius-sm;
background: folder == root.session.group ? Theme.bg-active : (folder-ta.has-hover ? Theme.bg-hover : transparent);
folder-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.move-folder(folder); }
}
Text {
x: 24px;
width: parent.width - 34px;
height: parent.height;
text: folder;
color: Theme.text-primary;
font-size: Theme.fs-sm;
vertical-alignment: center;
overflow: elide;
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
// Delete (red tint)
Rectangle {
height: 28px;
border-radius: Theme.radius-sm;
background: del-ta.has-hover ? #cc333320 : transparent;
del-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.remove(); }
}
HorizontalLayout {
height: parent.height;
padding-left: 10px;
spacing: 8px;
alignment: center;
Text { width: 16px; height: parent.height; text: "\u{E872}"; font-family: "Material Icons"; color: #cc3333; font-size: Theme.fs-sm; horizontal-alignment: center; vertical-alignment: center; }
Text { text: @tr("Delete"); color: #cc3333; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; }
}
}
}
}
}
// Row content ------------------------------------------------------------
HorizontalLayout {
width: parent.width;
height: parent.height;
padding-left: 10px;
padding-right: 10px;
spacing: 8px;
Rectangle {
width: 24px;
height: parent.height;
Text {
width: parent.width;
height: parent.height;
text: "\u{2514}";
color: Theme.text-muted;
font-size: Theme.fs-sm;
horizontal-alignment: right;
vertical-alignment: center;
}
}
Rectangle {
width: 18px;
height: parent.height;
Rectangle {
y: (parent.height - self.height) / 2;
width: parent.width;
height: 18px;
border-radius: 3px;
background: Theme.bg-elevated;
Text {
width: parent.width;
height: parent.height;
text: session.kind == "serial" ? "\u{E1B1}"
: session.kind == "telnet" ? "\u{E8AC}"
: "\u{E875}";
font-family: "Material Icons";
color: Theme.text-secondary;
font-size: Theme.fs-sm;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
Text {
height: parent.height;
text: session.name;
color: Theme.text-primary;
font-size: Theme.fs-md;
vertical-alignment: center;
width: 170px;
overflow: elide;
}
Rectangle {
width: 52px;
height: parent.height;
Rectangle {
y: (parent.height - self.height) / 2;
width: parent.width;
height: 20px;
border-radius: Theme.radius-sm;
background: Theme.bg-elevated;
Text {
width: parent.width;
height: parent.height;
text: session.kind == "serial" ? "SERIAL"
: session.kind == "telnet" ? "TELNET"
: "SSH";
color: Theme.text-secondary;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
Text {
height: parent.height;
text: session.kind == "serial"
? session.host
: session.host + ":" + session.port;
color: Theme.text-secondary;
font-size: Theme.fs-sm;
vertical-alignment: center;
width: 220px;
overflow: elide;
}
Text {
height: parent.height;
text: session.user;
color: Theme.text-secondary;
font-size: Theme.fs-sm;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
if root.debug-ids : Text {
height: parent.height;
text: "[session-row]";
color: Theme.warning;
font-size: Theme.fs-xs;
vertical-alignment: center;
}
}
}
component FolderSessionSlot inherits Rectangle {
in property <string> folder-name;
in property <bool> folder-expanded: true;
in property <SessionInfo> session;
in property <[string]> folder-names;
in property <bool> debug-ids: false;
callback connect-session(string);
callback edit-session(string);
callback remove-session(string);
callback move-session-folder(string, string);
height: root.folder-expanded && session.group == root.folder-name ? 34px : 0px;
visible: root.folder-expanded && session.group == root.folder-name;
if root.folder-expanded && session.group == root.folder-name : SessionRow {
width: parent.width;
height: parent.height;
session: root.session;
folder-names: root.folder-names;
debug-ids: root.debug-ids;
connect => { root.connect-session(root.session.id); }
edit => { root.edit-session(root.session.id); }
remove => { root.remove-session(root.session.id); }
move-folder(folder) => { root.move-session-folder(root.session.id, folder); }
}
}
component FolderSection inherits VerticalLayout {
in property <string> folder-name;
in property <bool> expanded: true;
in property <[SessionInfo]> sessions;
in property <[string]> folder-names;
in property <bool> debug-ids: false;
callback connect-session(string);
callback edit-session(string);
callback remove-session(string);
callback rename-folder(string);
callback delete-folder(string);
callback new-folder();
callback toggle-folder(string);
callback move-session-folder(string, string);
spacing: 2px;
Rectangle {
width: parent.width;
height: 28px;
border-radius: Theme.radius-sm;
background: header-ta.has-hover ? Theme.bg-hover : transparent;
property <length> ctx-x;
property <length> ctx-y;
header-ta := TouchArea {
mouse-cursor: pointer;
pointer-event(e) => {
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
ctx-x = self.mouse-x;
ctx-y = self.mouse-y;
folder-menu.show();
} else if (e.kind == PointerEventKind.down && e.button == PointerEventButton.left) {
root.toggle-folder(root.folder-name);
}
}
}
folder-menu := PopupWindow {
x: ctx-x;
y: ctx-y;
width: 132px;
height: 108px;
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;
Rectangle {
height: 28px;
border-radius: Theme.radius-sm;
background: new-folder-ta.has-hover ? Theme.bg-hover : transparent;
new-folder-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.new-folder(); }
}
Text { x: 10px; width: parent.width - 20px; height: parent.height; text: @tr("New category"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; }
}
Rectangle {
height: 28px;
border-radius: Theme.radius-sm;
background: rename-folder-ta.has-hover ? Theme.bg-hover : transparent;
rename-folder-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.rename-folder(root.folder-name); }
}
Text { x: 10px; width: parent.width - 20px; height: parent.height; text: @tr("Rename"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; }
}
Rectangle {
height: 28px;
border-radius: Theme.radius-sm;
background: delete-folder-ta.has-hover ? #cc333320 : transparent;
delete-folder-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.delete-folder(root.folder-name); }
}
Text { x: 10px; width: parent.width - 20px; height: parent.height; text: @tr("Delete"); color: #cc3333; font-size: Theme.fs-md; vertical-alignment: center; }
}
}
}
}
HorizontalLayout {
width: parent.width;
height: parent.height;
spacing: 8px;
padding-left: 10px;
padding-right: 10px;
Rectangle {
width: 24px;
height: parent.height;
Text {
width: parent.width;
height: parent.height;
text: root.expanded ? "\u{E5CF}" : "\u{E5CC}";
font-family: "Material Icons";
color: Theme.text-secondary;
font-size: 16px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Text {
height: parent.height;
text: root.folder-name;
color: Theme.text-secondary;
font-size: Theme.fs-sm;
font-weight: 600;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
if root.debug-ids : Text {
height: parent.height;
text: "[folder-section]";
color: Theme.warning;
font-size: Theme.fs-xs;
vertical-alignment: center;
}
}
}
for session in root.sessions : FolderSessionSlot {
width: parent.width;
folder-name: root.folder-name;
folder-expanded: root.expanded;
session: session;
folder-names: root.folder-names;
debug-ids: root.debug-ids;
connect-session(id) => { root.connect-session(id); }
edit-session(id) => { root.edit-session(id); }
remove-session(id) => { root.remove-session(id); }
move-session-folder(id, folder) => { root.move-session-folder(id, folder); }
}
}
// --- Welcome ---------------------------------------------------------------
export component Welcome inherits Rectangle {
in property <bool> app-background-active: false;
in property <float> ui-opacity: 0.72;
in property <[SessionInfo]> sessions;
in property <[ConnectionFolderInfo]> folders;
in property <[string]> all-folder-names;
in property <string> import-hint; // result text after an import
in property <bool> debug-ids: false;
callback new-session();
callback new-folder();
callback rename-folder(string);
callback delete-folder(string);
callback toggle-folder(string);
callback move-session-folder(string, string);
callback import-ssh-config();
callback connect-session(string);
callback edit-session(string);
callback remove-session(string);
background: root.app-background-active ? Theme.bg-root.with-alpha(root.ui-opacity) : Theme.bg-root;
clip: true;
Flickable {
width: parent.width;
height: parent.height;
viewport-width: self.width;
viewport-height: max(self.height, content.preferred-height);
content := VerticalLayout {
width: root.width;
padding: 24px;
spacing: 20px;
VerticalLayout {
spacing: 4px;
Text {
text: "meatshell";
color: Theme.text-primary;
font-size: 28px;
font-weight: 700;
}
Text {
text: @tr("meatshell — a lightweight Rust + Slint SSH client by 'lil meatball'");
color: Theme.text-secondary;
font-size: Theme.fs-md;
wrap: word-wrap;
}
}
HorizontalLayout {
spacing: 8px;
Rectangle { horizontal-stretch: 1; }
PrimaryButton {
text: @tr("New SSH");
clicked => { root.new-session(); }
}
}
// Connection management
Rectangle {
background: Theme.bg-panel;
border-radius: Theme.radius-md;
border-width: 1px;
border-color: Theme.border-subtle;
height: max(240px, min(560px, root.height - 190px));
VerticalLayout {
padding: 16px;
spacing: 12px;
HorizontalLayout {
spacing: 8px;
Text {
text: @tr("Connection manager");
color: Theme.text-primary;
font-size: Theme.fs-lg;
font-weight: 600;
vertical-alignment: center;
horizontal-stretch: 1;
}
// Result of the last "Import ~/.ssh/config" (triggered from
// the settings menu), e.g. "imported 3".
Text {
text: root.import-hint;
color: Theme.text-muted;
font-size: Theme.fs-sm;
vertical-alignment: center;
overflow: elide;
}
}
Rectangle {
vertical-stretch: 1;
clip: true;
background: transparent;
if sessions.length == 0 : Rectangle {
width: parent.width;
height: parent.height;
background: Theme.bg-root;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.border-subtle;
VerticalLayout {
alignment: center;
spacing: 6px;
Text {
text: @tr("No sessions yet");
color: Theme.text-secondary;
font-size: Theme.fs-md;
horizontal-alignment: center;
}
Text {
text: @tr("Use the plus button above to add SSH, Serial, or Telnet");
color: Theme.text-muted;
font-size: Theme.fs-sm;
horizontal-alignment: center;
wrap: word-wrap;
}
}
}
if root.folders.length > 0 : Flickable {
width: parent.width;
height: parent.height;
viewport-width: self.width;
viewport-height: max(self.height, folder-list.preferred-height);
folder-list := VerticalLayout {
width: parent.width;
spacing: 4px;
for folder in root.folders : FolderSection {
width: parent.width;
folder-name: folder.name;
expanded: folder.expanded;
sessions: root.sessions;
folder-names: root.all-folder-names;
debug-ids: root.debug-ids;
connect-session(id) => { root.connect-session(id); }
edit-session(id) => { root.edit-session(id); }
remove-session(id) => { root.remove-session(id); }
rename-folder(name) => { root.rename-folder(name); }
delete-folder(name) => { root.delete-folder(name); }
new-folder() => { root.new-folder(); }
toggle-folder(name) => { root.toggle-folder(name); }
move-session-folder(id, folder) => { root.move-session-folder(id, folder); }
}
}
}
}
}
}
// Filler
Rectangle { vertical-stretch: 1; }
}
}
}

174
ui/widgets.slint Normal file
View File

@@ -0,0 +1,174 @@
import { Theme } from "theme.slint";
// --- IconButton ------------------------------------------------------------
// A small square action button that accepts a single-character glyph (or short text).
// Keeps visual language consistent across the app.
export component IconButton inherits Rectangle {
in property <string> glyph;
in property <string> tooltip;
in property <length> size: 28px;
in property <brush> fg: Theme.text-secondary;
in property <brush> fg-hover: Theme.text-primary;
callback clicked();
width: size;
height: size;
border-radius: Theme.radius-sm;
background: touch.has-hover ? Theme.bg-hover : transparent;
animate background { duration: 120ms; }
Text {
width: parent.width;
height: parent.height;
text: glyph;
color: touch.has-hover ? fg-hover : fg;
font-size: Theme.fs-md;
horizontal-alignment: center;
vertical-alignment: center;
}
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
// --- PrimaryButton ---------------------------------------------------------
export component PrimaryButton inherits Rectangle {
in property <string> text;
in property <bool> disabled: false;
callback clicked();
height: 30px;
min-width: 80px;
border-radius: Theme.radius-sm;
background: disabled
? Theme.bg-elevated
: (touch.pressed ? Theme.accent-pressed
: (touch.has-hover ? Theme.accent-hover : Theme.accent));
animate background { duration: 120ms; }
HorizontalLayout {
height: parent.height;
padding-left: 14px;
padding-right: 14px;
alignment: center;
Text {
height: parent.height;
text: root.text;
color: disabled ? Theme.text-muted : white;
font-size: Theme.fs-md;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
}
}
touch := TouchArea {
enabled: !disabled;
mouse-cursor: disabled ? default : pointer;
clicked => { root.clicked(); }
}
}
// --- GhostButton -----------------------------------------------------------
export component GhostButton inherits Rectangle {
in property <string> text;
callback clicked();
height: 30px;
min-width: 80px;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: touch.has-hover ? Theme.border-strong : Theme.border-subtle;
background: touch.pressed ? Theme.bg-active
: (touch.has-hover ? Theme.bg-hover : Theme.bg-panel);
animate background, border-color { duration: 120ms; }
HorizontalLayout {
height: parent.height;
padding-left: 14px;
padding-right: 14px;
alignment: center;
Text {
height: parent.height;
text: root.text;
color: Theme.text-primary;
font-size: Theme.fs-md;
horizontal-alignment: center;
vertical-alignment: center;
}
}
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
// --- LabeledInput ----------------------------------------------------------
export component LabeledInput inherits VerticalLayout {
in property <string> label;
in property <string> placeholder;
in-out property <string> value;
in property <bool> password: false;
spacing: 4px;
Text {
text: label;
color: Theme.text-secondary;
font-size: Theme.fs-sm;
}
Rectangle {
height: 32px;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: input.has-focus ? Theme.accent : Theme.border-subtle;
background: Theme.bg-root;
input := TextInput {
x: 10px;
y: 0px;
width: parent.width - 20px;
height: parent.height;
text <=> root.value;
color: Theme.text-primary;
font-size: Theme.fs-md;
vertical-alignment: center;
single-line: true;
input-type: root.password ? InputType.password : InputType.text;
}
// Lightweight placeholder — shown when value is empty.
if value == "" : Text {
x: 10px;
y: 0px;
height: parent.height;
text: placeholder;
color: Theme.text-muted;
font-size: Theme.fs-md;
vertical-alignment: center;
}
}
}
// --- Sparkline -------------------------------------------------------------
// Compact horizontal bar chart used in the stats sidebar.
export component Sparkline inherits Rectangle {
in property <[float]> values;
in property <brush> line-color: Theme.accent;
background: Theme.bg-root;
border-radius: Theme.radius-sm;
clip: true;
for v[i] in values : Rectangle {
x: i * (parent.width / max(1, values.length));
y: parent.height - (parent.height * clamp(v, 0.0, 1.0));
width: (parent.width / max(1, values.length)) - 1px;
height: parent.height * clamp(v, 0.0, 1.0);
background: line-color.with-alpha(0.85);
border-radius: 1px;
}
}