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

+6
View File
@@ -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,
/// 边缘热区:开始缩放(方向由坐标决定)。
@@ -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`)。
+4
View File
@@ -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(),
@@ -126,6 +126,22 @@ pub(super) fn env_motion_fixture_at() -> Option<u32> {
.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)。
///
/// ## 为什么这一拍只能「先记、后落」
@@ -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 {
+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);
}
}
+28 -3
View File
@@ -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())
}
}
@@ -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 的
@@ -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<RailTrack>,
/// 右栗的目标档(`true` = 展开)。没有轨道时进度就是它的终值。
rail_open: bool,
/// 左轨(导航轨)折叠轨道(M300):与右栗**并列**的另一条宽度轨(`64 ⇄ 88`)。
nav: Option<RailTrack>,
/// 左轨的目标档(`true` = 展开 88)。出厂展开;启动时按落盘档由 [`Pump::snap_nav`] 对表。
nav_open: bool,
/// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。
close_pending: bool,
screen: Option<u64>,
@@ -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) => {
@@ -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<RailTrack> {
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,
}
}
}
@@ -9,6 +9,7 @@
//! 模态 / 系统偏好 / 降级那一半在 [`modal`]。
mod modal;
mod nav;
mod rail;
mod timeline;
@@ -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<f32> {
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);
}
+9
View File
@@ -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;
+121 -82
View File
@@ -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::<Vec<_>>();
// 轨顶留白走 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<Color> {
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<ForcedState>,
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<Color> {
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;
+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})");
}
}
+73
View File
@@ -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
}
+169
View File
@@ -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::<usize>()
}
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::<usize>()
}
/// **折叠态:文字标签不进控件树,但每个图标都有可读名(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} 时轨比胶囊还窄"
);
}
}
+49
View File
@@ -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()
}
+118
View File
@@ -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<ForcedState>,
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::<Message>(
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
}),
)
}