feat(nav): M300 导航轨折叠按钮 + 折叠宽度令牌 + 宽度过渡与落盘

用户原话「默认左侧固定,允许折叠」:M297 盘查确认 iced 从来没接过折叠按钮,
presenter 的 toggle_nav/nav_collapsed 是 Slint 遗留、唯一调用方在已退役的 crates/app。

## 令牌(先定数值再动手)
- nav_collapsed_width = 56 + 2 × Space::X1 = **64**:胶囊(M283 定稿 56×32,本轮不动)
  两侧各留 4px 才不被裁切;旧值 48 的死因写进注释(48 − 2×4 = 40 < 56)。
- 断言:折叠宽 ≥ 胶囊宽 + 2×X1、≥ touch_target 48、在 4px 栅格上。

## 折叠按钮与折叠态
- 按钮放**轨底、头像位之上**:轨顶一个像素都不动(M283/M299 的展开态对照图仍可比),
  轨底本来就是「固定贴底」的槽,两态下位置不变。几何与导航项同一套(56×32 药丸 + 52 命中高)。
- 折叠态:隐藏文字标签与账户名,每项/头像/按钮都补 M3 气泡 tooltip(折叠态不许是一列无名符号);
  纵节奏不变(项高仍 52),所以切换时图标不上下跳。五项图标两两不同由用例钉住。

## 动效(复用底盘、零新常数)
- 帧泵加**第二条并列宽度轨**(nav ⇄ rail),时长/曲线复用同一张 rail_spec();
  借用右栗那条会让左轨折叠改掉右栗的目标(暗耦合),tower 已否掉那个方案。
- 起跑在 update 侧;启动按落盘档对表(幂等,仅在没有轨道在跑时);折叠**不置 view_dirty**
  —— 宽度在 view 里逐帧采样,帧由轨道自己续,跑完 needs_frames() 回 false。

## 落盘(任务要求「重启读回仍折叠」)
- core schema 登记 ui.nav.collapsed(纯增量 bool,不碰 schemaVersion);presenter 桥加
  load/persist 一对(走唯一写入口 write_with,现读→改→落盘);启动 construct 读回。
- 截图通道不读用户配置(D30),折叠态出图走 ULL_NAV_COLLAPSED=1。

## 判据
- app-iced: 1053 passed(新增 3 条接线用例 + 7 条 shell/几何/树结构用例 + 6 条帧泵用例);
  presenter: 408 passed(新增落盘往返 1 条);fmt / clippy --workspace --all-targets 均 0。
- 变异实验:删 observe_nav ⇒ 宽度停在 88 红;删启动对表 ⇒ 首次展开硬跳红。
- 像素(DevDocs/artifacts/m300/):折叠态内容左缘 112→88(24px 真回收)、药丸两态都 56 宽
  且折叠档右缘 59 < 64(不裁切)、图标行是展开态子集(纵节奏不变)。

## 已知偏离(已登记,tower 批了紧随 mission)
crates/app-iced/src/pages/layout.rs 的 rail_width() 仍是 const fn(恒 88),所以折叠态内容块
宽度不变、整块左移 24px ⇒ 右侧多一条 24px 空白带(像素图里保留、未裁)。
This commit is contained in:
AskaEth committed 2026-11-13 16:15:16 +08:00
1 parent 1ce494631c
commit fa53d72f73
26 files changed
+1640 -320

No files matched your search

+257
View File
@@ -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<Message> {
// ① 起跑前先把泵的目标对到**真实档位**(幂等,且只在没有轨道在跑时做):
// - 泵的目标出厂是「展开」,而用户可能上次就是折叠着退出的(`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);
}
}