Merge branch 'feat/m300-navcollapse-nav-rail-collapse-butto'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
GPU evidence / GPU 证据 — 真实产物判据 (push) Canceled after 0s
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Canceled after 0s
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Canceled after 0s
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-13 16:42:58 +08:00
commit e9090d5668
26 files changed
+1640 -320

No files matched your search

+6
View File
@@ -53,6 +53,12 @@ pub enum Message {
/// M90 起产品界面用 `pages` 里的真 `NavigationRail`,这条变体是给**外壳回退路径** /// M90 起产品界面用 `pages` 里的真 `NavigationRail`,这条变体是给**外壳回退路径**
/// (`Route::Agent`)留的——`shell` 不在本 mission 的 scope 里,不能删它的消息。 /// (`Route::Agent`)留的——`shell` 不在本 mission 的 scope 里,不能删它的消息。
Select(NavItem), Select(NavItem),
/// 收起 / 展开左侧导航轨(**M300**)。
///
/// 为什么不复用 [`Message::Select`]:那个变体的唯一语义是「导航项 → 路由」(`route_of` 是
/// 全仓唯一的转换点)。把「折叠」塞进去会让 `route_of` 多出一条不是路由的分支,而它的
/// 清白正是两条导航入口共用同一套页面语义的前提。落点在 `app/update/nav.rs`。
ToggleNav,
/// 按下标题栏 → 拖窗。 /// 按下标题栏 → 拖窗。
DragWindow, DragWindow,
/// 边缘热区:开始缩放(方向由坐标决定)。 /// 边缘热区:开始缩放(方向由坐标决定)。
@@ -171,6 +171,13 @@ impl App {
// M229:设置页「主题」行的选中态来自设置组自有状态(`language_appearance::State::theme`), // M229:设置页「主题」行的选中态来自设置组自有状态(`language_appearance::State::theme`),
// 唯一写者是 `App::set_theme_mode`;这里把启动时读到的档位灌进去,首帧才不会画错档。 // 唯一写者是 `App::set_theme_mode`;这里把启动时读到的档位灌进去,首帧才不会画错档。
app.settings.language_appearance.theme = 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` 必须由解析出的方案构造,而不是色板。 // 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是色板。
app.rebuild_ui(); app.rebuild_ui();
// M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。 // M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。
+4
View File
@@ -60,6 +60,8 @@ mod download;
mod instances; mod instances;
mod motion_sync; mod motion_sync;
mod page_messages; mod page_messages;
// M300:左侧导航轨折叠 / 展开(`Message::ToggleNav` 的落点;`update.rs` 只留 3 行转发)
mod nav;
mod settings; mod settings;
mod update_action; mod update_action;
@@ -169,6 +171,8 @@ impl App {
self.refresh_view(); self.refresh_view();
Task::none() Task::none()
} }
// M300:左侧导航轨折叠 / 展开(正文在 `update/nav.rs`:`update.rs` 贴 400 行硬线)
Message::ToggleNav => self.on_toggle_nav(),
Message::DragWindow => match self.window { Message::DragWindow => match self.window {
Some(id) => window::drag(id), Some(id) => window::drag(id),
None => Task::none(), None => Task::none(),
@@ -126,6 +126,22 @@ pub(super) fn env_motion_fixture_at() -> Option<u32> {
.filter(|beats| *beats > 0) .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)。 /// 把启动那一拍的**事实**写进启动器自己的日志(M187 / r1 复审 P2-4)。
/// ///
/// ## 为什么这一拍只能「先记、后落」 /// ## 为什么这一拍只能「先记、后落」
@@ -44,6 +44,17 @@ impl App {
if self.window.is_none() && !self.has_screenshot() { if self.window.is_none() && !self.has_screenshot() {
return; 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 route = self.state.route();
let screen = screen_key(route); let screen = screen_key(route);
let detail = match 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 行硬线,而这块逻辑与页面树无关—— //! 为什么单独一个文件:`app/view.rs` 贴着 400 行硬线,而这块逻辑与页面树无关——
//! 它只回答「轨底那个头像位现在该显示成未登录,还是某个账户」。 //! 它只回答「轨底那个头像位现在该显示成未登录,还是某个账户」,以及「这一帧轨多宽」。
use crate::app::components::motion;
use crate::shell; use crate::shell;
use super::super::state::App; use super::super::state::App;
@@ -16,12 +17,17 @@ const RAIL_COMPACT_HEIGHT: f32 = 720.0;
impl App { impl App {
/// 导航轨底部头像位的状态(M114):未登录 / 已登录两态要一眼能分。 /// 导航轨底部头像位的状态(M114):未登录 / 已登录两态要一眼能分。
///
/// M300:这里同时是轨的折叠档与过渡进度的**唯一构造点**——`shell::NavRail`(不在 scope 内)
/// 只负责把它转交给 `nav::rail`,几何全部来自 [`shell::RailAccount::nav_width`]。
pub(super) fn rail_account(&self) -> shell::RailAccount { pub(super) fn rail_account(&self) -> shell::RailAccount {
let compact = self.height < RAIL_COMPACT_HEIGHT; let compact = self.height < RAIL_COMPACT_HEIGHT;
let (collapsed, progress) = self.nav_state();
match self.state.active_account() { match self.state.active_account() {
Some(account) => { Some(account) => {
shell::RailAccount::signed_in(account.monogram(), account.name.clone()) shell::RailAccount::signed_in(account.monogram(), account.name.clone())
.compact(compact) .compact(compact)
.nav(collapsed, progress)
} }
// M106:未登录标签跟着**生效语言**走(早期钉死 `Lang::En` 的写法已删)。 // M106:未登录标签跟着**生效语言**走(早期钉死 `Lang::En` 的写法已删)。
None => shell::RailAccount::signed_out( None => shell::RailAccount::signed_out(
@@ -29,7 +35,26 @@ impl App {
.catalog() .catalog()
.text(unlucky_i18n::Key::AccountRailSignedOut), .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 的 /// **总是**套这一层(位移为 `(0,0)` 时只是 `padding = 0`):它是树的**根**,而 iced 的
@@ -4,13 +4,14 @@
//! 「帧间隔上限」「结束后必须清空」这两条守卫是**算术性质**,用本地 `Pump` 直接摆时间轴就能判死, //! 「帧间隔上限」「结束后必须清空」这两条守卫是**算术性质**,用本地 `Pump` 直接摆时间轴就能判死,
//! 不必起窗口、不必等真实时间(那两条最容易写成「看起来在跑」的假绿)。 //! 不必起窗口、不必等真实时间(那两条最容易写成「看起来在跑」的假绿)。
//! //!
//! ## 两条轨道(M256):页面的两腿 + 模态的进出,各走各的时间轴(互不替代) //! ## 四条轨道(M256 / M257 / M300):页面两腿 + 模态进出 + 两条宽度轨,各走各的时间轴(互不替代)
//! //!
//! | 轨道 | 触发 | 结束 | //! | 轨道 | 触发 | 结束 |
//! |---|---|---| //! |---|---|---|
//! | 页面 | 内容标识变了([`Pump::observe`]) | 两腿里长的那条走完 | //! | 页面 | 内容标识变了([`Pump::observe`]) | 两腿里长的那条走完 |
//! | 模态 | 弹窗开 / 关([`Pump::observe_modal`] / [`Pump::begin_modal_close`]) | 进场转 [`Modal::Open`](**不再要帧**)、退场转 [`Modal::Hidden`] | //! | 模态 | 弹窗开 / 关([`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`]),页面只许按表取——它只能说「我要换页了」。 //! D31:时长/缓动是**表**([`super::transition`]),页面只许按表取——它只能说「我要换页了」。
@@ -68,6 +69,10 @@ pub struct Pump {
rail: Option<RailTrack>, rail: Option<RailTrack>,
/// 右栗的目标档(`true` = 展开)。没有轨道时进度就是它的终值。 /// 右栗的目标档(`true` = 展开)。没有轨道时进度就是它的终值。
rail_open: bool, rail_open: bool,
/// 左轨(导航轨)折叠轨道(M300):与右栗**并列**的另一条宽度轨(`64 ⇄ 88`)。
nav: Option<RailTrack>,
/// 左轨的目标档(`true` = 展开 88)。出厂展开;启动时按落盘档由 [`Pump::snap_nav`] 对表。
nav_open: bool,
/// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。 /// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。
close_pending: bool, close_pending: bool,
screen: Option<u64>, screen: Option<u64>,
@@ -90,6 +95,8 @@ impl Pump {
modal: Modal::Hidden, modal: Modal::Hidden,
rail: None, rail: None,
rail_open: false, rail_open: false,
nav: None,
nav_open: true,
close_pending: false, close_pending: false,
screen: None, screen: None,
rank: None, rank: None,
@@ -116,6 +123,10 @@ impl Pump {
// 定档时右栗按出厂档(收起)复位:定档之后的第一帧画的就是 48,不会先画一次展开态。 // 定档时右栗按出厂档(收起)复位:定档之后的第一帧画的就是 48,不会先画一次展开态。
self.rail = None; self.rail = None;
self.rail_open = false; 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; self.close_pending = false;
if scale.is_off() { if scale.is_off() {
// 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。 // 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。
@@ -143,9 +154,15 @@ impl Pump {
pub fn is_animating(&self) -> bool { pub fn is_animating(&self) -> bool {
self.page.is_some() self.page.is_some()
|| self.rail.is_some() || self.rail.is_some()
|| self.nav.is_some()
|| matches!(self.modal, Modal::Opening { .. } | Modal::Closing { .. }) || matches!(self.modal, Modal::Opening { .. } | Modal::Closing { .. })
} }
/// 左轨的宽度轨道是否在跑(`motion_sync` 用它判「该不该按落盘档对表」)。
pub fn nav_animating(&self) -> bool {
self.nav.is_some()
}
/// 轨道起点的口径:钉住相位时恒为 0(进度只取决于钉住值,与"谁在第几帧起跑"无关)。 /// 轨道起点的口径:钉住相位时恒为 0(进度只取决于钉住值,与"谁在第几帧起跑"无关)。
fn start_now(&self) -> f64 { fn start_now(&self) -> f64 {
if self.pin.is_some() { if self.pin.is_some() {
@@ -305,6 +322,11 @@ impl Pump {
self.rail = None; self.rail = None;
} }
} }
if let Some(track) = self.nav {
if now - track.start_ms >= track.duration_ms {
self.nav = None;
}
}
let spec = modal_spec(); let spec = modal_spec();
match self.modal { match self.modal {
Modal::Opening { start_ms } if now - start_ms >= self.leg_ms(spec.enter.duration) => { 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` 那一段的**时间轴**。
//! //!
//! 与页面 / 模态两条轨道并列的第三条:它只管「这一帧折叠到几成」,不碰宽度怎么算 //! 与页面 / 模态两条轨道并列的第三条(M300 起是**两条**宽度轨道,左 `nav` / 右 `rail`):
//! (宽度是 `pages/agent/metrics.rs` 的纯函数)、也不碰画什么(那是视图的事)。 //! 它们只管「这一帧折叠到几成」,不碰宽度怎么算(宽度是 `pages/agent/metrics.rs` /
//! `shell/nav.rs` 的纯函数)、也不碰画什么(那是视图的事)。
//! //!
//! | 项 | 值 | 出处 | //! | 项 | 值 | 出处 |
//! |---|---|---| //! |---|---|---|
//! | 展开(进) | `medium1` 250ms + `Emphasized` | `14-motion-and-animation.md` §3 A2(token 列)+ §7-1「强调曲线优先」「进略长于出」 | //! | 展开(进) | `medium1` 250ms + `Emphasized` | `14-motion-and-animation.md` §3 A2(token 列)+ §7-1「强调曲线优先」「进略长于出」 |
//! | 收起(出) | `short4` 200ms + `EmphasizedAccelerate` | 同上(退场比进场短 50ms) | //! | 收起(出) | `short4` 200ms + `EmphasizedAccelerate` | 同上(退场比进场短 50ms) |
//! //!
//! ## 为什么左轨另开一条轨道,而不是复用右栗那条
//!
//! 两条轨是**两个独立的用户动作**:`Pump::rail_open` 只有单个值,左轨一折叠就会把右栗的目标
//! 改掉(`motion::rail(true)` 走「目标不符」兜底 ⇒ 右栗自己的动画被吞掉)。那是暗耦合:
//! 一个功能的动画吃掉另一个功能的动画,出问题时很难查。所以 M300 给它开一条并列轨道
//! ([`Pump::observe_nav`]),**时长与曲线复用同一张 [`rail_spec`]**,零新常数。
//!
//! ## 为什么拆出去档(`Detached`)**不**走这条轨道 //! ## 为什么拆出去档(`Detached`)**不**走这条轨道
//! //!
//! 拆分是**布局切换**(右栗消失、会话搬到独立页),不是宽度的连续变化:把 0 也塞进这条轨道, //! 拆分是**布局切换**(右栗消失、会话搬到独立页),不是宽度的连续变化:把 0 也塞进这条轨道,
@@ -17,12 +25,14 @@
//! ## 起跑只在 update 侧 //! ## 起跑只在 update 侧
//! //!
//! [`Pump::observe_rail`] 由 `pages/agent/state.rs` 的三个档位迁移(`collapse` / `expand` / //! [`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::super::transition::rail_spec;
use super::{leg_fraction, Pump}; use super::{leg_fraction, Pump};
/// 一条正在跑的折叠轨道(0 = 图标条,1 = 展开档)。 /// 一条正在跑的折叠轨道(0 = 折叠档,1 = 展开档)。
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub(crate) struct RailTrack { pub(crate) struct RailTrack {
/// 起跑时已经在几成(中途反向时不为 0/1,保证**不跳变**)。 /// 起跑时已经在几成(中途反向时不为 0/1,保证**不跳变**)。
@@ -56,6 +66,29 @@ impl Pump {
self.rail_open 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` = 真的起跑了轨道(调用方要重置帧基准)。 /// 起跑一次折叠 / 展开。返回 `true` = 真的起跑了轨道(调用方要重置帧基准)。
/// ///
/// **关掉动效时不插轨道**(`MotionScale::NONE`):进度从此恒为终值,`needs_frames()` 不会 /// **关掉动效时不插轨道**(`MotionScale::NONE`):进度从此恒为终值,`needs_frames()` 不会
@@ -66,21 +99,8 @@ impl Pump {
} }
let from = self.rail_progress(); let from = self.rail_progress();
self.rail_open = open; self.rail_open = open;
if self.scale.is_off() { self.rail = self.rail_track(open, from);
// 关掉动效 = 直接落终态(不是跑一遍 0 时长)。 self.rail.is_some()
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
} }
/// 瞬时落档(拆分成独立页 / 窄窗退化):**不**起轨道,进度严格等于终值。 /// 瞬时落档(拆分成独立页 / 窄窗退化):**不**起轨道,进度严格等于终值。
@@ -101,4 +121,44 @@ impl Pump {
None => 0.0, 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`]。 //! 模态 / 系统偏好 / 降级那一半在 [`modal`]。
mod modal; mod modal;
mod nav;
mod rail; mod rail;
mod timeline; 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)。**将来哪边先漂没人拦** //! 这张表(表长 5 / 每一项都指向能高亮它的路由 / 第 5 项没有 section)。**将来哪边先漂没人拦**
//! ——统一要泛型化 shell 的消息类型,成本大于收益;在那之前任何改 rail 几何的改动都要两处一起改 //! ——统一要泛型化 shell 的消息类型,成本大于收益;在那之前任何改 rail 几何的改动都要两处一起改
//! 并各自跑守门(`pages/mod.rs` 偏离登记 #10)。 //! 并各自跑守门(`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; 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.3 形状 None / L0 / `Surface` | `MaterialLayer::Canvas` |
//! | 选中指示器 | 5.4 **56×32**、圆角 full、`SecondaryContainer` | [`indicator_size`](令牌推导) | //! | 选中指示器 | 5.4 **56×32**、圆角 full、`SecondaryContainer` | [`indicator_size`](令牌推导) |
//! | 图标 | 5.5 24px | `IconSize::Toolbar` | //! | 图标 | 5.5 24px | `IconSize::Toolbar` |
@@ -21,15 +21,26 @@ use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer; use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind; use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale; use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::{LayoutMetrics, Space}; use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole; use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Key, Lang}; use unlucky_i18n::{Key, Lang};
mod account; mod account;
// M300:纯几何(两个宽度档的插值 + 药丸矩形),拆出去守 400 行门禁
mod geometry;
// M300:气泡 tooltip(折叠态的可读名靠它)
mod tip;
// M300:轨底的折叠按钮
mod toggle;
pub use account::RailAccount; pub use account::RailAccount;
use account::account_item; 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::components::interaction::{self, Interaction};
use crate::app::Message; use crate::app::Message;
@@ -38,6 +49,8 @@ use crate::icons;
use crate::shell::{filled, label, overridden}; use crate::shell::{filled, label, overridden};
use crate::theme::Ui; use crate::theme::Ui;
use tip::tip;
/// 导航占位项(任务要求 4~6 个;真正的页面由后续 mission 接管)。 /// 导航占位项(任务要求 4~6 个;真正的页面由后续 mission 接管)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavItem { 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>( pub(super) fn rail<'a>(
ui: &'a Ui, ui: &'a Ui,
selected: NavItem, selected: NavItem,
@@ -109,13 +133,29 @@ pub(super) fn rail<'a>(
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let metrics = ui.metrics(); let metrics = ui.metrics();
let indicator = indicator_size(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 let items = NavItem::ALL
.iter() .iter()
// `ALL` 本来就不含 `Account`(它固定在轨底),filter 只是把这条意图写明。 // `ALL` 本来就不含 `Account`(它固定在轨底),filter 只是把这条意图写明。
.filter(|item| **item != NavItem::Account) .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<_>>(); .collect::<Vec<_>>();
// 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的 // 轨顶留白走 container padding,而不是「先塞一个 spacer 元素」:后者会被 column 的
@@ -129,25 +169,82 @@ pub(super) fn rail<'a>(
column![ column![
column(items).spacing(metrics.nav_item_gap), column(items).spacing(metrics.nav_item_gap),
iced::widget::space::vertical(), iced::widget::space::vertical(),
toggle_button(ui, account, forced, lang),
account_item(ui, selected, account, indicator, forced), account_item(ui, selected, account, indicator, forced),
] ]
.height(Length::Fill), .height(Length::Fill),
) )
.width(nav_rail_width(metrics)) .width(base.width)
.height(Length::Fill) .height(Length::Fill)
.padding(Padding::from([ui.space(Space::X6), 0.0])) .padding(Padding::from([ui.space(Space::X6), 0.0]))
.style(move |_theme| filled(ui.layer(MaterialLayer::Canvas))) .style(move |_theme| filled(ui.layer(MaterialLayer::Canvas)))
.into() .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, ui: &'a Ui,
item: NavItem, item: NavItem,
selected: bool, view: ItemView,
compact: bool,
forced: Option<ForcedState>, forced: Option<ForcedState>,
lang: Lang, lang: Lang,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let selected = view.selected;
// 状态层(hover / pressed / 过渡帧)落在**药丸**上,与选中指示器同一块几何(M283)。 // 状态层(hover / pressed / 过渡帧)落在**药丸**上,与选中指示器同一块几何(M283)。
let (pill_width, pill_height) = state_layer_box(ui.metrics()); let (pill_width, pill_height) = state_layer_box(ui.metrics());
// 取证态与运行期态是**同一个** `Interaction`:强制态由 `overridden` 直接推(`None` → `Active` // 取证态与运行期态是**同一个** `Interaction`:强制态由 `overridden` 直接推(`None` → `Active`
@@ -186,14 +283,19 @@ fn rail_item<'a>(
}); });
let item_height = nav_item_height(ui, pill_height); 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)` 就变成空操作, // 列必须显式占满轨宽,否则它会 shrink 到 56,`align_x(Center)` 就变成空操作,
// 药丸会贴左缘(x=0..55 而不是 16..71)——r1 实测踩到的第二个坑。 // 药丸会贴左缘(x=0..55 而不是 16..71)——r1 实测踩到的第二个坑。
let mut stack = column![pill] let mut stack = column![pill]
.width(nav_rail_width(ui.metrics())) .width(view.width)
.align_x(Alignment::Center) .align_x(Alignment::Center)
.height(height); .height(height);
if !compact { if view.labels_visible() {
// M114 的「窄高收成图标条」+ M106 的「按语言取词」并存。 // M114 的「窄高收成图标条」+ M106 的「按语言取词」并存。
stack = stack.push(label( stack = stack.push(label(
ui, ui,
@@ -206,7 +308,7 @@ fn rail_item<'a>(
stack = stack.spacing(ui.space(Space::X1)); stack = stack.spacing(ui.space(Space::X1));
let clickable: Element<'a, Message> = button(stack) let clickable: Element<'a, Message> = button(stack)
.width(nav_rail_width(ui.metrics())) .width(view.width)
.height(height) .height(height)
.padding(0) .padding(0)
.style(move |_theme: &IcedTheme, _status| button::Style { .style(move |_theme: &IcedTheme, _status| button::Style {
@@ -220,82 +322,19 @@ fn rail_item<'a>(
.on_press(Message::Select(item)) .on_press(Message::Select(item))
.into(); .into();
// A8:运行期态套状态取样控件(强制态 = 取证,**不套**:取证图不许有过渡中间帧)。 // A8:运行期态套状态取样控件(强制态 = 取证,**不套**:取证图不许有过渡中间帧)。
if forced.is_none() { let clickable = if forced.is_none() {
interaction::tracked(clickable, true) interaction::tracked(clickable, true)
} else { } else {
clickable clickable
} };
} // M300:标签不画的时候,图标是这一项**唯一**的可见标识 ⇒ 必须有可读名,否则折叠态就是一列
// 无名符号(用户点名不许)。tooltip 是这条判据的落点(与 `pages/agent` 同一条纪律)。
/// 导航轨宽度(M69 起是令牌层的 `nav_rail_width`)。 if view.labels_visible() {
/// clickable
/// 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,
))
} else { } else {
// 导航轨自己画的就是 `MaterialLayer::Canvas`(见 [`rail`] 的 `.style(..)`):状态层要叠在 tip(ui, clickable, item.label(lang))
// **同一个**角色上,否则 hover 色与轨底色对不上。
interaction::layer_color(
ui,
state,
Role::OnSurface,
MaterialLayer::Canvas.surface_role(),
)
} }
} }
/// 导航项高度 = 指示器 + 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)] #[cfg(test)]
mod tests; mod tests;
+68 -13
View File
@@ -10,7 +10,7 @@ use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer; use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind; use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale; use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space; use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::TypeRole; use unlucky_design::typography::TypeRole;
use crate::app::components::interaction::{self, Interaction}; use crate::app::components::interaction::{self, Interaction};
@@ -20,13 +20,24 @@ use crate::icons;
use crate::shell::overridden; use crate::shell::overridden;
use crate::theme::Ui; 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 项(那会让轨道越堆越长), /// 为什么单独传而不是塞进 `NavItem`:它不是主序列里的第 6 项(那会让轨道越堆越长),
/// 而是固定在**底部**的一个头像位;未登录/已登录两态要在**形状上**就不同(描边剪影 vs 首字母块)。 /// 而是固定在**底部**的一个头像位;未登录/已登录两态要在**形状上**就不同(描边剪影 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 struct RailAccount {
/// 是否已登录(至少有一个账户) /// 是否已登录(至少有一个账户)
pub signed_in: bool, pub signed_in: bool,
@@ -36,6 +47,10 @@ pub struct RailAccount {
pub label: String, pub label: String,
/// 窄高度/窄宽下的图标条形态(隐藏文字标签,只留头像位) /// 窄高度/窄宽下的图标条形态(隐藏文字标签,只留头像位)
pub compact: bool, pub compact: bool,
/// 用户把轨折叠了(M300):只留图标 + tooltip。判据只有一处(`ItemView::labels_visible`)。
nav_collapsed: bool,
/// 折叠进度的**这一帧**取值(0 = 折叠 64,1 = 展开 88)。中间帧由 `motion::nav(open)` 给。
nav_progress: f32,
} }
impl RailAccount { impl RailAccount {
@@ -46,6 +61,9 @@ impl RailAccount {
initial: String::new(), initial: String::new(),
label: label.into(), label: label.into(),
compact: false, compact: false,
// 出厂:轨展开(`UiPrefs::nav_collapsed` 的出厂值也是 false)
nav_collapsed: false,
nav_progress: 1.0,
} }
} }
@@ -56,6 +74,8 @@ impl RailAccount {
initial: initial.into(), initial: initial.into(),
label: label.into(), label: label.into(),
compact: false, compact: false,
nav_collapsed: false,
nav_progress: 1.0,
} }
} }
@@ -65,6 +85,33 @@ impl RailAccount {
self.compact = compact; self.compact = compact;
self 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() .into()
}; };
let width = account.nav_width(ui.metrics());
let height = nav_item_height(ui, indicator_height); 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] let mut stack = column![avatar]
.spacing(ui.space(Space::X1)) .spacing(ui.space(Space::X1))
.width(nav_rail_width(ui.metrics())) .width(width)
.align_x(Alignment::Center); .align_x(Alignment::Center);
if !account.compact { if labels_visible {
// 用**拥有的**字符串画标签:`RailAccount` 是调用方临时算出来的状态, // 用**拥有的**字符串画标签:`RailAccount` 是调用方临时算出来的状态,
// 借它的 `&str` 会让整棵树跟着那个临时值一起活(E0515)。 // 借它的 `&str` 会让整棵树跟着那个临时值一起活(E0515)。
let style = ui.style(TypeRole::LabelSmall); let style = ui.style(TypeRole::LabelSmall);
@@ -154,7 +205,7 @@ pub(super) fn account_item<'a>(
} }
let clickable: Element<'a, Message> = button(stack) let clickable: Element<'a, Message> = button(stack)
.width(nav_rail_width(ui.metrics())) .width(width)
.height(height) .height(height)
.padding(0) .padding(0)
.style(move |_theme: &IcedTheme, _status| button::Style { .style(move |_theme: &IcedTheme, _status| button::Style {
@@ -168,10 +219,16 @@ pub(super) fn account_item<'a>(
.on_press(Message::Select(NavItem::Account)) .on_press(Message::Select(NavItem::Account))
.into(); .into();
// A8:运行期态套状态取样控件(强制态 = 取证,**不套**:取证图不许有过渡中间帧)。 // A8:运行期态套状态取样控件(强制态 = 取证,**不套**:取证图不许有过渡中间帧)。
if forced.is_none() { let clickable = if forced.is_none() {
interaction::tracked(clickable, true) interaction::tracked(clickable, true)
} else { } else {
clickable 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 width = super::super::indicator_size(ui.metrics()).0;
let size = avatar_size(&ui, width); let size = avatar_size(&ui, width);
assert!(size > 0.0); assert!(size > 0.0);
assert!( // 展开档是两档里宽的那一档:头像位连它都放得下,折叠档才不需要另算一遍。
size <= nav_rail_width(ui.metrics()), let rail = ui.metrics().nav_rail_width;
"头像位不能比导航轨还宽({size} > {})", assert!(size <= rail, "头像位不能比导航轨还宽({size} > {rail})");
nav_rail_width(ui.metrics())
);
} }
} }
+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 super::*;
use unlucky_design::color::SchemeFlavor; use unlucky_design::color::SchemeFlavor;
use unlucky_design::spacing::LayoutMetrics;
use unlucky_design::typography::Platform; use unlucky_design::typography::Platform;
use crate::theme::Families; 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
}),
)
}
+10
View File
@@ -238,6 +238,16 @@ pub fn launcher_schema() -> SettingSchema {
AiPolicy::audited(), AiPolicy::audited(),
"界面语言", "界面语言",
)) ))
// M300(NAVCOLLAPSE):左侧导航轨折叠成图标条。纯视觉、一键可复原(切回 `false` 即展开),
// 没有安全面 ⇒ 分级与 `ui.language` 同族取 audited(AI 改过要留痕,但不要确认框)。
// 键名与 `unlucky_presenter::prefs::keys::NAV_COLLAPSED` 逐字相同(两侧各有一份常量,
// 相不相同由 `presenter/src/prefs/tests.rs` 与 `settings_bridge` 的往返用例一起钉住)。
.with(SettingSpec::bool(
"ui.nav.collapsed",
false,
AiPolicy::audited(),
"左侧导航轨折叠成图标条(false = 展开,带文字标签)",
))
.with(SettingSpec::enumeration( .with(SettingSpec::enumeration(
"appearance.theme_mode", "appearance.theme_mode",
"auto", "auto",
+63 -1
View File
@@ -101,11 +101,40 @@ pub struct LayoutMetrics {
/// 官方 `NavigationDrawerTokens.ContainerWidth = 360`、`MinimumDrawerWidth = 240`; /// 官方 `NavigationDrawerTokens.ContainerWidth = 360`、`MinimumDrawerWidth = 240`;
/// 本项目取官方下限 240——360 + AI 面板 320 = 680 = 1180 窗宽的 58%,内容区只剩 500 放不下两列卡片。 /// 本项目取官方下限 240——360 + AI 面板 320 = 680 = 1180 窗宽的 58%,内容区只剩 500 放不下两列卡片。
pub nav_drawer_width: f32, pub nav_drawer_width: f32,
/// 折叠态导航(rail 形态)宽度。 /// 折叠态导航(rail 形态)宽度 —— **轨的展开档(默认)**。
/// ///
/// 88 = 12b §5.1 对官方 rail 96 的登记偏离(−8):指示器宽 88 − 2×16 = 56 仍成立, /// 88 = 12b §5.1 对官方 rail 96 的登记偏离(−8):指示器宽 88 − 2×16 = 56 仍成立,
/// 再窄就装不下 56 宽的选中指示器。 /// 再窄就装不下 56 宽的选中指示器。
///
/// 「折叠态」这个词在本仓指 **rail 形态本身**(与 240 的 drawer 相对)。M300 起 rail 自己
/// 也允许再收一档(只留图标),那一档是 [`LayoutMetrics::nav_collapsed_width`]。
pub nav_rail_width: f32, 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)。 /// 搜索条高度(G11)。
/// ///
/// 官方 `SearchBarTokens.ContainerHeight = 56`。**不走 [`LayoutMetrics::control_height`](40)**: /// 官方 `SearchBarTokens.ContainerHeight = 56`。**不走 [`LayoutMetrics::control_height`](40)**:
@@ -157,6 +186,8 @@ impl LayoutMetrics {
// `nav_collapsed_width=48` 的名字与值相反,已删)。 // `nav_collapsed_width=48` 的名字与值相反,已删)。
nav_drawer_width: 240.0, nav_drawer_width: 240.0,
nav_rail_width: 88.0, nav_rail_width: 88.0,
// M300:轨自己再收一档(只留图标)= 胶囊 56 + 2 × Space::X1(4)。算式见该字段文档。
nav_collapsed_width: 64.0,
// G11:官方 SearchBarTokens.ContainerHeight = 56。 // G11:官方 SearchBarTokens.ContainerHeight = 56。
search_bar_height: 56.0, search_bar_height: 56.0,
// G11/G12:官方 DividerTokens.Thickness = 1.0dp → 一像素线的唯一真源。 // G11/G12:官方 DividerTokens.Thickness = 1.0dp → 一像素线的唯一真源。
@@ -208,6 +239,7 @@ mod tests {
("titlebar_height", m.titlebar_height), ("titlebar_height", m.titlebar_height),
("nav_drawer_width", m.nav_drawer_width), ("nav_drawer_width", m.nav_drawer_width),
("nav_rail_width", m.nav_rail_width), ("nav_rail_width", m.nav_rail_width),
("nav_collapsed_width", m.nav_collapsed_width),
("search_bar_height", m.search_bar_height), ("search_bar_height", m.search_bar_height),
("nav_item_height", m.nav_item_height), ("nav_item_height", m.nav_item_height),
("nav_item_gap", m.nav_item_gap), ("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); 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] #[test]
fn search_bar_height_is_a_page_level_entry() { fn search_bar_height_is_a_page_level_entry() {
let m = LayoutMetrics::DESKTOP; let m = LayoutMetrics::DESKTOP;
+2
View File
@@ -6,6 +6,8 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("NavDownloadExpanded", "Version management", "版本管理"), ("NavDownloadExpanded", "Version management", "版本管理"),
("NavLogs", "Logs", "日志"), ("NavLogs", "Logs", "日志"),
("NavSettings", "Settings", "设置"), ("NavSettings", "Settings", "设置"),
("NavCollapse", "Collapse the navigation rail", "折叠导航轨"),
("NavExpand", "Expand the navigation rail", "展开导航轨"),
("InstancesNew", "New instance", "新建实例"), ("InstancesNew", "New instance", "新建实例"),
("InstancesImport", "Import", "导入"), ("InstancesImport", "Import", "导入"),
("InstancesBack", "Back to instances", "返回实例列表"), ("InstancesBack", "Back to instances", "返回实例列表"),
+5
View File
@@ -35,6 +35,11 @@ macro_rules! nav_entries {
NavDownloadExpanded => ("Version management", "版本管理"), NavDownloadExpanded => ("Version management", "版本管理"),
NavLogs => ("Logs", "日志"), NavLogs => ("Logs", "日志"),
NavSettings => ("Settings", "设置"), NavSettings => ("Settings", "设置"),
// M300(NAVCOLLAPSE):轨底折叠按钮的**可读名**。按钮只有图标(`IconKind::Sidebar`),
// 所以这两句是它的 tooltip —— 与 `pages/agent` 的既有做法同一条纪律(「条上每个可点
// 元素都有可读文字」)。两态各一条,不拼字符串:文案要有主语与动作,别做 `Collapse {x}`。
NavCollapse => ("Collapse the navigation rail", "折叠导航轨"),
NavExpand => ("Expand the navigation rail", "展开导航轨"),
} } } }
}; };
} }
+4 -3
View File
@@ -3,7 +3,8 @@
//! 偏好不自己写盘:core::setting 的 schema 按 [`keys::ALL`] 登记这些键, //! 偏好不自己写盘:core::setting 的 schema 按 [`keys::ALL`] 登记这些键,
//! app 启动时读一次、变更时写回(跨模块约定见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`)。 //! app 启动时读一次、变更时写回(跨模块约定见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`)。
//! 落盘实现是 [`crate::settings_bridge`](同一份 `config/launcher.json`,与 `ull config set` 同源), //! 落盘实现是 [`crate::settings_bridge`](同一份 `config/launcher.json`,与 `ull config set` 同源),
//! 主题三态 + `appearance.*` 高级键已接(M41);其余 UI 偏好键仍只在内存里(后续 mission 接)。 //! 主题三态 + `appearance.*` 高级键已接(M41);其余 UI 偏好键仍只在内存里(后续 mission 接),
//! **唯一例外是 [`UiPrefs::nav_collapsed`](M300 已接:schema 键 + 读 + 写三处都在)**。
/// 背景图填充模式(09 §101)。直接复用 core 的枚举:取值与落盘词表只有一处真相。 /// 背景图填充模式(09 §101)。直接复用 core 的枚举:取值与落盘词表只有一处真相。
pub use unlucky_core::setting::appearance::BgFill; pub use unlucky_core::setting::appearance::BgFill;
@@ -304,7 +305,7 @@ impl AppearanceBase {
/// 注意:`Eq` 不成立([`AppearancePrefs`] 带 `f32` 滑条值),用到的地方按 `PartialEq` 比。 /// 注意:`Eq` 不成立([`AppearancePrefs`] 带 `f32` 滑条值),用到的地方按 `PartialEq` 比。
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct UiPrefs { pub struct UiPrefs {
/// 左侧导航栏折叠成图标条 /// 左侧导航栏折叠成图标条(M300 起**已落盘**:键 [`keys::NAV_COLLAPSED`],读 `SettingsBridge::load_nav_collapsed`)
pub nav_collapsed: bool, pub nav_collapsed: bool,
/// 右侧 AI 栏折叠成 icon 轨 /// 右侧 AI 栏折叠成 icon 轨
pub ai_collapsed: bool, pub ai_collapsed: bool,
@@ -337,7 +338,7 @@ impl Default for UiPrefs {
/// 持久化键名登记(core::setting schema 按此注册;改名等于迁移,慎动)。 /// 持久化键名登记(core::setting schema 按此注册;改名等于迁移,慎动)。
pub mod keys { pub mod keys {
/// 导航栏折叠 /// 导航栏折叠(M300 起是**真落盘**键,core schema 同名字符串)
pub const NAV_COLLAPSED: &str = "ui.nav.collapsed"; pub const NAV_COLLAPSED: &str = "ui.nav.collapsed";
/// AI 栏折叠 /// AI 栏折叠
pub const AI_COLLAPSED: &str = "ui.ai.collapsed"; pub const AI_COLLAPSED: &str = "ui.ai.collapsed";
+44 -196
View File
@@ -12,6 +12,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::SystemTime; use std::time::SystemTime;
use serde_json::Value;
use unlucky_core::platform::data_dir::Resolver; use unlucky_core::platform::data_dir::Resolver;
use unlucky_core::platform::paths::Platform; use unlucky_core::platform::paths::Platform;
use unlucky_core::setting::appearance::Appearance; use unlucky_core::setting::appearance::Appearance;
@@ -133,6 +134,47 @@ impl SettingsBridge {
} }
} }
/// 左侧导航轨的折叠档(**M300**):`UiPrefs::nav_collapsed` 的**唯一读入口**。
///
/// `ui.*` 这一族偏好此前**全部只在内存里**(见 `prefs.rs` 模块文档的「其余 UI 偏好键仍只在
/// 内存里」)——M300 起那句对 `nav_collapsed` 不再成立:它在 core 的 schema 里登记了
/// (`ui.nav.collapsed`)、在这里读、在 [`SettingsBridge::persist_nav_collapsed`] 写。
///
/// 缺键 / 值被手改成非布尔 → **`false`(展开)**:出厂态是展开,坏配置不许把用户关进图标条。
#[must_use]
pub fn load_nav_collapsed(&self) -> bool {
self.store
.as_ref()
.and_then(|store| {
store
.doc()
.effective_bool(crate::prefs::keys::NAV_COLLAPSED)
})
.unwrap_or(false)
}
/// 写回左侧导航轨的折叠档(**M300**)。返回 `true` = 磁盘上真的有新内容。
///
/// 走 [`SettingsBridge::write_with`](唯一写入口:现读 → 改 → 落盘 → 刷 mtime),于是
/// 「刚存完 AI 配置就点折叠」不会把 `ai.*` 连密钥一起抹掉(M174 两轮复审的教训)。
/// 值没变时返回 `Edit::Unchanged`:重复点同一个档位不该产生一次写盘 + `mtime` 抖动。
pub fn persist_nav_collapsed(&mut self, collapsed: bool) -> bool {
self.write_with(|store| {
if store
.doc()
.effective_bool(crate::prefs::keys::NAV_COLLAPSED)
== Some(collapsed)
{
return Edit::Unchanged;
}
match store.set(crate::prefs::keys::NAV_COLLAPSED, Value::from(collapsed)) {
Ok(()) => Edit::Changed,
Err(_) => Edit::Rejected,
}
})
.is_saved()
}
/// 落盘(失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。 /// 落盘(失败静默——配置写不进去不该把 UI 卡住,诊断走日志)。
/// ///
/// **写走 [`SettingsBridge::write_with`]**(M174 r1/r2 复审):本桥的 store 是长生命的 /// **写走 [`SettingsBridge::write_with`]**(M174 r1/r2 复审):本桥的 store 是长生命的
@@ -197,200 +239,6 @@ fn resolve_config_path() -> Option<PathBuf> {
fn mtime(path: &Path) -> Option<SystemTime> { fn mtime(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path).ok()?.modified().ok() std::fs::metadata(path).ok()?.modified().ok()
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "settings_bridge/tests.rs"]
use super::*; mod tests;
use crate::prefs::PaletteSource;
use unlucky_core::setting::appearance::BgFill;
fn temp_config(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-bridge-{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
}
/// **M216 ①**:数据根四键的写回落点 = 启动解析读的那份文件。
///
/// 抓得住:四键 → 便携那份、其余键 → 解析后那份、拿不到 exe 目录时退回。抓不住:真实
/// `current_exe()` 的取值与 `Resolver` 的 `redirect.json` 跟随(那两条靠 `set` 的回合用例)。
#[test]
fn data_root_keys_are_written_where_startup_reads_them() {
let exe = PathBuf::from("/games/ULL");
let portable = crate::host::env::portable_config_path(&exe);
let resolved = PathBuf::from("/appdata/ULL/config/launcher.json");
for key in crate::host::env::DATA_ROOT_KEYS {
assert_eq!(setting_path(&resolved, Some(&exe), key), portable, "{key}");
}
for key in ["java.default_memory_mb", "ai.model", "core.unknown_suffix"] {
assert_eq!(setting_path(&resolved, Some(&exe), key), resolved, "{key}");
}
// 拿不到 exe 目录:退回解析后那份(宁可写「没人读的那份」也不丢写、不 panic)。
assert_eq!(setting_path(&resolved, None, "core.data_mode"), resolved);
}
#[test]
fn factory_defaults_when_file_missing() {
let path = temp_config("factory");
let bridge = SettingsBridge::with_path(&path);
let (appearance, theme) = bridge.load();
assert_eq!(appearance, AppearancePrefs::factory());
assert_eq!(theme, ThemeMode::Auto);
assert!(!path.exists(), "只读不写:缺文件不该被创建");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn persist_then_reload_round_trips() {
let path = temp_config("roundtrip");
let mut bridge = SettingsBridge::with_path(&path);
let mut appearance = AppearancePrefs::factory();
appearance.bg_image_enabled = true;
appearance.bg_image = "D:/wall/p.png".to_owned();
appearance.bg_fill = BgFill::Contain;
appearance.bg_dim = 0.5;
appearance.panel_alpha = 0.8;
appearance.glass = true;
assert!(appearance.set_accent("#0f6cbd"));
bridge.persist(&appearance, ThemeMode::Dark);
let reloaded = SettingsBridge::with_path(&path);
let (value, theme) = reloaded.load();
assert_eq!(value, appearance);
assert_eq!(theme, ThemeMode::Dark);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn external_edit_is_picked_up_by_reload() {
let path = temp_config("hotreload");
let mut bridge = SettingsBridge::with_path(&path);
// 先落一份(建立 mtime 基线)
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light);
assert!(bridge.reload_if_changed().is_none(), "自己写完不该报告变更");
// 模拟外部写入(AI / `ull config set`):改主题 + 打开背景图
let mut external = SettingsBridge::with_path(&path);
let mut appearance = AppearancePrefs::factory();
appearance.bg_image_enabled = true;
appearance.bg_image = "D:/wall/x.png".to_owned();
external.persist(&appearance, ThemeMode::Dark);
// mtime 精度:有的文件系统 1s 粒度,这里显式等到时间戳变化
for _ in 0..50 {
if let Some((value, theme)) = bridge.reload_if_changed() {
assert_eq!(theme, ThemeMode::Dark);
assert!(value.bg_image_enabled);
assert_eq!(value.bg_image, "D:/wall/x.png");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
return;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
panic!("外部修改没有被 mtime 热切捕获");
}
/// **P2-4 回归**:外观写回不许盖掉别人刚写的键(`ai.api_key` 最贵)。修之前必红。
#[test]
fn persist_keeps_keys_another_writer_just_saved() {
let path = temp_config("persist-keeps-ai");
let mut bridge = SettingsBridge::with_path(&path);
// 先让本桥真的加载过一份文件(旧快照从这里来)。
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light);
{
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
// 另一个写者(M174 的助手设置页 / `ull config set`)写进三个 AI 键。
let mut store = SettingStore::load(&path, launcher_schema());
for (key, value) in [
("ai.base_url", "https://api.deepseek.com/v1"),
("ai.model", "deepseek-chat"),
("ai.api_key", "sk-must-survive"),
] {
store.set(key, value.to_string().into()).expect("写 AI 键");
}
store.save().expect("落盘");
}
// 同一秒内改主题:外观写回。
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Dark);
let text = std::fs::read_to_string(&path).expect("读文件");
assert!(
text.contains("sk-must-survive"),
"外观写回把刚保存的密钥盖掉了:{text}"
);
assert!(text.contains("deepseek-chat"), "model 也被盖掉了:{text}");
assert!(text.contains("\"dark\""), "主题这一项该写进去:{text}");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn accent_preset_and_validation() {
let mut appearance = AppearancePrefs::factory();
assert_eq!(appearance.accent_color, "");
assert!(appearance.set_accent_preset(1));
assert_eq!(appearance.accent_color, "#0F6CBD");
assert!(!appearance.set_accent("not-a-color"), "非法输入不改现状");
assert_eq!(appearance.accent_color, "#0F6CBD");
assert!(appearance.set_accent(""), "空串恢复出厂");
assert_eq!(appearance.accent_color, "");
assert!(!appearance.set_accent_preset(99));
}
#[test]
fn bg_active_needs_both_switch_and_path() {
let mut appearance = AppearancePrefs::factory();
appearance.bg_image = "D:/wall/p.png".to_owned();
assert!(!appearance.bg_active(), "开关没开不算生效");
appearance.bg_image_enabled = true;
assert!(appearance.bg_active());
appearance.bg_image.clear();
assert!(!appearance.bg_active(), "路径空也不算生效");
}
/// `load_base()` 是「重启后读回整窗 α / 配色来源 / 窗口圆角」的唯一通道(M93 · M116)。
///
/// 抓得住:①文件里写着 `window_opacity=0.6` / `palette_source=wallpaper` 时能被**新开的**
/// bridge 读到(= 重启语义);②缺键时回出厂(1.0 / preset),而不是 `0.0` 或随机档。
/// 抓不住:UI 侧是否真的用它来下发窗口 α(那由 `app-iced` 的日志与单测证明)。
#[test]
fn load_base_reads_the_four_read_only_keys_and_defaults_when_missing() {
let path = temp_config("base");
// 文件由「外部写者」(CLI / AI / 设置页的 SetSetting)产生,这里直接落 JSON。
std::fs::write(
&path,
r#"{"appearance":{"theme_mode":"dark","window_opacity":0.6,"palette_source":"wallpaper","panel_alpha":0.5}}"#,
)
.expect("写配置文件");
let bridge = SettingsBridge::with_path(&path);
let base = bridge.load_base();
assert_eq!(base.window_opacity, 0.6, "重启后读回整窗 α");
assert_eq!(base.palette_source, PaletteSource::Wallpaper);
assert!(base.is_transparent());
// 缺键 → 出厂值(与 `AppearanceBase::factory()` 逐字段相等)。
let empty = path.with_file_name("launcher-empty.json");
std::fs::write(&empty, "{}").expect("写空配置");
let factory = SettingsBridge::with_path(&empty).load_base();
assert_eq!(factory, AppearanceBase::factory());
assert_eq!(factory.window_opacity, 1.0);
assert_eq!(factory.palette_source, PaletteSource::Preset);
// 数据根解析不出来的内存态桥也要给得出出厂值(不 panic、不返回 0)。
assert_eq!(
SettingsBridge::memory_only().load_base(),
AppearanceBase::factory()
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
}
@@ -0,0 +1,250 @@
//! `SettingsBridge` 的单测(M300 从 `settings_bridge.rs` **纯搬移**,守 400 行硬线)。
//!
//! 搬移理由:M300 给桥加了 `load_nav_collapsed` / `persist_nav_collapsed` 两件(折叠档落盘),
//! 原文件 434 行已破门禁。判据与注释一字未动,只是换文件——`settings_bridge.rs` 里现在是一行
//! `#[cfg(test)] #[path = "settings_bridge/tests.rs"] mod tests;`,模块路径仍是
//! `settings_bridge::tests`。
use super::*;
use crate::prefs::PaletteSource;
use unlucky_core::setting::appearance::BgFill;
fn temp_config(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-bridge-{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
}
/// **M216 ①**:数据根四键的写回落点 = 启动解析读的那份文件。
///
/// 抓得住:四键 → 便携那份、其余键 → 解析后那份、拿不到 exe 目录时退回。抓不住:真实
/// `current_exe()` 的取值与 `Resolver` 的 `redirect.json` 跟随(那两条靠 `set` 的回合用例)。
#[test]
fn data_root_keys_are_written_where_startup_reads_them() {
let exe = PathBuf::from("/games/ULL");
let portable = crate::host::env::portable_config_path(&exe);
let resolved = PathBuf::from("/appdata/ULL/config/launcher.json");
for key in crate::host::env::DATA_ROOT_KEYS {
assert_eq!(setting_path(&resolved, Some(&exe), key), portable, "{key}");
}
for key in ["java.default_memory_mb", "ai.model", "core.unknown_suffix"] {
assert_eq!(setting_path(&resolved, Some(&exe), key), resolved, "{key}");
}
// 拿不到 exe 目录:退回解析后那份(宁可写「没人读的那份」也不丢写、不 panic)。
assert_eq!(setting_path(&resolved, None, "core.data_mode"), resolved);
}
#[test]
fn factory_defaults_when_file_missing() {
let path = temp_config("factory");
let bridge = SettingsBridge::with_path(&path);
let (appearance, theme) = bridge.load();
assert_eq!(appearance, AppearancePrefs::factory());
assert_eq!(theme, ThemeMode::Auto);
assert!(!path.exists(), "只读不写:缺文件不该被创建");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn persist_then_reload_round_trips() {
let path = temp_config("roundtrip");
let mut bridge = SettingsBridge::with_path(&path);
let mut appearance = AppearancePrefs::factory();
appearance.bg_image_enabled = true;
appearance.bg_image = "D:/wall/p.png".to_owned();
appearance.bg_fill = BgFill::Contain;
appearance.bg_dim = 0.5;
appearance.panel_alpha = 0.8;
appearance.glass = true;
assert!(appearance.set_accent("#0f6cbd"));
bridge.persist(&appearance, ThemeMode::Dark);
let reloaded = SettingsBridge::with_path(&path);
let (value, theme) = reloaded.load();
assert_eq!(value, appearance);
assert_eq!(theme, ThemeMode::Dark);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn external_edit_is_picked_up_by_reload() {
let path = temp_config("hotreload");
let mut bridge = SettingsBridge::with_path(&path);
// 先落一份(建立 mtime 基线)
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light);
assert!(bridge.reload_if_changed().is_none(), "自己写完不该报告变更");
// 模拟外部写入(AI / `ull config set`):改主题 + 打开背景图
let mut external = SettingsBridge::with_path(&path);
let mut appearance = AppearancePrefs::factory();
appearance.bg_image_enabled = true;
appearance.bg_image = "D:/wall/x.png".to_owned();
external.persist(&appearance, ThemeMode::Dark);
// mtime 精度:有的文件系统 1s 粒度,这里显式等到时间戳变化
for _ in 0..50 {
if let Some((value, theme)) = bridge.reload_if_changed() {
assert_eq!(theme, ThemeMode::Dark);
assert!(value.bg_image_enabled);
assert_eq!(value.bg_image, "D:/wall/x.png");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
return;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
panic!("外部修改没有被 mtime 热切捕获");
}
/// **P2-4 回归**:外观写回不许盖掉别人刚写的键(`ai.api_key` 最贵)。修之前必红。
#[test]
fn persist_keeps_keys_another_writer_just_saved() {
let path = temp_config("persist-keeps-ai");
let mut bridge = SettingsBridge::with_path(&path);
// 先让本桥真的加载过一份文件(旧快照从这里来)。
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Light);
{
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::store::SettingStore;
// 另一个写者(M174 的助手设置页 / `ull config set`)写进三个 AI 键。
let mut store = SettingStore::load(&path, launcher_schema());
for (key, value) in [
("ai.base_url", "https://api.deepseek.com/v1"),
("ai.model", "deepseek-chat"),
("ai.api_key", "sk-must-survive"),
] {
store.set(key, value.to_string().into()).expect("写 AI 键");
}
store.save().expect("落盘");
}
// 同一秒内改主题:外观写回。
bridge.persist(&AppearancePrefs::factory(), ThemeMode::Dark);
let text = std::fs::read_to_string(&path).expect("读文件");
assert!(
text.contains("sk-must-survive"),
"外观写回把刚保存的密钥盖掉了:{text}"
);
assert!(text.contains("deepseek-chat"), "model 也被盖掉了:{text}");
assert!(text.contains("\"dark\""), "主题这一项该写进去:{text}");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
#[test]
fn accent_preset_and_validation() {
let mut appearance = AppearancePrefs::factory();
assert_eq!(appearance.accent_color, "");
assert!(appearance.set_accent_preset(1));
assert_eq!(appearance.accent_color, "#0F6CBD");
assert!(!appearance.set_accent("not-a-color"), "非法输入不改现状");
assert_eq!(appearance.accent_color, "#0F6CBD");
assert!(appearance.set_accent(""), "空串恢复出厂");
assert_eq!(appearance.accent_color, "");
assert!(!appearance.set_accent_preset(99));
}
#[test]
fn bg_active_needs_both_switch_and_path() {
let mut appearance = AppearancePrefs::factory();
appearance.bg_image = "D:/wall/p.png".to_owned();
assert!(!appearance.bg_active(), "开关没开不算生效");
appearance.bg_image_enabled = true;
assert!(appearance.bg_active());
appearance.bg_image.clear();
assert!(!appearance.bg_active(), "路径空也不算生效");
}
/// `load_base()` 是「重启后读回整窗 α / 配色来源 / 窗口圆角」的唯一通道(M93 · M116)。
///
/// 抓得住:①文件里写着 `window_opacity=0.6` / `palette_source=wallpaper` 时能被**新开的**
/// bridge 读到(= 重启语义);②缺键时回出厂(1.0 / preset),而不是 `0.0` 或随机档。
/// 抓不住:UI 侧是否真的用它来下发窗口 α(那由 `app-iced` 的日志与单测证明)。
#[test]
fn load_base_reads_the_four_read_only_keys_and_defaults_when_missing() {
let path = temp_config("base");
// 文件由「外部写者」(CLI / AI / 设置页的 SetSetting)产生,这里直接落 JSON。
std::fs::write(
&path,
r#"{"appearance":{"theme_mode":"dark","window_opacity":0.6,"palette_source":"wallpaper","panel_alpha":0.5}}"#,
)
.expect("写配置文件");
let bridge = SettingsBridge::with_path(&path);
let base = bridge.load_base();
assert_eq!(base.window_opacity, 0.6, "重启后读回整窗 α");
assert_eq!(base.palette_source, PaletteSource::Wallpaper);
assert!(base.is_transparent());
// 缺键 → 出厂值(与 `AppearanceBase::factory()` 逐字段相等)。
let empty = path.with_file_name("launcher-empty.json");
std::fs::write(&empty, "{}").expect("写空配置");
let factory = SettingsBridge::with_path(&empty).load_base();
assert_eq!(factory, AppearanceBase::factory());
assert_eq!(factory.window_opacity, 1.0);
assert_eq!(factory.palette_source, PaletteSource::Preset);
// 数据根解析不出来的内存态桥也要给得出出厂值(不 panic、不返回 0)。
assert_eq!(
SettingsBridge::memory_only().load_base(),
AppearanceBase::factory()
);
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
/// **M300 落盘往返**:`ui.nav.collapsed` 写进文件 → **重新构造桥**(= 重启)→ 读回。
///
/// 这条用例守的是任务单里那句「不然重启就回去了」:只断言内存字段的用例对它零约束
/// (字段本来就地改,重启后 `UiPrefs::default()` 照样是展开)。
///
/// **抓得住**:①`persist_nav_collapsed` 忘了 `store.save()`(第二次构造读回 false);
/// ②键名两侧不一致(core schema 叫 `ui.nav.collapsed`、桥写成别的串 ⇒ `store.set` 被
/// schema 拒 ⇒ 第一次写返回 false);③core 没登记这个键(`UnknownKey` ⇒ 同样第一次写就 false);
/// ④`load_nav_collapsed` 读的是内存快照而不是刚 reload 的文档(跑完这次进程内的旧值还能骗过它,
/// 但「换一个桥对象」那一步就露馅)。
/// **抓不住**:真实数据根解析(`SettingsBridge::open()` 那条路,见本文件另一组用例)、
/// 并发写盘(`save_worker` 的锁),以及 UI 那一侧有没有真的调它(那归 app 侧的
/// `app/update/nav.rs` 用例)。
#[test]
fn nav_collapsed_survives_a_restart() {
let path = temp_config("nav-collapsed");
// ① 出厂(文件不存在):展开。
let mut bridge = SettingsBridge::with_path(&path);
assert!(!bridge.load_nav_collapsed(), "缺键 = 出厂展开");
// ② 折叠 → 真的落盘。
assert!(
bridge.persist_nav_collapsed(true),
"第一次写 `ui.nav.collapsed` 必须真的落盘(false = 被 schema 拒 / 没存)"
);
// ③ 「重启」:换一个桥对象读同一份文件。
let reopened = SettingsBridge::with_path(&path);
assert!(
reopened.load_nav_collapsed(),
"重启后必须读回折叠档(否则折叠是假的)"
);
// ④ 幂等:同一个值再写一次不该产生写盘(`Edit::Unchanged`)。
let mut bridge = reopened;
assert!(!bridge.persist_nav_collapsed(true), "值没变时不该再写盘");
// ⑤ 展开也要落盘(只写 true 的实现会在这里红)。
assert!(bridge.persist_nav_collapsed(false));
assert!(!SettingsBridge::with_path(&path).load_nav_collapsed());
// ⑥ 内存态桥(数据根解析不出来):不 panic、也不假装写成功。
let mut memory = SettingsBridge::memory_only();
assert!(!memory.load_nav_collapsed());
assert!(!memory.persist_nav_collapsed(true));
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}