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:
1 parent
1ce494631c
commit
fa53d72f73
26 files changed
+1640
-320
No files matched your search
@@ -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`)。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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})");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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} 时轨比胶囊还窄"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -238,6 +238,16 @@ pub fn launcher_schema() -> SettingSchema {
|
||||
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(
|
||||
"appearance.theme_mode",
|
||||
"auto",
|
||||
|
||||
@@ -101,11 +101,40 @@ pub struct LayoutMetrics {
|
||||
/// 官方 `NavigationDrawerTokens.ContainerWidth = 360`、`MinimumDrawerWidth = 240`;
|
||||
/// 本项目取官方下限 240——360 + AI 面板 320 = 680 = 1180 窗宽的 58%,内容区只剩 500 放不下两列卡片。
|
||||
pub nav_drawer_width: f32,
|
||||
/// 折叠态导航(rail 形态)宽度。
|
||||
/// 折叠态导航(rail 形态)宽度 —— **轨的展开档(默认)**。
|
||||
///
|
||||
/// 88 = 12b §5.1 对官方 rail 96 的登记偏离(−8):指示器宽 88 − 2×16 = 56 仍成立,
|
||||
/// 再窄就装不下 56 宽的选中指示器。
|
||||
///
|
||||
/// 「折叠态」这个词在本仓指 **rail 形态本身**(与 240 的 drawer 相对)。M300 起 rail 自己
|
||||
/// 也允许再收一档(只留图标),那一档是 [`LayoutMetrics::nav_collapsed_width`]。
|
||||
pub nav_rail_width: f32,
|
||||
/// 导航轨**再收一档**(只留图标、隐藏文字标签)时的宽度(M300)。
|
||||
///
|
||||
/// ## 算式:64 = 56 + 2 × 4
|
||||
///
|
||||
/// 判据是「胶囊不能被裁切」而不是「比 88 窄就行」:选中指示器 / 状态层那块矩形(M283 定稿
|
||||
/// **56×32**,本 mission 不动)在折叠态**仍然存在**(选中项照样要有底色),所以轨宽必须容得下
|
||||
/// 它加两侧留白:
|
||||
///
|
||||
/// ```text
|
||||
/// 胶囊宽 = nav_rail_width − 2 × Space::X4 = 88 − 32 = 56 (见 shell/nav.rs 的 indicator_size)
|
||||
/// nav_collapsed_width = 胶囊宽 + 2 × Space::X1 = 56 + 8 = 64
|
||||
/// ```
|
||||
///
|
||||
/// **为什么不是原来的 48**:旧令牌 `nav_collapsed_width = 48` 在 M69 被删,正是因为它连胶囊
|
||||
/// 都放不下 —— `48 − 2×X1 = 40 < 56`(`48 − 2×X4 = 16` 更不用说)。所以 48 不是「再收一档」,
|
||||
/// 是「收过头」。折叠档的下界由 `spacing.rs` 自己的断言
|
||||
/// `nav_collapsed_width >= 胶囊宽 + 2 × Space::X1` 钉住:任何人想把它改小,先得改胶囊尺寸,
|
||||
/// 而那是 M283 的定稿值(改它要在 mission 里声明)。
|
||||
///
|
||||
/// **为什么是 X1(4)而不是 X2(8)**:8 会让折叠档变成 72、与 88 只差 16(腾出的空间不足以
|
||||
/// 支撑「折叠」这个动作的意义),而 4 已经是栅格上的最小值、且 64 ≥ `touch_target`(48) ——
|
||||
/// 折叠态每一项的命中区仍是 64 宽整条,不比展开态差。
|
||||
///
|
||||
/// **纵节奏不变**:折叠只改宽度与标签可见性,项高仍是 `nav_item_height`(52 = 32 + 4 + 16),
|
||||
/// 于是切换时图标不上下跳、命中高 52 ≥ 48。
|
||||
pub nav_collapsed_width: f32,
|
||||
/// 搜索条高度(G11)。
|
||||
///
|
||||
/// 官方 `SearchBarTokens.ContainerHeight = 56`。**不走 [`LayoutMetrics::control_height`](40)**:
|
||||
@@ -157,6 +186,8 @@ impl LayoutMetrics {
|
||||
// `nav_collapsed_width=48` 的名字与值相反,已删)。
|
||||
nav_drawer_width: 240.0,
|
||||
nav_rail_width: 88.0,
|
||||
// M300:轨自己再收一档(只留图标)= 胶囊 56 + 2 × Space::X1(4)。算式见该字段文档。
|
||||
nav_collapsed_width: 64.0,
|
||||
// G11:官方 SearchBarTokens.ContainerHeight = 56。
|
||||
search_bar_height: 56.0,
|
||||
// G11/G12:官方 DividerTokens.Thickness = 1.0dp → 一像素线的唯一真源。
|
||||
@@ -208,6 +239,7 @@ mod tests {
|
||||
("titlebar_height", m.titlebar_height),
|
||||
("nav_drawer_width", m.nav_drawer_width),
|
||||
("nav_rail_width", m.nav_rail_width),
|
||||
("nav_collapsed_width", m.nav_collapsed_width),
|
||||
("search_bar_height", m.search_bar_height),
|
||||
("nav_item_height", m.nav_item_height),
|
||||
("nav_item_gap", m.nav_item_gap),
|
||||
@@ -244,6 +276,36 @@ mod tests {
|
||||
assert!(1180.0 - m.nav_drawer_width - m.ai_panel_width >= 600.0);
|
||||
}
|
||||
|
||||
/// **M300:折叠档必须容得下 56 宽的胶囊 + 两侧留白。**
|
||||
///
|
||||
/// 抓得住:①有人把 `nav_collapsed_width` 改回 48(`48 − 2×X1 = 40 < 56` ⇒ 第三条红,且第一条
|
||||
/// 直接对不上算式);②改胶囊尺寸却忘了改折叠档(第一条红:`nav_collapsed_width` 不再等于
|
||||
/// 「胶囊 + 2×X1」);③折叠档比命中下限还窄(第四条红)。
|
||||
/// 抓不住:真画出来的裁切(那要出图,见 `DevDocs/artifacts/m300/nav-collapsed-*.png`)。
|
||||
#[test]
|
||||
fn the_collapsed_rail_fits_the_pill_plus_its_side_padding() {
|
||||
let m = LayoutMetrics::DESKTOP;
|
||||
// 胶囊宽 = 轨宽 − 2×16(M283 定稿 56×32,本 mission 不动它)
|
||||
let pill = m.nav_rail_width - Space::X4.dp() * 2.0;
|
||||
assert_eq!(pill, 56.0, "M283 的胶囊宽");
|
||||
assert_eq!(
|
||||
m.nav_collapsed_width,
|
||||
pill + 2.0 * Space::X1.dp(),
|
||||
"折叠档 = 胶囊 + 两侧各一档 X1"
|
||||
);
|
||||
assert_eq!(m.nav_collapsed_width, 64.0);
|
||||
// 旧的 48 为什么死:它连一档 X1 的留白都放不下胶囊。
|
||||
assert!(
|
||||
48.0 - 2.0 * Space::X1.dp() < pill,
|
||||
"48 放不下 56 的胶囊 —— 这正是旧令牌被删的原因"
|
||||
);
|
||||
assert!(
|
||||
m.nav_collapsed_width >= m.touch_target,
|
||||
"折叠档(每一项的整条命中区)不许低于 48"
|
||||
);
|
||||
assert!(m.nav_collapsed_width < m.nav_rail_width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_bar_height_is_a_page_level_entry() {
|
||||
let m = LayoutMetrics::DESKTOP;
|
||||
|
||||
@@ -6,6 +6,8 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
||||
("NavDownloadExpanded", "Version management", "版本管理"),
|
||||
("NavLogs", "Logs", "日志"),
|
||||
("NavSettings", "Settings", "设置"),
|
||||
("NavCollapse", "Collapse the navigation rail", "折叠导航轨"),
|
||||
("NavExpand", "Expand the navigation rail", "展开导航轨"),
|
||||
("InstancesNew", "New instance", "新建实例"),
|
||||
("InstancesImport", "Import", "导入"),
|
||||
("InstancesBack", "Back to instances", "返回实例列表"),
|
||||
|
||||
@@ -35,6 +35,11 @@ macro_rules! nav_entries {
|
||||
NavDownloadExpanded => ("Version management", "版本管理"),
|
||||
NavLogs => ("Logs", "日志"),
|
||||
NavSettings => ("Settings", "设置"),
|
||||
// M300(NAVCOLLAPSE):轨底折叠按钮的**可读名**。按钮只有图标(`IconKind::Sidebar`),
|
||||
// 所以这两句是它的 tooltip —— 与 `pages/agent` 的既有做法同一条纪律(「条上每个可点
|
||||
// 元素都有可读文字」)。两态各一条,不拼字符串:文案要有主语与动作,别做 `Collapse {x}`。
|
||||
NavCollapse => ("Collapse the navigation rail", "折叠导航轨"),
|
||||
NavExpand => ("Expand the navigation rail", "展开导航轨"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//! 偏好不自己写盘:core::setting 的 schema 按 [`keys::ALL`] 登记这些键,
|
||||
//! app 启动时读一次、变更时写回(跨模块约定见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`)。
|
||||
//! 落盘实现是 [`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 的枚举:取值与落盘词表只有一处真相。
|
||||
pub use unlucky_core::setting::appearance::BgFill;
|
||||
@@ -304,7 +305,7 @@ impl AppearanceBase {
|
||||
/// 注意:`Eq` 不成立([`AppearancePrefs`] 带 `f32` 滑条值),用到的地方按 `PartialEq` 比。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UiPrefs {
|
||||
/// 左侧导航栏折叠成图标条
|
||||
/// 左侧导航栏折叠成图标条(M300 起**已落盘**:键 [`keys::NAV_COLLAPSED`],读 `SettingsBridge::load_nav_collapsed`)
|
||||
pub nav_collapsed: bool,
|
||||
/// 右侧 AI 栏折叠成 icon 轨
|
||||
pub ai_collapsed: bool,
|
||||
@@ -337,7 +338,7 @@ impl Default for UiPrefs {
|
||||
|
||||
/// 持久化键名登记(core::setting schema 按此注册;改名等于迁移,慎动)。
|
||||
pub mod keys {
|
||||
/// 导航栏折叠
|
||||
/// 导航栏折叠(M300 起是**真落盘**键,core schema 同名字符串)
|
||||
pub const NAV_COLLAPSED: &str = "ui.nav.collapsed";
|
||||
/// AI 栏折叠
|
||||
pub const AI_COLLAPSED: &str = "ui.ai.collapsed";
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use serde_json::Value;
|
||||
use unlucky_core::platform::data_dir::Resolver;
|
||||
use unlucky_core::platform::paths::Platform;
|
||||
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 卡住,诊断走日志)。
|
||||
///
|
||||
/// **写走 [`SettingsBridge::write_with`]**(M174 r1/r2 复审):本桥的 store 是长生命的
|
||||
@@ -197,200 +239,6 @@ fn resolve_config_path() -> Option<PathBuf> {
|
||||
fn mtime(path: &Path) -> Option<SystemTime> {
|
||||
std::fs::metadata(path).ok()?.modified().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod 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();
|
||||
}
|
||||
}
|
||||
#[path = "settings_bridge/tests.rs"]
|
||||
mod tests;
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user