20260619
This commit is contained in:
1608
ui/app.slint
Normal file
1608
ui/app.slint
Normal file
File diff suppressed because it is too large
Load Diff
123
ui/confirm_dialog.slint
Normal file
123
ui/confirm_dialog.slint
Normal 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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
ui/fonts/CascadiaMono-Bold.ttf
Normal file
BIN
ui/fonts/CascadiaMono-Bold.ttf
Normal file
Binary file not shown.
BIN
ui/fonts/CascadiaMono-Regular.ttf
Normal file
BIN
ui/fonts/CascadiaMono-Regular.ttf
Normal file
Binary file not shown.
BIN
ui/fonts/MaterialIcons-Regular.ttf
Normal file
BIN
ui/fonts/MaterialIcons-Regular.ttf
Normal file
Binary file not shown.
454
ui/session_dialog.slint
Normal file
454
ui/session_dialog.slint
Normal 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
591
ui/sftp_panel.slint
Normal 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
499
ui/sidebar.slint
Normal 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
223
ui/tabs.slint
Normal 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
2298
ui/terminal_view.slint
Normal file
File diff suppressed because it is too large
Load Diff
64
ui/theme.slint
Normal file
64
ui/theme.slint
Normal 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
589
ui/welcome.slint
Normal 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
174
ui/widgets.slint
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user