This commit is contained in:
2026-01-30 12:56:00 +08:00
parent 91f24cb462
commit dcbbda951e
89 changed files with 13486 additions and 72 deletions

View File

@@ -0,0 +1,116 @@
import { Theme, SpacingSystem, Typography, Animations } from "../theme/theme.slint";
export struct AccordionItemData {
title: string,
expanded: bool,
}
export component Accordion {
in-out property <[AccordionItemData]> items: [];
in property <bool> allow-multiple: false;
callback item-toggled(int, bool);
VerticalLayout {
spacing: 0;
for item[index] in root.items: AccordionItem {
title: item.title;
expanded: item.expanded;
is-last: index == root.items.length - 1;
toggled => {
root.item-toggled(index, !item.expanded);
}
}
}
}
component AccordionItem {
in property <string> title: "";
in property <bool> expanded: false;
in property <bool> is-last: false;
callback toggled();
private property <bool> hovered: false;
VerticalLayout {
spacing: 0;
// Header
header := Rectangle {
height: 48px;
background: root.hovered ? Theme.colors.muted : transparent;
animate background { duration: Animations.durations.fast; }
HorizontalLayout {
padding-left: SpacingSystem.spacing.s4;
padding-right: SpacingSystem.spacing.s4;
spacing: SpacingSystem.spacing.s4;
alignment: space-between;
Text {
text: root.title;
color: Theme.colors.foreground;
font-size: Typography.sizes.sm;
font-weight: Typography.weights.medium;
vertical-alignment: center;
}
chevron := Text {
text: "";
color: Theme.colors.muted-foreground;
font-size: Typography.sizes.lg;
font-weight: Typography.weights.bold;
vertical-alignment: center;
width: 20px;
horizontal-alignment: center;
rotation-angle: root.expanded ? 90deg : 0deg;
rotation-origin-x: self.width / 2;
rotation-origin-y: self.height / 2;
animate rotation-angle { duration: Animations.durations.normal; easing: Animations.ease-out; }
}
}
touch := TouchArea {
clicked => {
root.toggled();
}
moved => {
root.hovered = self.has-hover;
}
}
}
// Content
if root.expanded: content-wrapper := Rectangle {
background: transparent;
VerticalLayout {
padding: SpacingSystem.spacing.s4;
padding-top: 0;
padding-bottom: SpacingSystem.spacing.s4;
@children
}
}
// Border
if !root.is-last: Rectangle {
height: 1px;
background: Theme.colors.border;
}
}
}
// Wrapper for accordion content
export component AccordionContent {
VerticalLayout {
@children
}
}

View File

