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:
1 parent
00f132d8ce
commit
f159529936
69 files changed
+441
-129
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user