feat(app-iced): 页面切换按导航深度定向 + 模态缩放淡入(M256 MOTION1)

- transition.rs:唯一一张动效表。同级换页走水平(±12 / 旧页 ∓8)、进深层走纵向
  (新页 +16 上浮 / 旧页 -8),进腿 medium2/medium1(300/250),退腿 short4→medium1(更短),
  进用 Standard、退用 StandardAccelerate。幅度全部取自 Space 令牌。
- pump.rs:Slot 三互斥槽 → Kind(Boot/Peer{forward}/Push/Pop)+ 两腿轨道;新增模态状态机
  (Opening/Open/Closing,展开后不占帧,退场走完交回一次 take_modal_closed)。
- components/motion/zoom.rs:绕中心缩放的自绘控件(renderer.with_transformation,
  tiny-skia 与 wgpu 都实现了,文字也真的跟着缩放);scale==1.0 时不套变换 ⇒ 出图基线不变。
- pages/mod.rs:page_with_settings_transition(ui, current, previous, …) 叠
  stack![opaque(旧页@退场位移), 新页@进场位移];旧函数只剩一句委托(blast radius 不变)。
- App.previous_view:换页时用 std::mem::replace **移动**旧模型(冻结快照),过渡结束当拍
  置 None(不随换页次数增长)。
- 模态:逻辑状态当拍关(state.dialogs 立刻空),画面多留 150ms;退场期间弹窗消息被闸门吞掉。
- platform/motion.rs:系统「减少动效」三态探针(Windows=SystemParametersInfoW,
  Linux/macOS=子进程+300ms 超时),OnceLock 缓存;Unknown **不降级**(探针失败不静默关动效)。
- MotionScale::HALF 语义核清并写进 design 文档:是**时长减半**(更快),不是半速。
This commit is contained in:
AskaEth committed 2026-11-12 10:50:02 +08:00
1 parent 5a182e80de
commit b1c09c4c91
21 files changed
+2168 -658

No files matched your search

