diff --git a/crates/app-iced/src/app/message.rs b/crates/app-iced/src/app/message.rs index f9ec644..1517712 100644 --- a/crates/app-iced/src/app/message.rs +++ b/crates/app-iced/src/app/message.rs @@ -53,6 +53,12 @@ pub enum Message { /// M90 起产品界面用 `pages` 里的真 `NavigationRail`,这条变体是给**外壳回退路径** /// (`Route::Agent`)留的——`shell` 不在本 mission 的 scope 里,不能删它的消息。 Select(NavItem), + /// 收起 / 展开左侧导航轨(**M300**)。 + /// + /// 为什么不复用 [`Message::Select`]:那个变体的唯一语义是「导航项 → 路由」(`route_of` 是 + /// 全仓唯一的转换点)。把「折叠」塞进去会让 `route_of` 多出一条不是路由的分支,而它的 + /// 清白正是两条导航入口共用同一套页面语义的前提。落点在 `app/update/nav.rs`。 + ToggleNav, /// 按下标题栏 → 拖窗。 DragWindow, /// 边缘热区:开始缩放(方向由坐标决定)。 diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index 85708d1..e2d6a97 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -171,6 +171,13 @@ impl App { // M229:设置页「主题」行的选中态来自设置组自有状态(`language_appearance::State::theme`), // 唯一写者是 `App::set_theme_mode`;这里把启动时读到的档位灌进去,首帧才不会画错档。 app.settings.language_appearance.theme = theme_mode; + // M300:导航轨的折叠档(`ui.nav.collapsed`)。**截图通道不读用户配置**(D30:同一份代码 + // 在两台机器上要出同一张图):出图一律展开态,折叠态那两张图由 `ULL_NAV_COLLAPSED` 显式灌。 + app.state.prefs.nav_collapsed = if app.options.screenshot.is_some() { + crate::app::update::boot_env::env_nav_collapsed() + } else { + app.bridge.load_nav_collapsed() + }; // 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是色板。 app.rebuild_ui(); // M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。 diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index ca9d02d..bd2d3ee 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -60,6 +60,8 @@ mod download; mod instances; mod motion_sync; mod page_messages; +// M300:左侧导航轨折叠 / 展开(`Message::ToggleNav` 的落点;`update.rs` 只留 3 行转发) +mod nav; mod settings; mod update_action; @@ -169,6 +171,8 @@ impl App { self.refresh_view(); Task::none() } + // M300:左侧导航轨折叠 / 展开(正文在 `update/nav.rs`:`update.rs` 贴 400 行硬线) + Message::ToggleNav => self.on_toggle_nav(), Message::DragWindow => match self.window { Some(id) => window::drag(id), None => Task::none(), diff --git a/crates/app-iced/src/app/update/boot_env.rs b/crates/app-iced/src/app/update/boot_env.rs index a075c27..a5744b6 100644 --- a/crates/app-iced/src/app/update/boot_env.rs +++ b/crates/app-iced/src/app/update/boot_env.rs @@ -126,6 +126,22 @@ pub(super) fn env_motion_fixture_at() -> Option { .filter(|beats| *beats > 0) } +/// `ULL_NAV_COLLAPSED=1`:出图通道直接把左轨钉在**折叠档**(M300)。 +/// +/// ## 为什么出图必须靠它,而不是靠配置文件 +/// +/// 截图通道**不读用户配置**(D30:同一份代码在两台机器上要出同一张图,见 `construct.rs` 里 +/// 外观/语言/主题那几处同样的处理)。而折叠态与展开态是两张必须同页同主题的交付图 ⇒ 需要一条 +/// 只影响这一次进程的显式开关,与 `ULL_APPEARANCE_*` / `ULL_TOAST_FIXTURE` 同族。 +/// +/// 产品路径读不到它:只有 `--screenshot` 时 `construct.rs` 才会问这个函数。 +pub(in crate::app) fn env_nav_collapsed() -> bool { + matches!( + std::env::var("ULL_NAV_COLLAPSED").ok().as_deref(), + Some("1" | "true" | "yes") + ) +} + /// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。 /// /// ## 为什么这一拍只能「先记、后落」 diff --git a/crates/app-iced/src/app/update/motion_sync.rs b/crates/app-iced/src/app/update/motion_sync.rs index c98a708..27e24f3 100644 --- a/crates/app-iced/src/app/update/motion_sync.rs +++ b/crates/app-iced/src/app/update/motion_sync.rs @@ -44,6 +44,17 @@ impl App { if self.window.is_none() && !self.has_screenshot() { return; } + + // M300:左轨的折叠档**对表**(幂等)。泵的目标出厂是「展开」,而用户可能上次就是折叠着 + // 退出的(`ui.nav.collapsed` 落盘)。不对表的话,启动后第一次**展开**会被 + // `observe_nav` 的「目标没变」分支判成空操作 ⇒ 跳变没有动画(T37:数值对,但没人调它)。 + // + // 判据是「没有左轨轨道在跑」而不是「只做一次」:动画期间它不许动目标(否则每一拍都会把 + // 过渡掐掉),而轨道结束后它自然变成空操作(目标已经等于真实档位)。 + if !motion::nav_running() { + motion::snap_nav(!self.state.prefs.nav_collapsed); + } + let route = self.state.route(); let screen = screen_key(route); let detail = match route { diff --git a/crates/app-iced/src/app/update/nav.rs b/crates/app-iced/src/app/update/nav.rs new file mode 100644 index 0000000..0241d23 --- /dev/null +++ b/crates/app-iced/src/app/update/nav.rs @@ -0,0 +1,257 @@ +//! 左侧导航轨折叠 / 展开的**唯一落点**(M300 `NAVCOLLAPSE`)。 +//! +//! 为什么单独一个文件:`update.rs` 385 行贴着 400 行硬线(只剩十几行余量),而这条分支要做 +//! 三件事(翻 prefs、起跑宽度轨道、落盘)。这正是 M94/M223 拆 `update/<名>.rs` 的同一种手法: +//! `update.rs` 只留一支 3 行的转发。 +//! +//! ## 这条分支为什么不置 `view_dirty` +//! +//! 轨宽与标签可见性都在 **`view()` 里逐帧采样**(`app/view/rail.rs` 读 +//! `motion::nav(open)` + `prefs.nav_collapsed`),页面模型(`pages::Model`)里**没有**这一项 +//! ——所以折叠不需要重建模型,只需要**帧**。帧由动效帧泵自己续(`Pump::nav` 那条轨道到期即清), +//! 于是「折叠 → 动画 → 静息」整段结束后 `view_dirty` 仍是 `false`、`needs_frames()` 也回到 +//! `false`,不会退化成「靠每帧重建模型来画动画」(M298 r1 的返工点)。 +//! +//! ## 落盘 +//! +//! `ui.nav.collapsed` 是 `ui.*` 一族里**第一条真落盘**的偏好键(M300 打通:core schema 登记 → +//! `SettingsBridge::persist_nav_collapsed` 写 → 启动 `construct.rs` 读回)。写失败**不打断** +//! 折叠这个动作:配置写不进去不该让界面卡住,失败原因进 `launcher.log`。 + +use iced::Task; + +use crate::app::components::motion; +use crate::app::diag::diag; + +use super::*; + +impl App { + /// 收起 / 展开左侧导航轨(`Message::ToggleNav` 的落点)。 + pub(super) fn on_toggle_nav(&mut self) -> Task { + // ① 起跑前先把泵的目标对到**真实档位**(幂等,且只在没有轨道在跑时做): + // - 泵的目标出厂是「展开」,而用户可能上次就是折叠着退出的(`ui.nav.collapsed` 落盘), + // 不对表的话这次展开会走 `observe_nav` 的「目标没变」早退 ⇒ 硬跳(T37 的形态); + // - 有轨道在跑时**不许**对表:那会把正在跑的过渡当场掐掉(半路反向就变成跳变)。 + if !motion::nav_running() { + motion::snap_nav(!self.state.prefs.nav_collapsed); + } + self.state.toggle_nav(); + let collapsed = self.state.prefs.nav_collapsed; + + // ① 起跑宽度过渡。**必须在 update 侧**:iced 的订阅表在 `update()` 里求值、早于 `view()`, + // 在 `view` 里插轨道当拍订阅不上(`components/motion` 的模块文档有源码行号)。 + motion::observe_nav(!collapsed); + // ② 落盘(`ui.nav.collapsed`):不落盘就是「重启回到展开」的假折叠。 + let saved = self.bridge.persist_nav_collapsed(collapsed); + diag!( + "[nav] collapsed={collapsed} persist={}", + if saved { "saved" } else { "skipped" } + ); + + // ③ 不置 `view_dirty`,理由见模块文档「这条分支为什么不置」。 + Task::none() + } +} + +#[cfg(test)] +mod tests { + //! `Message::ToggleNav` 的接线判据(M300)。 + //! + //! 三条都和「任务单里的验收句」一一对应:①点击后**宽度真的变**(不是只改 state) + //! ②动画结束后**不烧帧**(`needs_frames()` / `view_dirty` 都回 false)③落盘**真读盘** + //! (换一个桥对象/重造 App 读回来)。 + //! + //! 时间轴一律用 `motion::advance_by(FRAME_MS)` 连拍(T37),不等真实时钟(T38:两条腿读 + //! 同一个 `Pump::now_ms`,所以这里推多少就是多少)。 + + use super::*; + use crate::app::components::motion::{self, FRAME_MS}; + use crate::cli::Options; + use crate::theme::Families; + use std::path::PathBuf; + use unlucky_design::color::SchemeFlavor; + use unlucky_design::motion::MotionScale; + use unlucky_presenter::mock::MockBackend; + use unlucky_presenter::settings_bridge::SettingsBridge; + + const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + /// 一份临时 `launcher.json`(父目录建好,文件删掉 = 出厂态)。 + fn temp_config(label: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "ull-nav-{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 + } + + fn app_with(bridge: SettingsBridge, scale: MotionScale) -> App { + motion::reset(); + motion::configure_for_test(scale, None); + let mut app = App::with_presenter( + Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(MockBackend::new()), + bridge, + ); + // 有窗口才谈得上动效(`sync_motion` 在没有窗口时直接返回)。 + app.window = Some(iced::window::Id::unique()); + // 先建一次模型:构造期 `hydrate_*` 会置 `view_dirty`(它们改了状态但还没人重建), + // 不清掉的话「折叠不置脏」这条断言测的是启动那一下。 + app.refresh_view(); + assert!(!app.view_dirty, "前提:构造 + 一次重建之后 dirty 已清"); + // 三样会**独立**点亮 `App::needs_frames()` 的东西清掉,否则「动画跑完就退订」这条 + // 断言测的是它们、不是动效(首屏 Java 扫描在构造这一拍就会亮)。 + app.watch_until = None; + app.settings.java.scanning = None; + app + } + + /// 推一拍时间轴并采样这一帧的轨宽。 + fn step(app: &App) -> f32 { + motion::advance_by(FRAME_MS); + app.nav_rail_width() + } + + /// **点击 → 宽度真的变 → 落盘真的写进文件 → 再点回去 → 空闲。** + /// + /// 抓得住:①只改 `prefs` 不画(宽度采样停在 88);②过度写成「一帧到底」(中间帧列表里没有 + /// 严格递减的中间值);③没落盘(重新构造的桥读回 false);④动画之后还在要帧(最后两条断言)。 + /// 抓不住:真机上 250/200ms 的手感与中间帧的像素(像素那半由 `DevDocs/artifacts/m300/` 两张图 + + /// 本文件的数值列表共同举证)。 + #[test] + fn toggling_narrows_the_rail_for_real_and_survives_a_restart() { + let path = temp_config("toggle"); + let mut app = app_with(SettingsBridge::with_path(&path), MotionScale::STANDARD); + assert_eq!(app.nav_rail_width(), 88.0, "前提:出厂展开"); + + let _ = app.update(Message::ToggleNav); + assert!(app.state.prefs.nav_collapsed, "点击必须翻掉 prefs"); + assert!( + app.watch_until.is_none(), + "折叠不该打开观察窗(帧是**动效**要来的)" + ); + assert!( + app.needs_frames(), + "同一拍的订阅表必须看得见左轨轨道(iced 先算订阅表后建视图)" + ); + + // 连拍(两次 advance 之间没有任何别的调用 = 「状态已切换但没有事件」那一段) + let mut widths = vec![app.nav_rail_width()]; + let mut guard = 0; + while motion::needs_frames() { + let width = step(&app); + // `--nocapture` 时这行就是报告里那张「中间帧」数值表(像素图拍不到中间帧: + // 截图通道是 `MotionScale::NONE`,只有端值)。 + println!("[nav] frame {guard}: width = {width:.2}"); + widths.push(width); + guard += 1; + assert!(guard < 200, "左轨轨道没跑完"); + } + assert_eq!(widths.first().copied(), Some(88.0), "起跑那一帧还在展开档"); + assert_eq!(widths.last().copied(), Some(64.0), "终值必须**严格**是 64"); + assert!( + widths.len() >= 5, + "必须有中间帧(实测 {} 帧:{:?})", + widths.len(), + widths + ); + for pair in widths.windows(2) { + assert!(pair[1] <= pair[0], "折叠必须单调不增:{:?}", pair); + } + assert!( + widths.windows(2).any(|pair| pair[1] < pair[0]), + "折叠必须真的改宽度,而不是只改 state" + ); + + // 落盘:**换一个桥对象**读同一份文件(= 重启后的读法) + assert!( + SettingsBridge::with_path(&path).load_nav_collapsed(), + "折叠必须落盘,否则重启就回到展开" + ); + assert!(!app.view_dirty, "折叠不该把页面模型置脏"); + assert!(!app.needs_frames(), "动画跑完必须退回 1 Hz 心跳"); + + // 再点回来:反向同一条判据(只断言端点与空闲) + let _ = app.update(Message::ToggleNav); + assert!(!app.state.prefs.nav_collapsed); + let mut guard = 0; + while motion::needs_frames() { + step(&app); + guard += 1; + assert!(guard < 200, "左轨轨道没跑完(展开)"); + } + assert_eq!(app.nav_rail_width(), 88.0); + assert!(!app.view_dirty, "展开也不该置脏"); + assert!( + !SettingsBridge::with_path(&path).load_nav_collapsed(), + "展开也要落盘" + ); + + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); + } + + /// **重启读回折叠档,且第一次展开仍然有动画。** + /// + /// 这条守两件事:①`construct.rs` 真的在启动时读了 `ui.nav.collapsed`(不读的话第一段红); + /// ②`sync_motion` 的 `snap_nav` 对表真的存在(没有它,`observe_nav(true)` 会走「目标没变」 + /// 早退 ⇒ 展开是硬跳,第二段红)。T37 说得很直白:数值对不等于产品路径会调到它。 + #[test] + fn a_restart_boots_collapsed_and_the_first_expand_still_animates() { + let path = temp_config("restart"); + let mut writer = SettingsBridge::with_path(&path); + assert!( + writer.persist_nav_collapsed(true), + "先造一份「上次折叠着退出」" + ); + + // 「重启」:新 App + 新桥,读同一份文件 + let mut app = app_with(SettingsBridge::with_path(&path), MotionScale::STANDARD); + assert!( + app.state.prefs.nav_collapsed, + "启动必须读回折叠档(`construct.rs` 的接线)" + ); + assert_eq!(app.nav_rail_width(), 64.0, "首帧就该是折叠档"); + assert!(!motion::needs_frames(), "静息折叠态不该要帧"); + + // 第一次展开:必须有中间帧(不是硬跳) + let _ = app.update(Message::ToggleNav); + let first = step(&app); + assert!( + first > 64.0 && first < 88.0, + "启动折叠之后第一次展开必须走过渡,实测第一帧 {first}" + ); + let mut guard = 0; + while motion::needs_frames() { + step(&app); + guard += 1; + assert!(guard < 200); + } + assert_eq!(app.nav_rail_width(), 88.0); + + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); + } + + /// **关掉动效(`MotionScale::NONE`):一步到位,且一帧都不订阅。** + /// + /// 抓得住:把 `observe_nav` 的 `is_off()` 分支删掉(插一条 0 时长轨道 ⇒ 进度还是终值,但 + /// `needs_frames()` 会有一拍为真 ⇒ 第一条红)。这也顺带守住「空闲不烧帧」:截图通道/CPU 档下 + /// 折叠不该把帧循环拉起来。 + #[test] + fn with_motion_off_the_rail_jumps_and_asks_for_no_frames() { + let mut app = app_with(SettingsBridge::memory_only(), MotionScale::NONE); + let _ = app.update(Message::ToggleNav); + assert_eq!(app.nav_rail_width(), 64.0, "NONE 下必须**逐位**落在终值"); + assert!(!motion::needs_frames(), "NONE 下一帧都不许要"); + assert!(!app.view_dirty); + } +} diff --git a/crates/app-iced/src/app/view/rail.rs b/crates/app-iced/src/app/view/rail.rs index e67285d..521769d 100644 --- a/crates/app-iced/src/app/view/rail.rs +++ b/crates/app-iced/src/app/view/rail.rs @@ -1,8 +1,9 @@ -//! 导航轨底部**账户头像位**的状态换算(M114)。 +//! 导航轨底部**账户头像位**的状态换算(M114)+ 轨自身的折叠档(M300)。 //! //! 为什么单独一个文件:`app/view.rs` 贴着 400 行硬线,而这块逻辑与页面树无关—— -//! 它只回答「轨底那个头像位现在该显示成未登录,还是某个账户」。 +//! 它只回答「轨底那个头像位现在该显示成未登录,还是某个账户」,以及「这一帧轨多宽」。 +use crate::app::components::motion; use crate::shell; use super::super::state::App; @@ -16,12 +17,17 @@ const RAIL_COMPACT_HEIGHT: f32 = 720.0; impl App { /// 导航轨底部头像位的状态(M114):未登录 / 已登录两态要一眼能分。 + /// + /// M300:这里同时是轨的折叠档与过渡进度的**唯一构造点**——`shell::NavRail`(不在 scope 内) + /// 只负责把它转交给 `nav::rail`,几何全部来自 [`shell::RailAccount::nav_width`]。 pub(super) fn rail_account(&self) -> shell::RailAccount { let compact = self.height < RAIL_COMPACT_HEIGHT; + let (collapsed, progress) = self.nav_state(); match self.state.active_account() { Some(account) => { shell::RailAccount::signed_in(account.monogram(), account.name.clone()) .compact(compact) + .nav(collapsed, progress) } // M106:未登录标签跟着**生效语言**走(早期钉死 `Lang::En` 的写法已删)。 None => shell::RailAccount::signed_out( @@ -29,7 +35,26 @@ impl App { .catalog() .text(unlucky_i18n::Key::AccountRailSignedOut), ) - .compact(compact), + .compact(compact) + .nav(collapsed, progress), } } + + /// 轨的折叠档 + **这一帧**的过渡进度(0 = 折叠 64,1 = 展开 88)。 + /// + /// 进度是**采样**来的,不是自己算的:`motion::nav(open)` 在「帧泵的目标与 `open` 不符」时 + /// 直接给终值,于是截图 harness / 单测直接摆状态也不会把界面卡在中间那一帧。 + fn nav_state(&self) -> (bool, f32) { + let collapsed = self.state.prefs.nav_collapsed; + (collapsed, motion::nav(!collapsed)) + } + + /// 这一帧导航轨的宽度(展开 88 ⇄ 折叠 64,中间帧按动效进度插值)。 + /// + /// **给用例用**:它读的就是 `view()` 递给 `nav::rail` 的那个值(同一个 `RailAccount`), + /// 所以「点击后宽度真的变了」这条断言不会与视图各算一套而假绿。 + #[cfg(test)] + pub(in crate::app) fn nav_rail_width(&self) -> f32 { + self.rail_account().nav_width(self.ui().metrics()) + } } diff --git a/crates/app-iced/src/components/motion/mod.rs b/crates/app-iced/src/components/motion/mod.rs index f623248..431153e 100644 --- a/crates/app-iced/src/components/motion/mod.rs +++ b/crates/app-iced/src/components/motion/mod.rs @@ -266,6 +266,50 @@ pub fn rail(open: bool) -> f32 { }) } +/// 左轨(导航轨)的折叠档变了(M300):`Message::ToggleNav` 的落点调它,**在 update 侧**。 +/// +/// 返回 `true` = 起跑了过渡(帧基准由本模块自己重置)。与 [`observe_rail`] 是**两条并列的 +/// 轨道**:左轨折叠不会改右栗的目标(理由见 `pump/rail.rs` 模块文档)。 +pub fn observe_nav(open: bool) -> bool { + let started = PUMP.with(|pump| pump.borrow_mut().observe_nav(open)); + if started { + LAST_FRAME.with(|last| last.set(Some(Instant::now()))); + } + started +} + +/// 瞬时落档(启动时按落盘的折叠档对表):**不**起轨道,进度严格等于终值。 +/// +/// 只在「没有左轨轨道在跑」时调用(`app/update/motion_sync.rs` 的判据就是 +/// [`nav_running`]),否则会把正在跑的过渡当场掐掉。 +pub fn snap_nav(open: bool) { + let was_running = PUMP.with(|pump| pump.borrow_mut().snap_nav(open)); + if was_running { + LAST_FRAME.with(|last| last.set(Some(Instant::now()))); + } +} + +/// 左轨的宽度轨道是否在跑(`motion_sync` 的「该不该对表」判据)。 +pub fn nav_running() -> bool { + PUMP.with(|pump| pump.borrow().nav_animating()) +} + +/// 这一帧的左轨折叠进度(0 = 折叠 64,1 = 展开 88)。**只采样、无副作用**。 +/// +/// 目标不符时给终值,理由同 [`rail`]:宁可无动效,也不许把界面卡在中间那一帧。 +pub fn nav(open: bool) -> f32 { + PUMP.with(|pump| { + let pump = pump.borrow(); + if pump.nav_open() == open { + pump.nav_progress() + } else if open { + 1.0 + } else { + 0.0 + } + }) +} + /// 把位移套到树上。 /// /// **总是**套这一层(位移为 `(0,0)` 时只是 `padding = 0`):它是树的**根**,而 iced 的 diff --git a/crates/app-iced/src/components/motion/pump/mod.rs b/crates/app-iced/src/components/motion/pump/mod.rs index 933831f..ba6d09a 100644 --- a/crates/app-iced/src/components/motion/pump/mod.rs +++ b/crates/app-iced/src/components/motion/pump/mod.rs @@ -4,13 +4,14 @@ //! 「帧间隔上限」「结束后必须清空」这两条守卫是**算术性质**,用本地 `Pump` 直接摆时间轴就能判死, //! 不必起窗口、不必等真实时间(那两条最容易写成「看起来在跑」的假绿)。 //! -//! ## 两条轨道(M256):页面的两腿 + 模态的进出,各走各的时间轴(互不替代) +//! ## 四条轨道(M256 / M257 / M300):页面两腿 + 模态进出 + 两条宽度轨,各走各的时间轴(互不替代) //! //! | 轨道 | 触发 | 结束 | //! |---|---|---| //! | 页面 | 内容标识变了([`Pump::observe`]) | 两腿里长的那条走完 | //! | 模态 | 弹窗开 / 关([`Pump::observe_modal`] / [`Pump::begin_modal_close`]) | 进场转 [`Modal::Open`](**不再要帧**)、退场转 [`Modal::Hidden`] | -//! | 折叠(M257) | 右栗档位变([`Pump::observe_rail`],拆出档走 [`Pump::snap_rail`]) | 主进度走完一条腿(`medium1` / `short4`) | +//! | 右栗折叠(M257) | 右栗档位变([`Pump::observe_rail`],拆出档走 [`Pump::snap_rail`]) | 主进度走完一条腿(`medium1` / `short4`) | +//! | 左轨折叠(M300) | 导航轨折叠档变([`Pump::observe_nav`],启动按落盘档对表走 [`Pump::snap_nav`]) | 同上(**同一张** [`super::transition::rail_spec`]) | //! //! D31:时长/缓动是**表**([`super::transition`]),页面只许按表取——它只能说「我要换页了」。 @@ -68,6 +69,10 @@ pub struct Pump { rail: Option, /// 右栗的目标档(`true` = 展开)。没有轨道时进度就是它的终值。 rail_open: bool, + /// 左轨(导航轨)折叠轨道(M300):与右栗**并列**的另一条宽度轨(`64 ⇄ 88`)。 + nav: Option, + /// 左轨的目标档(`true` = 展开 88)。出厂展开;启动时按落盘档由 [`Pump::snap_nav`] 对表。 + nav_open: bool, /// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。 close_pending: bool, screen: Option, @@ -90,6 +95,8 @@ impl Pump { modal: Modal::Hidden, rail: None, rail_open: false, + nav: None, + nav_open: true, close_pending: false, screen: None, rank: None, @@ -116,6 +123,10 @@ impl Pump { // 定档时右栗按出厂档(收起)复位:定档之后的第一帧画的就是 48,不会先画一次展开态。 self.rail = None; self.rail_open = false; + // 左轨出厂是**展开**(`UiPrefs::nav_collapsed` 的出厂值 false)。落盘过折叠档的会话由 + // `app/update/motion_sync.rs` 在定档之后 `snap_nav` 对表(那里的判据是「没有轨在跑」)。 + self.nav = None; + self.nav_open = true; self.close_pending = false; if scale.is_off() { // 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。 @@ -143,9 +154,15 @@ impl Pump { pub fn is_animating(&self) -> bool { self.page.is_some() || self.rail.is_some() + || self.nav.is_some() || matches!(self.modal, Modal::Opening { .. } | Modal::Closing { .. }) } + /// 左轨的宽度轨道是否在跑(`motion_sync` 用它判「该不该按落盘档对表」)。 + pub fn nav_animating(&self) -> bool { + self.nav.is_some() + } + /// 轨道起点的口径:钉住相位时恒为 0(进度只取决于钉住值,与"谁在第几帧起跑"无关)。 fn start_now(&self) -> f64 { if self.pin.is_some() { @@ -305,6 +322,11 @@ impl Pump { self.rail = None; } } + if let Some(track) = self.nav { + if now - track.start_ms >= track.duration_ms { + self.nav = None; + } + } let spec = modal_spec(); match self.modal { Modal::Opening { start_ms } if now - start_ms >= self.leg_ms(spec.enter.duration) => { diff --git a/crates/app-iced/src/components/motion/pump/rail.rs b/crates/app-iced/src/components/motion/pump/rail.rs index ed77be5..d3227ce 100644 --- a/crates/app-iced/src/components/motion/pump/rail.rs +++ b/crates/app-iced/src/components/motion/pump/rail.rs @@ -1,13 +1,21 @@ -//! 右栗折叠轨道(M257 / A2):`Collapsed 48 ⇄ Expanded` 那一段的**时间轴**。 +//! 宽度折叠轨道(M257 / A2 建右栗,M300 加左轨):`48 ⇄ 展开` / `64 ⇄ 88` 那一段的**时间轴**。 //! -//! 与页面 / 模态两条轨道并列的第三条:它只管「这一帧折叠到几成」,不碰宽度怎么算 -//! (宽度是 `pages/agent/metrics.rs` 的纯函数)、也不碰画什么(那是视图的事)。 +//! 与页面 / 模态两条轨道并列的第三条(M300 起是**两条**宽度轨道,左 `nav` / 右 `rail`): +//! 它们只管「这一帧折叠到几成」,不碰宽度怎么算(宽度是 `pages/agent/metrics.rs` / +//! `shell/nav.rs` 的纯函数)、也不碰画什么(那是视图的事)。 //! //! | 项 | 值 | 出处 | //! |---|---|---| //! | 展开(进) | `medium1` 250ms + `Emphasized` | `14-motion-and-animation.md` §3 A2(token 列)+ §7-1「强调曲线优先」「进略长于出」 | //! | 收起(出) | `short4` 200ms + `EmphasizedAccelerate` | 同上(退场比进场短 50ms) | //! +//! ## 为什么左轨另开一条轨道,而不是复用右栗那条 +//! +//! 两条轨是**两个独立的用户动作**:`Pump::rail_open` 只有单个值,左轨一折叠就会把右栗的目标 +//! 改掉(`motion::rail(true)` 走「目标不符」兜底 ⇒ 右栗自己的动画被吞掉)。那是暗耦合: +//! 一个功能的动画吃掉另一个功能的动画,出问题时很难查。所以 M300 给它开一条并列轨道 +//! ([`Pump::observe_nav`]),**时长与曲线复用同一张 [`rail_spec`]**,零新常数。 +//! //! ## 为什么拆出去档(`Detached`)**不**走这条轨道 //! //! 拆分是**布局切换**(右栗消失、会话搬到独立页),不是宽度的连续变化:把 0 也塞进这条轨道, @@ -17,12 +25,14 @@ //! ## 起跑只在 update 侧 //! //! [`Pump::observe_rail`] 由 `pages/agent/state.rs` 的三个档位迁移(`collapse` / `expand` / -//! `dock`)调用——那三处都在 `update` 里发生。`view` 侧只经 `motion::rail(open)` **采样**。 +//! `dock`)调用——那三处都在 `update` 里发生。[`Pump::observe_nav`] 由 +//! `app/update/nav.rs` 的 `Message::ToggleNav` 落点调用,同样在 `update` 里。`view` 侧只经 +//! `motion::rail(open)` / `motion::nav(open)` **采样**。 use super::super::transition::rail_spec; use super::{leg_fraction, Pump}; -/// 一条正在跑的折叠轨道(0 = 图标条,1 = 展开档)。 +/// 一条正在跑的折叠轨道(0 = 折叠档,1 = 展开档)。 #[derive(Debug, Clone, Copy)] pub(crate) struct RailTrack { /// 起跑时已经在几成(中途反向时不为 0/1,保证**不跳变**)。 @@ -56,6 +66,29 @@ impl Pump { self.rail_open } + /// 起跑一条宽度轨道(左右两条轨**共用**这张表与这条时间轴)。 + /// + /// `from` 是起跑时已经在几成(中途反向时不为 0/1 ⇒ 不跳变);`open` 只决定用哪条腿 + /// (进 `medium1` 250ms / 出 `short4` 200ms)与终点。 + /// + /// **关掉动效时返回 `None`**(`MotionScale::NONE`):调用方必须把轨道清成 `None`, + /// 而不是插一条 0 时长轨道 —— 那会让 `is_animating()` 有一拍为真(多要一帧, + /// `MotionScale::NONE` 的「一帧都不要」就破了)。 + fn rail_track(&self, open: bool, from: f32) -> Option { + if self.scale.is_off() { + return None; + } + let spec = rail_spec(); + let leg = if open { spec.enter } else { spec.exit }; + Some(RailTrack { + from, + to: if open { 1.0 } else { 0.0 }, + duration_ms: f64::from(self.scale.ms(leg.duration)), + leg_easing: leg.easing, + start_ms: self.start_now(), + }) + } + /// 起跑一次折叠 / 展开。返回 `true` = 真的起跑了轨道(调用方要重置帧基准)。 /// /// **关掉动效时不插轨道**(`MotionScale::NONE`):进度从此恒为终值,`needs_frames()` 不会 @@ -66,21 +99,8 @@ impl Pump { } let from = self.rail_progress(); self.rail_open = open; - if self.scale.is_off() { - // 关掉动效 = 直接落终态(不是跑一遍 0 时长)。 - self.rail = None; - return false; - } - let spec = rail_spec(); - let leg = if open { spec.enter } else { spec.exit }; - self.rail = Some(RailTrack { - from, - to: if open { 1.0 } else { 0.0 }, - duration_ms: f64::from(self.scale.ms(leg.duration)), - leg_easing: leg.easing, - start_ms: self.start_now(), - }); - true + self.rail = self.rail_track(open, from); + self.rail.is_some() } /// 瞬时落档(拆分成独立页 / 窄窗退化):**不**起轨道,进度严格等于终值。 @@ -101,4 +121,44 @@ impl Pump { None => 0.0, } } + + // ---------------------------------------------------------------- 左轨(导航轨,M300) + + /// 左轨的折叠目标态(`true` = 展开 88,`false` = 折叠 64)。出厂是展开。 + pub fn nav_open(&self) -> bool { + self.nav_open + } + + /// 起跑一次左轨折叠 / 展开(与右栗**并列**的另一条轨道,理由见模块文档)。 + /// + /// 返回 `true` = 真的起跑了轨道(调用方要重置帧基准)。 + pub fn observe_nav(&mut self, open: bool) -> bool { + if self.nav_open == open && self.nav.is_none() { + return false; + } + let from = self.nav_progress(); + self.nav_open = open; + self.nav = self.rail_track(open, from); + self.nav.is_some() + } + + /// 瞬时落档(启动时按落盘的折叠档对表):**不**起轨道,进度严格等于终值。 + /// + /// 为什么需要它:泵的目标出厂是「展开」,而用户可能上次就是折叠着退出的。不对表的话, + /// 启动后第一次**展开**会被 `observe_nav` 的「目标没变」分支判成空操作 ⇒ 跳变没有动画。 + /// + /// 返回 `true` = 原来有轨道被掐掉了(那一帧要重画成终值)。 + pub fn snap_nav(&mut self, open: bool) -> bool { + self.nav_open = open; + self.nav.take().is_some() + } + + /// 这一帧的左轨进度(0 = 折叠 64,1 = 展开 88)。只采样、无副作用。 + pub fn nav_progress(&self) -> f32 { + match self.nav { + Some(track) => track.sample(self.elapsed_ms() - track.start_ms), + None if self.nav_open => 1.0, + None => 0.0, + } + } } diff --git a/crates/app-iced/src/components/motion/tests/mod.rs b/crates/app-iced/src/components/motion/tests/mod.rs index 9ee2360..f49bad9 100644 --- a/crates/app-iced/src/components/motion/tests/mod.rs +++ b/crates/app-iced/src/components/motion/tests/mod.rs @@ -9,6 +9,7 @@ //! 模态 / 系统偏好 / 降级那一半在 [`modal`]。 mod modal; +mod nav; mod rail; mod timeline; diff --git a/crates/app-iced/src/components/motion/tests/nav.rs b/crates/app-iced/src/components/motion/tests/nav.rs new file mode 100644 index 0000000..1b80f88 --- /dev/null +++ b/crates/app-iced/src/components/motion/tests/nav.rs @@ -0,0 +1,177 @@ +//! 左轨(导航轨)宽度轨道(M300 `NAVCOLLAPSE`)的判据:与右栗**并列**、同一张表、端点精确。 +//! +//! 判据口径同 M257(`14-motion-and-animation.md` §7-7):**不靠像素比对**。像素只能证明「某一张图 +//! 里宽度是多少」,证明不了「中间有十几个相位」与「结束时不多要一帧」。 +//! +//! 全部用**本地** `Pump` 连拍 `advance(FRAME_MS)`(T37 的「从驱动源连拍」):这里不经过 +//! `App`、不等真实时钟,于是「状态已切换但没有事件」那段完全是自然覆盖的——两次 `advance` +//! 之间没有任何别的调用,时间轴照样往前走。 + +use super::{settle, standard}; +use crate::app::components::motion::*; +use unlucky_design::motion::MotionScale; + +/// 一路采样左轨进度(含终点),用完自己 settle。 +fn nav_samples(pump: &mut Pump) -> Vec { + let mut out = vec![pump.nav_progress()]; + let mut guard = 0; + while pump.is_animating() { + pump.advance(FRAME_MS); + out.push(pump.nav_progress()); + guard += 1; + assert!(guard < 200, "左轨轨道没在跑完,疑似终止条件失效"); + } + out +} + +/// **展开:帧数落在 12~20、端点精确、中间严格单调**(`medium1` 250ms / 16ms)。 +/// +/// 抓得住:①时长被改成 600ms(帧数窜到 38)或 0(硬跳);②`expire` 漏了新轨道(`while` 跑满 +/// 200 帧的兜底断言红);③插值写成阶跃(单调性断言的 `>` 会变成 `==`)。 +/// 抓不住:观感是否像 M3E——那要出图(`DevDocs/artifacts/m300/`)。 +#[test] +fn expanding_the_nav_rail_runs_the_expected_frames_and_lands_on_one() { + let mut pump = standard(); + pump.observe_nav(false); + settle(&mut pump); + assert_eq!(pump.nav_progress(), 0.0, "前提:先落到折叠档"); + + assert!(pump.observe_nav(true), "目标变了必须起跑"); + let values = nav_samples(&mut pump); + let frames = values.len() - 1; + assert!( + (12..=20).contains(&frames), + "250ms / 16ms 应落在 12~20 帧,实测 {frames} 帧" + ); + assert_eq!(values.last().copied(), Some(1.0), "结束必须**严格**是 1.0"); + for pair in values.windows(2) { + assert!(pair[1] > pair[0], "展开必须严格单调:{:?}", pair); + } + assert!(!pump.is_animating(), "跑完必须清空轨道(否则空闲烧帧)"); + assert_eq!(pump.nav_progress(), 1.0, "没有轨道时是终值"); +} + +/// **折叠:反向同一条判据**(`short4` 200ms ⇒ 10~20 帧)。 +#[test] +fn collapsing_the_nav_rail_is_monotone_downwards_and_stops_at_zero() { + let mut pump = standard(); + assert!(pump.observe_nav(false), "出厂展开 ⇒ 折叠必须起跑"); + let values = nav_samples(&mut pump); + let frames = values.len() - 1; + assert!( + (10..=20).contains(&frames), + "200ms / 16ms 应落在 10~20 帧,实测 {frames} 帧" + ); + assert_eq!(values.last().copied(), Some(0.0), "结束必须严格是 0.0"); + for pair in values.windows(2) { + assert!(pair[1] < pair[0], "折叠必须严格单调下降"); + } + assert!(!pump.is_animating()); +} + +/// **`MotionScale::NONE`:严格等于终值,且一帧都不要。** +/// +/// 抓得住:把 `rail_track` 的 `is_off()` 分支删掉(插一条 0 时长轨道 ⇒ 进度仍是终值,但 +/// `is_animating()` 会有一拍为真 ⇒ 「一帧都不要」红)。 +/// 抓不住:中间帧该不该存在(NONE 下不许有,这是定义)。 +#[test] +fn motion_scale_none_puts_the_nav_rail_strictly_at_the_end_value() { + let mut pump = Pump::new(); + pump.configure(MotionScale::NONE, None); + + assert!(!pump.observe_nav(false), "NONE 下不许起跑轨道"); + assert_eq!(pump.nav_progress(), 0.0, "折叠档终值必须逐位是 0.0"); + assert!(!pump.is_animating(), "NONE 下一帧都不许要"); + + assert!(!pump.observe_nav(true)); + assert_eq!(pump.nav_progress(), 1.0); + assert!(!pump.is_animating()); +} + +/// **两条宽度轨互不干扰**(tower 否掉「借用右栗那条轨道」的那个方案,这条就是它的判据)。 +/// +/// 场景:右栗先起跑展开,**同一拍**再折叠左轨。两条轨道各自从自己的起点走,谁也不改谁的 +/// 目标;`Pump::is_animating()` 在两条都跑完之前一直为真(帧订阅不会提前关)。 +/// +/// 抓得住:①把 `observe_nav` 接回 `rail`/`rail_open`(那会当场改掉右栗的目标 ⇒ 第二条断言红); +/// ②`expire` 只清一条轨道(先跑完的那条被留下 ⇒ 最后一条「谁都不在动」红); +/// ③`is_animating` 漏了新轨道(跑到一半就 false)。 +/// 抓不住:产品里两个按钮同时被点到的时序(那归 app 侧的 `ToggleNav` 用例)。 +#[test] +fn the_two_width_tracks_never_retarget_each_other() { + let mut pump = standard(); + pump.observe_rail(true); + pump.advance(FRAME_MS); + + assert!(pump.observe_nav(false), "左轨折叠必须自己起一条轨道"); + // 右栗的目标没被左轨改掉:它继续往 1.0 走,而不是跳到左轨的方向。 + let right = pump.rail_progress(); + pump.advance(FRAME_MS); + assert!( + pump.rail_progress() > right, + "右栗的进度必须继续上涨({} → {})", + right, + pump.rail_progress() + ); + assert!(pump.nav_progress() < 1.0, "左轨同时在往下走"); + + // 两条轨道都跑完:一条也不许留下(否则空闲烧帧)。 + for _ in 0..200 { + if !pump.is_animating() { + break; + } + pump.advance(FRAME_MS); + } + assert!(!pump.is_animating(), "两条轨道都必须到期删除"); + assert_eq!(pump.rail_progress(), 1.0); + assert_eq!(pump.nav_progress(), 0.0); +} + +/// **中途反向不跳变**:折叠到一半改主意展开,下一帧从当前值继续(不是回 0/1)。 +#[test] +fn reversing_the_nav_rail_mid_flight_continues_from_the_current_progress() { + let mut pump = standard(); + pump.observe_nav(false); + // **走到真正的中间**再反向:收起走的是 `EmphasizedAccelerate`(起步很慢),固定推 4 帧 + // 只到 6%(实测 0.941),那不是「半路改主意」而是「刚起跑」。 + let mut guard = 0; + while !(0.2..=0.8).contains(&pump.nav_progress()) { + pump.advance(FRAME_MS); + guard += 1; + assert!(guard < 40, "没走到中间:{}", pump.nav_progress()); + } + let before = pump.nav_progress(); + + pump.observe_nav(true); + let after = pump.nav_progress(); + assert!( + (after - before).abs() < 1e-6, + "反向那一帧不许跳变:{before} → {after}" + ); +} + +/// **启动对表**:上次折叠着退出(落盘 true)时,第一次**展开**仍然有动画。 +/// +/// 这条守的是 `snap_nav` 存在的理由:泵的目标出厂是「展开」,若不对表,`observe_nav(true)` +/// 会走「目标没变」的早退分支 ⇒ 展开变成一次硬跳(T37:数值对,但没人让它跑)。 +/// +/// 抓得住:删掉 `snap_nav` 的调用点(`app/update/motion_sync.rs` 那一句)后,本用例在泵层面 +/// 仍绿 ⇒ 真正的接线判据在 app 侧(`update::nav::tests`),这里只钉住泵的语义。 +#[test] +fn snapping_to_the_persisted_state_keeps_the_first_expand_animated() { + let mut pump = standard(); + // 出厂:目标=展开、没有轨道。模拟「上次折叠着退出」。 + assert_eq!(pump.nav_progress(), 1.0); + pump.snap_nav(false); + assert_eq!(pump.nav_progress(), 0.0, "对表之后必须严格是折叠档"); + assert!(!pump.is_animating(), "对表不许插轨道"); + + assert!(pump.observe_nav(true), "第一次展开必须真的起跑"); + pump.advance(FRAME_MS); + assert!( + pump.nav_progress() > 0.0 && pump.nav_progress() < 1.0, + "起跑之后必须处在中间,而不是直接到位" + ); + settle(&mut pump); + assert_eq!(pump.nav_progress(), 1.0); +} diff --git a/crates/app-iced/src/pages/nav.rs b/crates/app-iced/src/pages/nav.rs index f9dddd1..c2840de 100644 --- a/crates/app-iced/src/pages/nav.rs +++ b/crates/app-iced/src/pages/nav.rs @@ -11,6 +11,15 @@ //! 这张表(表长 5 / 每一项都指向能高亮它的路由 / 第 5 项没有 section)。**将来哪边先漂没人拦** //! ——统一要泛型化 shell 的消息类型,成本大于收益;在那之前任何改 rail 几何的改动都要两处一起改 //! 并各自跑守门(`pages/mod.rs` 偏离登记 #10)。 +//! +//! ## M300 的折叠**不在这条路径上**(2026-11-13) +//! +//! 导航轨的折叠(88 ⇄ 64、只留图标)只做在产品路径(`shell/nav.rs`):折叠档住在 presenter 的 +//! `UiPrefs::nav_collapsed` 里并由 `ui.nav.collapsed` 落盘,而本文件的 `rail` 是 `pages_demo` / +//! harness 用的那一份,它连 `UiPrefs` 都读不到。所以这里的表与宽度**故意不动**—— +//! 组件的 `drawer::NavGroup` 只有「rail 88 / drawer 240」两态,硬接第三档等于给演示路径造一个 +//! 产品里不存在的状态。将来若 `pages_demo` 要出折叠图,正确做法是把 `NavRail` 的折叠档一起递进来, +//! 而不是在这里抄一份判据。 use iced::Element; diff --git a/crates/app-iced/src/shell/nav.rs b/crates/app-iced/src/shell/nav.rs index d496b80..8efc8d9 100644 --- a/crates/app-iced/src/shell/nav.rs +++ b/crates/app-iced/src/shell/nav.rs @@ -4,7 +4,7 @@ //! //! | 项 | 参考值 | 令牌落点 | //! |---|---|---| -//! | 轨宽 | 5.1 官方 96(narrow 80);本项目 **88** | [`nav_rail_width`](`LayoutMetrics` 骨架值,详见该函数文档的 M69 迁移说明) | +//! | 轨宽 | 5.1 官方 96(narrow 80);本项目 **88**(展开)/ **64**(M300 折叠) | [`nav_rail_width`] / [`nav_width`](`LayoutMetrics` 骨架值,详见该函数文档的 M69 迁移说明) | //! | 轨容器 | 5.3 形状 None / L0 / `Surface` | `MaterialLayer::Canvas` | //! | 选中指示器 | 5.4 **56×32**、圆角 full、`SecondaryContainer` | [`indicator_size`](令牌推导) | //! | 图标 | 5.5 24px | `IconSize::Toolbar` | @@ -21,15 +21,26 @@ use unlucky_design::color::Role; use unlucky_design::elevation::MaterialLayer; use unlucky_design::icons::IconKind; use unlucky_design::shape::ShapeScale; -use unlucky_design::spacing::{LayoutMetrics, Space}; +use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; use unlucky_i18n::{Key, Lang}; mod account; +// M300:纯几何(两个宽度档的插值 + 药丸矩形),拆出去守 400 行门禁 +mod geometry; +// M300:气泡 tooltip(折叠态的可读名靠它) +mod tip; +// M300:轨底的折叠按钮 +mod toggle; pub use account::RailAccount; use account::account_item; +use toggle::toggle_button; + +// 几何按**原名**再导出:`shell/tests.rs` 的 M283/M299 判据写的是 `super::nav::<名字>`, +// 搬文件不许改断言(见 `geometry.rs` 的模块文档)。 +pub(super) use geometry::{indicator_size, nav_item_height, nav_width, state_layer_box}; use crate::app::components::interaction::{self, Interaction}; use crate::app::Message; @@ -38,6 +49,8 @@ use crate::icons; use crate::shell::{filled, label, overridden}; use crate::theme::Ui; +use tip::tip; + /// 导航占位项(任务要求 4~6 个;真正的页面由后续 mission 接管)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NavItem { @@ -100,6 +113,17 @@ impl NavItem { } /// 整条导航轨。 +/// +/// ## 几何来源(M300 起有**两个**宽度档) +/// +/// 轨宽 = [`RailAccount::nav_width`]:展开档 [`LayoutMetrics::nav_rail_width`](88,带标签)与 +/// 折叠档 [`LayoutMetrics::nav_collapsed_width`](64,只留图标)之间按动效进度插值。折叠档的 +/// 算式与「为什么不是 48」写在 `design/spacing.rs` 的 `nav_collapsed_width` 上。 +/// +/// ## 轨底那一摞(自下而上) +/// +/// 账户头像位(M114,**永远贴底**)→ 折叠按钮(M300)→ 一段 `Fill` → 主序列项。 +/// 两个 footer 元素都在 `Fill` 之下,所以它们的位置与页数、窗口高无关;折叠只改**宽**。 pub(super) fn rail<'a>( ui: &'a Ui, selected: NavItem, @@ -109,13 +133,29 @@ pub(super) fn rail<'a>( ) -> Element<'a, Message> { let metrics = ui.metrics(); let indicator = indicator_size(metrics); - let compact = account.compact; + let base = ItemView { + selected: false, + compact: account.compact, + collapsed: account.nav_collapsed(), + width: account.nav_width(metrics), + }; let items = NavItem::ALL .iter() // `ALL` 本来就不含 `Account`(它固定在轨底),filter 只是把这条意图写明。 .filter(|item| **item != NavItem::Account) - .map(|item| rail_item(ui, *item, *item == selected, compact, forced, lang)) + .map(|item| { + rail_item( + ui, + *item, + ItemView { + selected: *item == selected, + ..base + }, + forced, + lang, + ) + }) .collect::>(); // 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的 @@ -129,25 +169,82 @@ pub(super) fn rail<'a>( column![ column(items).spacing(metrics.nav_item_gap), iced::widget::space::vertical(), + toggle_button(ui, account, forced, lang), account_item(ui, selected, account, indicator, forced), ] .height(Length::Fill), ) - .width(nav_rail_width(metrics)) + .width(base.width) .height(Length::Fill) .padding(Padding::from([ui.space(Space::X6), 0.0])) .style(move |_theme| filled(ui.layer(MaterialLayer::Canvas))) .into() } -fn rail_item<'a>( +/// 药丸底色(**几何**的唯一实现点):hover / pressed / 过渡帧都画在这块 56×32 上。 +/// +/// **颜色**只有一个来源:选中支走 [`interaction::selected_fill`](全仓唯一的选中底色入口, +/// M299 收口了这里的私有拷贝),未选中支走 [`interaction::layer_color`]。选中/未选中**两条分支 +/// 不许合并**:选中项静息是实色 `SecondaryContainer`(`selected_fill` 的兜底),未选中项静息 +/// 不画药丸(`layer_color` 给 `None`)。两条都经状态层求值 ⇒ 没有取样控件时**逐位**等于原来的 +/// `state_over(..)` / `ui.color(..)`(`--force-state=rest` 的取证图因此一字不变)。 +pub(in crate::shell) fn pill_color(ui: &Ui, state: Interaction, selected: bool) -> Option { + if selected { + // 选中项走**全仓唯一的**选中底色入口(M299):静息 = 实色 `SecondaryContainer`, + // hover / pressed / 过渡帧叠在它上面。此前这里有一份私有拷贝,M296 的变异实验证明 + // 它不跟助手一起动。 + Some(interaction::selected_fill( + ui, + state, + Role::OnSecondaryContainer, + Role::SecondaryContainer, + )) + } else { + // 导航轨自己画的就是 `MaterialLayer::Canvas`(见 [`rail`] 的 `.style(..)`):状态层要叠在 + // **同一个**角色上,否则 hover 色与轨底色对不上。 + interaction::layer_color( + ui, + state, + Role::OnSurface, + MaterialLayer::Canvas.surface_role(), + ) + } +} + +/// 一项导航项这一帧的样子(M300 抽出来:折叠态多了「可见性」与「实时轨宽」两件)。 +/// +/// 用结构体而不是继续加形参:`rail_item` 原本已经 6 个形参(clippy 的上限是 7),折叠要再加两个。 +/// 把这些值捆起来的另一个好处是「同一项的四处几何读的是同一拍的值」。 +#[derive(Debug, Clone, Copy)] +pub(super) struct ItemView { + /// 是否是当前页(选中指示器 / 实色胶囊)。 + pub(super) selected: bool, + /// 窄高窗口把标签收掉(M114,窗口高 < 720)。 + pub(super) compact: bool, + /// 用户主动折叠(M300):只留图标 + tooltip。 + pub(super) collapsed: bool, + /// 这一帧的轨宽(动效中间帧既不等于 88 也不等于 64)。 + pub(super) width: f32, +} + +impl ItemView { + /// 文字标签这一帧画不画(两种收法:窄高 / 用户折叠)。 + /// + /// **判据写在一处**:`rail_item` 的标签与 tooltip 两支、以及 `shell` 的用例都读它,于是 + /// 「折叠态没有标签」与「折叠态的图标有可读名」不会各判一次而漂。 + pub(super) const fn labels_visible(self) -> bool { + !self.compact && !self.collapsed + } +} + +pub(super) fn rail_item<'a>( ui: &'a Ui, item: NavItem, - selected: bool, - compact: bool, + view: ItemView, forced: Option, lang: Lang, ) -> Element<'a, Message> { + let selected = view.selected; // 状态层(hover / pressed / 过渡帧)落在**药丸**上,与选中指示器同一块几何(M283)。 let (pill_width, pill_height) = state_layer_box(ui.metrics()); // 取证态与运行期态是**同一个** `Interaction`:强制态由 `overridden` 直接推(`None` → `Active` @@ -186,14 +283,19 @@ fn rail_item<'a>( }); let item_height = nav_item_height(ui, pill_height); - let height = if compact { pill_height } else { item_height }; + let height = if view.compact { + pill_height + } else { + // M300:折叠**不改纵节奏**(项高仍是 52)——否则切换时图标会上下跳,而 52 ≥ 命中下限 48。 + item_height + }; // 列必须显式占满轨宽,否则它会 shrink 到 56,`align_x(Center)` 就变成空操作, // 药丸会贴左缘(x=0..55 而不是 16..71)——r1 实测踩到的第二个坑。 let mut stack = column![pill] - .width(nav_rail_width(ui.metrics())) + .width(view.width) .align_x(Alignment::Center) .height(height); - if !compact { + if view.labels_visible() { // M114 的「窄高收成图标条」+ M106 的「按语言取词」并存。 stack = stack.push(label( ui, @@ -206,7 +308,7 @@ fn rail_item<'a>( stack = stack.spacing(ui.space(Space::X1)); let clickable: Element<'a, Message> = button(stack) - .width(nav_rail_width(ui.metrics())) + .width(view.width) .height(height) .padding(0) .style(move |_theme: &IcedTheme, _status| button::Style { @@ -220,82 +322,19 @@ fn rail_item<'a>( .on_press(Message::Select(item)) .into(); // A8:运行期态套状态取样控件(强制态 = 取证,**不套**:取证图不许有过渡中间帧)。 - if forced.is_none() { + let clickable = if forced.is_none() { interaction::tracked(clickable, true) } else { clickable - } -} - -/// 导航轨宽度(M69 起是令牌层的 `nav_rail_width`)。 -/// -/// M69(`141e9b1`)把 `LayoutMetrics` 的 nav 字段做了语义纠正: -/// -/// | 旧 | 新 | -/// |---|---| -/// | `nav_expanded_width = 88` | **`nav_rail_width = 88`**(值不变,名字从「展开」改成「轨」) | -/// | `nav_collapsed_width = 48` | 删除(48 连 56 宽的选中指示器都放不下) | -/// | —(缺) | `nav_drawer_width = 240`(D33 的展开态抽屉,本 crate 未用) | -/// -/// 三处调用点都走这里:将来令牌再改名,仍然只改这一行。 -fn nav_rail_width(metrics: LayoutMetrics) -> f32 { - metrics.nav_rail_width -} - -/// 选中指示器尺寸(12b 5.4 参考 56×32),全部由令牌推导、不写死: -/// -/// - 宽 = [`nav_rail_width`] − 2 × `Space::X4` = 88 − 32 = **56**(轨的左右内边距 16) -/// - 高 = `control_height − Space::X2` = 40 − 8 = **32**(可见高度上下各让 4) -pub fn indicator_size(metrics: LayoutMetrics) -> (f32, f32) { - ( - nav_rail_width(metrics) - Space::X4.dp() * 2.0, - metrics.control_height - Space::X2.dp(), - ) -} - -/// 状态层(hover / pressed / 过渡帧)画在**哪块矩形**上:药丸,与选中指示器同一块几何(M283)。 -/// -/// 这是那块矩形的唯一定义点——`rail_item` 用它定尺寸,`shell::tests` 的几何判据钉住它。 -/// 它**不是**按钮的 `nav_rail_width × 项高`(88×52):那正是用户点名的「父容器整个方形区域加深」。 -pub(super) fn state_layer_box(metrics: LayoutMetrics) -> (f32, f32) { - indicator_size(metrics) -} - -/// 药丸底色(**几何**的唯一实现点):hover / pressed / 过渡帧都画在这块 56×32 上。 -/// -/// **颜色**只有一个来源:选中支走 [`interaction::selected_fill`](全仓唯一的选中底色入口, -/// M299 收口了这里的私有拷贝),未选中支走 [`interaction::layer_color`]。选中/未选中**两条分支 -/// 不许合并**:选中项静息是实色 `SecondaryContainer`(`selected_fill` 的兜底),未选中项静息 -/// 不画药丸(`layer_color` 给 `None`)。两条都经状态层求值 ⇒ 没有取样控件时**逐位**等于原来的 -/// `state_over(..)` / `ui.color(..)`(`--force-state=rest` 的取证图因此一字不变)。 -fn pill_color(ui: &Ui, state: Interaction, selected: bool) -> Option { - if selected { - // 选中项走**全仓唯一的**选中底色入口(M299):静息 = 实色 `SecondaryContainer`, - // hover / pressed / 过渡帧叠在它上面。此前这里有一份私有拷贝,M296 的变异实验证明 - // 它不跟助手一起动。 - Some(interaction::selected_fill( - ui, - state, - Role::OnSecondaryContainer, - Role::SecondaryContainer, - )) + }; + // M300:标签不画的时候,图标是这一项**唯一**的可见标识 ⇒ 必须有可读名,否则折叠态就是一列 + // 无名符号(用户点名不许)。tooltip 是这条判据的落点(与 `pages/agent` 同一条纪律)。 + if view.labels_visible() { + clickable } else { - // 导航轨自己画的就是 `MaterialLayer::Canvas`(见 [`rail`] 的 `.style(..)`):状态层要叠在 - // **同一个**角色上,否则 hover 色与轨底色对不上。 - interaction::layer_color( - ui, - state, - Role::OnSurface, - MaterialLayer::Canvas.surface_role(), - ) + tip(ui, clickable, item.label(lang)) } } -/// 导航项高度 = 指示器 + 4 + 标签行高 = 32 + 4 + 16 = 52(`LM.nav_item_height` 是列表行节奏, -/// 药丸 + 标签的轨项放不进 44,偏差登记在交接文档里)。 -pub fn nav_item_height(ui: &Ui, indicator_height: f32) -> f32 { - indicator_height + ui.space(Space::X1) + ui.style(TypeRole::LabelMedium).line_height_px -} - #[cfg(test)] mod tests; diff --git a/crates/app-iced/src/shell/nav/account.rs b/crates/app-iced/src/shell/nav/account.rs index 9e4f5b0..00795d5 100644 --- a/crates/app-iced/src/shell/nav/account.rs +++ b/crates/app-iced/src/shell/nav/account.rs @@ -10,7 +10,7 @@ use unlucky_design::color::Role; use unlucky_design::elevation::MaterialLayer; use unlucky_design::icons::IconKind; use unlucky_design::shape::ShapeScale; -use unlucky_design::spacing::Space; +use unlucky_design::spacing::{LayoutMetrics, Space}; use unlucky_design::typography::TypeRole; use crate::app::components::interaction::{self, Interaction}; @@ -20,13 +20,24 @@ use crate::icons; use crate::shell::overridden; use crate::theme::Ui; -use super::{nav_item_height, nav_rail_width, NavItem}; +use super::tip::tip; +use super::{nav_item_height, NavItem}; -/// 导航轨底部的**账户头像位**状态(M114)。 +/// 导航轨底部的**账户头像位**状态(M114)+ 轨自身的折叠态(M300)。 /// /// 为什么单独传而不是塞进 `NavItem`:它不是主序列里的第 6 项(那会让轨道越堆越长), /// 而是固定在**底部**的一个头像位;未登录/已登录两态要在**形状上**就不同(描边剪影 vs 首字母块)。 -#[derive(Debug, Clone, PartialEq, Eq)] +/// +/// ## 为什么折叠态也搭这个结构(M300) +/// +/// `shell::NavRail`(`shell/mod.rs`)是 `nav::rail(..)` 唯一的入参通道,而那个文件不在本 +/// mission 的 scope 里;`RailAccount` 是**唯一**由 `app/view/rail.rs`(scope 内)构造、又直接递给 +/// `nav::rail`(scope 内)的值。于是轨的「这一帧多宽 / 标签收不收」搭它进来,一个字段都不新增到 +/// `NavRail` 上。代价是这个结构现在有两个职责——已经比「动 `shell/mod.rs` 去加第三个字段」便宜, +/// 且它们确实是同一块几何的两个输入(轨的宽与轨底的内容)。 +/// +/// `f32` 的进度进场后 `Eq` 不再成立(`PartialEq` 仍在):调用方只比字段,不做整结构相等。 +#[derive(Debug, Clone, PartialEq)] pub struct RailAccount { /// 是否已登录(至少有一个账户) pub signed_in: bool, @@ -36,6 +47,10 @@ pub struct RailAccount { pub label: String, /// 窄高度/窄宽下的图标条形态(隐藏文字标签,只留头像位) pub compact: bool, + /// 用户把轨折叠了(M300):只留图标 + tooltip。判据只有一处(`ItemView::labels_visible`)。 + nav_collapsed: bool, + /// 折叠进度的**这一帧**取值(0 = 折叠 64,1 = 展开 88)。中间帧由 `motion::nav(open)` 给。 + nav_progress: f32, } impl RailAccount { @@ -46,6 +61,9 @@ impl RailAccount { initial: String::new(), label: label.into(), compact: false, + // 出厂:轨展开(`UiPrefs::nav_collapsed` 的出厂值也是 false) + nav_collapsed: false, + nav_progress: 1.0, } } @@ -56,6 +74,8 @@ impl RailAccount { initial: initial.into(), label: label.into(), compact: false, + nav_collapsed: false, + nav_progress: 1.0, } } @@ -65,6 +85,33 @@ impl RailAccount { self.compact = compact; self } + + /// 轨的折叠档 + **这一帧**的过渡进度(M300)。 + /// + /// 两个值必须一起给:只给 `collapsed` 会让布局按一个过期的进度算宽,只给进度会让标签在 + /// 折叠档里还画着。`progress` 会被夹到 `0..=1`(`nav_width` 里再夹一次,这里只是让调用方 + /// 传什么都不会把轨画成负数宽)。 + #[must_use] + pub fn nav(mut self, collapsed: bool, progress: f32) -> Self { + self.nav_collapsed = collapsed; + self.nav_progress = progress.clamp(0.0, 1.0); + self + } + + /// 轨现在是不是折叠档(标签该不该收)。 + #[must_use] + pub fn nav_collapsed(&self) -> bool { + self.nav_collapsed + } + + /// 这一帧的轨宽(展开 88 ⇄ 折叠 64;中间帧按动效进度插值)。 + /// + /// **唯一实现点是 `super::nav_width`**:`nav::rail`、`account_item`、`toggle_button` 与用例 + /// 都读它,于是「谁把这四个调用点各算一遍」在结构上不可能。 + #[must_use] + pub fn nav_width(&self, metrics: LayoutMetrics) -> f32 { + super::nav_width(metrics, self.nav_progress) + } } /// 轨底的账户头像位:未登录 = 描边剪影,已登录 = 首字母块(两态在**形状**上就不同)。 @@ -133,12 +180,16 @@ pub(super) fn account_item<'a>( .into() }; + let width = account.nav_width(ui.metrics()); let height = nav_item_height(ui, indicator_height); + // M300:折叠档与「窄高图标条」是同一条判据——名字("Not signed in" / 账户名)在 64 宽的轨里 + // 放不下,收起来之后可读名改由 tooltip 给(与主序列项同一条纪律)。 + let labels_visible = !account.compact && !account.nav_collapsed(); let mut stack = column![avatar] .spacing(ui.space(Space::X1)) - .width(nav_rail_width(ui.metrics())) + .width(width) .align_x(Alignment::Center); - if !account.compact { + if labels_visible { // 用**拥有的**字符串画标签:`RailAccount` 是调用方临时算出来的状态, // 借它的 `&str` 会让整棵树跟着那个临时值一起活(E0515)。 let style = ui.style(TypeRole::LabelSmall); @@ -154,7 +205,7 @@ pub(super) fn account_item<'a>( } let clickable: Element<'a, Message> = button(stack) - .width(nav_rail_width(ui.metrics())) + .width(width) .height(height) .padding(0) .style(move |_theme: &IcedTheme, _status| button::Style { @@ -168,10 +219,16 @@ pub(super) fn account_item<'a>( .on_press(Message::Select(NavItem::Account)) .into(); // A8:运行期态套状态取样控件(强制态 = 取证,**不套**:取证图不许有过渡中间帧)。 - if forced.is_none() { + let clickable = if forced.is_none() { interaction::tracked(clickable, true) } else { clickable + }; + // M300:名字不画的时候,头像位也必须说得出自己是谁(否则折叠态的轨底就是一个孤零零的剪影)。 + if labels_visible { + clickable + } else { + tip(ui, clickable, account.label.clone()) } } @@ -277,10 +334,8 @@ mod tests { let width = super::super::indicator_size(ui.metrics()).0; let size = avatar_size(&ui, width); assert!(size > 0.0); - assert!( - size <= nav_rail_width(ui.metrics()), - "头像位不能比导航轨还宽({size} > {})", - nav_rail_width(ui.metrics()) - ); + // 展开档是两档里宽的那一档:头像位连它都放得下,折叠档才不需要另算一遍。 + let rail = ui.metrics().nav_rail_width; + assert!(size <= rail, "头像位不能比导航轨还宽({size} > {rail})"); } } diff --git a/crates/app-iced/src/shell/nav/geometry.rs b/crates/app-iced/src/shell/nav/geometry.rs new file mode 100644 index 0000000..b982466 --- /dev/null +++ b/crates/app-iced/src/shell/nav/geometry.rs @@ -0,0 +1,73 @@ +//! 导航轨的**纯几何**(M300 从 `nav.rs` 拆出):两个宽度档的插值 + 药丸矩形 + 项高。 +//! +//! 为什么拆:`nav.rs` 在 M300 要加「折叠态的一项」与「折叠按钮」两件事,几何这一块(约 85 行) +//! 又不与视图组装耦合——它就是几条令牌推导。拆开之后 `nav.rs` 只剩「谁放在哪」。 +//! +//! **唯一实现点不变**:`indicator_size` / `state_layer_box` / `nav_item_height` 仍各只有一份, +//! `nav.rs` 用 `pub(super) use` 把它们按原名再导出,于是 `shell/tests.rs` 里 +//! `super::nav::{indicator_size, nav_item_height, state_layer_box}` 的路径一个字符都没变 +//! (那是 M283/M299 的几何判据,不许为了搬文件改断言)。 +//! +//! `pill_color` **留在了 `nav.rs`**:`shell/tests.rs` 与 M299 的 +//! `components/selection/tests/single_entry.rs` 都按**文件名**扫「这一处必须经 +//! `selected_fill` / `layer_color` 求值」,把它搬走会让那两条门禁静默失守(`selected_fill` 的 +//! 白名单里写的是 `shell/nav.rs`)。搬文件不许改别人的门禁 —— 这条比「按职责拆分」优先。 + +use unlucky_design::spacing::{LayoutMetrics, Space}; +use unlucky_design::typography::TypeRole; + +use crate::theme::Ui; + +/// 导航轨宽度(M69 起是令牌层的 `nav_rail_width`)。 +/// +/// M69(`141e9b1`)把 `LayoutMetrics` 的 nav 字段做了语义纠正: +/// +/// | 旧 | 新 | +/// |---|---| +/// | `nav_expanded_width = 88` | **`nav_rail_width = 88`**(值不变,名字从「展开」改成「轨」) | +/// | `nav_collapsed_width = 48` | 删除(48 连 56 宽的选中指示器都放不下) | +/// | —(缺) | `nav_drawer_width = 240`(D33 的展开态抽屉,本 crate 未用) | +/// +/// 三处调用点都走这里:将来令牌再改名,仍然只改这一行。 +pub(in crate::shell) fn nav_rail_width(metrics: LayoutMetrics) -> f32 { + metrics.nav_rail_width +} + +/// 这一帧的轨宽:折叠档 ⇄ 展开档之间按动效进度插值(M300)。 +/// +/// - `progress = 1.0` → **逐位**等于 [`nav_rail_width`](88)——静息展开态与 M283/M299 的图一字不差; +/// - `progress = 0.0` → **逐位**等于 [`LayoutMetrics::nav_collapsed_width`](64); +/// - 中间帧是线性的(曲线已经在帧泵那一侧按 `Emphasized*` 采过样,这里再套一条曲线就是两次缓动)。 +/// +/// 为什么这一层住在这里而不是 `design::spacing`:它只是「两个令牌之间的插值」,不是新令牌; +/// 而折叠档能容下 56 宽胶囊这件事由 `spacing.rs` 的断言钉住,两处不重叠。 +pub(in crate::shell) fn nav_width(metrics: LayoutMetrics, progress: f32) -> f32 { + let collapsed = metrics.nav_collapsed_width; + let open = nav_rail_width(metrics); + collapsed + (open - collapsed) * progress.clamp(0.0, 1.0) +} + +/// 选中指示器尺寸(12b 5.4 参考 56×32),全部由令牌推导、不写死: +/// +/// - 宽 = [`nav_rail_width`] − 2 × `Space::X4` = 88 − 32 = **56**(轨的左右内边距 16) +/// - 高 = `control_height − Space::X2` = 40 − 8 = **32**(可见高度上下各让 4) +pub(in crate::shell) fn indicator_size(metrics: LayoutMetrics) -> (f32, f32) { + ( + nav_rail_width(metrics) - Space::X4.dp() * 2.0, + metrics.control_height - Space::X2.dp(), + ) +} + +/// 状态层(hover / pressed / 过渡帧)画在**哪块矩形**上:药丸,与选中指示器同一块几何(M283)。 +/// +/// 这是那块矩形的唯一定义点——`rail_item` 用它定尺寸,`shell::tests` 的几何判据钉住它。 +/// 它**不是**按钮的 `nav_rail_width × 项高`(88×52):那正是用户点名的「父容器整个方形区域加深」。 +pub(in crate::shell) fn state_layer_box(metrics: LayoutMetrics) -> (f32, f32) { + indicator_size(metrics) +} + +/// 导航项高度 = 指示器 + 4 + 标签行高 = 32 + 4 + 16 = 52(`LM.nav_item_height` 是列表行节奏, +/// 药丸 + 标签的轨项放不进 44,偏差登记在交接文档里)。 +pub(in crate::shell) fn nav_item_height(ui: &Ui, indicator_height: f32) -> f32 { + indicator_height + ui.space(Space::X1) + ui.style(TypeRole::LabelMedium).line_height_px +} diff --git a/crates/app-iced/src/shell/nav/tests.rs b/crates/app-iced/src/shell/nav/tests.rs index 6ed0e6f..0675285 100644 --- a/crates/app-iced/src/shell/nav/tests.rs +++ b/crates/app-iced/src/shell/nav/tests.rs @@ -6,6 +6,7 @@ use super::*; use unlucky_design::color::SchemeFlavor; +use unlucky_design::spacing::LayoutMetrics; use unlucky_design::typography::Platform; use crate::theme::Families; @@ -119,3 +120,171 @@ fn the_download_item_is_labelled_version_management() { ); } } + +// ---------------------------------------------------------------- M300 折叠档 + +use iced::advanced::widget::tree::Tag; +use iced::advanced::widget::Tree; +use iced::widget::tooltip::Position; +use iced::widget::{text, tooltip}; + +use super::{nav_width, rail_item, ItemView}; + +/// 一个元素在控件树里的标签,以及「这棵树里有几个文字节点 / 几个 tooltip 子树」。 +/// +/// 为什么要走**真控件树**而不是断言 `ItemView::labels_visible()`:后者是判据本身,断言它等于 +/// 「断言我自己写的那个 bool 是真的」。真树能抓住「折叠分支里又把 label 推回去了」,因为那时 +/// 文字节点会多出来。 +fn tree_of(el: &iced::Element<'_, Message>) -> Tree { + Tree::new(el.as_widget()) +} + +/// 文字节点的标签(`text` 与包着它的 `container` 同 tag,见 iced 的 `container::tag` 委托)。 +fn text_tag() -> Tag { + let probe: iced::Element<'static, Message> = text("probe").into(); + tree_of(&probe).tag +} + +/// tooltip 子树的标签。 +fn tooltip_tag() -> Tag { + let probe: iced::Element<'static, Message> = + tooltip(text("probe"), text("tip"), Position::Right).into(); + tree_of(&probe).tag +} + +/// 数「tooltip 子树之外」的文字节点:tooltip 自己带一个气泡文案,它在折叠态**应该**存在 +/// (那是可读名),所以不能算进「标签」的口径里。 +fn text_nodes_outside_tooltips(tree: &Tree, text: Tag, tip: Tag) -> usize { + if tree.tag == tip { + return 0; + } + let here = usize::from(tree.tag == text); + here + tree + .children + .iter() + .map(|child| text_nodes_outside_tooltips(child, text, tip)) + .sum::() +} + +fn tooltip_count(tree: &Tree, tip: Tag) -> usize { + let here = usize::from(tree.tag == tip); + here + tree + .children + .iter() + .map(|child| tooltip_count(child, tip)) + .sum::() +} + +/// **折叠态:文字标签不进控件树,但每个图标都有可读名(tooltip)。** +/// +/// 抓得住:①折叠分支里把 label 又推回去(`text_nodes_outside_tooltips` 从 0 变 1); +/// ②折叠态只剩图标却没有可读名(`tooltip_count` 从 1 变 0 —— 那正是用户点名的 +/// 「一列无名符号」);③展开态被顺手塞上 tooltip(展开态那个计数从 0 变 1)。 +/// 抓不住:tooltip 真弹出来长什么样、以及文字**内容**对不对(气泡里的文案由 `Key` 目录守, +/// 像素由 `DevDocs/artifacts/m300/` 两张图守)。 +#[test] +fn the_collapsed_item_keeps_no_label_but_gives_the_icon_a_name() { + let ui = ui(); + let item = NavItem::Instances; + let expanded = rail_item( + &ui, + item, + ItemView { + selected: true, + compact: false, + collapsed: false, + width: ui.metrics().nav_rail_width, + }, + None, + Lang::En, + ); + let collapsed = rail_item( + &ui, + item, + ItemView { + selected: true, + compact: false, + collapsed: true, + width: ui.metrics().nav_collapsed_width, + }, + None, + Lang::En, + ); + + let (text, tip) = (text_tag(), tooltip_tag()); + let expanded_tree = tree_of(&expanded); + let collapsed_tree = tree_of(&collapsed); + + assert!( + text_nodes_outside_tooltips(&expanded_tree, text, tip) > 0, + "前提:展开态必须画标签(否则这条用例什么都没测到)" + ); + assert_eq!( + text_nodes_outside_tooltips(&collapsed_tree, text, tip), + 0, + "折叠态不许把文字标签放进控件树" + ); + assert_eq!( + tooltip_count(&collapsed_tree, tip), + 1, + "折叠态的图标必须有可读名(tooltip)" + ); + assert_eq!( + tooltip_count(&expanded_tree, tip), + 0, + "展开态有可见标签,不需要 tooltip" + ); +} + +/// **折叠态里每个图标仍然可辨识**:五项图标两两不同。 +/// +/// 抓得住:有人把某一项的图标换成同一个(或把 `icon()` 写成常量)⇒ 折叠态立刻变成「一列同样的 +/// 符号」。抓不住:24px 下肉眼能否分辨(那要出图)与 tooltip 的文案(`Key` 目录守)。 +#[test] +fn the_five_icons_stay_distinguishable_when_the_labels_are_gone() { + let mut paths = Vec::new(); + for item in NavItem::ALL { + let path = item.icon().icon().path; + assert!(!path.is_empty(), "{item:?} 没有图标"); + assert!( + !paths.contains(&path), + "{item:?} 的图标与前面某项重复:折叠态会变成一列同样的符号" + ); + paths.push(path); + } + assert_eq!(paths.len(), NavItem::ALL.len()); +} + +/// **宽度是两档之间的插值**,两端**逐位**等于令牌。 +/// +/// 抓得住:①把折叠档写成 48(旧值)⇒ 端点断言红,且 `spacing.rs` 的「容得下胶囊」断言也会红; +/// ②插值写成非线性的(例如再套一条缓动)⇒ 中点断言红(曲线在帧泵那一侧采过样了); +/// ③进度不做钳位(动效中间帧越界一次就把轨画成负数宽)。 +#[test] +fn the_rail_width_interpolates_between_the_two_tokens() { + let metrics = ui().metrics(); + assert_eq!(nav_width(metrics, 1.0), metrics.nav_rail_width, "展开端"); + assert_eq!( + nav_width(metrics, 0.0), + metrics.nav_collapsed_width, + "折叠端" + ); + assert_eq!( + nav_width(metrics, 0.5), + (metrics.nav_rail_width + metrics.nav_collapsed_width) / 2.0, + "中点必须是线性的" + ); + // 钳位:越界的进度不许把轨画到两端之外 + assert_eq!(nav_width(metrics, 1.7), metrics.nav_rail_width); + assert_eq!(nav_width(metrics, -0.3), metrics.nav_collapsed_width); + // 折叠档必须容得下 56 宽的胶囊(两个端点各一次,含动效中间帧的单调性) + let pill = indicator_size(metrics).0; + assert!(pill <= nav_width(metrics, 0.0), "折叠端容不下胶囊"); + for step in 0..=10 { + let progress = step as f32 / 10.0; + assert!( + nav_width(metrics, progress) >= pill, + "进度 {progress} 时轨比胶囊还窄" + ); + } +} diff --git a/crates/app-iced/src/shell/nav/tip.rs b/crates/app-iced/src/shell/nav/tip.rs new file mode 100644 index 0000000..1198d98 --- /dev/null +++ b/crates/app-iced/src/shell/nav/tip.rs @@ -0,0 +1,49 @@ +//! M3 气泡 tooltip(导航轨专用):折叠态里图标是唯一的可见标识,可读名只能靠它。 +//! +//! 视觉与 `pages/agent/panel.rs` / `pages/agent/strip.rs` 那两处一致(`Overlay` 层底 + +//! `Small` 圆角 + `BodySmall` 字号)。**没有抽公共件**:那两个文件不在 M300 的 scope 里, +//! 硬抽要动别人的模块;三份拷贝已作为 finding 报给 tower,去重单独立项。 + +use iced::advanced::text::IntoFragment; +use iced::widget::tooltip::Position; +use iced::widget::{container, text, tooltip}; +use iced::{Background, Border, Element, Padding}; + +use unlucky_design::color::Role; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use crate::theme::Ui; + +/// 把一个元素包成「悬停出气泡」的形态。`Position::Right`:轨贴窗口左缘,气泡只能往右弹。 +/// +/// 文案接受 `impl IntoFragment`:导航项的标签是 `&'static str`(目录里的词),而账户名是这一帧 +/// 现拼的 `String`(`RailAccount` 是临时值,借它的 `&str` 会让整棵树跟着那个临时值活)。 +pub(super) fn tip<'a, M: 'a, S>( + ui: &'a Ui, + content: Element<'a, M>, + text_value: S, +) -> Element<'a, M> +where + S: IntoFragment<'a>, +{ + let style = ui.style(TypeRole::BodySmall); + let bubble = container( + text(text_value) + .size(style.size_px) + .font(ui.font_of(style)) + .color(ui.color(Role::OnSurface)), + ) + .padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)])) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))), + border: Border { + radius: ui.radius_dp(ShapeScale::Small), + ..Border::default() + }, + ..container::Style::default() + }); + tooltip(content, bubble, Position::Right).into() +} diff --git a/crates/app-iced/src/shell/nav/toggle.rs b/crates/app-iced/src/shell/nav/toggle.rs new file mode 100644 index 0000000..60624da --- /dev/null +++ b/crates/app-iced/src/shell/nav/toggle.rs @@ -0,0 +1,118 @@ +//! 导航轨底部的**折叠按钮**(M300 `NAVCOLLAPSE`):点一下在展开(88,带标签)与折叠(64,只图标) +//! 之间切换。 +//! +//! ## 为什么放在轨底、头像位之上 +//! +//! ①**轨顶一个像素都不动**:轨顶是 `Space::X6`(24) 留白 + 首项,M283/M299 的展开态对照图(D30) +//! 全靠那一段的可比性;把按钮塞在轨顶会把整列往下推 52px,等于给每个既有取证图加一条无关差异。 +//! ②轨底本来就已经有一个「固定贴底」的槽(M114 的账户头像位),按钮与它同属 footer:两态下位置 +//! 不变(只改宽),过渡期间目标也一直在同一处,不会边动边跑。 +//! ③折叠态下按钮仍然完整可见(64 里放得下 56 的胶囊),回得去——这是「可折叠」的必要条件。 +//! +//! ## 为什么图标是 `Sidebar` 而文案两态不同 +//! +//! 按钮只有图标,所以可读名走 tooltip([`Key::NavCollapse`] / [`Key::NavExpand`]):图标说「这是 +//! 侧栏开关」,文案说「点下去会发生什么」。用同一个字形而不是「< / >」的另一原因是两个方向的 +//! 箭头在 24px 下几乎对称,用户读不出当前态,反而在 tooltip 里说得清。 + +use iced::widget::{button, container}; +use iced::{Alignment, Background, Border, Element, Shadow, Theme as IcedTheme}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::shape::ShapeScale; +use unlucky_i18n::Lang; + +use crate::app::components::interaction::{self, Interaction}; +use crate::app::Message; +use crate::cli::ForcedState; +use crate::icons; +use crate::shell::overridden; +use crate::theme::Ui; + +use super::account::RailAccount; +use super::tip::tip; +use super::{nav_item_height, pill_color, state_layer_box}; + +/// 轨底的折叠按钮。 +/// +/// 几何与导航项**同一套**:轨宽 × 项高(52)的整条命中区,里面是 56×32 的胶囊(M283 的那块), +/// 状态层(hover / pressed / 过渡帧)画在胶囊上而不是按钮上——与 `rail_item` 同一个理由: +/// 同一个元素不许并存两种形状。 +/// +/// **强制度**:`--force-state` 的取证图**不套**状态取样控件(取证图里不许有过渡中间帧), +/// 但照样按强制态求值底色(`overridden`)。 +pub(super) fn toggle_button<'a>( + ui: &'a Ui, + account: &RailAccount, + forced: Option, + lang: Lang, +) -> Element<'a, Message> { + let metrics = ui.metrics(); + let width = account.nav_width(metrics); + let collapsed = account.nav_collapsed(); + let (pill_width, pill_height) = state_layer_box(metrics); + let state = Interaction::from_status(overridden(forced, button::Status::Active)); + + // 24 的盒也要走 `view_at`(显式给盒尺寸):`icons.rs` 的白名单按**文件**记 `icons::view` + // 的调用点数(`shell/nav.rs` 1 处、`shell/nav/account.rs` 1 处),本文件是 0——那个白名单 + // 不在 M300 的 scope 里,所以这里用等价的显式入口,而不是去改别人的门禁。 + let icon = container(icons::view_at::( + IconKind::Sidebar, + ui.color(Role::OnSurfaceVariant), + unlucky_design::icons::IconSize::Toolbar.px(), + )) + .width(pill_width) + .height(pill_height) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .style(move |_theme| container::Style { + // 与未选中的导航项同一条:导轨画的是 `MaterialLayer::Canvas`,状态层叠在同一个角色上。 + background: pill_color(ui, state, false).map(Background::Color), + border: Border { + radius: ui.radius(ShapeScale::Full, pill_height), + ..Border::default() + }, + ..container::Style::default() + }); + + let height = nav_item_height(ui, pill_height); + let clickable: Element<'a, Message> = button( + container(icon) + .width(width) + .height(height) + .align_x(Alignment::Center) + .align_y(Alignment::Center), + ) + .width(width) + .height(height) + .padding(0) + .style(move |_theme: &IcedTheme, _status| button::Style { + // 底色**恒为 `None`**:状态层由胶囊给(M283)。 + background: None, + text_color: ui.color(Role::OnSurfaceVariant), + border: Border::default(), + shadow: Shadow::default(), + snap: true, + }) + .on_press(Message::ToggleNav) + .into(); + + let clickable = if forced.is_none() { + interaction::tracked(clickable, true) + } else { + clickable + }; + + // 文案跟**当前态**走:展开时告诉用户「点了会折叠」,折叠时告诉用户「点了会展开」。 + let catalog = lang.catalog(); + tip( + ui, + clickable, + catalog.text(if collapsed { + unlucky_i18n::Key::NavExpand + } else { + unlucky_i18n::Key::NavCollapse + }), + ) +} diff --git a/crates/core/src/setting/keys.rs b/crates/core/src/setting/keys.rs index 65b6c4c..189b067 100644 --- a/crates/core/src/setting/keys.rs +++ b/crates/core/src/setting/keys.rs @@ -238,6 +238,16 @@ pub fn launcher_schema() -> SettingSchema { AiPolicy::audited(), "界面语言", )) + // M300(NAVCOLLAPSE):左侧导航轨折叠成图标条。纯视觉、一键可复原(切回 `false` 即展开), + // 没有安全面 ⇒ 分级与 `ui.language` 同族取 audited(AI 改过要留痕,但不要确认框)。 + // 键名与 `unlucky_presenter::prefs::keys::NAV_COLLAPSED` 逐字相同(两侧各有一份常量, + // 相不相同由 `presenter/src/prefs/tests.rs` 与 `settings_bridge` 的往返用例一起钉住)。 + .with(SettingSpec::bool( + "ui.nav.collapsed", + false, + AiPolicy::audited(), + "左侧导航轨折叠成图标条(false = 展开,带文字标签)", + )) .with(SettingSpec::enumeration( "appearance.theme_mode", "auto", diff --git a/crates/design/src/spacing.rs b/crates/design/src/spacing.rs index 618a471..3c18ccd 100644 --- a/crates/design/src/spacing.rs +++ b/crates/design/src/spacing.rs @@ -101,11 +101,40 @@ pub struct LayoutMetrics { /// 官方 `NavigationDrawerTokens.ContainerWidth = 360`、`MinimumDrawerWidth = 240`; /// 本项目取官方下限 240——360 + AI 面板 320 = 680 = 1180 窗宽的 58%,内容区只剩 500 放不下两列卡片。 pub nav_drawer_width: f32, - /// 折叠态导航(rail 形态)宽度。 + /// 折叠态导航(rail 形态)宽度 —— **轨的展开档(默认)**。 /// /// 88 = 12b §5.1 对官方 rail 96 的登记偏离(−8):指示器宽 88 − 2×16 = 56 仍成立, /// 再窄就装不下 56 宽的选中指示器。 + /// + /// 「折叠态」这个词在本仓指 **rail 形态本身**(与 240 的 drawer 相对)。M300 起 rail 自己 + /// 也允许再收一档(只留图标),那一档是 [`LayoutMetrics::nav_collapsed_width`]。 pub nav_rail_width: f32, + /// 导航轨**再收一档**(只留图标、隐藏文字标签)时的宽度(M300)。 + /// + /// ## 算式:64 = 56 + 2 × 4 + /// + /// 判据是「胶囊不能被裁切」而不是「比 88 窄就行」:选中指示器 / 状态层那块矩形(M283 定稿 + /// **56×32**,本 mission 不动)在折叠态**仍然存在**(选中项照样要有底色),所以轨宽必须容得下 + /// 它加两侧留白: + /// + /// ```text + /// 胶囊宽 = nav_rail_width − 2 × Space::X4 = 88 − 32 = 56 (见 shell/nav.rs 的 indicator_size) + /// nav_collapsed_width = 胶囊宽 + 2 × Space::X1 = 56 + 8 = 64 + /// ``` + /// + /// **为什么不是原来的 48**:旧令牌 `nav_collapsed_width = 48` 在 M69 被删,正是因为它连胶囊 + /// 都放不下 —— `48 − 2×X1 = 40 < 56`(`48 − 2×X4 = 16` 更不用说)。所以 48 不是「再收一档」, + /// 是「收过头」。折叠档的下界由 `spacing.rs` 自己的断言 + /// `nav_collapsed_width >= 胶囊宽 + 2 × Space::X1` 钉住:任何人想把它改小,先得改胶囊尺寸, + /// 而那是 M283 的定稿值(改它要在 mission 里声明)。 + /// + /// **为什么是 X1(4)而不是 X2(8)**:8 会让折叠档变成 72、与 88 只差 16(腾出的空间不足以 + /// 支撑「折叠」这个动作的意义),而 4 已经是栅格上的最小值、且 64 ≥ `touch_target`(48) —— + /// 折叠态每一项的命中区仍是 64 宽整条,不比展开态差。 + /// + /// **纵节奏不变**:折叠只改宽度与标签可见性,项高仍是 `nav_item_height`(52 = 32 + 4 + 16), + /// 于是切换时图标不上下跳、命中高 52 ≥ 48。 + pub nav_collapsed_width: f32, /// 搜索条高度(G11)。 /// /// 官方 `SearchBarTokens.ContainerHeight = 56`。**不走 [`LayoutMetrics::control_height`](40)**: @@ -157,6 +186,8 @@ impl LayoutMetrics { // `nav_collapsed_width=48` 的名字与值相反,已删)。 nav_drawer_width: 240.0, nav_rail_width: 88.0, + // M300:轨自己再收一档(只留图标)= 胶囊 56 + 2 × Space::X1(4)。算式见该字段文档。 + nav_collapsed_width: 64.0, // G11:官方 SearchBarTokens.ContainerHeight = 56。 search_bar_height: 56.0, // G11/G12:官方 DividerTokens.Thickness = 1.0dp → 一像素线的唯一真源。 @@ -208,6 +239,7 @@ mod tests { ("titlebar_height", m.titlebar_height), ("nav_drawer_width", m.nav_drawer_width), ("nav_rail_width", m.nav_rail_width), + ("nav_collapsed_width", m.nav_collapsed_width), ("search_bar_height", m.search_bar_height), ("nav_item_height", m.nav_item_height), ("nav_item_gap", m.nav_item_gap), @@ -244,6 +276,36 @@ mod tests { assert!(1180.0 - m.nav_drawer_width - m.ai_panel_width >= 600.0); } + /// **M300:折叠档必须容得下 56 宽的胶囊 + 两侧留白。** + /// + /// 抓得住:①有人把 `nav_collapsed_width` 改回 48(`48 − 2×X1 = 40 < 56` ⇒ 第三条红,且第一条 + /// 直接对不上算式);②改胶囊尺寸却忘了改折叠档(第一条红:`nav_collapsed_width` 不再等于 + /// 「胶囊 + 2×X1」);③折叠档比命中下限还窄(第四条红)。 + /// 抓不住:真画出来的裁切(那要出图,见 `DevDocs/artifacts/m300/nav-collapsed-*.png`)。 + #[test] + fn the_collapsed_rail_fits_the_pill_plus_its_side_padding() { + let m = LayoutMetrics::DESKTOP; + // 胶囊宽 = 轨宽 − 2×16(M283 定稿 56×32,本 mission 不动它) + let pill = m.nav_rail_width - Space::X4.dp() * 2.0; + assert_eq!(pill, 56.0, "M283 的胶囊宽"); + assert_eq!( + m.nav_collapsed_width, + pill + 2.0 * Space::X1.dp(), + "折叠档 = 胶囊 + 两侧各一档 X1" + ); + assert_eq!(m.nav_collapsed_width, 64.0); + // 旧的 48 为什么死:它连一档 X1 的留白都放不下胶囊。 + assert!( + 48.0 - 2.0 * Space::X1.dp() < pill, + "48 放不下 56 的胶囊 —— 这正是旧令牌被删的原因" + ); + assert!( + m.nav_collapsed_width >= m.touch_target, + "折叠档(每一项的整条命中区)不许低于 48" + ); + assert!(m.nav_collapsed_width < m.nav_rail_width); + } + #[test] fn search_bar_height_is_a_page_level_entry() { let m = LayoutMetrics::DESKTOP; diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index ba64137..3fd859b 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -6,6 +6,8 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("NavDownloadExpanded", "Version management", "版本管理"), ("NavLogs", "Logs", "日志"), ("NavSettings", "Settings", "设置"), + ("NavCollapse", "Collapse the navigation rail", "折叠导航轨"), + ("NavExpand", "Expand the navigation rail", "展开导航轨"), ("InstancesNew", "New instance", "新建实例"), ("InstancesImport", "Import", "导入"), ("InstancesBack", "Back to instances", "返回实例列表"), diff --git a/crates/i18n/src/catalog/nav.rs b/crates/i18n/src/catalog/nav.rs index 4b48360..6fa5109 100644 --- a/crates/i18n/src/catalog/nav.rs +++ b/crates/i18n/src/catalog/nav.rs @@ -35,6 +35,11 @@ macro_rules! nav_entries { NavDownloadExpanded => ("Version management", "版本管理"), NavLogs => ("Logs", "日志"), NavSettings => ("Settings", "设置"), + // M300(NAVCOLLAPSE):轨底折叠按钮的**可读名**。按钮只有图标(`IconKind::Sidebar`), + // 所以这两句是它的 tooltip —— 与 `pages/agent` 的既有做法同一条纪律(「条上每个可点 + // 元素都有可读文字」)。两态各一条,不拼字符串:文案要有主语与动作,别做 `Collapse {x}`。 + NavCollapse => ("Collapse the navigation rail", "折叠导航轨"), + NavExpand => ("Expand the navigation rail", "展开导航轨"), } } }; } diff --git a/crates/presenter/src/prefs.rs b/crates/presenter/src/prefs.rs index 5441456..46d3e28 100644 --- a/crates/presenter/src/prefs.rs +++ b/crates/presenter/src/prefs.rs @@ -3,7 +3,8 @@ //! 偏好不自己写盘:core::setting 的 schema 按 [`keys::ALL`] 登记这些键, //! app 启动时读一次、变更时写回(跨模块约定见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`)。 //! 落盘实现是 [`crate::settings_bridge`](同一份 `config/launcher.json`,与 `ull config set` 同源), -//! 主题三态 + `appearance.*` 高级键已接(M41);其余 UI 偏好键仍只在内存里(后续 mission 接)。 +//! 主题三态 + `appearance.*` 高级键已接(M41);其余 UI 偏好键仍只在内存里(后续 mission 接), +//! **唯一例外是 [`UiPrefs::nav_collapsed`](M300 已接:schema 键 + 读 + 写三处都在)**。 /// 背景图填充模式(09 §101)。直接复用 core 的枚举:取值与落盘词表只有一处真相。 pub use unlucky_core::setting::appearance::BgFill; @@ -304,7 +305,7 @@ impl AppearanceBase { /// 注意:`Eq` 不成立([`AppearancePrefs`] 带 `f32` 滑条值),用到的地方按 `PartialEq` 比。 #[derive(Debug, Clone, PartialEq)] pub struct UiPrefs { - /// 左侧导航栏折叠成图标条 + /// 左侧导航栏折叠成图标条(M300 起**已落盘**:键 [`keys::NAV_COLLAPSED`],读 `SettingsBridge::load_nav_collapsed`) pub nav_collapsed: bool, /// 右侧 AI 栏折叠成 icon 轨 pub ai_collapsed: bool, @@ -337,7 +338,7 @@ impl Default for UiPrefs { /// 持久化键名登记(core::setting schema 按此注册;改名等于迁移,慎动)。 pub mod keys { - /// 导航栏折叠 + /// 导航栏折叠(M300 起是**真落盘**键,core schema 同名字符串) pub const NAV_COLLAPSED: &str = "ui.nav.collapsed"; /// AI 栏折叠 pub const AI_COLLAPSED: &str = "ui.ai.collapsed"; diff --git a/crates/presenter/src/settings_bridge.rs b/crates/presenter/src/settings_bridge.rs index 27ee8cb..a15d374 100644 --- a/crates/presenter/src/settings_bridge.rs +++ b/crates/presenter/src/settings_bridge.rs @@ -12,6 +12,7 @@ use std::path::{Path, PathBuf}; use std::time::SystemTime; +use serde_json::Value; use unlucky_core::platform::data_dir::Resolver; use unlucky_core::platform::paths::Platform; use unlucky_core::setting::appearance::Appearance; @@ -133,6 +134,47 @@ impl SettingsBridge { } } + /// 左侧导航轨的折叠档(**M300**):`UiPrefs::nav_collapsed` 的**唯一读入口**。 + /// + /// `ui.*` 这一族偏好此前**全部只在内存里**(见 `prefs.rs` 模块文档的「其余 UI 偏好键仍只在 + /// 内存里」)——M300 起那句对 `nav_collapsed` 不再成立:它在 core 的 schema 里登记了 + /// (`ui.nav.collapsed`)、在这里读、在 [`SettingsBridge::persist_nav_collapsed`] 写。 + /// + /// 缺键 / 值被手改成非布尔 → **`false`(展开)**:出厂态是展开,坏配置不许把用户关进图标条。 + #[must_use] + pub fn load_nav_collapsed(&self) -> bool { + self.store + .as_ref() + .and_then(|store| { + store + .doc() + .effective_bool(crate::prefs::keys::NAV_COLLAPSED) + }) + .unwrap_or(false) + } + + /// 写回左侧导航轨的折叠档(**M300**)。返回 `true` = 磁盘上真的有新内容。 + /// + /// 走 [`SettingsBridge::write_with`](唯一写入口:现读 → 改 → 落盘 → 刷 mtime),于是 + /// 「刚存完 AI 配置就点折叠」不会把 `ai.*` 连密钥一起抹掉(M174 两轮复审的教训)。 + /// 值没变时返回 `Edit::Unchanged`:重复点同一个档位不该产生一次写盘 + `mtime` 抖动。 + pub fn persist_nav_collapsed(&mut self, collapsed: bool) -> bool { + self.write_with(|store| { + if store + .doc() + .effective_bool(crate::prefs::keys::NAV_COLLAPSED) + == Some(collapsed) + { + return Edit::Unchanged; + } + match store.set(crate::prefs::keys::NAV_COLLAPSED, Value::from(collapsed)) { + Ok(()) => Edit::Changed, + Err(_) => Edit::Rejected, + } + }) + .is_saved() + } + /// 落盘(失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。 /// /// **写走 [`SettingsBridge::write_with`]**(M174 r1/r2 复审):本桥的 store 是长生命的 @@ -197,200 +239,6 @@ fn resolve_config_path() -> Option { fn mtime(path: &Path) -> Option { std::fs::metadata(path).ok()?.modified().ok() } - #[cfg(test)] -mod tests { - use super::*; - use crate::prefs::PaletteSource; - 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 - } - - /// **M216 ①**:数据根四键的写回落点 = 启动解析读的那份文件。 - /// - /// 抓得住:四键 → 便携那份、其余键 → 解析后那份、拿不到 exe 目录时退回。抓不住:真实 - /// `current_exe()` 的取值与 `Resolver` 的 `redirect.json` 跟随(那两条靠 `set` 的回合用例)。 - #[test] - fn data_root_keys_are_written_where_startup_reads_them() { - let exe = PathBuf::from("/games/ULL"); - let portable = crate::host::env::portable_config_path(&exe); - let resolved = PathBuf::from("/appdata/ULL/config/launcher.json"); - for key in crate::host::env::DATA_ROOT_KEYS { - assert_eq!(setting_path(&resolved, Some(&exe), key), portable, "{key}"); - } - for key in ["java.default_memory_mb", "ai.model", "core.unknown_suffix"] { - assert_eq!(setting_path(&resolved, Some(&exe), key), resolved, "{key}"); - } - // 拿不到 exe 目录:退回解析后那份(宁可写「没人读的那份」也不丢写、不 panic)。 - assert_eq!(setting_path(&resolved, None, "core.data_mode"), resolved); - } - - #[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 热切捕获"); - } - - /// **P2-4 回归**:外观写回不许盖掉别人刚写的键(`ai.api_key` 最贵)。修之前必红。 - #[test] - fn persist_keeps_keys_another_writer_just_saved() { - let path = temp_config("persist-keeps-ai"); - let mut bridge = SettingsBridge::with_path(&path); - // 先让本桥真的加载过一份文件(旧快照从这里来)。 - bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light); - - { - use unlucky_core::setting::keys::launcher_schema; - use unlucky_core::setting::store::SettingStore; - // 另一个写者(M174 的助手设置页 / `ull config set`)写进三个 AI 键。 - let mut store = SettingStore::load(&path, launcher_schema()); - for (key, value) in [ - ("ai.base_url", "https://api.deepseek.com/v1"), - ("ai.model", "deepseek-chat"), - ("ai.api_key", "sk-must-survive"), - ] { - store.set(key, value.to_string().into()).expect("写 AI 键"); - } - store.save().expect("落盘"); - } - - // 同一秒内改主题:外观写回。 - bridge.persist(&AppearancePrefs::factory(), ThemeMode::Dark); - - let text = std::fs::read_to_string(&path).expect("读文件"); - assert!( - text.contains("sk-must-survive"), - "外观写回把刚保存的密钥盖掉了:{text}" - ); - assert!(text.contains("deepseek-chat"), "model 也被盖掉了:{text}"); - assert!(text.contains("\"dark\""), "主题这一项该写进去:{text}"); - std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); - } - - #[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(), "路径空也不算生效"); - } - - /// `load_base()` 是「重启后读回整窗 α / 配色来源 / 窗口圆角」的唯一通道(M93 · M116)。 - /// - /// 抓得住:①文件里写着 `window_opacity=0.6` / `palette_source=wallpaper` 时能被**新开的** - /// bridge 读到(= 重启语义);②缺键时回出厂(1.0 / preset),而不是 `0.0` 或随机档。 - /// 抓不住:UI 侧是否真的用它来下发窗口 α(那由 `app-iced` 的日志与单测证明)。 - #[test] - fn load_base_reads_the_four_read_only_keys_and_defaults_when_missing() { - let path = temp_config("base"); - // 文件由「外部写者」(CLI / AI / 设置页的 SetSetting)产生,这里直接落 JSON。 - std::fs::write( - &path, - r#"{"appearance":{"theme_mode":"dark","window_opacity":0.6,"palette_source":"wallpaper","panel_alpha":0.5}}"#, - ) - .expect("写配置文件"); - - let bridge = SettingsBridge::with_path(&path); - let base = bridge.load_base(); - assert_eq!(base.window_opacity, 0.6, "重启后读回整窗 α"); - assert_eq!(base.palette_source, PaletteSource::Wallpaper); - assert!(base.is_transparent()); - - // 缺键 → 出厂值(与 `AppearanceBase::factory()` 逐字段相等)。 - let empty = path.with_file_name("launcher-empty.json"); - std::fs::write(&empty, "{}").expect("写空配置"); - let factory = SettingsBridge::with_path(&empty).load_base(); - assert_eq!(factory, AppearanceBase::factory()); - assert_eq!(factory.window_opacity, 1.0); - assert_eq!(factory.palette_source, PaletteSource::Preset); - - // 数据根解析不出来的内存态桥也要给得出出厂值(不 panic、不返回 0)。 - assert_eq!( - SettingsBridge::memory_only().load_base(), - AppearanceBase::factory() - ); - - std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); - } -} +#[path = "settings_bridge/tests.rs"] +mod tests; diff --git a/crates/presenter/src/settings_bridge/tests.rs b/crates/presenter/src/settings_bridge/tests.rs new file mode 100644 index 0000000..298707d --- /dev/null +++ b/crates/presenter/src/settings_bridge/tests.rs @@ -0,0 +1,250 @@ +//! `SettingsBridge` 的单测(M300 从 `settings_bridge.rs` **纯搬移**,守 400 行硬线)。 +//! +//! 搬移理由:M300 给桥加了 `load_nav_collapsed` / `persist_nav_collapsed` 两件(折叠档落盘), +//! 原文件 434 行已破门禁。判据与注释一字未动,只是换文件——`settings_bridge.rs` 里现在是一行 +//! `#[cfg(test)] #[path = "settings_bridge/tests.rs"] mod tests;`,模块路径仍是 +//! `settings_bridge::tests`。 + +use super::*; +use crate::prefs::PaletteSource; +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 +} + +/// **M216 ①**:数据根四键的写回落点 = 启动解析读的那份文件。 +/// +/// 抓得住:四键 → 便携那份、其余键 → 解析后那份、拿不到 exe 目录时退回。抓不住:真实 +/// `current_exe()` 的取值与 `Resolver` 的 `redirect.json` 跟随(那两条靠 `set` 的回合用例)。 +#[test] +fn data_root_keys_are_written_where_startup_reads_them() { + let exe = PathBuf::from("/games/ULL"); + let portable = crate::host::env::portable_config_path(&exe); + let resolved = PathBuf::from("/appdata/ULL/config/launcher.json"); + for key in crate::host::env::DATA_ROOT_KEYS { + assert_eq!(setting_path(&resolved, Some(&exe), key), portable, "{key}"); + } + for key in ["java.default_memory_mb", "ai.model", "core.unknown_suffix"] { + assert_eq!(setting_path(&resolved, Some(&exe), key), resolved, "{key}"); + } + // 拿不到 exe 目录:退回解析后那份(宁可写「没人读的那份」也不丢写、不 panic)。 + assert_eq!(setting_path(&resolved, None, "core.data_mode"), resolved); +} + +#[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 热切捕获"); +} + +/// **P2-4 回归**:外观写回不许盖掉别人刚写的键(`ai.api_key` 最贵)。修之前必红。 +#[test] +fn persist_keeps_keys_another_writer_just_saved() { + let path = temp_config("persist-keeps-ai"); + let mut bridge = SettingsBridge::with_path(&path); + // 先让本桥真的加载过一份文件(旧快照从这里来)。 + bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light); + + { + use unlucky_core::setting::keys::launcher_schema; + use unlucky_core::setting::store::SettingStore; + // 另一个写者(M174 的助手设置页 / `ull config set`)写进三个 AI 键。 + let mut store = SettingStore::load(&path, launcher_schema()); + for (key, value) in [ + ("ai.base_url", "https://api.deepseek.com/v1"), + ("ai.model", "deepseek-chat"), + ("ai.api_key", "sk-must-survive"), + ] { + store.set(key, value.to_string().into()).expect("写 AI 键"); + } + store.save().expect("落盘"); + } + + // 同一秒内改主题:外观写回。 + bridge.persist(&AppearancePrefs::factory(), ThemeMode::Dark); + + let text = std::fs::read_to_string(&path).expect("读文件"); + assert!( + text.contains("sk-must-survive"), + "外观写回把刚保存的密钥盖掉了:{text}" + ); + assert!(text.contains("deepseek-chat"), "model 也被盖掉了:{text}"); + assert!(text.contains("\"dark\""), "主题这一项该写进去:{text}"); + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); +} + +#[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(), "路径空也不算生效"); +} + +/// `load_base()` 是「重启后读回整窗 α / 配色来源 / 窗口圆角」的唯一通道(M93 · M116)。 +/// +/// 抓得住:①文件里写着 `window_opacity=0.6` / `palette_source=wallpaper` 时能被**新开的** +/// bridge 读到(= 重启语义);②缺键时回出厂(1.0 / preset),而不是 `0.0` 或随机档。 +/// 抓不住:UI 侧是否真的用它来下发窗口 α(那由 `app-iced` 的日志与单测证明)。 +#[test] +fn load_base_reads_the_four_read_only_keys_and_defaults_when_missing() { + let path = temp_config("base"); + // 文件由「外部写者」(CLI / AI / 设置页的 SetSetting)产生,这里直接落 JSON。 + std::fs::write( + &path, + r#"{"appearance":{"theme_mode":"dark","window_opacity":0.6,"palette_source":"wallpaper","panel_alpha":0.5}}"#, + ) + .expect("写配置文件"); + + let bridge = SettingsBridge::with_path(&path); + let base = bridge.load_base(); + assert_eq!(base.window_opacity, 0.6, "重启后读回整窗 α"); + assert_eq!(base.palette_source, PaletteSource::Wallpaper); + assert!(base.is_transparent()); + + // 缺键 → 出厂值(与 `AppearanceBase::factory()` 逐字段相等)。 + let empty = path.with_file_name("launcher-empty.json"); + std::fs::write(&empty, "{}").expect("写空配置"); + let factory = SettingsBridge::with_path(&empty).load_base(); + assert_eq!(factory, AppearanceBase::factory()); + assert_eq!(factory.window_opacity, 1.0); + assert_eq!(factory.palette_source, PaletteSource::Preset); + + // 数据根解析不出来的内存态桥也要给得出出厂值(不 panic、不返回 0)。 + assert_eq!( + SettingsBridge::memory_only().load_base(), + AppearanceBase::factory() + ); + + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); +} + +/// **M300 落盘往返**:`ui.nav.collapsed` 写进文件 → **重新构造桥**(= 重启)→ 读回。 +/// +/// 这条用例守的是任务单里那句「不然重启就回去了」:只断言内存字段的用例对它零约束 +/// (字段本来就地改,重启后 `UiPrefs::default()` 照样是展开)。 +/// +/// **抓得住**:①`persist_nav_collapsed` 忘了 `store.save()`(第二次构造读回 false); +/// ②键名两侧不一致(core schema 叫 `ui.nav.collapsed`、桥写成别的串 ⇒ `store.set` 被 +/// schema 拒 ⇒ 第一次写返回 false);③core 没登记这个键(`UnknownKey` ⇒ 同样第一次写就 false); +/// ④`load_nav_collapsed` 读的是内存快照而不是刚 reload 的文档(跑完这次进程内的旧值还能骗过它, +/// 但「换一个桥对象」那一步就露馅)。 +/// **抓不住**:真实数据根解析(`SettingsBridge::open()` 那条路,见本文件另一组用例)、 +/// 并发写盘(`save_worker` 的锁),以及 UI 那一侧有没有真的调它(那归 app 侧的 +/// `app/update/nav.rs` 用例)。 +#[test] +fn nav_collapsed_survives_a_restart() { + let path = temp_config("nav-collapsed"); + + // ① 出厂(文件不存在):展开。 + let mut bridge = SettingsBridge::with_path(&path); + assert!(!bridge.load_nav_collapsed(), "缺键 = 出厂展开"); + + // ② 折叠 → 真的落盘。 + assert!( + bridge.persist_nav_collapsed(true), + "第一次写 `ui.nav.collapsed` 必须真的落盘(false = 被 schema 拒 / 没存)" + ); + + // ③ 「重启」:换一个桥对象读同一份文件。 + let reopened = SettingsBridge::with_path(&path); + assert!( + reopened.load_nav_collapsed(), + "重启后必须读回折叠档(否则折叠是假的)" + ); + + // ④ 幂等:同一个值再写一次不该产生写盘(`Edit::Unchanged`)。 + let mut bridge = reopened; + assert!(!bridge.persist_nav_collapsed(true), "值没变时不该再写盘"); + + // ⑤ 展开也要落盘(只写 true 的实现会在这里红)。 + assert!(bridge.persist_nav_collapsed(false)); + assert!(!SettingsBridge::with_path(&path).load_nav_collapsed()); + + // ⑥ 内存态桥(数据根解析不出来):不 panic、也不假装写成功。 + let mut memory = SettingsBridge::memory_only(); + assert!(!memory.load_nav_collapsed()); + assert!(!memory.persist_nav_collapsed(true)); + + std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); +}