@@ -0,0 +1,238 @@
import { Theme, SpacingSystem, Typography, Animations } from "../theme/theme.slint";
import { Button } from "./button.slint";
import { StatusIndicator, StatusState } from "./status-indicator.slint";
export struct NavItem {
title: string,
icon: string,
}
export component AppSidebar {
in property <[NavItem]> nav-items: [];
in-out property <int> current-page: 0;
in property <StatusState> mihomo-status: StatusState.stopped;
in property <bool> mihomo-loading: false;
callback navigate(int);
callback toggle-mihomo();
width: 240px;
Rectangle {
background: Theme.colors.background;
border-width: 1px;
border-color: Theme.colors.border;
VerticalLayout {
padding: SpacingSystem.spacing.s4;
spacing: SpacingSystem.spacing.s4;
// Header
HorizontalLayout {
spacing: SpacingSystem.spacing.s3;
Rectangle {
width: 32px;
height: 32px;
border-radius: SpacingSystem.radius.md;
background: Theme.colors.primary;
Text {
text: "C";
color: Theme.colors.primary-foreground;
font-size: Typography.sizes.lg;
font-weight: Typography.weights.bold;
horizontal-alignment: center;
vertical-alignment: center;
}
}
VerticalLayout {
spacing: 2px;
Text {
text: "Clash Manager";
color: Theme.colors.foreground;
font-size: Typography.sizes.sm;
font-weight: Typography.weights.semibold;
}
Text {
text: "v1.0.0";
color: Theme.colors.muted-foreground;
font-size: Typography.sizes.xs;
}
}
}
// Navigation
VerticalLayout {
spacing: SpacingSystem.spacing.s1;
Text {
text: "NAVIGATION";
color: Theme.colors.muted-foreground;
font-size: 10px;
font-weight: Typography.weights.semibold;
}
for item[index] in root.nav-items: NavButton {
icon: item.icon;
title: item.title;
active: index == root.current-page;
clicked => {
root.navigate(index);
}
}
}
Rectangle {
vertical-stretch: 1;
}
// Mihomo Status Footer
MihomoStatusBar {
status: root.mihomo-status;
loading: root.mihomo-loading;
toggle-clicked => {
root.toggle-mihomo();
}
}
}
}
}
export component NavButton {
in property <string> icon: "";
in property <string> title: "";
in property <bool> active: false;
callback clicked();
private property <bool> hovered: false;
height: 40px;
states [
active when root.active: {
container.background: Theme.colors.primary.transparentize(0.9);
icon-text.color: Theme.colors.primary;
title-text.color: Theme.colors.foreground;
}
hovered when root.hovered && !root.active: {
container.background: Theme.colors.muted.transparentize(0.5);
}
]
container := Rectangle {
background: transparent;
border-radius: SpacingSystem.radius.md;
animate background { duration: Animations.durations.fast; }
HorizontalLayout {
padding-left: SpacingSystem.spacing.s3;
padding-right: SpacingSystem.spacing.s3;
spacing: SpacingSystem.spacing.s3;
icon-text := Text {
text: root.icon;
color: Theme.colors.muted-foreground;
font-size: Typography.sizes.lg;
vertical-alignment: center;
width: 20px;
animate color { duration: Animations.durations.fast; }
}
title-text := Text {
text: root.title;
color: Theme.colors.muted-foreground;
font-size: Typography.sizes.sm;
font-weight: Typography.weights.medium;
vertical-alignment: center;
animate color { duration: Animations.durations.fast; }
}
}
touch := TouchArea {
clicked => {
root.clicked();
}
moved => {
root.hovered = self.has-hover;
}
}
}
}
export component MihomoStatusBar {
in property <StatusState> status: StatusState.stopped;
in property <bool> loading: false;
callback toggle-clicked();
private property <string> status-text: status == StatusState.running ? "Running" :
status == StatusState.starting ? "Starting..." :
"Stopped";
private property <color> status-color: status == StatusState.running ? #22c55e :
status == StatusState.starting ? #eab308 :
#9ca3af;
Rectangle {
background: status == StatusState.starting ? #eab30820 :
status == StatusState.running ? #22c55e20 :
transparent;
border-radius: SpacingSystem.radius.md;
animate background { duration: Animations.durations.normal; }
HorizontalLayout {
padding: SpacingSystem.spacing.s3;
spacing: SpacingSystem.spacing.s3;
alignment: space-between;
HorizontalLayout {
spacing: SpacingSystem.spacing.s3;
StatusIndicator {
state: root.status;
}
VerticalLayout {
spacing: 2px;
Text {
text: "Clash";
color: root.status-color;
font-size: Typography.sizes.xs;
font-weight: Typography.weights.semibold;
}
Text {
text: root.status-text;
color: Theme.colors.muted-foreground;
font-size: 10px;
}
}
}
Button {
width: 32px;
height: 32px;
text: root.loading ? "..." : (status == StatusState.running ? "||" : ">");
variant: "ghost";
disabled: root.loading;
clicked => {
root.toggle-clicked();
}
}
}
}
}

44
ui/components/label.slint Normal file
View File

@@ -0,0 +1,44 @@
import { Theme, Typography } from "../theme/theme.slint";
export enum LabelSize {
xs,
sm,
base,
lg,
xl,
}
export enum LabelColor {
foreground,
muted,
primary,
secondary,
destructive,
}
export component Label {
in property <string> text: "";
in property <LabelSize> size: LabelSize.base;
in property <LabelColor> color-variant: LabelColor.foreground;
in property <int> font-weight: Typography.weights.normal;
private property <length> font-size: size == LabelSize.xs ? Typography.sizes.xs :
size == LabelSize.sm ? Typography.sizes.sm :
size == LabelSize.lg ? Typography.sizes.lg :
size == LabelSize.xl ? Typography.sizes.xl :
Typography.sizes.base;
private property <color> text-color: color-variant == LabelColor.muted ? Theme.colors.muted-foreground :
color-variant == LabelColor.primary ? Theme.colors.primary :
color-variant == LabelColor.secondary ? Theme.colors.secondary :
color-variant == LabelColor.destructive ? Theme.colors.destructive :
Theme.colors.foreground;
Text {
text: root.text;
color: root.text-color;
font-size: root.font-size;
font-weight: root.font-weight;
vertical-alignment: center;
}
}

