Unlucky Launcher v0.2.0
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

首个 iced + Material 3 Expressive 版本的源码快照。
完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
AskaEth committed 2026-11-05 14:22:11 +08:00
commit 8a8e5ca0c0
1079 files changed
+208035

No files matched your search

+283
View File
@@ -0,0 +1,283 @@
// 右侧 AI 常驻栏(D16 §1):默认 320px 可折叠;可「展开为页面」变成全宽独立页,
// 两形态共享同一份会话模型(由 Rust 侧 AiSession 持有,这里只渲染,切换不丢状态)。
// 栏顶承载 YOLO 快捷开关(D4 补充)与端点标签。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { UiChatMessage, ChatRole } from "../models.slint";
import { UiText, TextRole } from "label.slint";
import { AppIcon, IconKind } from "icons.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint";
import { PlainInput } from "../widgets/inputs.slint";
import { GlassBase } from "glass.slint";
import { ListView } from "std-widgets.slint";
// 一条会话消息:user 靠右(accent-soft 块)/ assistant 靠左 / tool 用状态边框 / system 居中弱化
component ChatBubble inherits Rectangle {
in property <UiChatMessage> message;
property <bool> is-tool: message.role == ChatRole.tool;
property <bool> is-system: message.role == ChatRole.system;
property <length> text-width: root.width - 2 * Metrics.gap-2;
property <length> tool-row: is-tool ? 16px : 0px;
height: text-node.preferred-height + 2 * Metrics.gap-2 + tool-row;
background: message.role == ChatRole.user
? Theme.accent-soft
: is-tool ? Theme.bg-canvas
: is-system ? transparent
: Theme.bg-elevated;
border-radius: Metrics.radius-md;
border-width: is-tool ? 1px : 0px;
border-color: message.tool_ok ? Theme.state-success : Theme.state-danger;
// 工具调用可视化(D4 要求:工具执行在会话里可见)
if is-tool : AppIcon {
x: Metrics.gap-2;
y: Metrics.gap-2 + 1px;
icon-size: 12px;
kind: message.tool_ok ? IconKind.check : IconKind.error;
tint: message.tool_ok ? Theme.state-success : Theme.state-danger;
}
if is-tool : UiText {
x: Metrics.gap-2 + 16px;
y: Metrics.gap-2;
width: text-width - 16px;
height: 14px;
role: TextRole.caption;
text: "工具 · " + message.tool_name;
color: message.tool_ok ? Theme.state-success : Theme.state-danger;
}
text-node := UiText {
x: Metrics.gap-2;
y: Metrics.gap-2 + tool-row;
width: text-width;
height: text-node.preferred-height;
role: TextRole.body;
text: message.text;
wrap: word-wrap;
overflow: elide;
vertical-alignment: top;
horizontal-alignment: message.role == ChatRole.user ? right : left;
color: is-system ? Theme.fg-secondary : Theme.fg-primary;
}
// 流式未收敛:末尾的 accent 光标块
if message.streaming : Rectangle {
x: 0;
y: root.height - 10px;
width: 8px;
height: 10px;
background: Theme.accent;
}
}
export component AiPanel inherits Rectangle {
in property <[UiChatMessage]> messages;
in-out property <bool> collapsed: false;
/// true = 停在右侧栏形态;false = 已拆成独立全宽页
in property <bool> docked: true;
in property <bool> yolo: false;
in property <bool> streaming: false;
in property <string> endpoint-label: "llama.cpp · 本地";
in-out property <string> draft: "";
callback toggle-collapsed();
callback expand();
callback dock();
callback toggle-yolo();
callback send(string);
callback stop();
// 栏形态用 min/max 钉死宽度(布局里等价于固定宽度),页形态交给父容器铺满
min-width: docked ? (collapsed ? Metrics.ai-rail-width : Metrics.ai-panel-width) : 0px;
max-width: docked ? (collapsed ? Metrics.ai-rail-width : Metrics.ai-panel-width) : 100000px;
horizontal-stretch: docked ? 0 : 1;
height: 100%;
background: Theme.bg-surface;
// 玻璃拟态底(D17 高级特性;关闭态不画东西)——排在内容之前,压在最底层
GlassBase { card-radius: 0px; }
// 左边界线
Rectangle {
x: 0;
y: 0;
width: 1px;
height: root.height;
background: Theme.stroke;
}
// ── 折叠态:icon 轨 ─────────────────────────────────────────
if docked && collapsed : VerticalLayout {
x: 1px;
width: root.width - 1px;
height: root.height;
padding-top: Metrics.gap-2;
padding-bottom: Metrics.gap-2;
spacing: Metrics.gap-2;
AppIcon {
kind: IconKind.ai;
icon-size: 18px;
tint: Theme.accent;
TouchArea {
mouse-cursor: pointer;
clicked => { root.toggle-collapsed(); }
}
}
AppIcon {
kind: IconKind.expand;
icon-size: 14px;
tint: Theme.fg-secondary;
TouchArea {
mouse-cursor: pointer;
clicked => { root.expand(); }
}
}
Rectangle { vertical-stretch: 1; background: transparent; }
// YOLO 指示点:开启时实心 accent
Rectangle {
width: 6px;
height: 6px;
border-radius: 3px;
background: root.yolo ? Theme.accent : Theme.stroke-strong;
}
}
// ── 展开态:栏内 / 独立页共用 ────────────────────────────────
if !(docked && collapsed) : VerticalLayout {
x: 1px;
width: root.width - 1px;
height: root.height;
// 头部
Rectangle {
height: 52px;
background: transparent;
HorizontalLayout {
padding-left: Metrics.gap-3;
padding-right: Metrics.gap-2;
spacing: Metrics.gap-2;
AppIcon { kind: IconKind.ai; icon-size: 16px; tint: Theme.accent; }
VerticalLayout {
spacing: 0px;
vertical-stretch: 1;
UiText {
role: TextRole.bodyStrong;
text: "AI 助手";
horizontal-stretch: 1;
}
UiText {
role: TextRole.caption;
text: root.endpoint-label;
horizontal-stretch: 1;
}
}
MetroButton {
kind: root.yolo ? ButtonKind.accent : ButtonKind.subtle;
text: "YOLO";
button-height: 24px;
clicked => { root.toggle-yolo(); }
}
if root.docked : MetroButton {
kind: ButtonKind.subtle;
icon: IconKind.expand;
icon-only: true;
button-height: 24px;
clicked => { root.expand(); }
}
if !root.docked : MetroButton {
kind: ButtonKind.subtle;
icon: IconKind.sidebar;
icon-only: true;
button-height: 24px;
clicked => { root.dock(); }
}
if root.docked : MetroButton {
kind: ButtonKind.subtle;
icon: IconKind.chevronRight;
icon-only: true;
button-height: 24px;
clicked => { root.toggle-collapsed(); }
}
}
}
Rectangle { height: 1px; background: Theme.stroke; }
// 对话流(ListView 行虚拟化,research/01 已确认)
ListView {
vertical-stretch: 1;
for m in root.messages : ChatBubble {
message: m;
width: root.width - 2 * Metrics.gap-3;
}
}
if root.messages.length == 0 : UiText {
role: TextRole.caption;
text: "问点什么:装哪个版本、崩溃怎么修、帮我改设置…";
horizontal-alignment: center;
height: 40px;
}
Rectangle { height: 1px; background: Theme.stroke; }
// 输入区
Rectangle {
height: 56px;
background: transparent;
HorizontalLayout {
padding: Metrics.gap-2;
spacing: Metrics.gap-2;
input-box := Rectangle {
horizontal-stretch: 1;
vertical-stretch: 1;
background: Theme.bg-input;
border-radius: Metrics.radius-sm;
border-width: 1px;
border-color: input-field.focused ? Theme.accent : Theme.stroke;
input-field := PlainInput {
text-padding: Metrics.gap-2;
placeholder: "输入消息,Enter 发送";
text <=> root.draft;
accepted(t) => {
if (t != "") {
root.send(t);
}
}
}
}
MetroButton {
kind: ButtonKind.accent;
icon: root.streaming ? IconKind.stop : IconKind.send;
icon-only: true;
clicked => {
if (root.streaming) {
root.stop();
} else if (root.draft != "") {
root.send(root.draft);
}
}
}
}
}
}
}
+27
View File
@@ -0,0 +1,27 @@
// 徽标:MC 版本 / 加载器 / 已装 / 远古版 等短标记。Metro 做法是细边框小方块,不用药丸圆角。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { UiText, TextRole } from "../components/label.slint";
export component Badge inherits Rectangle {
in property <string> text: "";
in property <color> tint: Theme.fg-secondary;
in property <color> fill: transparent;
height: 18px;
width: label.preferred-width + 2 * Metrics.gap-1 + 2px;
background: fill;
border-width: 1px;
border-color: tint;
border-radius: Metrics.radius-sm;
label := UiText {
role: TextRole.caption;
text: root.text;
color: root.tint;
font-size: 11px;
horizontal-alignment: center;
x: Metrics.gap-1 + 1px;
width: label.preferred-width;
}
}
+59
View File
@@ -0,0 +1,59 @@
// 玻璃拟态卡片的公共底座(09 §103:Slint 没有 backdrop-filter,用「预生成模糊背景层」替代)。
//
// 手法:Rust 侧把背景图降采样成一张极小的图(`Theme.bg-blur`),渲染时被平滑放大 → 天然模糊。
// 卡片里把它按 `absolute-position` **反向偏移**画出来,于是卡片显示的是「自己这一块屏幕区域的
// 模糊影像」,视觉上就是毛玻璃;再叠一层半透明面板色保证文字可读(对比度优先)。
//
// 关闭态(`Theme.glass == false` 或无背景图)不画任何东西:卡片保持 Metro 纯色观感。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
export component GlassBase inherits Rectangle {
/// 卡片圆角(跟随宿主卡片)
in property <length> card-radius: Metrics.radius-md;
/// 叠在模糊层上的半透明面板色(保证前景对比度)
in property <color> tint: Theme.surface-solid;
out property <bool> active: Theme.glass && Theme.bg-blur.width > 0 && Theme.bg-image.width > 0;
x: 0;
y: 0;
width: 100%;
height: 100%;
border-radius: root.card-radius;
clip: true;
background: transparent;
if root.active : Image {
// 整窗模糊层 + 反向偏移 = 只露出「卡片所在那一块」。
// 必须用 `parent`(本组件自身)的绝对位置:用 `self` 会与自身 x 形成绑定环
// (absolute-position 依赖 x,x 又依赖 absolute-position)。
source: Theme.bg-blur;
x: -parent.absolute-position.x;
y: -parent.absolute-position.y;
width: Theme.window-width;
height: Theme.window-height;
image-fit: cover;
}
if root.active : Rectangle {
// 暗化层要**重画一次**:模糊层在卡片内部、盖在窗口的暗化层之上,
// 不补这一层的话玻璃卡片会比周围亮一大截(05 截图验收时的真实问题)。
background: rgba(0, 0, 0, Theme.bg-dim);
border-radius: root.card-radius;
}
if root.active : Rectangle {
// 玻璃染色:面板底色 55% 不透明,字压得住背景(对比度优先于「更透」)
background: root.tint.with-alpha(0.55);
border-radius: root.card-radius;
}
if root.active : Rectangle {
// 上缘一道细高光,玻璃的「厚度感」
y: 0;
height: 1px;
width: root.width;
background: Theme.stroke.with-alpha(0.35);
}
}
+192
View File
@@ -0,0 +1,192 @@
// 图标:18×18 viewbox 的矢量绘制(Metro 要求线性几何图标,不用 emoji/位图)。
// 只用 M/L/Q/Z 命令 + 直线,避免依赖字体字形;描边图标 stroke,实心图标 fill。
import { Theme } from "../theme/tokens.slint";
export enum IconKind {
instances,
download,
logs,
settings,
ai,
play,
stop,
search,
plus,
close,
minimize,
maximize,
chevronLeft,
chevronRight,
chevronDown,
expand,
send,
check,
warn,
error,
more,
sidebar,
restore,
}
export component AppIcon inherits Rectangle {
in property <IconKind> kind: IconKind.instances;
in property <color> tint: Theme.fg-secondary;
in property <length> icon-size: 18px;
width: icon-size;
height: icon-size;
background: transparent;
if kind == IconKind.instances : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 2 2 L 8 2 L 8 8 L 2 8 Z M 10 2 L 16 2 L 16 8 L 10 8 Z M 2 10 L 8 10 L 8 16 L 2 16 Z M 10 10 L 16 10 L 16 16 L 10 16 Z";
fill: transparent; stroke: root.tint; stroke-width: 1.4px;
}
if kind == IconKind.download : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 9 2 L 9 11 M 4 7 L 9 12 L 14 7 M 3 15 L 15 15";
fill: transparent; stroke: root.tint; stroke-width: 1.6px;
}
if kind == IconKind.logs : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 3 4 L 15 4 M 3 9 L 15 9 M 3 14 L 11 14";
fill: transparent; stroke: root.tint; stroke-width: 1.6px;
}
if kind == IconKind.settings : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 3 5 L 15 5 M 3 9 L 15 9 M 3 13 L 15 13";
fill: transparent; stroke: root.tint; stroke-width: 1.4px;
}
if kind == IconKind.settings : Rectangle {
x: 5px;
width: 5px; height: 5px; y: 3px;
border-radius: 2.5px; background: root.tint;
}
if kind == IconKind.settings : Rectangle {
x: 10px; y: 7px; width: 5px; height: 5px;
border-radius: 2.5px; background: root.tint;
}
if kind == IconKind.settings : Rectangle {
x: 3px; y: 11px; width: 5px; height: 5px;
border-radius: 2.5px; background: root.tint;
}
if kind == IconKind.ai : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 9 1.5 L 10.7 7.3 L 16.5 9 L 10.7 10.7 L 9 16.5 L 7.3 10.7 L 1.5 9 L 7.3 7.3 Z";
fill: root.tint; stroke: transparent; stroke-width: 1px;
}
if kind == IconKind.play : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 5 3 L 15 9 L 5 15 Z";
fill: root.tint; stroke: transparent; stroke-width: 1px;
}
if kind == IconKind.stop : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 5 5 L 13 5 L 13 13 L 5 13 Z";
fill: root.tint; stroke: transparent; stroke-width: 1px;
}
if kind == IconKind.search : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 8 3 Q 13 3 13 8 Q 13 13 8 13 Q 3 13 3 8 Q 3 3 8 3 Z M 11.5 11.5 L 16 16";
fill: transparent; stroke: root.tint; stroke-width: 1.5px;
}
if kind == IconKind.plus : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 9 3 L 9 15 M 3 9 L 15 9";
fill: transparent; stroke: root.tint; stroke-width: 1.8px;
}
if kind == IconKind.close : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 4 4 L 14 14 M 14 4 L 4 14";
fill: transparent; stroke: root.tint; stroke-width: 1.4px;
}
if kind == IconKind.minimize : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 4 9 L 14 9";
fill: transparent; stroke: root.tint; stroke-width: 1.4px;
}
if kind == IconKind.maximize : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 4.5 4.5 L 13.5 4.5 L 13.5 13.5 L 4.5 13.5 Z";
fill: transparent; stroke: root.tint; stroke-width: 1.2px;
}
if kind == IconKind.chevronLeft : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 11 3 L 6 9 L 11 15";
fill: transparent; stroke: root.tint; stroke-width: 1.6px;
}
if kind == IconKind.chevronRight : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 7 3 L 12 9 L 7 15";
fill: transparent; stroke: root.tint; stroke-width: 1.6px;
}
if kind == IconKind.chevronDown : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 3 7 L 9 13 L 15 7";
fill: transparent; stroke: root.tint; stroke-width: 1.6px;
}
if kind == IconKind.expand : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 3 7 L 3 3 L 7 3 M 11 3 L 15 3 L 15 7 M 15 11 L 15 15 L 11 15 M 7 15 L 3 15 L 3 11";
fill: transparent; stroke: root.tint; stroke-width: 1.4px;
}
if kind == IconKind.send : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 2 9 L 16 9 M 11 4 L 16 9 L 11 14";
fill: transparent; stroke: root.tint; stroke-width: 1.6px;
}
if kind == IconKind.check : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 3 9 L 7 13 L 15 4";
fill: transparent; stroke: root.tint; stroke-width: 1.8px;
}
if kind == IconKind.warn : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 9 2 L 16 15 L 2 15 Z M 9 6.5 L 9 10.5";
fill: transparent; stroke: root.tint; stroke-width: 1.4px;
}
if kind == IconKind.error : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 9 2 Q 16 2 16 9 Q 16 16 9 16 Q 2 16 2 9 Q 2 2 9 2 Z M 9 5 L 9 10";
fill: transparent; stroke: root.tint; stroke-width: 1.5px;
}
if kind == IconKind.more : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 4 7 L 6 7 L 6 9 L 4 9 Z M 8 7 L 10 7 L 10 9 L 8 9 Z M 12 7 L 14 7 L 14 9 L 12 9 Z";
fill: root.tint; stroke: transparent; stroke-width: 1px;
}
if kind == IconKind.sidebar : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 3 4 L 15 4 L 15 14 L 3 14 Z M 7 4 L 7 14";
fill: transparent; stroke: root.tint; stroke-width: 1.4px;
}
// 还原(已最大化时的标题栏按钮):两层叠放方框
if kind == IconKind.restore : Path {
width: root.width; height: root.height;
viewbox-width: 18; viewbox-height: 18;
commands: "M 6 3 L 15 3 L 15 12 M 3 6 L 12 6 L 12 15 L 3 15 Z";
fill: transparent; stroke: root.tint; stroke-width: 1.2px;
}
}
@@ -0,0 +1,73 @@
// 安装进度卡片(下载页底部 / 向导进度步共用):阶段 + 进度条 + 文件/字节/速度/剩余 + 取消。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { UiInstallProgress, InstallStage } from "../models.slint";
import { UiText, TextRole } from "../components/label.slint";
import { ProgressBar } from "../components/progress-bar.slint";
import { AppIcon, IconKind } from "../components/icons.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint";
export component InstallPanel inherits Rectangle {
in property <UiInstallProgress> data;
callback cancel();
height: 96px;
visible: root.data.visible;
background: Theme.bg-surface;
border-width: 1px;
border-color: Theme.stroke;
VerticalLayout {
padding: Metrics.gap-3;
spacing: Metrics.gap-1;
HorizontalLayout {
spacing: Metrics.gap-2;
AppIcon {
icon-size: 16px;
kind: root.data.failed ? IconKind.error
: root.data.terminal ? IconKind.check
: IconKind.download;
tint: root.data.failed ? Theme.state-danger
: root.data.terminal ? Theme.state-success
: Theme.accent;
}
UiText {
role: TextRole.bodyStrong;
text: root.data.instance + " · " + root.data.loader;
horizontal-stretch: 1;
}
UiText {
role: TextRole.caption;
text: root.data.stage_label;
color: root.data.failed ? Theme.state-danger : Theme.fg-secondary;
}
if !root.data.terminal : MetroButton {
kind: ButtonKind.subtle;
text: "取消";
button-height: 24px;
clicked => { root.cancel(); }
}
}
ProgressBar {
ratio: root.data.ratio;
known: root.data.known;
}
HorizontalLayout {
spacing: Metrics.gap-3;
UiText {
role: TextRole.caption;
text: root.data.message;
horizontal-stretch: 1;
overflow: elide;
}
UiText { role: TextRole.caption; text: root.data.files_label; }
UiText { role: TextRole.caption; text: root.data.bytes_label; }
UiText { role: TextRole.caption; text: root.data.speed; color: Theme.accent; }
UiText { role: TextRole.caption; text: root.data.eta; }
}
}
}
+34
View File
@@ -0,0 +1,34 @@
// 排版角色封装:所有文字都走 UiText,不在页面里散落 font-size/font-family 字面量。
// 角色 → 字号/字重/默认前景色由 tokens.slint 的 ramp 决定;外部赋值可覆盖(例如强调色标题)。
import { Theme } from "../theme/tokens.slint";
export enum TextRole {
display,
title,
subtitle,
body,
bodyStrong,
caption,
mono,
monoSmall,
}
export component UiText inherits Text {
in property <TextRole> role: TextRole.body;
color: role == TextRole.caption ? Theme.fg-secondary : Theme.fg-primary;
font-family: (role == TextRole.mono || role == TextRole.monoSmall)
? Theme.font-family-mono
: Theme.font-family;
font-size: (role == TextRole.display) ? Theme.font-size-display
: (role == TextRole.title) ? Theme.font-size-title
: (role == TextRole.subtitle) ? Theme.font-size-subtitle
: (role == TextRole.caption || role == TextRole.monoSmall) ? Theme.font-size-caption
: Theme.font-size-body;
font-weight: (role == TextRole.bodyStrong || role == TextRole.title || role == TextRole.display)
? 600
: 400;
vertical-alignment: center;
wrap: no-wrap;
overflow: elide;
}
+182
View File
@@ -0,0 +1,182 @@
// 左侧主导航(D16 §1):图标+文字,选中项 accent 色块;可折叠成纯图标窄条(偏好记忆)。
// 折叠宽度/展开宽度取 Metrics,动画时长取 Theme.motion-normal。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { RouteKind } from "../models.slint";
import { UiText, TextRole } from "label.slint";
import { AppIcon, IconKind } from "icons.slint";
component NavItem inherits Rectangle {
in property <IconKind> icon: IconKind.instances;
in property <string> label: "";
in property <bool> active: false;
in property <bool> collapsed: false;
callback clicked();
height: root.collapsed ? 48px : 64px;
background: root.active
? Theme.accent-soft
: touch.has-hover ? Theme.bg-hover : transparent;
animate background { duration: Theme.motion-fast; }
// 选中态:左侧 accent 竖条(Metro 侧栏语义)
Rectangle {
visible: root.active;
x: 0;
y: 0;
width: 3px;
height: root.height;
background: Theme.accent;
}
AppIcon {
kind: root.icon;
icon-size: 20px;
x: (root.width - self.width) / 2;
y: root.collapsed ? (root.height - self.height) / 2 : 12px;
tint: root.active ? Theme.accent : Theme.fg-secondary;
}
UiText {
visible: !root.collapsed;
role: TextRole.caption;
text: root.label;
color: root.active ? Theme.accent : Theme.fg-secondary;
horizontal-alignment: center;
x: 0;
y: 38px;
width: root.width;
}
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
accessible-role: button;
accessible-label: root.label;
}
export component Nav inherits Rectangle {
in property <RouteKind> active: RouteKind.instances;
in-out property <bool> collapsed: false;
in property <string> account-name: "未登录";
in property <color> account-hue: #5C2D91;
in property <string> account-initial: "?";
callback navigate(RouteKind);
callback toggle-collapsed();
callback account-clicked();
width: collapsed ? Metrics.nav-collapsed-width : Metrics.nav-expanded-width;
height: 100%;
background: Theme.bg-surface;
animate width { duration: Theme.motion-normal; easing: ease-in-out; }
// 右边界线
Rectangle {
x: root.width - 1px;
y: 0;
width: 1px;
height: root.height;
background: Theme.stroke;
}
VerticalLayout {
x: 0;
y: Metrics.gap-2;
width: root.width;
height: root.height - Metrics.gap-2 - Metrics.gap-2;
NavItem {
icon: IconKind.instances;
label: "实例";
active: root.active == RouteKind.instances || root.active == RouteKind.detail;
collapsed: root.collapsed;
clicked => { root.navigate(RouteKind.instances); }
}
NavItem {
icon: IconKind.download;
label: "下载";
active: root.active == RouteKind.download;
collapsed: root.collapsed;
clicked => { root.navigate(RouteKind.download); }
}
NavItem {
icon: IconKind.logs;
label: "日志";
active: root.active == RouteKind.logs;
collapsed: root.collapsed;
clicked => { root.navigate(RouteKind.logs); }
}
NavItem {
icon: IconKind.settings;
label: "设置";
active: root.active == RouteKind.settings;
collapsed: root.collapsed;
clicked => { root.navigate(RouteKind.settings); }
}
Rectangle { vertical-stretch: 1; background: transparent; }
// 底部:账户头像(点击进设置·账户)
Rectangle {
height: 56px;
background: account-touch.has-hover ? Theme.bg-hover : transparent;
Rectangle {
width: 28px;
height: 28px;
border-radius: 14px;
background: root.account-hue;
x: (root.width - self.width) / 2;
y: 8px;
UiText {
text: root.account-initial;
font-size: 12px;
font-weight: 600;
color: Theme.fg-on-accent;
horizontal-alignment: center;
vertical-alignment: center;
}
}
UiText {
visible: !root.collapsed;
role: TextRole.caption;
text: root.account-name;
horizontal-alignment: center;
x: 0;
y: 38px;
width: root.width;
}
account-touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.account-clicked(); }
}
}
// 折叠开关(偏好由 Rust 侧持久化到 ui.nav.collapsed)
Rectangle {
height: 40px;
background: toggle-touch.has-hover ? Theme.bg-hover : transparent;
AppIcon {
kind: root.collapsed ? IconKind.chevronRight : IconKind.chevronLeft;
icon-size: 16px;
x: (root.width - self.width) / 2;
y: (root.height - self.height) / 2;
tint: Theme.fg-secondary;
}
toggle-touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.toggle-collapsed(); }
}
accessible-role: button;
accessible-label: root.collapsed ? "展开导航栏" : "折叠导航栏";
}
}
}
+57
View File
@@ -0,0 +1,57 @@
// 横幅通知条:隔离档位切换提示(M30 ModeChangeWarning)/ 启动失败 / 启动告警。
// 与 Toast 的分工:Toast 是「刚发生了什么」,通知条是「现在需要你处理」(用户点了才收)。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { UiNotice, NoticeKind } from "../models.slint";
import { PageState } from "../page_state.slint";
import { UiText, TextRole } from "../components/label.slint";
import { AppIcon, IconKind } from "../components/icons.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint";
// 横幅通知:隔离档位切换提示(M30 ModeChangeWarning)/ 启动失败 / 启动告警。
// 与 Toast 的分工:Toast 是「刚发生了什么」,通知条是「现在需要你处理」。
export component NoticeBar inherits Rectangle {
in property <[UiNotice]> notices;
visible: root.notices.length > 0;
height: root.notices.length > 0 ? 48px : 0px;
background: Theme.state-warn-soft;
HorizontalLayout {
padding-left: Metrics.gap-4;
padding-right: Metrics.gap-3;
spacing: Metrics.gap-2;
for notice in root.notices : Rectangle {
horizontal-stretch: 1;
background: transparent;
HorizontalLayout {
spacing: Metrics.gap-2;
AppIcon {
icon-size: 16px;
kind: notice.kind == NoticeKind.error ? IconKind.error : IconKind.warn;
tint: notice.kind == NoticeKind.error ? Theme.state-danger : Theme.state-warn;
}
VerticalLayout {
vertical-stretch: 1;
spacing: 0px;
UiText { role: TextRole.caption; text: notice.title; horizontal-stretch: 1; }
UiText {
role: TextRole.caption;
text: notice.body;
horizontal-stretch: 1;
overflow: elide;
color: Theme.fg-secondary;
}
}
MetroButton {
kind: ButtonKind.subtle;
text: "知道了";
button-height: 22px;
clicked => { PageState.notice-dismiss(notice.id); }
}
}
}
}
}
@@ -0,0 +1,34 @@
// 细进度条(0.0~1.0):安装进度 / 下载页顶部 / 向导进度共用。
// 未知总量(known = false)时画一条不确定态的弱色轨道,而不是假装 0%。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
export component ProgressBar inherits Rectangle {
in property <float> ratio: 0.0;
in property <bool> known: true;
in property <length> bar-height: 6px;
height: root.bar-height;
border-radius: root.bar-height / 2;
background: Theme.bg-hover;
clip: true;
Rectangle {
x: 0;
y: 0;
width: root.known ? max(0px, root.width * clamp(root.ratio, 0.0, 1.0)) : 0px;
height: root.height;
border-radius: root.bar-height / 2;
background: Theme.accent;
animate width { duration: 180ms; easing: ease-out; }
}
if !root.known : Rectangle {
x: 0;
y: 0;
width: root.width / 3;
height: root.height;
border-radius: root.bar-height / 2;
background: Theme.accent-soft;
}
}
@@ -0,0 +1,22 @@
// 启动进度环:磁贴「启动中」状态用(下载/校验/启动三个阶段共用一个环)。
// 用 std-widgets 的 ProgressIndicator:progress <= 0 时转为不确定态(转圈)。
// P1 若要完全贴合 Metro 强调色,需自绘弧线(Slint 无 arc 命令,得用旋转遮罩法)。
import { Theme } from "../theme/tokens.slint";
import { ProgressIndicator } from "std-widgets.slint";
export component ProgressRing inherits Rectangle {
/// 0.0~1.0;<= 0 表示未知进度
in property <float> progress: 0;
in property <length> ring-size: 28px;
width: ring-size;
height: ring-size;
background: transparent;
ProgressIndicator {
width: root.ring-size;
height: root.ring-size;
progress: root.progress;
indeterminate: root.progress <= 0;
}
}
+198
View File
@@ -0,0 +1,198 @@
// 实例磁贴(G1):图标 / 名字 / MC 版本徽标 / 加载器徽标 / 最后游玩 / 大启动键 + 设置键。
// 三档尺寸由 Metrics.tile-* 决定(小磁贴藏徽标与时间,只留名字+启动);
// 启动中把底部按钮行替换成「进度环 + 阶段文案 + 停止」。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { UiTile, RuntimePhase, LoaderKind } from "../models.slint";
import { UiText, TextRole } from "label.slint";
import { AppIcon, IconKind } from "icons.slint";
import { Badge } from "badge.slint";
import { ProgressRing } from "progress-ring.slint";
import { GlassBase } from "glass.slint";
import { MetroButton, ButtonKind } from "../widgets/button.slint";
component TileIcon inherits Rectangle {
in property <UiTile> data;
in property <length> box-size: 44px;
width: box-size;
height: box-size;
border-radius: Metrics.radius-md;
background: root.data.hue;
if root.data.icon.width > 0 : Image {
x: 0;
y: 0;
width: root.width;
height: root.height;
source: root.data.icon;
image-fit: cover;
}
if root.data.icon.width <= 0 : UiText {
text: root.data.initial;
font-size: 18px;
font-weight: 600;
color: Theme.fg-on-accent;
horizontal-alignment: center;
vertical-alignment: center;
}
}
export component InstanceTile inherits Rectangle {
in property <UiTile> data;
in property <bool> compact: false;
callback open();
callback launch();
callback configure();
/// 启动中/运行中的停止键(dispatch StopInstance)
callback stop();
height: Metrics.tile-height;
background: touch.has-hover ? Theme.bg-elevated : Theme.bg-surface;
border-radius: Metrics.radius-md;
border-width: 1px;
border-color: root.data.phase == RuntimePhase.running ? Theme.accent : Theme.stroke;
drop-shadow-blur: touch.has-hover ? 12px : 0px;
drop-shadow-color: Theme.shadow;
drop-shadow-offset-y: 2px;
animate background { duration: Theme.motion-fast; }
animate drop-shadow-blur { duration: Theme.motion-normal; }
// 玻璃拟态底(关闭态什么都不画,卡片就是 Metro 纯色);必须排在最前,否则盖住 accent 细线
GlassBase {
card-radius: root.border-radius;
}
// 运行中:顶部 accent 细线(Metro 色块语义)
Rectangle {
visible: root.data.phase == RuntimePhase.running;
x: 0;
y: 0;
width: root.width;
height: 2px;
background: Theme.accent;
}
// 磁贴主体点击 → 实例详情(G4);按钮的 TouchArea 后声明,命中优先
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.open(); }
}
VerticalLayout {
padding: Metrics.gap-3;
spacing: Metrics.gap-2;
HorizontalLayout {
spacing: Metrics.gap-2;
TileIcon {
data: root.data;
box-size: Metrics.tile-icon-size;
}
VerticalLayout {
spacing: 2px;
vertical-stretch: 1;
UiText {
role: TextRole.bodyStrong;
text: root.data.name;
horizontal-stretch: 1;
}
UiText {
role: TextRole.caption;
text: root.data.last_played;
visible: !root.compact;
}
}
}
HorizontalLayout {
visible: !root.compact;
spacing: Metrics.gap-1;
Badge {
text: root.data.mc_version;
tint: Theme.fg-secondary;
}
Badge {
text: root.data.loader_label;
tint: root.data.loader == LoaderKind.vanilla ? Theme.fg-secondary : Theme.accent;
}
}
Rectangle { vertical-stretch: 1; background: transparent; }
// 空闲/失败:大启动键 + 设置键
HorizontalLayout {
visible: root.data.phase == RuntimePhase.idle || root.data.phase == RuntimePhase.failed;
spacing: Metrics.gap-2;
MetroButton {
kind: ButtonKind.accent;
text: root.data.phase == RuntimePhase.failed ? "重试" : "启动";
show-icon: true;
icon: IconKind.play;
grow: true;
clicked => { root.launch(); }
}
MetroButton {
kind: ButtonKind.subtle;
icon: IconKind.settings;
icon-only: true;
clicked => { root.configure(); }
}
}
// 启动中:进度环 + 阶段文案 + 停止
HorizontalLayout {
visible: root.data.phase == RuntimePhase.downloading
|| root.data.phase == RuntimePhase.verifying
|| root.data.phase == RuntimePhase.launching;
spacing: Metrics.gap-2;
ProgressRing {
progress: root.data.progress;
ring-size: 22px;
}
UiText {
role: TextRole.caption;
text: root.data.phase_label;
horizontal-stretch: 1;
}
MetroButton {
kind: ButtonKind.subtle;
icon: IconKind.stop;
icon-only: true;
clicked => { root.stop(); }
}
}
// 运行中:状态点 + 停止
HorizontalLayout {
visible: root.data.phase == RuntimePhase.running;
spacing: Metrics.gap-2;
Rectangle {
width: 8px;
height: 8px;
border-radius: 4px;
background: Theme.state-success;
}
UiText {
role: TextRole.caption;
text: "运行中";
color: Theme.state-success;
horizontal-stretch: 1;
}
MetroButton {
kind: ButtonKind.subtle;
icon: IconKind.stop;
icon-only: true;
clicked => { root.stop(); }
}
}
}
}
+126
View File
@@ -0,0 +1,126 @@
// 自绘无边框标题栏(D16 §1):Metro 一体感 + accent 顶线。
// 拖拽/最大化/最小化/关闭只发回调,原生窗口操作由 main.rs 接到 Slint Window 平台上
// (Slint DSL 内无法移动原生窗口)。
import { Theme } from "../theme/tokens.slint";
import { Metrics } from "../theme/metrics.slint";
import { UiText, TextRole } from "label.slint";
import { AppIcon, IconKind } from "icons.slint";
component WindowButton inherits Rectangle {
in property <IconKind> icon: IconKind.minimize;
/// 关闭键:hover 用 Metro 红底白字
in property <bool> danger: false;
callback clicked();
width: Metrics.titlebar-button-width;
height: Metrics.titlebar-height;
background: touch.pressed
? (danger ? Theme.state-danger-hover : Theme.bg-pressed)
: touch.has-hover ? (danger ? Theme.state-danger : Theme.bg-hover) : transparent;
animate background { duration: Theme.motion-fast; }
AppIcon {
kind: root.icon;
icon-size: 12px;
x: (root.width - self.width) / 2;
y: (root.height - self.height) / 2;
tint: root.danger && touch.has-hover ? Theme.fg-on-accent : Theme.fg-primary;
}
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
export component TitleBar inherits Rectangle {
in property <string> title: "Unlucky Launcher";
in property <string> subtitle: "";
in property <bool> maximized: false;
callback toggle-maximize();
callback minimize();
callback request-close();
height: Metrics.titlebar-height;
background: Theme.bg-titlebar;
// accent 顶线(Metro 强调色贯穿)
Rectangle {
x: 0;
y: 0;
width: root.width;
height: Metrics.accent-line;
background: Theme.accent;
}
// 拖拽区:交给原生 `WindowMoveArea`(Slint 1.18 内置,拖过阈值才移动窗口,
// 并保持子元素可交互)。只覆盖左侧,右侧三个窗口按钮留出来。
// 平台不支持「系统级窗口移动」时该元素退化为无操作(Qt/部分 Wayland 组合),
// winit on Windows/macOS/X11 可用——Win7 落在这条路上。
WindowMoveArea {
x: 0;
y: Metrics.accent-line;
width: root.width - 3 * Metrics.titlebar-button-width;
height: root.height - Metrics.accent-line;
}
// 应用标记 + 标题
HorizontalLayout {
x: Metrics.gap-3;
y: Metrics.accent-line;
width: root.width - 3 * Metrics.titlebar-button-width - 2 * Metrics.gap-3;
height: root.height - Metrics.accent-line;
spacing: Metrics.gap-2;
Rectangle {
width: 14px;
height: 14px;
border-radius: Metrics.radius-sm;
background: Theme.accent;
UiText {
text: "U";
font-size: 10px;
font-weight: 700;
color: Theme.fg-on-accent;
horizontal-alignment: center;
vertical-alignment: center;
}
}
UiText {
role: TextRole.caption;
text: root.title;
font-weight: 600;
horizontal-stretch: 1;
}
UiText {
role: TextRole.caption;
text: root.subtitle;
color: Theme.fg-secondary;
}
}
HorizontalLayout {
x: root.width - 3 * Metrics.titlebar-button-width;
y: Metrics.accent-line;
width: 3 * Metrics.titlebar-button-width;
height: root.height - Metrics.accent-line;
spacing: 0px;
WindowButton {
icon: IconKind.minimize;
clicked => { root.minimize(); }
}
WindowButton {
icon: root.maximized ? IconKind.restore : IconKind.maximize;
clicked => { root.toggle-maximize(); }
}
WindowButton {
icon: IconKind.close;
danger: true;
clicked => { root.request-close(); }
}
}
}