Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+124
View File
@@ -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();
});
}
}
+370
View File
@@ -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 crate::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(&current);
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);
}
}
+354
View File
@@ -0,0 +1,354 @@
//! 回调接线:把 `ui/main.slint` 暴露的每个回调翻译成「状态变更 + AppCommand + 同步」。
//!
//! 逐条列出而不做通用转发,是为了让「用户能做的每一件事」在这里一目了然;
//! 新增交互时只改这一处 + `.slint` 的 callback 声明。
use std::rc::Weak;
use slint::ComponentHandle;
use crate::conv;
use crate::dto::AppCommand;
use crate::router::Route;
use crate::toast::ToastKind;
use crate::ui_bind::Inner;
/// 接全部回调;闭包持弱引用,避免 `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 };
this.state
.borrow_mut()
.toasts
.push(ToastKind::Info, "新建实例向导(G5)在 P1 落地");
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();
}
+103
View File
@@ -0,0 +1,103 @@
//! AI 页与覆盖层:会话消息、对话框(一次一条)、Toast。
use crate::dialogs as dlg;
use crate::dto::ChatRole as DtoChatRole;
use crate::state::AppState;
use crate::toast as toast_mod;
use crate::{ChatRole, DialogKind, UiChatMessage, UiDialog, UiToast};
/// 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()
}
+162
View File
@@ -0,0 +1,162 @@
//! 实例相关:磁贴外观(相位/进度/徽标/占位色)、运行中列表。
use slint::Color;
use crate::dto::{self, InstanceRuntime};
use crate::state::{AppState, TileCell};
use crate::{LoaderKind, RuntimePhase, UiTile};
/// 头像/图标占位的底色池(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,
};
(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())), "从未启动");
}
}
+84
View File
@@ -0,0 +1,84 @@
//! 日志页:会话列表、日志行、D8 诊断卡片。
use crate::dto::{LogLevel as DtoLogLevel, LogOutcome as DtoLogOutcome};
use crate::state::AppState;
use crate::{LogLevel, LogOutcome, UiDiagnosis, UiLogLine, UiLogSession};
/// 日志会话列表。
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");
}
}
+21
View File
@@ -0,0 +1,21 @@
//! 状态层 → Slint 视图模型的单向转换(DTO 值 → `models.slint` 结构体)。
//!
//! 只做「领域值 → 展示值」的搬运与格式化,不含业务判断;
//! 这样 `ui_bind.rs` 里的同步逻辑就是纯粹的「状态灌进属性」。
//! 按领域拆子模块:instances / versions / logs / agent / ui,全量 `pub use`,
//! 调用方统一写 `crate::conv::tiles(...)` 这类路径。
mod agent;
mod instances;
mod logs;
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 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;
+136
View File
@@ -0,0 +1,136 @@
//! UI 偏好/路由枚举的双向映射(Slint 枚举 ↔ 状态层枚举)。
use crate::dto::{self, VersionCategory};
use crate::prefs;
use crate::router;
use crate::{BgFillKind, DownloadCategory, InstanceView, RouteKind, ThemeMode, TileSize};
/// 路由 → 导航高亮/页面选择。
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),
}
}
+81
View File
@@ -0,0 +1,81 @@
//! 版本列表与下载分类映射(下载页)。
use crate::dto::{AncientKind, VersionKind as DtoVersionKind};
use crate::state::AppState;
use crate::{UiVersion, VersionKind};
/// 下载页版本行(已按「仅已装」+ 版本号筛选词过滤)。
pub fn versions(state: &AppState) -> Vec<UiVersion> {
let query = state.download_query.trim().to_lowercase();
state
.versions
.iter()
.filter(|version| !state.installed_only || version.installed)
.filter(|version| query.is_empty() || version.id.to_lowercase().contains(&query))
.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()
}
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 crate::dto::{VersionKind, VersionSummary};
use crate::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());
}
}
+303
View File
@@ -0,0 +1,303 @@
//! 对话框队列(HMCL 队列语义 + Metro 化,09 §3)。
//!
//! 语义:**一次只显示一条**。新请求按来源插队(用户操作 > 后台任务 > 提示),同来源 FIFO;
//! 当前条被确认/取消后由 [`DialogQueue::advance`] 推进到下一条,UI 只渲染 `current`。
//! 非阻塞提示走 [`crate::toast`],不占模态队列。
/// 对话框标识。
pub type DialogId = u64;
/// 对话框来源:决定插队优先级。
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum DialogSource {
/// 提示类(教程、首次使用说明)
Hint = 0,
/// 后台任务(下载完成、需要选择镜像源)
BackgroundTask = 1,
/// 用户操作触发(AI 敏感写操作的二次确认)
UserAction = 2,
}
impl DialogSource {
/// 数值越大越优先。
pub fn priority(self) -> u8 {
self as u8
}
}
/// 对话框类型(09 §3:确认 / 进度 / 输入 / 错误)。
#[derive(Debug, Clone, PartialEq)]
pub enum DialogKind {
/// 确认:主按钮 + 次按钮
Confirm {
/// 主按钮文字(如「允许」)
primary: String,
/// 次按钮文字(如「拒绝」)
secondary: String,
},
/// 输入:单行文本
Input {
/// 输入框标签
label: String,
/// 初始值
value: String,
/// 主按钮文字
primary: String,
},
/// 进度:不可点主按钮(任务结束后由 [`crate::dto::AppEvent::DialogClosed`] 收起)
Progress {
/// 阶段文案
label: String,
/// 0.0~1.0
progress: f32,
},
/// 错误:含诊断入口提示
Error {
/// 主按钮文字
primary: String,
/// 诊断/修复提示
hint: Option<String>,
},
}
impl DialogKind {
/// 主按钮文字。
pub fn primary_label(&self) -> &str {
match self {
Self::Confirm { primary, .. }
| Self::Input { primary, .. }
| Self::Error { primary, .. } => primary,
Self::Progress { .. } => "取消",
}
}
/// 次按钮文字(None 表示不显示次按钮)。
pub fn secondary_label(&self) -> Option<&str> {
match self {
Self::Confirm { secondary, .. } => Some(secondary),
Self::Error { .. } => Some("忽略"),
Self::Input { .. } | Self::Progress { .. } => None,
}
}
/// 是否可交互(进度条不可点主按钮)。
pub fn is_blocking_progress(&self) -> bool {
matches!(self, Self::Progress { .. })
}
}
/// 一条对话框请求。
#[derive(Debug, Clone, PartialEq)]
pub struct DialogRequest {
/// 队列内唯一 id(由 [`DialogQueue::push_request`] 重新编号)
pub id: DialogId,
/// 来源(决定插队优先级)
pub source: DialogSource,
/// 标题
pub title: String,
/// 正文
pub body: String,
/// 类型与按钮
pub kind: DialogKind,
}
/// 模态队列:一次一条 + 来源插队。
#[derive(Debug, Default)]
pub struct DialogQueue {
current: Option<DialogRequest>,
pending: Vec<DialogRequest>,
next_id: DialogId,
}
impl DialogQueue {
/// 新建空队列。
pub fn new() -> Self {
Self::default()
}
/// 当前应显示的对话框。
pub fn current(&self) -> Option<&DialogRequest> {
self.current.as_ref()
}
/// 排队中(不含当前)条数。
pub fn pending_len(&self) -> usize {
self.pending.len()
}
/// 是否完全没有对话框(当前 + 排队)。
pub fn is_empty(&self) -> bool {
self.current.is_none() && self.pending.is_empty()
}
/// 入队一条请求(UI 内部调用方用,id 由队列自增分配)。没有当前条时立即成为当前条。
///
/// 返回值用于后续 [`Self::close`] / 进度更新。
pub fn push(
&mut self,
source: DialogSource,
title: impl Into<String>,
body: impl Into<String>,
kind: DialogKind,
) -> DialogId {
self.next_id += 1;
self.push_request(DialogRequest {
id: self.next_id,
source,
title: title.into(),
body: body.into(),
kind,
})
}
/// 入队一条已经组装好的请求(事件通道用)。
///
/// **id 归生产者**:这里原样保留 `request.id`(不重编号),因为关闭事件
/// [`crate::dto::AppEvent::DialogClosed`] 携带的是生产者 id;重编号会让
/// 「任务完成 → 自动收起进度框」在多来源弹框时静默失配。生产者负责自身 id 唯一。
pub fn push_request(&mut self, request: DialogRequest) -> DialogId {
let id = request.id;
let source = request.source;
if self.current.is_none() {
self.current = Some(request);
} else {
let at = self
.pending
.iter()
.position(|queued| queued.source < source)
.unwrap_or(self.pending.len());
self.pending.insert(at, request);
}
id
}
/// 关闭指定对话框:若是当前条则推进到下一条,否则从队列移除。
///
/// 返回 `true` 表示确实移除了。
pub fn close(&mut self, id: DialogId) -> bool {
if self
.current
.as_ref()
.is_some_and(|current| current.id == id)
{
self.advance();
return true;
}
if let Some(at) = self.pending.iter().position(|queued| queued.id == id) {
self.pending.remove(at);
return true;
}
false
}
/// 推进到下一条排队请求(当前条已被处理)。
pub fn advance(&mut self) {
self.current = if self.pending.is_empty() {
None
} else {
Some(self.pending.remove(0))
};
}
/// 关闭当前条并推进到下一条(确认 / 取消 / 点遮罩都走这里)。
///
/// 返回 `false` 表示本来就没有当前条(幂等,双击不会误推进队列)。
pub fn close_current(&mut self) -> bool {
if self.current.is_none() {
return false;
}
self.advance();
true
}
/// 更新进度对话框的进度(任务回调直接喂进来)。
pub fn update_progress(&mut self, id: DialogId, progress: f32) {
let Some(current) = &mut self.current else {
return;
};
if current.id != id {
return;
}
if let DialogKind::Progress {
progress: value, ..
} = &mut current.kind
{
*value = progress.clamp(0.0, 1.0);
}
}
}
#[cfg(test)]
mod tests {
use super::{DialogKind, DialogQueue, DialogSource};
fn confirm() -> DialogKind {
DialogKind::Confirm {
primary: "确定".into(),
secondary: "取消".into(),
}
}
#[test]
fn shows_one_dialog_at_a_time() {
let mut queue = DialogQueue::new();
let first = queue.push(DialogSource::BackgroundTask, "A", "", confirm());
let second = queue.push(DialogSource::BackgroundTask, "B", "", confirm());
assert_eq!(queue.current().map(|d| d.id), Some(first));
assert_eq!(queue.pending_len(), 1);
queue.advance();
assert_eq!(queue.current().map(|d| d.id), Some(second));
assert_eq!(queue.pending_len(), 0);
queue.advance();
assert!(queue.is_empty());
}
#[test]
fn user_action_jumps_the_queue() {
let mut queue = DialogQueue::new();
queue.push(DialogSource::Hint, "提示", "", confirm());
queue.push(DialogSource::Hint, "提示2", "", confirm());
queue.push(DialogSource::BackgroundTask, "后台", "", confirm());
let urgent = queue.push(DialogSource::UserAction, "确认删除", "", confirm());
// 当前仍是队首(一次一条),但排队里用户操作插到最前 → 关掉当前条后立刻轮到它
queue.advance();
assert_eq!(queue.current().map(|d| d.id), Some(urgent));
}
#[test]
fn closing_non_current_dialog_removes_it_from_pending() {
let mut queue = DialogQueue::new();
let first = queue.push(DialogSource::Hint, "A", "", confirm());
let second = queue.push(DialogSource::Hint, "B", "", confirm());
assert!(queue.close(second));
assert_eq!(queue.pending_len(), 0);
assert_eq!(queue.current().map(|d| d.id), Some(first));
assert!(!queue.close(999));
}
#[test]
fn progress_dialog_can_be_updated() {
let mut queue = DialogQueue::new();
let id = queue.push(
DialogSource::BackgroundTask,
"下载中",
"",
DialogKind::Progress {
label: "资源".into(),
progress: 0.0,
},
);
queue.update_progress(id, 1.7);
match queue.current().map(|d| &d.kind) {
Some(DialogKind::Progress { progress, .. }) => {
assert!((*progress - 1.0).abs() < f32::EPSILON);
}
other => panic!("unexpected dialog kind: {other:?}"),
}
}
}
+95
View File
@@ -0,0 +1,95 @@
//! AI 会话 DTO:消息、工具调用可视化与 agent 事件。
/// 会话消息角色。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChatRole {
/// 用户
User,
/// 助手(可流式)
Assistant,
/// 工具调用(会话里可见,D4 要求)
Tool,
/// 系统提示(弱化展示)
System,
}
/// 工具调用可视化条目。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolCallView {
/// 工具名(与 ToolSpec 注册表同名)
pub name: String,
/// 是否成功
pub ok: bool,
/// 一行结果摘要
pub summary: String,
}
/// 一条会话消息。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChatMessage {
/// 角色
pub role: ChatRole,
/// 正文(流式时持续追加)
pub text: String,
/// role == Tool 时带上工具调用详情
pub tool: Option<ToolCallView>,
/// 流式未收敛
pub streaming: bool,
}
impl ChatMessage {
/// 用户消息。
pub fn user(text: impl Into<String>) -> Self {
Self {
role: ChatRole::User,
text: text.into(),
tool: None,
streaming: false,
}
}
/// 助手流式消息起点(空文本 + streaming)。
pub fn assistant_stream() -> Self {
Self {
role: ChatRole::Assistant,
text: String::new(),
tool: None,
streaming: true,
}
}
}
/// agent 侧事件。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentEvent {
/// 追加一条完整消息
MessageAppended(ChatMessage),
/// 给指定序号的流式消息追加增量
MessageDelta {
/// 目标消息在会话里的序号
index: usize,
/// 增量文本
delta: String,
},
/// 流式收敛
MessageFinished {
/// 目标消息序号
index: usize,
},
/// 开始一次工具调用(会话里插入工具条目)
ToolCallStarted {
/// 工具名
name: String,
/// 调用参数摘要
summary: String,
},
/// 工具调用结束
ToolCallFinished {
/// 工具名
name: String,
/// 是否成功
ok: bool,
/// 结果摘要
summary: String,
},
}
+86
View File
@@ -0,0 +1,86 @@
//! 事件与指令:core/agent → app 的事件队列,app → core/agent 的用户意图。
use crate::dialogs::{DialogId, DialogRequest};
use crate::dto::{AgentEvent, DiagnosisReport, InstanceId, InstanceRuntime, InstanceSummary};
use crate::dto::{LogLine, LogSession, SessionId, VersionCategory, VersionSummary};
use crate::toast::Toast;
/// core 侧事件(不可变快照发布 + 事件流,01c §5 的三条通道之二)。
#[derive(Debug, Clone, PartialEq)]
pub enum CoreEvent {
/// 实例列表整体刷新
InstancesChanged(Vec<InstanceSummary>),
/// 单个实例运行时状态变化(启动进度/运行中/失败)
InstanceRuntimeChanged {
/// 实例 id
id: InstanceId,
/// 新状态
runtime: InstanceRuntime,
},
/// 下载页版本列表刷新
VersionListChanged(Vec<VersionSummary>),
/// 日志会话列表刷新
LogSessionsChanged(Vec<LogSession>),
/// 当前会话日志行刷新(P0 整批替换;P1 改增量追加)
LogLinesChanged(Vec<LogLine>),
/// 诊断结论产出
DiagnosisReady(DiagnosisReport),
}
/// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路,
/// 这样 [`crate::state::AppState::apply`] 是唯一的状态变更入口。
#[derive(Debug, Clone, PartialEq)]
pub enum AppEvent {
/// 来自 core
Core(CoreEvent),
/// 来自 agent
Agent(AgentEvent),
/// core/agent 请求弹对话框(会进队列,一次一条)
DialogRequested(DialogRequest),
/// 关闭指定对话框(任务完成收起进度条)
DialogClosed(DialogId),
/// 右下角 Toast
Toast(Toast),
}
/// app → core/agent 的用户意图。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppCommand {
/// 重新扫描实例目录
RefreshInstances,
/// 启动实例
LaunchInstance(InstanceId),
/// 停止实例(含取消启动中的管线)
StopInstance(InstanceId),
/// 拉取某分类的版本列表
ListVersions(VersionCategory),
/// 安装指定版本
InstallVersion(String),
/// 刷新日志会话列表
RefreshLogSessions,
/// 打开某个会话(拉取日志行 + 触发诊断)
OpenLogSession(SessionId),
/// 向 AI 提问(会话状态在 app 侧,agent 只回事件)
AskAgent(String),
/// 中断当前 AI 流
StopAgent,
/// YOLO 开关(跳过确认,审计照记)
SetYolo(bool),
}
/// 后端通道:core/agent host 实现它;P0 用 [`crate::mock::MockBackend`] 验证通道语义。
///
/// 轮询模型(而非回调)是为了让 Slint 的定时器成为唯一调度者,
/// 避免跨线程回调里碰 Slint 模型。
pub trait Backend {
/// 取走自上次调用以来积压的事件(FIFO,不可丢序号)。
fn drain_events(&mut self) -> Vec<AppEvent>;
/// 下发一条用户意图。
fn dispatch(&mut self, command: AppCommand);
/// 时间推进(mock 用脚本推进进度;真实后端可以是空实现)。
fn tick(&mut self, elapsed: std::time::Duration) {
let _ = elapsed;
}
}
+151
View File
@@ -0,0 +1,151 @@
//! 实例相关 DTO:磁贴与列表视图的唯一数据源。
use std::path::PathBuf;
/// 实例标识:core::game 生成的稳定 id,app 视为不透明字符串。
pub type InstanceId = String;
/// 加载器种类(磁贴徽标 + 详情页版本链)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoaderKind {
/// 原版(无加载器)
Vanilla,
/// Fabric
Fabric,
/// Quilt
Quilt,
/// Forge
Forge,
/// NeoForge
NeoForge,
/// OptiFine
OptiFine,
/// LiteLoader
LiteLoader,
}
impl LoaderKind {
/// 徽标显示文字。
pub fn label(self) -> &'static str {
match self {
Self::Vanilla => "原版",
Self::Fabric => "Fabric",
Self::Quilt => "Quilt",
Self::Forge => "Forge",
Self::NeoForge => "NeoForge",
Self::OptiFine => "OptiFine",
Self::LiteLoader => "LiteLoader",
}
}
}
/// 加载器 + 版本(例如 Fabric 0.16.5)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoaderInfo {
/// 加载器种类
pub kind: LoaderKind,
/// 加载器版本号;原版为空串
pub version: String,
}
impl LoaderInfo {
/// 徽标文字:原版只写「原版」,装加载器时写「Fabric 0.16.5」。
pub fn badge(&self) -> String {
if self.kind == LoaderKind::Vanilla || self.version.is_empty() {
self.kind.label().to_string()
} else {
format!("{} {}", self.kind.label(), self.version)
}
}
}
/// 字节进度;`total == 0` 表示总量未知(UI 转不确定态)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Progress {
/// 已完成量
pub done: u64,
/// 总量;0 = 未知
pub total: u64,
}
impl Progress {
/// 0.0~1.0;未知总量返回 0.0。
pub fn ratio(self) -> f32 {
if self.total == 0 {
0.0
} else {
(self.done as f32 / self.total as f32).clamp(0.0, 1.0)
}
}
/// 总量是否已知。
pub fn is_known(self) -> bool {
self.total > 0
}
}
/// 启动管线阶段(磁贴进度环的文案来源;D8 的 10 阶段在 P1 细化)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LaunchStage {
/// 下载资源
Download,
/// 校验文件
Verify,
/// 组装命令行并拉起进程
Launch,
}
impl LaunchStage {
/// 磁贴上的阶段文案。
pub fn label(self) -> &'static str {
match self {
Self::Download => "下载资源",
Self::Verify => "校验文件",
Self::Launch => "启动中",
}
}
}
/// 实例运行时状态(磁贴的四种外观)。
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum InstanceRuntime {
/// 空闲
#[default]
Idle,
/// 启动管线进行中
Busy {
/// 当前阶段
stage: LaunchStage,
/// 该阶段进度
progress: Progress,
},
/// 游戏进程存活
Running {
/// 进程号(停止/诊断用)
pid: u32,
},
/// 上次启动失败(磁贴显示「重试」)
Failed {
/// 失败原因(用户可见文案)
reason: String,
},
}
/// 实例摘要:磁贴/列表视图唯一数据源。
#[derive(Debug, Clone, PartialEq)]
pub struct InstanceSummary {
/// 稳定 id
pub id: InstanceId,
/// 显示名
pub name: String,
/// 游戏版本(如 1.20.4)
pub mc_version: String,
/// 加载器信息
pub loader: LoaderInfo,
/// ISO-8601 UTC 最后游玩时间;None = 从未启动
pub last_played: Option<String>,
/// 运行时状态
pub runtime: InstanceRuntime,
/// 实例图标路径(P0 直接给路径;P1 换成 core 提供的图像句柄)
pub icon: Option<PathBuf>,
}
+76
View File
@@ -0,0 +1,76 @@
//! 日志页 DTO:会话列表、日志行与 D8 诊断结论。
/// 一次启动会话(日志页左栏单位)标识。
pub type SessionId = String;
/// 一次运行的结局(G3 左栏图标:✓ 正常 / ✗ 崩溃 / ⚠ 静默失败)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogOutcome {
/// 正常退出
Ok,
/// 崩溃(非零退出码或 hs_err_pid)
Crashed,
/// exit0 但游戏没起来(D8 新增检测)
SilentFail,
}
/// 会话摘要。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogSession {
/// 会话 id
pub id: SessionId,
/// 实例名(会话属于哪个实例)
pub instance: String,
/// 展示用时间(`10-29 19:22`)
pub when: String,
/// 结局
pub outcome: LogOutcome,
/// 时长(秒)
pub duration_secs: u64,
}
/// 日志行等级(错误行高亮)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogLevel {
/// debug
Debug,
/// info
Info,
/// warn
Warn,
/// error
Error,
}
/// 一行日志。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogLine {
/// 原始文本(含时间戳前缀)
pub text: String,
/// 等级
pub level: LogLevel,
}
/// D8 规则引擎结论(日志页崩溃诊断卡片)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnosis {
/// 命中规则 id(`gpu.driver_old`)
pub rule: String,
/// 结论标题
pub title: String,
/// 0~100 置信度
pub confidence: u8,
/// 建议文案
pub hint: String,
/// 可一键执行的修复动作(无则只给建议)
pub fix: Option<String>,
}
/// 诊断结论 + 归属会话(UI 用它决定卡片显隐)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagnosisReport {
/// 归属会话
pub session: SessionId,
/// 诊断内容
pub diagnosis: Diagnosis,
}
+23
View File
@@ -0,0 +1,23 @@
//! app ↔ core/agent 的 IPC 边界(01c §5 纪律)。
//!
//! 这里只允许出现「扁平 DTO / 事件 / 指令」:`crates/app` 任何模块都不得 `use unlucky_core::*`
//! 或 `use unlucky_agent::*` 的内部类型。core/agent 侧的 host 层负责把自身类型翻译成本模块的类型,
//! 反向的 [`AppCommand`] 也一样。P0 阶段由 [`crate::mock`] 的假后端实现 [`Backend`] 驱动骨架。
//!
//! 结构:**按领域拆子模块**(instances / versions / logs / agent / events),
//! 全量 `pub use` 出去,调用方依旧写 `crate::dto::InstanceSummary` 这类路径。
//! 契约说明(字段口径、事件顺序、错误语义)见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`。
mod agent;
mod events;
mod instances;
mod logs;
mod versions;
pub use agent::{AgentEvent, ChatMessage, ChatRole, ToolCallView};
pub use events::{AppCommand, AppEvent, Backend, CoreEvent};
pub use instances::{
InstanceId, InstanceRuntime, InstanceSummary, LaunchStage, LoaderInfo, LoaderKind, Progress,
};
pub use logs::{Diagnosis, DiagnosisReport, LogLevel, LogLine, LogOutcome, LogSession, SessionId};
pub use versions::{AncientKind, VersionCategory, VersionKind, VersionSummary};
+60
View File
@@ -0,0 +1,60 @@
//! 下载页 DTO:版本列表与分类(含远古版子类,D16/Q24-4)。
/// 版本类型(远古版按 unlisted-versions 的分类拆开)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VersionKind {
/// 正式版
Release,
/// 预览版(snapshot)
Snapshot,
/// 远古版(带子类)
Ancient(AncientKind),
}
/// 远古版子类。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AncientKind {
/// classic(rd-*)
Classic,
/// old_alpha(a1.*)
OldAlpha,
/// old_beta(b1.*)
OldBeta,
/// 未列入官方清单的远古版汇总
Unlisted,
}
impl VersionKind {
/// 是否属于「远古版」——列表行要加警示徽标。
pub fn is_ancient(self) -> bool {
matches!(self, Self::Ancient(_))
}
}
/// 下载页版本行。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionSummary {
/// 版本 id(如 `1.20.4` / `rd-132211`)
pub id: String,
/// 版本类型
pub kind: VersionKind,
/// 发布日期(ISO-8601 日期部分)
pub released: String,
/// 本地是否已安装(行尾显示「已装」)
pub installed: bool,
}
/// 下载页左侧分类(页内常驻侧栏的选中项)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VersionCategory {
/// 正式版
Release,
/// 预览版
Snapshot,
/// 远古版某子类
Ancient(AncientKind),
/// 整合包(P1)
Modpack,
/// Mod(P1)
Mod,
}
+94
View File
@@ -0,0 +1,94 @@
//! Unlucky-Launcher 桌面壳(`unlucky_app`):Slint UI + Metro 视觉。
//!
//! 边界(决策 D3 / D16):本 crate 是**唯一**允许依赖 UI 框架的 crate;
//! 它只经「快照 + 事件流 + 注入的显示服务」消费 core/agent,不直接触碰其内部类型
//! (P0 的 host 是 `mock` 假后端,`crate::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(`crate::settings_bridge`),AI 可写并且**热切**
//! (tick 里 1s 一次 mtime 比对);系统主题由 `core::platform::theme_probe` 对齐 + 兜底轮询。
//!
//! 架构分层(本文件只有一个职责:把窗口拉起来):
//! - `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)]
mod appearance_bind;
mod bg_image;
mod callbacks;
mod conv;
mod dialogs;
mod dto;
mod mock;
mod mock_data;
mod png;
mod prefs;
mod router;
mod settings_bridge;
mod state;
mod theme_runtime;
mod toast;
mod ui_bind;
slint::include_modules!();
fn main() -> Result<(), slint::PlatformError> {
apply_renderer_flag();
let app = ui_bind::AppUi::new(MainWindow::new()?);
// 截图验收通道(M41 视觉验收;无交互桌面/无 GPU 环境下的唯一可靠取图方式:
// 不截屏幕,而是让 Slint 把当前场景渲染进内存像素缓冲再自己编码 PNG)。
if let Some(path) = screenshot_path() {
if std::env::args().any(|arg| arg == "--screenshot-settings") {
app.open_settings_appearance();
}
app.render_screenshot(&path)?;
return Ok(());
}
app.start_timer();
app.run()
}
/// 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");
}
}
/// `--screenshot <path>` / `--screenshot=<path>`:渲染一帧并写 PNG 后退出。
fn screenshot_path() -> Option<String> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if let Some(value) = arg.strip_prefix("--screenshot=") {
return Some(value.to_owned());
}
if arg == "--screenshot" {
return args.next();
}
}
None
}
+366
View File
@@ -0,0 +1,366 @@
//! P0 假后端:用脚本化事件驱动骨架,验证 app ↔ core/agent 通道语义。
//!
//! 覆盖四条通道:实例运行状态(磁贴进度环)、AI 流式会话(含工具调用)、
//! 对话框队列(进度对话框)、Toast。阶段 B 里由真正的 core/agent host 替换
//! [`MockBackend`],[`crate::dto::Backend`] 之上的 state/reducer 不动。
//! 假数据本体在 [`crate::mock_data`]。
use std::collections::VecDeque;
use std::time::Duration;
use crate::dialogs::{DialogKind, DialogRequest, DialogSource};
use crate::dto::{
AgentEvent, AppCommand, AppEvent, Backend, ChatMessage, ChatRole, CoreEvent, InstanceId,
InstanceRuntime, LaunchStage, Progress,
};
use crate::mock_data::{mock_instances, mock_log_lines, mock_log_sessions, mock_versions};
use crate::toast::{Toast, ToastKind};
/// 启动脚本:按时间推进 download → verify → launch → running。
#[derive(Debug)]
struct LaunchScript {
id: InstanceId,
elapsed_ms: u64,
}
/// AI 流式脚本:每条提问回一段可切分的话 + 一次工具调用。
#[derive(Debug)]
struct ChatScript {
elapsed_ms: u64,
chunks: VecDeque<String>,
/// 流式助手消息在 app 会话里的序号
message_index: usize,
}
/// 假后端。
#[derive(Debug, Default)]
pub struct MockBackend {
events: VecDeque<AppEvent>,
launch: Option<LaunchScript>,
chat: Option<ChatScript>,
/// 已投递给 app 的会话消息条数(用来给 MessageDelta 算序号)
emitted_messages: usize,
next_dialog: u64,
}
impl MockBackend {
/// 预置一批假数据事件(实例/版本/日志会话)。
pub fn new() -> Self {
let mut backend = Self::default();
backend.seed();
backend
}
fn seed(&mut self) {
self.events.push_back(AppEvent::Core(
CoreEvent::InstancesChanged(mock_instances()),
));
self.events
.push_back(AppEvent::Core(CoreEvent::VersionListChanged(
mock_versions(),
)));
self.events
.push_back(AppEvent::Core(CoreEvent::LogSessionsChanged(
mock_log_sessions(),
)));
self.events
.push_back(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines())));
}
fn emit(&mut self, event: AppEvent) {
self.events.push_back(event);
}
fn next_dialog_id(&mut self) -> u64 {
self.next_dialog += 1;
self.next_dialog
}
fn script_launch(&mut self, id: InstanceId) {
self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
id: id.clone(),
runtime: InstanceRuntime::Busy {
stage: LaunchStage::Download,
progress: Progress {
done: 0,
total: 1_500,
},
},
}));
self.launch = Some(LaunchScript { id, elapsed_ms: 0 });
}
fn step_launch(&mut self, elapsed: Duration) {
let Some((id, elapsed_ms)) = self.launch.as_mut().map(|script| {
script.elapsed_ms += u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
(script.id.clone(), script.elapsed_ms)
}) else {
return;
};
// 0~1.5s 下载、1.5~2.5s 校验、2.5~3s 启动、3s 后转为运行中
let runtime = if elapsed_ms < 1_500 {
InstanceRuntime::Busy {
stage: LaunchStage::Download,
progress: Progress {
done: elapsed_ms,
total: 1_500,
},
}
} else if elapsed_ms < 2_500 {
InstanceRuntime::Busy {
stage: LaunchStage::Verify,
progress: Progress {
done: elapsed_ms - 1_500,
total: 1_000,
},
}
} else if elapsed_ms < 3_000 {
InstanceRuntime::Busy {
stage: LaunchStage::Launch,
progress: Progress {
done: elapsed_ms - 2_500,
total: 500,
},
}
} else {
self.launch = None;
self.emit(AppEvent::Toast(Toast {
id: 0,
kind: ToastKind::Success,
text: "游戏已启动".to_string(),
remaining_ms: 0,
}));
InstanceRuntime::Running { pid: 12_345 }
};
self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
id,
runtime,
}));
}
fn script_chat(&mut self, text: String) {
let user_index = self.emitted_messages;
self.emit(AppEvent::Agent(AgentEvent::MessageAppended(
ChatMessage::user(text),
)));
self.emit(AppEvent::Agent(AgentEvent::MessageAppended(
ChatMessage::assistant_stream(),
)));
self.emitted_messages += 2;
self.chat = Some(ChatScript {
elapsed_ms: 0,
chunks: VecDeque::from(vec![
"先确认版本:".to_string(),
"官方 1.20.4 是正式版,".to_string(),
"装完再用 Fabric 加加载器。".to_string(),
]),
message_index: user_index + 1,
});
}
fn step_chat(&mut self, elapsed: Duration) {
let Some(chat) = self.chat.as_mut() else {
return;
};
chat.elapsed_ms += u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
if chat.elapsed_ms < 300 {
return;
}
chat.elapsed_ms = 0;
let index = chat.message_index;
let chunk = chat.chunks.pop_front();
if let Some(delta) = chunk {
self.emit(AppEvent::Agent(AgentEvent::MessageDelta { index, delta }));
return;
}
// 流结束后补一次工具调用(工具执行在会话里可见)+ 收尾消息
self.emit(AppEvent::Agent(AgentEvent::ToolCallStarted {
name: "read_game_log".to_string(),
summary: "读取最近一次崩溃日志".to_string(),
}));
self.emit(AppEvent::Agent(AgentEvent::ToolCallFinished {
name: "read_game_log".to_string(),
ok: true,
summary: "读到 128 行,无 ERROR".to_string(),
}));
self.emit(AppEvent::Agent(AgentEvent::MessageFinished { index }));
self.emit(AppEvent::Agent(AgentEvent::MessageAppended(ChatMessage {
role: ChatRole::Assistant,
text: "日志没有 ERROR,问题更可能在 Java 版本。".to_string(),
tool: None,
streaming: false,
})));
self.emitted_messages += 2;
self.chat = None;
}
fn script_install(&mut self, version: String) {
let id = self.next_dialog_id();
self.emit(AppEvent::DialogRequested(DialogRequest {
id,
source: DialogSource::BackgroundTask,
title: format!("正在安装 {version}"),
body: String::new(),
kind: DialogKind::Progress {
label: "下载资源".to_string(),
progress: 0.0,
},
}));
self.emit(AppEvent::DialogClosed(id));
self.emit(AppEvent::Toast(Toast {
id: 0,
kind: ToastKind::Success,
text: format!("{version} 安装完成"),
remaining_ms: 0,
}));
}
}
impl Backend for MockBackend {
fn drain_events(&mut self) -> Vec<AppEvent> {
self.events.drain(..).collect()
}
fn dispatch(&mut self, command: AppCommand) {
match command {
AppCommand::LaunchInstance(id) => self.script_launch(id),
AppCommand::StopInstance(id) => {
self.launch = None;
self.emit(AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
id,
runtime: InstanceRuntime::Idle,
}));
}
AppCommand::AskAgent(text) => self.script_chat(text),
AppCommand::InstallVersion(version) => self.script_install(version),
AppCommand::SetYolo(enabled) => self.emit(AppEvent::Toast(Toast {
id: 0,
kind: ToastKind::Info,
text: if enabled {
"YOLO 已开启(审计照记)"
} else {
"YOLO 已关闭"
}
.to_string(),
remaining_ms: 0,
})),
AppCommand::ListVersions(category) => {
self.emit(AppEvent::Core(CoreEvent::VersionListChanged(
crate::mock_data::mock_versions_for(category),
)));
}
AppCommand::OpenLogSession(id) => {
// P0:换会话 = 换一批假日志行 + 一条诊断结论(日志页诊断卡片可见)
self.emit(AppEvent::Core(CoreEvent::LogLinesChanged(mock_log_lines())));
self.emit(AppEvent::Core(CoreEvent::DiagnosisReady(
crate::mock_data::mock_diagnosis(id),
)));
}
AppCommand::RefreshInstances
| AppCommand::RefreshLogSessions
| AppCommand::StopAgent => {}
}
}
fn tick(&mut self, elapsed: Duration) {
self.step_launch(elapsed);
self.step_chat(elapsed);
}
}
#[cfg(test)]
mod tests {
use super::MockBackend;
use crate::dto::{AgentEvent, AppCommand, AppEvent, Backend, CoreEvent, InstanceRuntime};
#[test]
fn seeds_instances_and_versions() {
let mut backend = MockBackend::new();
let events = backend.drain_events();
assert_eq!(events.len(), 4);
assert!(events
.iter()
.all(|event| matches!(event, AppEvent::Core(_))));
assert!(backend.drain_events().is_empty());
}
#[test]
fn launching_emits_progress_then_running() {
let mut backend = MockBackend::new();
backend.drain_events();
backend.dispatch(AppCommand::LaunchInstance("survival".to_string()));
let first = backend.drain_events();
assert!(matches!(
first[0],
AppEvent::Core(CoreEvent::InstanceRuntimeChanged { .. })
));
backend.tick(std::time::Duration::from_millis(1_600));
let verify = backend.drain_events();
match &verify[0] {
AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
runtime: InstanceRuntime::Busy { stage, .. },
..
}) => assert_eq!(*stage, crate::dto::LaunchStage::Verify),
other => panic!("unexpected event: {other:?}"),
}
backend.tick(std::time::Duration::from_millis(2_000));
let done = backend.drain_events();
assert!(done.iter().any(|event| matches!(
event,
AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
runtime: InstanceRuntime::Running { .. },
..
})
)));
}
#[test]
fn stopping_cancels_the_launch_script() {
let mut backend = MockBackend::new();
backend.drain_events();
backend.dispatch(AppCommand::LaunchInstance("a".to_string()));
backend.drain_events();
backend.dispatch(AppCommand::StopInstance("a".to_string()));
let events = backend.drain_events();
assert!(matches!(
events[0],
AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
runtime: InstanceRuntime::Idle,
..
})
));
// 脚本已取消:再推进时间也不会再产出事件
backend.tick(std::time::Duration::from_millis(5_000));
assert!(backend.drain_events().is_empty());
}
#[test]
fn asking_the_agent_streams_and_finishes() {
let mut backend = MockBackend::new();
backend.drain_events();
backend.dispatch(AppCommand::AskAgent("装哪个版本".to_string()));
let start = backend.drain_events();
assert_eq!(start.len(), 2);
let mut deltas = 0;
for _ in 0..6 {
backend.tick(std::time::Duration::from_millis(400));
let events = backend.drain_events();
deltas += events
.iter()
.filter(|event| matches!(event, AppEvent::Agent(AgentEvent::MessageDelta { .. })))
.count();
}
assert_eq!(deltas, 3);
}
}
+226
View File
@@ -0,0 +1,226 @@
//! P0 假数据:实例 / 版本 / 日志 / 诊断 / 工具调用样例。
//!
//! 只被 [`crate::mock`] 与阶段 B 的 main.rs 使用;真实后端接入后整文件可删。
use crate::dto::{
AncientKind, ChatMessage, ChatRole, Diagnosis, DiagnosisReport, InstanceRuntime,
InstanceSummary, LoaderInfo, LoaderKind, LogLevel, LogLine, LogOutcome, LogSession, SessionId,
ToolCallView, VersionCategory, VersionKind, VersionSummary,
};
/// P0 演示实例:覆盖空闲 / 失败两种磁贴状态(运行中由启动脚本产生)。
pub fn mock_instances() -> Vec<InstanceSummary> {
vec![
InstanceSummary {
id: "survival".to_string(),
name: "生存整合包".to_string(),
mc_version: "1.20.4".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Fabric,
version: "0.16.5".to_string(),
},
last_played: Some("2026-10-29T19:22:00Z".to_string()),
runtime: InstanceRuntime::Idle,
icon: None,
},
InstanceSummary {
id: "vanilla-latest".to_string(),
name: "原版最新".to_string(),
mc_version: "1.21.9".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Vanilla,
version: String::new(),
},
last_played: Some("2026-10-28T21:03:00Z".to_string()),
runtime: InstanceRuntime::Idle,
icon: None,
},
InstanceSummary {
id: "ancient-1.7.10".to_string(),
name: "远古 1.7.10".to_string(),
mc_version: "1.7.10".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Forge,
version: "10.13.4.1614".to_string(),
},
last_played: None,
runtime: InstanceRuntime::Idle,
icon: None,
},
InstanceSummary {
id: "modded-dev".to_string(),
name: "开发测试".to_string(),
mc_version: "1.21.1".to_string(),
loader: LoaderInfo {
kind: LoaderKind::NeoForge,
version: "21.1.72".to_string(),
},
last_played: Some("2026-10-27T12:40:00Z".to_string()),
runtime: InstanceRuntime::Failed {
reason: "exit0 静默失败".to_string(),
},
icon: None,
},
]
}
/// P0 演示版本列表(含远古版三子类,验证分类侧栏与警示徽标)。
pub fn mock_versions() -> Vec<VersionSummary> {
fn row(id: &str, kind: VersionKind, released: &str, installed: bool) -> VersionSummary {
VersionSummary {
id: id.to_string(),
kind,
released: released.to_string(),
installed,
}
}
vec![
row("1.21.9", VersionKind::Release, "2026-09-20", false),
row("1.21.8", VersionKind::Release, "2026-07-11", true),
row("1.20.4", VersionKind::Release, "2023-12-07", true),
row("1.21.9-pre3", VersionKind::Snapshot, "2026-09-01", false),
row(
"rd-132211",
VersionKind::Ancient(AncientKind::Classic),
"2009-05-13",
false,
),
row(
"a1.2.6",
VersionKind::Ancient(AncientKind::OldAlpha),
"2010-08-11",
false,
),
row(
"b1.7.3",
VersionKind::Ancient(AncientKind::OldBeta),
"2011-07-08",
false,
),
]
}
/// 日志会话样例(✓ / ✗ / ⚠ 三种结局各一条)。
pub fn mock_log_sessions() -> Vec<LogSession> {
vec![
LogSession {
id: "s-1029-1922".to_string(),
instance: "生存整合包".to_string(),
when: "10-29 19:22".to_string(),
outcome: LogOutcome::Ok,
duration_secs: 3_780,
},
LogSession {
id: "s-1029-1840".to_string(),
instance: "开发测试".to_string(),
when: "10-29 18:40".to_string(),
outcome: LogOutcome::Crashed,
duration_secs: 62,
},
LogSession {
id: "s-1029-1755".to_string(),
instance: "原版最新".to_string(),
when: "10-29 17:55".to_string(),
outcome: LogOutcome::SilentFail,
duration_secs: 8,
},
]
}
/// 日志行样例(含一条 ERROR 供高亮验证)。
pub fn mock_log_lines() -> Vec<LogLine> {
fn line(text: &str, level: LogLevel) -> LogLine {
LogLine {
text: text.to_string(),
level,
}
}
vec![
line(
"[19:22:01] [main/INFO] Launching Minecraft 1.20.4",
LogLevel::Info,
),
line("[19:22:02] [main/INFO] Loading 42 mods", LogLevel::Info),
line(
"[19:22:03] [Render thread/WARN] GL vendor: unknown",
LogLevel::Warn,
),
line(
"[19:22:05] [Render thread/ERROR] OpenGL 2.1 not supported",
LogLevel::Error,
),
line("[19:22:05] [main/INFO] Stopping!", LogLevel::Info),
]
}
/// 崩溃诊断样例(验证日志页诊断卡片 + 「让 AI 详解」入口)。
pub fn mock_diagnosis(session: SessionId) -> DiagnosisReport {
DiagnosisReport {
session,
diagnosis: Diagnosis {
rule: "gpu.driver_old".to_string(),
title: "显卡驱动过旧(OpenGL 版本不足)".to_string(),
confidence: 92,
hint: "升级显卡驱动,或在启动参数里改用软件渲染".to_string(),
fix: Some("switch_software_renderer".to_string()),
},
}
}
/// 工具调用条目样例(AI 面板静态填充用)。
pub fn mock_tool_message() -> ChatMessage {
ChatMessage {
role: ChatRole::Tool,
text: "读到 128 行,无 ERROR".to_string(),
tool: Some(ToolCallView {
name: "read_game_log".to_string(),
ok: true,
summary: "读到 128 行,无 ERROR".to_string(),
}),
streaming: false,
}
}
/// 分类 → 该分类下的假版本(下载页切换分类时用)。
pub fn mock_versions_for(category: VersionCategory) -> Vec<VersionSummary> {
mock_versions()
.into_iter()
.filter(|version| match category {
VersionCategory::Release => version.kind == VersionKind::Release,
VersionCategory::Snapshot => version.kind == VersionKind::Snapshot,
VersionCategory::Ancient(kind) => version.kind == VersionKind::Ancient(kind),
VersionCategory::Modpack | VersionCategory::Mod => false,
})
.collect()
}
#[cfg(test)]
mod tests {
use crate::dto::{AncientKind, VersionCategory, VersionKind};
use crate::mock_data::{mock_instances, mock_versions_for};
#[test]
fn mock_instances_cover_the_tile_states() {
let instances = mock_instances();
assert_eq!(instances.len(), 4);
assert!(instances.iter().any(|instance| matches!(
instance.runtime,
crate::dto::InstanceRuntime::Failed { .. }
)));
}
#[test]
fn versions_are_filtered_by_category() {
let release = mock_versions_for(VersionCategory::Release);
assert!(release
.iter()
.all(|version| version.kind == VersionKind::Release));
assert_eq!(release.len(), 3);
let classic = mock_versions_for(VersionCategory::Ancient(AncientKind::Classic));
assert_eq!(classic.len(), 1);
assert_eq!(classic[0].id, "rd-132211");
assert!(mock_versions_for(VersionCategory::Mod).is_empty());
}
}
+140
View File
@@ -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"));
}
}
+288
View File
@@ -0,0 +1,288 @@
//! UI 偏好项与持久化键登记(导航折叠、AI 栏折叠、磁贴尺寸、视图、主题三态与外观四件、下载分类)。
//!
//! 偏好不自己写盘:core::setting 的 schema 按 [`keys::ALL`] 登记这些键,
//! app 启动时读一次、变更时写回(跨模块约定见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`)。
//! 落盘实现是 [`crate::settings_bridge`](同一份 `config/launcher.json`,与 `ull config set` 同源),
//! 主题三态 + `appearance.*` 高级键已接(M41);其余 UI 偏好键仍只在内存里(后续 mission 接)。
/// 背景图填充模式(09 §101)。直接复用 core 的枚举:取值与落盘词表只有一处真相。
pub use unlucky_core::setting::appearance::BgFill;
use crate::dto::VersionCategory;
/// 预设强调色板(09 §104)。**必须**与 `ui/pages/settings-appearance.slint` 的 `swatch-colors`
/// 同序同值:Slint 侧只回传索引,十六进制串由这张表给出(一处真相)。
pub const ACCENT_PRESETS: [&str; 8] = [
"#0078D4", "#0F6CBD", "#107C10", "#9D5D00", "#C42B1C", "#8764B8", "#C239B3", "#00B7C3",
];
/// 磁贴尺寸(Q24-3:小 6 列 / 中 4 列 / 大 3 列)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TileSize {
/// 小:6 列,只留图标/名字/启动键
Small,
/// 中:4 列(D16 默认)
#[default]
Medium,
/// 大:3 列,显示完整徽标与最后游玩时间
Large,
}
impl TileSize {
/// 该尺寸对应的网格列数。
pub fn columns(self) -> usize {
match self {
Self::Small => 6,
Self::Medium => 4,
Self::Large => 3,
}
}
/// 设置页/实例页分段控件的文案。
pub fn label(self) -> &'static str {
match self {
Self::Small => "小",
Self::Medium => "中",
Self::Large => "大",
}
}
}
/// 实例页视图模式。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InstanceView {
/// 磁贴网格(默认)
#[default]
Tiles,
/// 紧凑列表
List,
}
/// 主题三态(D17,P0 交付):`appearance.theme_mode ∈ {light, dark, auto}`,出厂 auto。
///
/// auto 由 `theme/tokens.slint` 读 Slint 原生 `Palette.color-scheme` 跟随系统(后端随 OS 热更);
/// light/dark 由 `Theme.theme-mode` 钉死**自绘体系**;std-widgets 内部件(ListView 滚动条 /
/// ProgressIndicator / AboutSlint)目前仍跟随系统色,`Palette.color-scheme` 的钉死留 P1。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ThemeMode {
/// 跟随系统(默认)
#[default]
Auto,
/// 强制浅色
Light,
/// 强制深色
Dark,
}
impl ThemeMode {
/// 落盘取值(`appearance.theme_mode`):`auto` / `light` / `dark`。
pub fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Light => "light",
Self::Dark => "dark",
}
}
/// 从落盘取值解析;未知值按 `auto` 兜底(配置被手改坏也不崩)。
pub fn from_key_value(value: &str) -> Self {
match value {
"light" => Self::Light,
"dark" => Self::Dark,
_ => Self::Auto,
}
}
}
/// 09 §101-104「外观(高级)」四件的取值快照(出厂全部关闭)。
///
/// 与 `unlucky_core::setting::appearance::Appearance` 一一对应,但字段类型是 UI 侧好用的
/// `f32`/`String`;落盘由 [`crate::settings_bridge`] 转成 schema 键。
#[derive(Debug, Clone, PartialEq, Default)]
pub struct AppearancePrefs {
/// 背景图总开关
pub bg_image_enabled: bool,
/// 背景图路径(PNG;空 = 未设置)
pub bg_image: String,
/// 填充模式
pub bg_fill: BgFill,
/// 暗化层强度 0.0~0.9
pub bg_dim: f32,
/// 面板半透明度 0.4~1.0
pub panel_alpha: f32,
/// 玻璃拟态卡片
pub glass: bool,
/// 自定义强调色 `#RRGGBB`;空 = 出厂 [#0078D4]
pub accent_color: String,
}
impl AppearancePrefs {
/// 出厂态:Metro 纯色,四个高级特性全关。
pub fn factory() -> Self {
Self {
bg_image_enabled: false,
bg_image: String::new(),
bg_fill: BgFill::Cover,
bg_dim: 0.35,
panel_alpha: 1.0,
glass: false,
accent_color: String::new(),
}
}
/// 背景图是否真的生效(开关打开且路径非空)。
pub fn bg_active(&self) -> bool {
self.bg_image_enabled && !self.bg_image.trim().is_empty()
}
/// 规范化写入强调色:`#RRGGBB` 才接受(空串 = 恢复出厂)。
///
/// 返回 `false` 表示输入不合法(调用方据此亮设置页的 warn 行,不改现状)。
pub fn set_accent(&mut self, text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() {
self.accent_color.clear();
return true;
}
match unlucky_core::setting::appearance::normalize_hex(trimmed) {
Some(hex) => {
self.accent_color = hex;
true
}
None => false,
}
}
/// 用预设色板第 `index` 个颜色(越界忽略)。
pub fn set_accent_preset(&mut self, index: usize) -> bool {
match ACCENT_PRESETS.get(index) {
Some(hex) => self.set_accent(hex),
None => false,
}
}
}
/// 需要跨会话记忆的 UI 偏好。
///
/// 注意:`Eq` 不成立([`AppearancePrefs`] 带 `f32` 滑条值),用到的地方按 `PartialEq` 比。
#[derive(Debug, Clone, PartialEq)]
pub struct UiPrefs {
/// 左侧导航栏折叠成图标条
pub nav_collapsed: bool,
/// 右侧 AI 栏折叠成 icon 轨
pub ai_collapsed: bool,
/// 磁贴尺寸
pub tile_size: TileSize,
/// 实例页视图
pub instance_view: InstanceView,
/// 主题模式
pub theme: ThemeMode,
/// 外观(高级)四件(09 §101-104)
pub appearance: AppearancePrefs,
/// 下载页选中的分类
pub download_category: VersionCategory,
}
impl Default for UiPrefs {
fn default() -> Self {
Self {
nav_collapsed: false,
ai_collapsed: false,
// D16 默认中磁贴(4 列)
tile_size: TileSize::Medium,
instance_view: InstanceView::Tiles,
theme: ThemeMode::Auto,
appearance: AppearancePrefs::factory(),
download_category: VersionCategory::Release,
}
}
}
/// 持久化键名登记(core::setting schema 按此注册;改名等于迁移,慎动)。
pub mod keys {
/// 导航栏折叠
pub const NAV_COLLAPSED: &str = "ui.nav.collapsed";
/// AI 栏折叠
pub const AI_COLLAPSED: &str = "ui.ai.collapsed";
/// 磁贴尺寸
pub const TILE_SIZE: &str = "ui.instances.tile_size";
/// 实例页视图(磁贴/列表)
pub const INSTANCE_VIEW: &str = "ui.instances.view";
/// 主题三态(D17;与设置页「外观」分组同名,取值见 [`super::ThemeMode::as_str`])
pub const THEME_MODE: &str = "appearance.theme_mode";
/// 背景图开关(09 §101)
pub const BG_IMAGE_ENABLED: &str = "appearance.bg_image_enabled";
/// 背景图路径
pub const BG_IMAGE: &str = "appearance.bg_image";
/// 背景图填充模式
pub const BG_FILL: &str = "appearance.bg_fill";
/// 暗化层强度
pub const BG_DIM: &str = "appearance.bg_dim";
/// 面板半透明度(09 §102)
pub const PANEL_ALPHA: &str = "appearance.panel_alpha";
/// 玻璃拟态卡片(09 §103)
pub const GLASS: &str = "appearance.glass";
/// 自定义强调色(09 §104)
pub const ACCENT_COLOR: &str = "appearance.accent_color";
/// 下载页分类
pub const DOWNLOAD_CATEGORY: &str = "ui.download.category";
/// 本模块登记的全部键(自检/迁移用)。
pub const ALL: [&str; 13] = [
NAV_COLLAPSED,
AI_COLLAPSED,
TILE_SIZE,
INSTANCE_VIEW,
THEME_MODE,
BG_IMAGE_ENABLED,
BG_IMAGE,
BG_FILL,
BG_DIM,
PANEL_ALPHA,
GLASS,
ACCENT_COLOR,
DOWNLOAD_CATEGORY,
];
}
#[cfg(test)]
mod tests {
use super::{keys, InstanceView, ThemeMode, TileSize, UiPrefs};
#[test]
fn defaults_match_d16() {
let prefs = UiPrefs::default();
assert_eq!(prefs.tile_size, TileSize::Medium);
assert_eq!(prefs.tile_size.columns(), 4);
assert_eq!(prefs.instance_view, InstanceView::Tiles);
assert_eq!(prefs.theme, ThemeMode::Auto);
assert_eq!(prefs.theme.as_str(), "auto");
assert!(!prefs.nav_collapsed);
assert!(!prefs.ai_collapsed);
}
#[test]
fn tile_size_columns_follow_q24() {
assert_eq!(TileSize::Small.columns(), 6);
assert_eq!(TileSize::Medium.columns(), 4);
assert_eq!(TileSize::Large.columns(), 3);
}
#[test]
fn theme_mode_round_trips_through_key_values() {
for mode in [ThemeMode::Auto, ThemeMode::Light, ThemeMode::Dark] {
assert_eq!(ThemeMode::from_key_value(mode.as_str()), mode);
}
// 坏值兜底
assert_eq!(ThemeMode::from_key_value("solarized"), ThemeMode::Auto);
}
#[test]
fn pref_keys_are_unique() {
let mut keys = keys::ALL.to_vec();
let total = keys.len();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), total);
}
}
+184
View File
@@ -0,0 +1,184 @@
//! 页面路由与 AI 助手栏形态(09 §1 / D16)。
//!
//! 栈语义抄 HMCL 的导航栈:**顶层页面互相替换**(实例/下载/日志/设置),
//! 子页面(实例详情、AI 独立页)压栈,`back()` 逐层回退。
//! 本模块刻意不依赖 Slint 类型,便于单元测试;UI 侧枚举映射在 main.rs 的转换层做。
use crate::dto::InstanceId;
/// 一级导航项(左侧导航栏高亮单位)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavSection {
/// 实例
Instances,
/// 下载
Download,
/// 日志
Logs,
/// 设置
Settings,
}
/// 页面路由。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Route {
/// G1 实例页(主页)
Instances,
/// G2 下载页
Download,
/// G3 日志页
Logs,
/// G6 设置页
Settings,
/// 实例详情(G4):从磁贴点进来,返回时回到实例页
Detail(InstanceId),
/// AI 助手独立页(D16 双形态之一)
Agent,
}
impl Route {
/// 是否是一级导航页(互相替换,不进栈)。
pub fn is_top_level(&self) -> bool {
matches!(
self,
Self::Instances | Self::Download | Self::Logs | Self::Settings
)
}
/// 该路由高亮哪个导航项(None = 没有导航项高亮,如 AI 独立页)。
pub fn nav(&self) -> Option<NavSection> {
match self {
Self::Instances | Self::Detail(_) => Some(NavSection::Instances),
Self::Download => Some(NavSection::Download),
Self::Logs => Some(NavSection::Logs),
Self::Settings => Some(NavSection::Settings),
Self::Agent => None,
}
}
}
/// AI 栏形态:右侧栏 / 独立全宽页(两形态共享同一份会话状态)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AiPanelMode {
/// 右侧常驻栏
Docked,
/// 拆出的独立全宽页
Page,
}
/// 导航栈。
#[derive(Debug, Clone)]
pub struct Router {
stack: Vec<Route>,
}
impl Default for Router {
fn default() -> Self {
Self::new()
}
}
impl Router {
/// 启动时停在实例页。
pub fn new() -> Self {
Self {
stack: vec![Route::Instances],
}
}
/// 当前页面。
pub fn route(&self) -> &Route {
// 栈不变量:构造后永远非空(navigate/back 都不允许清空)
self.stack.last().expect("router stack is never empty")
}
/// 栈深度(1 表示没有可回退的子页面)。
pub fn depth(&self) -> usize {
self.stack.len()
}
/// 是否能回退。
pub fn can_go_back(&self) -> bool {
self.stack.len() > 1
}
/// 跳转到某路由:一级页替换整栈,子页面压栈(重复目标不重复压栈)。
pub fn navigate(&mut self, route: Route) {
if route.is_top_level() {
self.stack.clear();
self.stack.push(route);
return;
}
if self.stack.last() == Some(&route) {
return;
}
self.stack.push(route);
}
/// 回退一层;已在根页面时返回 `false`。
pub fn back(&mut self) -> bool {
if self.stack.len() <= 1 {
return false;
}
self.stack.pop();
true
}
/// 当前 AI 栏形态(栈顶是 Agent 页即为独立页形态)。
pub fn ai_mode(&self) -> AiPanelMode {
if self.route() == &Route::Agent {
AiPanelMode::Page
} else {
AiPanelMode::Docked
}
}
}
#[cfg(test)]
mod tests {
use super::{AiPanelMode, NavSection, Route, Router};
#[test]
fn top_level_navigation_replaces_the_stack() {
let mut router = Router::new();
router.navigate(Route::Detail("a".into()));
assert_eq!(router.depth(), 2);
router.navigate(Route::Download);
assert_eq!(router.depth(), 1);
assert_eq!(router.route(), &Route::Download);
}
#[test]
fn detail_pushes_and_back_pops_to_instances() {
let mut router = Router::new();
router.navigate(Route::Detail("survival".into()));
assert!(router.can_go_back());
assert_eq!(router.route().nav(), Some(NavSection::Instances));
assert!(router.back());
assert_eq!(router.route(), &Route::Instances);
assert!(!router.back());
}
#[test]
fn agent_page_switches_ai_panel_mode() {
let mut router = Router::new();
assert_eq!(router.ai_mode(), AiPanelMode::Docked);
router.navigate(Route::Agent);
assert_eq!(router.ai_mode(), AiPanelMode::Page);
router.back();
assert_eq!(router.ai_mode(), AiPanelMode::Docked);
}
#[test]
fn navigating_to_the_same_sub_route_does_not_grow_the_stack() {
let mut router = Router::new();
router.navigate(Route::Agent);
router.navigate(Route::Agent);
assert_eq!(router.depth(), 2);
assert_eq!(router.route().nav(), None);
}
}
+294
View File
@@ -0,0 +1,294 @@
//! 设置持久化桥:把 UI 偏好落到 `core::setting` 的 schema 文件里,并支持**外部热改热切**。
//!
//! 三条纪律(`DevDocs/SubAgentsTalk/app-DTO通道约定.md` §6 / AGENTS §6):
//!
//! 1. **不另立配置文件**:读写的就是 `ull config set/get` 与 AI `set_config` 用的那一份
//! `<data root>/config/launcher.json`(`core::platform::data_dir` 解析数据根、`layout` 给路径);
//! 2. **不自己拼 JSON**:一律过 `core::setting::store::SettingStore`(schema 校验 + 原子写);
//! 3. **不 import 领域类型**:跨边界只看「键名 + 取值」,领域模型留在 core。
//!
//! 热切:文件 `mtime` 变了就整份重读(AI / CLI 改完下一拍即生效),见 [`SettingsBridge::reload_if_changed`]。
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use unlucky_core::platform::data_dir::{DataDirRequest, DataMode, Resolver};
use unlucky_core::platform::layout::LauncherLayout;
use unlucky_core::platform::paths::Platform;
use unlucky_core::setting::appearance::Appearance;
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
use crate::prefs::{AppearancePrefs, ThemeMode};
/// 数据根配置键(决定 `launcher.json` 落在哪个数据目录;与 CLI 的 `DATA_ROOT_KEYS` 同源同义)。
const DATA_ROOT_KEYS: [&str; 4] = [
"core.data_mode",
"core.data_custom_root",
"core.launcher_home",
"core.game_home",
];
/// 设置文件桥(一个进程一份)。
#[derive(Debug)]
pub struct SettingsBridge {
/// `config/launcher.json`;`None` = 数据根解析不出来(内存态运行,不落盘)
path: Option<PathBuf>,
store: Option<SettingStore>,
/// 上次读/写之后的文件 `mtime`(热切探测用)
last_mtime: Option<SystemTime>,
}
impl SettingsBridge {
/// 按三模式数据根打开真实配置文件(启动时调用一次)。
#[must_use]
pub fn open() -> Self {
match resolve_config_path() {
Some(path) => Self::with_path(path),
None => Self::memory_only(),
}
}
/// 指定路径打开(测试与特殊数据根用)。
#[must_use]
pub fn with_path(path: impl Into<PathBuf>) -> Self {
let path = path.into();
let store = SettingStore::load(&path, launcher_schema());
let last_mtime = mtime(&path);
Self {
path: Some(path),
store: Some(store),
last_mtime,
}
}
/// 不落盘(数据根解析失败 / 单测)。
#[must_use]
pub fn memory_only() -> Self {
Self {
path: None,
store: None,
last_mtime: None,
}
}
/// 配置文件路径(诊断/日志用)。
#[must_use]
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
/// 当前生效的外观配置与主题模式。
#[must_use]
pub fn load(&self) -> (AppearancePrefs, ThemeMode) {
let Some(store) = &self.store else {
return (AppearancePrefs::factory(), ThemeMode::Auto);
};
let appearance = Appearance::from_store(store);
let theme = ThemeMode::from_key_value(&appearance.theme_mode);
(
AppearancePrefs {
bg_image_enabled: appearance.bg_image_enabled,
bg_image: appearance.bg_image,
bg_fill: appearance.bg_fill,
bg_dim: appearance.bg_dim as f32,
panel_alpha: appearance.panel_alpha as f32,
glass: appearance.glass,
accent_color: appearance.accent_color,
},
theme,
)
}
/// 落盘(只写出现过的键;失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。
pub fn persist(&mut self, appearance: &AppearancePrefs, theme: ThemeMode) {
let Some(store) = self.store.as_mut() else {
return;
};
let mut snapshot = Appearance::from_store(store);
snapshot.theme_mode = theme.as_str().to_owned();
snapshot.bg_image_enabled = appearance.bg_image_enabled;
snapshot.bg_image = appearance.bg_image.clone();
snapshot.bg_fill = appearance.bg_fill;
snapshot.bg_dim = f64::from(appearance.bg_dim);
snapshot.panel_alpha = f64::from(appearance.panel_alpha);
snapshot.glass = appearance.glass;
snapshot.accent_color = appearance.accent_color.clone();
if snapshot.write(store).is_err() {
return;
}
let _ = store.save();
if let Some(path) = self.path() {
self.last_mtime = mtime(path);
}
}
/// 文件被外部(AI / `ull config set`)改过就重读;返回新的生效值。
///
/// `mtime` 采样不到(某些文件系统精度/权限)时返回 `None`,退化为「只在启动时读一次」。
pub fn reload_if_changed(&mut self) -> Option<(AppearancePrefs, ThemeMode)> {
let path = self.path.clone()?;
let current = mtime(&path);
if current.is_none() || current == self.last_mtime {
return None;
}
self.last_mtime = current;
let store = self.store.as_mut()?;
store.reload();
Some(self.load())
}
}
/// 数据根解析 → `config/launcher.json`。
///
/// 与 CLI 同一套语义:先读便携目录那份 `launcher.json` 的 `core.*`(数据根决定键),
/// 再交 `Resolver` 解析(含 `redirect.json` 跟随)。任何一步失败都退回内存态。
fn resolve_config_path() -> Option<PathBuf> {
let exe_dir = std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(Path::to_path_buf));
let exe_dir = exe_dir?;
let request = request_from_portable(&exe_dir);
let resolver = Resolver::new(Platform::current(), Some(exe_dir.clone()));
match resolver.resolve_default(&request) {
Ok(resolved) => Some(resolved.layout().launcher_config()),
// 连 exe 目录都解析不了(极端情况):仍旧用便携落点,至少设置能存下来
Err(_) => Some(LauncherLayout::new(exe_dir.join(".ULL")).launcher_config()),
}
}
/// 便携目录那份 `launcher.json` → 三模式请求(只读 `core.*` 四个键)。
fn request_from_portable(exe_dir: &Path) -> DataDirRequest {
let mut request = DataDirRequest::default();
let path = portable_config_path(exe_dir);
if !path.is_file() {
return request;
}
let store = SettingStore::load(&path, launcher_schema());
let text = |key: &str| store.doc().effective_str(key).map(str::to_string);
if let Some(mode) = text(DATA_ROOT_KEYS[0]).as_deref().and_then(DataMode::parse) {
request.mode = mode;
}
if let Some(root) = text(DATA_ROOT_KEYS[1]) {
request.custom_root = Some(PathBuf::from(root));
}
if let Some(home) = text(DATA_ROOT_KEYS[2]) {
request.launcher_home = Some(PathBuf::from(home));
}
if let Some(home) = text(DATA_ROOT_KEYS[3]) {
request.game_home = Some(PathBuf::from(home));
}
request
}
/// `<exe 目录>/.ULL/config/launcher.json`。
fn portable_config_path(exe_dir: &Path) -> PathBuf {
LauncherLayout::new(exe_dir.join(".ULL")).launcher_config()
}
fn mtime(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path).ok()?.modified().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use unlucky_core::setting::appearance::BgFill;
fn temp_config(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-bridge-{label}-{}-{}",
std::process::id(),
line!()
));
std::fs::create_dir_all(dir.join("config")).expect("临时目录");
let path = dir.join("config/launcher.json");
let _ = std::fs::remove_file(&path);
path
}
#[test]
fn factory_defaults_when_file_missing() {
let path = temp_config("factory");
let bridge = SettingsBridge::with_path(&path);
let (appearance, theme) = bridge.load();
assert_eq!(appearance, AppearancePrefs::factory());
assert_eq!(theme, ThemeMode::Auto);
assert!(!path.exists(), "只读不写:缺文件不该被创建");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn persist_then_reload_round_trips() {
let path = temp_config("roundtrip");
let mut bridge = SettingsBridge::with_path(&path);
let mut appearance = AppearancePrefs::factory();
appearance.bg_image_enabled = true;
appearance.bg_image = "D:/wall/p.png".to_owned();
appearance.bg_fill = BgFill::Contain;
appearance.bg_dim = 0.5;
appearance.panel_alpha = 0.8;
appearance.glass = true;
assert!(appearance.set_accent("#0f6cbd"));
bridge.persist(&appearance, ThemeMode::Dark);
let reloaded = SettingsBridge::with_path(&path);
let (value, theme) = reloaded.load();
assert_eq!(value, appearance);
assert_eq!(theme, ThemeMode::Dark);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn external_edit_is_picked_up_by_reload() {
let path = temp_config("hotreload");
let mut bridge = SettingsBridge::with_path(&path);
// 先落一份(建立 mtime 基线)
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light);
assert!(bridge.reload_if_changed().is_none(), "自己写完不该报告变更");
// 模拟外部写入(AI / `ull config set`):改主题 + 打开背景图
let mut external = SettingsBridge::with_path(&path);
let mut appearance = AppearancePrefs::factory();
appearance.bg_image_enabled = true;
appearance.bg_image = "D:/wall/x.png".to_owned();
external.persist(&appearance, ThemeMode::Dark);
// mtime 精度:有的文件系统 1s 粒度,这里显式等到时间戳变化
for _ in 0..50 {
if let Some((value, theme)) = bridge.reload_if_changed() {
assert_eq!(theme, ThemeMode::Dark);
assert!(value.bg_image_enabled);
assert_eq!(value.bg_image, "D:/wall/x.png");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
return;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
panic!("外部修改没有被 mtime 热切捕获");
}
#[test]
fn accent_preset_and_validation() {
let mut appearance = AppearancePrefs::factory();
assert_eq!(appearance.accent_color, "");
assert!(appearance.set_accent_preset(1));
assert_eq!(appearance.accent_color, "#0F6CBD");
assert!(!appearance.set_accent("not-a-color"), "非法输入不改现状");
assert_eq!(appearance.accent_color, "#0F6CBD");
assert!(appearance.set_accent(""), "空串恢复出厂");
assert_eq!(appearance.accent_color, "");
assert!(!appearance.set_accent_preset(99));
}
#[test]
fn bg_active_needs_both_switch_and_path() {
let mut appearance = AppearancePrefs::factory();
appearance.bg_image = "D:/wall/p.png".to_owned();
assert!(!appearance.bg_active(), "开关没开不算生效");
appearance.bg_image_enabled = true;
assert!(appearance.bg_active());
appearance.bg_image.clear();
assert!(!appearance.bg_active(), "路径空也不算生效");
}
}
+304
View File
@@ -0,0 +1,304 @@
//! UI 状态与 reducer:Slint 只负责渲染,所有状态变更都汇聚到 [`AppState::apply`]。
//!
//! 数据流向:mock/host 后端 → [`crate::dto::AppEvent`] 队列 → `apply` → 状态字段 → Slint 属性。
//! 偏好项在 [`crate::prefs`] 里登记了持久化键名,core::setting 的 schema 落地后按这些键读写。
use crate::dialogs::DialogQueue;
use crate::dto::{
AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, DiagnosisReport, InstanceRuntime,
InstanceSummary, LogLine, LogSession, Progress, ToolCallView, VersionSummary,
};
use crate::prefs::UiPrefs;
use crate::router::{AiPanelMode, Route, Router};
use crate::toast::ToastCenter;
/// AI 会话状态(右侧栏与独立页共享同一份,切换不丢)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AiSession {
/// 会话消息(user / assistant / tool / system)
pub messages: Vec<ChatMessage>,
/// 是否有流式消息在跑
pub streaming: bool,
/// YOLO 开关(跳过确认,审计照记)
pub yolo: bool,
/// 输入框草稿(切形态不丢)
pub draft: String,
/// 端点标签(显示在栏顶)
pub endpoint_label: String,
}
impl Default for AiSession {
fn default() -> Self {
Self::new()
}
}
impl AiSession {
/// 默认端点标签(P0 是本地 llama.cpp;P1 由设置页决定)。
pub fn new() -> Self {
Self {
messages: Vec::new(),
streaming: false,
yolo: false,
draft: String::new(),
endpoint_label: "llama.cpp · 本地".to_string(),
}
}
/// 流式收敛:清掉全部流式标记。
pub fn finish_stream(&mut self) {
self.streaming = false;
for message in &mut self.messages {
message.streaming = false;
}
}
}
/// 磁贴网格单元:位置由 [`grid_positions`] 算好,Slint 只消费 row/col。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TileCell<'a> {
/// 实例数据
pub instance: &'a InstanceSummary,
/// 网格行号(0 起)
pub row: usize,
/// 网格列号(0 起)
pub col: usize,
}
/// 纯函数:把 n 个磁贴摊到 `columns` 列的网格上,返回 `(row, col)`。
///
/// 特意放在 Rust 侧:Slint DSL 里做整数除法会引入隐式浮点,且这段逻辑可以单测。
pub fn grid_positions(count: usize, columns: usize) -> Vec<(usize, usize)> {
let columns = columns.max(1);
(0..count)
.map(|index| (index / columns, index % columns))
.collect()
}
/// 应用全局状态。
#[derive(Debug)]
pub struct AppState {
/// UI 偏好(记忆项)
pub prefs: UiPrefs,
/// 导航栈
pub router: Router,
/// 实例列表快照
pub instances: Vec<InstanceSummary>,
/// 当前分类的版本列表
pub versions: Vec<VersionSummary>,
/// 日志会话列表
pub log_sessions: Vec<LogSession>,
/// 当前会话日志行
pub log_lines: Vec<LogLine>,
/// 最近一次诊断结论
pub diagnosis: Option<DiagnosisReport>,
/// 选中的日志会话
pub selected_session: Option<String>,
/// AI 会话状态
pub ai: AiSession,
/// 对话框队列(一次一条)
pub dialogs: DialogQueue,
/// Toast 中心
pub toasts: ToastCenter,
/// 实例页搜索词
pub instance_query: String,
/// 下载页「仅已装」过滤
pub installed_only: bool,
/// 下载页版本号筛选词
pub download_query: String,
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
impl AppState {
/// 空状态(数据由后端事件填充,P0 是 mock)。
pub fn new() -> Self {
Self {
prefs: UiPrefs::default(),
router: Router::new(),
instances: Vec::new(),
versions: Vec::new(),
log_sessions: Vec::new(),
log_lines: Vec::new(),
diagnosis: None,
selected_session: None,
ai: AiSession::new(),
dialogs: DialogQueue::new(),
toasts: ToastCenter::new(),
instance_query: String::new(),
installed_only: false,
download_query: String::new(),
}
}
/// 当前页面。
pub fn route(&self) -> &Route {
self.router.route()
}
/// 跳转(一级页替换、子页面压栈;去 AI 独立页时自动展开 AI 栏偏好)。
pub fn navigate(&mut self, route: Route) {
if route == Route::Agent {
self.prefs.ai_collapsed = false;
}
self.router.navigate(route);
}
/// 回退一层。
pub fn back(&mut self) -> bool {
self.router.back()
}
/// AI 栏当前形态(跟着路由走)。
pub fn ai_mode(&self) -> AiPanelMode {
self.router.ai_mode()
}
/// 应用一条事件(唯一状态变更入口)。
pub fn apply(&mut self, event: AppEvent) {
match event {
AppEvent::Core(core) => self.apply_core(core),
AppEvent::Agent(agent) => self.apply_agent(agent),
AppEvent::DialogRequested(request) => {
self.dialogs.push_request(request);
}
AppEvent::DialogClosed(id) => {
self.dialogs.close(id);
}
AppEvent::Toast(toast) => {
self.toasts.push_request(toast);
}
}
}
/// core 事件。
pub fn apply_core(&mut self, event: CoreEvent) {
match event {
CoreEvent::InstancesChanged(instances) => self.instances = instances,
CoreEvent::InstanceRuntimeChanged { id, runtime } => {
if let Some(instance) = self.instances.iter_mut().find(|item| item.id == id) {
instance.runtime = runtime;
}
}
CoreEvent::VersionListChanged(versions) => self.versions = versions,
CoreEvent::LogSessionsChanged(sessions) => self.log_sessions = sessions,
CoreEvent::LogLinesChanged(lines) => self.log_lines = lines,
CoreEvent::DiagnosisReady(report) => self.diagnosis = Some(report),
}
}
/// agent 事件。
pub fn apply_agent(&mut self, event: AgentEvent) {
match event {
AgentEvent::MessageAppended(message) => {
// 流式占位到达即视为「一轮回复进行中」:UI 的发送键/停止键据此切换
if message.streaming {
self.ai.streaming = true;
}
self.ai.messages.push(message);
}
AgentEvent::MessageDelta { index, delta } => {
if let Some(message) = self.ai.messages.get_mut(index) {
message.text.push_str(&delta);
}
}
AgentEvent::MessageFinished { index } => {
if let Some(message) = self.ai.messages.get_mut(index) {
message.streaming = false;
}
self.ai.streaming = false;
}
AgentEvent::ToolCallStarted { name, summary } => {
self.ai.streaming = true;
self.ai.messages.push(ChatMessage {
role: ChatRole::Tool,
text: summary.clone(),
tool: Some(ToolCallView {
name,
ok: false,
summary,
}),
streaming: true,
});
}
AgentEvent::ToolCallFinished { name, ok, summary } => {
let target =
self.ai.messages.iter_mut().rev().find(|message| {
message.tool.as_ref().is_some_and(|tool| tool.name == name)
});
if let Some(message) = target {
message.text.clone_from(&summary);
message.streaming = false;
if let Some(tool) = &mut message.tool {
tool.ok = ok;
tool.summary = summary;
}
}
}
}
}
/// 按搜索词过滤后的磁贴网格(顺序稳定 = 实例列表顺序)。
pub fn tiles(&self) -> Vec<TileCell<'_>> {
let query = self.instance_query.trim().to_lowercase();
let filtered: Vec<&InstanceSummary> = self
.instances
.iter()
.filter(|instance| query.is_empty() || instance.name.to_lowercase().contains(&query))
.collect();
let positions = grid_positions(filtered.len(), self.prefs.tile_size.columns());
filtered
.into_iter()
.zip(positions)
.map(|(instance, (row, col))| TileCell { instance, row, col })
.collect()
}
/// 运行中的实例(实例页顶部迷你状态条)。
pub fn running(&self) -> Vec<&InstanceSummary> {
self.instances
.iter()
.filter(|instance| {
matches!(
instance.runtime,
InstanceRuntime::Running { .. } | InstanceRuntime::Busy { .. }
)
})
.collect()
}
/// 覆盖某实例的启动进度(mock / 真实后端共用同一入口)。
pub fn set_progress(&mut self, id: &str, progress: Progress) {
if let Some(instance) = self.instances.iter_mut().find(|item| item.id == id) {
if let InstanceRuntime::Busy {
progress: current, ..
} = &mut instance.runtime
{
*current = progress;
}
}
}
/// 折叠/展开导航栏。
pub fn toggle_nav(&mut self) {
self.prefs.nav_collapsed = !self.prefs.nav_collapsed;
}
/// 折叠/展开 AI 栏;若正处在独立页形态则先收回侧栏。
pub fn toggle_ai(&mut self) {
if self.route() == &Route::Agent {
self.router.back();
self.prefs.ai_collapsed = false;
return;
}
self.prefs.ai_collapsed = !self.prefs.ai_collapsed;
}
}
#[cfg(test)]
#[path = "state_tests.rs"]
mod tests;
+262
View File
@@ -0,0 +1,262 @@
//! state.rs 的单元测试(拆文件保持单文件 ≤400 行)。
use super::{grid_positions, AiSession, AppState};
use crate::dialogs::{DialogKind, DialogRequest, DialogSource};
use crate::dto::{
AgentEvent, AppEvent, ChatMessage, ChatRole, CoreEvent, Diagnosis, DiagnosisReport,
InstanceRuntime, InstanceSummary, LoaderInfo, LoaderKind,
};
use crate::prefs::TileSize;
use crate::router::Route;
use crate::toast::Toast;
use crate::toast::ToastKind;
fn instance(id: &str, name: &str) -> InstanceSummary {
InstanceSummary {
id: id.to_string(),
name: name.to_string(),
mc_version: "1.20.4".to_string(),
loader: LoaderInfo {
kind: LoaderKind::Fabric,
version: "0.16.5".to_string(),
},
last_played: None,
runtime: InstanceRuntime::Idle,
icon: None,
}
}
#[test]
fn grid_positions_wrap_by_column_count() {
assert_eq!(grid_positions(0, 4), vec![]);
assert_eq!(grid_positions(3, 2), vec![(0, 0), (0, 1), (1, 0)]);
// columns == 0 时按 1 列兜底,不允许除零
assert_eq!(grid_positions(2, 0), vec![(0, 0), (1, 0)]);
}
#[test]
fn tiles_follow_tile_size_and_search() {
let mut state = AppState::new();
state.instances = vec![
instance("a", "生存"),
instance("b", "创造"),
instance("c", "Demo"),
];
assert_eq!(state.tiles().len(), 3);
assert_eq!((state.tiles()[2].row, state.tiles()[2].col), (0, 2));
state.prefs.tile_size = TileSize::Large;
assert_eq!((state.tiles()[2].row, state.tiles()[2].col), (0, 2));
state.prefs.tile_size = TileSize::Small;
assert_eq!(state.tiles().len(), 3);
state.instance_query = "demo".to_string();
let tiles = state.tiles();
assert_eq!(tiles.len(), 1);
assert_eq!(tiles[0].instance.id, "c");
assert_eq!((tiles[0].row, tiles[0].col), (0, 0));
}
#[test]
fn tiles_reflow_when_tile_size_shrinks() {
let mut state = AppState::new();
state.instances = (0..8)
.map(|i| instance(&format!("i{i}"), &format!("实例{i}")))
.collect();
state.prefs.tile_size = TileSize::Medium;
let medium_rows = state.tiles().iter().map(|cell| cell.row).max();
state.prefs.tile_size = TileSize::Small;
let small_rows = state.tiles().iter().map(|cell| cell.row).max();
assert_eq!(medium_rows, Some(1)); // 8 / 4 列 = 2 行
assert_eq!(small_rows, Some(1)); // 8 / 6 列 = 2 行
}
#[test]
fn runtime_events_update_the_matching_instance() {
let mut state = AppState::new();
state.instances = vec![instance("a", "生存"), instance("b", "创造")];
state.apply(AppEvent::Core(CoreEvent::InstanceRuntimeChanged {
id: "b".to_string(),
runtime: InstanceRuntime::Running { pid: 42 },
}));
assert_eq!(state.instances[0].runtime, InstanceRuntime::Idle);
assert_eq!(
state.instances[1].runtime,
InstanceRuntime::Running { pid: 42 }
);
assert_eq!(state.running().len(), 1);
assert_eq!(state.running()[0].id, "b");
}
#[test]
fn progress_events_only_touch_busy_instances() {
let mut state = AppState::new();
state.instances = vec![instance("a", "生存")];
state.instances[0].runtime = InstanceRuntime::Busy {
stage: crate::dto::LaunchStage::Download,
progress: crate::dto::Progress {
done: 0,
total: 100,
},
};
state.set_progress(
"a",
crate::dto::Progress {
done: 50,
total: 100,
},
);
match &state.instances[0].runtime {
InstanceRuntime::Busy { progress, .. } => {
assert!((progress.ratio() - 0.5).abs() < f32::EPSILON);
}
other => panic!("unexpected runtime: {other:?}"),
}
// 不存在的 id / 非 Busy 状态不改变任何东西
state.set_progress("missing", crate::dto::Progress { done: 1, total: 1 });
assert_eq!(state.instances.len(), 1);
}
#[test]
fn ai_stream_deltas_accumulate_and_finish() {
let mut state = AppState::new();
state.apply(AppEvent::Agent(AgentEvent::MessageAppended(
ChatMessage::user("怎么装 1.20.4"),
)));
state.apply(AppEvent::Agent(AgentEvent::MessageAppended(
ChatMessage::assistant_stream(),
)));
assert_eq!(state.ai.messages.len(), 2);
assert!(state.ai.streaming);
assert_eq!(state.ai.messages[1].role, ChatRole::Assistant);
state.apply(AppEvent::Agent(AgentEvent::MessageDelta {
index: 1,
delta: "先".to_string(),
}));
state.apply(AppEvent::Agent(AgentEvent::MessageDelta {
index: 1,
delta: "下载".to_string(),
}));
state.apply(AppEvent::Agent(AgentEvent::MessageFinished { index: 1 }));
assert_eq!(state.ai.messages[1].text, "先下载");
assert!(!state.ai.messages[1].streaming);
assert!(!state.ai.streaming);
}
#[test]
fn ai_session_finish_stream_clears_every_flag() {
let mut session = AiSession::new();
session.messages.push(ChatMessage::user("hi"));
session.messages.push(ChatMessage::assistant_stream());
session.finish_stream();
assert!(session.messages.iter().all(|message| !message.streaming));
assert!(!session.streaming);
}
#[test]
fn tool_call_finish_updates_the_last_matching_message() {
let mut state = AppState::new();
state.apply(AppEvent::Agent(AgentEvent::ToolCallStarted {
name: "get_config".to_string(),
summary: "读取设置".to_string(),
}));
state.apply(AppEvent::Agent(AgentEvent::ToolCallFinished {
name: "get_config".to_string(),
ok: true,
summary: "读取到 3 项".to_string(),
}));
let tool = &state.ai.messages[0];
assert_eq!(tool.role, ChatRole::Tool);
assert_eq!(tool.text, "读取到 3 项");
assert!(!tool.streaming);
assert!(tool.tool.as_ref().is_some_and(|call| call.ok));
}
#[test]
fn dialogs_and_toasts_flow_through_apply() {
let mut state = AppState::new();
state.apply(AppEvent::DialogRequested(DialogRequest {
id: 42,
source: DialogSource::UserAction,
title: "AI 想修改内存".to_string(),
body: "改为 8G".to_string(),
kind: DialogKind::Confirm {
primary: "允许".to_string(),
secondary: "拒绝".to_string(),
},
}));
state.apply(AppEvent::Toast(Toast {
id: 0,
kind: ToastKind::Success,
text: "下载完成".to_string(),
remaining_ms: 0,
}));
assert_eq!(state.dialogs.pending_len(), 0);
// id 归生产者:队列原样保留,DialogClosed(42) 才能命中同一条
assert_eq!(state.dialogs.current().map(|dialog| dialog.id), Some(42));
assert_eq!(state.toasts.len(), 1);
state.apply(AppEvent::DialogClosed(42));
assert!(state.dialogs.is_empty());
}
#[test]
fn navigate_to_agent_page_expands_the_ai_panel() {
let mut state = AppState::new();
state.prefs.ai_collapsed = true;
state.navigate(Route::Agent);
assert_eq!(state.route(), &Route::Agent);
assert_eq!(state.ai_mode(), crate::router::AiPanelMode::Page);
assert!(!state.prefs.ai_collapsed);
}
#[test]
fn toggling_ai_from_the_page_form_docks_it_again() {
let mut state = AppState::new();
state.navigate(Route::Agent);
state.toggle_ai();
assert_eq!(state.route(), &Route::Instances);
assert_eq!(state.ai_mode(), crate::router::AiPanelMode::Docked);
assert!(!state.prefs.ai_collapsed);
state.toggle_ai();
assert!(state.prefs.ai_collapsed);
}
#[test]
fn toggle_nav_flips_the_pref() {
let mut state = AppState::new();
state.toggle_nav();
assert!(state.prefs.nav_collapsed);
state.toggle_nav();
assert!(!state.prefs.nav_collapsed);
}
#[test]
fn diagnosis_report_lands_on_state() {
let mut state = AppState::new();
state.apply(AppEvent::Core(CoreEvent::DiagnosisReady(DiagnosisReport {
session: "s1".to_string(),
diagnosis: Diagnosis {
rule: "gpu.driver_old".to_string(),
title: "显卡驱动过旧".to_string(),
confidence: 92,
hint: "升级驱动".to_string(),
fix: Some("open_driver_page".to_string()),
},
})));
assert!(state.diagnosis.is_some());
}
+152
View File
@@ -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::prefs::{AppearancePrefs, BgFill, ThemeMode};
use crate::toast::ToastKind;
use crate::ui_bind::{
BackgroundKey, Inner, CONFIG_POLL_TICKS, SYSTEM_THEME_POLL_TICKS, TILE_REGENERATE_STEP,
};
impl Inner {
pub(crate) fn poll_settings(&self) {
if !self.ticks.get().is_multiple_of(CONFIG_POLL_TICKS) {
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 = !self.system_theme_probed.get()
|| (auto && !backend_known && self.ticks.get().is_multiple_of(SYSTEM_THEME_POLL_TICKS));
if !due {
return;
}
self.system_theme_probed.set(true);
let value = match unlucky_core::platform::theme_probe::probe() {
unlucky_core::platform::theme_probe::SystemTheme::Light => 1,
unlucky_core::platform::theme_probe::SystemTheme::Dark => 2,
unlucky_core::platform::theme_probe::SystemTheme::Unknown => 0,
};
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`],避免拖动窗口时反复重算)。
fn tile_viewport(&self, fill: BgFill) -> (u32, u32) {
if fill != BgFill::Tile {
return (0, 0);
}
let (width, height) = self.window_physical_size();
(
width / TILE_REGENERATE_STEP * TILE_REGENERATE_STEP,
height / TILE_REGENERATE_STEP * TILE_REGENERATE_STEP,
)
}
/// 窗口物理像素尺寸(取不到时 (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);
}
}
+152
View File
@@ -0,0 +1,152 @@
//! Toast 中心:右下角非阻塞提示(下载完成 / 复制成功 / 后台任务结论)。
//!
//! 与模态对话框队列([`crate::dialogs`])分开:Toast 不阻塞、不排序、可叠加,
//! 只做「先进先出 + 上限裁剪 + TTL 到期」。UI 侧由 Rust 定时器每帧调用 [`ToastCenter::expire`]。
/// Toast 标识。
pub type ToastId = u64;
/// Toast 语义色。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToastKind {
/// 一般信息
Info,
/// 成功
Success,
/// 警告
Warn,
/// 错误
Error,
}
/// 一条 Toast;`remaining_ms` 由 [`ToastCenter::expire`] 递减。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Toast {
/// 队列内唯一 id(由 [`ToastCenter::push_request`] 重新编号)
pub id: ToastId,
/// 语义色
pub kind: ToastKind,
/// 提示文案
pub text: String,
/// 剩余存活毫秒(0 = 用默认 TTL,入队时补)
pub remaining_ms: u64,
}
/// Toast 中心:先进先出,超出上限丢最旧的。
#[derive(Debug)]
pub struct ToastCenter {
items: Vec<Toast>,
next_id: ToastId,
ttl_ms: u64,
}
impl Default for ToastCenter {
fn default() -> Self {
Self::new()
}
}
impl ToastCenter {
/// 默认 4s 后自动消失。
pub fn new() -> Self {
Self {
items: Vec::new(),
next_id: 0,
ttl_ms: 4_000,
}
}
/// 同时最多显示的条数。
pub const MAX_VISIBLE: usize = 4;
/// 入队一条 Toast。
pub fn push(&mut self, kind: ToastKind, text: impl Into<String>) -> ToastId {
self.push_request(Toast {
id: 0,
kind,
text: text.into(),
remaining_ms: 0,
})
}
/// 入队一条已组装好的 Toast(事件通道用);id 重新编号,`remaining_ms == 0` 视为用默认 TTL。
pub fn push_request(&mut self, mut toast: Toast) -> ToastId {
self.next_id += 1;
toast.id = self.next_id;
if toast.remaining_ms == 0 {
toast.remaining_ms = self.ttl_ms;
}
let id = toast.id;
self.items.push(toast);
while self.items.len() > Self::MAX_VISIBLE {
self.items.remove(0);
}
id
}
/// 当前显示列表(UI 模型来源)。
pub fn items(&self) -> &[Toast] {
&self.items
}
/// 手动关闭(点了关闭按钮)。
pub fn dismiss(&mut self, id: ToastId) -> bool {
if let Some(at) = self.items.iter().position(|toast| toast.id == id) {
self.items.remove(at);
true
} else {
false
}
}
/// 时间推进:移除到期 Toast。
pub fn expire(&mut self, elapsed_ms: u64) {
for toast in &mut self.items {
toast.remaining_ms = toast.remaining_ms.saturating_sub(elapsed_ms);
}
self.items.retain(|toast| toast.remaining_ms > 0);
}
/// 当前条数。
pub fn len(&self) -> usize {
self.items.len()
}
/// 是否没有 Toast。
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::{ToastCenter, ToastKind};
#[test]
fn toasts_expire_and_cap_the_stack() {
let mut center = ToastCenter::new();
center.push(ToastKind::Info, "a");
center.push(ToastKind::Info, "b");
assert_eq!(center.len(), 2);
for i in 0..10 {
center.push(ToastKind::Success, format!("t{i}"));
}
assert_eq!(center.len(), ToastCenter::MAX_VISIBLE);
center.expire(3_000);
assert_eq!(center.len(), ToastCenter::MAX_VISIBLE);
center.expire(1_001);
assert!(center.is_empty());
}
#[test]
fn dismiss_removes_one_toast() {
let mut center = ToastCenter::new();
let first = center.push(ToastKind::Info, "a");
center.push(ToastKind::Warn, "b");
assert!(center.dismiss(first));
assert!(!center.dismiss(first));
assert_eq!(center.len(), 1);
}
}
+333
View File
@@ -0,0 +1,333 @@
//! 绑定层(核心):窗口/状态/后端的持有与同步;回调接线在 [`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::dto::{AppCommand, Backend, InstanceId};
use crate::mock::MockBackend;
use crate::prefs::BgFill;
use crate::settings_bridge::SettingsBridge;
use crate::state::AppState;
use crate::{MainWindow, SettingSection};
/// 定时器节拍:33ms ≈ 30fps(磁贴进度环 / 流式文本 / Toast TTL 的刷新下限)。
const TICK_MS: u64 = 33;
/// 配置热切轮询间隔(拍数):约 1s 一次 `mtime` 比对,AI/CLI 改完下一拍即生效。
pub(crate) const CONFIG_POLL_TICKS: u64 = 30;
/// 系统主题兜底轮询间隔(拍数):只在 `auto` 且后端不认识系统主题时启动(约 3s)。
pub(crate) const SYSTEM_THEME_POLL_TICKS: u64 = 90;
/// tile 模式重建画布的窗口尺寸粒度(像素):避免拖动窗口时反复重算平铺画布。
pub(crate) const TILE_REGENERATE_STEP: u32 = 128;
/// 应用外壳:持有窗口、状态与后端,把三者缝在一起。
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>,
}
/// 背景图加工输入(值相同就不重新解码)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct BackgroundKey {
pub(crate) path: String,
pub(crate) fill: BgFill,
/// tile 用的窗口尺寸(按 [`TILE_REGENERATE_STEP`] 量化;cover/contain 恒为 (0, 0))
pub(crate) viewport: (u32, u32),
}
impl AppUi {
/// 建状态 + 假后端、读配置、接全部回调、灌初始事件并做第一次同步。
pub fn new(ui: MainWindow) -> Self {
// 先读一次落盘配置:主题三态 + 外观四件必须在首帧前就位(否则会闪一下出厂外观)
let bridge = SettingsBridge::open();
let (appearance, theme) = bridge.load();
let mut state = AppState::new();
state.prefs.appearance = appearance;
state.prefs.theme = theme;
let inner = Rc::new(Inner {
ui,
state: RefCell::new(state),
backend: RefCell::new(Box::new(MockBackend::new())),
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));
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(crate::router::Route::Settings);
self.inner.sync();
self.inner
.ui
.set_settings_section(SettingSection::Appearance);
slint::platform::update_timers_and_animations();
}
/// 渲染当前场景并存成 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,
)));
// 先走几拍:把 mock 事件(实例/版本/日志)与 33ms 节拍的效果灌进去
for _ in 0..4 {
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);
}
}
pub(crate) fn model<T: Clone + 'static>(items: Vec<T>) -> ModelRc<T> {
ModelRc::new(VecModel::from(items))
}
/// 停止实例的指令构造(阶段 C 的磁贴「停止」键用;P0 只保留 API)。
#[allow(dead_code)]
pub fn stop_instance(id: &InstanceId) -> AppCommand {
AppCommand::StopInstance(id.clone())
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::send_agent_message;
use crate::dto::{Backend, ChatRole};
use crate::mock::MockBackend;
use crate::state::AppState;
/// 集成测试(callbacks→state→mock 全链路):发一次提问后
/// ①用户消息恰出现一次(无本地回显重复)②所有 assistant 气泡收敛(无幽灵气泡)。
///
/// 分层单测漏掉这条正是 review P1-1 的根因,故这里用真实 mock 后端跑完整事件流。
#[test]
fn send_path_has_no_duplicate_user_message_and_no_ghost_stream() {
let mut state = AppState::new();
let mut backend = MockBackend::new();
// 先灌入种子事件(实例/版本/日志快照)
for event in backend.drain_events() {
state.apply(event);
}
send_agent_message(&mut state, &mut backend, "装哪个版本");
fn user_count(state: &AppState) -> usize {
state
.ai
.messages
.iter()
.filter(|message| message.role == ChatRole::User)
.count()
}
assert_eq!(
user_count(&state),
1,
"用户消息必须恰好一条(本地回显会造成两条)"
);
assert!(state.ai.streaming);
// 推进到流式收敛(mock 300ms/片 + 工具调用 + 收尾消息)
for _ in 0..12 {
backend.tick(Duration::from_millis(400));
for event in backend.drain_events() {
state.apply(event);
}
}
assert_eq!(user_count(&state), 1, "事件回流后用户消息仍应只有一条");
assert!(
state.ai.messages.iter().all(|message| !message.streaming),
"所有气泡必须收敛(幽灵气泡会永久 streaming)"
);
assert!(!state.ai.streaming);
assert!(
state
.ai
.messages
.iter()
.any(|message| message.role == ChatRole::Assistant && !message.text.is_empty()),
"增量应累积到 assistant 正文"
);
assert_eq!(
state
.ai
.messages
.iter()
.filter(|message| message.role == ChatRole::Assistant)
.count(),
2,
"一轮对话 = 流式回复 + 收尾回复"
);
}
}