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` 现在无输出。
This commit is contained in:
AskaEth committed 2026-11-02 09:29:31 +08:00
1 parent 00f132d8ce
commit f159529936
69 files changed
+441 -129

No files matched your search

+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);
}
}