Files
Unlucky-Launcher/crates/presenter/src/dto/settings.rs
T
AskaEth f159529936 feat(presenter): 新建 unlucky-presenter,把 host/状态/DTO/设置桥从 app 迁出(M85 第 1 块)
D26(iced 重写)的关键一次性动作:UI 框架无关的宿主层独立成 crate,
Slint 版(crates/app)与 iced 版(crates/app-iced)共用,Slint 退役后不再重写一遍。

- 新建 crates/presenter(package `unlucky-presenter` / lib `unlucky_presenter`),
  workspace 登记 + crate 文档头写清「这层为什么存在」、对外表面、UI 侧允许/禁止、
  iced 接线骨架、以及哪些东西留在了 crates/app 及为什么。
- `git mv`(保留文件历史)迁入:host/(25 文件)、dto/(11 文件)、state.rs(+tests)、
  prefs.rs、settings_bridge.rs、toast.rs、router.rs、dialogs.rs、wizard.rs、
  mock.rs、mock_data.rs、backend.rs。模块路径与公开 API 名称不变(`host::install` 等),
  内部实现零改动。
- 新增 `theme`:把原本混在 ui_bind/theme_runtime 里的**纯判据**(CONFIG_POLL_TICKS /
  SYSTEM_THEME_POLL_TICKS / TILE_REGENERATE_STEP / BackgroundKey / tile_viewport /
  system_theme_value / 两个 poll 判据)抽出来,附 5 个单测;app 侧改成调用它们。
- crates/app 改为消费者:删掉迁走的文件,`crate::host|state|dto|prefs|settings_bridge|
  toast|router|dialogs|wizard|mock|mock_data|backend` 全部改写为 `unlucky_presenter::*`
  (64 处),backend.rs 的 config_path/build_backend 提为 pub。
- crates/app-iced 只登记 unlucky-presenter 依赖,本 mission 不接线。
- prefs.rs 的 2 处 slint 文档引用改写;`grep -rn slint crates/presenter` 现在无输出。
2026-11-02 09:29:31 +08:00

221 lines
6.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 设置页 DTO:设置分组的通用行模型。
//!
//! 「行」是 UI 与 `core::setting` schema 之间的唯一形状:一行 = 一个键 + 一种控件 +
//! 当前值。写回走 [`crate::dto::AppCommand::SetSetting`],由 host 用 `SettingStore::set`
//! 落盘(不自己拼 JSON、不另立配置文件,`AGENTS §6`)。
use serde::{Deserialize, Serialize};
/// 控件类型(决定设置页画什么)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SettingControl {
/// 开关
Toggle,
/// 分段单选(`options` + `selected`)
Choice,
/// 单行文本(`value_text`)
Text,
/// 只读展示(当前路径/当前版本这类)
ReadOnly,
/// 动作按钮(`action` 给回调 id)
Action,
}
/// 一行设置。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SettingRowView {
/// schema 键(`download.concurrency`)
pub key: String,
/// 行标题
pub label: String,
/// 说明/当前值附注
pub description: String,
/// 控件
pub control: SettingControl,
/// 开关值
pub flag: bool,
/// 单选序号
pub selected: i32,
/// 单选项文案
pub options: Vec<String>,
/// 文本值
pub value_text: String,
/// 动作 id(`detect_region` 这类)
pub action: String,
}
impl SettingRowView {
/// 开关行。
pub fn toggle(key: &str, label: &str, description: &str, value: bool) -> Self {
Self {
key: key.to_string(),
label: label.to_string(),
description: description.to_string(),
control: SettingControl::Toggle,
flag: value,
selected: 0,
options: Vec::new(),
value_text: String::new(),
action: String::new(),
}
}
/// 单选行。
pub fn choice(
key: &str,
label: &str,
description: &str,
options: &[&str],
selected: i32,
) -> Self {
Self {
key: key.to_string(),
label: label.to_string(),
description: description.to_string(),
control: SettingControl::Choice,
flag: false,
selected,
options: options.iter().map(|item| (*item).to_string()).collect(),
value_text: String::new(),
action: String::new(),
}
}
/// 文本行。
pub fn text(key: &str, label: &str, description: &str, value: &str) -> Self {
Self {
key: key.to_string(),
label: label.to_string(),
description: description.to_string(),
control: SettingControl::Text,
flag: false,
selected: 0,
options: Vec::new(),
value_text: value.to_string(),
action: String::new(),
}
}
/// 只读行。
pub fn read_only(label: &str, value: &str) -> Self {
Self {
key: String::new(),
label: label.to_string(),
description: value.to_string(),
control: SettingControl::ReadOnly,
flag: false,
selected: 0,
options: Vec::new(),
value_text: value.to_string(),
action: String::new(),
}
}
/// 动作行。
pub fn action(key: &str, label: &str, description: &str, action: &str) -> Self {
Self {
key: key.to_string(),
label: label.to_string(),
description: description.to_string(),
control: SettingControl::Action,
flag: false,
selected: 0,
options: Vec::new(),
value_text: String::new(),
action: action.to_string(),
}
}
}
/// 一个设置分组(设置页右栏的一段)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SettingGroupView {
/// 分组 id(与设置页子导航的 `SettingSection` 同名:download / java / data / ai / mcp)
pub id: String,
/// 分组标题
pub title: String,
/// 行
pub rows: Vec<SettingRowView>,
}
impl SettingGroupView {
/// 建组。
pub fn new(id: &str, title: &str) -> Self {
Self {
id: id.to_string(),
title: title.to_string(),
rows: Vec::new(),
}
}
/// 追加一行(链式)。
pub fn with(mut self, row: SettingRowView) -> Self {
self.rows.push(row);
self
}
}
/// 在分组快照里找一个键的当前值(设置页回填用)。
pub fn find_row<'a>(groups: &'a [SettingGroupView], key: &str) -> Option<&'a SettingRowView> {
groups
.iter()
.flat_map(|group| group.rows.iter())
.find(|row| row.key == key)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rows_carry_exactly_one_control_kind() {
let toggle = SettingRowView::toggle("agent.mcp.enabled", "MCP", "", true);
assert_eq!(toggle.control, SettingControl::Toggle);
assert!(toggle.flag);
let choice = SettingRowView::choice("download.region", "地区", "", &["auto", "cn"], 1);
assert_eq!(choice.options, vec!["auto", "cn"]);
assert_eq!(choice.selected, 1);
let action = SettingRowView::action("java.action", "检测", "", "detect_region");
assert_eq!(action.action, "detect_region");
}
#[test]
fn find_row_scans_all_groups() {
let groups = vec![
SettingGroupView::new("download", "下载").with(SettingRowView::text(
"download.proxy_url",
"代理",
"",
"",
)),
SettingGroupView::new("mcp", "MCP").with(SettingRowView::toggle(
"agent.mcp.enabled",
"开关",
"",
false,
)),
];
assert_eq!(
find_row(&groups, "agent.mcp.enabled").map(|row| row.label.as_str()),
Some("开关")
);
assert!(find_row(&groups, "nope").is_none());
}
#[test]
fn rows_are_serde_round_trip_safe() {
let group = SettingGroupView::new("download", "下载").with(SettingRowView::choice(
"download.concurrency",
"并发",
"",
&["4", "8", "16"],
1,
));
let text = serde_json::to_string(&group).expect("序列化");
let back: SettingGroupView = serde_json::from_str(&text).expect("反序列化");
assert_eq!(back, group);
}
}