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

+68 -13
View File
@@ -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})");
}
}