Unlucky Launcher v0.2.0
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
首个 iced + Material 3 Expressive 版本的源码快照。 完整历史与日常提交在 dev 分支;main 只放阶段性快照。
This commit is contained in:
commit
8a8e5ca0c0
1079 files changed
+208035
No files matched your search
@@ -0,0 +1,50 @@
|
||||
[package]
|
||||
name = "unlucky-app"
|
||||
description = "Unlucky-Launcher 桌面壳:Slint + Metro 视觉(唯一允许依赖 UI 框架的 crate)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "unlucky-launcher"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
slint.workspace = true
|
||||
# 跨界 DTO 必须可序列化(AGENTS §6 / 01c §5):事件与快照是 IPC 契约,
|
||||
# 序列化能力由 derive 钉住,测试里做 round-trip 断言。
|
||||
serde.workspace = true
|
||||
# host 层要读 core 设置/事件里的 JSON 值(serde_json 是 workspace 既有依赖)
|
||||
serde_json.workspace = true
|
||||
|
||||
# M85:UI 框架无关的宿主层(DTO / 状态 reducer / host / 设置桥 / 偏好 / 路由 / 对话框 / 提示 /
|
||||
# 向导 / 假后端)。Slint 版与 iced 版共用这一层,所以 `crates/app` 只是它的**消费者**:
|
||||
# 本 crate 里剩下的 `crate::` 引用都换成了 `unlucky_presenter::`。
|
||||
#
|
||||
# presenter 的零 UI 依赖守门(原文与实测输出就在这里,放不进 present 的自家文档——自指):
|
||||
# grep -rn slint crates/presenter # 期望:无输出
|
||||
# cargo tree -p unlucky-presenter --edges normal # 期望:只有 serde/serde_json/unlucky-core
|
||||
unlucky-presenter = { path = "../presenter", version = "0.2" }
|
||||
|
||||
# 边界(01c §5 / D3):app 只经 DTO 消费 core/agent,**不 import 内部类型**。
|
||||
# 迁移到 presenter 之后,app 侧只剩视图模型转换(`conv/ui.rs` 的 BgFill/强调色映射)与
|
||||
# 系统主题探测需要 core;业务装配(读盘/起进程/写设置)全部在 presenter 的 host 里。
|
||||
unlucky-core = { path = "../core", version = "0.2" }
|
||||
unlucky-agent = { path = "../agent", version = "0.2" }
|
||||
# M82:同一个二进制也带 CLI 子命令(`unlucky-launcher instances list` …)。
|
||||
# 依赖方向 app → cli → core/agent 不违反 core 零 UI 铁律(guards.yml 的 core-no-ui 只查
|
||||
# `cargo tree -p unlucky-core`,core 不依赖 cli)。零 UI 依赖的 `ull` 仍在 `crates/cli`,
|
||||
# 给没有图形库的机器用——两者不是重复品。
|
||||
unlucky-cli = { path = "../cli", version = "0.2" }
|
||||
|
||||
[build-dependencies]
|
||||
slint-build.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Slint 编译入口:把 ui/main.slint 编译成 Rust 代码,供 src/main.rs 的 `slint::include_modules!()` 引入。
|
||||
//! 保持极简——UI 结构演进(多文件拆分、主题文件)在 M17 阶段 B 里扩展本脚本。
|
||||
|
||||
/// 编译 `ui/main.slint`;失败时让构建直接报错(UI 语法错误不该被静默忽略)。
|
||||
fn main() {
|
||||
slint_build::compile("ui/main.slint").expect("Slint UI 编译失败(ui/main.slint)");
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! 「外观(高级)」四件的回调接线(从 `callbacks.rs` 拆出,守住单文件 ≤400 行)。
|
||||
//!
|
||||
//! 与 `wire_settings` 的分工:这个文件只负责 09 §101-104 的四个高级特性
|
||||
//! (背景图 / 面板半透明度 / 玻璃拟态 / 强调色)+ 主题三态。
|
||||
//! 每个回调都是同一套动作:改 `state.prefs` → 下发到 Slint(`apply_*`)→ 落盘(`persist_settings`)→
|
||||
//! `sync()`。出厂全部关闭,所以「不碰设置页」时这里一次都不会触发。
|
||||
|
||||
use std::rc::Weak;
|
||||
|
||||
use crate::conv;
|
||||
use crate::ui_bind::Inner;
|
||||
|
||||
/// 接主题三态与外观四件的回调。
|
||||
pub(crate) fn wire(ui: &crate::MainWindow, weak: &Weak<Inner>) {
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_theme_changed(move |mode| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().prefs.theme = conv::theme_mode_from(mode);
|
||||
this.apply_theme();
|
||||
this.persist_settings();
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
// ── 09 §101-104「外观(高级)」四件(出厂全关,改动即刻落盘)────────
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_bg_image_enabled_changed(move |enabled| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().prefs.appearance.bg_image_enabled = enabled;
|
||||
this.apply_appearance();
|
||||
this.persist_settings();
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_bg_image_changed(move |path| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().prefs.appearance.bg_image = path.to_string();
|
||||
this.apply_appearance();
|
||||
this.persist_settings();
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_bg_fill_changed(move |fill| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().prefs.appearance.bg_fill = conv::bg_fill_from(fill);
|
||||
this.apply_appearance();
|
||||
this.persist_settings();
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_bg_dim_changed(move |value| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().prefs.appearance.bg_dim = value;
|
||||
this.apply_appearance();
|
||||
this.persist_settings();
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_panel_alpha_changed(move |value| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().prefs.appearance.panel_alpha = value;
|
||||
this.apply_appearance();
|
||||
this.persist_settings();
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_glass_changed(move |enabled| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().prefs.appearance.glass = enabled;
|
||||
this.apply_appearance();
|
||||
this.persist_settings();
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_accent_color_changed(move |text| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
let accepted = this.state.borrow_mut().prefs.appearance.set_accent(&text);
|
||||
// 非法输入:只亮设置页的 warn 行,不改现状、不落盘
|
||||
this.ui.set_accent_invalid(!accepted);
|
||||
if accepted {
|
||||
this.apply_appearance();
|
||||
this.persist_settings();
|
||||
}
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_accent_swatch_picked(move |index| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
let Ok(index) = usize::try_from(index) else {
|
||||
return;
|
||||
};
|
||||
let accepted = this
|
||||
.state
|
||||
.borrow_mut()
|
||||
.prefs
|
||||
.appearance
|
||||
.set_accent_preset(index);
|
||||
if !accepted {
|
||||
return;
|
||||
}
|
||||
let hex = this.state.borrow().prefs.appearance.accent_color.clone();
|
||||
this.ui.set_accent_color(conv::accent_color(&hex));
|
||||
this.ui.set_accent_invalid(false);
|
||||
this.apply_appearance();
|
||||
this.persist_settings();
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
//! 背景图与玻璃拟态模糊层的准备(09 §101/§103 的「预生成」手法,纯 Rust、无新依赖)。
|
||||
//!
|
||||
//! 为什么要自己算:Slint 没有 `backdrop-filter`,也无法在渲染管线里做高斯模糊。可用的只有
|
||||
//! `Image::load_from_path` + 像素缓冲(`to_rgba8` / `from_rgba8`)。于是:
|
||||
//!
|
||||
//! - **模糊层** = 把原图按面积平均降到极小的尺寸(默认宽 48px),渲染时被平滑放大 → 天然模糊
|
||||
//! (面积平均本身就是低通滤波,比在满分辨率上跑 box blur 快几个数量级,且一次只在设置变更时算);
|
||||
//! - **tile 模式** = Slint 的 `image-fit` 没有 `repeat`,所以在 Rust 侧按窗口尺寸平铺成一张画布,
|
||||
//! 交给 `image-fit: cover`(画布与窗口同尺寸,等价于 1:1 铺设)。
|
||||
//!
|
||||
//! 解码格式:Slint 默认只编译 PNG 解码器(`image-default-formats` 特性未开),因此这里只保证 PNG;
|
||||
//! 其它格式 `load_from_path` 直接失败,UI 侧给出提示而不是静默无效果。
|
||||
|
||||
use slint::{Image, Rgba8Pixel, SharedPixelBuffer};
|
||||
|
||||
use unlucky_presenter::prefs::BgFill;
|
||||
|
||||
/// 模糊层目标宽度(像素)。越小越糊;96 在 1180px 宽窗口上 ≈ 24px 半径高斯的效果。
|
||||
const BLUR_WIDTH: u32 = 96;
|
||||
/// 小图上再跑几轮 box blur(3×3):把降采样的块状边缘抹平。
|
||||
const BLUR_PASSES: usize = 3;
|
||||
/// tile 画布上限(防超大窗口吃爆内存:4096×2560×4 ≈ 40MB)。
|
||||
const TILE_MAX_WIDTH: u32 = 4096;
|
||||
const TILE_MAX_HEIGHT: u32 = 2560;
|
||||
|
||||
/// 一次准备出来的两张图:原图(按填充模式处理)+ 模糊层。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct BackgroundLayers {
|
||||
/// 铺在窗口底的图(tile 模式下已平铺成整窗画布)
|
||||
pub image: Image,
|
||||
/// 玻璃拟态用的模糊层
|
||||
pub blur: Image,
|
||||
}
|
||||
|
||||
/// 载入并加工背景图。
|
||||
///
|
||||
/// `viewport` 是窗口的**物理像素**尺寸(tile 模式平铺用;拿不到时传 `(0, 0)` 退回只载入)。
|
||||
/// 返回 `None` 表示路径无效 / 解码失败 / 读了像素但为空——调用方据此提示用户。
|
||||
#[must_use]
|
||||
pub fn prepare(path: &str, fill: BgFill, viewport: (u32, u32)) -> Option<BackgroundLayers> {
|
||||
let path = path.trim();
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let loaded = Image::load_from_path(std::path::Path::new(path)).ok()?;
|
||||
let source = loaded.to_rgba8()?;
|
||||
if source.width() == 0 || source.height() == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let image = match fill {
|
||||
BgFill::Tile => {
|
||||
let (width, height) = tile_canvas_size(source.width(), source.height(), viewport);
|
||||
Image::from_rgba8(tile(&source, width, height))
|
||||
}
|
||||
// cover / contain 交给 Slint 的 image-fit,这里保留原图
|
||||
BgFill::Cover | BgFill::Contain => loaded,
|
||||
};
|
||||
|
||||
let blurred = box_blur(&downscale(&source, BLUR_WIDTH), BLUR_PASSES);
|
||||
// 放大回窗口物理尺寸再做 1:1 贴图:软件渲染器对图片缩放不做平滑插值(最近邻),
|
||||
// 直接把 96px 的小图丢给它会出现明显方块;这里自己双线性放大,观感才是「磨砂」。
|
||||
let blur = match viewport {
|
||||
(0, 0) => Image::from_rgba8(blurred),
|
||||
(width, height) => Image::from_rgba8(upscale(&blurred, width, height)),
|
||||
};
|
||||
|
||||
Some(BackgroundLayers { image, blur })
|
||||
}
|
||||
|
||||
/// 3×3 均值模糊,`passes` 轮(小图上跑,代价可忽略)。
|
||||
fn box_blur(
|
||||
source: &SharedPixelBuffer<Rgba8Pixel>,
|
||||
passes: usize,
|
||||
) -> SharedPixelBuffer<Rgba8Pixel> {
|
||||
let width = source.width() as usize;
|
||||
let height = source.height() as usize;
|
||||
if width < 3 || height < 3 || passes == 0 {
|
||||
return source.clone();
|
||||
}
|
||||
let mut current: Vec<Rgba8Pixel> = source.as_slice().to_vec();
|
||||
let mut next = current.clone();
|
||||
for _ in 0..passes {
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let mut sum = [0u32; 4];
|
||||
let mut count = 0u32;
|
||||
for dy in -1i32..=1 {
|
||||
for dx in -1i32..=1 {
|
||||
let sy = (y as i32 + dy).clamp(0, height as i32 - 1) as usize;
|
||||
let sx = (x as i32 + dx).clamp(0, width as i32 - 1) as usize;
|
||||
let pixel = current[sy * width + sx];
|
||||
sum[0] += u32::from(pixel.r);
|
||||
sum[1] += u32::from(pixel.g);
|
||||
sum[2] += u32::from(pixel.b);
|
||||
sum[3] += u32::from(pixel.a);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
next[y * width + x] = Rgba8Pixel {
|
||||
r: (sum[0] / count) as u8,
|
||||
g: (sum[1] / count) as u8,
|
||||
b: (sum[2] / count) as u8,
|
||||
a: (sum[3] / count) as u8,
|
||||
};
|
||||
}
|
||||
}
|
||||
std::mem::swap(&mut current, &mut next);
|
||||
}
|
||||
let mut out = SharedPixelBuffer::<Rgba8Pixel>::new(source.width(), source.height());
|
||||
out.make_mut_slice().copy_from_slice(¤t);
|
||||
out
|
||||
}
|
||||
|
||||
/// 双线性放大到目标尺寸。
|
||||
fn upscale(
|
||||
source: &SharedPixelBuffer<Rgba8Pixel>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> SharedPixelBuffer<Rgba8Pixel> {
|
||||
let src_w = source.width();
|
||||
let src_h = source.height();
|
||||
if src_w == 0 || src_h == 0 || width == 0 || height == 0 {
|
||||
return SharedPixelBuffer::new(width.max(1), height.max(1));
|
||||
}
|
||||
let pixels = source.as_slice();
|
||||
let mut out = SharedPixelBuffer::<Rgba8Pixel>::new(width, height);
|
||||
let dst = out.make_mut_slice();
|
||||
let sample = |fx: f32, fy: f32| -> Rgba8Pixel {
|
||||
let x = (fx * (src_w - 1) as f32).clamp(0.0, (src_w - 1) as f32);
|
||||
let y = (fy * (src_h - 1) as f32).clamp(0.0, (src_h - 1) as f32);
|
||||
let x0 = x.floor() as usize;
|
||||
let y0 = y.floor() as usize;
|
||||
let x1 = (x0 + 1).min(src_w as usize - 1);
|
||||
let y1 = (y0 + 1).min(src_h as usize - 1);
|
||||
let tx = x - x0 as f32;
|
||||
let ty = y - y0 as f32;
|
||||
let at = |px: usize, py: usize| pixels[py * src_w as usize + px];
|
||||
let blend = |a: u8, b: u8, c: u8, d: u8| -> u8 {
|
||||
let top = f32::from(a) * (1.0 - tx) + f32::from(b) * tx;
|
||||
let bottom = f32::from(c) * (1.0 - tx) + f32::from(d) * tx;
|
||||
(top * (1.0 - ty) + bottom * ty).round().clamp(0.0, 255.0) as u8
|
||||
};
|
||||
let (p00, p10, p01, p11) = (at(x0, y0), at(x1, y0), at(x0, y1), at(x1, y1));
|
||||
Rgba8Pixel {
|
||||
r: blend(p00.r, p10.r, p01.r, p11.r),
|
||||
g: blend(p00.g, p10.g, p01.g, p11.g),
|
||||
b: blend(p00.b, p10.b, p01.b, p11.b),
|
||||
a: blend(p00.a, p10.a, p01.a, p11.a),
|
||||
}
|
||||
};
|
||||
for y in 0..height as usize {
|
||||
let fy = y as f32 / height as f32;
|
||||
for x in 0..width as usize {
|
||||
dst[y * width as usize + x] = sample(x as f32 / width as f32, fy);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 平铺画布尺寸:覆盖整个视口、且是原图尺寸的整数倍(超上限就截断)。
|
||||
fn tile_canvas_size(source_width: u32, source_height: u32, viewport: (u32, u32)) -> (u32, u32) {
|
||||
if viewport.0 == 0 || viewport.1 == 0 {
|
||||
return (source_width, source_height);
|
||||
}
|
||||
let repeat_x = viewport.0.div_ceil(source_width).max(1);
|
||||
let repeat_y = viewport.1.div_ceil(source_height).max(1);
|
||||
let width = (repeat_x * source_width).min(TILE_MAX_WIDTH);
|
||||
let height = (repeat_y * source_height).min(TILE_MAX_HEIGHT);
|
||||
(width.max(source_width), height.max(source_height))
|
||||
}
|
||||
|
||||
/// 按整数格平铺(源图超出画布的部分裁掉)。
|
||||
fn tile(
|
||||
source: &SharedPixelBuffer<Rgba8Pixel>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> SharedPixelBuffer<Rgba8Pixel> {
|
||||
let mut canvas = SharedPixelBuffer::<Rgba8Pixel>::new(width, height);
|
||||
let src_w = source.width() as usize;
|
||||
let src_h = source.height() as usize;
|
||||
let src = source.as_slice();
|
||||
let dst = canvas.make_mut_slice();
|
||||
for y in 0..height as usize {
|
||||
let src_row = (y % src_h) * src_w;
|
||||
let dst_row = y * width as usize;
|
||||
for x in 0..width as usize {
|
||||
dst[dst_row + x] = src[src_row + (x % src_w)];
|
||||
}
|
||||
}
|
||||
canvas
|
||||
}
|
||||
|
||||
/// 面积平均降采样(保留宽高比,最窄 1px)。
|
||||
fn downscale(
|
||||
source: &SharedPixelBuffer<Rgba8Pixel>,
|
||||
target_width: u32,
|
||||
) -> SharedPixelBuffer<Rgba8Pixel> {
|
||||
let src_w = source.width();
|
||||
let src_h = source.height();
|
||||
let target_width = target_width.clamp(1, src_w);
|
||||
let target_height = ((src_h as u64 * target_width as u64) / src_w as u64).max(1) as u32;
|
||||
|
||||
let mut out = SharedPixelBuffer::<Rgba8Pixel>::new(target_width, target_height);
|
||||
let src = source.as_slice();
|
||||
let dst = out.make_mut_slice();
|
||||
|
||||
for ty in 0..target_height as usize {
|
||||
// 每个目标像素覆盖的源矩形(按比例分块,块内取平均)
|
||||
let y0 = ty * src_h as usize / target_height as usize;
|
||||
let y1 = ((ty + 1) * src_h as usize / target_height as usize).max(y0 + 1);
|
||||
for tx in 0..target_width as usize {
|
||||
let x0 = tx * src_w as usize / target_width as usize;
|
||||
let x1 = ((tx + 1) * src_w as usize / target_width as usize).max(x0 + 1);
|
||||
let mut sum = [0u32; 4];
|
||||
let mut count = 0u32;
|
||||
for y in y0..y1.min(src_h as usize) {
|
||||
for x in x0..x1.min(src_w as usize) {
|
||||
let pixel = src[y * src_w as usize + x];
|
||||
sum[0] += u32::from(pixel.r);
|
||||
sum[1] += u32::from(pixel.g);
|
||||
sum[2] += u32::from(pixel.b);
|
||||
sum[3] += u32::from(pixel.a);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
let count = count.max(1);
|
||||
dst[ty * target_width as usize + tx] = Rgba8Pixel {
|
||||
r: (sum[0] / count) as u8,
|
||||
g: (sum[1] / count) as u8,
|
||||
b: (sum[2] / count) as u8,
|
||||
a: (sum[3] / count) as u8,
|
||||
};
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn solid(width: u32, height: u32, pixel: Rgba8Pixel) -> SharedPixelBuffer<Rgba8Pixel> {
|
||||
let mut buffer = SharedPixelBuffer::<Rgba8Pixel>::new(width, height);
|
||||
buffer.make_mut_slice().fill(pixel);
|
||||
buffer
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downscale_keeps_aspect_and_averages_area() {
|
||||
let mut source = SharedPixelBuffer::<Rgba8Pixel>::new(96, 48);
|
||||
// 左半黑、右半白:降采样后左右两侧应各自保持纯色
|
||||
for y in 0..48 {
|
||||
for x in 0..96 {
|
||||
let value = if x < 48 { 0 } else { 255 };
|
||||
source.make_mut_slice()[y * 96 + x] = Rgba8Pixel {
|
||||
r: value,
|
||||
g: value,
|
||||
b: value,
|
||||
a: 255,
|
||||
};
|
||||
}
|
||||
}
|
||||
let small = downscale(&source, 24);
|
||||
assert_eq!(small.width(), 24);
|
||||
assert_eq!(small.height(), 12, "宽高比保持 2:1");
|
||||
let pixels = small.as_slice();
|
||||
assert_eq!(pixels[0].r, 0, "左侧仍是黑");
|
||||
assert_eq!(pixels[23].r, 255, "右侧仍是白");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downscale_never_produces_zero_height() {
|
||||
let source = solid(
|
||||
400,
|
||||
3,
|
||||
Rgba8Pixel {
|
||||
r: 1,
|
||||
g: 2,
|
||||
b: 3,
|
||||
a: 255,
|
||||
},
|
||||
);
|
||||
let small = downscale(&source, 2);
|
||||
assert_eq!(small.width(), 2);
|
||||
assert!(small.height() >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_canvas_covers_viewport_in_whole_repeats() {
|
||||
assert_eq!(tile_canvas_size(120, 100, (1180, 760)), (1200, 800));
|
||||
// 视口未知(取不到窗口尺寸)时退回单块
|
||||
assert_eq!(tile_canvas_size(120, 100, (0, 0)), (120, 100));
|
||||
// 超大视口被上限截断
|
||||
let (width, height) = tile_canvas_size(64, 64, (9000, 9000));
|
||||
assert!(width <= TILE_MAX_WIDTH && height <= TILE_MAX_HEIGHT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_repeats_source_pixels() {
|
||||
let source = solid(
|
||||
2,
|
||||
2,
|
||||
Rgba8Pixel {
|
||||
r: 9,
|
||||
g: 9,
|
||||
b: 9,
|
||||
a: 255,
|
||||
},
|
||||
);
|
||||
let canvas = tile(&source, 5, 3);
|
||||
assert_eq!(canvas.width(), 5);
|
||||
assert_eq!(canvas.height(), 3);
|
||||
assert!(canvas.as_slice().iter().all(|pixel| pixel.r == 9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_rejects_bad_input() {
|
||||
assert!(prepare("", BgFill::Cover, (100, 100)).is_none());
|
||||
assert!(prepare("D:/definitely/not/here.png", BgFill::Cover, (100, 100)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_blur_smooths_a_hard_edge() {
|
||||
// 左黑右白:模糊后交界处应出现中间灰(硬边被抹平)
|
||||
let mut source = SharedPixelBuffer::<Rgba8Pixel>::new(9, 3);
|
||||
for y in 0..3 {
|
||||
for x in 0..9 {
|
||||
let value = if x < 4 { 0 } else { 255 };
|
||||
source.make_mut_slice()[y * 9 + x] = Rgba8Pixel {
|
||||
r: value,
|
||||
g: value,
|
||||
b: value,
|
||||
a: 255,
|
||||
};
|
||||
}
|
||||
}
|
||||
let blurred = box_blur(&source, 2);
|
||||
let middle = blurred.as_slice()[9 + 4]; // 第 1 行(共 9 列)的第 4 个像素
|
||||
assert!(
|
||||
middle.r > 20 && middle.r < 235,
|
||||
"交界处应被抹成中间灰,实际 {}",
|
||||
middle.r
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upscale_matches_requested_size_and_interpolates() {
|
||||
let mut source = SharedPixelBuffer::<Rgba8Pixel>::new(2, 1);
|
||||
let pixels = source.make_mut_slice();
|
||||
pixels[0] = Rgba8Pixel {
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 255,
|
||||
};
|
||||
pixels[1] = Rgba8Pixel {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 255,
|
||||
a: 255,
|
||||
};
|
||||
let big = upscale(&source, 8, 2);
|
||||
assert_eq!(big.width(), 8);
|
||||
assert_eq!(big.height(), 2);
|
||||
let row = big.as_slice();
|
||||
// 双线性:中间像素应是灰阶过渡而不是 0/255 二值
|
||||
assert!(row[3].r > 60 && row[3].r < 200, "实际 {}", row[3].r);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
//! 回调接线:把 `ui/main.slint` 暴露的每个回调翻译成「状态变更 + AppCommand + 同步」。
|
||||
//!
|
||||
//! 逐条列出而不做通用转发,是为了让「用户能做的每一件事」在这里一目了然;
|
||||
//! 新增交互时只改这一处 + `.slint` 的 callback 声明。
|
||||
|
||||
use std::rc::Weak;
|
||||
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::conv;
|
||||
use crate::ui_bind::Inner;
|
||||
use unlucky_presenter::dto::AppCommand;
|
||||
use unlucky_presenter::router::Route;
|
||||
use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
/// 接全部回调;闭包持弱引用,避免 `MainWindow` 与闭包互相持有导致窗口永不释放。
|
||||
pub(crate) fn wire(weak: Weak<Inner>) {
|
||||
let Some(inner) = weak.upgrade() else { return };
|
||||
// 只在注册期间持有强引用;闭包内一律持弱引用(否则窗口与闭包互相持有、永不释放)。
|
||||
let ui = &inner.ui;
|
||||
|
||||
wire_window(ui, &weak);
|
||||
wire_navigation(ui, &weak);
|
||||
wire_instances(ui, &weak);
|
||||
wire_download(ui, &weak);
|
||||
wire_logs(ui, &weak);
|
||||
wire_settings(ui, &weak);
|
||||
wire_overlays(ui, &weak);
|
||||
}
|
||||
|
||||
fn wire_window(ui: &crate::MainWindow, weak: &Weak<Inner>) {
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_toggle_maximize(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
let window = this.ui.window();
|
||||
window.set_maximized(!window.is_maximized());
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_minimize(move || {
|
||||
if let Some(this) = weak.upgrade() {
|
||||
this.ui.window().set_minimized(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_request_close(move || {
|
||||
if let Some(this) = weak.upgrade() {
|
||||
let _ = this.ui.hide();
|
||||
}
|
||||
let _ = slint::quit_event_loop();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_navigation(ui: &crate::MainWindow, weak: &Weak<Inner>) {
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_navigate(move |route| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
if let Some(route) = conv::route_from(route) {
|
||||
this.state.borrow_mut().navigate(route);
|
||||
this.sync();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_toggle_nav(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().toggle_nav();
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_toggle_ai(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().toggle_ai();
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_expand_ai(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().navigate(Route::Agent);
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_dock_ai(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().back();
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_toggle_yolo(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
let enabled = {
|
||||
let mut state = this.state.borrow_mut();
|
||||
state.ai.yolo = !state.ai.yolo;
|
||||
state.ai.yolo
|
||||
};
|
||||
this.dispatch(AppCommand::SetYolo(enabled));
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_ai_send(move |text| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
let text = text.to_string();
|
||||
if text.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
// 不做本地回显:user 消息由 host 的 MessageAppended 事件带回(契约 §3)
|
||||
this.state.borrow_mut().ai.draft.clear();
|
||||
this.ui.set_ai_draft("".into());
|
||||
this.ask_agent(&text);
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_ai_stop(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().ai.finish_stream();
|
||||
this.dispatch(AppCommand::StopAgent);
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_instances(ui: &crate::MainWindow, weak: &Weak<Inner>) {
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_instance_launch(move |id| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.dispatch(AppCommand::LaunchInstance(id.to_string()));
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_instance_configure(move |id| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
// 齿轮键进实例详情(G4 占位页),实例级设置后续挂在该页 tab 上
|
||||
this.state
|
||||
.borrow_mut()
|
||||
.navigate(Route::Detail(id.to_string()));
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_instance_stop(move |id| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.dispatch(AppCommand::StopInstance(id.to_string()));
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_instance_open(move |id| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state
|
||||
.borrow_mut()
|
||||
.navigate(Route::Detail(id.to_string()));
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_new_instance(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
// G5:打开向导(版本 → 加载器 → 名字 → 确认 → 安装进度)
|
||||
this.state.borrow_mut().wizard = unlucky_presenter::wizard::WizardState::open();
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_instance_view_changed(move |view| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().prefs.instance_view = conv::instance_view_from(view);
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_tile_size_changed(move |size| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().prefs.tile_size = conv::tile_size_from(size);
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_instance_query_changed(move |query| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().instance_query = query.to_string();
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_download(ui: &crate::MainWindow, weak: &Weak<Inner>) {
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_download_category_changed(move |category| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
let category = conv::version_category(category);
|
||||
this.state.borrow_mut().prefs.download_category = category;
|
||||
this.dispatch(AppCommand::ListVersions(category));
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_download_install(move |id| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.dispatch(AppCommand::InstallVersion(id.to_string()));
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_installed_only_changed(move |only| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().installed_only = only;
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_download_query_changed(move |query| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().download_query = query.to_string();
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_ancient_help(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().toasts.push(
|
||||
ToastKind::Info,
|
||||
"远古版(classic/alpha/beta)需旧版 Java 与旧启动参数;完整说明页 P1 接入",
|
||||
);
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_logs(ui: &crate::MainWindow, weak: &Weak<Inner>) {
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_log_select(move |id| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().selected_session = Some(id.to_string());
|
||||
this.dispatch(AppCommand::OpenLogSession(id.to_string()));
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_log_ask_ai(move |id| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
let question = format!("帮我看下会话 {id} 为什么崩溃");
|
||||
this.state.borrow_mut().navigate(Route::Agent);
|
||||
this.ask_agent(&question);
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_log_apply_fix(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state
|
||||
.borrow_mut()
|
||||
.toasts
|
||||
.push(ToastKind::Warn, "一键修复在 P1 接 diagnose 的 fix_action");
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_settings(ui: &crate::MainWindow, weak: &Weak<Inner>) {
|
||||
// 主题三态 + 外观四件在 appearance_bind.rs(拆文件守 400 行)
|
||||
crate::appearance_bind::wire(ui, weak);
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_yolo_changed(move |enabled| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().ai.yolo = enabled;
|
||||
this.dispatch(AppCommand::SetYolo(enabled));
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_mcp_changed(move |_enabled| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state
|
||||
.borrow_mut()
|
||||
.toasts
|
||||
.push(ToastKind::Info, "MCP server 开关在 P1 接 agent");
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
// P0 只保留入口:设置分组与 AI 端点暂不落状态(P1 接 setting schema)。
|
||||
ui.on_settings_section_changed(|_section| {});
|
||||
ui.on_ai_endpoint_changed(|_index| {});
|
||||
}
|
||||
|
||||
fn wire_overlays(ui: &crate::MainWindow, weak: &Weak<Inner>) {
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_dialog_primary(move || close_current_dialog(&weak));
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_dialog_secondary(move || close_current_dialog(&weak));
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_dialog_dismissed(move || close_current_dialog(&weak));
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
ui.on_toast_dismissed(move |id| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
if let Ok(id) = id.parse::<u64>() {
|
||||
this.state.borrow_mut().toasts.dismiss(id);
|
||||
this.sync();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn close_current_dialog(weak: &Weak<Inner>) {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().dialogs.close_current();
|
||||
this.sync();
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! `PageState` global 的回调接线:账户 / Java / 设置 / 通知 / 向导 / 崩溃导入 / 日志刷新。
|
||||
//!
|
||||
//! 与 `callbacks.rs` 同一纪律:每个回调 = 「改状态 + 发 AppCommand + 同步 UI」,一处看全。
|
||||
//! 用 global 而不是逐层转发属性(见 `ui/page_state.slint` 顶部的取舍说明)。
|
||||
|
||||
use std::rc::Weak;
|
||||
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::conv;
|
||||
use crate::ui_bind::Inner;
|
||||
use unlucky_presenter::dto::{AppCommand, CreateRequest, LoaderKind};
|
||||
|
||||
/// 接全部页面状态回调。
|
||||
pub(crate) fn wire_pages(weak: Weak<Inner>) {
|
||||
let Some(inner) = weak.upgrade() else { return };
|
||||
let page = inner.ui.global::<crate::PageState<'_>>();
|
||||
|
||||
// ── 账户 ──────────────────────────────────────────────────
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_account_add_offline(move |name| {
|
||||
let name = name.to_string();
|
||||
if name.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.dispatch(AppCommand::AddOfflineAccount(name));
|
||||
this.ui
|
||||
.global::<crate::PageState<'_>>()
|
||||
.set_offline_name("".into());
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_account_remove(move |id| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.dispatch(AppCommand::RemoveAccount(id.to_string()));
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_account_switch(move |id| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.dispatch(AppCommand::SwitchAccount(id.to_string()));
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_msa_begin(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.dispatch(AppCommand::StartDeviceLogin);
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_msa_browser_begin(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.dispatch(AppCommand::StartBrowserLogin);
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_msa_cancel(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.dispatch(AppCommand::CancelDeviceLogin);
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Java ──────────────────────────────────────────────────
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_java_install(move |vendor| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.dispatch(AppCommand::InstallJava(vendor.to_string()));
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
|
||||
// ── 设置 ──────────────────────────────────────────────────
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_setting_changed(move |key, value| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.dispatch(AppCommand::SetSetting {
|
||||
key: key.to_string(),
|
||||
value: value.to_string(),
|
||||
});
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_setting_action(move |action| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
let action = action.to_string();
|
||||
match action.as_str() {
|
||||
// 清 24h 地区缓存 → 下次下载重新判定
|
||||
"detect_region" => this.dispatch(AppCommand::SetSetting {
|
||||
key: "download.action.detect_region".to_string(),
|
||||
value: String::new(),
|
||||
}),
|
||||
// Java 分组的「安装/管理」→ 按回退链装一个托管 JDK
|
||||
"install_java" => this.dispatch(AppCommand::InstallJava(String::new())),
|
||||
_ => {}
|
||||
}
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_settings_tab_changed(move |tab| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().settings_tab = tab.to_string();
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
|
||||
// ── 通知 ──────────────────────────────────────────────────
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_notice_dismiss(move |id| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.dispatch(AppCommand::DismissNotice(id.to_string()));
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
|
||||
// ── 向导 ──────────────────────────────────────────────────
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_wizard_open(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().wizard = unlucky_presenter::wizard::WizardState::open();
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_wizard_close(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().wizard.open = false;
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_wizard_select_version(move |version| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state
|
||||
.borrow_mut()
|
||||
.wizard
|
||||
.select_version(version.as_ref());
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_wizard_select_loader(move |index| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
let loader = match index {
|
||||
1 => LoaderKind::Fabric,
|
||||
2 => LoaderKind::Quilt,
|
||||
3 => LoaderKind::Forge,
|
||||
4 => LoaderKind::NeoForge,
|
||||
_ => LoaderKind::Vanilla,
|
||||
};
|
||||
this.state.borrow_mut().wizard.select_loader(loader);
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_wizard_name_changed(move |name| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
let mut state = this.state.borrow_mut();
|
||||
state.wizard.name = name.to_string();
|
||||
state.wizard.error.clear();
|
||||
drop(state);
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_wizard_next(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().wizard.advance();
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_wizard_back(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.state.borrow_mut().wizard.step_back();
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_wizard_confirm(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
let request = {
|
||||
let state = this.state.borrow();
|
||||
let wizard = &state.wizard;
|
||||
wizard.ready_to_submit().map(|()| CreateRequest {
|
||||
version: wizard.version.trim().to_string(),
|
||||
loader: wizard.loader,
|
||||
loader_version: wizard.loader_version.trim().to_string(),
|
||||
name: wizard.name.trim().to_string(),
|
||||
})
|
||||
};
|
||||
match request {
|
||||
Ok(request) => {
|
||||
this.dispatch(AppCommand::CreateInstance(request));
|
||||
this.state.borrow_mut().wizard.submitted();
|
||||
}
|
||||
Err(error) => {
|
||||
this.state.borrow_mut().wizard.error = error;
|
||||
}
|
||||
}
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_install_cancel(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.dispatch(AppCommand::CancelInstall);
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
|
||||
// ── 日志页 ────────────────────────────────────────────────
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_crash_import(move |path| {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
let path = path.to_string();
|
||||
if path.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
this.dispatch(AppCommand::ImportCrashLog(path));
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = weak.clone();
|
||||
page.on_logs_refresh(move || {
|
||||
let Some(this) = weak.upgrade() else { return };
|
||||
this.dispatch(AppCommand::RefreshLogSessions);
|
||||
this.sync();
|
||||
});
|
||||
}
|
||||
|
||||
// 设置页分组切换同时刷新一遍分组快照(外部改配置后手点分组即最新)
|
||||
let _ = conv::setting_groups;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! AI 页与覆盖层:会话消息、对话框(一次一条)、Toast。
|
||||
|
||||
use crate::{ChatRole, DialogKind, UiChatMessage, UiDialog, UiToast};
|
||||
use unlucky_presenter::dialogs as dlg;
|
||||
use unlucky_presenter::dto::ChatRole as DtoChatRole;
|
||||
use unlucky_presenter::state::AppState;
|
||||
use unlucky_presenter::toast as toast_mod;
|
||||
|
||||
/// AI 会话消息。
|
||||
pub fn chat(state: &AppState) -> Vec<UiChatMessage> {
|
||||
state
|
||||
.ai
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| UiChatMessage {
|
||||
role: match message.role {
|
||||
DtoChatRole::User => ChatRole::User,
|
||||
DtoChatRole::Assistant => ChatRole::Assistant,
|
||||
DtoChatRole::Tool => ChatRole::Tool,
|
||||
DtoChatRole::System => ChatRole::System,
|
||||
},
|
||||
text: message.text.clone().into(),
|
||||
tool_name: message
|
||||
.tool
|
||||
.as_ref()
|
||||
.map(|tool| tool.name.clone())
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
tool_ok: message.tool.as_ref().is_none_or(|tool| tool.ok),
|
||||
streaming: message.streaming,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 当前对话框(一次一条)+ 是否显示 + 队列剩余条数。
|
||||
pub fn dialog(state: &AppState) -> (UiDialog, bool, i32) {
|
||||
let queued = i32::try_from(state.dialogs.pending_len()).unwrap_or(0);
|
||||
match state.dialogs.current() {
|
||||
None => (UiDialog::default(), false, queued),
|
||||
Some(request) => {
|
||||
let (kind, primary, secondary) = match &request.kind {
|
||||
dlg::DialogKind::Confirm { primary, secondary } => {
|
||||
(DialogKind::Confirm, primary.clone(), secondary.clone())
|
||||
}
|
||||
dlg::DialogKind::Input { primary, .. } => {
|
||||
(DialogKind::Input, primary.clone(), String::new())
|
||||
}
|
||||
dlg::DialogKind::Progress { .. } => {
|
||||
(DialogKind::Progress, "取消".to_string(), String::new())
|
||||
}
|
||||
dlg::DialogKind::Error { primary, .. } => {
|
||||
(DialogKind::Error, primary.clone(), "忽略".to_string())
|
||||
}
|
||||
};
|
||||
let progress = match &request.kind {
|
||||
dlg::DialogKind::Progress { progress, .. } => *progress,
|
||||
_ => 0.0,
|
||||
};
|
||||
let input_label = match &request.kind {
|
||||
dlg::DialogKind::Input { label, .. } => label.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let input_value = match &request.kind {
|
||||
dlg::DialogKind::Input { value, .. } => value.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
(
|
||||
UiDialog {
|
||||
id: request.id.to_string().into(),
|
||||
kind,
|
||||
title: request.title.clone().into(),
|
||||
body: request.body.clone().into(),
|
||||
primary: primary.into(),
|
||||
secondary: secondary.into(),
|
||||
progress,
|
||||
input_label: input_label.into(),
|
||||
input_value: input_value.into(),
|
||||
},
|
||||
true,
|
||||
queued,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toast 列表。
|
||||
pub fn toasts(state: &AppState) -> Vec<UiToast> {
|
||||
state
|
||||
.toasts
|
||||
.items()
|
||||
.iter()
|
||||
.map(|toast| UiToast {
|
||||
id: toast.id.to_string().into(),
|
||||
kind: match toast.kind {
|
||||
toast_mod::ToastKind::Info => crate::ToastKind::Info,
|
||||
toast_mod::ToastKind::Success => crate::ToastKind::Success,
|
||||
toast_mod::ToastKind::Warn => crate::ToastKind::Warn,
|
||||
toast_mod::ToastKind::Error => crate::ToastKind::Error,
|
||||
},
|
||||
text: toast.text.clone().into(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! 实例相关:磁贴外观(相位/进度/徽标/占位色)、运行中列表。
|
||||
|
||||
use slint::Color;
|
||||
|
||||
use crate::{LoaderKind, RuntimePhase, UiTile};
|
||||
use unlucky_presenter::dto::{self, InstanceRuntime};
|
||||
use unlucky_presenter::state::{AppState, TileCell};
|
||||
|
||||
/// 头像/图标占位的底色池(Metro 强调色系,按 id 哈希稳定取色)。
|
||||
const HUES: [u32; 8] = [
|
||||
0x0078D4, 0x8764B0, 0xD13438, 0xCA5010, 0x498205, 0x038387, 0x5C2D91, 0x0050EF,
|
||||
];
|
||||
|
||||
/// 加载器枚举。
|
||||
pub fn loader(kind: dto::LoaderKind) -> LoaderKind {
|
||||
match kind {
|
||||
dto::LoaderKind::Vanilla => LoaderKind::Vanilla,
|
||||
dto::LoaderKind::Fabric => LoaderKind::Fabric,
|
||||
dto::LoaderKind::Quilt => LoaderKind::Quilt,
|
||||
dto::LoaderKind::Forge => LoaderKind::Forge,
|
||||
dto::LoaderKind::NeoForge => LoaderKind::Neoforge,
|
||||
dto::LoaderKind::OptiFine => LoaderKind::Optifine,
|
||||
dto::LoaderKind::LiteLoader => LoaderKind::Liteloader,
|
||||
}
|
||||
}
|
||||
|
||||
/// 运行时状态 → (磁贴外观相位, 进度 0~1, 阶段文案)。
|
||||
pub fn phase(runtime: &InstanceRuntime) -> (RuntimePhase, f32, String) {
|
||||
match runtime {
|
||||
InstanceRuntime::Idle => (RuntimePhase::Idle, 0.0, String::new()),
|
||||
InstanceRuntime::Busy { stage, progress } => {
|
||||
let phase = match stage {
|
||||
dto::LaunchStage::Download => RuntimePhase::Downloading,
|
||||
dto::LaunchStage::Verify => RuntimePhase::Verifying,
|
||||
dto::LaunchStage::Launch => RuntimePhase::Launching,
|
||||
dto::LaunchStage::Window => RuntimePhase::Running,
|
||||
};
|
||||
(phase, progress.ratio(), stage.label().to_string())
|
||||
}
|
||||
InstanceRuntime::Running { .. } => (RuntimePhase::Running, 1.0, "运行中".to_string()),
|
||||
InstanceRuntime::Failed { reason } => (RuntimePhase::Failed, 0.0, reason.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 名字首字(无图标时的占位字母)。
|
||||
pub fn initial_of(name: &str) -> String {
|
||||
name.chars()
|
||||
.next()
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| "?".to_string())
|
||||
}
|
||||
|
||||
/// id → 稳定底色(同一实例每次启动同色)。
|
||||
pub fn hue_of(id: &str) -> Color {
|
||||
let hash = id.bytes().fold(0u32, |acc, byte| {
|
||||
acc.wrapping_mul(31).wrapping_add(u32::from(byte))
|
||||
});
|
||||
Color::from_argb_encoded(0xFF00_0000 | HUES[(hash as usize) % HUES.len()])
|
||||
}
|
||||
|
||||
/// ISO-8601 UTC → 逗号少的本地展示串(`2026-10-29 19:22`);None/空 → 从未启动。
|
||||
pub fn last_played(raw: &Option<String>) -> String {
|
||||
match raw {
|
||||
None => "从未启动".to_string(),
|
||||
Some(value) if value.is_empty() => "从未启动".to_string(),
|
||||
Some(value) => {
|
||||
let trimmed = value.trim_end_matches('Z');
|
||||
let mut out = String::with_capacity(trimmed.len());
|
||||
let mut seen_time = false;
|
||||
for ch in trimmed.chars() {
|
||||
match ch {
|
||||
'T' => {
|
||||
seen_time = true;
|
||||
out.push(' ');
|
||||
}
|
||||
':' if seen_time && out.ends_with(|c: char| c.is_ascii_digit()) => {
|
||||
// 只保留到分钟:秒(第三段冒号之后)直接截断
|
||||
if out.matches(':').count() >= 1 {
|
||||
break;
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个磁贴。
|
||||
pub fn tile(cell: &TileCell<'_>) -> UiTile {
|
||||
let instance = cell.instance;
|
||||
let (phase, progress, label) = phase(&instance.runtime);
|
||||
UiTile {
|
||||
id: instance.id.clone().into(),
|
||||
name: instance.name.clone().into(),
|
||||
mc_version: instance.mc_version.clone().into(),
|
||||
loader: loader(instance.loader.kind),
|
||||
loader_version: instance.loader.version.clone().into(),
|
||||
loader_label: instance.loader.badge().into(),
|
||||
last_played: last_played(&instance.last_played).into(),
|
||||
phase,
|
||||
phase_label: label.into(),
|
||||
progress,
|
||||
icon: slint::Image::default(),
|
||||
hue: hue_of(&instance.id),
|
||||
initial: initial_of(&instance.name).into(),
|
||||
row: i32::try_from(cell.row).unwrap_or(0),
|
||||
col: i32::try_from(cell.col).unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// 磁贴网格(已按当前尺寸算好 row/col)。
|
||||
pub fn tiles(state: &AppState) -> Vec<UiTile> {
|
||||
state.tiles().iter().map(tile).collect()
|
||||
}
|
||||
|
||||
/// 运行中实例(顶部迷你状态条)。
|
||||
pub fn running_tiles(state: &AppState) -> Vec<UiTile> {
|
||||
state
|
||||
.running()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, instance)| {
|
||||
let cell = TileCell {
|
||||
instance,
|
||||
row: 0,
|
||||
col: index,
|
||||
};
|
||||
tile(&cell)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{hue_of, initial_of, last_played};
|
||||
|
||||
#[test]
|
||||
fn initial_is_first_grapheme_or_question_mark() {
|
||||
assert_eq!(initial_of("生存整合包"), "生");
|
||||
assert_eq!(initial_of("Vanilla"), "V");
|
||||
assert_eq!(initial_of(""), "?");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_is_stable_per_id() {
|
||||
assert_eq!(hue_of("survival"), hue_of("survival"));
|
||||
// 不同 id 大体不同色(哈希池 8 色,这里只验证确定性 + 不 panic)
|
||||
let _ = hue_of("another-instance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_played_formats_iso_to_minutes() {
|
||||
assert_eq!(
|
||||
last_played(&Some("2026-10-29T19:22:45Z".to_string())),
|
||||
"2026-10-29 19:22"
|
||||
);
|
||||
assert_eq!(last_played(&None), "从未启动");
|
||||
assert_eq!(last_played(&Some(String::new())), "从未启动");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! 日志页:会话列表、日志行、D8 诊断卡片。
|
||||
|
||||
use crate::{LogLevel, LogOutcome, UiDiagnosis, UiLogLine, UiLogSession};
|
||||
use unlucky_presenter::dto::{LogLevel as DtoLogLevel, LogOutcome as DtoLogOutcome};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
/// 日志会话列表。
|
||||
pub fn log_sessions(state: &AppState) -> Vec<UiLogSession> {
|
||||
state
|
||||
.log_sessions
|
||||
.iter()
|
||||
.map(|session| UiLogSession {
|
||||
id: session.id.clone().into(),
|
||||
when: session.when.clone().into(),
|
||||
instance: session.instance.clone().into(),
|
||||
outcome: match session.outcome {
|
||||
DtoLogOutcome::Ok => LogOutcome::Ok,
|
||||
DtoLogOutcome::Crashed => LogOutcome::Crashed,
|
||||
DtoLogOutcome::SilentFail => LogOutcome::SilentFail,
|
||||
},
|
||||
duration: duration(session.duration_secs).into(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 秒 → `1h03m` / `62s`。
|
||||
pub fn duration(secs: u64) -> String {
|
||||
if secs >= 3_600 {
|
||||
format!("{}h{:02}m", secs / 3_600, (secs % 3_600) / 60)
|
||||
} else if secs >= 60 {
|
||||
format!("{}m{:02}s", secs / 60, secs % 60)
|
||||
} else {
|
||||
format!("{secs}s")
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前会话日志行。
|
||||
pub fn log_lines(state: &AppState) -> Vec<UiLogLine> {
|
||||
state
|
||||
.log_lines
|
||||
.iter()
|
||||
.map(|line| UiLogLine {
|
||||
text: line.text.clone().into(),
|
||||
level: match line.level {
|
||||
DtoLogLevel::Debug => LogLevel::Debug,
|
||||
DtoLogLevel::Info => LogLevel::Info,
|
||||
DtoLogLevel::Warn => LogLevel::Warn,
|
||||
DtoLogLevel::Error => LogLevel::Error,
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 诊断卡片内容(无结论时返回默认值 + has=false)。
|
||||
pub fn diagnosis(state: &AppState) -> (UiDiagnosis, bool) {
|
||||
match &state.diagnosis {
|
||||
None => (UiDiagnosis::default(), false),
|
||||
Some(report) => {
|
||||
let diagnosis = &report.diagnosis;
|
||||
(
|
||||
UiDiagnosis {
|
||||
rule: diagnosis.rule.clone().into(),
|
||||
title: diagnosis.title.clone().into(),
|
||||
confidence: i32::from(diagnosis.confidence),
|
||||
hint: diagnosis.hint.clone().into(),
|
||||
has_fix: diagnosis.fix.is_some(),
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::duration;
|
||||
|
||||
#[test]
|
||||
fn duration_is_human_readable() {
|
||||
assert_eq!(duration(8), "8s");
|
||||
assert_eq!(duration(62), "1m02s");
|
||||
assert_eq!(duration(3_780), "1h03m");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! 状态层 → Slint 视图模型的单向转换(DTO 值 → `models.slint` 结构体)。
|
||||
//!
|
||||
//! 只做「领域值 → 展示值」的搬运与格式化,不含业务判断;
|
||||
//! 这样 `ui_bind.rs` 里的同步逻辑就是纯粹的「状态灌进属性」。
|
||||
//! 按领域拆子模块:instances / versions / logs / agent / ui,全量 `pub use`,
|
||||
//! 调用方统一写 `crate::conv::tiles(...)` 这类路径。
|
||||
|
||||
mod agent;
|
||||
mod instances;
|
||||
mod logs;
|
||||
mod pages;
|
||||
mod ui;
|
||||
mod versions;
|
||||
|
||||
pub use agent::{chat, dialog, toasts};
|
||||
pub use instances::{running_tiles, tiles};
|
||||
pub use logs::{diagnosis, log_lines, log_sessions};
|
||||
pub use pages::{
|
||||
accounts, active_account_name, device, install, java_chain, java_runtimes, java_vendors,
|
||||
notices, setting_groups, wizard, wizard_versions,
|
||||
};
|
||||
pub use ui::{
|
||||
accent_color, bg_fill, bg_fill_from, category_from, instance_view, instance_view_from, route,
|
||||
route_from, theme_mode, theme_mode_from, tile_size, tile_size_from, version_category,
|
||||
};
|
||||
pub use versions::versions;
|
||||
@@ -0,0 +1,301 @@
|
||||
//! 状态 → `page_state.slint` 的视图模型(账户 / Java / 安装 / 设置分组 / 通知 / 向导)。
|
||||
//!
|
||||
//! 与 `conv/{instances,versions,logs,agent}.rs` 同一套路:只做「领域值 → 展示值」的搬运,
|
||||
//! 不含业务判断。`ui_bind::sync` 把这些灌进 `PageState` global。
|
||||
|
||||
use slint::{ModelRc, SharedString, VecModel};
|
||||
|
||||
use crate::{
|
||||
AccountKind, InstallStage, JavaStepOutcome, NoticeKind, SettingControl as UiControl, UiAccount,
|
||||
UiDeviceCode, UiInstallProgress, UiJavaRuntime, UiJavaStep, UiJavaVendor, UiNotice,
|
||||
UiSettingGroup, UiSettingRow, UiVersion, UiWizard,
|
||||
};
|
||||
use unlucky_presenter::dto::{
|
||||
AccountKind as DtoAccountKind, DeviceLoginPhase, InstallStage as DtoInstallStage,
|
||||
JavaChainOutcome, NoticeLevel, SettingControl, VersionKind as DtoVersionKind,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
use unlucky_presenter::wizard::WizardStep;
|
||||
|
||||
fn strings(items: &[String]) -> ModelRc<SharedString> {
|
||||
ModelRc::new(VecModel::from(
|
||||
items
|
||||
.iter()
|
||||
.map(|item| SharedString::from(item.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 账户行。
|
||||
pub fn accounts(state: &AppState) -> Vec<UiAccount> {
|
||||
state
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|account| UiAccount {
|
||||
id: account.id.as_str().into(),
|
||||
name: account.name.as_str().into(),
|
||||
kind: match account.kind {
|
||||
DtoAccountKind::Offline => AccountKind::Offline,
|
||||
DtoAccountKind::Microsoft => AccountKind::Microsoft,
|
||||
DtoAccountKind::Authlib => AccountKind::Authlib,
|
||||
},
|
||||
kind_label: account.kind.label().into(),
|
||||
profile_id: account.profile_id.as_str().into(),
|
||||
login_name: account.login_name.as_str().into(),
|
||||
created: account.created.as_str().into(),
|
||||
active: account.active,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 当前启动账户的展示名(导航底部头像)。
|
||||
pub fn active_account_name(state: &AppState) -> String {
|
||||
state
|
||||
.accounts
|
||||
.iter()
|
||||
.find(|account| account.active)
|
||||
.map(|account| account.name.clone())
|
||||
.unwrap_or_else(|| "未登录".to_string())
|
||||
}
|
||||
|
||||
/// 微软登录面板。
|
||||
pub fn device(state: &AppState) -> UiDeviceCode {
|
||||
let view = &state.device_login;
|
||||
UiDeviceCode {
|
||||
visible: view.phase.visible(),
|
||||
busy: view.phase.busy(),
|
||||
user_code: view.user_code.as_str().into(),
|
||||
verification_uri: view.verification_uri.as_str().into(),
|
||||
qr_link: view.qr_link.as_str().into(),
|
||||
expires: i32::try_from(view.expires_in).unwrap_or(i32::MAX),
|
||||
message: view.message.as_str().into(),
|
||||
phase_label: match view.phase {
|
||||
DeviceLoginPhase::Idle => "空闲",
|
||||
DeviceLoginPhase::Requesting => "申请中",
|
||||
DeviceLoginPhase::Waiting => "等待确认",
|
||||
DeviceLoginPhase::Succeeded => "成功",
|
||||
DeviceLoginPhase::Failed => "失败",
|
||||
}
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Java 发行版行。
|
||||
pub fn java_vendors(state: &AppState) -> Vec<UiJavaVendor> {
|
||||
state
|
||||
.java
|
||||
.vendors
|
||||
.iter()
|
||||
.map(|vendor| UiJavaVendor {
|
||||
id: vendor.id.as_str().into(),
|
||||
display: vendor.display.as_str().into(),
|
||||
rank: i32::try_from(vendor.rank).unwrap_or(0),
|
||||
majors: vendor.majors.as_str().into(),
|
||||
sources: vendor.sources.join(" · ").into(),
|
||||
installed: vendor.installed,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Java 回退链。
|
||||
pub fn java_chain(state: &AppState) -> Vec<UiJavaStep> {
|
||||
state
|
||||
.java
|
||||
.chain
|
||||
.iter()
|
||||
.map(|step| UiJavaStep {
|
||||
vendor: step.vendor.as_str().into(),
|
||||
outcome: match step.outcome {
|
||||
JavaChainOutcome::Pending => JavaStepOutcome::Pending,
|
||||
JavaChainOutcome::Skip => JavaStepOutcome::Skip,
|
||||
JavaChainOutcome::Failed => JavaStepOutcome::Failed,
|
||||
JavaChainOutcome::Hit => JavaStepOutcome::Hit,
|
||||
},
|
||||
outcome_label: step.outcome.label().into(),
|
||||
detail: step.detail.as_str().into(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Java 本地运行时。
|
||||
pub fn java_runtimes(state: &AppState) -> Vec<UiJavaRuntime> {
|
||||
state
|
||||
.java
|
||||
.runtimes
|
||||
.iter()
|
||||
.map(|runtime| UiJavaRuntime {
|
||||
binary: runtime.binary.as_str().into(),
|
||||
version: runtime.version.as_str().into(),
|
||||
vendor: runtime.vendor.as_str().into(),
|
||||
origin: runtime.origin.as_str().into(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 安装进度卡片。
|
||||
pub fn install(state: &AppState) -> UiInstallProgress {
|
||||
let Some(progress) = &state.install else {
|
||||
return UiInstallProgress {
|
||||
visible: false,
|
||||
stage: InstallStage::Planning,
|
||||
..UiInstallProgress::default()
|
||||
};
|
||||
};
|
||||
UiInstallProgress {
|
||||
visible: true,
|
||||
version: progress.version.as_str().into(),
|
||||
instance: progress.instance.as_str().into(),
|
||||
loader: progress.loader.as_str().into(),
|
||||
stage: match progress.stage {
|
||||
DtoInstallStage::Planning => InstallStage::Planning,
|
||||
DtoInstallStage::Downloading => InstallStage::Downloading,
|
||||
DtoInstallStage::Installing => InstallStage::Installing,
|
||||
DtoInstallStage::Registering => InstallStage::Registering,
|
||||
DtoInstallStage::Done => InstallStage::Done,
|
||||
DtoInstallStage::Failed => InstallStage::Failed,
|
||||
DtoInstallStage::Cancelled => InstallStage::Cancelled,
|
||||
},
|
||||
stage_label: progress.stage.label().into(),
|
||||
ratio: progress.ratio(),
|
||||
known: progress.is_known(),
|
||||
files_label: progress.files_label().into(),
|
||||
bytes_label: if progress.bytes_total > 0 {
|
||||
format!(
|
||||
"{}/{}",
|
||||
unlucky_presenter::dto::human_bytes(progress.bytes_done),
|
||||
unlucky_presenter::dto::human_bytes(progress.bytes_total)
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
.into(),
|
||||
speed: progress.speed_label().into(),
|
||||
eta: progress.eta_label().into(),
|
||||
message: progress.message.as_str().into(),
|
||||
terminal: progress.stage.is_terminal(),
|
||||
failed: progress.stage == DtoInstallStage::Failed,
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置分组(行 + 控件)。
|
||||
pub fn setting_groups(state: &AppState) -> Vec<UiSettingGroup> {
|
||||
state
|
||||
.settings_groups
|
||||
.iter()
|
||||
.map(|group| UiSettingGroup {
|
||||
id: group.id.as_str().into(),
|
||||
title: group.title.as_str().into(),
|
||||
rows: ModelRc::new(VecModel::from(
|
||||
group
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| UiSettingRow {
|
||||
key: row.key.as_str().into(),
|
||||
label: row.label.as_str().into(),
|
||||
description: row.description.as_str().into(),
|
||||
control: match row.control {
|
||||
SettingControl::Toggle => UiControl::Toggle,
|
||||
SettingControl::Choice => UiControl::Choice,
|
||||
SettingControl::Text => UiControl::Text,
|
||||
SettingControl::ReadOnly => UiControl::Readonly,
|
||||
SettingControl::Action => UiControl::Action,
|
||||
},
|
||||
flag: row.flag,
|
||||
selected: row.selected,
|
||||
options: strings(&row.options),
|
||||
value_text: row.value_text.as_str().into(),
|
||||
action: row.action.as_str().into(),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 横幅通知。
|
||||
pub fn notices(state: &AppState) -> Vec<UiNotice> {
|
||||
state
|
||||
.notices
|
||||
.iter()
|
||||
.map(|notice| UiNotice {
|
||||
id: notice.id.as_str().into(),
|
||||
kind: match notice.level {
|
||||
NoticeLevel::Info => NoticeKind::Info,
|
||||
NoticeLevel::Warn => NoticeKind::Warn,
|
||||
NoticeLevel::Error => NoticeKind::Error,
|
||||
},
|
||||
title: notice.title.as_str().into(),
|
||||
body: notice.body.as_str().into(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 向导状态。
|
||||
pub fn wizard(state: &AppState) -> UiWizard {
|
||||
let wizard = &state.wizard;
|
||||
let step = wizard.step;
|
||||
let can_next = match step {
|
||||
WizardStep::Version => !wizard.version.trim().is_empty(),
|
||||
WizardStep::Name => unlucky_presenter::wizard::validate_name(&wizard.name).is_ok(),
|
||||
WizardStep::Loader | WizardStep::Confirm => true,
|
||||
WizardStep::Progress => false,
|
||||
};
|
||||
UiWizard {
|
||||
open: wizard.open,
|
||||
step: i32::try_from(step.index()).unwrap_or(0),
|
||||
step_label: step.title().into(),
|
||||
step_index: i32::try_from(step.index()).unwrap_or(0),
|
||||
step_total: 5,
|
||||
version: wizard.version.as_str().into(),
|
||||
loader: loader_index(wizard.loader),
|
||||
loader_label: if wizard.loader == unlucky_presenter::dto::LoaderKind::Vanilla {
|
||||
"原版".to_string()
|
||||
} else {
|
||||
format!("{} {}", wizard.loader.label(), wizard.loader_version.trim())
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
.into(),
|
||||
loader_version: wizard.loader_version.as_str().into(),
|
||||
name: wizard.name.as_str().into(),
|
||||
error: wizard.error.as_str().into(),
|
||||
busy: wizard.submitting,
|
||||
can_next,
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载器在向导里的单选序号(与 `wizard.slint` 的 LoaderChoice 顺序一致)。
|
||||
pub fn loader_index(loader: unlucky_presenter::dto::LoaderKind) -> i32 {
|
||||
match loader {
|
||||
unlucky_presenter::dto::LoaderKind::Vanilla => 0,
|
||||
unlucky_presenter::dto::LoaderKind::Fabric => 1,
|
||||
unlucky_presenter::dto::LoaderKind::Quilt => 2,
|
||||
unlucky_presenter::dto::LoaderKind::Forge => 3,
|
||||
unlucky_presenter::dto::LoaderKind::NeoForge => 4,
|
||||
unlucky_presenter::dto::LoaderKind::OptiFine
|
||||
| unlucky_presenter::dto::LoaderKind::LiteLoader => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 向导里的版本列表(正式版 + 快照;远古版走下载页的显式入口)。
|
||||
pub fn wizard_versions(state: &AppState) -> Vec<UiVersion> {
|
||||
state
|
||||
.versions
|
||||
.iter()
|
||||
.filter(|version| !version.kind.is_ancient())
|
||||
.map(|version| UiVersion {
|
||||
id: version.id.as_str().into(),
|
||||
kind: match version.kind {
|
||||
DtoVersionKind::Release => crate::VersionKind::Release,
|
||||
_ => crate::VersionKind::Snapshot,
|
||||
},
|
||||
released: version.released.as_str().into(),
|
||||
installed: version.installed,
|
||||
ancient: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pages_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,172 @@
|
||||
//! conv/pages.rs 的单测(拆文件守 400 行上限)。
|
||||
|
||||
use super::*;
|
||||
use slint::Model;
|
||||
use unlucky_presenter::dto::{
|
||||
AccountView, DeviceLoginView, InstallProgressView, JavaPageView, JavaVendorView, NoticeView,
|
||||
SettingGroupView, SettingRowView, VersionSummary,
|
||||
};
|
||||
use unlucky_presenter::wizard::WizardState;
|
||||
|
||||
#[test]
|
||||
fn accounts_and_device_login_render() {
|
||||
let mut state = AppState::new();
|
||||
state.accounts = vec![AccountView {
|
||||
id: "account:1".to_string(),
|
||||
name: "Steve".to_string(),
|
||||
kind: DtoAccountKind::Offline,
|
||||
profile_id: "0f8f".to_string(),
|
||||
login_name: String::new(),
|
||||
created: "2026-10-31 20:12".to_string(),
|
||||
active: true,
|
||||
}];
|
||||
state.device_login =
|
||||
DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "");
|
||||
|
||||
let rows = accounts(&state);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert!(rows[0].active);
|
||||
assert_eq!(rows[0].kind_label, "离线");
|
||||
assert_eq!(active_account_name(&state), "Steve");
|
||||
|
||||
let device = device(&state);
|
||||
assert!(device.visible && device.busy);
|
||||
assert_eq!(device.user_code, "ABCD-EFGH");
|
||||
assert_eq!(device.phase_label, "等待确认");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_login_panel_renders_the_authorization_link() {
|
||||
let mut state = AppState::new();
|
||||
state.device_login = DeviceLoginView::browser_waiting(
|
||||
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id=x",
|
||||
300,
|
||||
None,
|
||||
);
|
||||
let device = device(&state);
|
||||
assert!(device.visible && device.busy);
|
||||
assert_eq!(device.user_code, "", "授权码流没有用户码");
|
||||
assert!(device.verification_uri.contains("/authorize?client_id=x"));
|
||||
assert_eq!(device.qr_link, device.verification_uri);
|
||||
assert_eq!(device.expires, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_progress_renders_labels_and_terminal_flags() {
|
||||
let mut state = AppState::new();
|
||||
state.install = Some(InstallProgressView {
|
||||
version: "1.20.4".to_string(),
|
||||
instance: "生存".to_string(),
|
||||
loader: "Fabric 0.16.5".to_string(),
|
||||
stage: DtoInstallStage::Downloading,
|
||||
files_done: 3,
|
||||
files_total: 6,
|
||||
bytes_done: 1024,
|
||||
bytes_total: 4096,
|
||||
speed_bps: 2048,
|
||||
eta_secs: 2,
|
||||
message: "下载中".to_string(),
|
||||
});
|
||||
let ui = install(&state);
|
||||
assert!(ui.visible && !ui.terminal && !ui.failed);
|
||||
assert!((ui.ratio - 0.5).abs() < f32::EPSILON);
|
||||
assert_eq!(ui.files_label, "3/6 文件");
|
||||
assert_eq!(ui.speed, "2.0 KB/s");
|
||||
assert_eq!(ui.eta, "剩 2s");
|
||||
|
||||
state.install = None;
|
||||
assert!(!install(&state).visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_chain_and_vendors_map_outcomes() {
|
||||
let mut state = AppState::new();
|
||||
state.java = JavaPageView {
|
||||
vendors: vec![JavaVendorView {
|
||||
id: "temurin".to_string(),
|
||||
display: "Eclipse Temurin".to_string(),
|
||||
rank: 1,
|
||||
majors: "17/21".to_string(),
|
||||
sources: vec!["BMCLAPI".to_string(), "GitHub".to_string()],
|
||||
installed: true,
|
||||
}],
|
||||
chain: vec![unlucky_presenter::dto::JavaChainStep {
|
||||
vendor: "temurin".to_string(),
|
||||
outcome: JavaChainOutcome::Hit,
|
||||
detail: "21".to_string(),
|
||||
}],
|
||||
..JavaPageView::new()
|
||||
};
|
||||
let vendors = java_vendors(&state);
|
||||
assert_eq!(vendors.len(), 1);
|
||||
assert_eq!(vendors[0].sources, "BMCLAPI · GitHub");
|
||||
let chain = java_chain(&state);
|
||||
assert!(matches!(chain[0].outcome, JavaStepOutcome::Hit));
|
||||
assert_eq!(chain[0].outcome_label, "命中");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_groups_map_controls() {
|
||||
let mut state = AppState::new();
|
||||
state.settings_groups = vec![SettingGroupView::new("download", "下载")
|
||||
.with(SettingRowView::toggle("agent.mcp.enabled", "MCP", "", true))
|
||||
.with(SettingRowView::choice(
|
||||
"download.region",
|
||||
"地区",
|
||||
"",
|
||||
&["auto", "cn"],
|
||||
1,
|
||||
))];
|
||||
let groups = setting_groups(&state);
|
||||
assert_eq!(groups.len(), 1);
|
||||
let rows = groups[0].rows.clone();
|
||||
assert_eq!(rows.row_count(), 2);
|
||||
let first = rows.row_data(0).expect("第一行");
|
||||
let second = rows.row_data(1).expect("第二行");
|
||||
assert!(first.flag);
|
||||
assert!(matches!(second.control, UiControl::Choice));
|
||||
assert_eq!(second.selected, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notices_and_wizard_render() {
|
||||
let mut state = AppState::new();
|
||||
state.notices = vec![NoticeView::new(
|
||||
"isolation:x",
|
||||
NoticeLevel::Warn,
|
||||
"隔离档位已切换",
|
||||
"两侧都有内容",
|
||||
)];
|
||||
let notices = notices(&state);
|
||||
assert!(matches!(notices[0].kind, NoticeKind::Warn));
|
||||
|
||||
state.wizard = WizardState::open();
|
||||
state.wizard.select_version("1.20.4");
|
||||
let wizard = wizard(&state);
|
||||
assert!(wizard.open);
|
||||
assert_eq!(wizard.step_label, "选择版本");
|
||||
assert!(wizard.can_next);
|
||||
assert_eq!(wizard.loader, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wizard_versions_skip_ancient_ones() {
|
||||
let mut state = AppState::new();
|
||||
state.versions = vec![
|
||||
VersionSummary {
|
||||
id: "1.20.4".to_string(),
|
||||
kind: DtoVersionKind::Release,
|
||||
released: "2023-12-07".to_string(),
|
||||
installed: false,
|
||||
},
|
||||
VersionSummary {
|
||||
id: "rd-132211".to_string(),
|
||||
kind: DtoVersionKind::Ancient(unlucky_presenter::dto::AncientKind::Classic),
|
||||
released: "2009-05-13".to_string(),
|
||||
installed: false,
|
||||
},
|
||||
];
|
||||
let rows = wizard_versions(&state);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].id, "1.20.4");
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! UI 偏好/路由枚举的双向映射(Slint 枚举 ↔ 状态层枚举)。
|
||||
|
||||
use crate::{BgFillKind, DownloadCategory, InstanceView, RouteKind, ThemeMode, TileSize};
|
||||
use unlucky_presenter::dto::{self, VersionCategory};
|
||||
use unlucky_presenter::prefs;
|
||||
use unlucky_presenter::router;
|
||||
|
||||
/// 路由 → 导航高亮/页面选择。
|
||||
pub fn route(route: &router::Route) -> RouteKind {
|
||||
match route {
|
||||
router::Route::Instances => RouteKind::Instances,
|
||||
router::Route::Download => RouteKind::Download,
|
||||
router::Route::Logs => RouteKind::Logs,
|
||||
router::Route::Settings => RouteKind::Settings,
|
||||
router::Route::Detail(_) => RouteKind::Detail,
|
||||
router::Route::Agent => RouteKind::Agent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Slint 侧导航点击 → 路由(详情页保持当前实例,不在导航里触发)。
|
||||
pub fn route_from(kind: RouteKind) -> Option<router::Route> {
|
||||
match kind {
|
||||
RouteKind::Instances => Some(router::Route::Instances),
|
||||
RouteKind::Download => Some(router::Route::Download),
|
||||
RouteKind::Logs => Some(router::Route::Logs),
|
||||
RouteKind::Settings => Some(router::Route::Settings),
|
||||
RouteKind::Agent => Some(router::Route::Agent),
|
||||
RouteKind::Detail => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 磁贴尺寸。
|
||||
pub fn tile_size(size: prefs::TileSize) -> TileSize {
|
||||
match size {
|
||||
prefs::TileSize::Small => TileSize::Small,
|
||||
prefs::TileSize::Medium => TileSize::Medium,
|
||||
prefs::TileSize::Large => TileSize::Large,
|
||||
}
|
||||
}
|
||||
|
||||
/// 视图模式。
|
||||
pub fn instance_view(view: prefs::InstanceView) -> InstanceView {
|
||||
match view {
|
||||
prefs::InstanceView::Tiles => InstanceView::Tiles,
|
||||
prefs::InstanceView::List => InstanceView::List,
|
||||
}
|
||||
}
|
||||
|
||||
/// 主题三态。
|
||||
pub fn theme_mode(mode: prefs::ThemeMode) -> ThemeMode {
|
||||
match mode {
|
||||
prefs::ThemeMode::Auto => ThemeMode::Auto,
|
||||
prefs::ThemeMode::Light => ThemeMode::Light,
|
||||
prefs::ThemeMode::Dark => ThemeMode::Dark,
|
||||
}
|
||||
}
|
||||
|
||||
/// Slint 主题三态 → 偏好枚举。
|
||||
pub fn theme_mode_from(mode: ThemeMode) -> prefs::ThemeMode {
|
||||
match mode {
|
||||
ThemeMode::Auto => prefs::ThemeMode::Auto,
|
||||
ThemeMode::Light => prefs::ThemeMode::Light,
|
||||
ThemeMode::Dark => prefs::ThemeMode::Dark,
|
||||
}
|
||||
}
|
||||
|
||||
/// Slint 磁贴尺寸 → 偏好枚举。
|
||||
pub fn tile_size_from(size: TileSize) -> prefs::TileSize {
|
||||
match size {
|
||||
TileSize::Small => prefs::TileSize::Small,
|
||||
TileSize::Medium => prefs::TileSize::Medium,
|
||||
TileSize::Large => prefs::TileSize::Large,
|
||||
}
|
||||
}
|
||||
|
||||
/// Slint 视图模式 → 偏好枚举。
|
||||
pub fn instance_view_from(view: InstanceView) -> prefs::InstanceView {
|
||||
match view {
|
||||
InstanceView::Tiles => prefs::InstanceView::Tiles,
|
||||
InstanceView::List => prefs::InstanceView::List,
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载分类 → core 侧分类枚举。
|
||||
pub fn version_category(category: DownloadCategory) -> VersionCategory {
|
||||
match category {
|
||||
DownloadCategory::Release => VersionCategory::Release,
|
||||
DownloadCategory::Snapshot => VersionCategory::Snapshot,
|
||||
DownloadCategory::AncientClassic => VersionCategory::Ancient(dto::AncientKind::Classic),
|
||||
DownloadCategory::AncientAlpha => VersionCategory::Ancient(dto::AncientKind::OldAlpha),
|
||||
DownloadCategory::AncientBeta => VersionCategory::Ancient(dto::AncientKind::OldBeta),
|
||||
DownloadCategory::AncientUnlisted => VersionCategory::Ancient(dto::AncientKind::Unlisted),
|
||||
DownloadCategory::Modpack => VersionCategory::Modpack,
|
||||
DownloadCategory::Mod => VersionCategory::Mod,
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载分类(偏好 → Slint)——供启动时回填。
|
||||
pub fn category_from(category: VersionCategory) -> DownloadCategory {
|
||||
match category {
|
||||
VersionCategory::Release => DownloadCategory::Release,
|
||||
VersionCategory::Snapshot => DownloadCategory::Snapshot,
|
||||
VersionCategory::Ancient(dto::AncientKind::Classic) => DownloadCategory::AncientClassic,
|
||||
VersionCategory::Ancient(dto::AncientKind::OldAlpha) => DownloadCategory::AncientAlpha,
|
||||
VersionCategory::Ancient(dto::AncientKind::OldBeta) => DownloadCategory::AncientBeta,
|
||||
VersionCategory::Ancient(dto::AncientKind::Unlisted) => DownloadCategory::AncientUnlisted,
|
||||
VersionCategory::Modpack => DownloadCategory::Modpack,
|
||||
VersionCategory::Mod => DownloadCategory::Mod,
|
||||
}
|
||||
}
|
||||
|
||||
/// 背景图填充模式(core 侧枚举 → Slint)。
|
||||
pub fn bg_fill(fill: unlucky_core::setting::appearance::BgFill) -> BgFillKind {
|
||||
match fill {
|
||||
unlucky_core::setting::appearance::BgFill::Cover => BgFillKind::Cover,
|
||||
unlucky_core::setting::appearance::BgFill::Contain => BgFillKind::Contain,
|
||||
unlucky_core::setting::appearance::BgFill::Tile => BgFillKind::Tile,
|
||||
}
|
||||
}
|
||||
|
||||
/// Slint 填充模式 → core 侧枚举。
|
||||
pub fn bg_fill_from(fill: BgFillKind) -> unlucky_core::setting::appearance::BgFill {
|
||||
match fill {
|
||||
BgFillKind::Cover => unlucky_core::setting::appearance::BgFill::Cover,
|
||||
BgFillKind::Contain => unlucky_core::setting::appearance::BgFill::Contain,
|
||||
BgFillKind::Tile => unlucky_core::setting::appearance::BgFill::Tile,
|
||||
}
|
||||
}
|
||||
|
||||
/// 强调色十六进制串 → Slint 颜色;空串/非法值给透明(= 用出厂 accent)。
|
||||
pub fn accent_color(hex: &str) -> slint::Color {
|
||||
match unlucky_core::setting::appearance::hex_to_rgb(hex) {
|
||||
Some((r, g, b)) => slint::Color::from_rgb_u8(r, g, b),
|
||||
None => slint::Color::from_argb_u8(0, 0, 0, 0),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//! 版本列表与下载分类映射(下载页)。
|
||||
|
||||
use crate::{UiVersion, VersionKind};
|
||||
use unlucky_presenter::dto::{
|
||||
AncientKind, VersionCategory, VersionKind as DtoVersionKind, VersionSummary,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
/// 分类侧栏 → 该分类下的版本(远古版按子类细分)。
|
||||
pub fn in_category(category: VersionCategory, kind: DtoVersionKind) -> bool {
|
||||
match (category, kind) {
|
||||
(VersionCategory::Release, DtoVersionKind::Release) => true,
|
||||
(VersionCategory::Snapshot, DtoVersionKind::Snapshot) => true,
|
||||
(VersionCategory::Ancient(expected), DtoVersionKind::Ancient(actual)) => expected == actual,
|
||||
// 未接线的分类(整合包 / Mod)不显示任何版本,而不是把全部版本倒进去
|
||||
(VersionCategory::Modpack | VersionCategory::Mod, _) => false,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本行(下载页与新建向导共用)。
|
||||
pub fn version_rows(versions: &[VersionSummary]) -> Vec<UiVersion> {
|
||||
versions
|
||||
.iter()
|
||||
.map(|version| UiVersion {
|
||||
id: version.id.clone().into(),
|
||||
kind: version_kind(version.kind),
|
||||
released: version.released.clone().into(),
|
||||
installed: version.installed,
|
||||
ancient: version.kind.is_ancient(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 下载页版本行(按当前分类 + 「仅已装」+ 版本号筛选词过滤)。
|
||||
pub fn versions(state: &AppState) -> Vec<UiVersion> {
|
||||
let query = state.download_query.trim().to_lowercase();
|
||||
let filtered: Vec<VersionSummary> = state
|
||||
.versions
|
||||
.iter()
|
||||
.filter(|version| in_category(state.prefs.download_category, version.kind))
|
||||
.filter(|version| !state.installed_only || version.installed)
|
||||
.filter(|version| query.is_empty() || version.id.to_lowercase().contains(&query))
|
||||
.cloned()
|
||||
.collect();
|
||||
version_rows(&filtered)
|
||||
}
|
||||
|
||||
fn version_kind(kind: DtoVersionKind) -> VersionKind {
|
||||
match kind {
|
||||
DtoVersionKind::Release => VersionKind::Release,
|
||||
DtoVersionKind::Snapshot => VersionKind::Snapshot,
|
||||
DtoVersionKind::Ancient(AncientKind::Classic) => VersionKind::AncientClassic,
|
||||
DtoVersionKind::Ancient(AncientKind::OldAlpha) => VersionKind::AncientAlpha,
|
||||
DtoVersionKind::Ancient(AncientKind::OldBeta) => VersionKind::AncientBeta,
|
||||
DtoVersionKind::Ancient(AncientKind::Unlisted) => VersionKind::AncientUnlisted,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::versions;
|
||||
use unlucky_presenter::dto::{VersionKind, VersionSummary};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
fn state_with_versions() -> AppState {
|
||||
let mut state = AppState::new();
|
||||
let row = |id: &str, installed: bool| VersionSummary {
|
||||
id: id.to_string(),
|
||||
kind: VersionKind::Release,
|
||||
released: "2026-01-01".to_string(),
|
||||
installed,
|
||||
};
|
||||
state.versions = vec![
|
||||
row("1.21.9", false),
|
||||
row("1.20.4", true),
|
||||
row("1.20.1", true),
|
||||
];
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_only_filter_hides_uninstalled_versions() {
|
||||
let mut state = state_with_versions();
|
||||
assert_eq!(versions(&state).len(), 3);
|
||||
|
||||
state.installed_only = true;
|
||||
let filtered = versions(&state);
|
||||
assert_eq!(filtered.len(), 2);
|
||||
assert!(filtered.iter().all(|version| version.installed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_filters_by_version_id_substring() {
|
||||
let mut state = state_with_versions();
|
||||
state.download_query = "1.20".to_string();
|
||||
let filtered = versions(&state);
|
||||
assert_eq!(filtered.len(), 2);
|
||||
|
||||
state.download_query = "1.21".to_string();
|
||||
state.installed_only = true;
|
||||
// 「仅已装」优先:1.21.9 未安装 → 空列表
|
||||
assert!(versions(&state).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Unlucky-Launcher 桌面壳(`unlucky_app`):Slint UI + Metro 视觉。
|
||||
//!
|
||||
//! 边界(决策 D3 / D16):本 crate 是**唯一**允许依赖 UI 框架的 crate;
|
||||
//! 它只经「快照 + 事件流 + 注入的显示服务」消费 core/agent,不直接触碰其内部类型
|
||||
//! (P0 的 host 是 `mock` 假后端,`unlucky_presenter::dto` 是唯一跨界类型集合)。
|
||||
//!
|
||||
//! 渲染策略(`DevDocs/PlanDoc/01c-native-gui-options.md` §6):
|
||||
//! GL 优先(femtovg),启动参数 `--software-renderer` 或 Win7 老机探测失败时切 CPU 软渲染。
|
||||
//!
|
||||
//! 主题与外观(D17 / 09 §101-113):
|
||||
//! - 主题三态 `appearance.theme_mode ∈ {auto, light, dark}`(出厂 auto);
|
||||
//! - 「外观(高级)」四件(背景图 / 面板半透明度 / 玻璃拟态 / 强调色)出厂全关;
|
||||
//! - 全部走 `core::setting` 的 schema(`unlucky_presenter::settings_bridge`),AI 可写并且**热切**
|
||||
//! (tick 里 1s 一次 mtime 比对);系统主题由 `core::platform::theme_probe` 对齐 + 兜底轮询。
|
||||
//!
|
||||
//! 一个二进制两种用法(M82):`unlucky-launcher` 不带子命令时起 GUI;带子命令
|
||||
//! (`instances` / `install` / `launch` / `accounts` / `sessions` / `logs` / `diag` /
|
||||
//! `config` / `java` / `agent`)时直接转给 `unlucky_cli::run`,**不初始化任何 UI**。
|
||||
//! 子命令表不在这里抄一份——唯一真相源是 `crates/cli` 的解析器(见
|
||||
//! `unlucky_cli::cli::looks_like_cli`)。
|
||||
//! 零 UI 依赖的 `ull` 仍然保留:它连 fontconfig 都不需要,给服务器 / CI / Termux 用。
|
||||
//!
|
||||
//! 架构分层(本文件只有一个职责:分发 + 把窗口拉起来):
|
||||
//! - `state.rs` / `prefs.rs` / `router.rs` / `dialogs.rs` / `toast.rs`:纯 Rust 状态(可单测,无 Slint)
|
||||
//! - `dto/`:跨边界 DTO 与事件/指令
|
||||
//! - `conv.rs`:状态 → Slint 视图模型
|
||||
//! - `ui_bind.rs` + `callbacks.rs`:窗口/状态/后端缝合 + 回调接线
|
||||
//! - `settings_bridge.rs`:`config/launcher.json` 的读写与热切(core::setting 的 schema API)
|
||||
//! - `bg_image.rs` + `png.rs`:背景图/模糊层加工与截图编码(09 §101/§103 的预生成手法)
|
||||
//! - `mock.rs` + `mock_data.rs`:P0 假后端(脚本化事件)
|
||||
//!
|
||||
//! 关于 unsafe:本 crate 不写 unsafe,workspace lint 已设 `unsafe_code = "deny"`。
|
||||
//! 这里不能升级成 `#![forbid(unsafe_code)]`——Slint 生成的代码自带 `#[allow(unsafe_code)]`,
|
||||
//! 而 forbid 无法被内层 allow 覆盖(会直接编译失败)。同理 Slint 生成类型不带文档注释,
|
||||
//! 只能在 crate 级放开 `missing_docs`(库 crate 仍由 workspace lint 保持 warn)。
|
||||
// 关于 dead_code:P0 骨架里有一批「已定义、等接线」的公共 API——事件通道 / 偏好键登记 /
|
||||
// 诊断文案 / mock 数据构造器等,它们被单测覆盖但业务侧要 P1 才用到。逐条 `#[allow]`
|
||||
// 会把噪音藏进实现细节,这里统一放开并在 P1 接线时逐个转正(届时删掉本行即可暴露真死码)。
|
||||
#![allow(missing_docs, dead_code)]
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
mod appearance_bind;
|
||||
mod bg_image;
|
||||
mod callbacks;
|
||||
mod callbacks_pages;
|
||||
mod conv;
|
||||
mod page_sync;
|
||||
mod png;
|
||||
mod screenshot;
|
||||
mod theme_runtime;
|
||||
mod ui_bind;
|
||||
|
||||
slint::include_modules!();
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if let Some(code) = run_cli_if_requested(&args) {
|
||||
return code;
|
||||
}
|
||||
match run_gui() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("[run] {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 把窗口拉起来(分发之后的唯一路径)。
|
||||
fn run_gui() -> Result<(), slint::PlatformError> {
|
||||
apply_renderer_flag();
|
||||
|
||||
let app = ui_bind::AppUi::with_options(MainWindow::new()?, screenshot::app_options());
|
||||
|
||||
// 截图验收通道(M41 视觉验收;无交互桌面/无 GPU 环境下的唯一可靠取图方式:
|
||||
// 不截屏幕,而是让 Slint 把当前场景渲染进内存像素缓冲再自己编码 PNG)。
|
||||
// P3 追加页面选择与中间态(安装进度 / 启动事件流),见 `crate::screenshot`。
|
||||
if let Some(options) = screenshot::shot_options() {
|
||||
screenshot::capture(&app, &options)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
app.start_timer();
|
||||
app.run()
|
||||
}
|
||||
|
||||
/// 子命令命中 CLI 就跑 CLI 并以它的退出码收尾;返回 `None` 表示「这不是 CLI 调用,去开窗」。
|
||||
///
|
||||
/// 这条路径上**不构造窗口、不探后端、不扫字体、不依赖任何图形库**(这些库缺失时同一条命令照样
|
||||
/// 跑完,实测见 packaging/README.md §7.4)。`unlucky-launcher launch …` 的耗时因此与首帧无关——
|
||||
/// 这句是结构性判断,**没有重量**(原因见 §10-8)。
|
||||
///
|
||||
/// 「这条命令行算不算 CLI 调用」的判据不在这里重复:`unlucky_cli::cli::looks_like_cli` 是唯一
|
||||
/// 一处(M82 复审去重),两个 GUI 二进制都只调它,改语义不会漂。
|
||||
fn run_cli_if_requested(args: &[String]) -> Option<ExitCode> {
|
||||
if !unlucky_cli::cli::looks_like_cli(args) {
|
||||
return None;
|
||||
}
|
||||
let mut stdout = std::io::stdout();
|
||||
let mut stderr = std::io::stderr();
|
||||
let code = unlucky_cli::run(args, &mut stdout, &mut stderr);
|
||||
let _ = std::io::Write::flush(&mut stdout);
|
||||
let _ = std::io::Write::flush(&mut stderr);
|
||||
Some(ExitCode::from(u8::try_from(code).unwrap_or(2)))
|
||||
}
|
||||
|
||||
/// Win7 老机 / 无可用 OpenGL 驱动时的软渲染兜底开关(01c §6 的 Slint 核心卖点)。
|
||||
fn apply_renderer_flag() {
|
||||
let software = std::env::args()
|
||||
.skip(1)
|
||||
.any(|arg| arg == "--software-renderer");
|
||||
if software {
|
||||
std::env::set_var("SLINT_BACKEND", "winit-software");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! `PageState` global 的状态灌入(从 `ui_bind` 拆出,守 400 行上限)。
|
||||
//!
|
||||
//! 一个函数只做「AppState → Slint global 属性」的搬运,同步发生在每一拍末尾
|
||||
//! (`ui_bind::Inner::sync`)。分文件而不是砍内容:这些字段每一行都对应一个页面元素。
|
||||
|
||||
use crate::conv;
|
||||
use crate::ui_bind::model;
|
||||
use crate::{MainWindow, PageState};
|
||||
use slint::ComponentHandle;
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
/// 把页面状态灌进 `PageState`。
|
||||
pub(crate) fn apply(ui: &MainWindow, state: &AppState) {
|
||||
// 导航底部账户 + PageState 全量页面状态(账户/Java/安装/设置/通知/向导)
|
||||
ui.set_account_name(conv::active_account_name(state).into());
|
||||
let page = ui.global::<PageState<'_>>();
|
||||
page.set_accounts(model(conv::accounts(state)));
|
||||
page.set_active_account(conv::active_account_name(state).into());
|
||||
page.set_device(conv::device(state));
|
||||
page.set_java_vendors(model(conv::java_vendors(state)));
|
||||
page.set_java_chain(model(conv::java_chain(state)));
|
||||
page.set_java_runtimes(model(conv::java_runtimes(state)));
|
||||
page.set_java_order(state.java.order_text.clone().into());
|
||||
page.set_java_region(state.java.region.clone().into());
|
||||
page.set_java_priority_source(state.java.priority_source.clone().into());
|
||||
page.set_java_message(state.java.message.clone().into());
|
||||
page.set_java_busy(state.java.busy);
|
||||
let major = state.java.majors().last().copied().unwrap_or(17);
|
||||
page.set_java_target_major(i32::try_from(major).unwrap_or(17));
|
||||
page.set_install(conv::install(state));
|
||||
page.set_setting_groups(model(conv::setting_groups(state)));
|
||||
page.set_setting_tab(state.settings_tab.clone().into());
|
||||
page.set_notices(model(conv::notices(state)));
|
||||
page.set_wizard(conv::wizard(state));
|
||||
page.set_wizard_versions(model(conv::wizard_versions(state)));
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! 极简 PNG 编码器(只做「无压缩 stored deflate」),给截图验收用。
|
||||
//!
|
||||
//! 为什么不引 `image` / `png` crate:截屏只是开发期验收工具(`--screenshot`),
|
||||
//! 为它加一个依赖违反「新依赖过 deny.toml 白名单」的成本收益。PNG 规范里 zlib 流允许
|
||||
//! **stored(未压缩)块**,于是我们只需要 CRC32 + Adler32 两个校验和就能产出合法 PNG——
|
||||
//! 文件大一些,但任何看图工具都认。
|
||||
//!
|
||||
//! 输出:8bit RGBA(color type 6),逐行 filter=0(None)。
|
||||
|
||||
use slint::{Rgba8Pixel, SharedPixelBuffer};
|
||||
|
||||
/// 把 RGBA8 像素缓冲写成 PNG 字节流。
|
||||
#[must_use]
|
||||
pub fn encode_rgba8(buffer: &SharedPixelBuffer<Rgba8Pixel>) -> Vec<u8> {
|
||||
let width = buffer.width();
|
||||
let height = buffer.height();
|
||||
let pixels = buffer.as_slice();
|
||||
|
||||
// 原始扫描线:每行前面加一个 filter 字节 0
|
||||
let stride = width as usize * 4;
|
||||
let mut raw = Vec::with_capacity((stride + 1) * height as usize);
|
||||
for y in 0..height as usize {
|
||||
raw.push(0u8);
|
||||
for x in 0..width as usize {
|
||||
let pixel = pixels[y * width as usize + x];
|
||||
raw.extend_from_slice(&[pixel.r, pixel.g, pixel.b, pixel.a]);
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
let mut ihdr = Vec::with_capacity(13);
|
||||
ihdr.extend_from_slice(&width.to_be_bytes());
|
||||
ihdr.extend_from_slice(&height.to_be_bytes());
|
||||
ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); // 位深 8、RGBA、deflate、无交错
|
||||
push_chunk(&mut out, b"IHDR", &ihdr);
|
||||
push_chunk(&mut out, b"IDAT", &zlib_stored(&raw));
|
||||
push_chunk(&mut out, b"IEND", &[]);
|
||||
out
|
||||
}
|
||||
|
||||
fn push_chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
|
||||
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(kind);
|
||||
out.extend_from_slice(data);
|
||||
let mut crc_input = Vec::with_capacity(4 + data.len());
|
||||
crc_input.extend_from_slice(kind);
|
||||
crc_input.extend_from_slice(data);
|
||||
out.extend_from_slice(&crc32(&crc_input).to_be_bytes());
|
||||
}
|
||||
|
||||
/// zlib 容器 + 全 stored deflate 块(每块上限 65535 字节)。
|
||||
fn zlib_stored(raw: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(raw.len() + raw.len() / 65_535 * 5 + 16);
|
||||
out.extend_from_slice(&[0x78, 0x01]); // CMF/FLG:deflate、32K 窗口、无字典、最快
|
||||
let mut offset = 0usize;
|
||||
while offset < raw.len() {
|
||||
let len = (raw.len() - offset).min(65_535);
|
||||
let last = offset + len >= raw.len();
|
||||
out.push(u8::from(last)); // BFINAL + BTYPE=00
|
||||
let len_u16 = len as u16;
|
||||
out.extend_from_slice(&len_u16.to_le_bytes());
|
||||
out.extend_from_slice(&(!len_u16).to_le_bytes());
|
||||
out.extend_from_slice(&raw[offset..offset + len]);
|
||||
offset += len;
|
||||
}
|
||||
if raw.is_empty() {
|
||||
out.push(1);
|
||||
out.extend_from_slice(&[0, 0, 0xff, 0xff]);
|
||||
}
|
||||
out.extend_from_slice(&adler32(raw).to_be_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn crc32(data: &[u8]) -> u32 {
|
||||
let mut crc = 0xffff_ffffu32;
|
||||
for byte in data {
|
||||
crc ^= u32::from(*byte);
|
||||
for _ in 0..8 {
|
||||
let mask = (crc & 1).wrapping_neg();
|
||||
crc = (crc >> 1) ^ (0xedb8_8320 & mask);
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
|
||||
fn adler32(data: &[u8]) -> u32 {
|
||||
let mut a: u32 = 1;
|
||||
let mut b: u32 = 0;
|
||||
for byte in data {
|
||||
a = (a + u32::from(*byte)) % 65_521;
|
||||
b = (b + a) % 65_521;
|
||||
}
|
||||
(b << 16) | a
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pixel(r: u8, g: u8, b: u8) -> Rgba8Pixel {
|
||||
Rgba8Pixel { r, g, b, a: 255 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_and_chunks_are_well_formed() {
|
||||
let mut buffer = SharedPixelBuffer::<Rgba8Pixel>::new(3, 2);
|
||||
buffer.make_mut_slice().fill(pixel(1, 2, 3));
|
||||
let bytes = encode_rgba8(&buffer);
|
||||
assert_eq!(
|
||||
&bytes[..8],
|
||||
&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]
|
||||
);
|
||||
// IHDR:宽 3 高 2、8bit、RGBA(6)
|
||||
assert_eq!(&bytes[8..12], &13u32.to_be_bytes());
|
||||
assert_eq!(&bytes[12..16], b"IHDR");
|
||||
assert_eq!(&bytes[16..20], &3u32.to_be_bytes());
|
||||
assert_eq!(&bytes[20..24], &2u32.to_be_bytes());
|
||||
assert_eq!(bytes[24], 8);
|
||||
assert_eq!(bytes[25], 6);
|
||||
// 结尾是 IEND 块
|
||||
assert_eq!(&bytes[bytes.len() - 8..bytes.len() - 4], b"IEND");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checksums_match_known_vectors() {
|
||||
// CRC32("IEND") 是公认值 0xAE426082(写进 IEND 块尾)
|
||||
assert_eq!(crc32(b"IEND"), 0xae42_6082);
|
||||
// Adler32("Wikipedia") = 0x11E60398
|
||||
assert_eq!(adler32(b"Wikipedia"), 0x11e6_0398);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_buffer_still_produces_valid_stream() {
|
||||
let buffer = SharedPixelBuffer::<Rgba8Pixel>::new(0, 0);
|
||||
let bytes = encode_rgba8(&buffer);
|
||||
assert!(bytes.windows(4).any(|window| window == b"IDAT"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! 截图验收通道的参数解析与流程(`--screenshot*` / `--data-dir` / `--mock`)。
|
||||
//!
|
||||
//! 为什么用进程内渲染而不是截屏:验收机没有交互桌面(`CopyFromScreen` 直接失败),
|
||||
//! 而 Slint 的软件渲染器能把场景渲进内存像素缓冲(`Window::take_snapshot`),
|
||||
//! 结果与屏幕上逐像素一致且不需要 GPU(M41 落地的通道,这里只加页面/中间态参数)。
|
||||
//!
|
||||
//! 中间态(安装进度 / 启动事件流)走真实后端:`--screenshot-install <版本>` 会真的发起
|
||||
//! 一次安装并停若干毫秒后取图,`--screenshot-launch <实例>` 同理。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::ui_bind::AppUi;
|
||||
use unlucky_presenter::backend::AppOptions;
|
||||
use unlucky_presenter::dto::AppCommand;
|
||||
|
||||
/// 一次截图请求。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ShotOptions {
|
||||
/// 输出 PNG 路径
|
||||
pub path: String,
|
||||
/// 页面名(instances / download / java / logs / settings / account / appearance / agent)
|
||||
pub page: Option<String>,
|
||||
/// 打开新建实例向导
|
||||
pub wizard: bool,
|
||||
/// 发起一次真实安装并停 `dwell_ms` 后取图(中间态)
|
||||
pub install: Option<String>,
|
||||
/// 安装走哪个加载器(`--screenshot-loader forge`;缺省 = 原版)
|
||||
pub loader: Option<String>,
|
||||
/// 发起一次真实启动并停 `dwell_ms` 后取图(中间态)
|
||||
pub launch: Option<String>,
|
||||
/// 打开一个日志会话(日志页有数据态:日志行 + 诊断卡片)
|
||||
pub session: Option<String>,
|
||||
/// 中间态停留毫秒(默认 2500)
|
||||
pub dwell_ms: u64,
|
||||
}
|
||||
|
||||
impl ShotOptions {
|
||||
/// 中间态默认停留时长。
|
||||
pub const DEFAULT_DWELL_MS: u64 = 2_500;
|
||||
}
|
||||
|
||||
/// 从命令行读启动选项(数据根 / mock)。
|
||||
pub fn app_options() -> AppOptions {
|
||||
AppOptions {
|
||||
data_root: value_of("--data-dir").map(PathBuf::from),
|
||||
mock: flag("--mock"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从命令行读截图请求;没有 `--screenshot` 时返回 `None`。
|
||||
pub fn shot_options() -> Option<ShotOptions> {
|
||||
let path = value_of("--screenshot")?;
|
||||
let page = value_of("--screenshot-page");
|
||||
let mut options = ShotOptions {
|
||||
path,
|
||||
page: if flag("--screenshot-settings") {
|
||||
Some("appearance".to_string())
|
||||
} else {
|
||||
page
|
||||
},
|
||||
wizard: flag("--screenshot-wizard"),
|
||||
install: value_of("--screenshot-install"),
|
||||
loader: value_of("--screenshot-loader"),
|
||||
launch: value_of("--screenshot-launch"),
|
||||
session: value_of("--screenshot-session"),
|
||||
dwell_ms: value_of("--screenshot-dwell")
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(ShotOptions::DEFAULT_DWELL_MS),
|
||||
};
|
||||
if options.dwell_ms < 500 {
|
||||
options.dwell_ms = 500;
|
||||
}
|
||||
options.page = options.page.filter(|page| !page.trim().is_empty());
|
||||
Some(options)
|
||||
}
|
||||
|
||||
/// 执行截图流程:切页 → (可选)中间态 → 渲染 PNG。
|
||||
///
|
||||
/// # Errors
|
||||
/// 窗口显示/快照/写文件失败时返回错误。
|
||||
pub fn capture(app: &AppUi, options: &ShotOptions) -> Result<(), slint::PlatformError> {
|
||||
if let Some(page) = &options.page {
|
||||
app.open_page(page);
|
||||
// 先给后端一次全量刷新:版本清单要在后台线程里拉(12h 缓存),
|
||||
// 下载页/Java 页/向导需要它才有「有数据态」。
|
||||
let warmup = if matches!(page.as_str(), "download" | "java") {
|
||||
4_000
|
||||
} else {
|
||||
1_200
|
||||
};
|
||||
app.dispatch_and_run(AppCommand::RefreshAll, warmup);
|
||||
}
|
||||
if options.wizard {
|
||||
app.open_wizard();
|
||||
}
|
||||
if let Some(version) = &options.install {
|
||||
app.dispatch_and_run(
|
||||
install_command(version, options.loader.as_deref()),
|
||||
options.dwell_ms,
|
||||
);
|
||||
}
|
||||
if let Some(session) = &options.session {
|
||||
app.open_session(session);
|
||||
app.dispatch_and_run(AppCommand::RefreshLogSessions, options.dwell_ms.min(1_000));
|
||||
}
|
||||
if let Some(instance) = &options.launch {
|
||||
app.dispatch_and_run(
|
||||
AppCommand::LaunchInstance(instance.clone()),
|
||||
options.dwell_ms,
|
||||
);
|
||||
}
|
||||
app.render_screenshot(&options.path)?;
|
||||
// 启动中间态截完就把进程收掉:截图进程退出后子进程会变孤儿,
|
||||
// 所以在取图之后显式 stop(kill_tree),不留给系统。
|
||||
if let Some(instance) = &options.launch {
|
||||
app.dispatch_and_run(AppCommand::StopInstance(instance.clone()), 1_500);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 截图通道的安装指令:缺省原版,给了 `--screenshot-loader` 就走向导同一条路
|
||||
/// (`CreateInstance`),因此 Forge/NeoForge 的中间态也能取图。
|
||||
fn install_command(version: &str, loader: Option<&str>) -> AppCommand {
|
||||
match loader.map(unlucky_presenter::dto::LoaderKind::parse) {
|
||||
Some(Some(kind)) => AppCommand::CreateInstance(unlucky_presenter::dto::CreateRequest {
|
||||
version: version.to_string(),
|
||||
loader: kind,
|
||||
loader_version: String::new(),
|
||||
name: version.to_string(),
|
||||
}),
|
||||
_ => AppCommand::InstallVersion(version.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `--flag` 是否存在。
|
||||
pub fn flag(name: &str) -> bool {
|
||||
std::env::args().skip(1).any(|arg| arg == name)
|
||||
}
|
||||
|
||||
/// `--key value` / `--key=value` 的取值。
|
||||
pub fn value_of(name: &str) -> Option<String> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
if let Some(value) = arg.strip_prefix(&format!("{name}=")) {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
if arg == name {
|
||||
return args.next();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_dwell_is_a_visible_mid_state() {
|
||||
const DEFAULT: u64 = ShotOptions::DEFAULT_DWELL_MS;
|
||||
let dwell = DEFAULT;
|
||||
assert!(dwell >= 1_000, "中间态至少要停 1 秒才看得见进度");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_without_screenshot_are_none() {
|
||||
// 测试进程没有 --screenshot 参数
|
||||
assert!(
|
||||
shot_options().is_none() || std::env::args().any(|arg| arg.contains("--screenshot"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_of_ignores_absent_flags() {
|
||||
assert!(value_of("--definitely-not-a-real-flag").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! 主题与外观的运行期逻辑(从 `ui_bind.rs` 拆出,守住单文件 ≤400 行)。
|
||||
//!
|
||||
//! 全是 `impl Inner` 的方法:配置热切轮询、系统主题对齐、外观令牌下发、背景图/模糊层重建。
|
||||
//! 拆出来的边界是「时间驱动的状态维护」——`ui_bind.rs` 只留「建窗口 + 每拍驱动 + 灌属性」。
|
||||
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::bg_image::{self, BackgroundLayers};
|
||||
use crate::conv;
|
||||
use crate::ui_bind::Inner;
|
||||
use unlucky_presenter::prefs::{AppearancePrefs, BgFill, ThemeMode};
|
||||
use unlucky_presenter::theme::{
|
||||
config_poll_due, system_theme_poll_due, system_theme_value, BackgroundKey,
|
||||
};
|
||||
use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
impl Inner {
|
||||
pub(crate) fn poll_settings(&self) {
|
||||
if !config_poll_due(self.ticks.get()) {
|
||||
return;
|
||||
}
|
||||
let Some((appearance, theme)) = self.bridge.borrow_mut().reload_if_changed() else {
|
||||
return;
|
||||
};
|
||||
{
|
||||
let mut state = self.state.borrow_mut();
|
||||
state.prefs.appearance = appearance;
|
||||
state.prefs.theme = theme;
|
||||
}
|
||||
self.apply_theme();
|
||||
self.apply_appearance();
|
||||
}
|
||||
|
||||
/// 系统主题对齐:启动时先读一次真实系统主题(避免 auto 首帧按浅色画再翻黑);
|
||||
/// 之后仅在「auto + 后端不认识系统主题」(`Theme.system-theme-known == false`)时兜底轮询。
|
||||
pub(crate) fn poll_system_theme(&self) {
|
||||
let auto = matches!(self.state.borrow().prefs.theme, ThemeMode::Auto);
|
||||
let backend_known = self.ui.get_system_theme_known();
|
||||
let due = system_theme_poll_due(
|
||||
self.system_theme_probed.get(),
|
||||
auto,
|
||||
backend_known,
|
||||
self.ticks.get(),
|
||||
);
|
||||
if !due {
|
||||
return;
|
||||
}
|
||||
self.system_theme_probed.set(true);
|
||||
let value = system_theme_value();
|
||||
if self.ui.get_system_theme() != value {
|
||||
self.ui.set_system_theme(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动时的系统主题对齐(首帧前调用一次)。
|
||||
pub(crate) fn align_system_theme(&self) {
|
||||
self.poll_system_theme();
|
||||
}
|
||||
|
||||
/// 主题三态 → 根组件属性;`ui/main.slint` 里的 changed 处理器再赋给 `Theme` 全局
|
||||
/// (auto 时 Theme 内部读系统的 `Palette.color-scheme` 与本进程探测到的系统主题)。
|
||||
pub(crate) fn apply_theme(&self) {
|
||||
let mode = self.state.borrow().prefs.theme;
|
||||
self.ui.set_theme_mode(conv::theme_mode(mode));
|
||||
}
|
||||
|
||||
/// 外观(高级)四件 → 根组件属性(进而写进 `Theme` 全局)。
|
||||
///
|
||||
/// 背景图只在「开关打开 + 路径非空 + 换了输入」时才重新解码/生成模糊层。
|
||||
pub(crate) fn apply_appearance(&self) {
|
||||
let appearance = self.state.borrow().prefs.appearance.clone();
|
||||
self.ui.set_bg_fill(conv::bg_fill(appearance.bg_fill));
|
||||
self.ui.set_bg_dim(appearance.bg_dim.clamp(0.0, 0.9));
|
||||
self.ui
|
||||
.set_panel_alpha(appearance.panel_alpha.clamp(0.4, 1.0));
|
||||
self.ui.set_glass(appearance.glass);
|
||||
self.ui
|
||||
.set_accent_color(conv::accent_color(&appearance.accent_color));
|
||||
self.ui
|
||||
.set_accent_hex(appearance.accent_color.clone().into());
|
||||
self.refresh_background(&appearance);
|
||||
}
|
||||
|
||||
/// 按需重建背景图 + 模糊层。
|
||||
fn refresh_background(&self, appearance: &AppearancePrefs) {
|
||||
if !appearance.bg_active() {
|
||||
let had = self.background_key.borrow_mut().take().is_some();
|
||||
if had {
|
||||
self.background.replace(BackgroundLayers::default());
|
||||
self.ui.set_background_image(slint::Image::default());
|
||||
self.ui.set_background_blur(slint::Image::default());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let key = BackgroundKey {
|
||||
path: appearance.bg_image.clone(),
|
||||
fill: appearance.bg_fill,
|
||||
viewport: self.tile_viewport(appearance.bg_fill),
|
||||
};
|
||||
if self.background_key.borrow().as_ref() == Some(&key) {
|
||||
return;
|
||||
}
|
||||
self.background_key.replace(Some(key.clone()));
|
||||
|
||||
// 模糊层按窗口物理尺寸放大(软件渲染器不做图片缩放插值,见 bg_image::prepare);
|
||||
// tile 之外的模式窗口缩放不重建画布,模糊层被拉伸也无感。
|
||||
let real_viewport = self.window_physical_size();
|
||||
match bg_image::prepare(&key.path, key.fill, real_viewport) {
|
||||
Some(layers) => {
|
||||
self.ui.set_background_image(layers.image.clone());
|
||||
self.ui.set_background_blur(layers.blur.clone());
|
||||
self.background.replace(layers);
|
||||
}
|
||||
None => {
|
||||
// 载入失败(路径不存在 / 不是 PNG):清空图层并提示一次,别留上一张图的残影
|
||||
self.background.replace(BackgroundLayers::default());
|
||||
self.ui.set_background_image(slint::Image::default());
|
||||
self.ui.set_background_blur(slint::Image::default());
|
||||
self.state.borrow_mut().toasts.push(
|
||||
ToastKind::Warn,
|
||||
"背景图载入失败(当前只支持 PNG,且路径需存在)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// tile 画布的窗口尺寸(量化到 `TILE_REGENERATE_STEP`,避免拖动窗口时反复重算)。
|
||||
///
|
||||
/// 判据在 [`unlucky_presenter::theme::tile_viewport`](两侧 UI 共用一份);这里只负责
|
||||
/// 提供窗口物理尺寸。
|
||||
fn tile_viewport(&self, fill: BgFill) -> (u32, u32) {
|
||||
if fill != BgFill::Tile {
|
||||
return (0, 0);
|
||||
}
|
||||
unlucky_presenter::theme::tile_viewport(fill, self.window_physical_size())
|
||||
}
|
||||
|
||||
/// 窗口物理像素尺寸(取不到时 (0, 0),tile 退化为单块)。
|
||||
fn window_physical_size(&self) -> (u32, u32) {
|
||||
let size = self.ui.window().size();
|
||||
(size.width, size.height)
|
||||
}
|
||||
|
||||
/// 落盘当前偏好(主题 + 外观四件)。
|
||||
pub(crate) fn persist_settings(&self) {
|
||||
let state = self.state.borrow();
|
||||
self.bridge
|
||||
.borrow_mut()
|
||||
.persist(&state.prefs.appearance, state.prefs.theme);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
//! 绑定层(核心):窗口/状态/后端的持有与同步;回调接线在 [`crate::callbacks`]。
|
||||
//!
|
||||
//! P0 的“后端”是 [`MockBackend`](脚本化假数据);把 `Box<dyn Backend>` 换成真实的 core/agent
|
||||
//! host 时,除本文件、`conv.rs`、`callbacks.rs` 外都不需要改
|
||||
//! (通道语义见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`)。
|
||||
//!
|
||||
//! 主题与外观(D17 / 09 §101-113):落盘配置由 [`SettingsBridge`] 读写(`config/launcher.json`,
|
||||
//! 与 `ull config set` 同一份),tick 里 1s 一次 `mtime` 热切;系统主题由
|
||||
//! `core::platform::theme_probe` 做启动对齐 + 后端不支持时的兜底轮询。
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use slint::{ComponentHandle, ModelRc, VecModel};
|
||||
|
||||
use crate::bg_image::BackgroundLayers;
|
||||
use crate::conv;
|
||||
use crate::{MainWindow, PageState, SettingSection};
|
||||
use unlucky_presenter::backend::{build_backend, config_path, AppOptions};
|
||||
use unlucky_presenter::dto::{AppCommand, Backend};
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
use unlucky_presenter::state::AppState;
|
||||
use unlucky_presenter::theme::BackgroundKey;
|
||||
|
||||
/// 定时器节拍:33ms ≈ 30fps(磁贴进度环 / 流式文本 / Toast TTL 的刷新下限)。
|
||||
const TICK_MS: u64 = 33;
|
||||
|
||||
/// 应用外壳:持有窗口、状态与后端,把三者缝在一起。
|
||||
pub struct AppUi {
|
||||
inner: Rc<Inner>,
|
||||
timer: slint::Timer,
|
||||
}
|
||||
|
||||
pub(crate) struct Inner {
|
||||
pub(crate) ui: MainWindow,
|
||||
pub(crate) state: RefCell<AppState>,
|
||||
pub(crate) backend: RefCell<Box<dyn Backend>>,
|
||||
/// `config/launcher.json` 读写桥(app 不自己写盘:一律走 core::setting 的 schema API)
|
||||
pub(crate) bridge: RefCell<SettingsBridge>,
|
||||
/// 当前背景图 / 模糊层(避免每次同步都重新解码)
|
||||
pub(crate) background: RefCell<BackgroundLayers>,
|
||||
/// 生成 `background` 时的输入(路径 / 填充模式 / 窗口粒度尺寸)
|
||||
pub(crate) background_key: RefCell<Option<BackgroundKey>>,
|
||||
/// 拍数(轮询节拍)
|
||||
pub(crate) ticks: Cell<u64>,
|
||||
/// 是否已经做过启动时的系统主题对齐
|
||||
pub(crate) system_theme_probed: Cell<bool>,
|
||||
}
|
||||
|
||||
impl AppUi {
|
||||
/// 建状态 + 真实后端、读配置、接全部回调、灌初始事件并做第一次同步。
|
||||
pub fn new(ui: MainWindow) -> Self {
|
||||
Self::with_options(ui, AppOptions::default())
|
||||
}
|
||||
|
||||
/// 带启动选项装配(数据根覆盖 / 强制 mock)。
|
||||
pub fn with_options(ui: MainWindow, options: AppOptions) -> Self {
|
||||
// 先读一次落盘配置:主题三态 + 外观四件必须在首帧前就位(否则会闪一下出厂外观)
|
||||
let bridge = match &options.data_root {
|
||||
Some(root) => SettingsBridge::with_path(config_path(root)),
|
||||
None => SettingsBridge::open(),
|
||||
};
|
||||
let (appearance, theme) = bridge.load();
|
||||
let mut state = AppState::new();
|
||||
state.prefs.appearance = appearance;
|
||||
state.prefs.theme = theme;
|
||||
|
||||
let backend = build_backend(&options);
|
||||
let inner = Rc::new(Inner {
|
||||
ui,
|
||||
state: RefCell::new(state),
|
||||
backend: RefCell::new(backend),
|
||||
bridge: RefCell::new(bridge),
|
||||
background: RefCell::new(BackgroundLayers::default()),
|
||||
background_key: RefCell::new(None),
|
||||
ticks: Cell::new(0),
|
||||
system_theme_probed: Cell::new(false),
|
||||
});
|
||||
|
||||
crate::callbacks::wire(Rc::downgrade(&inner));
|
||||
crate::callbacks_pages::wire_pages(Rc::downgrade(&inner));
|
||||
inner.drain_events();
|
||||
inner.align_system_theme();
|
||||
inner.apply_theme();
|
||||
inner.apply_appearance();
|
||||
inner.sync();
|
||||
|
||||
Self {
|
||||
inner,
|
||||
timer: slint::Timer::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 进入 Slint 事件循环(窗口句柄由本结构持有,直到循环退出)。
|
||||
pub fn run(&self) -> Result<(), slint::PlatformError> {
|
||||
self.inner.ui.run()
|
||||
}
|
||||
|
||||
/// 启动 33ms 节拍(后端 tick → 事件 → 状态 → UI)。
|
||||
pub fn start_timer(&self) {
|
||||
let inner = Rc::clone(&self.inner);
|
||||
self.timer.start(
|
||||
slint::TimerMode::Repeated,
|
||||
Duration::from_millis(TICK_MS),
|
||||
move || inner.tick(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 推进一拍(截图通道用:没有事件循环时手动把 tick 走起来,让 mock 事件灌进状态)。
|
||||
pub fn tick_once(&self) {
|
||||
self.inner.tick();
|
||||
slint::platform::update_timers_and_animations();
|
||||
}
|
||||
|
||||
/// 直接跳到「设置 → 外观」页(截图验收用:证明外观分组的四组控件真的接线了)。
|
||||
pub fn open_settings_appearance(&self) {
|
||||
self.inner
|
||||
.state
|
||||
.borrow_mut()
|
||||
.navigate(unlucky_presenter::router::Route::Settings);
|
||||
self.inner.sync();
|
||||
self.inner
|
||||
.ui
|
||||
.set_settings_section(SettingSection::Appearance);
|
||||
slint::platform::update_timers_and_animations();
|
||||
}
|
||||
|
||||
/// 直接跳到一个页面(截图验收用:每页一张)。
|
||||
pub fn open_page(&self, page: &str) {
|
||||
let route = match page {
|
||||
"download" | "java" => unlucky_presenter::router::Route::Download,
|
||||
"logs" => unlucky_presenter::router::Route::Logs,
|
||||
"settings" | "account" | "appearance" => unlucky_presenter::router::Route::Settings,
|
||||
"agent" => unlucky_presenter::router::Route::Agent,
|
||||
_ => unlucky_presenter::router::Route::Instances,
|
||||
};
|
||||
{
|
||||
let mut state = self.inner.state.borrow_mut();
|
||||
state.navigate(route);
|
||||
match page {
|
||||
"account" => state.settings_tab = "account".to_string(),
|
||||
"java" => state.settings_tab = "java".to_string(),
|
||||
"settings" => state.settings_tab = "download".to_string(),
|
||||
_ => {}
|
||||
}
|
||||
let download_tab = i32::from(page == "java");
|
||||
self.inner
|
||||
.ui
|
||||
.global::<PageState<'_>>()
|
||||
.set_download_tab(download_tab);
|
||||
}
|
||||
match page {
|
||||
"account" => self.inner.ui.set_settings_section(SettingSection::Account),
|
||||
"java" => self.inner.ui.set_settings_section(SettingSection::Java),
|
||||
"appearance" => self
|
||||
.inner
|
||||
.ui
|
||||
.set_settings_section(SettingSection::Appearance),
|
||||
"settings" => self.inner.ui.set_settings_section(SettingSection::Download),
|
||||
_ => {}
|
||||
}
|
||||
self.inner.sync();
|
||||
slint::platform::update_timers_and_animations();
|
||||
}
|
||||
|
||||
/// 打开新建实例向导(截图验收用)。
|
||||
pub fn open_wizard(&self) {
|
||||
self.inner.state.borrow_mut().wizard = unlucky_presenter::wizard::WizardState::open();
|
||||
self.inner.sync();
|
||||
slint::platform::update_timers_and_animations();
|
||||
}
|
||||
|
||||
/// 打开一个日志会话(截图验收:日志行 + 诊断卡片 + 头部标题都要有值)。
|
||||
pub fn open_session(&self, id: &str) {
|
||||
self.inner.state.borrow_mut().selected_session = Some(id.to_string());
|
||||
self.inner
|
||||
.dispatch(AppCommand::OpenLogSession(id.to_string()));
|
||||
self.inner.sync();
|
||||
slint::platform::update_timers_and_animations();
|
||||
}
|
||||
|
||||
/// 派发一条指令并跑若干毫秒(截图中间态:安装进度 / 启动事件流)。
|
||||
pub fn dispatch_and_run(&self, command: AppCommand, millis: u64) {
|
||||
self.inner.dispatch(command);
|
||||
let steps = (millis / TICK_MS).max(1);
|
||||
for _ in 0..steps {
|
||||
self.inner.tick();
|
||||
slint::platform::update_timers_and_animations();
|
||||
std::thread::sleep(Duration::from_millis(TICK_MS));
|
||||
}
|
||||
}
|
||||
|
||||
/// 渲染当前场景并存成 PNG(`--screenshot` 通道;见 `crate::png`)。
|
||||
///
|
||||
/// 为什么要这条通道:验收环境可能没有交互桌面(`CopyFromScreen` 直接失败),
|
||||
/// 而 Slint 的软件渲染器可以把场景渲染进内存像素缓冲(`Window::take_snapshot`),
|
||||
/// 结果与屏幕上看到的逐像素一致,且不需要 GPU。
|
||||
///
|
||||
/// # Errors
|
||||
/// 窗口显示失败、快照失败或写文件失败时返回错误。
|
||||
pub fn render_screenshot(&self, path: &str) -> Result<(), slint::PlatformError> {
|
||||
let ui = &self.inner.ui;
|
||||
ui.show()?;
|
||||
// 固定逻辑尺寸 = main.slint 的默认窗口口径,保证验收图可比
|
||||
ui.window()
|
||||
.set_size(slint::WindowSize::Logical(slint::LogicalSize::new(
|
||||
1180.0, 760.0,
|
||||
)));
|
||||
// 先走几拍:把后端事件(实例/版本/日志/账户/设置)与 33ms 节拍的效果灌进去
|
||||
for _ in 0..8 {
|
||||
self.tick_once();
|
||||
std::thread::sleep(Duration::from_millis(TICK_MS));
|
||||
}
|
||||
let buffer = ui.window().take_snapshot()?;
|
||||
let bytes = crate::png::encode_rgba8(&buffer);
|
||||
std::fs::write(path, bytes)
|
||||
.map_err(|error| slint::PlatformError::Other(format!("写截图失败 {path}: {error}")))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 发送一条 AI 提问:**只发指令 + 回流事件**,绝不做本地乐观回显。
|
||||
///
|
||||
/// 契约 §3 规定 host 先发 `MessageAppended(user)`:本地再 push 一次用户消息就会重复;
|
||||
/// 且流式气泡的收敛依赖事件里的 `MessageFinished{index}`,本地多建的占位会变成
|
||||
/// 永不收敛的「幽灵气泡」(review P1-1)。callbacks 与单测都走这一个入口。
|
||||
pub(crate) fn send_agent_message(state: &mut AppState, backend: &mut dyn Backend, text: &str) {
|
||||
backend.dispatch(AppCommand::AskAgent(text.to_string()));
|
||||
for event in backend.drain_events() {
|
||||
state.apply(event);
|
||||
}
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
/// 走一次完整的「提问 → 事件回流」。
|
||||
pub(crate) fn ask_agent(&self, text: &str) {
|
||||
let mut state = self.state.borrow_mut();
|
||||
let mut backend = self.backend.borrow_mut();
|
||||
send_agent_message(&mut state, backend.as_mut(), text);
|
||||
}
|
||||
|
||||
pub(crate) fn dispatch(&self, command: AppCommand) {
|
||||
self.backend.borrow_mut().dispatch(command);
|
||||
self.drain_events();
|
||||
}
|
||||
|
||||
pub(crate) fn drain_events(&self) {
|
||||
let events = self.backend.borrow_mut().drain_events();
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut state = self.state.borrow_mut();
|
||||
for event in events {
|
||||
state.apply(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn tick(&self) {
|
||||
self.backend
|
||||
.borrow_mut()
|
||||
.tick(Duration::from_millis(TICK_MS));
|
||||
self.drain_events();
|
||||
self.state.borrow_mut().toasts.expire(TICK_MS);
|
||||
self.ticks.set(self.ticks.get() + 1);
|
||||
self.poll_settings();
|
||||
self.poll_system_theme();
|
||||
self.sync();
|
||||
}
|
||||
|
||||
pub(crate) fn sync(&self) {
|
||||
let state = self.state.borrow();
|
||||
let ui = &self.ui;
|
||||
|
||||
ui.set_route(conv::route(state.route()));
|
||||
ui.set_nav_collapsed(state.prefs.nav_collapsed);
|
||||
ui.set_ai_collapsed(state.prefs.ai_collapsed);
|
||||
ui.set_tile_size(conv::tile_size(state.prefs.tile_size));
|
||||
ui.set_instance_view(conv::instance_view(state.prefs.instance_view));
|
||||
ui.set_download_category(conv::category_from(state.prefs.download_category));
|
||||
ui.set_download_installed_only(state.installed_only);
|
||||
ui.set_theme_mode(conv::theme_mode(state.prefs.theme));
|
||||
|
||||
ui.set_tiles(model(conv::tiles(&state)));
|
||||
ui.set_running_tiles(model(conv::running_tiles(&state)));
|
||||
ui.set_versions(model(conv::versions(&state)));
|
||||
ui.set_log_sessions(model(conv::log_sessions(&state)));
|
||||
ui.set_log_lines(model(conv::log_lines(&state)));
|
||||
ui.set_toasts(model(conv::toasts(&state)));
|
||||
|
||||
let (diagnosis, has_diagnosis) = conv::diagnosis(&state);
|
||||
ui.set_diagnosis(diagnosis);
|
||||
ui.set_has_diagnosis(has_diagnosis);
|
||||
ui.set_selected_session(state.selected_session.clone().unwrap_or_default().into());
|
||||
|
||||
ui.set_ai_messages(model(conv::chat(&state)));
|
||||
ui.set_ai_streaming(state.ai.streaming);
|
||||
ui.set_yolo(state.ai.yolo);
|
||||
ui.set_ai_endpoint_label(state.ai.endpoint_label.clone().into());
|
||||
|
||||
let (dialog, open, queued) = conv::dialog(&state);
|
||||
ui.set_dialog(dialog);
|
||||
ui.set_dialog_open(open);
|
||||
ui.set_dialog_queued(queued);
|
||||
|
||||
// PageState(账户/Java/安装/设置/通知/向导)在独立模块里灌,守 400 行
|
||||
crate::page_sync::apply(ui, &state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Slint 列表模型(VecModel → ModelRc)。
|
||||
pub(crate) fn model<T: Clone + 'static>(items: Vec<T>) -> ModelRc<T> {
|
||||
ModelRc::new(VecModel::from(items))
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ? "展开导航栏" : "折叠导航栏";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// Unlucky-Launcher · 主入口窗口(P0 阶段 B 接线)
|
||||
//
|
||||
// 布局真相源:DevDocs/PlanDoc/09-ui-layout.md(D16:左侧固定导航 + 右侧 AI 常驻栏 + 磁贴实例页)。
|
||||
// 本文件只做三件事:①无边框窗口与字体/缩放口径 ②实例化 `AppShell` ③把 AppShell 的全部
|
||||
// 属性/回调原样上抛给 src/main.rs(Slint 只能从根组件访问属性与回调,所以这层转发是必需的)。
|
||||
//
|
||||
// 与 M11 骨架的关系:M11 的 `Metro` palette global(accent/surface/sidebar/tile/text/muted/pad/gap)
|
||||
// 已由 `theme/tokens.slint` 的 `Theme` 继承并扩展——同一「palette global 令牌化」思路,但多了
|
||||
// 亮/暗双套色板(LightPalette/DarkPalette)、主题三态解析、排版 ramp 与字体栈;M11 的占位
|
||||
// NavItem/Tile 组件由 `components/nav.slint` 与 `components/tile.slint` 取代,占位内核不再保留。
|
||||
|
||||
// P3:页面状态 global 从根文件再导出一遍,Rust 侧才能拿到 `PageState` 类型。
|
||||
export { PageState } from "page_state.slint";
|
||||
import { AppShell } from "shell.slint";
|
||||
import { Theme } from "theme/tokens.slint";
|
||||
import { Metrics } from "theme/metrics.slint";
|
||||
import {
|
||||
RouteKind, TileSize, InstanceView, SettingSection, DownloadCategory, ThemeMode, BgFillKind,
|
||||
UiTile, UiVersion, UiLogSession, UiLogLine, UiDiagnosis, UiChatMessage, UiDialog, UiToast,
|
||||
} from "models.slint";
|
||||
|
||||
export component MainWindow inherits Window {
|
||||
title: root.app-title;
|
||||
// 默认尺寸取 D16 线框口径;最小尺寸保证 5 列磁贴 + 导航 + AI 栏不挤压
|
||||
width: 1180px;
|
||||
height: 760px;
|
||||
// Metro 一体感:自绘标题栏(components/titlebar.slint)
|
||||
no-frame: true;
|
||||
// 无边框窗口必须显式给边缘缩放热区,否则用户无法拖拽改窗口大小
|
||||
resize-border-width: 4px;
|
||||
default-font-family: Theme.font-family;
|
||||
default-font-size: Theme.font-size-body;
|
||||
// 窗口自身底色必须**不透明**:面板半透明度(panel-alpha)只该透出背景图,不该透到桌面
|
||||
background: Theme.canvas-solid;
|
||||
|
||||
// ── 从 main.rs 灌入的状态 ───────────────────────────────────
|
||||
in-out property <string> app-title: "Unlucky Launcher";
|
||||
in property <RouteKind> route: RouteKind.instances;
|
||||
|
||||
in property <bool> nav-collapsed: false;
|
||||
in property <string> account-name: "未登录";
|
||||
in property <string> account-initial: "?";
|
||||
in property <color> account-hue: #5C2D91;
|
||||
|
||||
in-out property <bool> ai-collapsed: false;
|
||||
in property <[UiChatMessage]> ai-messages;
|
||||
in property <bool> ai-streaming: false;
|
||||
in-out property <bool> yolo: false;
|
||||
in property <string> ai-endpoint-label: "llama.cpp · 本地";
|
||||
in-out property <string> ai-draft: "";
|
||||
|
||||
in property <[UiTile]> tiles;
|
||||
in property <[UiTile]> running-tiles;
|
||||
in property <[UiVersion]> versions;
|
||||
in property <[UiLogSession]> log-sessions;
|
||||
in property <[UiLogLine]> log-lines;
|
||||
in property <UiDiagnosis> diagnosis;
|
||||
in property <bool> has-diagnosis: false;
|
||||
in property <string> selected-session: "";
|
||||
|
||||
in-out property <TileSize> tile-size: TileSize.medium;
|
||||
in-out property <InstanceView> instance-view: InstanceView.tiles;
|
||||
in-out property <string> instance-query: "";
|
||||
in-out property <SettingSection> settings-section: SettingSection.general;
|
||||
in-out property <ThemeMode> theme-mode: ThemeMode.auto;
|
||||
// 系统主题(M41):0 = 未知(回落 Slint 后端),1 = 浅色,2 = 深色
|
||||
in property <int> system-theme: 0;
|
||||
/// 后端是否认得系统主题(`false` 时 Rust 侧才需要轮询探测兜底)
|
||||
out property <bool> system-theme-known: Theme.system-theme-known;
|
||||
|
||||
// ── 外观(高级)P1 四件(09 §101-104,出厂全部关闭)──────────────
|
||||
// 图片由 Rust 灌入(`in`);其余是 in-out:设置页控件会写回,回调里再原样上抛给 Rust。
|
||||
in property <image> background-image;
|
||||
in property <image> background-blur;
|
||||
in-out property <bool> bg-image-enabled: false;
|
||||
in-out property <string> bg-image: "";
|
||||
in-out property <BgFillKind> bg-fill: BgFillKind.cover;
|
||||
in-out property <float> bg-dim: 0.35;
|
||||
in-out property <float> panel-alpha: 1.0;
|
||||
in-out property <bool> glass: false;
|
||||
in-out property <color> accent-color: transparent;
|
||||
/// 设置页「强调色(十六进制)」输入框的内容(Rust 写入规范化后的值)
|
||||
in-out property <string> accent-hex: "";
|
||||
/// 强调色输入是否非法(设置页亮 warn 行)
|
||||
in-out property <bool> accent-invalid: false;
|
||||
in-out property <DownloadCategory> download-category: DownloadCategory.release;
|
||||
in-out property <bool> download-installed-only: false;
|
||||
in-out property <string> download-query: "";
|
||||
|
||||
in property <UiDialog> dialog;
|
||||
in property <bool> dialog-open: false;
|
||||
in property <int> dialog-queued: 0;
|
||||
in property <[UiToast]> toasts;
|
||||
|
||||
// 主题/外观:handler 里整体赋值给 `Theme` 全局(Slint 不允许在组件体内给全局属性做绑定,
|
||||
// 但允许在回调/init 里赋值)。auto 分支由 Theme 自己读系统的 Palette.color-scheme。
|
||||
init => {
|
||||
Theme.theme-mode = root.theme-mode;
|
||||
Theme.system-theme = root.system-theme;
|
||||
Theme.bg-image = root.background-image;
|
||||
Theme.bg-blur = root.background-blur;
|
||||
Theme.bg-dim = root.bg-dim;
|
||||
Theme.panel-alpha = root.panel-alpha;
|
||||
Theme.glass = root.glass;
|
||||
Theme.accent-override = root.accent-color;
|
||||
}
|
||||
changed theme-mode => { Theme.theme-mode = root.theme-mode; }
|
||||
changed system-theme => { Theme.system-theme = root.system-theme; }
|
||||
changed background-image => { Theme.bg-image = root.background-image; }
|
||||
changed background-blur => { Theme.bg-blur = root.background-blur; }
|
||||
changed bg-dim => { Theme.bg-dim = root.bg-dim; }
|
||||
changed panel-alpha => { Theme.panel-alpha = root.panel-alpha; }
|
||||
changed glass => { Theme.glass = root.glass; }
|
||||
changed accent-color => { Theme.accent-override = root.accent-color; }
|
||||
|
||||
// ── 用户操作(main.rs 转成 AppCommand 或纯 UI 偏好)──────────────
|
||||
callback toggle-maximize();
|
||||
callback minimize();
|
||||
callback request-close();
|
||||
|
||||
callback navigate(RouteKind);
|
||||
callback toggle-nav();
|
||||
callback toggle-ai();
|
||||
callback expand-ai();
|
||||
callback dock-ai();
|
||||
callback toggle-yolo();
|
||||
callback ai-send(string);
|
||||
callback ai-stop();
|
||||
|
||||
callback instance-launch(string);
|
||||
callback instance-configure(string);
|
||||
callback instance-stop(string);
|
||||
callback instance-open(string);
|
||||
callback new-instance();
|
||||
callback instance-view-changed(InstanceView);
|
||||
callback tile-size-changed(TileSize);
|
||||
callback instance-query-changed(string);
|
||||
|
||||
callback download-category-changed(DownloadCategory);
|
||||
callback download-install(string);
|
||||
callback installed-only-changed(bool);
|
||||
callback download-query-changed(string);
|
||||
callback ancient-help();
|
||||
|
||||
callback log-select(string);
|
||||
callback log-ask-ai(string);
|
||||
callback log-apply-fix();
|
||||
|
||||
callback settings-section-changed(SettingSection);
|
||||
callback theme-changed(ThemeMode);
|
||||
callback bg-image-enabled-changed(bool);
|
||||
callback bg-image-changed(string);
|
||||
callback bg-fill-changed(BgFillKind);
|
||||
callback bg-dim-changed(float);
|
||||
callback panel-alpha-changed(float);
|
||||
callback glass-changed(bool);
|
||||
callback accent-color-changed(string);
|
||||
callback accent-swatch-picked(int);
|
||||
callback yolo-changed(bool);
|
||||
callback mcp-changed(bool);
|
||||
callback ai-endpoint-changed(int);
|
||||
|
||||
callback dialog-primary();
|
||||
callback dialog-secondary();
|
||||
callback dialog-dismissed();
|
||||
callback toast-dismissed(string);
|
||||
|
||||
AppShell {
|
||||
app-title: root.app-title;
|
||||
// 直接复用 Window 的内建 `maximized`(后端随窗口状态更新,无需自建属性)
|
||||
maximized: root.maximized;
|
||||
route: root.route;
|
||||
nav-collapsed: root.nav-collapsed;
|
||||
account-name: root.account-name;
|
||||
account-initial: root.account-initial;
|
||||
account-hue: root.account-hue;
|
||||
ai-collapsed: root.ai-collapsed;
|
||||
ai-messages: root.ai-messages;
|
||||
ai-streaming: root.ai-streaming;
|
||||
yolo: root.yolo;
|
||||
ai-endpoint-label: root.ai-endpoint-label;
|
||||
ai-draft: root.ai-draft;
|
||||
tiles: root.tiles;
|
||||
running-tiles: root.running-tiles;
|
||||
versions: root.versions;
|
||||
log-sessions: root.log-sessions;
|
||||
log-lines: root.log-lines;
|
||||
diagnosis: root.diagnosis;
|
||||
has-diagnosis: root.has-diagnosis;
|
||||
selected-session: root.selected-session;
|
||||
tile-size: root.tile-size;
|
||||
instance-view: root.instance-view;
|
||||
instance-query: root.instance-query;
|
||||
settings-section: root.settings-section;
|
||||
theme-mode: root.theme-mode;
|
||||
bg-image-enabled: root.bg-image-enabled;
|
||||
bg-image: root.bg-image;
|
||||
bg-fill: root.bg-fill;
|
||||
bg-dim: root.bg-dim;
|
||||
panel-alpha: root.panel-alpha;
|
||||
glass: root.glass;
|
||||
accent-hex: root.accent-hex;
|
||||
accent-invalid: root.accent-invalid;
|
||||
download-category: root.download-category;
|
||||
download-installed-only: root.download-installed-only;
|
||||
download-query: root.download-query;
|
||||
dialog: root.dialog;
|
||||
dialog-open: root.dialog-open;
|
||||
dialog-queued: root.dialog-queued;
|
||||
toasts: root.toasts;
|
||||
|
||||
toggle-maximize => { root.toggle-maximize(); }
|
||||
minimize => { root.minimize(); }
|
||||
request-close => { root.request-close(); }
|
||||
|
||||
navigate(r) => { root.navigate(r); }
|
||||
toggle-nav => { root.toggle-nav(); }
|
||||
toggle-ai => { root.toggle-ai(); }
|
||||
expand-ai => { root.expand-ai(); }
|
||||
dock-ai => { root.dock-ai(); }
|
||||
toggle-yolo => { root.toggle-yolo(); }
|
||||
ai-send(text) => { root.ai-send(text); }
|
||||
ai-stop => { root.ai-stop(); }
|
||||
|
||||
instance-launch(id) => { root.instance-launch(id); }
|
||||
instance-configure(id) => { root.instance-configure(id); }
|
||||
instance-stop(id) => { root.instance-stop(id); }
|
||||
instance-open(id) => { root.instance-open(id); }
|
||||
new-instance => { root.new-instance(); }
|
||||
instance-view-changed(view) => {
|
||||
root.instance-view = view;
|
||||
root.instance-view-changed(view);
|
||||
}
|
||||
tile-size-changed(size) => {
|
||||
root.tile-size = size;
|
||||
root.tile-size-changed(size);
|
||||
}
|
||||
instance-query-changed(query) => { root.instance-query-changed(query); }
|
||||
|
||||
download-category-changed(category) => {
|
||||
root.download-category = category;
|
||||
root.download-category-changed(category);
|
||||
}
|
||||
download-install(id) => { root.download-install(id); }
|
||||
download-query-changed(query) => {
|
||||
root.download-query = query;
|
||||
root.download-query-changed(query);
|
||||
}
|
||||
ancient-help => { root.ancient-help(); }
|
||||
installed-only-changed(only) => {
|
||||
root.download-installed-only = only;
|
||||
root.installed-only-changed(only);
|
||||
}
|
||||
|
||||
log-select(id) => { root.log-select(id); }
|
||||
log-ask-ai(id) => { root.log-ask-ai(id); }
|
||||
log-apply-fix => { root.log-apply-fix(); }
|
||||
|
||||
settings-section-changed(section) => {
|
||||
root.settings-section = section;
|
||||
root.settings-section-changed(section);
|
||||
}
|
||||
theme-changed(mode) => {
|
||||
root.theme-mode = mode;
|
||||
root.theme-changed(mode);
|
||||
}
|
||||
bg-image-enabled-changed(v) => {
|
||||
root.bg-image-enabled = v;
|
||||
root.bg-image-enabled-changed(v);
|
||||
}
|
||||
bg-image-changed(path) => {
|
||||
root.bg-image = path;
|
||||
root.bg-image-changed(path);
|
||||
}
|
||||
bg-fill-changed(fill) => {
|
||||
root.bg-fill = fill;
|
||||
root.bg-fill-changed(fill);
|
||||
}
|
||||
bg-dim-changed(v) => {
|
||||
root.bg-dim = v;
|
||||
root.bg-dim-changed(v);
|
||||
}
|
||||
panel-alpha-changed(v) => {
|
||||
root.panel-alpha = v;
|
||||
root.panel-alpha-changed(v);
|
||||
}
|
||||
glass-changed(v) => {
|
||||
root.glass = v;
|
||||
root.glass-changed(v);
|
||||
}
|
||||
accent-color-changed(text) => {
|
||||
root.accent-hex = text;
|
||||
root.accent-color-changed(text);
|
||||
}
|
||||
accent-swatch-picked(index) => { root.accent-swatch-picked(index); }
|
||||
yolo-changed(enabled) => { root.yolo-changed(enabled); }
|
||||
mcp-changed(enabled) => { root.mcp-changed(enabled); }
|
||||
ai-endpoint-changed(index) => { root.ai-endpoint-changed(index); }
|
||||
|
||||
dialog-primary => { root.dialog-primary(); }
|
||||
dialog-secondary => { root.dialog-secondary(); }
|
||||
dialog-dismissed => { root.dialog-dismissed(); }
|
||||
toast-dismissed(id) => { root.toast-dismissed(id); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// UI 视图模型:Rust 的 dto.rs 做单向转换后喂给 Slint(app 不 import core 内部类型,01c §5)。
|
||||
// 命名一律 kebab-case,Rust 侧 slint-build 会生成 snake_case 字段。
|
||||
// 约定:这里只放「界面要显示什么」,不放领域语义(版本继承链、下载源健康度等留在 core)。
|
||||
|
||||
// ── 共享枚举 ─────────────────────────────────────────────────────
|
||||
export enum RouteKind {
|
||||
instances,
|
||||
download,
|
||||
logs,
|
||||
settings,
|
||||
detail,
|
||||
agent,
|
||||
}
|
||||
|
||||
export enum TileSize {
|
||||
small,
|
||||
medium,
|
||||
large,
|
||||
}
|
||||
|
||||
export enum InstanceView {
|
||||
tiles,
|
||||
list,
|
||||
}
|
||||
|
||||
/// D17 主题三态:auto 跟随系统(Slint Palette.color-scheme 热更),light/dark 钉死。
|
||||
/// 键值 `appearance.theme_mode ∈ {light, dark, auto}`,出厂 auto。
|
||||
export enum ThemeMode {
|
||||
auto,
|
||||
light,
|
||||
dark,
|
||||
}
|
||||
|
||||
/// 背景图填充模式(09 §101:cover / contain / tile)。与 `core::setting::appearance::BgFill` 同序。
|
||||
export enum BgFillKind {
|
||||
cover,
|
||||
contain,
|
||||
tile,
|
||||
}
|
||||
|
||||
export enum LoaderKind {
|
||||
vanilla,
|
||||
fabric,
|
||||
quilt,
|
||||
forge,
|
||||
neoforge,
|
||||
optifine,
|
||||
liteloader,
|
||||
}
|
||||
|
||||
export enum RuntimePhase {
|
||||
idle,
|
||||
downloading,
|
||||
verifying,
|
||||
launching,
|
||||
running,
|
||||
failed,
|
||||
}
|
||||
|
||||
export enum ChatRole {
|
||||
user,
|
||||
assistant,
|
||||
tool,
|
||||
system,
|
||||
}
|
||||
|
||||
export enum DialogKind {
|
||||
confirm,
|
||||
input,
|
||||
progress,
|
||||
error,
|
||||
}
|
||||
|
||||
export enum ToastKind {
|
||||
info,
|
||||
success,
|
||||
warn,
|
||||
error,
|
||||
}
|
||||
|
||||
export enum VersionKind {
|
||||
release,
|
||||
snapshot,
|
||||
ancientClassic,
|
||||
ancientAlpha,
|
||||
ancientBeta,
|
||||
ancientUnlisted,
|
||||
modpack,
|
||||
mod,
|
||||
}
|
||||
|
||||
// 下载页分类侧栏的选中项(远古版把四个子类提升为平级枚值,便于单选高亮)
|
||||
export enum DownloadCategory {
|
||||
release,
|
||||
snapshot,
|
||||
ancientClassic,
|
||||
ancientAlpha,
|
||||
ancientBeta,
|
||||
ancientUnlisted,
|
||||
modpack,
|
||||
mod,
|
||||
}
|
||||
|
||||
export enum LogOutcome {
|
||||
ok,
|
||||
crashed,
|
||||
silentFail,
|
||||
}
|
||||
|
||||
export enum LogLevel {
|
||||
debug,
|
||||
info,
|
||||
warn,
|
||||
error,
|
||||
}
|
||||
|
||||
export enum SettingSection {
|
||||
general,
|
||||
/// D17「外观(高级)」:主题三态 / 背景图 / 半透明度 / 玻璃拟态 / 强调色 / 动画强度
|
||||
appearance,
|
||||
data,
|
||||
download,
|
||||
java,
|
||||
account,
|
||||
ai,
|
||||
proxy,
|
||||
about,
|
||||
}
|
||||
|
||||
// ── 磁贴 ────────────────────────────────────────────────────────
|
||||
// row/col 由 Rust state.rs 的 tile_grid() 按当前磁贴尺寸算好(小 6 / 中 4 / 大 3 列),
|
||||
// 这样 Slint 侧 GridLayout 只消费整数位置,不必在 DSL 里做除法。
|
||||
export struct UiTile {
|
||||
id: string,
|
||||
name: string,
|
||||
mc_version: string,
|
||||
loader: LoaderKind,
|
||||
loader_version: string,
|
||||
/// 加载器徽标文字(「原版」/「Fabric 0.16.5」):由 Rust 侧用 `LoaderInfo::badge()` 拼好,
|
||||
/// DSL 不做枚举→字符串映射(少一处双份真相)。
|
||||
loader_label: string,
|
||||
last_played: string,
|
||||
phase: RuntimePhase,
|
||||
/// 启动中阶段文案(「下载资源」/「校验文件」/「启动中」),idle 时为空。
|
||||
phase_label: string,
|
||||
/// 0.0~1.0;<= 0 表示进度未知(进度环转为不确定态)。
|
||||
progress: float,
|
||||
/// 实例图标;空 image 时退回「名字首字 + hue 底色」占位。
|
||||
icon: image,
|
||||
hue: color,
|
||||
/// 名字首字,无图标时的占位字母(Rust 侧取首个字素)。
|
||||
initial: string,
|
||||
row: int,
|
||||
col: int,
|
||||
}
|
||||
|
||||
// ── 下载页 ──────────────────────────────────────────────────────
|
||||
export struct UiVersion {
|
||||
id: string,
|
||||
kind: VersionKind,
|
||||
released: string,
|
||||
installed: bool,
|
||||
/// 远古版(classic/alpha/beta/unlisted)——列表行带徽标 + 页顶兼容性提示。
|
||||
ancient: bool,
|
||||
}
|
||||
|
||||
// ── 日志页 ──────────────────────────────────────────────────────
|
||||
export struct UiLogSession {
|
||||
id: string,
|
||||
when: string,
|
||||
instance: string,
|
||||
outcome: LogOutcome,
|
||||
duration: string,
|
||||
}
|
||||
|
||||
export struct UiLogLine {
|
||||
text: string,
|
||||
level: LogLevel,
|
||||
}
|
||||
|
||||
/// D8 规则引擎结论(core::diagnose 的 DTO,app 只负责展示)。
|
||||
export struct UiDiagnosis {
|
||||
rule: string,
|
||||
title: string,
|
||||
confidence: int,
|
||||
hint: string,
|
||||
has_fix: bool,
|
||||
}
|
||||
|
||||
// ── AI 栏 ───────────────────────────────────────────────────────
|
||||
export struct UiChatMessage {
|
||||
role: ChatRole,
|
||||
text: string,
|
||||
/// role == tool 时的工具名。
|
||||
tool_name: string,
|
||||
tool_ok: bool,
|
||||
/// 流式未完成时 true(末尾显示光标块)。
|
||||
streaming: bool,
|
||||
}
|
||||
|
||||
// ── 对话框 / Toast ──────────────────────────────────────────────
|
||||
export struct UiDialog {
|
||||
id: string,
|
||||
kind: DialogKind,
|
||||
title: string,
|
||||
body: string,
|
||||
primary: string,
|
||||
secondary: string,
|
||||
progress: float,
|
||||
input_label: string,
|
||||
input_value: string,
|
||||
}
|
||||
|
||||
export struct UiToast {
|
||||
id: string,
|
||||
kind: ToastKind,
|
||||
text: string,
|
||||
}
|
||||
|
||||
// ── P3 全量接线的视图模型(账户 / Java / 安装 / 设置分组 / 通知 / 向导)──────────
|
||||
// 这些类型由 Rust 的 dto 单向转换(`conv/pages.rs`),Slint 只读不写业务真相。
|
||||
// 全部经 `page_state.slint` 的 `PageState` global 下发(避免 main/shell 的属性转发膨胀)。
|
||||
|
||||
export enum AccountKind {
|
||||
offline,
|
||||
microsoft,
|
||||
authlib,
|
||||
}
|
||||
|
||||
export struct UiAccount {
|
||||
id: string,
|
||||
name: string,
|
||||
kind: AccountKind,
|
||||
kind_label: string,
|
||||
profile_id: string,
|
||||
login_name: string,
|
||||
created: string,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
/// 微软登录面板(用户码 / 授权链接都不是凭据,可直接展示)。
|
||||
export struct UiDeviceCode {
|
||||
visible: bool,
|
||||
busy: bool,
|
||||
user_code: string,
|
||||
verification_uri: string,
|
||||
qr_link: string,
|
||||
expires: int,
|
||||
message: string,
|
||||
phase_label: string,
|
||||
}
|
||||
|
||||
export enum JavaStepOutcome {
|
||||
pending,
|
||||
skip,
|
||||
failed,
|
||||
hit,
|
||||
}
|
||||
|
||||
export struct UiJavaStep {
|
||||
vendor: string,
|
||||
outcome: JavaStepOutcome,
|
||||
outcome_label: string,
|
||||
detail: string,
|
||||
}
|
||||
|
||||
export struct UiJavaVendor {
|
||||
id: string,
|
||||
display: string,
|
||||
rank: int,
|
||||
majors: string,
|
||||
sources: string,
|
||||
installed: bool,
|
||||
}
|
||||
|
||||
export struct UiJavaRuntime {
|
||||
binary: string,
|
||||
version: string,
|
||||
vendor: string,
|
||||
origin: string,
|
||||
}
|
||||
|
||||
export enum InstallStage {
|
||||
planning,
|
||||
downloading,
|
||||
installing,
|
||||
registering,
|
||||
done,
|
||||
failed,
|
||||
cancelled,
|
||||
}
|
||||
|
||||
export struct UiInstallProgress {
|
||||
visible: bool,
|
||||
version: string,
|
||||
instance: string,
|
||||
loader: string,
|
||||
stage: InstallStage,
|
||||
stage_label: string,
|
||||
ratio: float,
|
||||
known: bool,
|
||||
files_label: string,
|
||||
bytes_label: string,
|
||||
speed: string,
|
||||
eta: string,
|
||||
message: string,
|
||||
terminal: bool,
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
export enum SettingControl {
|
||||
toggle,
|
||||
choice,
|
||||
text,
|
||||
readonly,
|
||||
action,
|
||||
}
|
||||
|
||||
export struct UiSettingRow {
|
||||
key: string,
|
||||
label: string,
|
||||
description: string,
|
||||
control: SettingControl,
|
||||
flag: bool,
|
||||
selected: int,
|
||||
options: [string],
|
||||
value_text: string,
|
||||
action: string,
|
||||
}
|
||||
|
||||
export struct UiSettingGroup {
|
||||
id: string,
|
||||
title: string,
|
||||
rows: [UiSettingRow],
|
||||
}
|
||||
|
||||
export enum NoticeKind {
|
||||
info,
|
||||
warn,
|
||||
error,
|
||||
}
|
||||
|
||||
export struct UiNotice {
|
||||
id: string,
|
||||
kind: NoticeKind,
|
||||
title: string,
|
||||
body: string,
|
||||
}
|
||||
|
||||
export struct UiWizard {
|
||||
open: bool,
|
||||
step: int,
|
||||
step_label: string,
|
||||
step_index: int,
|
||||
step_total: int,
|
||||
version: string,
|
||||
loader: int,
|
||||
loader_label: string,
|
||||
loader_version: string,
|
||||
name: string,
|
||||
error: string,
|
||||
busy: bool,
|
||||
can-next: bool,
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// P3 全量接线的页面状态:一个导出的 global,Slint 任何页面都能直接读/回推。
|
||||
//
|
||||
// 为什么用 global 而不是继续经 main.slint → shell.slint 逐层转发:属性表已经 100+ 条,
|
||||
// 再加五页的数据会把 main.slint 顶破 400 行上限(AGENTS §3)。global 在同一个 Slint
|
||||
// 编译单元内是单例,Rust 侧用 `ui.global::<PageState>()` 读写,语义与 property 相同。
|
||||
//
|
||||
// 纪律:这里只放「页面要显示什么」与「用户点了什么」,业务真相在 Rust 的 state.rs。
|
||||
import { UiAccount, UiDeviceCode, UiJavaVendor, UiJavaStep, UiJavaRuntime, UiInstallProgress, UiSettingGroup, UiNotice, UiWizard, UiVersion } from "models.slint";
|
||||
|
||||
export global PageState {
|
||||
// ── 账户页 ────────────────────────────────────────────────
|
||||
in-out property <[UiAccount]> accounts;
|
||||
in property <string> active-account: "";
|
||||
in property <UiDeviceCode> device;
|
||||
/// 离线添加表单的输入(不落盘,点「添加」才提交)
|
||||
in-out property <string> offline-name: "";
|
||||
|
||||
// ── Java(下载页 Java 分组 / 设置页 Java 分组)──────────────
|
||||
in property <[UiJavaVendor]> java-vendors;
|
||||
in property <[UiJavaStep]> java-chain;
|
||||
in property <[UiJavaRuntime]> java-runtimes;
|
||||
in property <string> java-order: "";
|
||||
in property <string> java-region: "";
|
||||
in property <string> java-priority-source: "";
|
||||
in property <string> java-message: "";
|
||||
in property <bool> java-busy: false;
|
||||
in property <int> java-target-major: 17;
|
||||
|
||||
// ── 安装进度(下载页 + 向导共用)────────────────────────────
|
||||
in property <UiInstallProgress> install;
|
||||
/// 下载页右侧视图:0 = 版本列表,1 = Java 运行时(截图通道也从这里切)
|
||||
in-out property <int> download-tab: 0;
|
||||
|
||||
// ── 设置分组(settings_bridge 同源 launcher.json)──────────
|
||||
in property <[UiSettingGroup]> setting-groups;
|
||||
in-out property <string> setting-tab: "download";
|
||||
|
||||
// ── 通知条 / 向导 ─────────────────────────────────────────
|
||||
in property <[UiNotice]> notices;
|
||||
in property <UiWizard> wizard;
|
||||
in property <[UiVersion]> wizard-versions;
|
||||
|
||||
// ── 账户 ──────────────────────────────────────────────────
|
||||
callback account-add-offline(string);
|
||||
callback account-remove(string);
|
||||
callback account-switch(string);
|
||||
callback msa-begin();
|
||||
callback msa-browser-begin();
|
||||
callback msa-cancel();
|
||||
|
||||
// ── Java ──────────────────────────────────────────────────
|
||||
callback java-install(string);
|
||||
|
||||
// ── 设置 ──────────────────────────────────────────────────
|
||||
callback setting-changed(string, string);
|
||||
callback setting-action(string);
|
||||
callback settings-tab-changed(string);
|
||||
|
||||
// ── 通知 ──────────────────────────────────────────────────
|
||||
callback notice-dismiss(string);
|
||||
|
||||
// ── 向导 ──────────────────────────────────────────────────
|
||||
callback wizard-open();
|
||||
callback wizard-close();
|
||||
callback wizard-select-version(string);
|
||||
callback wizard-select-loader(int);
|
||||
callback wizard-name-changed(string);
|
||||
callback wizard-next();
|
||||
callback wizard-back();
|
||||
callback wizard-confirm();
|
||||
callback install-cancel();
|
||||
|
||||
// ── 日志页:崩溃日志导入 / 手动刷新 ──────────────────────────
|
||||
in-out property <string> crash-path: "";
|
||||
callback crash-import(string);
|
||||
callback logs-refresh();
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// 下载页 · Java 分组(G2 的第二个分类):四家发行版 + 回退链可视化 + 本地运行时。
|
||||
//
|
||||
// 回退链是「可观测性」核心:安装失败时用户要能看见 Temurin 跳过 → Oracle 失败 →
|
||||
// Microsoft 命中的每一步原因(D23),而不是只看到一句「下载失败」。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { UiJavaVendor, UiJavaStep, UiJavaRuntime, JavaStepOutcome } from "../models.slint";
|
||||
import { PageState } from "../page_state.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { Badge } from "../components/badge.slint";
|
||||
import { AppIcon, IconKind } from "../components/icons.slint";
|
||||
import { MetroButton, ButtonKind } from "../widgets/button.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
|
||||
component ChainStep inherits Rectangle {
|
||||
in property <UiJavaStep> step;
|
||||
|
||||
height: 26px;
|
||||
background: transparent;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Metrics.gap-1;
|
||||
|
||||
AppIcon {
|
||||
icon-size: 12px;
|
||||
kind: root.step.outcome == JavaStepOutcome.hit ? IconKind.check
|
||||
: root.step.outcome == JavaStepOutcome.failed ? IconKind.error
|
||||
: root.step.outcome == JavaStepOutcome.skip ? IconKind.warn
|
||||
: IconKind.more;
|
||||
tint: root.step.outcome == JavaStepOutcome.hit ? Theme.state-success
|
||||
: root.step.outcome == JavaStepOutcome.failed ? Theme.state-danger
|
||||
: Theme.fg-secondary;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.step.vendor;
|
||||
width: 72px;
|
||||
color: root.step.outcome == JavaStepOutcome.hit ? Theme.state-success : Theme.fg-primary;
|
||||
}
|
||||
Badge {
|
||||
text: root.step.outcome_label;
|
||||
tint: root.step.outcome == JavaStepOutcome.hit ? Theme.state-success
|
||||
: root.step.outcome == JavaStepOutcome.failed ? Theme.state-danger
|
||||
: Theme.fg-secondary;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.step.detail;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component VendorRow inherits Rectangle {
|
||||
in property <UiJavaVendor> data;
|
||||
callback install();
|
||||
|
||||
height: 56px;
|
||||
background: vendor-touch.has-hover ? Theme.bg-hover : transparent;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-3;
|
||||
padding-right: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
UiText {
|
||||
role: TextRole.bodyStrong;
|
||||
text: root.data.rank + ". " + root.data.display;
|
||||
width: 170px;
|
||||
}
|
||||
if root.data.installed : Badge { text: "已装"; tint: Theme.state-success; }
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "Java " + root.data.majors;
|
||||
width: 96px;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.data.sources;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.standard;
|
||||
text: "安装此家";
|
||||
button-height: 26px;
|
||||
enabled: !PageState.java-busy;
|
||||
clicked => { root.install(); }
|
||||
}
|
||||
}
|
||||
|
||||
vendor-touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.install(); }
|
||||
}
|
||||
}
|
||||
|
||||
export component JavaPanel inherits Rectangle {
|
||||
background: Theme.bg-canvas;
|
||||
|
||||
VerticalLayout {
|
||||
// 顶栏:地区 / 优先级来源 / 目标大版本 / 一键按回退链安装
|
||||
Rectangle {
|
||||
height: 56px;
|
||||
background: Theme.bg-canvas;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-4;
|
||||
spacing: Metrics.gap-3;
|
||||
|
||||
UiText {
|
||||
role: TextRole.subtitle;
|
||||
text: "Java 运行时";
|
||||
width: 110px;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "地区 " + PageState.java-region + " · 优先级 " + PageState.java-priority-source
|
||||
+ " · 目标 Java " + PageState.java-target-major;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.accent;
|
||||
text: PageState.java-busy ? "安装中…" : "按回退链安装";
|
||||
button-height: 28px;
|
||||
enabled: !PageState.java-busy;
|
||||
clicked => { PageState.java-install(""); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UiText {
|
||||
x: Metrics.gap-4;
|
||||
role: TextRole.caption;
|
||||
text: PageState.java-order;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
|
||||
if PageState.java-message != "" : Rectangle {
|
||||
height: 30px;
|
||||
background: Theme.accent-soft;
|
||||
UiText {
|
||||
x: Metrics.gap-4;
|
||||
role: TextRole.caption;
|
||||
text: PageState.java-message;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.stroke; }
|
||||
|
||||
ScrollView {
|
||||
vertical-stretch: 1;
|
||||
|
||||
VerticalLayout {
|
||||
padding-bottom: Metrics.gap-4;
|
||||
|
||||
// 回退链
|
||||
UiText {
|
||||
x: Metrics.gap-4;
|
||||
role: TextRole.bodyStrong;
|
||||
text: "回退链";
|
||||
}
|
||||
for step in PageState.java-chain : ChainStep { step: step; }
|
||||
|
||||
UiText {
|
||||
x: Metrics.gap-4;
|
||||
role: TextRole.bodyStrong;
|
||||
text: "四家发行版";
|
||||
}
|
||||
for vendor in PageState.java-vendors : VendorRow {
|
||||
data: vendor;
|
||||
install => { PageState.java-install(vendor.id); }
|
||||
}
|
||||
|
||||
UiText {
|
||||
x: Metrics.gap-4;
|
||||
role: TextRole.bodyStrong;
|
||||
text: "本地已发现";
|
||||
}
|
||||
if PageState.java-runtimes.length == 0 : UiText {
|
||||
x: Metrics.gap-4;
|
||||
role: TextRole.caption;
|
||||
text: "还没有探测到 Java:点上面的「按回退链安装」装一个托管 JDK。";
|
||||
}
|
||||
for runtime in PageState.java-runtimes : Rectangle {
|
||||
height: 24px;
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
spacing: Metrics.gap-2;
|
||||
UiText { role: TextRole.caption; text: "Java " + runtime.version; width: 120px; }
|
||||
UiText { role: TextRole.caption; text: runtime.vendor; width: 96px; }
|
||||
UiText { role: TextRole.caption; text: runtime.origin; width: 80px; }
|
||||
UiText { role: TextRole.caption; text: runtime.binary; horizontal-stretch: 1; overflow: elide; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// G2 下载页(09 §2 P0·G2 / Q24-4 定案):页内独立左侧分类栏(常驻,非 tab)+ 右侧版本列表。
|
||||
// 远古版入口显式可达(ULL 对 HMCL「隐藏」行为的纠偏),下挂 classic/alpha/beta/unlisted 子类树,
|
||||
// 进入远古版分类时顶部提示兼容性 + 每行带「远古版」徽标(防误装)。
|
||||
//
|
||||
// P3 接线:右侧多一个「Java 运行时」视图(四家发行版 + 回退链),页面底部常驻安装进度条。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { UiVersion, VersionKind, DownloadCategory } from "../models.slint";
|
||||
import { PageState } from "../page_state.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { AppIcon, IconKind } from "../components/icons.slint";
|
||||
import { Badge } from "../components/badge.slint";
|
||||
import { MetroButton, ButtonKind } from "../widgets/button.slint";
|
||||
import { SearchField, CheckBox } from "../widgets/inputs.slint";
|
||||
import { InstallPanel } from "../components/install-panel.slint";
|
||||
import { JavaPanel } from "download-java.slint";
|
||||
import { ListView } from "std-widgets.slint";
|
||||
|
||||
component CategoryRow inherits Rectangle {
|
||||
in property <string> label: "";
|
||||
in property <bool> active: false;
|
||||
in property <bool> sub: false;
|
||||
in property <bool> disabled: false;
|
||||
callback clicked();
|
||||
|
||||
height: 34px;
|
||||
background: root.active ? Theme.accent-soft : (row-touch.has-hover ? Theme.bg-hover : transparent);
|
||||
|
||||
if root.active : Rectangle {
|
||||
x: 0;
|
||||
y: 0;
|
||||
width: 3px;
|
||||
height: root.height;
|
||||
background: Theme.accent;
|
||||
}
|
||||
|
||||
UiText {
|
||||
x: root.sub ? Metrics.gap-5 : Metrics.gap-3;
|
||||
width: root.width - (root.sub ? Metrics.gap-5 : Metrics.gap-3) - Metrics.gap-2;
|
||||
height: root.height;
|
||||
role: TextRole.body;
|
||||
text: root.label;
|
||||
color: root.disabled ? Theme.fg-disabled
|
||||
: root.active ? Theme.accent
|
||||
: Theme.fg-primary;
|
||||
}
|
||||
|
||||
row-touch := TouchArea {
|
||||
mouse-cursor: root.disabled ? default : pointer;
|
||||
clicked => {
|
||||
if (!root.disabled) {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component VersionRow inherits Rectangle {
|
||||
in property <UiVersion> data;
|
||||
callback install();
|
||||
|
||||
height: Metrics.row-height + 8px;
|
||||
background: row-touch.has-hover ? Theme.bg-hover : transparent;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-4;
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
UiText {
|
||||
role: TextRole.bodyStrong;
|
||||
text: root.data.id;
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
Badge {
|
||||
text: root.data.kind == VersionKind.release ? "正式版"
|
||||
: root.data.kind == VersionKind.snapshot ? "预览版"
|
||||
: "远古版";
|
||||
tint: root.data.ancient ? Theme.state-warn : Theme.fg-secondary;
|
||||
}
|
||||
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.data.released;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
|
||||
if root.data.installed : HorizontalLayout {
|
||||
spacing: Metrics.gap-1;
|
||||
AppIcon { kind: IconKind.check; icon-size: 12px; tint: Theme.state-success; }
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "已装";
|
||||
color: Theme.state-success;
|
||||
}
|
||||
}
|
||||
|
||||
MetroButton {
|
||||
kind: ButtonKind.standard;
|
||||
text: root.data.installed ? "重装" : "安装";
|
||||
button-height: 26px;
|
||||
clicked => { root.install(); }
|
||||
}
|
||||
}
|
||||
|
||||
row-touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.install(); }
|
||||
}
|
||||
}
|
||||
|
||||
export component DownloadPage inherits Rectangle {
|
||||
in property <[UiVersion]> versions;
|
||||
in-out property <DownloadCategory> category: DownloadCategory.release;
|
||||
in-out property <bool> installed-only: false;
|
||||
in-out property <string> query: "";
|
||||
/// 远古版子类树是否展开(默认展开,显式可达)
|
||||
in-out property <bool> ancient-expanded: true;
|
||||
/// 右侧视图:0 = 版本列表,1 = Java 运行时
|
||||
in-out property <int> view-tab <=> PageState.download-tab;
|
||||
|
||||
callback category-changed(DownloadCategory);
|
||||
callback install(string);
|
||||
callback installed-only-changed(bool);
|
||||
callback query-changed(string);
|
||||
callback toggle-ancient();
|
||||
/// 远古版兼容性说明(P1 出说明页;当前由 Rust 侧弹 Toast 占位)
|
||||
callback ancient-help();
|
||||
|
||||
background: Theme.bg-canvas;
|
||||
|
||||
VerticalLayout {
|
||||
// 视图切换(版本 / Java)
|
||||
Rectangle {
|
||||
height: 40px;
|
||||
background: Theme.bg-surface;
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
spacing: Metrics.gap-2;
|
||||
UiText {
|
||||
role: TextRole.subtitle;
|
||||
text: "版本获取";
|
||||
width: 96px;
|
||||
}
|
||||
MetroButton {
|
||||
kind: root.view-tab == 0 ? ButtonKind.accent : ButtonKind.subtle;
|
||||
text: "版本列表";
|
||||
button-height: 26px;
|
||||
clicked => { root.view-tab = 0; }
|
||||
}
|
||||
MetroButton {
|
||||
kind: root.view-tab == 1 ? ButtonKind.accent : ButtonKind.subtle;
|
||||
text: "Java 运行时";
|
||||
button-height: 26px;
|
||||
clicked => { root.view-tab = 1; }
|
||||
}
|
||||
Rectangle { horizontal-stretch: 1; background: transparent; }
|
||||
if PageState.java-busy : UiText {
|
||||
role: TextRole.caption;
|
||||
text: "Java 安装中…";
|
||||
color: Theme.accent;
|
||||
}
|
||||
}
|
||||
}
|
||||
Rectangle { height: 1px; background: Theme.stroke; }
|
||||
|
||||
if root.view-tab == 0 : HorizontalLayout {
|
||||
vertical-stretch: 1;
|
||||
|
||||
// ── 页内分类侧栏(常驻)───────────────────────────────────
|
||||
Rectangle {
|
||||
width: 200px;
|
||||
background: Theme.bg-surface;
|
||||
border-width: 1px;
|
||||
border-color: Theme.stroke;
|
||||
|
||||
VerticalLayout {
|
||||
padding-top: Metrics.gap-2;
|
||||
|
||||
CategoryRow {
|
||||
label: "正式版 release";
|
||||
active: root.category == DownloadCategory.release;
|
||||
clicked => { root.category-changed(DownloadCategory.release); }
|
||||
}
|
||||
CategoryRow {
|
||||
label: "预览版 snapshot";
|
||||
active: root.category == DownloadCategory.snapshot;
|
||||
clicked => { root.category-changed(DownloadCategory.snapshot); }
|
||||
}
|
||||
|
||||
// 远古版:可展开的父节点 + 子类树
|
||||
CategoryRow {
|
||||
label: root.ancient-expanded ? "远古版 ▾" : "远古版 ▸";
|
||||
active: root.category == DownloadCategory.ancientClassic
|
||||
|| root.category == DownloadCategory.ancientAlpha
|
||||
|| root.category == DownloadCategory.ancientBeta
|
||||
|| root.category == DownloadCategory.ancientUnlisted;
|
||||
clicked => { root.toggle-ancient(); }
|
||||
}
|
||||
if root.ancient-expanded : CategoryRow {
|
||||
label: "classic(rd-*)";
|
||||
sub: true;
|
||||
active: root.category == DownloadCategory.ancientClassic;
|
||||
clicked => { root.category-changed(DownloadCategory.ancientClassic); }
|
||||
}
|
||||
if root.ancient-expanded : CategoryRow {
|
||||
label: "old_alpha(a1.*)";
|
||||
sub: true;
|
||||
active: root.category == DownloadCategory.ancientAlpha;
|
||||
clicked => { root.category-changed(DownloadCategory.ancientAlpha); }
|
||||
}
|
||||
if root.ancient-expanded : CategoryRow {
|
||||
label: "old_beta(b1.*)";
|
||||
sub: true;
|
||||
active: root.category == DownloadCategory.ancientBeta;
|
||||
clicked => { root.category-changed(DownloadCategory.ancientBeta); }
|
||||
}
|
||||
if root.ancient-expanded : CategoryRow {
|
||||
label: "unlisted 汇总";
|
||||
sub: true;
|
||||
active: root.category == DownloadCategory.ancientUnlisted;
|
||||
clicked => { root.category-changed(DownloadCategory.ancientUnlisted); }
|
||||
}
|
||||
|
||||
CategoryRow {
|
||||
label: "整合包(P1)";
|
||||
disabled: true;
|
||||
}
|
||||
CategoryRow {
|
||||
label: "Mod(P1)";
|
||||
disabled: true;
|
||||
}
|
||||
|
||||
Rectangle { vertical-stretch: 1; background: transparent; }
|
||||
}
|
||||
}
|
||||
|
||||
// ── 右侧版本列表 ──────────────────────────────────────────
|
||||
VerticalLayout {
|
||||
vertical-stretch: 1;
|
||||
|
||||
Rectangle {
|
||||
height: Metrics.page-header-height;
|
||||
background: Theme.bg-canvas;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-4;
|
||||
spacing: Metrics.gap-3;
|
||||
|
||||
SearchField {
|
||||
width: 220px;
|
||||
placeholder: "筛选版本号";
|
||||
text <=> root.query;
|
||||
edited(t) => { root.query-changed(t); }
|
||||
}
|
||||
CheckBox {
|
||||
text: "仅已装";
|
||||
checked: root.installed-only;
|
||||
toggled(v) => { root.installed-only-changed(v); }
|
||||
}
|
||||
Rectangle { horizontal-stretch: 1; background: transparent; }
|
||||
}
|
||||
}
|
||||
|
||||
// 远古版兼容性提示条(首次进入显示,显式可达的代价告知)
|
||||
Rectangle {
|
||||
height: 44px;
|
||||
visible: root.category == DownloadCategory.ancientClassic
|
||||
|| root.category == DownloadCategory.ancientAlpha
|
||||
|| root.category == DownloadCategory.ancientBeta
|
||||
|| root.category == DownloadCategory.ancientUnlisted;
|
||||
background: Theme.state-warn-soft;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-4;
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
AppIcon { kind: IconKind.warn; icon-size: 16px; tint: Theme.state-warn; }
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "远古版(classic/alpha/beta)需要旧版 Java 与旧启动参数,装完请先看兼容性说明。";
|
||||
wrap: word-wrap;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "说明";
|
||||
button-height: 26px;
|
||||
clicked => { root.ancient-help(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.stroke; }
|
||||
|
||||
ListView {
|
||||
vertical-stretch: 1;
|
||||
|
||||
for v in root.versions : VersionRow {
|
||||
data: v;
|
||||
install => { root.install(v.id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if root.view-tab == 1 : JavaPanel {
|
||||
vertical-stretch: 1;
|
||||
}
|
||||
|
||||
// 安装进度(下载页行内安装 / 向导共用同一份进度)
|
||||
InstallPanel {
|
||||
data: PageState.install;
|
||||
cancel => { PageState.install-cancel(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// G1 实例页(09 §2 P0·G1):顶栏(搜索/新建/视图切换/磁贴尺寸)+ 磁贴网格 + 运行中迷你状态条。
|
||||
// 网格用 GridLayout:列数由 Rust 按磁贴尺寸算好(小 6 / 中 4 / 大 3),row/col 随 UiTile 一起给。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { UiTile, InstanceView, TileSize, RuntimePhase } from "../models.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { AppIcon, IconKind } from "../components/icons.slint";
|
||||
import { MetroButton, ButtonKind } from "../widgets/button.slint";
|
||||
import { SearchField, SegmentedControl } from "../widgets/inputs.slint";
|
||||
import { InstanceTile } from "../components/tile.slint";
|
||||
import { ListView } from "std-widgets.slint";
|
||||
|
||||
// 列表视图的一行(与磁贴共用同一份 UiTile 数据)
|
||||
component InstanceRow inherits Rectangle {
|
||||
in property <UiTile> data;
|
||||
callback open();
|
||||
callback launch();
|
||||
|
||||
height: Metrics.row-height + 8px;
|
||||
background: row-touch.has-hover ? Theme.bg-hover : transparent;
|
||||
|
||||
// 行点击区先声明,按钮后声明 → 按钮命中优先
|
||||
row-touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.open(); }
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-3;
|
||||
padding-right: Metrics.gap-3;
|
||||
spacing: Metrics.gap-3;
|
||||
|
||||
Rectangle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: Metrics.radius-sm;
|
||||
background: root.data.hue;
|
||||
|
||||
UiText {
|
||||
text: root.data.initial;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: Theme.fg-on-accent;
|
||||
horizontal-alignment: center;
|
||||
vertical-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
UiText {
|
||||
role: TextRole.body;
|
||||
text: root.data.name;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.data.mc_version;
|
||||
width: 88px;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.data.loader_label;
|
||||
width: 88px;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.data.last_played;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
icon: IconKind.play;
|
||||
icon-only: true;
|
||||
enabled: root.data.phase == RuntimePhase.idle
|
||||
|| root.data.phase == RuntimePhase.failed;
|
||||
clicked => { root.launch(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component InstancesPage inherits Rectangle {
|
||||
in property <[UiTile]> tiles;
|
||||
in property <[UiTile]> running;
|
||||
in property <InstanceView> view: InstanceView.tiles;
|
||||
in-out property <TileSize> tile-size: TileSize.medium;
|
||||
in-out property <string> query: "";
|
||||
|
||||
callback launch(string);
|
||||
callback configure(string);
|
||||
callback stop(string);
|
||||
callback open(string);
|
||||
callback new-instance();
|
||||
callback view-changed(InstanceView);
|
||||
callback tile-size-changed(TileSize);
|
||||
callback query-changed(string);
|
||||
|
||||
background: Theme.bg-canvas;
|
||||
|
||||
VerticalLayout {
|
||||
// ── 顶栏 ────────────────────────────────────────────────
|
||||
Rectangle {
|
||||
height: Metrics.page-header-height;
|
||||
background: Theme.bg-surface;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-4;
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
UiText {
|
||||
role: TextRole.subtitle;
|
||||
text: "实例";
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
SearchField {
|
||||
width: 240px;
|
||||
placeholder: "搜索实例";
|
||||
text <=> root.query;
|
||||
edited(t) => { root.query-changed(t); }
|
||||
}
|
||||
|
||||
Rectangle { horizontal-stretch: 1; background: transparent; }
|
||||
|
||||
SegmentedControl {
|
||||
width: 120px;
|
||||
options: ["磁贴", "列表"];
|
||||
selected: root.view == InstanceView.tiles ? 0 : 1;
|
||||
changed(i) => {
|
||||
root.view-changed(i == 0 ? InstanceView.tiles : InstanceView.list);
|
||||
}
|
||||
}
|
||||
|
||||
SegmentedControl {
|
||||
width: 132px;
|
||||
visible: root.view == InstanceView.tiles;
|
||||
options: ["小", "中", "大"];
|
||||
selected: root.tile-size == TileSize.small ? 0
|
||||
: root.tile-size == TileSize.large ? 2 : 1;
|
||||
changed(i) => {
|
||||
root.tile-size-changed(
|
||||
i == 0 ? TileSize.small : i == 2 ? TileSize.large : TileSize.medium);
|
||||
}
|
||||
}
|
||||
|
||||
MetroButton {
|
||||
kind: ButtonKind.accent;
|
||||
text: "新建实例";
|
||||
show-icon: true;
|
||||
icon: IconKind.plus;
|
||||
clicked => { root.new-instance(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.stroke; }
|
||||
|
||||
// ── 运行中迷你状态条 ──────────────────────────────────────
|
||||
Rectangle {
|
||||
height: Metrics.running-strip-height;
|
||||
visible: root.running.length > 0;
|
||||
background: Theme.state-info-soft;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-4;
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
Rectangle {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: Theme.state-success;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.running.length + " 个实例运行中";
|
||||
color: Theme.state-success;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
for r in root.running : UiText {
|
||||
role: TextRole.caption;
|
||||
text: r.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 磁贴网格 ─────────────────────────────────────────────
|
||||
body := Flickable {
|
||||
vertical-stretch: 1;
|
||||
visible: root.view == InstanceView.tiles && root.tiles.length > 0;
|
||||
content-width: self.width;
|
||||
content-height: grid-content.height;
|
||||
|
||||
grid-content := Rectangle {
|
||||
width: body.width;
|
||||
height: grid.preferred-height;
|
||||
background: transparent;
|
||||
|
||||
grid := GridLayout {
|
||||
padding: Metrics.gap-4;
|
||||
spacing: Metrics.gap-3;
|
||||
|
||||
for t in root.tiles : InstanceTile {
|
||||
row: t.row;
|
||||
col: t.col;
|
||||
data: t;
|
||||
compact: root.tile-size == TileSize.small;
|
||||
open => { root.open(t.id); }
|
||||
launch => { root.launch(t.id); }
|
||||
configure => { root.configure(t.id); }
|
||||
stop => { root.stop(t.id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 列表视图 ─────────────────────────────────────────────
|
||||
ListView {
|
||||
vertical-stretch: 1;
|
||||
visible: root.view == InstanceView.list && root.tiles.length > 0;
|
||||
|
||||
for t in root.tiles : InstanceRow {
|
||||
data: t;
|
||||
open => { root.open(t.id); }
|
||||
launch => { root.launch(t.id); }
|
||||
}
|
||||
}
|
||||
|
||||
// ── 空态 ────────────────────────────────────────────────
|
||||
VerticalLayout {
|
||||
vertical-stretch: 1;
|
||||
visible: root.tiles.length == 0;
|
||||
alignment: center;
|
||||
spacing: Metrics.gap-3;
|
||||
|
||||
UiText {
|
||||
role: TextRole.title;
|
||||
text: "还没有实例";
|
||||
horizontal-alignment: center;
|
||||
color: Theme.fg-secondary;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "新建一个实例,或者先去下载页装个版本";
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
MetroButton {
|
||||
kind: ButtonKind.accent;
|
||||
text: "新建实例";
|
||||
show-icon: true;
|
||||
icon: IconKind.plus;
|
||||
clicked => { root.new-instance(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// G3 日志页(09 §2 P0·G3):左侧会话列表(✓正常 / ✗崩溃 / ⚠静默失败)+ 右侧日志流 + 崩溃诊断卡片。
|
||||
// 诊断卡片 = D8 规则引擎结论(命中规则 + 置信度 + 修复动作 + 跳 AI 栏带上下文)。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { UiLogSession, UiLogLine, UiDiagnosis, LogOutcome, LogLevel } from "../models.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { AppIcon, IconKind } from "../components/icons.slint";
|
||||
import { Badge } from "../components/badge.slint";
|
||||
import { MetroButton, ButtonKind } from "../widgets/button.slint";
|
||||
import { SearchField } from "../widgets/inputs.slint";
|
||||
import { PageState } from "../page_state.slint";
|
||||
import { ListView } from "std-widgets.slint";
|
||||
|
||||
component SessionRow inherits Rectangle {
|
||||
in property <UiLogSession> data;
|
||||
in property <bool> active: false;
|
||||
callback clicked();
|
||||
|
||||
height: 52px;
|
||||
background: root.active ? Theme.accent-soft : (row-touch.has-hover ? Theme.bg-hover : transparent);
|
||||
|
||||
if root.active : Rectangle {
|
||||
x: 0;
|
||||
y: 0;
|
||||
width: 3px;
|
||||
height: root.height;
|
||||
background: Theme.accent;
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-3;
|
||||
padding-right: Metrics.gap-2;
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
AppIcon {
|
||||
icon-size: 14px;
|
||||
kind: root.data.outcome == LogOutcome.ok ? IconKind.check
|
||||
: root.data.outcome == LogOutcome.crashed ? IconKind.error
|
||||
: IconKind.warn;
|
||||
tint: root.data.outcome == LogOutcome.ok ? Theme.state-success
|
||||
: root.data.outcome == LogOutcome.crashed ? Theme.state-danger
|
||||
: Theme.state-warn;
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
vertical-stretch: 1;
|
||||
|
||||
UiText {
|
||||
role: TextRole.body;
|
||||
text: root.data.instance;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.data.when;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
}
|
||||
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.data.duration;
|
||||
width: 56px;
|
||||
horizontal-alignment: right;
|
||||
}
|
||||
}
|
||||
|
||||
row-touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.clicked(); }
|
||||
}
|
||||
}
|
||||
|
||||
component LogRow inherits Rectangle {
|
||||
in property <UiLogLine> line;
|
||||
|
||||
height: 18px;
|
||||
background: root.line.level == LogLevel.error
|
||||
? Theme.state-danger-soft
|
||||
: transparent;
|
||||
|
||||
UiText {
|
||||
x: Metrics.gap-2;
|
||||
y: 0;
|
||||
width: root.width - 2 * Metrics.gap-2;
|
||||
height: root.height;
|
||||
role: TextRole.monoSmall;
|
||||
text: root.line.text;
|
||||
color: root.line.level == LogLevel.error ? Theme.state-danger
|
||||
: root.line.level == LogLevel.warn ? Theme.state-warn
|
||||
: root.line.level == LogLevel.debug ? Theme.fg-disabled
|
||||
: Theme.fg-primary;
|
||||
}
|
||||
}
|
||||
|
||||
export component LogsPage inherits Rectangle {
|
||||
in property <[UiLogSession]> sessions;
|
||||
in property <[UiLogLine]> lines;
|
||||
in property <UiDiagnosis> diagnosis;
|
||||
in property <bool> has-diagnosis: false;
|
||||
in property <bool> live: false;
|
||||
in-out property <string> selected: "";
|
||||
in-out property <string> query: "";
|
||||
|
||||
callback select(string);
|
||||
callback ask-ai(string);
|
||||
callback apply-fix();
|
||||
callback query-changed(string);
|
||||
/// 重新扫会话索引(手动刷新)
|
||||
callback refresh();
|
||||
|
||||
background: Theme.bg-canvas;
|
||||
|
||||
HorizontalLayout {
|
||||
// ── 左侧会话列表 ─────────────────────────────────────────
|
||||
Rectangle {
|
||||
width: 248px;
|
||||
background: Theme.bg-surface;
|
||||
|
||||
VerticalLayout {
|
||||
Rectangle {
|
||||
height: 44px;
|
||||
background: transparent;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-3;
|
||||
padding-right: Metrics.gap-2;
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
UiText {
|
||||
role: TextRole.bodyStrong;
|
||||
text: "会话";
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
if root.live : Rectangle {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: Theme.state-success;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListView {
|
||||
vertical-stretch: 1;
|
||||
|
||||
for s in root.sessions : SessionRow {
|
||||
data: s;
|
||||
active: root.selected == s.id;
|
||||
clicked => { root.select(s.id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { width: 1px; background: Theme.stroke; }
|
||||
|
||||
// ── 右侧日志流 + 诊断卡片 ────────────────────────────────
|
||||
VerticalLayout {
|
||||
vertical-stretch: 1;
|
||||
|
||||
// 会话头:标题 + 实时标记 + 筛选 + 刷新(第一行),崩溃日志导入(第二行)
|
||||
Rectangle {
|
||||
height: 84px;
|
||||
background: Theme.bg-surface;
|
||||
|
||||
VerticalLayout {
|
||||
HorizontalLayout {
|
||||
height: 44px;
|
||||
padding-left: Metrics.gap-3;
|
||||
padding-right: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
UiText {
|
||||
role: TextRole.bodyStrong;
|
||||
text: root.selected == "" ? "未选择会话" : root.selected;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
if root.live : Badge { text: "实时"; tint: Theme.state-success; }
|
||||
SearchField {
|
||||
width: 140px;
|
||||
placeholder: "筛选日志行";
|
||||
text <=> root.query;
|
||||
edited(t) => { root.query-changed(t); }
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "刷新";
|
||||
button-height: 26px;
|
||||
clicked => { root.refresh(); }
|
||||
}
|
||||
}
|
||||
|
||||
// 崩溃日志导入分析(hs_err_pid*.log / crash-*.txt / latest.log)
|
||||
HorizontalLayout {
|
||||
height: 40px;
|
||||
padding-left: Metrics.gap-3;
|
||||
padding-right: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
SearchField {
|
||||
horizontal-stretch: 1;
|
||||
placeholder: "崩溃日志路径(如 .minecraft/crash-reports/crash-*.txt)";
|
||||
text <=> PageState.crash-path;
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.standard;
|
||||
text: "导入分析";
|
||||
button-height: 26px;
|
||||
enabled: PageState.crash-path != "";
|
||||
clicked => { PageState.crash-import(PageState.crash-path); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.stroke; }
|
||||
|
||||
// 崩溃诊断卡片(D8)
|
||||
Rectangle {
|
||||
height: root.has-diagnosis ? 76px : 0px;
|
||||
visible: root.has-diagnosis;
|
||||
background: Theme.state-danger-soft;
|
||||
|
||||
HorizontalLayout {
|
||||
padding: Metrics.gap-3;
|
||||
spacing: Metrics.gap-3;
|
||||
|
||||
AppIcon { kind: IconKind.error; icon-size: 20px; tint: Theme.state-danger; }
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 2px;
|
||||
vertical-stretch: 1;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Metrics.gap-2;
|
||||
UiText {
|
||||
role: TextRole.bodyStrong;
|
||||
text: root.diagnosis.title;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
Badge {
|
||||
text: "置信度 " + root.diagnosis.confidence + "%";
|
||||
tint: Theme.state-danger;
|
||||
}
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.diagnosis.hint;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
spacing: Metrics.gap-1;
|
||||
alignment: center;
|
||||
|
||||
if root.diagnosis.has_fix : MetroButton {
|
||||
kind: ButtonKind.standard;
|
||||
text: "修复建议";
|
||||
button-height: 26px;
|
||||
clicked => { root.apply-fix(); }
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "让 AI 详解";
|
||||
button-height: 26px;
|
||||
clicked => { root.ask-ai(root.selected); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 日志流(行虚拟化)
|
||||
Rectangle {
|
||||
vertical-stretch: 1;
|
||||
background: Theme.bg-canvas;
|
||||
|
||||
ListView {
|
||||
for l in root.lines : LogRow {
|
||||
line: l;
|
||||
width: root.width;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// 设置 → 账户(D9 / G6):三轨账户列表 + 离线添加 + 微软登录(授权码浏览器 / 设备码)+ 删除/切换。
|
||||
//
|
||||
// 凭据红线:这里能看到的只有「名字 / 轨道 / 公开 UUID / 登录名 / 创建时间」——
|
||||
// token 与密码永远不进 UI(Rust 侧的 AccountView 不含这些字段)。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { AccountKind, UiAccount } from "../models.slint";
|
||||
import { PageState } from "../page_state.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { Badge } from "../components/badge.slint";
|
||||
import { AppIcon, IconKind } from "../components/icons.slint";
|
||||
import { MetroButton, ButtonKind } from "../widgets/button.slint";
|
||||
import { SearchField } from "../widgets/inputs.slint";
|
||||
import { SettingGroup } from "../widgets/setting-item.slint";
|
||||
|
||||
component AccountRow inherits Rectangle {
|
||||
in property <UiAccount> data;
|
||||
callback activate();
|
||||
callback remove();
|
||||
|
||||
height: 56px;
|
||||
background: root.data.active ? Theme.accent-soft : transparent;
|
||||
|
||||
if root.data.active : Rectangle {
|
||||
x: 0;
|
||||
y: 0;
|
||||
width: 3px;
|
||||
height: root.height;
|
||||
background: Theme.accent;
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-3;
|
||||
padding-right: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
AppIcon {
|
||||
icon-size: 16px;
|
||||
kind: root.data.kind == AccountKind.offline ? IconKind.instances : IconKind.ai;
|
||||
tint: root.data.active ? Theme.accent : Theme.fg-secondary;
|
||||
}
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
vertical-stretch: 1;
|
||||
UiText { role: TextRole.bodyStrong; text: root.data.name; horizontal-stretch: 1; }
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.data.profile_id + (root.data.login_name == "" ? "" : " · " + root.data.login_name);
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
Badge { text: root.data.kind_label; tint: Theme.fg-secondary; }
|
||||
UiText { role: TextRole.caption; text: root.data.created; width: 108px; }
|
||||
if !root.data.active : MetroButton {
|
||||
kind: ButtonKind.standard;
|
||||
text: "用这个";
|
||||
button-height: 24px;
|
||||
clicked => { root.activate(); }
|
||||
}
|
||||
if root.data.active : Badge { text: "启动中"; tint: Theme.accent; }
|
||||
MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "删除";
|
||||
button-height: 24px;
|
||||
clicked => { root.remove(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component AccountSection inherits Rectangle {
|
||||
background: transparent;
|
||||
|
||||
VerticalLayout {
|
||||
// 微软登录面板(设备码显示用户码;授权码显示可复制的授权链接;失败显示原因)
|
||||
if PageState.device.visible : Rectangle {
|
||||
height: PageState.device.busy ? 96px : 84px;
|
||||
background: PageState.device.phase_label == "失败" ? Theme.state-danger-soft : Theme.accent-soft;
|
||||
|
||||
VerticalLayout {
|
||||
padding: Metrics.gap-3;
|
||||
spacing: Metrics.gap-1;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Metrics.gap-2;
|
||||
AppIcon {
|
||||
icon-size: 16px;
|
||||
kind: PageState.device.phase_label == "失败" ? IconKind.error : IconKind.ai;
|
||||
tint: PageState.device.phase_label == "失败" ? Theme.state-danger : Theme.accent;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.bodyStrong;
|
||||
text: "微软登录 · " + PageState.device.phase_label;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
if PageState.device.busy : MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "取消";
|
||||
button-height: 24px;
|
||||
clicked => { PageState.msa-cancel(); }
|
||||
}
|
||||
if !PageState.device.busy : MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "关闭";
|
||||
button-height: 24px;
|
||||
clicked => { PageState.msa-cancel(); }
|
||||
}
|
||||
}
|
||||
|
||||
if PageState.device.user_code != "" : HorizontalLayout {
|
||||
spacing: Metrics.gap-3;
|
||||
UiText {
|
||||
role: TextRole.title;
|
||||
text: PageState.device.user_code;
|
||||
color: Theme.accent;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "在浏览器打开 " + PageState.device.verification_uri + " 输入上面的用户码";
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: PageState.device.message;
|
||||
horizontal-stretch: 1;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
if PageState.device.qr_link != "" : UiText {
|
||||
role: TextRole.monoSmall;
|
||||
text: PageState.device.qr_link;
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 离线添加表单
|
||||
SettingGroup { title: "添加离线账户"; }
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-4;
|
||||
spacing: Metrics.gap-2;
|
||||
SearchField {
|
||||
width: 220px;
|
||||
text <=> PageState.offline-name;
|
||||
placeholder: "玩家名(离线 UUID 自动生成)";
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.standard;
|
||||
text: "添加离线";
|
||||
button-height: 28px;
|
||||
enabled: PageState.offline-name != "";
|
||||
clicked => { PageState.account-add-offline(PageState.offline-name); }
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.accent;
|
||||
text: "微软登录";
|
||||
button-height: 28px;
|
||||
enabled: !PageState.device.busy;
|
||||
clicked => { PageState.msa-browser-begin(); }
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "设备码登录";
|
||||
button-height: 28px;
|
||||
enabled: !PageState.device.busy;
|
||||
clicked => { PageState.msa-begin(); }
|
||||
}
|
||||
}
|
||||
|
||||
// 账户列表
|
||||
SettingGroup { title: "账户(" + PageState.accounts.length + ")"; }
|
||||
if PageState.accounts.length == 0 : Rectangle {
|
||||
height: 72px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 2px;
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "还没有账户:启动时会用「Player」离线占位。";
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
for account in PageState.accounts : AccountRow {
|
||||
data: account;
|
||||
activate => { PageState.account-switch(account.id); }
|
||||
remove => { PageState.account-remove(account.id); }
|
||||
}
|
||||
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
x: Metrics.gap-4;
|
||||
text: "凭据只存在 <数据根>/data/auth/(账号文件与凭据文件分离),界面与 AI 都拿不到 token。";
|
||||
horizontal-stretch: 1;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// 设置页「外观」分组(09 §2 G6 + §113 D17 定案):主题三态 + 「外观(高级)」P1 四件。
|
||||
//
|
||||
// 从 pages/settings.slint 拆出来只为守住 400 行门槛;控件语义与设置键一一对应:
|
||||
// theme-mode → appearance.theme_mode / bg-* → appearance.bg_image* / panel-alpha →
|
||||
// appearance.panel_alpha / glass → appearance.glass / accent-color → appearance.accent_color。
|
||||
// 09 §111 硬约束:高级特性全部默认关闭,开启背景图时这里会给出对比度提示(warn 行)。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { ThemeMode, BgFillKind } from "../models.slint";
|
||||
import { SettingItem, SettingGroup, ControlKind } from "../widgets/setting-item.slint";
|
||||
|
||||
export component AppearanceSection inherits VerticalLayout {
|
||||
in-out property <ThemeMode> theme-mode: ThemeMode.auto;
|
||||
in-out property <bool> bg-image-enabled: false;
|
||||
in-out property <string> bg-image: "";
|
||||
in-out property <BgFillKind> bg-fill: BgFillKind.cover;
|
||||
in-out property <float> bg-dim: 0.35;
|
||||
in-out property <float> panel-alpha: 1.0;
|
||||
in-out property <bool> glass: false;
|
||||
in-out property <string> accent-color: "";
|
||||
in property <bool> accent-invalid: false;
|
||||
|
||||
callback theme-changed(ThemeMode);
|
||||
callback bg-image-enabled-changed(bool);
|
||||
callback bg-image-changed(string);
|
||||
callback bg-fill-changed(BgFillKind);
|
||||
callback bg-dim-changed(float);
|
||||
callback panel-alpha-changed(float);
|
||||
callback glass-changed(bool);
|
||||
callback accent-color-changed(string);
|
||||
callback accent-swatch-picked(int);
|
||||
|
||||
// ── 外观(D17:主题三态 + 09 §101-104 高级四件)──────
|
||||
SettingGroup { title: "主题"; }
|
||||
SettingItem {
|
||||
label: "主题模式";
|
||||
description: "跟随系统(Auto,随 OS 热切)/ 浅色 / 深色";
|
||||
kind: ControlKind.segmented;
|
||||
options: ["跟随系统", "浅色", "深色"];
|
||||
selected: root.theme-mode == ThemeMode.auto ? 0
|
||||
: root.theme-mode == ThemeMode.light ? 1 : 2;
|
||||
changed(i) => {
|
||||
root.theme-mode = i == 0 ? ThemeMode.auto
|
||||
: i == 1 ? ThemeMode.light : ThemeMode.dark;
|
||||
root.theme-changed(root.theme-mode);
|
||||
}
|
||||
}
|
||||
|
||||
SettingGroup {
|
||||
title: "高级特性(出厂全部关闭,开启后请确认前景可读)";
|
||||
}
|
||||
// ① 自定义窗口背景图(09 §101)
|
||||
SettingItem {
|
||||
label: "自定义窗口背景图";
|
||||
description: "PNG 图片铺满窗口;开启后所有面板半透明处都会透出它";
|
||||
kind: ControlKind.toggle;
|
||||
checked: root.bg-image-enabled;
|
||||
toggled(v) => {
|
||||
root.bg-image-enabled = v;
|
||||
root.bg-image-enabled-changed(v);
|
||||
}
|
||||
}
|
||||
SettingItem {
|
||||
label: "背景图路径";
|
||||
description: root.bg-image == "" ? "尚未选择(绝对路径,例:D:/wall/p.png)" : root.bg-image;
|
||||
kind: ControlKind.text;
|
||||
button-text: "D:/wall/p.png";
|
||||
value <=> root.bg-image;
|
||||
edited(t) => { root.bg-image-changed(t); }
|
||||
}
|
||||
SettingItem {
|
||||
label: "填充模式";
|
||||
description: "铺满(裁切)/ 完整(留底色)/ 平铺";
|
||||
kind: ControlKind.segmented;
|
||||
options: ["铺满", "完整", "平铺"];
|
||||
selected: root.bg-fill == BgFillKind.cover ? 0
|
||||
: root.bg-fill == BgFillKind.contain ? 1 : 2;
|
||||
changed(i) => {
|
||||
root.bg-fill = i == 0 ? BgFillKind.cover
|
||||
: i == 1 ? BgFillKind.contain : BgFillKind.tile;
|
||||
root.bg-fill-changed(root.bg-fill);
|
||||
}
|
||||
}
|
||||
SettingItem {
|
||||
label: "暗化层强度";
|
||||
// 09 §111:开启背景图时自动给对比度提示——暗化太低 + 面板太透会伤可读性
|
||||
description: (root.bg-image-enabled && (root.bg-dim < 0.25 || root.panel-alpha < 0.8))
|
||||
? "⚠ 对比度偏低:建议暗化 ≥ 25% 或面板不透明度 ≥ 80%"
|
||||
: "越大背景越暗、前景越清晰(0% = 不压暗)";
|
||||
warn: root.bg-image-enabled && (root.bg-dim < 0.25 || root.panel-alpha < 0.8);
|
||||
kind: ControlKind.slider;
|
||||
min-value: 0.0;
|
||||
max-value: 0.9;
|
||||
slider-value: root.bg-dim;
|
||||
slid(v) => {
|
||||
root.bg-dim = v;
|
||||
root.bg-dim-changed(v);
|
||||
}
|
||||
}
|
||||
// ② 全局面板半透明度(09 §102)
|
||||
SettingItem {
|
||||
label: "全局面板半透明度";
|
||||
description: "卡片 / 导航 / AI 栏统一透明度(100% = 出厂 Metro 纯色)";
|
||||
kind: ControlKind.slider;
|
||||
min-value: 0.4;
|
||||
max-value: 1.0;
|
||||
slider-value: root.panel-alpha;
|
||||
slid(v) => {
|
||||
root.panel-alpha = v;
|
||||
root.panel-alpha-changed(v);
|
||||
}
|
||||
}
|
||||
// ③ 玻璃拟态卡片(09 §103)
|
||||
SettingItem {
|
||||
label: "玻璃拟态卡片";
|
||||
description: "卡片底铺背景图的预生成模糊层(需先开启背景图)";
|
||||
kind: ControlKind.toggle;
|
||||
checked: root.glass;
|
||||
toggled(v) => {
|
||||
root.glass = v;
|
||||
root.glass-changed(v);
|
||||
}
|
||||
}
|
||||
// ④ 自定义强调色(09 §104)
|
||||
SettingItem {
|
||||
label: "预设强调色";
|
||||
description: root.accent-color == "" ? "当前:出厂 Metro 蓝" : "当前:" + root.accent-color;
|
||||
kind: ControlKind.swatches;
|
||||
swatch-colors: [
|
||||
#0078D4, #0F6CBD, #107C10, #9D5D00,
|
||||
#C42B1C, #8764B8, #C239B3, #00B7C3,
|
||||
];
|
||||
swatch-current: root.accent-color == "" ? #0078D4 : Theme.accent;
|
||||
swatch-picked(i) => { root.accent-swatch-picked(i); }
|
||||
}
|
||||
SettingItem {
|
||||
label: "强调色(十六进制)";
|
||||
description: root.accent-invalid
|
||||
? "⚠ 需要 #RRGGBB 形式,留空恢复出厂蓝"
|
||||
: "留空 = 出厂 #0078D4;影响顶线 / 选中态 / 按钮 / 徽标";
|
||||
warn: root.accent-invalid;
|
||||
kind: ControlKind.text;
|
||||
button-text: "#0078D4";
|
||||
value <=> root.accent-color;
|
||||
edited(t) => { root.accent-color-changed(t); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// 设置页右栏的「schema 分组」渲染器:下载 / Java / 数据 / AI / MCP 五组。
|
||||
//
|
||||
// 行来自 Rust 侧的 `SettingGroupView`(core::setting schema 的键),这里只按控件类型画:
|
||||
// 开关 → toggle;分段 → segmented;文本 → text;只读 → 纯文案;动作 → 按钮。
|
||||
// 写回一律 `PageState.setting-changed(key, value)` / `setting-action(action)`,
|
||||
// 由 host 走 `SettingStore::set`(同源 launcher.json)。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { UiSettingGroup, UiSettingRow, SettingControl } from "../models.slint";
|
||||
import { PageState } from "../page_state.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { SettingItem, SettingGroup, ControlKind } from "../widgets/setting-item.slint";
|
||||
|
||||
component RowView inherits Rectangle {
|
||||
in property <UiSettingRow> item;
|
||||
/// 文本编辑的本地缓冲(编辑中不回写模型,失焦/回车才提交)
|
||||
in-out property <string> draft;
|
||||
|
||||
height: root.item.control == SettingControl.readonly ? 48px
|
||||
: root.item.description == "" ? 44px : 58px;
|
||||
background: transparent;
|
||||
|
||||
init => { self.draft = root.item.value_text; }
|
||||
changed item => { self.draft = root.item.value_text; }
|
||||
|
||||
if root.item.control == SettingControl.toggle : SettingItem {
|
||||
label: root.item.label;
|
||||
description: root.item.description;
|
||||
kind: ControlKind.toggle;
|
||||
checked: root.item.flag;
|
||||
toggled(v) => { PageState.setting-changed(root.item.key, v ? "true" : "false"); }
|
||||
}
|
||||
|
||||
if root.item.control == SettingControl.choice : SettingItem {
|
||||
label: root.item.label;
|
||||
description: root.item.description;
|
||||
kind: ControlKind.segmented;
|
||||
options: root.item.options;
|
||||
selected: root.item.selected;
|
||||
changed(i) => {
|
||||
if (i >= 0 && i < root.item.options.length) {
|
||||
PageState.setting-changed(root.item.key, root.item.options[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if root.item.control == SettingControl.text : SettingItem {
|
||||
label: root.item.label;
|
||||
description: root.item.description;
|
||||
kind: ControlKind.text;
|
||||
value: root.draft;
|
||||
edited(t) => {
|
||||
root.draft = t;
|
||||
PageState.setting-changed(root.item.key, t);
|
||||
}
|
||||
}
|
||||
|
||||
if root.item.control == SettingControl.action : SettingItem {
|
||||
label: root.item.label;
|
||||
description: root.item.description;
|
||||
kind: ControlKind.button;
|
||||
button-text: "执行";
|
||||
clicked => { PageState.setting-action(root.item.action); }
|
||||
}
|
||||
|
||||
if root.item.control == SettingControl.readonly : HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-4;
|
||||
spacing: Metrics.gap-3;
|
||||
|
||||
UiText {
|
||||
role: TextRole.body;
|
||||
text: root.item.label;
|
||||
width: 200px;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.item.description;
|
||||
horizontal-stretch: 1;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component SettingsGroups inherits Rectangle {
|
||||
in property <string> tab: "";
|
||||
|
||||
background: transparent;
|
||||
|
||||
VerticalLayout {
|
||||
padding-bottom: Metrics.gap-5;
|
||||
|
||||
if PageState.setting-groups.length == 0 : Rectangle {
|
||||
height: 120px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: 2px;
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "设置还没加载出来(host 未就绪或数据根不可写)";
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for group in PageState.setting-groups : Rectangle {
|
||||
visible: group.id == root.tab;
|
||||
height: group.rows.length * 58px + 40px;
|
||||
background: transparent;
|
||||
|
||||
VerticalLayout {
|
||||
SettingGroup { title: group.title; }
|
||||
for setting-row in group.rows : RowView { item: setting-row; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// G6 设置页(09 §2 P0·G6):左子导航 + 右表单 + 搜索框(设置项多,防迷路)。
|
||||
// 「通用 / 外观(D17 三态 + 09 §101-104 高级四件,实现在 settings-appearance.slint)/ 数据 / AI /
|
||||
// 关于」是真实控件,Java / 账户 / 代理分组仍是入口按钮(等各自 mission 接实现)。
|
||||
// 关于页内嵌 AboutSlint —— Slint RF 2.0 许可义务(01c §8:关于页放官方部件)。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { SettingSection, TileSize, ThemeMode, BgFillKind } from "../models.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { SettingItem, SettingGroup, ControlKind } from "../widgets/setting-item.slint";
|
||||
import { AppearanceSection } from "settings-appearance.slint";
|
||||
import { AccountSection } from "settings-account.slint";
|
||||
import { SettingsGroups } from "settings-groups.slint";
|
||||
import { PageState } from "../page_state.slint";
|
||||
import { SearchField } from "../widgets/inputs.slint";
|
||||
import { AboutSlint, ScrollView } from "std-widgets.slint";
|
||||
|
||||
component SectionRow inherits Rectangle {
|
||||
in property <string> label: "";
|
||||
in property <SettingSection> section: SettingSection.general;
|
||||
in property <bool> active: false;
|
||||
callback clicked();
|
||||
|
||||
height: 36px;
|
||||
background: root.active ? Theme.accent-soft : (row-touch.has-hover ? Theme.bg-hover : transparent);
|
||||
|
||||
if root.active : Rectangle {
|
||||
x: 0;
|
||||
y: 0;
|
||||
width: 3px;
|
||||
height: root.height;
|
||||
background: Theme.accent;
|
||||
}
|
||||
|
||||
UiText {
|
||||
x: Metrics.gap-3;
|
||||
y: 0;
|
||||
width: root.width - Metrics.gap-3 - Metrics.gap-2;
|
||||
height: root.height;
|
||||
role: TextRole.body;
|
||||
text: root.label;
|
||||
color: root.active ? Theme.accent : Theme.fg-primary;
|
||||
}
|
||||
|
||||
row-touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.clicked(); }
|
||||
}
|
||||
}
|
||||
|
||||
export component SettingsPage inherits Rectangle {
|
||||
in-out property <SettingSection> section: SettingSection.general;
|
||||
in-out property <string> query: "";
|
||||
|
||||
// 外观(D17:主题三态 + 09 §101-104 高级特性四件)
|
||||
in-out property <ThemeMode> theme-mode: ThemeMode.auto;
|
||||
in-out property <bool> bg-image-enabled: false;
|
||||
in-out property <string> bg-image: "";
|
||||
in-out property <BgFillKind> bg-fill: BgFillKind.cover;
|
||||
in-out property <float> bg-dim: 0.35;
|
||||
in-out property <float> panel-alpha: 1.0;
|
||||
in-out property <bool> glass: false;
|
||||
in-out property <string> accent-color: "";
|
||||
in property <bool> accent-invalid: false;
|
||||
// 通用
|
||||
in-out property <TileSize> tile-size: TileSize.medium;
|
||||
in-out property <bool> close-to-tray: true;
|
||||
|
||||
// 数据
|
||||
in-out property <int> data-mode: 0;
|
||||
in property <string> data-path: "";
|
||||
// 下载
|
||||
in-out property <int> download-concurrency: 1;
|
||||
in property <string> region-label: "未知";
|
||||
// 账户
|
||||
in property <string> account-label: "未登录";
|
||||
// AI
|
||||
in-out property <int> ai-endpoint: 0;
|
||||
in-out property <bool> yolo: false;
|
||||
in-out property <bool> mcp-enabled: false;
|
||||
// 代理
|
||||
in-out property <string> proxy: "";
|
||||
// 关于
|
||||
in property <string> version: "";
|
||||
|
||||
callback section-changed(SettingSection);
|
||||
callback query-changed(string);
|
||||
callback theme-changed(ThemeMode);
|
||||
callback bg-image-enabled-changed(bool);
|
||||
callback bg-image-changed(string);
|
||||
callback bg-fill-changed(BgFillKind);
|
||||
callback bg-dim-changed(float);
|
||||
callback panel-alpha-changed(float);
|
||||
callback glass-changed(bool);
|
||||
callback accent-color-changed(string);
|
||||
callback accent-swatch-picked(int);
|
||||
callback tile-size-changed(TileSize);
|
||||
callback close-to-tray-changed(bool);
|
||||
callback data-mode-changed(int);
|
||||
callback download-concurrency-changed(int);
|
||||
callback ai-endpoint-changed(int);
|
||||
callback yolo-changed(bool);
|
||||
callback mcp-changed(bool);
|
||||
callback proxy-changed(string);
|
||||
callback migrate-data();
|
||||
callback login();
|
||||
callback manage-java();
|
||||
callback open-audit-log();
|
||||
callback detect-region();
|
||||
|
||||
background: Theme.bg-canvas;
|
||||
|
||||
HorizontalLayout {
|
||||
// ── 左子导航 ──────────────────────────────────────────────
|
||||
Rectangle {
|
||||
width: 200px;
|
||||
background: Theme.bg-surface;
|
||||
|
||||
VerticalLayout {
|
||||
padding-top: Metrics.gap-2;
|
||||
|
||||
SectionRow { label: "通用"; section: SettingSection.general; active: root.section == SettingSection.general; clicked => { root.section-changed(SettingSection.general); } }
|
||||
SectionRow { label: "外观"; section: SettingSection.appearance; active: root.section == SettingSection.appearance; clicked => { root.section-changed(SettingSection.appearance); } }
|
||||
SectionRow { label: "数据"; section: SettingSection.data; active: root.section == SettingSection.data; clicked => { root.section-changed(SettingSection.data); } }
|
||||
SectionRow { label: "下载"; section: SettingSection.download; active: root.section == SettingSection.download; clicked => { root.section-changed(SettingSection.download); } }
|
||||
SectionRow { label: "Java"; section: SettingSection.java; active: root.section == SettingSection.java; clicked => { root.section-changed(SettingSection.java); } }
|
||||
SectionRow { label: "账户"; section: SettingSection.account; active: root.section == SettingSection.account; clicked => { root.section-changed(SettingSection.account); } }
|
||||
SectionRow { label: "AI"; section: SettingSection.ai; active: root.section == SettingSection.ai; clicked => { root.section-changed(SettingSection.ai); } }
|
||||
SectionRow { label: "代理"; section: SettingSection.proxy; active: root.section == SettingSection.proxy; clicked => { root.section-changed(SettingSection.proxy); } }
|
||||
SectionRow { label: "关于"; section: SettingSection.about; active: root.section == SettingSection.about; clicked => { root.section-changed(SettingSection.about); } }
|
||||
|
||||
Rectangle { vertical-stretch: 1; background: transparent; }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { width: 1px; background: Theme.stroke; }
|
||||
|
||||
// ── 右表单 ────────────────────────────────────────────────
|
||||
VerticalLayout {
|
||||
vertical-stretch: 1;
|
||||
|
||||
Rectangle {
|
||||
height: Metrics.page-header-height;
|
||||
background: Theme.bg-surface;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-4;
|
||||
spacing: Metrics.gap-3;
|
||||
|
||||
UiText {
|
||||
role: TextRole.subtitle;
|
||||
text: "设置";
|
||||
width: 72px;
|
||||
}
|
||||
SearchField {
|
||||
width: 240px;
|
||||
placeholder: "搜索设置项";
|
||||
text <=> root.query;
|
||||
edited(t) => { root.query-changed(t); }
|
||||
}
|
||||
Rectangle { horizontal-stretch: 1; background: transparent; }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.stroke; }
|
||||
|
||||
ScrollView {
|
||||
vertical-stretch: 1;
|
||||
|
||||
VerticalLayout {
|
||||
padding-bottom: Metrics.gap-5;
|
||||
|
||||
// 外观(D17:主题三态 + 09 §101-104 高级四件;实现在 settings-appearance.slint)
|
||||
if root.section == SettingSection.appearance : AppearanceSection {
|
||||
theme-mode: root.theme-mode;
|
||||
bg-image-enabled: root.bg-image-enabled;
|
||||
bg-image: root.bg-image;
|
||||
bg-fill: root.bg-fill;
|
||||
bg-dim: root.bg-dim;
|
||||
panel-alpha: root.panel-alpha;
|
||||
glass: root.glass;
|
||||
accent-color: root.accent-color;
|
||||
accent-invalid: root.accent-invalid;
|
||||
theme-changed(m) => {
|
||||
root.theme-mode = m;
|
||||
root.theme-changed(m);
|
||||
}
|
||||
bg-image-enabled-changed(v) => {
|
||||
root.bg-image-enabled = v;
|
||||
root.bg-image-enabled-changed(v);
|
||||
}
|
||||
bg-image-changed(t) => {
|
||||
root.bg-image = t;
|
||||
root.bg-image-changed(t);
|
||||
}
|
||||
bg-fill-changed(f) => {
|
||||
root.bg-fill = f;
|
||||
root.bg-fill-changed(f);
|
||||
}
|
||||
bg-dim-changed(v) => {
|
||||
root.bg-dim = v;
|
||||
root.bg-dim-changed(v);
|
||||
}
|
||||
panel-alpha-changed(v) => {
|
||||
root.panel-alpha = v;
|
||||
root.panel-alpha-changed(v);
|
||||
}
|
||||
glass-changed(v) => {
|
||||
root.glass = v;
|
||||
root.glass-changed(v);
|
||||
}
|
||||
accent-color-changed(t) => {
|
||||
root.accent-color = t;
|
||||
root.accent-color-changed(t);
|
||||
}
|
||||
accent-swatch-picked(i) => { root.accent-swatch-picked(i); }
|
||||
}
|
||||
|
||||
// ── 通用 ────────────────────────────────────────
|
||||
if root.section == SettingSection.general : SettingGroup { title: "外观"; }
|
||||
if root.section == SettingSection.general : SettingItem {
|
||||
label: "磁贴尺寸";
|
||||
description: "小 6 列 / 中 4 列(默认)/ 大 3 列";
|
||||
kind: ControlKind.segmented;
|
||||
options: ["小", "中", "大"];
|
||||
selected: root.tile-size == TileSize.small ? 0
|
||||
: root.tile-size == TileSize.large ? 2 : 1;
|
||||
changed(i) => {
|
||||
root.tile-size-changed(
|
||||
i == 0 ? TileSize.small : i == 2 ? TileSize.large : TileSize.medium);
|
||||
}
|
||||
}
|
||||
if root.section == SettingSection.general : SettingItem {
|
||||
label: "关闭窗口时最小化到托盘";
|
||||
description: "避免误关正在运行的游戏";
|
||||
kind: ControlKind.toggle;
|
||||
checked: root.close-to-tray;
|
||||
toggled(v) => { root.close-to-tray-changed(v); }
|
||||
}
|
||||
|
||||
// ── 数据 / 下载 / Java / 账户 / AI / MCP ────────────
|
||||
// 行来自 core::setting schema(`PageState.setting-groups`),写回走
|
||||
// `SettingStore::set`——与 `ull config set` / AI `set_config` 同一份 launcher.json。
|
||||
if root.section == SettingSection.data : SettingsGroups { tab: "data"; }
|
||||
if root.section == SettingSection.download : SettingsGroups { tab: "download"; }
|
||||
if root.section == SettingSection.java : SettingsGroups { tab: "java"; }
|
||||
if root.section == SettingSection.ai : SettingsGroups { tab: "ai"; }
|
||||
if root.section == SettingSection.ai : SettingsGroups { tab: "mcp"; }
|
||||
if root.section == SettingSection.account : AccountSection { }
|
||||
|
||||
// ── 代理 ────────────────────────────────────────
|
||||
if root.section == SettingSection.proxy : SettingGroup { title: "代理"; }
|
||||
if root.section == SettingSection.proxy : SettingItem {
|
||||
label: "HTTP 代理";
|
||||
description: "留空则使用系统代理";
|
||||
kind: ControlKind.text;
|
||||
button-text: "http://127.0.0.1:7890";
|
||||
value <=> root.proxy;
|
||||
edited(t) => { root.proxy-changed(t); }
|
||||
}
|
||||
|
||||
// ── 关于 ────────────────────────────────────────
|
||||
if root.section == SettingSection.about : SettingGroup { title: "关于"; }
|
||||
if root.section == SettingSection.about : SettingItem {
|
||||
label: "版本";
|
||||
description: root.version;
|
||||
}
|
||||
if root.section == SettingSection.about : SettingItem {
|
||||
label: "许可";
|
||||
description: "Apache-2.0(Slint 走 RF 2.0 轨道)";
|
||||
}
|
||||
if root.section == SettingSection.about : Rectangle {
|
||||
height: 120px;
|
||||
AboutSlint { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
// 主窗口框架(D16 §1):无边框标题栏 + 左侧可折叠导航 + 内容区路由 + 右侧 AI 常驻栏 + 对话框/Toast 覆盖层。
|
||||
// 这里只做「组装 + 路由转发」,状态全在 Rust 侧(state.rs),Slint 不持有业务真相。
|
||||
// main.slint(阶段 B 接线)只负责实例化 AppShell 并把 Window 的窗口事件接上。
|
||||
import { Theme } from "theme/tokens.slint";
|
||||
import { Metrics } from "theme/metrics.slint";
|
||||
import {
|
||||
RouteKind, TileSize, InstanceView, SettingSection, DownloadCategory, ThemeMode, BgFillKind,
|
||||
UiTile, UiVersion, UiLogSession, UiLogLine, UiDiagnosis, UiChatMessage, UiDialog, UiToast,
|
||||
} from "models.slint";
|
||||
import { PageState } from "page_state.slint";
|
||||
import { NewInstanceWizard } from "widgets/wizard.slint";
|
||||
import { TitleBar } from "components/titlebar.slint";
|
||||
import { Nav } from "components/nav.slint";
|
||||
import { AiPanel } from "components/ai-panel.slint";
|
||||
import { UiText, TextRole } from "components/label.slint";
|
||||
import { MetroButton, ButtonKind } from "widgets/button.slint";
|
||||
import { DialogHost } from "widgets/dialog.slint";
|
||||
import { ToastHost } from "widgets/toast.slint";
|
||||
import { NoticeBar } from "components/notice-bar.slint";
|
||||
import { InstancesPage } from "pages/instances.slint";
|
||||
import { DownloadPage } from "pages/download.slint";
|
||||
import { LogsPage } from "pages/logs.slint";
|
||||
import { SettingsPage } from "pages/settings.slint";
|
||||
|
||||
|
||||
export component AppShell inherits Rectangle {
|
||||
// ── 窗口 ────────────────────────────────────────────────────
|
||||
in property <string> app-title: "Unlucky Launcher";
|
||||
in property <bool> maximized: false;
|
||||
|
||||
// ── 路由 ────────────────────────────────────────────────────
|
||||
in property <RouteKind> route: RouteKind.instances;
|
||||
|
||||
// ── 导航 / 账户 ──────────────────────────────────────────────
|
||||
in property <bool> nav-collapsed: false;
|
||||
in property <string> account-name: "未登录";
|
||||
in property <string> account-initial: "?";
|
||||
in property <color> account-hue: #5C2D91;
|
||||
|
||||
// ── AI 栏(双形态共享同一份会话)──────────────────────────────
|
||||
in property <bool> ai-collapsed: false;
|
||||
in property <[UiChatMessage]> ai-messages;
|
||||
in property <bool> ai-streaming: false;
|
||||
in property <bool> yolo: false;
|
||||
in property <string> ai-endpoint-label: "llama.cpp · 本地";
|
||||
in-out property <string> ai-draft: "";
|
||||
|
||||
// ── 页面数据 ────────────────────────────────────────────────
|
||||
in property <[UiTile]> tiles;
|
||||
in property <[UiTile]> running-tiles;
|
||||
in property <[UiVersion]> versions;
|
||||
in property <[UiLogSession]> log-sessions;
|
||||
in property <[UiLogLine]> log-lines;
|
||||
in property <UiDiagnosis> diagnosis;
|
||||
in property <bool> has-diagnosis: false;
|
||||
in property <string> selected-session: "";
|
||||
|
||||
// ── 偏好(持久化键见 SubAgentsTalk/app-DTO通道约定.md)──────────
|
||||
in-out property <TileSize> tile-size: TileSize.medium;
|
||||
in-out property <InstanceView> instance-view: InstanceView.tiles;
|
||||
in-out property <string> instance-query: "";
|
||||
in-out property <SettingSection> settings-section: SettingSection.general;
|
||||
/// D17 主题三态(auto 跟随系统;light/dark 钉死)。Rust 侧同时写 Theme 全局。
|
||||
in-out property <ThemeMode> theme-mode: ThemeMode.auto;
|
||||
/// 外观(高级)四件的 in-out 通道:MainWindow ↔ SettingsPage(图片本身走 `Theme.bg-image`)
|
||||
in-out property <bool> bg-image-enabled: false;
|
||||
in-out property <string> bg-image: "";
|
||||
in-out property <BgFillKind> bg-fill: BgFillKind.cover;
|
||||
in-out property <float> bg-dim: 0.35;
|
||||
in-out property <float> panel-alpha: 1.0;
|
||||
in-out property <bool> glass: false;
|
||||
/// 设置页编辑的十六进制串(`accent-color` 是解析后的颜色,只给 Theme 用)
|
||||
in-out property <string> accent-hex: "";
|
||||
/// 强调色输入是否非法(设置页亮 warn 行)
|
||||
in-out property <bool> accent-invalid: false;
|
||||
in-out property <DownloadCategory> download-category: DownloadCategory.release;
|
||||
in-out property <bool> download-installed-only: false;
|
||||
/// 下载页版本号筛选词(输入由页面写回,state.download_query 参与过滤)
|
||||
in-out property <string> download-query: "";
|
||||
|
||||
// ── 对话框队列 / Toast ───────────────────────────────────────
|
||||
in property <UiDialog> dialog;
|
||||
in property <bool> dialog-open: false;
|
||||
in property <int> dialog-queued: 0;
|
||||
in property <[UiToast]> toasts;
|
||||
|
||||
// ── 窗口控制(拖拽走 WindowMoveArea,不需要 Rust 参与)────────
|
||||
callback toggle-maximize();
|
||||
callback minimize();
|
||||
callback request-close();
|
||||
|
||||
// ── 导航 / AI ───────────────────────────────────────────────
|
||||
callback navigate(RouteKind);
|
||||
callback toggle-nav();
|
||||
callback toggle-ai();
|
||||
callback expand-ai();
|
||||
callback dock-ai();
|
||||
callback toggle-yolo();
|
||||
callback ai-send(string);
|
||||
callback ai-stop();
|
||||
|
||||
// ── 实例页 ──────────────────────────────────────────────────
|
||||
callback instance-launch(string);
|
||||
callback instance-configure(string);
|
||||
callback instance-stop(string);
|
||||
callback instance-open(string);
|
||||
callback new-instance();
|
||||
callback instance-view-changed(InstanceView);
|
||||
callback tile-size-changed(TileSize);
|
||||
callback instance-query-changed(string);
|
||||
|
||||
// ── 下载页 ──────────────────────────────────────────────────
|
||||
callback download-category-changed(DownloadCategory);
|
||||
callback download-install(string);
|
||||
callback installed-only-changed(bool);
|
||||
callback download-query-changed(string);
|
||||
callback ancient-help();
|
||||
|
||||
// ── 日志页 ──────────────────────────────────────────────────
|
||||
callback log-select(string);
|
||||
callback log-ask-ai(string);
|
||||
callback log-apply-fix();
|
||||
|
||||
// ── 设置页 ──────────────────────────────────────────────────
|
||||
callback settings-section-changed(SettingSection);
|
||||
callback theme-changed(ThemeMode);
|
||||
callback bg-image-enabled-changed(bool);
|
||||
callback bg-image-changed(string);
|
||||
callback bg-fill-changed(BgFillKind);
|
||||
callback bg-dim-changed(float);
|
||||
callback panel-alpha-changed(float);
|
||||
callback glass-changed(bool);
|
||||
callback accent-color-changed(string);
|
||||
callback accent-swatch-picked(int);
|
||||
callback yolo-changed(bool);
|
||||
callback mcp-changed(bool);
|
||||
callback ai-endpoint-changed(int);
|
||||
|
||||
// ── 对话框 / Toast ──────────────────────────────────────────
|
||||
callback dialog-primary();
|
||||
callback dialog-secondary();
|
||||
callback dialog-dismissed();
|
||||
callback toast-dismissed(string);
|
||||
|
||||
// 窗口底色交给 main.slint 的 Window(不透明 `canvas-solid`),这里只铺背景图层
|
||||
background: transparent;
|
||||
|
||||
// 玻璃拟态的对齐基准:整窗逻辑尺寸(components/glass.slint 反向偏移用)
|
||||
init => {
|
||||
Theme.window-width = root.width;
|
||||
Theme.window-height = root.height;
|
||||
}
|
||||
changed width => { Theme.window-width = root.width; }
|
||||
changed height => { Theme.window-height = root.height; }
|
||||
|
||||
// ── ① 自定义窗口背景图(09 §101)────────────────────────────
|
||||
// 只在「开关打开且图片已加载」时才画:空 image 的 width 为 0,出厂态零开销。
|
||||
if Theme.bg-image.width > 0 : Rectangle {
|
||||
x: 0;
|
||||
y: 0;
|
||||
width: root.width;
|
||||
height: root.height;
|
||||
clip: true;
|
||||
background: Theme.canvas-solid;
|
||||
|
||||
Image {
|
||||
source: Theme.bg-image;
|
||||
x: 0;
|
||||
y: 0;
|
||||
width: parent.width;
|
||||
height: parent.height;
|
||||
// tile 模式由 Rust 预平铺成整窗画布,故这里等同 cover
|
||||
image-fit: root.bg-fill == BgFillKind.contain ? contain : cover;
|
||||
}
|
||||
}
|
||||
|
||||
// ── ② 暗化层(09 §111:保证前景可读;0 = 不压暗)─────────────
|
||||
if Theme.bg-image.width > 0 && Theme.bg-dim > 0 : Rectangle {
|
||||
x: 0;
|
||||
y: 0;
|
||||
width: root.width;
|
||||
height: root.height;
|
||||
background: rgba(0, 0, 0, Theme.bg-dim);
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
TitleBar {
|
||||
title: root.app-title;
|
||||
subtitle: "P0 骨架 · Metro";
|
||||
maximized: root.maximized;
|
||||
toggle-maximize => { root.toggle-maximize(); }
|
||||
minimize => { root.minimize(); }
|
||||
request-close => { root.request-close(); }
|
||||
}
|
||||
|
||||
NoticeBar {
|
||||
notices: PageState.notices;
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
// 左侧主导航
|
||||
Nav {
|
||||
active: root.route;
|
||||
collapsed: root.nav-collapsed;
|
||||
account-name: root.account-name;
|
||||
account-initial: root.account-initial;
|
||||
account-hue: root.account-hue;
|
||||
navigate(r) => { root.navigate(r); }
|
||||
toggle-collapsed => { root.toggle-nav(); }
|
||||
account-clicked => { root.navigate(RouteKind.settings); root.settings-section-changed(SettingSection.account); }
|
||||
}
|
||||
|
||||
// 内容区(每页独立滚动,路由切换栈语义在 Rust Router)
|
||||
Rectangle {
|
||||
horizontal-stretch: 1;
|
||||
background: Theme.bg-canvas;
|
||||
|
||||
if root.route == RouteKind.instances : InstancesPage {
|
||||
tiles: root.tiles;
|
||||
running: root.running-tiles;
|
||||
view: root.instance-view;
|
||||
tile-size: root.tile-size;
|
||||
query: root.instance-query;
|
||||
launch(id) => { root.instance-launch(id); }
|
||||
configure(id) => { root.instance-configure(id); }
|
||||
stop(id) => { root.instance-stop(id); }
|
||||
open(id) => { root.instance-open(id); }
|
||||
new-instance => { root.new-instance(); }
|
||||
view-changed(v) => {
|
||||
root.instance-view = v;
|
||||
root.instance-view-changed(v);
|
||||
}
|
||||
tile-size-changed(s) => {
|
||||
root.tile-size = s;
|
||||
root.tile-size-changed(s);
|
||||
}
|
||||
query-changed(q) => { root.instance-query-changed(q); }
|
||||
}
|
||||
|
||||
if root.route == RouteKind.download : DownloadPage {
|
||||
versions: root.versions;
|
||||
category: root.download-category;
|
||||
installed-only: root.download-installed-only;
|
||||
query: root.download-query;
|
||||
install(id) => { root.download-install(id); }
|
||||
query-changed(q) => {
|
||||
root.download-query = q;
|
||||
root.download-query-changed(q);
|
||||
}
|
||||
ancient-help => { root.ancient-help(); }
|
||||
category-changed(c) => {
|
||||
root.download-category = c;
|
||||
root.download-category-changed(c);
|
||||
}
|
||||
installed-only-changed(v) => {
|
||||
root.download-installed-only = v;
|
||||
root.installed-only-changed(v);
|
||||
}
|
||||
}
|
||||
|
||||
if root.route == RouteKind.logs : LogsPage {
|
||||
sessions: root.log-sessions;
|
||||
lines: root.log-lines;
|
||||
diagnosis: root.diagnosis;
|
||||
has-diagnosis: root.has-diagnosis;
|
||||
selected: root.selected-session;
|
||||
select(id) => { root.log-select(id); }
|
||||
ask-ai(id) => { root.log-ask-ai(id); }
|
||||
apply-fix => { root.log-apply-fix(); }
|
||||
refresh => { PageState.logs-refresh(); }
|
||||
}
|
||||
|
||||
if root.route == RouteKind.settings : SettingsPage {
|
||||
section: root.settings-section;
|
||||
theme-mode: root.theme-mode;
|
||||
bg-image-enabled: root.bg-image-enabled;
|
||||
bg-image: root.bg-image;
|
||||
bg-fill: root.bg-fill;
|
||||
bg-dim: root.bg-dim;
|
||||
panel-alpha: root.panel-alpha;
|
||||
glass: root.glass;
|
||||
accent-color: root.accent-hex;
|
||||
accent-invalid: root.accent-invalid;
|
||||
section-changed(s) => {
|
||||
root.settings-section = s;
|
||||
root.settings-section-changed(s);
|
||||
}
|
||||
theme-changed(mode) => {
|
||||
root.theme-mode = mode;
|
||||
root.theme-changed(mode);
|
||||
}
|
||||
bg-image-enabled-changed(v) => { root.bg-image-enabled-changed(v); }
|
||||
bg-image-changed(path) => { root.bg-image-changed(path); }
|
||||
bg-fill-changed(fill) => { root.bg-fill-changed(fill); }
|
||||
bg-dim-changed(v) => { root.bg-dim-changed(v); }
|
||||
panel-alpha-changed(v) => { root.panel-alpha-changed(v); }
|
||||
glass-changed(v) => { root.glass-changed(v); }
|
||||
accent-color-changed(text) => {
|
||||
root.accent-hex = text;
|
||||
root.accent-color-changed(text);
|
||||
}
|
||||
accent-swatch-picked(index) => { root.accent-swatch-picked(index); }
|
||||
yolo-changed(v) => { root.yolo-changed(v); }
|
||||
mcp-changed(v) => { root.mcp-changed(v); }
|
||||
ai-endpoint-changed(i) => { root.ai-endpoint-changed(i); }
|
||||
}
|
||||
|
||||
// AI 拆成独立全宽页:与右侧栏共用同一份 ai-messages(切换无损)
|
||||
if root.route == RouteKind.agent : AiPanel {
|
||||
docked: false;
|
||||
messages: root.ai-messages;
|
||||
collapsed: false;
|
||||
yolo: root.yolo;
|
||||
streaming: root.ai-streaming;
|
||||
endpoint-label: root.ai-endpoint-label;
|
||||
draft <=> root.ai-draft;
|
||||
dock => { root.dock-ai(); }
|
||||
toggle-yolo => { root.toggle-yolo(); }
|
||||
send(t) => { root.ai-send(t); }
|
||||
stop => { root.ai-stop(); }
|
||||
}
|
||||
|
||||
// 实例详情 G4:P0 只留占位(点击磁贴进入)
|
||||
if root.route == RouteKind.detail : VerticalLayout {
|
||||
alignment: center;
|
||||
spacing: Metrics.gap-3;
|
||||
|
||||
UiText {
|
||||
role: TextRole.title;
|
||||
text: "实例详情(G4)";
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "P0 骨架未实现:概要与设置 tab 在 G4 补齐";
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
HorizontalLayout {
|
||||
alignment: center;
|
||||
MetroButton {
|
||||
kind: ButtonKind.standard;
|
||||
text: "返回实例页";
|
||||
clicked => { root.navigate(RouteKind.instances); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧 AI 常驻栏(拆成独立页时让位给内容区)
|
||||
AiPanel {
|
||||
visible: root.route != RouteKind.agent;
|
||||
docked: true;
|
||||
collapsed: root.ai-collapsed;
|
||||
messages: root.ai-messages;
|
||||
yolo: root.yolo;
|
||||
streaming: root.ai-streaming;
|
||||
endpoint-label: root.ai-endpoint-label;
|
||||
draft <=> root.ai-draft;
|
||||
toggle-collapsed => { root.toggle-ai(); }
|
||||
expand => { root.expand-ai(); }
|
||||
toggle-yolo => { root.toggle-yolo(); }
|
||||
send(t) => { root.ai-send(t); }
|
||||
stop => { root.ai-stop(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 新建实例向导(全屏模态;数据来自 PageState)
|
||||
NewInstanceWizard {
|
||||
versions: root.versions;
|
||||
}
|
||||
|
||||
// 覆盖层:模态队列(一次一条)+ 右下 Toast
|
||||
DialogHost {
|
||||
current: root.dialog;
|
||||
open: root.dialog-open;
|
||||
queued: root.dialog-queued;
|
||||
primary => { root.dialog-primary(); }
|
||||
secondary => { root.dialog-secondary(); }
|
||||
dismissed => { root.dialog-dismissed(); }
|
||||
}
|
||||
|
||||
ToastHost {
|
||||
toasts: root.toasts;
|
||||
dismissed(id) => { root.toast-dismissed(id); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// 布局度量令牌(D16 定稿的尺寸口径集中在此):
|
||||
// 导航栏折叠宽度、AI 栏宽度、标题栏高度、磁贴三档尺寸与列数。
|
||||
import { TileSize } from "../models.slint";
|
||||
|
||||
export global Metrics {
|
||||
// 间距刻度(4 的倍数,Metro 栅格)
|
||||
out property <length> gap-1: 4px;
|
||||
out property <length> gap-2: 8px;
|
||||
out property <length> gap-3: 12px;
|
||||
out property <length> gap-4: 16px;
|
||||
out property <length> gap-5: 24px;
|
||||
out property <length> gap-6: 32px;
|
||||
|
||||
out property <length> radius-sm: 2px;
|
||||
out property <length> radius-md: 4px;
|
||||
out property <length> radius-lg: 8px;
|
||||
|
||||
// 主窗口骨架
|
||||
out property <length> titlebar-height: 40px;
|
||||
out property <length> titlebar-button-width: 46px;
|
||||
out property <length> accent-line: 2px;
|
||||
out property <length> nav-expanded-width: 88px;
|
||||
out property <length> nav-collapsed-width: 48px;
|
||||
out property <length> ai-panel-width: 320px;
|
||||
out property <length> ai-rail-width: 44px;
|
||||
out property <length> page-header-height: 56px;
|
||||
out property <length> running-strip-height: 32px;
|
||||
out property <length> row-height: 44px;
|
||||
out property <length> control-height: 32px;
|
||||
out property <length> touch-target: 32px;
|
||||
|
||||
// 磁贴三档(Q24-3:默认中磁贴 4 列,设置可调小 6 / 大 3)
|
||||
in-out property <TileSize> tile-size: TileSize.medium;
|
||||
out property <int> tile-columns:
|
||||
tile-size == TileSize.small ? 6
|
||||
: tile-size == TileSize.large ? 3
|
||||
: 4;
|
||||
out property <length> tile-height:
|
||||
tile-size == TileSize.small ? 104px
|
||||
: tile-size == TileSize.large ? 176px
|
||||
: 136px;
|
||||
out property <length> tile-icon-size:
|
||||
tile-size == TileSize.small ? 32px
|
||||
: tile-size == TileSize.large ? 56px
|
||||
: 44px;
|
||||
// 小磁贴只留图标+名字+启动键,藏起徽标与最后游玩时间(空间不足)
|
||||
out property <bool> tile-compact: tile-size == TileSize.small;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// Metro 主题令牌(D14 + D17 主题三态 + 09 §101-104「外观(高级)」):palette + 排版层级 + 字体栈 +
|
||||
// 动效时长 + 外观令牌(背景图 / 面板半透明度 / 玻璃拟态 / 强调色)。
|
||||
//
|
||||
// 结构约定:
|
||||
// - `LightPalette` / `DarkPalette` = 两套完整色板(**不是**一套加条件),换肤只改这两处。
|
||||
// - `Theme` = 解析器:按主题三态(auto/light/dark)挑一套,并持有**共享**的 accent 与字体/排版。
|
||||
// - auto 跟随系统:`Theme.system-theme` 由 Rust 的 `core::platform::theme_probe` 灌入(启动对齐 +
|
||||
// 未知时兜底轮询),后端支持时另有 `Palette.color-scheme` 实时热切;两者都不认识就按浅色。
|
||||
// - 「外观(高级)」四件全部默认关闭:`panel-alpha` 1.0 / `glass` false / `bg-image` 空 /
|
||||
// `accent-override` 透明——出厂观感与 M17 骨架逐像素一致(09 §111 硬约束)。
|
||||
// - 组件只准用 `Theme.*`,不得出现裸色字面量(背景图暗化层是唯一例外,它是「遮罩」而非配色)。
|
||||
|
||||
import { ThemeMode } from "../models.slint";
|
||||
import { Palette } from "std-widgets.slint";
|
||||
|
||||
// ── 亮色色板 ────────────────────────────────────────────────────
|
||||
export global LightPalette {
|
||||
out property <color> bg-canvas: #F3F3F3;
|
||||
out property <color> bg-surface: #FFFFFF;
|
||||
out property <color> bg-elevated: #FFFFFF;
|
||||
out property <color> bg-titlebar: #EDEDED;
|
||||
out property <color> bg-hover: #E9E9E9;
|
||||
out property <color> bg-pressed: #DEDEDE;
|
||||
out property <color> bg-input: #FFFFFF;
|
||||
|
||||
out property <color> fg-primary: #1A1A1A;
|
||||
out property <color> fg-secondary: #5F5F5F;
|
||||
out property <color> fg-disabled: #9A9A9A;
|
||||
|
||||
out property <color> stroke: #E1E1E1;
|
||||
out property <color> stroke-strong: #C8C8C8;
|
||||
out property <color> shadow: #00000026;
|
||||
out property <color> scrim: #00000099;
|
||||
|
||||
out property <color> accent-soft: #E5F1FB;
|
||||
out property <color> accent-hover: #106EBE;
|
||||
out property <color> accent-pressed: #005A9E;
|
||||
|
||||
out property <color> state-success: #107C10;
|
||||
out property <color> state-success-soft: #DFF6DD;
|
||||
out property <color> state-warn: #9D5D00;
|
||||
out property <color> state-warn-soft: #FFF4CE;
|
||||
out property <color> state-danger: #C42B1C;
|
||||
out property <color> state-danger-hover: #A82214;
|
||||
out property <color> state-danger-soft: #FDE7E9;
|
||||
out property <color> state-info-soft: #E7F1FA;
|
||||
}
|
||||
|
||||
// ── 暗色色板 ────────────────────────────────────────────────────
|
||||
export global DarkPalette {
|
||||
out property <color> bg-canvas: #1B1B1B;
|
||||
out property <color> bg-surface: #232323;
|
||||
out property <color> bg-elevated: #2B2B2B;
|
||||
out property <color> bg-titlebar: #161616;
|
||||
out property <color> bg-hover: #2F2F2F;
|
||||
out property <color> bg-pressed: #383838;
|
||||
out property <color> bg-input: #1E1E1E;
|
||||
|
||||
out property <color> fg-primary: #F2F2F2;
|
||||
out property <color> fg-secondary: #A6A6A6;
|
||||
out property <color> fg-disabled: #6B6B6B;
|
||||
|
||||
out property <color> stroke: #3A3A3A;
|
||||
out property <color> stroke-strong: #4A4A4A;
|
||||
out property <color> shadow: #000000CC;
|
||||
out property <color> scrim: #00000099;
|
||||
|
||||
out property <color> accent-soft: #0F2A3F;
|
||||
out property <color> accent-hover: #2B8FE0;
|
||||
out property <color> accent-pressed: #005A9E;
|
||||
|
||||
out property <color> state-success: #4CCB4C;
|
||||
out property <color> state-success-soft: #16321A;
|
||||
out property <color> state-warn: #FFD24C;
|
||||
out property <color> state-warn-soft: #3A2E0F;
|
||||
out property <color> state-danger: #FF6B6B;
|
||||
out property <color> state-danger-hover: #FF8A8A;
|
||||
out property <color> state-danger-soft: #3A1A1A;
|
||||
out property <color> state-info-soft: #10263A;
|
||||
}
|
||||
|
||||
// ── 解析器 ──────────────────────────────────────────────────────
|
||||
export global Theme {
|
||||
// ── 主题三态(D17 设置页「外观」分组,出厂 auto)──────────────────
|
||||
// D17 主题三态(设置页「外观」分组,出厂 auto)
|
||||
in-out property <ThemeMode> theme-mode: ThemeMode.auto;
|
||||
// 系统主题来源(M41):0 = 未知(此时回落到 Slint 后端 `Palette.color-scheme`),
|
||||
// 1 = 浅色 / 2 = 深色。由 Rust 的 `core::platform::theme_probe` 探测后灌入:
|
||||
// 启动时给首帧对齐,之后按需轮询兜底(后端不支持系统主题时 auto 仍可用)。
|
||||
in-out property <int> system-theme: 0;
|
||||
// 后端是否认得系统主题(`unknown` 表示不支持/无 portal)。
|
||||
out property <bool> system-theme-known: Palette.color-scheme != ColorScheme.unknown;
|
||||
// 是否深色:显式钉死(light/dark)优先;auto 时用探测值,探测未知才问后端。
|
||||
out property <bool> dark: theme-mode == ThemeMode.dark
|
||||
|| (theme-mode == ThemeMode.auto
|
||||
&& (system-theme == 2
|
||||
|| (system-theme != 1 && Palette.color-scheme == ColorScheme.dark)));
|
||||
|
||||
// ── 外观(高级)令牌(09 §101-104 / §111:出厂全部关闭)─────────
|
||||
// 全局面板半透明度:`bg-*` 令牌统一按 `panel-alpha` 重建(见下方「面板底色」段),
|
||||
// 组件侧零改动即透出背景图。出厂 1.0 = 完全不透明,Metro 纯色外观与 M17 骨架一致。
|
||||
in-out property <float> panel-alpha: 1.0;
|
||||
// 自定义窗口背景图(空 image = 纯色背景,出厂态)。
|
||||
in-out property <image> bg-image;
|
||||
// 背景图暗化层强度(0~0.95,越大前景越清晰)。Rust 侧夹到 schema 范围。
|
||||
in-out property <float> bg-dim: 0.35;
|
||||
// 玻璃拟态:预生成模糊层(Rust 侧降采样 + box blur),卡片内按 `absolute-position`
|
||||
// 反向偏移对齐整窗,得到 Slint 版「毛玻璃」(无 backdrop-filter 的替代手法)。
|
||||
in-out property <bool> glass: false;
|
||||
in-out property <image> bg-blur;
|
||||
// 玻璃层对齐用的窗口逻辑尺寸(AppShell 在 init/changed 里灌入)。
|
||||
in-out property <length> window-width: 1180px;
|
||||
in-out property <length> window-height: 760px;
|
||||
|
||||
// ── 强调色(accent 令牌全域生效;两套色板共享)──────────────────
|
||||
// `accent-override` 的 alpha == 0 表示「未自定义」,走 D14 出厂 Metro 蓝。
|
||||
// (color 的 `.alpha` 字段是 0~255 整数,不是 0~1 浮点。)
|
||||
in-out property <color> accent-override: transparent;
|
||||
out property <bool> accent-custom: accent-override.alpha > 1;
|
||||
out property <color> accent: accent-custom ? accent-override : #0078D4;
|
||||
// 自定义时由覆盖色派生 hover/pressed/soft(未自定义仍走双色板常量,出场观感不变)。
|
||||
out property <color> accent-hover: accent-custom
|
||||
? (dark ? accent.brighter(0.18) : accent.darker(0.12))
|
||||
: (dark ? DarkPalette.accent-hover : LightPalette.accent-hover);
|
||||
out property <color> accent-pressed: accent-custom
|
||||
? (dark ? accent.brighter(0.28) : accent.darker(0.24))
|
||||
: (dark ? DarkPalette.accent-pressed : LightPalette.accent-pressed);
|
||||
out property <color> accent-soft: accent-custom
|
||||
? accent.mix(surface-solid, 0.82)
|
||||
: (dark ? DarkPalette.accent-soft : LightPalette.accent-soft);
|
||||
out property <color> accent-contrast: #FFFFFF;
|
||||
// accent 上的前景色按相对亮度自动选黑/白(自定义亮黄、浅青时白字会糊)。
|
||||
// 注意:Slint 的 color 通道字段(`.red` 等)是 0~255 的**整数**。
|
||||
out property <float> accent-luma: accent.red * 0.299 + accent.green * 0.587 + accent.blue * 0.114;
|
||||
out property <color> fg-on-accent: accent-luma > 160 ? #1A1A1A : #FFFFFF;
|
||||
|
||||
// ── 面板底色(`panel-alpha` 统一透出背景图)────────────────────
|
||||
// `*-solid` 是不透明原件(窗口自身底色 / accent.mix 的混合基准必须不透明,
|
||||
// 否则浮点叠加会让 accent-soft 随 panel-alpha 一起变淡)。
|
||||
// 半透明色用 `rgba(r, g, b, a)` 重建而不是 `.with-alpha()`:后者返回 Brush,
|
||||
// 赋给 `<color>` 属性会类型不符(Slint 无 Brush → color 隐式回退)。
|
||||
out property <color> surface-solid: dark ? DarkPalette.bg-surface : LightPalette.bg-surface;
|
||||
out property <color> canvas-solid: dark ? DarkPalette.bg-canvas : LightPalette.bg-canvas;
|
||||
out property <color> surface-elevated-solid: dark ? DarkPalette.bg-elevated : LightPalette.bg-elevated;
|
||||
out property <color> titlebar-solid: dark ? DarkPalette.bg-titlebar : LightPalette.bg-titlebar;
|
||||
out property <color> hover-solid: dark ? DarkPalette.bg-hover : LightPalette.bg-hover;
|
||||
out property <color> pressed-solid: dark ? DarkPalette.bg-pressed : LightPalette.bg-pressed;
|
||||
out property <color> input-solid: dark ? DarkPalette.bg-input : LightPalette.bg-input;
|
||||
|
||||
out property <color> bg-canvas: rgba(
|
||||
canvas-solid.red, canvas-solid.green, canvas-solid.blue, panel-alpha);
|
||||
out property <color> bg-surface: rgba(
|
||||
surface-solid.red, surface-solid.green, surface-solid.blue, panel-alpha);
|
||||
out property <color> bg-elevated: rgba(
|
||||
surface-elevated-solid.red, surface-elevated-solid.green, surface-elevated-solid.blue, panel-alpha);
|
||||
out property <color> bg-titlebar: rgba(
|
||||
titlebar-solid.red, titlebar-solid.green, titlebar-solid.blue, panel-alpha);
|
||||
out property <color> bg-hover: rgba(
|
||||
hover-solid.red, hover-solid.green, hover-solid.blue, panel-alpha);
|
||||
out property <color> bg-pressed: rgba(
|
||||
pressed-solid.red, pressed-solid.green, pressed-solid.blue, panel-alpha);
|
||||
out property <color> bg-input: rgba(
|
||||
input-solid.red, input-solid.green, input-solid.blue, panel-alpha);
|
||||
|
||||
out property <color> fg-primary: dark ? DarkPalette.fg-primary : LightPalette.fg-primary;
|
||||
out property <color> fg-secondary: dark ? DarkPalette.fg-secondary : LightPalette.fg-secondary;
|
||||
out property <color> fg-disabled: dark ? DarkPalette.fg-disabled : LightPalette.fg-disabled;
|
||||
|
||||
out property <color> stroke: dark ? DarkPalette.stroke : LightPalette.stroke;
|
||||
out property <color> stroke-strong: dark ? DarkPalette.stroke-strong : LightPalette.stroke-strong;
|
||||
out property <color> shadow: dark ? DarkPalette.shadow : LightPalette.shadow;
|
||||
out property <color> scrim: dark ? DarkPalette.scrim : LightPalette.scrim;
|
||||
|
||||
out property <color> state-success: dark ? DarkPalette.state-success : LightPalette.state-success;
|
||||
out property <color> state-success-soft: dark ? DarkPalette.state-success-soft : LightPalette.state-success-soft;
|
||||
out property <color> state-warn: dark ? DarkPalette.state-warn : LightPalette.state-warn;
|
||||
out property <color> state-warn-soft: dark ? DarkPalette.state-warn-soft : LightPalette.state-warn-soft;
|
||||
out property <color> state-danger: dark ? DarkPalette.state-danger : LightPalette.state-danger;
|
||||
out property <color> state-danger-hover: dark ? DarkPalette.state-danger-hover : LightPalette.state-danger-hover;
|
||||
out property <color> state-danger-soft: dark ? DarkPalette.state-danger-soft : LightPalette.state-danger-soft;
|
||||
out property <color> state-info-soft: dark ? DarkPalette.state-info-soft : LightPalette.state-info-soft;
|
||||
|
||||
// ── 字体 ────────────────────────────────────────────────────
|
||||
// Segoe UI 不可再分发(微软许可)→ Windows 用系统自带,其他平台用 Selawik
|
||||
// (微软官方开源、Segoe UI 度量兼容)+ Open Sans 回退栈(01c §7 第 5 条)。
|
||||
// Slint 的 font-family 只吃单个家族名,回退链由 Rust 侧启动时按平台选择 + 注册字体族实现:
|
||||
// windows → "Segoe UI",macos/linux → "Selawik"(随包)+ "Open Sans" 兜底。
|
||||
in-out property <string> font-family: "Selawik";
|
||||
in-out property <string> font-family-mono: "Cascadia Mono";
|
||||
// 中文回退(思源黑体/微软雅黑由系统字体匹配,这里仅作显式声明)
|
||||
in-out property <string> font-family-cjk: "Microsoft YaHei UI";
|
||||
|
||||
// 排版层级(Metro typography ramp)
|
||||
out property <length> font-size-display: 24px;
|
||||
out property <length> font-size-title: 20px;
|
||||
out property <length> font-size-subtitle: 16px;
|
||||
out property <length> font-size-body: 14px;
|
||||
out property <length> font-size-caption: 12px;
|
||||
out property <length> line-height-body: 20px;
|
||||
out property <length> line-height-caption: 16px;
|
||||
|
||||
// ── 动效(Metro 快速、克制)───────────────────────────────────
|
||||
out property <duration> motion-fast: 90ms;
|
||||
out property <duration> motion-normal: 160ms;
|
||||
out property <duration> motion-slow: 240ms;
|
||||
// D17 动画强度倍率(P0 占位,P1 接设置页滑条与「减少动画」无障碍开关)
|
||||
in-out property <float> motion-scale: 1.0;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Metro 按钮:扁平 + accent 填充,无圆角渐变,hover 只变底色(D14)。
|
||||
// 四种语义:accent(主操作)/ standard(次要)/ subtle(工具条)/ danger(危险确认)。
|
||||
// grow = true 时占满布局剩余宽度(磁贴的启动键),否则保持内容宽度(工具按钮)。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { AppIcon, IconKind } from "../components/icons.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
|
||||
export enum ButtonKind {
|
||||
accent,
|
||||
standard,
|
||||
subtle,
|
||||
danger,
|
||||
}
|
||||
|
||||
export component MetroButton inherits Rectangle {
|
||||
in property <ButtonKind> kind: ButtonKind.standard;
|
||||
in property <string> text: "";
|
||||
in property <IconKind> icon: IconKind.plus;
|
||||
/// 是否显示图标(纯文字按钮置 false)
|
||||
in property <bool> show-icon: false;
|
||||
in property <bool> enabled: true;
|
||||
/// 图标按钮:不画文字,尺寸压到正方形
|
||||
in property <bool> icon-only: false;
|
||||
/// 在布局中占据剩余宽度
|
||||
in property <bool> grow: false;
|
||||
in property <length> button-height: Metrics.control-height;
|
||||
callback clicked();
|
||||
|
||||
min-width: icon-only ? button-height : text-layout.preferred-width;
|
||||
height: button-height;
|
||||
horizontal-stretch: grow ? 1 : 0;
|
||||
border-radius: Metrics.radius-sm;
|
||||
border-width: kind == ButtonKind.standard ? 1px : 0px;
|
||||
border-color: Theme.stroke-strong;
|
||||
opacity: enabled ? 1.0 : 0.45;
|
||||
|
||||
background: kind == ButtonKind.accent
|
||||
? (touch.has-hover ? Theme.accent-hover : Theme.accent)
|
||||
: kind == ButtonKind.danger
|
||||
? (touch.has-hover ? Theme.state-danger-hover : Theme.state-danger)
|
||||
: kind == ButtonKind.subtle
|
||||
? (touch.pressed ? Theme.bg-pressed : touch.has-hover ? Theme.bg-hover : transparent)
|
||||
: (touch.pressed ? Theme.bg-pressed : touch.has-hover ? Theme.bg-hover : Theme.bg-surface);
|
||||
animate background { duration: Theme.motion-fast; }
|
||||
|
||||
text-layout := HorizontalLayout {
|
||||
alignment: center;
|
||||
padding-left: icon-only ? 0px : Metrics.gap-2;
|
||||
padding-right: icon-only ? 0px : Metrics.gap-2;
|
||||
spacing: Metrics.gap-1;
|
||||
|
||||
AppIcon {
|
||||
icon-size: Metrics.touch-target / 2;
|
||||
kind: root.icon;
|
||||
visible: root.show-icon || root.icon-only;
|
||||
tint: kind == ButtonKind.accent || kind == ButtonKind.danger
|
||||
? Theme.fg-on-accent
|
||||
: Theme.fg-primary;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.bodyStrong;
|
||||
text: root.text;
|
||||
visible: !root.icon-only;
|
||||
color: kind == ButtonKind.accent || kind == ButtonKind.danger
|
||||
? Theme.fg-on-accent
|
||||
: Theme.fg-primary;
|
||||
horizontal-alignment: center;
|
||||
}
|
||||
}
|
||||
|
||||
touch := TouchArea {
|
||||
mouse-cursor: root.enabled ? pointer : default;
|
||||
clicked => {
|
||||
if (root.enabled) {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
accessible-role: button;
|
||||
accessible-label: root.text;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// 对话框宿主:HMCL 队列语义(一次只显示一条,来源排序由 Rust DialogQueue 决定)+ Metro 化外观。
|
||||
// 类型:确认 / 输入 / 进度 / 错误(含诊断入口)。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { UiDialog, DialogKind } from "../models.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { AppIcon, IconKind } from "../components/icons.slint";
|
||||
import { MetroButton, ButtonKind } from "button.slint";
|
||||
import { TextField } from "inputs.slint";
|
||||
|
||||
export component DialogHost inherits Rectangle {
|
||||
in property <UiDialog> current;
|
||||
in property <bool> open: false;
|
||||
/// 队列中剩余条数(>0 时卡片底部提示「还有 N 条待处理」,语义上是队列而非堆叠)
|
||||
in property <int> queued: 0;
|
||||
in-out property <string> input-text: "";
|
||||
callback primary();
|
||||
callback secondary();
|
||||
callback dismissed();
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: Theme.scrim;
|
||||
visible: open;
|
||||
|
||||
// 吞掉遮罩上的点击,避免误触下层页面
|
||||
TouchArea {
|
||||
clicked => { root.dismissed(); }
|
||||
}
|
||||
|
||||
card := Rectangle {
|
||||
x: (root.width - card.width) / 2;
|
||||
y: (root.height - card.height) / 2;
|
||||
width: 440px;
|
||||
height: content.preferred-height;
|
||||
background: Theme.bg-elevated;
|
||||
border-radius: Metrics.radius-lg;
|
||||
border-width: 1px;
|
||||
border-color: Theme.stroke;
|
||||
drop-shadow-blur: 24px;
|
||||
drop-shadow-color: Theme.shadow;
|
||||
drop-shadow-offset-y: 6px;
|
||||
|
||||
content := VerticalLayout {
|
||||
padding: Metrics.gap-5;
|
||||
spacing: Metrics.gap-3;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
if root.current.kind == DialogKind.error : AppIcon {
|
||||
kind: IconKind.error;
|
||||
icon-size: 20px;
|
||||
tint: Theme.state-danger;
|
||||
}
|
||||
if root.current.kind == DialogKind.confirm : AppIcon {
|
||||
kind: IconKind.warn;
|
||||
icon-size: 20px;
|
||||
tint: Theme.state-warn;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.title;
|
||||
text: root.current.title;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
}
|
||||
|
||||
body-text := UiText {
|
||||
role: TextRole.body;
|
||||
text: root.current.body;
|
||||
wrap: word-wrap;
|
||||
overflow: elide;
|
||||
color: Theme.fg-secondary;
|
||||
vertical-alignment: top;
|
||||
height: body-text.preferred-height;
|
||||
}
|
||||
|
||||
if root.current.kind == DialogKind.input : TextField {
|
||||
placeholder: root.current.input_label;
|
||||
text <=> root.input-text;
|
||||
}
|
||||
|
||||
if root.current.kind == DialogKind.progress : track := Rectangle {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: Theme.bg-pressed;
|
||||
|
||||
Rectangle {
|
||||
width: track.width * Math.min(Math.max(root.current.progress, 0), 1);
|
||||
height: track.height;
|
||||
border-radius: 3px;
|
||||
background: Theme.accent;
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Metrics.gap-2;
|
||||
alignment: end;
|
||||
padding-top: Metrics.gap-2;
|
||||
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.queued > 0 ? "还有 " + root.queued + " 条待处理" : "";
|
||||
horizontal-stretch: 1;
|
||||
horizontal-alignment: left;
|
||||
}
|
||||
|
||||
if root.current.secondary != "" : MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: root.current.secondary;
|
||||
clicked => { root.secondary(); }
|
||||
}
|
||||
MetroButton {
|
||||
kind: root.current.kind == DialogKind.error ? ButtonKind.danger : ButtonKind.accent;
|
||||
text: root.current.primary;
|
||||
enabled: root.current.kind != DialogKind.progress;
|
||||
clicked => { root.primary(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
// 基础输入件:单行输入(含占位文本)、搜索框、开关、分段控件、复选框。
|
||||
// 一律自绘(Metro 需要方角 + accent 高亮)。占位文本手绘:内置 TextInput 没有
|
||||
// placeholder-text(那是 std-widgets 的 LineEdit,外观不可控)。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { AppIcon, IconKind } from "../components/icons.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
|
||||
/// 单行输入:内置 TextInput + 占位文本层(Focus 态由外部读 `focused`)。
|
||||
export component PlainInput inherits Rectangle {
|
||||
in-out property <string> text: "";
|
||||
in property <string> placeholder: "";
|
||||
in property <color> text-color: Theme.fg-primary;
|
||||
in property <length> text-padding: 0px;
|
||||
out property <bool> focused: input.has-focus;
|
||||
callback edited(string);
|
||||
callback accepted(string);
|
||||
|
||||
background: transparent;
|
||||
|
||||
input := TextInput {
|
||||
x: root.text-padding;
|
||||
y: 0;
|
||||
width: root.width - 2 * root.text-padding;
|
||||
height: root.height;
|
||||
color: root.text-color;
|
||||
font-family: Theme.font-family;
|
||||
font-size: Theme.font-size-body;
|
||||
vertical-alignment: center;
|
||||
single-line: true;
|
||||
text <=> root.text;
|
||||
edited => { root.edited(self.text); }
|
||||
accepted => { root.accepted(self.text); }
|
||||
}
|
||||
|
||||
if root.text == "" && root.placeholder != "" : UiText {
|
||||
x: root.text-padding;
|
||||
y: 0;
|
||||
width: root.width - 2 * root.text-padding;
|
||||
height: root.height;
|
||||
role: TextRole.body;
|
||||
text: root.placeholder;
|
||||
color: Theme.fg-disabled;
|
||||
}
|
||||
}
|
||||
|
||||
export component SearchField inherits Rectangle {
|
||||
in-out property <string> text: "";
|
||||
in property <string> placeholder: "搜索";
|
||||
callback edited(string);
|
||||
callback accepted(string);
|
||||
|
||||
height: Metrics.control-height;
|
||||
background: Theme.bg-input;
|
||||
border-radius: Metrics.radius-sm;
|
||||
border-width: 1px;
|
||||
border-color: field.focused ? Theme.accent : Theme.stroke;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-2;
|
||||
padding-right: Metrics.gap-2;
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
AppIcon { kind: IconKind.search; icon-size: 14px; }
|
||||
|
||||
field := PlainInput {
|
||||
placeholder: root.placeholder;
|
||||
text <=> root.text;
|
||||
edited(t) => { root.edited(t); }
|
||||
accepted(t) => { root.accepted(t); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component TextField inherits Rectangle {
|
||||
in-out property <string> text: "";
|
||||
in property <string> placeholder: "";
|
||||
callback edited(string);
|
||||
|
||||
height: Metrics.control-height;
|
||||
background: Theme.bg-input;
|
||||
border-radius: Metrics.radius-sm;
|
||||
border-width: 1px;
|
||||
border-color: field.focused ? Theme.accent : Theme.stroke;
|
||||
|
||||
field := PlainInput {
|
||||
text-padding: Metrics.gap-2;
|
||||
placeholder: root.placeholder;
|
||||
text <=> root.text;
|
||||
edited(t) => { root.edited(t); }
|
||||
}
|
||||
}
|
||||
|
||||
export component ToggleSwitch inherits Rectangle {
|
||||
in-out property <bool> checked: false;
|
||||
callback toggled(bool);
|
||||
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: checked ? Theme.accent : Theme.stroke-strong;
|
||||
animate background { duration: Theme.motion-normal; }
|
||||
|
||||
Rectangle {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
y: 3px;
|
||||
x: root.checked ? root.width - self.width - 3px : 3px;
|
||||
border-radius: 7px;
|
||||
background: Theme.fg-on-accent;
|
||||
animate x { duration: Theme.motion-normal; easing: ease-in-out; }
|
||||
}
|
||||
|
||||
TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.checked = !root.checked;
|
||||
root.toggled(root.checked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 分段控件(视图切换、磁贴尺寸、主题三态共用)
|
||||
export component SegmentedControl inherits Rectangle {
|
||||
in property <[string]> options;
|
||||
in-out property <int> selected: 0;
|
||||
callback changed(int);
|
||||
|
||||
height: Metrics.control-height;
|
||||
background: Theme.bg-canvas;
|
||||
border-radius: Metrics.radius-sm;
|
||||
border-width: 1px;
|
||||
border-color: Theme.stroke;
|
||||
|
||||
HorizontalLayout {
|
||||
padding: 2px;
|
||||
spacing: 2px;
|
||||
for o[i] in options : Rectangle {
|
||||
border-radius: Metrics.radius-sm;
|
||||
background: i == root.selected
|
||||
? Theme.accent
|
||||
: (seg.has-hover ? Theme.bg-hover : transparent);
|
||||
animate background { duration: Theme.motion-fast; }
|
||||
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: o;
|
||||
horizontal-alignment: center;
|
||||
color: i == root.selected ? Theme.fg-on-accent : Theme.fg-primary;
|
||||
}
|
||||
|
||||
seg := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.selected = i;
|
||||
root.changed(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component CheckBox inherits Rectangle { in-out property <bool> checked: false;
|
||||
in property <string> text: "";
|
||||
callback toggled(bool);
|
||||
|
||||
height: Metrics.touch-target;
|
||||
background: transparent;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
Rectangle {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: Metrics.radius-sm;
|
||||
border-width: 1px;
|
||||
border-color: root.checked ? Theme.accent : Theme.stroke-strong;
|
||||
background: root.checked ? Theme.accent : Theme.bg-input;
|
||||
|
||||
if root.checked : AppIcon {
|
||||
kind: IconKind.check;
|
||||
icon-size: 12px;
|
||||
x: 2px;
|
||||
y: 2px;
|
||||
tint: Theme.fg-on-accent;
|
||||
}
|
||||
}
|
||||
|
||||
UiText { role: TextRole.body; text: root.text; }
|
||||
|
||||
TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => {
|
||||
root.checked = !root.checked;
|
||||
root.toggled(root.checked);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 滑条(09 §102/§101 的「强度滑条」:暗化层强度、面板半透明度共用)。
|
||||
/// 自绘而非 std-widgets 的 Slider:Metro 需要方头轨道 + accent 填充,且要跟主题令牌一起换肤。
|
||||
export component MetroSlider inherits Rectangle {
|
||||
in property <float> minimum: 0.0;
|
||||
in property <float> maximum: 1.0;
|
||||
in-out property <float> value: 1.0;
|
||||
callback changed(float);
|
||||
|
||||
out property <float> ratio: root.maximum > root.minimum
|
||||
? Math.max(0.0, Math.min(1.0, (root.value - root.minimum) / (root.maximum - root.minimum)))
|
||||
: 0.0;
|
||||
|
||||
width: 200px;
|
||||
height: Metrics.touch-target;
|
||||
background: transparent;
|
||||
|
||||
/// 鼠标位置 → 取值(Slint 的 function 不能声明局部变量,所以拆成纯函数;
|
||||
/// 函数名不用 kebab-case:`value-at(...)` 会被当成减法解析)
|
||||
pure function valueForX(x: length) -> float {
|
||||
return root.minimum
|
||||
+ Math.max(0.0, Math.min(1.0, x / root.width)) * (root.maximum - root.minimum);
|
||||
}
|
||||
|
||||
// 轨道 + 已填充段
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: (root.height - 4px) / 2;
|
||||
width: root.width;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: Theme.stroke-strong;
|
||||
}
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: (root.height - 4px) / 2;
|
||||
width: root.width * root.ratio;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: Theme.accent;
|
||||
}
|
||||
// 滑块
|
||||
Rectangle {
|
||||
x: (root.width - 14px) * root.ratio;
|
||||
y: (root.height - 14px) / 2;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 7px;
|
||||
background: Theme.accent;
|
||||
border-width: 2px;
|
||||
border-color: Theme.surface-solid;
|
||||
}
|
||||
|
||||
area := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
moved => {
|
||||
root.value = root.valueForX(self.mouse-x);
|
||||
root.changed(root.value);
|
||||
}
|
||||
clicked => {
|
||||
root.value = root.valueForX(self.mouse-x);
|
||||
root.changed(root.value);
|
||||
}
|
||||
}
|
||||
|
||||
accessible-role: slider;
|
||||
}
|
||||
|
||||
/// 预设色板(09 §104 自定义强调色:预设色板 + 取色器,取色器由十六进制输入承担)。
|
||||
export component ColorSwatches inherits Rectangle {
|
||||
in property <[color]> palette;
|
||||
in property <color> current;
|
||||
/// 传**索引**而不是 color:Slint 没有「颜色 → #RRGGBB」的格式化,
|
||||
/// 预设色板的十六进制串由 Rust 侧同一张表给出(一处真相)。
|
||||
callback picked(int);
|
||||
|
||||
width: root.palette.length * 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: 4px;
|
||||
|
||||
for shade[i] in root.palette : Rectangle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: Metrics.radius-sm;
|
||||
background: shade;
|
||||
border-width: shade == root.current ? 2px : 1px;
|
||||
border-color: shade == root.current ? Theme.fg-primary : Theme.stroke;
|
||||
|
||||
swatch-touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.picked(i); }
|
||||
}
|
||||
|
||||
accessible-role: button;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// 设置项行:左标签+说明,右控件。控件类型用 ControlKind 收敛,避免为每个设置项写一遍布局。
|
||||
// (Slint 没有 slot 机制,所以用 kind 枚举 + 通用属性把控件种类收在一处。)
|
||||
//
|
||||
// M41 增加两种控件:`slider`(强度/透明度滑条)与 `swatches`(预设色板 + 取色器入口),
|
||||
// 它们服务 09 §101-104 的「外观(高级)」四件——高级特性默认关闭,控件本身常显(关着也能调)。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { MetroButton, ButtonKind } from "button.slint";
|
||||
import {
|
||||
ToggleSwitch, SegmentedControl, TextField, MetroSlider, ColorSwatches,
|
||||
} from "inputs.slint";
|
||||
|
||||
export enum ControlKind {
|
||||
none,
|
||||
toggle,
|
||||
segmented,
|
||||
button,
|
||||
text,
|
||||
slider,
|
||||
swatches,
|
||||
}
|
||||
|
||||
export component SettingItem inherits Rectangle {
|
||||
in property <string> label: "";
|
||||
in property <string> description: "";
|
||||
in property <ControlKind> kind: ControlKind.none;
|
||||
in property <[string]> options;
|
||||
in property <string> button-text: "";
|
||||
in-out property <int> selected: 0;
|
||||
in-out property <bool> checked: false;
|
||||
in-out property <string> value: "";
|
||||
// 滑条
|
||||
in property <float> min-value: 0.0;
|
||||
in property <float> max-value: 1.0;
|
||||
in-out property <float> slider-value: 1.0;
|
||||
// 色板
|
||||
in property <[color]> swatch-colors;
|
||||
in property <color> swatch-current: transparent;
|
||||
// 危险/提示色描述(如「对比度偏低」)
|
||||
in property <bool> warn: false;
|
||||
|
||||
callback toggled(bool);
|
||||
callback changed(int);
|
||||
callback clicked();
|
||||
callback edited(string);
|
||||
callback slid(float);
|
||||
callback swatch-picked(int);
|
||||
|
||||
height: root.description == "" ? 44px : 60px;
|
||||
background: transparent;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-4;
|
||||
spacing: Metrics.gap-4;
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
vertical-stretch: 1;
|
||||
|
||||
UiText {
|
||||
role: TextRole.body;
|
||||
text: root.label;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.description;
|
||||
visible: root.description != "";
|
||||
color: root.warn ? Theme.state-warn : Theme.fg-secondary;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
}
|
||||
|
||||
if root.kind == ControlKind.toggle : ToggleSwitch {
|
||||
checked: root.checked;
|
||||
toggled(v) => {
|
||||
root.checked = v;
|
||||
root.toggled(v);
|
||||
}
|
||||
}
|
||||
|
||||
if root.kind == ControlKind.segmented : SegmentedControl {
|
||||
width: root.options.length * 64px;
|
||||
options: root.options;
|
||||
selected: root.selected;
|
||||
changed(i) => {
|
||||
root.selected = i;
|
||||
root.changed(i);
|
||||
}
|
||||
}
|
||||
|
||||
if root.kind == ControlKind.button : MetroButton {
|
||||
kind: ButtonKind.standard;
|
||||
text: root.button-text;
|
||||
button-height: 28px;
|
||||
clicked => { root.clicked(); }
|
||||
}
|
||||
|
||||
if root.kind == ControlKind.text : TextField {
|
||||
width: 240px;
|
||||
placeholder: root.button-text;
|
||||
text <=> root.value;
|
||||
edited(t) => { root.edited(t); }
|
||||
}
|
||||
|
||||
if root.kind == ControlKind.slider : HorizontalLayout {
|
||||
spacing: Metrics.gap-2;
|
||||
alignment: end;
|
||||
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: (root.slider-value * 100).round() + "%";
|
||||
width: 40px;
|
||||
horizontal-alignment: right;
|
||||
color: Theme.fg-secondary;
|
||||
}
|
||||
MetroSlider {
|
||||
minimum: root.min-value;
|
||||
maximum: root.max-value;
|
||||
value: root.slider-value;
|
||||
changed(v) => {
|
||||
root.slider-value = v;
|
||||
root.slid(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if root.kind == ControlKind.swatches : ColorSwatches {
|
||||
palette: root.swatch-colors;
|
||||
current: root.swatch-current;
|
||||
picked(i) => { root.swatch-picked(i); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 分组标题
|
||||
export component SettingGroup inherits Rectangle {
|
||||
in property <string> title: "";
|
||||
|
||||
height: 36px;
|
||||
background: transparent;
|
||||
|
||||
UiText {
|
||||
x: Metrics.gap-4;
|
||||
y: 0;
|
||||
width: root.width - 2 * Metrics.gap-4;
|
||||
height: root.height;
|
||||
role: TextRole.caption;
|
||||
text: root.title;
|
||||
color: Theme.fg-secondary;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Toast:右下角非阻塞提示(下载完成 / 复制成功 / 后台任务结论)。
|
||||
// 队列与自动消失时长由 Rust ToastCenter 管理(P0 默认 4s),这里只负责渲染与手动关闭。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { UiToast, ToastKind } from "../models.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { AppIcon, IconKind } from "../components/icons.slint";
|
||||
|
||||
component ToastCard inherits Rectangle {
|
||||
in property <UiToast> data;
|
||||
callback dismissed(string);
|
||||
|
||||
width: 300px;
|
||||
height: body-layout.preferred-height;
|
||||
background: Theme.bg-elevated;
|
||||
border-radius: Metrics.radius-md;
|
||||
border-width: 1px;
|
||||
border-color: Theme.stroke;
|
||||
drop-shadow-blur: 16px;
|
||||
drop-shadow-color: Theme.shadow;
|
||||
drop-shadow-offset-y: 3px;
|
||||
|
||||
// 左侧语义色条:Metro 用色块表达状态,不用图标喧宾夺主
|
||||
Rectangle {
|
||||
x: 0px;
|
||||
y: 0px;
|
||||
width: 3px;
|
||||
height: root.height;
|
||||
background: root.data.kind == ToastKind.success ? Theme.state-success
|
||||
: root.data.kind == ToastKind.warn ? Theme.state-warn
|
||||
: root.data.kind == ToastKind.error ? Theme.state-danger
|
||||
: Theme.accent;
|
||||
}
|
||||
|
||||
body-layout := HorizontalLayout {
|
||||
padding-left: Metrics.gap-3;
|
||||
padding-right: Metrics.gap-2;
|
||||
padding-top: Metrics.gap-2;
|
||||
padding-bottom: Metrics.gap-2;
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
UiText {
|
||||
role: TextRole.body;
|
||||
text: root.data.text;
|
||||
wrap: word-wrap;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
|
||||
AppIcon {
|
||||
kind: IconKind.close;
|
||||
icon-size: 14px;
|
||||
tint: Theme.fg-secondary;
|
||||
|
||||
TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.dismissed(root.data.id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export component ToastHost inherits Rectangle {
|
||||
in property <[UiToast]> toasts;
|
||||
callback dismissed(string);
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
|
||||
stack-box := Rectangle {
|
||||
x: root.width - stack-box.width - Metrics.gap-4;
|
||||
y: root.height - stack-box.height - Metrics.gap-4;
|
||||
width: 300px;
|
||||
height: stack.preferred-height;
|
||||
background: transparent;
|
||||
|
||||
stack := VerticalLayout {
|
||||
width: stack-box.width;
|
||||
spacing: Metrics.gap-2;
|
||||
|
||||
for t in root.toasts : ToastCard {
|
||||
data: t;
|
||||
dismissed(id) => { root.dismissed(id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// 新建实例向导(G5,全屏模态):版本 → 加载器 → 名字 → 确认 → 进度。
|
||||
// 步骤推进/校验在 Rust(`crate::wizard`),这里只画当前步与提交按钮。
|
||||
import { Theme } from "../theme/tokens.slint";
|
||||
import { Metrics } from "../theme/metrics.slint";
|
||||
import { UiVersion, VersionKind } from "../models.slint";
|
||||
import { PageState } from "../page_state.slint";
|
||||
import { UiText, TextRole } from "../components/label.slint";
|
||||
import { Badge } from "../components/badge.slint";
|
||||
import { AppIcon, IconKind } from "../components/icons.slint";
|
||||
import { MetroButton, ButtonKind } from "../widgets/button.slint";
|
||||
import { SearchField } from "../widgets/inputs.slint";
|
||||
import { InstallPanel } from "../components/install-panel.slint";
|
||||
import { ScrollView } from "std-widgets.slint";
|
||||
|
||||
component StepDot inherits Rectangle {
|
||||
in property <int> index: 0;
|
||||
in property <string> label: "";
|
||||
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
|
||||
HorizontalLayout {
|
||||
spacing: Metrics.gap-1;
|
||||
alignment: center;
|
||||
Rectangle {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
background: PageState.wizard.step >= root.index ? Theme.accent : Theme.bg-hover;
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.index + 1;
|
||||
horizontal-alignment: center;
|
||||
color: PageState.wizard.step >= root.index ? Theme.fg-on-accent : Theme.fg-secondary;
|
||||
}
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: root.label;
|
||||
color: PageState.wizard.step >= root.index ? Theme.fg-primary : Theme.fg-disabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component VersionChoice inherits Rectangle {
|
||||
in property <UiVersion> data;
|
||||
callback picked();
|
||||
|
||||
height: 36px;
|
||||
background: PageState.wizard.version == root.data.id ? Theme.accent-soft
|
||||
: (touch.has-hover ? Theme.bg-hover : transparent);
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-3;
|
||||
padding-right: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
UiText { role: TextRole.body; text: root.data.id; width: 160px; }
|
||||
Badge {
|
||||
text: root.data.kind == VersionKind.release ? "正式版" : "预览版";
|
||||
tint: Theme.fg-secondary;
|
||||
}
|
||||
UiText { role: TextRole.caption; text: root.data.released; horizontal-stretch: 1; }
|
||||
if PageState.wizard.version == root.data.id : AppIcon {
|
||||
kind: IconKind.check;
|
||||
icon-size: 14px;
|
||||
tint: Theme.accent;
|
||||
}
|
||||
}
|
||||
|
||||
touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.picked(); }
|
||||
}
|
||||
}
|
||||
|
||||
component LoaderChoice inherits Rectangle {
|
||||
in property <string> label: "";
|
||||
in property <string> hint: "";
|
||||
in property <int> index: 0;
|
||||
callback picked();
|
||||
|
||||
height: 48px;
|
||||
background: PageState.wizard.loader == root.index ? Theme.accent-soft
|
||||
: (touch.has-hover ? Theme.bg-hover : transparent);
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-3;
|
||||
padding-right: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
VerticalLayout {
|
||||
vertical-stretch: 1;
|
||||
spacing: 0px;
|
||||
UiText { role: TextRole.body; text: root.label; }
|
||||
UiText { role: TextRole.caption; text: root.hint; horizontal-stretch: 1; }
|
||||
}
|
||||
if PageState.wizard.loader == root.index : AppIcon {
|
||||
kind: IconKind.check;
|
||||
icon-size: 14px;
|
||||
tint: Theme.accent;
|
||||
}
|
||||
}
|
||||
|
||||
touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.picked(); }
|
||||
}
|
||||
}
|
||||
|
||||
export component NewInstanceWizard inherits Rectangle {
|
||||
// 版本列表由 shell 传入(与下载页共用一份,避免二次拉取)
|
||||
in property <[UiVersion]> versions;
|
||||
in-out property <string> query: "";
|
||||
|
||||
visible: PageState.wizard.open;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
|
||||
// 吃掉底层点击(模态)
|
||||
TouchArea { }
|
||||
|
||||
Rectangle {
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
width: min(parent.width - 80px, 720px);
|
||||
height: min(parent.height - 60px, 560px);
|
||||
background: Theme.bg-surface;
|
||||
border-radius: Metrics.radius-md;
|
||||
border-width: 1px;
|
||||
border-color: Theme.stroke;
|
||||
|
||||
VerticalLayout {
|
||||
// 标题栏
|
||||
Rectangle {
|
||||
height: 52px;
|
||||
background: transparent;
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
padding-right: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
UiText {
|
||||
role: TextRole.subtitle;
|
||||
text: "新建实例 · " + PageState.wizard.step_label;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "关闭";
|
||||
button-height: 24px;
|
||||
clicked => { PageState.wizard-close(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤指示
|
||||
HorizontalLayout {
|
||||
padding-left: Metrics.gap-4;
|
||||
spacing: Metrics.gap-3;
|
||||
StepDot { index: 0; label: "版本"; }
|
||||
StepDot { index: 1; label: "加载器"; }
|
||||
StepDot { index: 2; label: "名字"; }
|
||||
StepDot { index: 3; label: "确认"; }
|
||||
StepDot { index: 4; label: "进度"; }
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.stroke; }
|
||||
|
||||
// 步骤内容
|
||||
Rectangle {
|
||||
vertical-stretch: 1;
|
||||
background: transparent;
|
||||
|
||||
// ① 选版本(只列正式版/快照,远古版走下载页的显式入口)
|
||||
if PageState.wizard.step == 0 : VerticalLayout {
|
||||
padding: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
SearchField {
|
||||
width: 220px;
|
||||
text <=> root.query;
|
||||
placeholder: "筛选版本号";
|
||||
}
|
||||
ScrollView {
|
||||
vertical-stretch: 1;
|
||||
VerticalLayout {
|
||||
for v in root.versions : VersionChoice {
|
||||
visible: !v.ancient;
|
||||
data: v;
|
||||
picked => { PageState.wizard-select-version(v.id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ② 选加载器
|
||||
if PageState.wizard.step == 1 : VerticalLayout {
|
||||
padding: Metrics.gap-3;
|
||||
spacing: 0px;
|
||||
LoaderChoice { index: 0; label: "原版(Vanilla)"; hint: "不装加载器,最快最稳"; picked => { PageState.wizard-select-loader(0); } }
|
||||
LoaderChoice { index: 1; label: "Fabric"; hint: "轻量模组加载器(GUI 可直接装)"; picked => { PageState.wizard-select-loader(1); } }
|
||||
LoaderChoice { index: 2; label: "Quilt"; hint: "Fabric 的分支(GUI 可直接装)"; picked => { PageState.wizard-select-loader(2); } }
|
||||
LoaderChoice { index: 3; label: "Forge"; hint: "需要 installer processors(首装会外调 java,约 1–3 分钟)"; picked => { PageState.wizard-select-loader(3); } }
|
||||
LoaderChoice { index: 4; label: "NeoForge"; hint: "需要 installer processors(首装会外调 java,约 1–3 分钟)"; picked => { PageState.wizard-select-loader(4); } }
|
||||
}
|
||||
|
||||
// ③ 名字
|
||||
if PageState.wizard.step == 2 : VerticalLayout {
|
||||
padding: Metrics.gap-4;
|
||||
spacing: Metrics.gap-2;
|
||||
UiText { role: TextRole.body; text: "实例名(默认用版本号)"; }
|
||||
SearchField {
|
||||
width: 320px;
|
||||
text: PageState.wizard.name;
|
||||
placeholder: "例如:生存 1.20.4";
|
||||
edited(t) => { PageState.wizard-name-changed(t); }
|
||||
}
|
||||
if PageState.wizard.error != "" : UiText {
|
||||
role: TextRole.caption;
|
||||
text: PageState.wizard.error;
|
||||
color: Theme.state-danger;
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "不能包含 / \\ : * ? \" < > |,最多 64 个字符。";
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 确认
|
||||
if PageState.wizard.step == 3 : VerticalLayout {
|
||||
padding: Metrics.gap-4;
|
||||
spacing: Metrics.gap-2;
|
||||
UiText { role: TextRole.bodyStrong; text: "将创建实例"; }
|
||||
UiText { role: TextRole.body; text: "版本 " + PageState.wizard.version; }
|
||||
UiText { role: TextRole.body; text: "加载器 " + PageState.wizard.loader_label; }
|
||||
UiText { role: TextRole.body; text: "实例名 " + PageState.wizard.name; }
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "确认后会按 core 安装管线下载:版本 JSON → 客户端 jar → 库 → 资源索引 → 登记实例。";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
if PageState.wizard.error != "" : UiText {
|
||||
role: TextRole.caption;
|
||||
text: PageState.wizard.error;
|
||||
color: Theme.state-danger;
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 进度
|
||||
if PageState.wizard.step == 4 : VerticalLayout {
|
||||
padding: Metrics.gap-3;
|
||||
spacing: Metrics.gap-2;
|
||||
InstallPanel {
|
||||
data: PageState.install;
|
||||
cancel => { PageState.install-cancel(); }
|
||||
}
|
||||
UiText {
|
||||
role: TextRole.caption;
|
||||
text: "装完会自动跳到实例页;失败原因会显示在上面。";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.stroke; }
|
||||
|
||||
// 底部按钮
|
||||
Rectangle {
|
||||
height: 56px;
|
||||
background: transparent;
|
||||
HorizontalLayout {
|
||||
padding: Metrics.gap-4;
|
||||
spacing: Metrics.gap-2;
|
||||
Rectangle { horizontal-stretch: 1; background: transparent; }
|
||||
MetroButton {
|
||||
kind: ButtonKind.subtle;
|
||||
text: "上一步";
|
||||
button-height: 30px;
|
||||
enabled: PageState.wizard.step > 0 && PageState.wizard.step < 4;
|
||||
clicked => { PageState.wizard-back(); }
|
||||
}
|
||||
if PageState.wizard.step < 3 : MetroButton {
|
||||
kind: ButtonKind.accent;
|
||||
text: "下一步";
|
||||
button-height: 30px;
|
||||
enabled: PageState.wizard.can-next;
|
||||
clicked => { PageState.wizard-next(); }
|
||||
}
|
||||
if PageState.wizard.step == 3 : MetroButton {
|
||||
kind: ButtonKind.accent;
|
||||
text: "开始安装";
|
||||
button-height: 30px;
|
||||
enabled: PageState.wizard.can-next && !PageState.wizard.busy;
|
||||
clicked => { PageState.wizard-confirm(); }
|
||||
}
|
||||
if PageState.wizard.step == 4 : MetroButton {
|
||||
kind: ButtonKind.standard;
|
||||
text: "后台继续";
|
||||
button-height: 30px;
|
||||
clicked => { PageState.wizard-close(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user