View File

@@ -0,0 +1,36 @@
import { Theme, SpacingSystem, Typography, Animations } from "../theme/theme.slint";
export component Progress {
in property <float> value: 0; // 0-100
in property <bool> show-label: false;
min-height: 8px;
VerticalLayout {
spacing: SpacingSystem.spacing.s1;
// Progress bar
track := Rectangle {
height: 8px;
background: Theme.colors.primary.transparentize(0.8);
border-radius: SpacingSystem.radius.full;
indicator := Rectangle {
width: parent.width * Math.max(0, Math.min(100, root.value)) / 100;
height: parent.height;
background: Theme.colors.primary;
border-radius: SpacingSystem.radius.full;
animate width { duration: Animations.durations.normal; easing: Animations.ease-out; }
}
}
// Optional label
if root.show-label: label := Text {
text: Math.round(root.value) + "%";
color: Theme.colors.muted-foreground;
font-size: Typography.sizes.xs;
horizontal-alignment: right;
}
}
}

174
ui/components/select.slint Normal file
View File

@@ -0,0 +1,174 @@
import { Theme, SpacingSystem, Typography, Animations } from "../theme/theme.slint";
export struct SelectOption {
label: string,
value: string,
}
export component Select {
in property <[SelectOption]> options: [];
in-out property <int> selected-index: -1;
in property <string> placeholder: "Select...";
in property <bool> enabled: true;
callback selected(int, string);
private property <bool> open: false;
private property <bool> hovered: false;
min-width: 120px;
min-height: 36px;
states [
disabled when !root.enabled: {
trigger.opacity: 0.5;
}
]
VerticalLayout {
// Trigger button
trigger := Rectangle {
height: 36px;
background: Theme.colors.background;
border-radius: SpacingSystem.radius.md;
border-width: 1px;
border-color: Theme.colors.input;
drop-shadow-blur: 1px;
drop-shadow-color: #00000010;
drop-shadow-offset-y: 1px;
HorizontalLayout {
padding-left: SpacingSystem.spacing.s3;
padding-right: SpacingSystem.spacing.s3;
spacing: SpacingSystem.spacing.s2;
alignment: space-between;
value-text := Text {
text: root.selected-index >= 0 && root.selected-index < root.options.length
? root.options[root.selected-index].label
: root.placeholder;
color: root.selected-index >= 0
? Theme.colors.foreground
: Theme.colors.muted-foreground;
font-size: Typography.sizes.sm;
vertical-alignment: center;
}
chevron := Text {
text: "";
color: Theme.colors.muted-foreground;
font-size: Typography.sizes.base;
font-weight: Typography.weights.bold;
vertical-alignment: center;
width: 16px;
horizontal-alignment: center;
rotation-angle: root.open ? -90deg : 90deg;
rotation-origin-x: self.width / 2;
rotation-origin-y: self.height / 2;
animate rotation-angle { duration: Animations.durations.fast; easing: Animations.ease-out; }
}
}
touch := TouchArea {
enabled: root.enabled;
clicked => {
root.open = !root.open;
}
moved => {
root.hovered = self.has-hover;
}
}
}
// Dropdown menu
if root.open: dropdown := Rectangle {
y: trigger.height + 4px;
background: Theme.colors.background;
border-radius: SpacingSystem.radius.md;
border-width: 1px;
border-color: Theme.colors.border;
drop-shadow-blur: 8px;
drop-shadow-color: #00000020;
drop-shadow-offset-y: 4px;
VerticalLayout {
padding: SpacingSystem.spacing.s1;
for option[index] in root.options: SelectItem {
label: option.label;
selected: index == root.selected-index;
clicked => {
root.selected-index = index;
root.selected(index, option.value);
root.open = false;
}
}
}
}
}
}
component SelectItem {
in property <string> label: "";
in property <bool> selected: false;
callback clicked();
private property <bool> hovered: false;
height: 32px;
states [
selected when root.selected: {
container.background: Theme.colors.accent;
}
hovered when root.hovered && !root.selected: {
container.background: Theme.colors.accent.transparentize(0.5);
}
]
container := Rectanglen background: transparent;
border-radius: SpacingSystem.radius.sm;
animate background { duration: Animations.durations.fast; }
HorizontalLayout {
padding-left: SpacingSystem.spacing.s2;
padding-right: SpacingSystem.spacing.s2;
spacing: SpacingSystem.spacing.s2;
if root.selected: checkmark := Text {
text: "✓";
color: Theme.colors.primary;
font-size: Typography.sizes.sm;
font-weight: Typography.weights.bold;
vertical-alignment: center;
width: 16px;
}
Text {
text: root.label;
color: Theme.colors.foreground;
font-size: Typography.sizes.sm;
vertical-alignment: center;
}
}
touch := TouchArea {
clicked => {
root.clicked();
}
moved => {
root.hovered = self.has-hover;
}
}
}
}