+58 -29
View File
@@ -58,11 +58,9 @@
//!
//! ### 设置页只有一个投递口(M101 `SETTINGSMSG`)
//!
//! 设置页的四条填页线各自在自己的文件里定义消息与状态,本文件的 [`Message`] 只留一个
//! [`Message::Settings`](详见 `pages/settings/mod.rs` 的「消息 / 状态」两节);`Message` 与
//! `Screen` 本身也在 M101 r2 搬到 [`message`] / [`screen`](`pages/mod.rs` 合并后 397 行,只剩
//! 3 行余量),这里的 `pub use` 保证了 `pages::Message` / `pages::Action` / `pages::Screen`
//! 三条路径一字未变。
//! 设置页的四条填页线各自定义消息与状态,本文件的 [`Message`] 只留一个 [`Message::Settings`]
//! (见 `pages/settings/mod.rs`)。`Message` / `Screen` 在 M101 r2 搬到 [`message`] / [`screen`],
//! 这里的 `pub use` 保证 `pages::Message` / `Action` / `Screen` 三条路径一字未变。
//!
//! ## 偏离登记(D30 要求逐条写清;M84 六条 + M90 三条)
//!
@@ -81,16 +79,13 @@
//!
//! ### 偏离 #7 的收口记录(M90 r2 / P1-1)
//!
//! r1 的产品页渲染的是 `pages::app`:一条**只有高度、没有任何控件**的标题栏留白带。而
//! `DragWindow` / `ResizeStart` / `Minimize` / `ToggleMaximize` / `Close` 五种消息的**构造点只有
//! `shell/mod.rs`**,窗口又是 `decorations: false` ⇒ 交互模式下拖不动、缩不了、点不到关闭。
//!
//! 修法:`shell::view_with_content(ui, nav, title, content, forced)` 把外壳(标题栏 + 三条热区 +
//! 导航轨)与内容区拆开,产品页把 [`page`] 的树塞进内容槽位;`shell::view`(M68 骨架卡)保留给
//! `Route::Agent` 回落 —— 删了会让私有模块里的 `view` / `shell::nav::rail` 变死代码,`-D warnings` 会红。
//!
//! `pages::app`(本文件)现在**只服务 harness**(`pages_demo` example 与单测):它的标题栏仍是留白带,
//! 因为 `shell` 的回调类型是 `crate::app::Message`,套不进 `pages::Message` 的树里。
//! r1 的产品页渲染的是 `pages::app`:一条**只有高度、没有控件**的留白带,而五种窗口消息的构造点
//! 只有 `shell/mod.rs`、窗口又是 `decorations: false` ⇒ 拖不动、缩不了、点不到关闭。
//! 修法:`shell::view_with_content` 把外壳(标题栏 + 三条热区 + 导航轨)与内容区拆开,产品页把
//! [`page`] 塞进内容槽位;`shell::view`(M68 骨架卡)留给 `Route::Agent` 回落(删了会让私有模块
//! 里的 `view` / `shell::nav::rail` 变死代码,`-D warnings` 会红)。
//! `pages::app` 现在**只服务 harness**:`shell` 的回调类型是 `crate::app::Message`,套不进
//! `pages::Message` 的树里。
//!
//! ## 截图通道
//!
@@ -167,6 +162,7 @@ pub fn page<'a>(
render(
ui,
model,
None,
settings::States {
appearance: None,
groups: settings::demo_groups(),
@@ -195,6 +191,7 @@ pub fn page_with_appearance<'a>(
render(
ui,
model,
None,
settings::States {
appearance: Some(appearance),
groups: settings::demo_groups(),
@@ -207,12 +204,10 @@ pub fn page_with_appearance<'a>(
/// 产品路径:带上**设置页那份只读状态**(外观运行期状态 + 各组自己的状态)。
///
/// 为什么要单独一个入口:组状态的所有者是 `App`(`app/state.rs` 的 `settings` 字段),页面层
/// 只借用一帧。加/改某一组的字段不改变这条签名(`States` 里只有 `&Groups`),所以四条填页线
/// 都不必来这里动一行。
/// `language` 是 M106 加的:「当前档位 + 生效语言」只有 `App` 知道(`App::language()`),而设置页
/// 的 General 组要画它。唯一调用点是 `app/view.rs`;harness 那三条入口([`page`] /
/// [`page_with_appearance`] / [`app_with_appearance`])仍用 [`Language::default`]。
/// 为什么要单独一个入口:组状态的所有者是 `App`(`app/state.rs` 的 `settings` 字段),页面层只借用
/// 一帧;加/改某一组的字段不改变这条签名(`States` 里只有 `&Groups`)。`language` 是 M106 加的
/// (「当前档位 + 生效语言」只有 `App` 知道,设置页 General 组要画它);harness 那三条入口仍用
/// [`Language::default`]。
pub fn page_with_settings<'a>(
ui: &'a Ui,
model: &'a Model,
@@ -220,10 +215,48 @@ pub fn page_with_settings<'a>(
language: Language,
launch_form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
render(ui, model, states, language, launch_form)
page_with_settings_transition(ui, model, None, states, language, launch_form)
}
/// 同 [`page_with_settings`],但带上**上一帧的模型**(M256:换页时旧页要反向退场)。
///
/// 另开一条而不是加形参:上面那条是全仓(含 `pages::harness` / `pages_demo`)的既有调用点,
/// 它们没有旧页可传。`previous` 必须由调用方(`App`)持有——`Element<'a>` 会借它里面的字符串,
/// 线程局部的借用守卫活不过函数体。
pub fn page_with_settings_transition<'a>(
ui: &'a Ui,
model: &'a Model,
previous: Option<&'a Model>,
states: settings::States<'a>,
language: Language,
launch_form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
render(ui, model, previous, states, language, launch_form)
}
fn render<'a>(
ui: &'a Ui,
model: &'a Model,
previous: Option<&'a Model>,
states: settings::States<'a>,
language: Language,
launch_form: &'a instances::LaunchForm,
) -> Element<'a, Message> {
// M192 / M256:主体入场(首屏 / 同级换页 / 进深层)。**这里只采样**——「标识变了就起跑」
// 在 update 侧(`App::sync_motion`):iced 先算订阅表后建视图,在 view 里插轨道当拍订阅不上。
let frame = motion::frame();
let current = motion::shifted(body(ui, model, states, language, launch_form), frame.enter);
// 旧页反向退场:退场腿没走完才画(`exiting` 一假就丢掉);`opaque` 让幽灵**吃掉**自己的点击。
let Some(older) = previous.filter(|_| frame.exiting) else {
return current;
};
let ghost = motion::shifted(body(ui, older, states, language, launch_form), frame.exit);
iced::widget::stack![iced::widget::opaque(ghost), current].into()
}
/// 一页的完整主体:页头 + 内容列,外加自更新提示条(M159)。
fn body<'a>(
ui: &'a Ui,
model: &'a Model,
states: settings::States<'a>,
@@ -256,19 +289,14 @@ fn render<'a>(
// M159:自更新提示条挂在**每一页**的页头之上(有新版 / 正在下载 / 已就位时才出现)。
// 为什么在这里而不是外壳里:外壳(`shell`)只认窗口装饰与导航,页面层才能按快照决定画什么;
// 而「每一页都要能看见」这条要求又决定了它不能只挂在实例页上。
let tree: Element<'a, Message> = match update::banner(ui, &model.update, model.lang.catalog()) {
match update::banner(ui, &model.update, model.lang.catalog()) {
Some(banner) => column![banner, page]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.height(Length::Fill)
.into(),
None => page,
};
// M192:主体入场(首屏 / 换页 / 列表↔详情)。**这里只采样**——「标识变了就起跑」在 update 侧
// (`App::sync_motion`):iced 先算订阅表后建视图,在 view 里插轨道当拍订阅不上(复审 P1-1)。
// 静息时位移恒为 `(0, 0)`,`shifted` 常驻的那一层零内距,与没有动效时逐像素相同。
motion::shifted(tree, motion::sample())
}
}
/// 整窗(**harness 专用**):标题栏留白带 + 导航轨 + 当前路由的页面。
@@ -300,6 +328,7 @@ pub fn app_with_appearance<'a>(
let body = render(
ui,
model,
None,
settings::States {
appearance,
groups: settings::demo_groups(),