Files
Unlucky-Launcher/crates/app-iced/src/app/update/nav.rs
T
AskaEth fa53d72f73 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 空白带(像素图里保留、未裁)。
2026-11-13 16:15:16 +08:00

258 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 左侧导航轨折叠 / 展开的**唯一落点**(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);
}
}