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

+63 -1
View File
@@ -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;