View File

@@ -0,0 +1,20 @@
import { Theme } from "../theme/theme.slint";
export enum Orientation {
horizontal,
vertical,
}
export component Separator {
in property <Orientation> orientation: Orientation.horizontal;
if orientation == Orientation.horizontal: Rectangle {
height: 1px;
background: Theme.colors.border;
}
if orientation == Orientation.vertical: Rectangle {
width: 1px;
background: Theme.colors.border;
}
}

View File

@@ -0,0 +1,57 @@
import { Theme, Animations } from "../theme/theme.slint";
export enum StatusState {
stopped,
starting,
running,
}
export component StatusIndicator {
in property <StatusState> state: StatusState.stopped;
private property <color> dot-color: state == StatusState.running ? #22c55e :
state == StatusState.starting ? #eab308 :
#9ca3af;
private property <bool> should-animate: state == StatusState.running || state == StatusState.starting;
width: 10px;
height: 10px;
// Breathing animation ring
if root.should-animate: ping := Rectangle {
width: parent.width;
height: parent.height;
border-radius: 5px;
background: root.dot-color.transparentize(0.25);
// Animate scale and opacity for breathing effect
states [
pulse: {
ping.width: parent.width * 2;
ping.height: parent.height * 2;
ping.x: -parent.width / 2;
ping.y: -parent.height / 2;
ping.opacity: 0;
in {
animate width, height, x, y, opacity {
duration: 1500ms;
easing: cubic-bezier(0, 0, 0.2, 1);
iteration-count: -1;
}
}
}
]
}
// Main dot
dot := Rectangle {
width: parent.width;
height: parent.height;
border-radius: 5px;
background: root.dot-color;
animate background { duration: Animations.durations.normal; }
}
}

View File

@@ -0,0 +1,77 @@
import { Theme, SpacingSystem, Animations } from "../theme/theme.slint";
export component Switch {
in property <bool> checked: false;
in property <bool> enabled: true;
callback toggled(bool);
private property <bool> pressed: false;
private property <bool> hovered: false;
min-width: 32px;
min-height: 18px;
states [
disabled when !root.enabled: {
track.opacity: 0.5;
thumb.opacity: 0.5;
}
checked when root.checked: {
track.background: Theme.colors.primary;
thumb.x: root.width - thumb.width - 2px;
}
unchecked when !root.checked: {
track.background: Theme.colors.input;
thumb.x: 2px;
}
]
track := Rectangle {
width: 32px;
height: 18px;
border-radius: 9px;
background: Theme.colors.input;
border-width: 1px;
border-color: transparent;
animate background { duration: Animations.durations.fast; easing: Animations.ease-out; }
thumb := Rectangle {
width: 14px;
height: 14px;
y: (parent.height - self.height) / 2;
x: 2px;
border-radius: 7px;
background: Theme.colors.background;
drop-shadow-blur: 2px;
drop-shadow-color: #00000040;
drop-shadow-offset-y: 1px;
animate x { duration: Animations.durations.fast; easing: Animations.ease-out; }
}
}
touch := TouchArea {
enabled: root.enabled;
clicked => {
if (root.enabled) {
root.toggled(!root.checked);
}
}
pointer-event(event) => {
if (event.kind == PointerEventKind.down) {
root.pressed = true;
} else if (event.kind == PointerEventKind.up || event.kind == PointerEventKind.cancel) {
root.pressed = false;
}
}
moved => {
root.hovered = self.has-hover;
}
}
}

125
ui/components/tabs.slint Normal file
View File

@@ -0,0 +1,125 @@
import { Theme, SpacingSystem, Typography, Animations } from "../theme/theme.slint";
export struct TabItem {
title: string,
}
export component Tabs {
in property <[TabItem]> tabs: [];
in-out property <int> current-index: 0;
callback tab-changed(int);
min-height: 200px;
VerticalLayout {
spacing: SpacingSystem.spacing.s2;
// Tab List
tab-list := HorizontalLayout {
height: 36px;
spacing: 0;
Rectangle {
background: Theme.colors.muted;
border-radius: SpacingSystem.radius.lg;
padding: 3px;
HorizontalLayout {
spacing: 0;
padding: 0;
for tab[index] in root.tabs: TabTrigger {
text: tab.title;
active: index == root.current-index;
clicked => {
root.current-index = index;
root.tab-changed(index);
}
}
}
}
}
// Tab Content Area
content-area := Rectangle {
// Content will be provided by @children in parent component
@children
}
}
}
component TabTrigger {
in property <string> text: "";
in property <bool> active: false;
callback clicked();
private property <bool> hovered: false;
min-width: 60px;
height: 30px;
states [
active when root.active: {
container.background: Theme.colors.background;
label.color: Theme.colors.foreground;
container.border-color: Theme.colors.input;
}
inactive when !root.active: {
container.background: transparent;
label.color: Theme.colors.muted-foreground;
container.border-color: transparent;
}
hovered when root.hovered && !root.active: {
label.color: Theme.colors.foreground;
}
]
container := Rectangle {
border-radius: SpacingSystem.radius.md;
background: transparent;
border-width: 1px;
border-color: transparent;
drop-shadow-blur: root.active ? 2px : 0;
drop-shadow-color: root.active ? #00000010 : transparent;
drop-shadow-offset-y: root.active ? 1px : 0;
animate background { duration: Animations.durations.fast; }
animate border-color { duration: Animations.durations.fast; }
label := Text {
text: root.text;
color: Theme.colors.muted-foreground;
font-size: Typography.sizes.sm;
font-weight: Typography.weights.medium;
horizontal-alignment: center;
vertical-alignment: center;
animate color { duration: Animations.durations.fast; }
}
}
touch := TouchArea {
clicked => {
root.clicked();
}
moved => {
root.hovered = self.has-hover;
}
}
}
// TabContent component for wrapping content
export component TabContent {
in property <int> index: 0;
in property <int> current-index: 0;
visible: root.index == root.current-index;
VerticalLayout {
@children
}
}

View File

@@ -0,0 +1,42 @@
import { Theme, SpacingSystem, Typography, Animations } from "../theme/theme.slint";
export component Tooltip {
in property <string> text: "";
in property <bool> show: false;
if root.show: popup := Rectangle {
y: -self.height - 8px;
x: (parent.width - self.width) / 2;
width: tooltip-text.width + SpacingSystem.spacing.s3 * 2;
height: tooltip-text.height + SpacingSystem.spacing.s2 * 2;
background: Theme.colors.foreground;
border-radius: SpacingSystem.radius.sm;
drop-shadow-blur: 8px;
drop-shadow-color: #00000030;
drop-shadow-offset-y: 2px;
opacity: root.show ? 1 : 0;
animate opacity { duration: Animations.durations.fast; }
tooltip-text := Text {
text: root.text;
color: Theme.colors.background;
font-size: Typography.sizes.xs;
horizontal-alignment: center;
vertical-alignment: center;
x: SpacingSystem.spacing.s3;
y: SpacingSystem.spacing.s2;
}
// Arrow
arrow := Path {
y: parent.height;
x: (parent.width - 8px) / 2;
width: 8px;
height: 4px;
fill: Theme.colors.foreground;
commands: "M 0 0 L 4 4 L 8 0 Z";
}
}
}