From b1c09c4c91fd3fff20f6352ca3d5a7e828dfef51 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 22:25:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(app-iced):=20=E9=A1=B5=E9=9D=A2=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E6=8C=89=E5=AF=BC=E8=88=AA=E6=B7=B1=E5=BA=A6=E5=AE=9A?= =?UTF-8?q?=E5=90=91=20+=20=E6=A8=A1=E6=80=81=E7=BC=A9=E6=94=BE=E6=B7=A1?= =?UTF-8?q?=E5=85=A5=EF=BC=88M256=20MOTION1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 文档:是**时长减半**(更快),不是半速。 --- .github/workflows/guards.yml | 6 +- crates/app-iced/src/app/dialog/view.rs | 121 ++++- crates/app-iced/src/app/state.rs | 9 + crates/app-iced/src/app/state/construct.rs | 2 + crates/app-iced/src/app/state/refresh.rs | 31 +- crates/app-iced/src/app/subscription.rs | 58 ++- crates/app-iced/src/app/update/dialog.rs | 42 +- crates/app-iced/src/app/update/motion_sync.rs | 224 +++++++++ crates/app-iced/src/app/view.rs | 4 +- crates/app-iced/src/components/motion/mod.rs | 169 ++++--- crates/app-iced/src/components/motion/pump.rs | 432 ++++++++++-------- .../app-iced/src/components/motion/tests.rs | 345 -------------- .../src/components/motion/tests/mod.rs | 345 ++++++++++++++ .../src/components/motion/tests/modal.rs | 165 +++++++ .../src/components/motion/tests/timeline.rs | 95 ++++ .../src/components/motion/transition.rs | 230 ++++++++++ crates/app-iced/src/components/motion/zoom.rs | 186 ++++++++ crates/app-iced/src/pages/mod.rs | 87 ++-- crates/app-iced/src/platform/mod.rs | 15 +- crates/app-iced/src/platform/motion.rs | 240 ++++++++++ crates/design/src/motion/mod.rs | 20 + 21 files changed, 2168 insertions(+), 658 deletions(-) delete mode 100644 crates/app-iced/src/components/motion/tests.rs create mode 100644 crates/app-iced/src/components/motion/tests/mod.rs create mode 100644 crates/app-iced/src/components/motion/tests/modal.rs create mode 100644 crates/app-iced/src/components/motion/tests/timeline.rs create mode 100644 crates/app-iced/src/components/motion/transition.rs create mode 100644 crates/app-iced/src/components/motion/zoom.rs create mode 100644 crates/app-iced/src/platform/motion.rs diff --git a/.github/workflows/guards.yml b/.github/workflows/guards.yml index 1feddf2..1de583a 100644 --- a/.github/workflows/guards.yml +++ b/.github/workflows/guards.yml @@ -181,8 +181,10 @@ jobs: # D36 定案:整窗 α 需要 `unsafe`(`SetLayeredWindowAttributes` 没有安全替代)。 # M196 加了第二处:控制台附着(`AttachConsole`)同样只有 Win32 一条路。两处各自成文件, + # M256 加了第三处:系统「减少动效」探针(`SystemParametersInfoW(SPI_GETCLIENTAREAANIMATION)` + # 同样没有安全替代;Linux/macOS 那两支走子进程,零 unsafe)。三处各自成文件, # 判据是**集合相等**而不是「黑名单命中」: - # - 任何第三个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿); + # - 任何第四个显式放行 unsafe 的文件 → 红(黑名单写法在这里会假绿); # - 落点文件把放行删掉 / 改名 / 搬走 → 红(否则门禁守着一条已经不存在的路径); # - 多行属性、同行合并属性、多行 `cfg_attr`、`expect(unsafe_code)` 都已覆盖 # (r2 前的行级正则对前两种会假绿,当时只能靠 ci.yml 的 fmt job 兜底;r3 起由词法器直接抓)。 @@ -194,7 +196,7 @@ jobs: - name: 全仓显式放行 unsafe 的文件集合 == 期望集合 run: | set -uo pipefail - EXPECTED=$'crates/app-iced/src/platform/win32/console.rs\ncrates/app-iced/src/platform/win32/transparency.rs' + EXPECTED=$'crates/app-iced/src/platform/motion.rs\ncrates/app-iced/src/platform/win32/console.rs\ncrates/app-iced/src/platform/win32/transparency.rs' # 扫描口径(M91 r3):一个小词法器,而不是正则。 # # 为什么必须用词法器:r2 的写法是「按全文方括号净深度合并续行」,但 Rust 里 diff --git a/crates/app-iced/src/app/dialog/view.rs b/crates/app-iced/src/app/dialog/view.rs index d96c2e7..f4962ae 100644 --- a/crates/app-iced/src/app/dialog/view.rs +++ b/crates/app-iced/src/app/dialog/view.rs @@ -27,6 +27,7 @@ use unlucky_design::typography::TypeRole; use crate::app::components::buttons; use crate::app::components::feedback::dialog::scrim; use crate::app::components::feedback::panel::DialogGeometry; +use crate::app::components::motion; use crate::pages::frame; use crate::theme::Ui; @@ -58,37 +59,68 @@ pub(crate) fn layer<'a>( base: Element<'a, Message>, modal: &'a Modal, ) -> Element<'a, Message> { + // M256:模态进出只由**一个**进度驱动——遮罩做画布色→scrim 色的**预合成**插值(不透明), + // 卡片做 0.96→1.0 的缩放。`progress = 1` 时两者都退化成改造前的原值(出图基线不变)。 + let progress = motion::modal().progress; + let backdrop = mouse_area( container(Space::new()) .width(Length::Fill) .height(Length::Fill) .style(move |_theme| container::Style { - background: Some(Background::Color(backdrop_color(ui))), + background: Some(Background::Color(backdrop_color_at(ui, progress))), ..container::Style::default() }), ) .on_press(Message::Dialog(DialogMessage::Dismiss)); // 透明的一层只负责居中;会吃点击的 `opaque` 只包卡片。 - let centered = container(card_plane(ui, modal)) - .width(Length::Fill) - .height(Length::Fill) - .align_x(Alignment::Center) - .align_y(Alignment::Center) - .padding(ui.space(Sp::X6)); + let centered = container(motion::scaled( + card_plane(ui, modal), + motion::modal_scale(progress), + )) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .padding(ui.space(Sp::X6)); iced::widget::stack![base, backdrop, centered].into() } -/// 遮罩色:**组件层那一份不透明预合成**(黑 @0.32 叠在画布上)。 +/// 遮罩色(静息 = 完全展开):**组件层那一份不透明预合成**(黑 @0.32 叠在画布上)。 /// -/// 为什么不自己拿 `Role::OnSurface` 加 α:它在深色档是 **tone 90(近白)**,32% 铺上去会把 -/// 整窗**提亮**(r1 复审实测 (9,21,28) → (129,138,145));而且 α 混色在 wgpu/tiny-skia 两档 -/// 合成口径不同(linear vs sRGB),D27 要求 CPU 档零半透明。组件层 [`scrim`] 两件事都解决了。 +/// 不自己拿 `Role::OnSurface` 加 α:它在深色档是 **tone 90(近白)**,32% 铺上去会把整窗 +/// **提亮**(r1 实测 (9,21,28) → (129,138,145));且 α 混色在 wgpu/tiny-skia 两档合成口径不同 +/// (linear vs sRGB),D27 要求 CPU 档零半透明。组件层 [`scrim`] 两件事都解决了。 /// -/// 单独成函数是为了让 [`super::tests`] 断言**视图真正用的那一份**(P1 的守卫)。 +/// 只在测试构建里存在:让 [`super::tests`] 断言**视图真正用的那一份**;产品路径一律走 +/// [`backdrop_color_at`](`progress = 1` 那一端逐位相同)。 +#[cfg(test)] pub(super) fn backdrop_color(ui: &Ui) -> Color { - scrim(ui) + backdrop_color_at(ui, 1.0) +} + +/// 遮罩色在**动画进度** `progress` 上的取值(M256):画布色 → scrim 色的预合成插值。 +/// +/// **为什么是"插值颜色"而不是"调 α"**:D27 禁 CPU 渲染路径上的面板级半透明(D36 只对**窗口级** +/// 统一 α 开过口子,面板 α 仍强制 1.0)。所以「淡入」不能靠 `Color { a: 0.1 }`,只能把**已经 +/// 合成好的不透明色**从画布色插值到 scrim 色:`0` 时与背后画布同色(看不见)、`1` 时是原遮罩, +/// 全程 `a = 1.0`,两个后端出图一致。`1` 那一端必须**逐位等于**静息遮罩色,否则出图基线会漂。 +pub(super) fn backdrop_color_at(ui: &Ui, progress: f32) -> Color { + let t = progress.clamp(0.0, 1.0); + if t >= 1.0 { + return scrim(ui); + } + let from = ui.layer(MaterialLayer::Canvas); + let to = scrim(ui); + Color { + r: from.r + (to.r - from.r) * t, + g: from.g + (to.g - from.g) * t, + b: from.b + (to.b - from.b) * t, + // 预合成色两端都是不透明的:这里显式钉死 1.0,不给"淡入=半透明"留口子。 + a: 1.0, + } } /// 卡片层:`opaque` 让「卡片内的点击」被吃掉,不再冒到遮罩(点遮罩才关)。 @@ -297,3 +329,66 @@ fn wrapped<'a>(ui: &'a Ui, content: &'a str, color: Color) -> Element<'a, Messag .wrapping(iced::widget::text::Wrapping::WordOrGlyph) .into() } + +#[cfg(test)] +mod motion_tests { + //! 模态进场的**可判读数值取证**(M256 / tower 门槛 E):缩放值与遮罩色逐档钉住。 + //! + //! 抓得住:把淡入做回「调 α」(`a < 1` 即红,D27)、把 `progress = 1` 做成近似值(与 + //! `backdrop_color` 不等即红)、把缩放方向做反。抓不住:**观感**(截图见 artifacts/motion1)。 + + use super::*; + use crate::app::components::motion; + use unlucky_design::color::SchemeFlavor; + use unlucky_design::elevation::MaterialLayer; + use unlucky_design::typography::Platform; + + const FAMILIES: crate::theme::Families = crate::theme::Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }; + + fn luma(color: Color) -> f32 { + 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b + } + + /// 遮罩:`0` 是画布色(看不见)、`1` 是原遮罩(逐位相同)、全程不透明且单调变暗。 + #[test] + fn the_scrim_fades_by_lerping_opaque_colors_never_by_alpha() { + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let ui = crate::theme::Ui::with_platform(flavor, Platform::Windows, FAMILIES); + let canvas = ui.layer(MaterialLayer::Canvas); + let rest = backdrop_color(&ui); + assert_eq!( + backdrop_color_at(&ui, 0.0), + canvas, + "{flavor:?}: 起手同画布" + ); + assert_eq!( + backdrop_color_at(&ui, 1.0), + rest, + "{flavor:?}: 到位逐位相同" + ); + + let mut previous = luma(canvas); + for step in [0.0_f32, 0.25, 0.5, 0.75, 1.0] { + let color = backdrop_color_at(&ui, step); + assert_eq!(color.a, 1.0, "{flavor:?}: 遮罩必须全程不透明(D27)"); + let current = luma(color); + assert!( + current <= previous + 1.0e-6, + "{flavor:?}: 遮罩必须单调变暗({previous} → {current})" + ); + previous = current; + println!( + "[motion1] {flavor:?} t={step:.2} scale={:.3} rgb({:.3},{:.3},{:.3})", + motion::modal_scale(step), + color.r, + color.g, + color.b + ); + } + } + } +} diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index 318da94..2de6a71 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -118,6 +118,15 @@ pub struct App { pub(super) width: f32, /// 展示转换缓存(presenter 快照 → 页面视图模型)。 pub(super) view: pages::Model, + /// **换页过渡期的旧页快照**(M256):页面切换时把上一帧的 `view` 移进来,退场腿走完就丢掉。 + /// + /// 为什么必须存在 `App` 里而不是线程局部:`pages::page_with_settings_transition` 返回的 + /// `Element<'a>` **借**模型里的字符串,`'a` 由调用方决定 ⇒ 线程局部的借用守卫活不过函数体, + /// 只有 `App` 的字段能给出 `&'a Model`。 + /// + /// **用完即焚**:`App::sync_motion` 在 `motion::needs_frames()` 为假的那一拍把它置 `None`—— + /// 这是长时间常驻的启动器,留着就是「每换一次页多一份模型」的线性增长。 + pub(super) previous_view: Option, /// 视图模型是否过期(P2-1):只有 `apply` / `navigate` / resize / 主题这类**真的改了状态** /// 的事情才置位,`advance()` 每拍只在置位时重建模型(否则 30 Hz × N 实例的白克隆)。 pub(super) view_dirty: bool, diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index 6a9c2ff..1e7874c 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -126,6 +126,8 @@ impl App { last_tick: std::time::Instant::now(), width, view, + // M256:换页过渡期的「旧页」快照。启动时没有旧页。 + previous_view: None, view_dirty: false, watch_until: None, window: None::, diff --git a/crates/app-iced/src/app/state/refresh.rs b/crates/app-iced/src/app/state/refresh.rs index 1ea443f..680786d 100644 --- a/crates/app-iced/src/app/state/refresh.rs +++ b/crates/app-iced/src/app/state/refresh.rs @@ -9,6 +9,7 @@ use unlucky_presenter::dto::{AppCommand, ModsCommand, SavesCommand}; use super::{mods_shot, saves_shot}; use unlucky_presenter::toast::ToastKind; +use crate::app::components::motion; use crate::app::view; use crate::pages; @@ -81,7 +82,7 @@ impl App { // 右栏占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104) let width = self.agent.content_width(self.width); // M106:语言形参跟着 `LangState` 走(`draft` 那一位是 M105 的安装草稿)。 - self.view = super::super::view::model( + let next = super::super::view::model( &self.state, width, self.height, @@ -91,6 +92,15 @@ impl App { self.lang(), &self.content, ); + // M256:换页 / 换层(屏 或 详情 id 变了)时才把**上一帧的模型**移进 `previous_view`。 + // 用 `replace`(移动)而不是克隆:旧页要的是**转换开始那一刻的快照**,之后 presenter 再怎么 + // 刷新都不该动它(否则会看到"两个页面同时在动内容")。 + // 同一屏的普通刷新(下载进度 / 主题 / 语言)不换——否则每来一条事件就多留一份模型。 + if (self.view.screen, detail_key(&self.view)) != (next.screen, detail_key(&next)) { + self.previous_view = Some(std::mem::replace(&mut self.view, next)); + } else { + self.view = next; + } // M157:启动参数表单的重播种(换实例必重播种、同实例的脏草稿不覆盖)。 self.sync_launch_form(); // 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`, @@ -98,7 +108,15 @@ impl App { self.rebuild_account_page(width); // M187:弹窗的视图模型也在这一处重建——`view()` 只借 `&self`,放不进局部变量 // (Element 借字符串,局部一死树就悬空)。全仓「快照 → 视图模型」只在这里发生。 - self.dialog_model = self.dialog_modal(); + // + // M256:**退场期间要留住旧框**。逻辑状态(`state.dialogs`)在用户点下去的那一拍就关掉了 + // (所以 `dialog_modal()` 变 `None`),但画面还要缩着退出去 150ms——那一份模型必须留到 + // 退场跑完(`App::sync_motion` 收到 `take_modal_closed()` 时置 `None`)。 + // 判据用 `motion::modal_closing()` 而不是"有没有动效":关着动效时它恒为假 ⇒ 当场清。 + let next_modal = self.dialog_modal(); + if next_modal.is_some() || !motion::modal_closing() { + self.dialog_model = next_modal; + } self.view_dirty = false; self.refreshes += 1; } @@ -232,3 +250,12 @@ impl App { self.mods_failed_seen = Some(key); } } + +/// 详情的身份键(`None` = 不在详情页):与 `app/update/motion_sync.rs` 的 `detail` 同一判据。 +fn detail_key(model: &pages::Model) -> Option<&str> { + model + .instances + .detail + .as_ref() + .map(|detail| detail.id.as_str()) +} diff --git a/crates/app-iced/src/app/subscription.rs b/crates/app-iced/src/app/subscription.rs index d49ce2a..9524e60 100644 --- a/crates/app-iced/src/app/subscription.rs +++ b/crates/app-iced/src/app/subscription.rs @@ -115,7 +115,7 @@ mod tests { //! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。 use super::*; - use crate::app::components::motion::{Slot, FRAME_MS}; + use crate::app::components::motion::FRAME_MS; use crate::cli::Options; use crate::pages; use crate::theme::Families; @@ -341,10 +341,58 @@ mod tests { } } - /// 槽枚举的可见性检查:`Slot` 只经帧泵导出(页面拿不到「给我 250ms」这种口子)。 + /// 「**页面拿不到时长**」的源码守卫(M256 取代 M192 的 `Slot` 三条断言)。 + /// + /// 口径:`pages/**` 的产品代码里不许出现时长的任何入口(`DurationToken` / `page_spec` / + /// `modal_spec` / `FRAME_MS` / `advance(`):页面只能说「我要换页了」,用哪一档、多久由 + /// `components/motion/transition.rs` 那张表决定(D31)。 + /// + /// **抓得住**:页面里写 `DurationToken::Medium2` / `motion::FRAME_MS` / 自己推时间轴 + /// (变异:在 `pages/download.rs` 顶上加 `use unlucky_design::motion::DurationToken;` ⇒ 红)。 + /// **抓不住**:①表里某一档的数字写错(那是 `components/motion/tests` 的登记用例);②绕过 + /// 符号名的路径(转一手、通配导入)——只按符号名扫,不做类型分析。 #[test] - fn slots_are_the_three_documented_ones() { - assert_eq!(Slot::ALL.len(), 3); - assert_eq!(Slot::ALL.map(Slot::name), ["boot", "screen", "detail"]); + fn pages_cannot_reach_for_a_duration() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("pages"); + let mut files = Vec::new(); + collect_rust_files(&dir, &mut files); + assert!( + files.len() >= 20, + "只扫到 {} 个页面文件:扫描逻辑坏了", + files.len() + ); + + const FORBIDDEN: [&str; 5] = [ + "DurationToken", + "page_spec", + "modal_spec", + "FRAME_MS", + "advance(", + ]; + let mut hits = Vec::new(); + for path in files { + let text = std::fs::read_to_string(&path).expect("读源文件"); + // 只看产品代码(测试与文档注释不算:`#[cfg(test)]` 之后整段跳过)。 + let product = text.split("#[cfg(test)]").next().unwrap_or(&text); + let code = product + .lines() + .filter(|line| !line.trim_start().starts_with("//")); + for symbol in FORBIDDEN { + if code.clone().any(|line| line.contains(symbol)) { + let name = path + .strip_prefix(&dir) + .unwrap_or(&path) + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/"); + hits.push((name, symbol)); + } + } + } + assert!( + hits.is_empty(), + "页面层不许直接碰时长/时间轴(用哪一档是 `transition.rs` 的表决定的):{hits:?}" + ); } } diff --git a/crates/app-iced/src/app/update/dialog.rs b/crates/app-iced/src/app/update/dialog.rs index 06b546f..341ec2e 100644 --- a/crates/app-iced/src/app/update/dialog.rs +++ b/crates/app-iced/src/app/update/dialog.rs @@ -7,10 +7,33 @@ use iced::Task; use unlucky_presenter::toast::ToastKind; +use crate::app::components::motion; use crate::app::dialog::{export, Message as DialogMessage}; use crate::app::state::App; +/// 关掉当前这个框(**唯一出口**,M256)。 +/// +/// 逻辑状态是**当场**关的(`close_dialog()` 立刻清 `state.dialogs`)——延迟关闭会让队列里那个 +/// 框在退场期间仍然"活着"(`current()` 还是它),所有按"有没有框"判断的代码都会看到一个已经 +/// 点过「确定」的框。所以退场只发生在**画面上**:`refresh_view` 在 `motion::modal_closing()` +/// 期间保留旧框的视图模型,`App::sync_motion` 在退场走完那一拍把它置 `None`。 +/// +/// 关着动效(`MotionScale::NONE` / 截图通道)时 `begin_modal_close()` 返回 `true`, +/// `modal_closing()` 恒假 ⇒ 当场清,退回改造前的行为。 +fn close(app: &mut App) { + let _ = motion::begin_modal_close(); + app.close_dialog(); + // 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒 + // (r1 复审 P3-1)。退场的第一帧必须在这一拍画出来。 + app.refresh_view(); +} + pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task { + // M256:退场动画期间**吞掉**弹窗消息。框正在缩小,这时再点一次「删除世界」不该再生效 + // (主按钮的 `take_confirm` 已经在上一次点击里核销过目标,但用户看到的仍是"框还在")。 + if motion::modal_closing() { + return Task::none(); + } match message { DialogMessage::Primary => { // M247:删除存档的二次确认——主按钮就是**真删**(这是本页唯一不可逆的动作)。 @@ -20,7 +43,10 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task Task Task { @@ -129,8 +153,8 @@ pub(super) fn escape(app: &mut App) -> bool { // 现在还有 id 绑定兜一道(`take_confirm(current)`),这里是纵深防御的第二层。 // 复审 r2 加固:清的时候**只清这个框自己的目标**(`close_confirm`),不碰别人的。 app.state.saves.close_confirm(current); - app.close_dialog(); - app.refresh_view(); + // M256:统一出口(Esc 也走退场动画,而不是硬跳)。 + close(app); true } diff --git a/crates/app-iced/src/app/update/motion_sync.rs b/crates/app-iced/src/app/update/motion_sync.rs index 3996dac..63f5070 100644 --- a/crates/app-iced/src/app/update/motion_sync.rs +++ b/crates/app-iced/src/app/update/motion_sync.rs @@ -36,6 +36,14 @@ use super::*; impl App { /// 每条消息处理完之后同步一次内容标识(**必须在 `update` 里**,理由见模块文档)。 pub(in crate::app) fn sync_motion(&mut self) { + // M256:模态退场走完 ⇒ **这一刻**才把保留的框模型丢掉。 + // 逻辑状态早就在用户点下去的那一拍关了(`state.dialogs` 已清),这里丢的只是"还在缩小的 + // 那一份画面"。同一拍里置 `view_dirty`,下一帧就没有模态层了。 + if motion::take_modal_closed() { + self.dialog_model = None; + self.view_dirty = true; + } + // **还没有窗口就什么都不做**(也不记标识):动效是画出来的东西,没有窗口时 // `window::frames()` 一条消息也不会有,起跑只会白占一档。更要紧的是「首屏入场」的语义—— // 等窗口到了再算「第一眼看到的内容」,否则首屏那一下会被启动期的空树吃掉。 @@ -50,6 +58,16 @@ impl App { _ => None, }; motion::observe(screen, detail); + // M256:模态的**逻辑**开关(队列里有没有框)。用 presenter 的队列而不是 `dialog_model`: + // 退场期间框还画着(`dialog_model` 仍是 `Some`),但逻辑上已经在关的路上了。 + motion::observe_modal(self.state.dialogs.current().is_some()); + + // M256:过渡结束(没有轨道了)当拍就**丢掉旧页快照**。 + // 这条是硬要求:`previous_view` 是一份完整的页面模型,留着就是「每换一次页多一份」的 + // 线性增长——长时间常驻的启动器上这是明确的缺陷,不是"小浪费"。 + if !motion::needs_frames() { + self.previous_view = None; + } } /// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`super::boot_env::MotionFixture`])。 @@ -136,6 +154,57 @@ fn screen_key(route: &Route) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::app::components::motion::FRAME_MS; + use crate::app::message::Message; + use crate::cli::Options; + use crate::theme::Families; + 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", + }; + + /// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。 + fn headless() -> App { + App::with_presenter( + Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(MockBackend::new()), + SettingsBridge::memory_only(), + ) + } + + /// 推时间轴到没有轨道(每步 16 ms,上限 20 s)。 + fn run_until_idle() { + for _ in 0..1250 { + if !motion::needs_frames() { + return; + } + motion::advance_by(FRAME_MS); + } + } + + /// 一个有窗口、首屏动效已经跑完的 App(`sync_motion` 在没有窗口时直接返回)。 + fn idle_app() -> App { + motion::reset(); + motion::configure(MotionScale::STANDARD, None); + let mut app = headless(); + app.window = Some(iced::window::Id::unique()); + let _ = app.update(Message::Boot); + app.watch_until = None; + run_until_idle(); + // 再走一条消息:`sync_motion` 在这一拍把启动期那次换页的 `previous_view` 烧掉 + // (首屏从「构造时的模型」换到真路由,本身就是一次内容标识变化)。 + let _ = app.update(Message::Frame); + assert!(!motion::needs_frames(), "前提:首屏动效已经跑完"); + app + } /// 路由 → 屏键:五个一级页各自唯一,`Detail` 归「实例」,账户/AI 不归 pages。 #[test] @@ -163,4 +232,159 @@ mod tests { assert_eq!(keys[6], None, "账户页不归 pages 画"); assert_eq!(keys[7], None, "AI 独立页不归 pages 画"); } + + /// **旧页模型用完即焚**(M256 门槛 B):过渡结束的那一拍 `previous_view` 必须是 `None`。 + /// + /// 抓得住:把 `sync_motion` 末尾的 `if !needs_frames() { previous_view = None }` 删掉 + /// ——那样每换一次页就永久多留一份完整模型,长时间跑下去是线性增长。 + /// 抓不住:模型有多大(这条只看"有没有留")。 + #[test] + fn the_previous_page_model_is_dropped_when_the_transition_ends() { + let mut app = idle_app(); + assert!(app.previous_view.is_none(), "前提:静息态没有旧页"); + + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download))); + assert!( + app.previous_view.is_some(), + "换页那一拍必须留下旧页(退场要画它)" + ); + assert_eq!( + app.previous_view.as_ref().map(|model| model.screen), + Some(pages::Screen::Instances), + "旧页必须是**切换前**那一屏" + ); + + run_until_idle(); + // 下一拍消息(真机上就是最后一帧)收尾。 + let _ = app.update(Message::Frame); + assert!( + app.previous_view.is_none(), + "过渡结束必须当拍丢掉旧页模型(不许随换页次数线性增长)" + ); + } + + /// **连续多次换页也只保有 1 份旧模型**(门槛 B 的另一半)。 + #[test] + fn repeated_page_changes_never_accumulate_models() { + let mut app = idle_app(); + let routes = [ + Route::Download, + Route::Content, + Route::Settings, + Route::Instances, + ]; + for route in routes { + let _ = app.update(Message::Page(pages::Message::Navigate(route))); + // 中间只走 4 帧再点下一次(模拟快速连点导航轨) + for _ in 0..4 { + motion::advance_by(FRAME_MS); + } + let count = usize::from(app.previous_view.is_some()); + assert!(count <= 1, "同一时刻最多只有 1 份旧页模型,实测 {count}"); + } + run_until_idle(); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Logs))); + run_until_idle(); + let _ = app.update(Message::Frame); + assert!( + app.previous_view.is_none(), + "连续 5 次换页之后仍应是 None(不是攒了 5 份)" + ); + } + + /// 旧页是**冻结快照**(门槛 C):过渡期间的刷新不许把它换成新内容。 + /// + /// 抓得住:把 `refresh.rs` 的「只在屏/详情变了才换」改成"每次刷新都换"——那样旧页会跟着 + /// presenter 变,画面上就是两个页面同时动内容。 + #[test] + fn the_previous_page_is_a_frozen_snapshot() { + let mut app = idle_app(); + let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download))); + let before = app + .previous_view + .as_ref() + .map(|model| (model.screen, model.window_width)); + assert!(before.is_some()); + + // 过渡期间来一次「同屏刷新」(主题/下载进度这一类的形状:不换屏) + app.view_dirty = true; + app.refresh_view(); + motion::advance_by(FRAME_MS); + + assert_eq!( + app.previous_view + .as_ref() + .map(|model| (model.screen, model.window_width)), + before, + "同屏刷新不该换掉旧页快照" + ); + } + + /// **退场窗口内那个框已经关掉,且不可再交互**(tower 的硬要求)。 + /// + /// 三件事分别断言:①逻辑状态当拍就空(`state.dialogs` 里没有它);②画面还在退场 + /// (`dialog_model` 仍 `Some` ⇒ 不是硬跳);③这 150ms 内再点一次**什么都不发生** + /// (`on_dialog` 的 `modal_closing` 闸门 → 连一次重画都不该有)。 + /// + /// **抓得住**:把 `on_dialog` 顶部的 `if motion::modal_closing()` 删掉(第二次点击会走进 + /// `close(app)`,`refreshes` 涨一次 ⇒ 红);或让逻辑关闭延后(①红);或让退场直接清模型(②红)。 + /// **抓不住**:几何级的命中测试——「点在那个坐标上会不会命中幽灵框」。本仓的单元测试没有 + /// iced 的布局树,`zoom.rs` 里的缩放也**不改**命中矩形(记在它自己的"已知局限"里)。 + /// 这一条由闸门保证:**无论命中在哪,退场期间的弹窗消息一律不生效**。 + #[test] + fn the_exiting_dialog_is_already_closed_and_swallows_clicks() { + use crate::app::dialog::Message as DialogMessage; + use crate::app::update::dialog::on_dialog; + use unlucky_presenter::dialogs::{DialogKind, DialogSource}; + + let mut app = idle_app(); + let _ = app.state.dialogs.push( + DialogSource::UserAction, + "删除世界", + "不可撤销", + DialogKind::Confirm { + primary: "删除".to_string(), + secondary: "取消".to_string(), + }, + ); + app.view_dirty = true; + app.refresh_view(); + // 一条消息让 `sync_motion` 看见「队列里有框」⇒ 起跑进场(与真机的顺序一致) + let _ = app.update(Message::Frame); + assert!(motion::needs_frames(), "开框必须在同一拍起跑进场"); + run_until_idle(); // 进场跑完(`Open` 稳态) + assert!(app.dialog_model.is_some(), "前提:框已经画出来了"); + + // 用户点「取消」:逻辑当拍关闭,画面开始退场 + let _ = on_dialog(&mut app, DialogMessage::Secondary); + assert!( + app.state.dialogs.current().is_none(), + "退场窗口内逻辑上必须**已经**关掉(任何状态读取者都该看到没有框)" + ); + assert!(motion::modal_closing(), "画面还在退场"); + assert!( + app.dialog_model.is_some(), + "退场窗口内仍画得到旧框(否则就是一次硬跳)" + ); + assert!(motion::needs_frames(), "退场要帧"); + + // 150ms 内再点一次原来的主按钮位置:必须被闸门吞掉 + let refreshes = app.refreshes; + let _ = on_dialog(&mut app, DialogMessage::Primary); + assert_eq!( + app.refreshes, refreshes, + "退场窗口内的点击必须被吞掉(连一次重画都不该发生)" + ); + assert!( + app.state.dialogs.current().is_none(), + "幽灵点击不许复活那个框" + ); + assert!(app.dialog_model.is_some(), "也不许把它重新打开"); + + // 退场走完:下一拍的 `sync_motion` 把它丢掉,帧泵停 + run_until_idle(); + let _ = app.update(Message::Frame); + assert!(app.dialog_model.is_none(), "退场结束后不许留残影"); + assert!(!motion::needs_frames(), "退场结束后帧泵必须停"); + } } diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index b6858ed..550758b 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -142,9 +142,11 @@ impl App { APP_TITLE, background, // M101 r2:设置页的只读状态(外观运行期状态 + 各组自己的状态)一次递进去。 - pages::page_with_settings( + pages::page_with_settings_transition( &self.ui, &self.view, + // M256:换页过渡期的旧页快照(`App::sync_motion` 在动画结束当拍把它丢掉)。 + self.previous_view.as_ref(), pages::settings::States { appearance: Some(&self.appearance), groups: &self.settings, diff --git a/crates/app-iced/src/components/motion/mod.rs b/crates/app-iced/src/components/motion/mod.rs index 5919e1c..3dbb7f7 100644 --- a/crates/app-iced/src/components/motion/mod.rs +++ b/crates/app-iced/src/components/motion/mod.rs @@ -1,65 +1,69 @@ -//! **动效帧泵**(M192):design 的 motion 令牌 → iced 的**有界**帧订阅。 +//! **动效帧泵**(M192 建,M256 接上页面方向与模态):design 的 motion 令牌 → iced 的**有界**帧订阅。 //! //! ## 这个模块解决什么 //! //! `crates/design/src/motion/` 里令牌一直齐着(16 档时长 / 可采样缓动 / 二维弹簧表 / -//! `MotionScale::NONE` 降级),但适配层从没消费过 ⇒ 所有状态变化都是硬切。本模块是那条缺失的 -//! 中间层:**谁能动**([`Slot`])、**动多久**([`spec`] 的令牌表)、**什么时候该订阅帧** -//! ([`needs_frames`])。 +//! `MotionScale::NONE` 降级),但适配层长期只有一个消费点。本模块是那条中间层: +//! **谁在动**(页面轨道 / 模态轨道)、**动多久朝哪动**([`pump`] 与 `transition` 的表)、 +//! **什么时候该订阅帧**([`needs_frames`])。 //! -//! ## 有界泵的契约(四条,都有守卫) +//! ## 有界泵的契约(五条,都有守卫) //! //! | 契约 | 为什么 | 守在哪 | //! |---|---|---| -//! | 只在**有轨道**时订阅帧 | `window::frames()` 一订阅就自持重绘循环(实测几百帧/秒) | `pump::tests::a_finished_animation_stops_asking_for_frames` | -//! | 轨道到期**当场删除**,不靠「下次状态变化」 | 删晚了就是空闲烧帧 | 同上(把 `advance` 的清理删掉就红) | -//! | **时间轴推进**不密于 [`FRAME_MS`](16 ms ≈ 62.5/s),且单次最多吃 [`pump::MAX_STEP_MS`] | 帧到得再快也只该改这么多次布局;空闲醒来时 stale 的帧基准不许把动画一帧杀完 | `pump::tests::frames_are_capped_at_sixty_per_second`、`pump::tests::a_stale_baseline_cannot_kill_the_track_in_one_sample` | -//! | 起跑**必须发生在 `update` 侧**([`observe`]),`view` 侧只采样([`sample`]) | iced 先算订阅表(`update()` 内部)后建视图 ⇒ 在 `view` 里插轨道当拍订阅不上,动画会退化成「一帧偏移 + 闪跳」 | `app::subscription::tests::a_state_change_opens_the_frame_subscription_in_the_same_tick` | -//! -//! ⚠️ **16 ms 只卡「采样与布局推进」,不卡整窗重绘。** 整窗重绘**跟随渲染器**: -//! 软件渲染实测 ≈**970 帧/秒**(一次 300 ms 的换页 ≈ **290 次整窗重绘** ≈ **0.30 核·秒**), -//! GPU 档被 AutoVsync 限到 40~60 帧/秒。重绘次数是「订阅开着多久」的函数,与闸门无关。 -//! -//! 接法是**加进**既有那条链,不是另起一条:`App::needs_frames()` 多一个「有轨道」的分支, -//! 订阅表仍然是**一处** `if`(`app/subscription.rs`),所以与 `watch_until` / Busy 续订/ 心跳 -//! 天然不会双订阅——它们共享同一个布尔。 +//! | 只在**有轨道**时订阅帧 | `window::frames()` 一订阅就自持重绘循环(实测几百帧/秒) | `pump/tests.rs`、`app/subscription.rs` | +//! | 轨道到期**当场删除**,不靠「下次状态变化」 | 删晚了就是空闲烧帧 | `pump/tests.rs` | +//! | **模态完全展开后不占帧** | 对话框开着可能几分钟,动画只有开/关那两段 | `pump/tests.rs::an_open_modal_stops_requesting_frames` | +//! | **时间轴推进**不密于 [`pump::FRAME_MS`](16 ms ≈ 62.5/s),且单次最多吃 [`pump::MAX_STEP_MS`] | 帧到得再快也只该改这么多次布局;空闲醒来时 stale 的帧基准不许把动画一帧杀完 | `pump/tests.rs` | +//! | 起跑**必须发生在 `update` 侧**([`observe`] / [`observe_modal`]),`view` 侧只采样 | iced 先算订阅表(`update()` 内部)后建视图 ⇒ 在 `view` 里插轨道当拍订阅不上 | `app::subscription::tests` | //! //! ## `MotionScale::NONE` 是**真归零** //! -//! 关掉动效 ≠ 跑一遍 0 时长动画:`Pump::play` 在 `is_off()` 时**不插入轨道**,`progress` 于是 -//! 直接返回 `1.0`(到位),`needs_frames()` 恒为假 ⇒ **一帧都不订阅**。守卫见 -//! `pump::tests::none_scale_never_starts_a_track`。 +//! 关掉动效 ≠ 跑一遍 0 时长动画:`Pump::play_page` 在 `is_off()` 时**不插入轨道**, +//! [`frame`] 于是返回 [`PageFrame::STILL`](位移**严格** `(0,0)`、不画旧页),[`modal`] 要么 +//! 不画要么 `progress = 1.0`(缩放严格 1.0),`needs_frames()` 恒为假 ⇒ **一帧都不订阅**。 +//! +//! ## 系统「减少动效」(M256 接的真入口) +//! +//! [`configure`] 收的是一个**已经算好**的 `MotionScale`(来源在 `app/update/boot_env.rs`: +//! 截图 / CPU / env)。系统偏好是**上限**,在这里与它合成: +//! +//! | 系统偏好 | 结果 | 为什么 | +//! |---|---|---| +//! | [`crate::platform::motion::ReduceMotion::Reduced`] | `NONE` | 用户明确要求减少动效,优先于默认档 | +//! | `AnimationsOn` | 传入的档 | —— | +//! | [`crate::platform::motion::ReduceMotion::Unknown`] | 传入的档 | **不许**因为探针失败就静默关掉动效(那会变成「某些机器上动效神秘消失」) | +//! +//! 探针本身**只在启动时读一次并缓存**(`OnceLock`),不在帧路径上。 //! //! ## 为什么是 thread-local 而不是 `App` 的字段 //! -//! 它是**时间轴**(`(槽 → 进度)` 一张表 + 一个时钟),不是应用状态:没有任何业务语义,也不需要 -//! 跨进程持久。iced 在事件循环线程上串行调 `update` / `view`(这与 `App::update(&mut self)` 能存在 -//! 是同一条纪律),所以一个线程局部就够,且**测试天然隔离**(`cargo test` 每个用例一个线程, -//! 不会出现「另一条线的动画把 `needs_frames()` 顶成真」的假红)。 +//! 它是**时间轴**(一张轨道表 + 一个时钟),不是应用状态:没有业务语义、不需要跨进程持久。 +//! iced 在事件循环线程上串行调 `update` / `view`,所以一个线程局部就够,且**测试天然隔离**。 +//! 仓内已有同样的先例(`pages/appearance/probe.rs`、`core/src/addon/pack/format/probe.rs`)。 //! -//! 仓内已有同样的先例(`pages/appearance/probe.rs`、`core/src/addon/pack/format/probe.rs` 都用了 -//! `#[allow(clippy::missing_const_for_thread_local)]`),所以这不是给本 crate 开的新口子。 +//! ## 位移走 `padding` //! -//! ## 谁在什么时候调 -//! -//! | 时机 | 调用 | 在哪 | -//! |---|---|---| -//! | 每条消息处理完(**订阅表求值之前**) | [`observe`]:内容标识变了就起跑 | `App::sync_motion`(`app/update/motion.rs`) | -//! | 每帧画视图 | [`sample`]:只读当前位移 | `pages::render` | -//! | 每帧 | [`advance`]:推进时间轴 | `App::on_frame` | -//! -//! 页面**拿不到**时长/缓动/弹簧参数——那是 [`spec`] 的事。 -//! -//! 位移走 `container` 的 `padding`:动画期间内容比容器少几像素高,末帧归零后与静息**逐像素相同**。 -//! 不做逐像素 α(D27:CPU 档零运行时半透明)也不做模糊(同上),所以两个渲染后端出图一致。 +//! 位移套在 `container` 的 `padding` 上:动画期间内容比容器少几像素,末帧归零后与静息**逐像素相同**。 +//! 不做逐像素 α(D27:CPU 档禁面板半透明)也不做模糊(同上),所以两个渲染后端出图一致。 +//! 模态的**缩放**走 `renderer.with_transformation`(两个后端都实现,见 [`scaled`])。 mod pump; +mod transition; +mod zoom; -// 本 mission 里这五个只被测试与文档引用(产品路径只经 `observe`/`sample`/`shifted`,拿不到时长/缓动), -// 所以显式 `allow` 一下——与 `interaction::Tracker` 那条同一条理由:它们是对外口径的一部分 -// (`spec` 是唯一一张规格表),不该因为「暂时没人调」就被删掉。 +// `Pump` / `MAX_STEP_MS` 只被测试与文档引用(产品路径拿不到时长与时间轴),显式放过: +// 它们是对外口径的一部分(`FRAME_MS` 被 `app/subscription.rs` 的守卫用)。 #[allow(unused_imports)] -pub use pump::{offset, spec, Slot, Spec, FRAME_MS}; +pub use pump::{Pump, FRAME_MS, MAX_STEP_MS}; +// 规格表与采样形状全部经这里导出:页面只拿得到「这一帧怎么画」,拿不到「用哪一档、多久」。 +// (`page_spec`/`Leg`/`*Spec` 只被测试与文档引用,产品路径只用 `Kind` 与采样形状。) +#[allow(unused_imports)] +pub use transition::{ + enter_offset, exit_offset, modal_scale, modal_spec, page_spec, Kind, Leg, ModalFrame, + ModalSpec, PageFrame, PageSpec, MODAL_SCALE_FROM, +}; +pub use zoom::scaled; use std::cell::{Cell, RefCell}; use std::time::Instant; @@ -69,10 +73,10 @@ use iced::{Element, Length, Padding}; use unlucky_design::motion::MotionScale; +use crate::platform::motion::ReduceMotion; + // clippy 1.99 的 `missing_const_for_thread_local` 对**已经是** `const { … }` 的初始化也报 -// (换过三种写法都照报,属于误报;复审 r1 已复核并确认,仓内 `pages/appearance/probe.rs` 等 -// 两处已有同样的 `allow`)。初始化确实已是常量,所以按项显式放过(上在宏外面不算,lint 是对 -// 展开后的 item 报的)。 +// (换过三种写法都照报,属于误报;仓内 `pages/appearance/probe.rs` 等两处已有同样的 `allow`)。 thread_local! { /// 进程内唯一时间轴(见模块文档「为什么是 thread-local」)。 #[allow(clippy::missing_const_for_thread_local)] @@ -83,10 +87,24 @@ thread_local! { } /// 定档:`on_boot` 调一次(截图档给 `NONE`,CPU 档给 `HALF`,见 `app/update/boot_env.rs`)。 +/// +/// **系统「减少动效」在这里合成**(见模块文档的表):`Reduced` ⇒ `NONE`;`Unknown` ⇒ 不动。 pub fn configure(scale: MotionScale, pin: Option) { + let scale = effective_scale(scale, ReduceMotion::probe(), pin.is_some()); PUMP.with(|pump| pump.borrow_mut().configure(scale, pin)); } +/// 档位与系统偏好的**合成本身**(纯函数,可断言)。 +/// +/// `pinned` = 取证相位钉住(`ULL_MOTION_AT`):多帧序列图要在同一台机器上重复拍到同一相位, +/// 所以钉住时**不**被系统偏好降级(否则取证机一旦开了"减少动效"就一张图都拍不出来)。 +pub fn effective_scale(scale: MotionScale, reduce: ReduceMotion, pinned: bool) -> MotionScale { + match reduce { + ReduceMotion::Reduced if !pinned => MotionScale::NONE, + _ => scale, + } +} + /// 当前档位。 pub fn scale() -> MotionScale { PUMP.with(|pump| pump.borrow().scale()) @@ -105,14 +123,8 @@ pub fn needs_frames() -> bool { /// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。 /// /// `screen` 是 `pages::Screen` 的稳定键(`None` = 这一屏不归 pages 画:账户页 / AI 独立页), -/// `detail` 是详情页的实例 id。 -/// -/// ## 为什么必须在 update 侧(M192 r1 复审 P1-1) -/// -/// iced 0.14 的顺序是「消息 → `update()`(订阅表在**这里**求值)→ `build_user_interfaces` -/// (这时才调 `view`)」(iced_winit-0.14.1 `src/lib.rs:1222-1240`)。轨道若在 `view` 里插, -/// 同一拍的订阅表看不见它 ⇒ `frames()` 要等下一条消息(最快鼠标 / 最慢 1 Hz 心跳)才打开, -/// 而那一帧的 dt 是整段空闲时长 ⇒ 动画一帧走完,用户只看到一次偏移闪跳。 +/// `detail` 是详情页的实例 id。方向与深度由 [`pump::Pump::observe`] 从这几个标识里判: +/// **同级换页走水平、进/出深层走纵向,旧页反向退场**。 /// /// 起跑时**顺带把帧基准重置到「现在」**:空闲期没有帧,`LAST_FRAME` 是几分钟前的值, /// 不重置的话第一帧仍会按整段空闲时长结算(第二道兜底是 [`pump::MAX_STEP_MS`])。 @@ -123,9 +135,52 @@ pub fn observe(screen: Option, detail: Option<&str>) { } } -/// 当前入场位移(**只采样、无副作用**):`pages::render` 每帧调一次。 -pub fn sample() -> (f32, f32) { - PUMP.with(|pump| pump.borrow().offsets()) +/// 当前这一帧的页面位移(**只采样、无副作用**):`pages::render` 每帧调一次。 +pub fn frame() -> PageFrame { + PUMP.with(|pump| pump.borrow().page_frame()) +} + +/// 弹窗的**逻辑**开关(每帧 update 侧同步一次):`true` = 队列里有框。 +/// +/// 返回 `true` = 起跑了进场轨道(调用方要重置帧基准)。 +pub fn observe_modal(open: bool) -> bool { + let started = PUMP.with(|pump| pump.borrow_mut().observe_modal(open)); + if started { + LAST_FRAME.with(|last| last.set(Some(Instant::now()))); + } + started +} + +/// 请求关掉弹窗。返回 `true` = **已经关了**(关着动效 / 本来就没开),调用方立刻 +/// `close_dialog()`;返回 `false` = 退场动画在跑,真正的关闭由 [`take_modal_closed`] 交回。 +pub fn begin_modal_close() -> bool { + let done = PUMP.with(|pump| pump.borrow_mut().begin_modal_close()); + if !done { + LAST_FRAME.with(|last| last.set(Some(Instant::now()))); + } + done +} + +/// 退场是否已走完(取走一次)。**在 `update` 侧调**:拿到 `true` 就把框真正关掉。 +pub fn take_modal_closed() -> bool { + PUMP.with(|pump| pump.borrow_mut().take_modal_closed()) +} + +/// 退场是否正在跑(`app/update/dialog.rs` 据此吞掉弹窗消息:框已经在缩小,再点一次「删除」 +/// 不该再生效)。 +pub fn modal_closing() -> bool { + PUMP.with(|pump| pump.borrow().modal_closing()) +} + +/// 这一帧模态该画成什么样(缩放进度 + 遮罩浓度都用它)。 +pub fn modal() -> ModalFrame { + PUMP.with(|pump| pump.borrow().modal_frame()) +} + +/// 当前页面轨道是哪一档(`None` = 静息;日志与断言用,产品路径只采样位移)。 +#[allow(dead_code)] +pub fn kind() -> Option { + PUMP.with(|pump| pump.borrow().kind()) } /// 推进时间轴(`App::on_frame` 每帧调一次)。 @@ -142,7 +197,7 @@ pub fn advance() { }); } -/// 把入场位移套到树上。 +/// 把位移套到树上。 /// /// **总是**套这一层(位移为 `(0,0)` 时只是 `padding = 0`):它是树的**根**,而 iced 的 /// `Tree::diff` 按 widget tag 判等——根 tag 一换就重建整棵子树。若只在动画期间才套, diff --git a/crates/app-iced/src/components/motion/pump.rs b/crates/app-iced/src/components/motion/pump.rs index 749efee..d977bd6 100644 --- a/crates/app-iced/src/components/motion/pump.rs +++ b/crates/app-iced/src/components/motion/pump.rs @@ -1,26 +1,23 @@ -//! 帧泵的**纯逻辑**:槽 / 规格表 / 时间轴 / 进度采样。 +//! 帧泵的**纯逻辑**:时间轴 / 页面切换轨道 / 模态轨道 / 采样。 //! //! 这一层不认识 iced、不认识线程、不读环境变量——那些都在 [`super`]。分开的理由很实际: //! 「帧间隔上限」「结束后必须清空」这两条守卫是**算术性质**,用本地 `Pump` 直接摆时间轴就能判死, //! 不必起窗口、不必等真实时间(那两条最容易写成「看起来在跑」的假绿)。 //! -//! ## 三个概念 +//! ## 两条轨道(M256):页面的两腿 + 模态的进出,各走各的时间轴(互不替代) //! -//! | 名字 | 是什么 | 谁定 | +//! | 轨道 | 触发 | 结束 | //! |---|---|---| -//! | [`Slot`] | 一个**同屏互斥**的动画位(首屏 / 换页 / 列表↔详情) | 本文件 | -//! | [`Spec`] | 该位的时长档 + 缓动档,**只能**是 design 令牌 | [`spec`](全项目唯一一张表) | -//! | [`Pump`] | 一条时间轴 + 每槽最多一条轨道 | 本文件 | +//! | 页面 | 内容标识变了([`Pump::observe`]) | 两腿里长的那条走完 | +//! | 模态 | 弹窗开 / 关([`Pump::observe_modal`] / [`Pump::begin_modal_close`]) | 进场转 [`Modal::Open`](**不再要帧**)、退场转 [`Modal::Hidden`] | //! -//! ## 为什么规格表在代码里而不是页面里 -//! -//! D31:弹簧/时长令牌是**表**,页面只许按表取、不许自调参数。所以页面拿不到「给我 250ms」这种 -//! 口子——它只能说「我要换页了」([`Pump::enter`]),用哪一档由 [`spec`] 决定。改观感只改这张表。 +//! D31:时长/缓动是**表**([`super::transition`]),页面只许按表取——它只能说「我要换页了」。 -use std::collections::BTreeMap; +use unlucky_design::motion::{DurationToken, MotionScale}; -use unlucky_design::motion::{DurationToken, Easing, MotionScale}; -use unlucky_design::spacing::Space; +use super::transition::{ + enter_offset, exit_offset, modal_spec, page_spec, Kind, ModalFrame, PageFrame, PageSpec, +}; /// 逐帧**推进时间轴**的最短间隔(16 ms ≈ 60 次/秒)。 /// @@ -30,10 +27,6 @@ use unlucky_design::spacing::Space; /// |---|---|---| /// | 时间轴推进 / 位移采样(本常量) | ≤62.5 次/秒 | 这里 | /// | **整窗重绘** | **跟随渲染器**:软件渲染实测 ≈**970 帧/秒**(一次 300 ms 的换页 ≈**290 次整窗重绘** ≈ **0.30 核·秒**);GPU 档被 AutoVsync 限到 40~60 | `window::frames()` 一订阅,iced_winit 每条消息后都 `request_redraw()`(iced_winit-0.14.1 `src/lib.rs:1222-1240`) | -/// -/// 所以「动画期间最多 60fps」只对**采样**成立;重绘次数是订阅时长的函数,与 16 ms 无关。 -/// 为什么仍然要 16 ms:每帧重绘已经是既成事实(忙碌态本来就这样),16 ms 卡的是**布局重排** -/// 与 `pages::render` 的重建成本;两者的量级差一个数量级以上(见报告的性能记账)。 pub const FRAME_MS: f64 = 16.0; /// 单次推进的**最大步进**(4 帧 = 64 ms)。 @@ -44,92 +37,31 @@ pub const FRAME_MS: f64 = 16.0; /// **任何一次采样最多只吃掉 4 帧的时长**,掉帧/被挂起时宁可把动画放慢,也不许把它一次性抹掉。 pub const MAX_STEP_MS: f64 = FRAME_MS * 4.0; -/// 一个动效槽。 -/// -/// 槽是**同屏互斥**的:三个槽都是「页主体入场」的不同触发点,同时跑会让位移叠加 -/// (首屏 16 + 换页 12 = 28px)。所以 [`Pump::play`] 起跑新槽时丢掉旧的——语义上也对: -/// 新的一次内容变化取代上一次。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum Slot { - /// 首屏进入:进程内只在第一帧跑一次。 - Boot, - /// 换页:路由换了(导航轨点选、返回、启动直达)。 - Screen, - /// 列表 ↔ 详情:同一页里的子视图切换(实例页点一行)。 - Detail, -} - -impl Slot { - /// 全部槽(遍历顺序稳定,写日志/断言用)。 - pub const ALL: [Slot; 3] = [Slot::Boot, Slot::Screen, Slot::Detail]; - - /// 稳定名字(日志与测试失败信息用;只有测试在调,故按 `cfg(test)` 编译)。 - #[cfg(test)] - pub const fn name(self) -> &'static str { - match self { - Slot::Boot => "boot", - Slot::Screen => "screen", - Slot::Detail => "detail", - } - } -} - -/// 一条轨道的规格:**时长档 × 缓动档**,两个都是 design 令牌。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Spec { - /// 时长档(毫秒值经 [`MotionScale`] 缩放后生效)。 - pub duration: DurationToken, - /// 缓动曲线。 - pub easing: Easing, -} - -/// 槽 → 规格(**唯一**一张表)。 -/// -/// 三档时长取自令牌注释里对应场景的那一档(`Medium2` = 导航/tab 切换、`Medium3` = 卡片展开/ -/// 列表重排、`Medium4` = 面板开合),曲线**统一用 `Standard`**: -/// -/// - 整块页面的位移不是「元素从屏外飞入」,而是「屏内这块内容换了」——M3 把 `Standard` 定义为 -/// 「起止都在屏内的元素」,`*Decelerate` 是给「已经在高速运动、要迅速落定」的共享元素用的; -/// - 本地实测(`dev` 机器,见报告):`EmphasizedDecelerate` 把 **75% 的位移压在 51 ms 内**走完 -/// (300 ms 档),12px 的整页平移会看起来像「跳一格」;`Standard` 同样时长下是 105 ms,落点更稳。 -pub const fn spec(slot: Slot) -> Spec { - match slot { - Slot::Boot => Spec { - duration: DurationToken::Medium4, - easing: Easing::Standard, - }, - Slot::Screen => Spec { - duration: DurationToken::Medium2, - easing: Easing::Standard, - }, - Slot::Detail => Spec { - duration: DurationToken::Medium3, - easing: Easing::Standard, - }, - } -} - -/// 槽 → 入场位移(像素;正值 = 从右下方向归位,末态恒为 `(0, 0)`)。 -/// -/// 幅度取**间距令牌**(4px 栅格)而不是新造一组数字:位移是布局量,和间距用同一把尺子, -/// 将来改栅格不会出现「间距 12、位移 14」这种两套刻度。 -pub fn offset(slot: Slot) -> (f32, f32) { - match slot { - Slot::Boot => (0.0, Space::X4.dp()), - Slot::Screen => (0.0, Space::X3.dp()), - Slot::Detail => (Space::X2.dp(), 0.0), - } -} - -/// 一条正在跑的轨道。 +/// 一条正在跑的页面轨道。 #[derive(Debug, Clone, Copy)] -struct Track { - /// 起跑时刻(动画时间轴的坐标,毫秒)。 +struct PageTrack { + kind: Kind, + spec: PageSpec, start_ms: f64, - spec: Spec, } -/// 帧泵:一条时间轴 + 每槽一条轨道。 +/// 模态的状态机。 +/// +/// 为什么要状态机而不是一条进度:**弹窗开着的时候不能订阅帧**。`Open` 是一个"没有轨道"的 +/// 稳态([`Pump::is_animating`] 为假),只有起手与收尾那两段是动画。 +#[derive(Debug, Clone, Copy, PartialEq)] +enum Modal { + /// 没有弹窗。 + Hidden, + /// 进场中。 + Opening { start_ms: f64 }, + /// 完全展开(稳态,不占帧)。 + Open, + /// 退场中。 + Closing { start_ms: f64 }, +} + +/// 帧泵:一条时间轴 + 最多一条页面轨道 + 一个模态状态机。 /// /// **默认是关的**(`MotionScale::NONE`):产品启动时由 `App::on_boot` 按档位打开,而 /// `pages_demo` 这类截图 harness 从不打开——D30 的「同一份代码出同一张图」因此不受动效影响。 @@ -140,7 +72,10 @@ pub struct Pump { pin: Option, now_ms: f64, pending_ms: f64, - tracks: BTreeMap, + page: Option, + modal: Modal, + /// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。 + close_pending: bool, screen: Option, detail: Option, painted: bool, @@ -155,7 +90,9 @@ impl Pump { pin: None, now_ms: 0.0, pending_ms: 0.0, - tracks: BTreeMap::new(), + page: None, + modal: Modal::Hidden, + close_pending: false, screen: None, detail: None, painted: false, @@ -175,9 +112,11 @@ impl Pump { self.screen = None; self.detail = None; self.pending_ms = 0.0; + self.modal = Modal::Hidden; + self.close_pending = false; if scale.is_off() { // 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。 - self.tracks.clear(); + self.page = None; } } @@ -199,43 +138,16 @@ impl Pump { /// 现在有没有动画在跑(= 要不要订阅帧)。 pub fn is_animating(&self) -> bool { - !self.tracks.is_empty() + self.page.is_some() || matches!(self.modal, Modal::Opening { .. } | Modal::Closing { .. }) } - /// 起跑一个槽(关着动效时什么都不做——**不是**跑一遍 0 时长动画)。返回 `true` = 真的起跑了。 - /// - /// 三个槽**互斥**:它们都是「页主体入场」的不同触发点,同时跑会让位移叠加(首屏 + 换页 = - /// 28px)。所以起跑新槽就丢掉旧的——语义上也对:新的一次内容变化取代上一次。 - pub fn play(&mut self, slot: Slot) -> bool { - self.tracks.clear(); - if self.scale.is_off() { - return false; + /// 轨道起点的口径:钉住相位时恒为 0(进度只取决于钉住值,与"谁在第几帧起跑"无关)。 + fn start_now(&self) -> f64 { + if self.pin.is_some() { + 0.0 + } else { + self.now_ms } - // 钉住相位时轨道的起点固定在 0:进度于是恒等于「钉住的毫秒 / 时长」, - // 与「谁在第几帧起跑」无关——这正是多帧序列图要的可复现性。 - let start_ms = if self.pin.is_some() { 0.0 } else { self.now_ms }; - self.tracks.insert( - slot, - Track { - start_ms, - spec: spec(slot), - }, - ); - true - } - - /// 采样:`0.0` = 刚起跑,`1.0` = 到位(**没有轨道就是到位**)。 - pub fn progress(&self, slot: Slot) -> f32 { - let Some(track) = self.tracks.get(&slot) else { - return 1.0; - }; - let duration = f64::from(self.scale.ms(track.spec.duration)); - if duration <= 0.0 { - return 1.0; - } - let elapsed = self.elapsed_ms() - track.start_ms; - let t = (elapsed / duration).clamp(0.0, 1.0) as f32; - track.spec.easing.sample(t) } /// 当前动画时刻(钉住相位时恒为钉住值)。 @@ -243,71 +155,193 @@ impl Pump { self.pin.unwrap_or(self.now_ms) } - /// 页主体的内容**标识**变了就起跑对应槽;**必须在 update 侧调**(见 `App::sync_motion`)。 + /// 把某一档时长换算成实际毫秒(`MotionScale` 是唯一缩放口)。 + fn leg_ms(&self, duration: DurationToken) -> f64 { + f64::from(self.scale.ms(duration)) + } + + // ------------------------------------------------------------ 页面切换 + + /// 起跑一次页面切换(关着动效时什么都不做——**不是**跑一遍 0 时长动画)。 /// - /// ## 为什么不能放在 `view` 里(M192 r1 复审 P1-1,这是本轮最重要的一行注释) + /// 新轨道**取代**上一条:同一时刻只有一次页面切换(否则位移会叠加)。 + pub fn play_page(&mut self, kind: Kind) -> bool { + self.page = None; + if self.scale.is_off() { + return false; + } + let spec = page_spec(kind); + self.page = Some(PageTrack { + kind, + spec, + start_ms: self.start_now(), + }); + true + } + + /// 当前页面轨道(测试与日志用;产品路径只看采样出来的位移)。 + #[allow(dead_code)] + pub fn kind(&self) -> Option { + self.page.map(|track| track.kind) + } + + /// 页主体的这一帧采样(**只采样,无副作用**)。 /// - /// iced 0.14 的顺序是「消息 → `update()`(**`program.subscription()` 在这里求值**)→ - /// `build_user_interfaces`(这时才调 `view`)」(iced_winit-0.14.1 `src/lib.rs:1222-1240`, - /// 订阅表在 `update()` 内部的 `lib.rs:1338` 求值)。所以在 `view` 里插轨道,同一拍的订阅表 - /// **看不见**它,`frames()` 要等下一条消息(最快鼠标、最慢 1 Hz 心跳)才打开——那一段里 - /// 页面已经带着位移画过一帧,然后就没了:用户看到的是一次偏移闪跳。 + /// 两条腿各走各的时长:进场读 `enter.duration`,退场读 `exit.duration`(更短)。 + /// `exiting` 在退场腿走完的那一帧就变假 ⇒ 调用方不再画旧页(不长留一份幽灵树)。 + pub fn page_frame(&self) -> PageFrame { + let Some(track) = &self.page else { + return PageFrame::STILL; + }; + let elapsed = self.elapsed_ms() - track.start_ms; + let enter_t = leg_fraction(elapsed, self.leg_ms(track.spec.enter.duration)); + let exit_t = leg_fraction(elapsed, self.leg_ms(track.spec.exit.duration)); + PageFrame { + enter: enter_offset( + track.spec.enter_from, + track.spec.enter.easing.sample(enter_t), + ), + exit: exit_offset(track.spec.exit_to, track.spec.exit.easing.sample(exit_t)), + exiting: exit_t < 1.0, + } + } + + /// 内容**标识**变了就起跑对应的一档;**必须在 update 侧调**(见 `App::sync_motion`)。 /// /// 三条判据(顺序即优先级): - /// 1. 第一次看到**归 pages 画的屏** → [`Slot::Boot`]; - /// 2. 屏变了 → [`Slot::Screen`]; - /// 3. 屏没变但详情变了 → [`Slot::Detail`]。 + /// 1. 第一次看到**归 pages 画的屏** → [`Kind::Boot`]; + /// 2. 屏变了 → [`Kind::Peer`],方向由**屏在导航表里的先后**决定(更靠后的屏 = 向右推进); + /// 3. 屏没变但详情变了 → 列表→详情 = [`Kind::Push`](深一层)、详情→列表 = [`Kind::Pop`], + /// 两个不同的详情之间横跳按同级处理。 /// - /// `screen == None`(账户页 / AI 独立页:它们不走 `pages::render`)**只记标识、不起跑**—— - /// 否则会白跑 300 ms 的帧却没人采样它;而「从这些页回到某一屏」因为标识从 `None` 变回 - /// `Some`,仍然会跑换页那一档。 + /// `screen == None`(账户页 / AI 独立页:它们不走 `pages::render`)**只记标识、不起跑**。 /// /// 返回 `true` = 真的起跑了新轨道(调用方要重置帧基准,见 [`MAX_STEP_MS`])。 pub fn observe(&mut self, screen: Option, detail: Option<&str>) -> bool { - let mut started = false; - if !self.painted { + let kind = if !self.painted { self.painted = true; - if screen.is_some() { - started = self.play(Slot::Boot); - } + screen.map(|_| Kind::Boot) } else if self.screen != screen { - if screen.is_some() { - started = self.play(Slot::Screen); - } - } else if self.detail.as_deref() != detail && screen.is_some() { - started = self.play(Slot::Detail); - } + screen.map(|new| match self.screen { + Some(old) => Kind::Peer { forward: new > old }, + // 从「不归 pages 画」的屏回来:没有旧页可退,按首屏那一档的入场方向。 + None => Kind::Boot, + }) + } else if screen.is_some() && self.detail.as_deref() != detail { + Some(match (self.detail.is_some(), detail.is_some()) { + (false, true) => Kind::Push, + (true, false) => Kind::Pop, + // 两个不同的详情之间横跳:深度没变,按同级换内容处理。 + _ => Kind::Peer { forward: true }, + }) + } else { + None + }; + self.screen = screen; if self.detail.as_deref() != detail { self.detail = detail.map(str::to_owned); } - started + kind.is_some_and(|kind| self.play_page(kind)) } - /// 页主体的入场位移(**只采样,无副作用**):`pages::render` 每帧调一次。 + // ------------------------------------------------------------ 模态 + + /// 弹窗的**逻辑**开关(每帧 update 侧同步一次)。 /// - /// 三个槽的剩余量求和后**取整**:整像素位移不会让文字在动画期间发虚;末帧全部 `1.0` - /// ⇒ 恒为 `(0, 0)` ⇒ 与静息的布局逐像素相同。 - pub fn offsets(&self) -> (f32, f32) { - let mut dx = 0.0_f32; - let mut dy = 0.0_f32; - for slot in Slot::ALL { - let remaining = 1.0 - self.progress(slot); - let (ox, oy) = offset(slot); - dx += ox * remaining; - dy += oy * remaining; + /// - 开了 → 从 [`Modal::Hidden`] 起跑进场;已经在进/开着就不动。 + /// - 关了(不是走 [`Pump::begin_modal_close`] 的那条路,即框已经被别处直接关掉)→ + /// 立刻落 [`Modal::Hidden`](没有框可画,也就没有退场可言)。 + /// + /// 返回 `true` = 起跑了进场轨道。 + pub fn observe_modal(&mut self, open: bool) -> bool { + if open { + if matches!(self.modal, Modal::Hidden) { + if self.scale.is_off() { + self.modal = Modal::Open; + return false; + } + self.modal = Modal::Opening { + start_ms: self.start_now(), + }; + return true; + } + return false; } - (dx.round(), dy.round()) + if !matches!(self.modal, Modal::Closing { .. }) { + self.modal = Modal::Hidden; + self.close_pending = false; + } + false } + /// 请求关掉弹窗:有动效就起跑退场(返回 `false`,真正的关闭由 [`Pump::take_modal_closed`] + /// 交回调用方),关着动效就返回 `true`(**立刻关,不留 0 时长动画**)。 + pub fn begin_modal_close(&mut self) -> bool { + if self.scale.is_off() || matches!(self.modal, Modal::Hidden) { + self.modal = Modal::Hidden; + self.close_pending = false; + return true; + } + self.modal = Modal::Closing { + start_ms: self.start_now(), + }; + false + } + + /// 退场是否已经走完(取走一次,之后返回 `false`)。 + pub fn take_modal_closed(&mut self) -> bool { + std::mem::take(&mut self.close_pending) + } + + /// 退场是否正在跑(这段时间里的弹窗消息要被吞掉:框已经在缩小,再点一次「删除」不该再生效)。 + pub fn modal_closing(&self) -> bool { + matches!(self.modal, Modal::Closing { .. }) + } + + /// 这一帧模态该画成什么样。 + pub fn modal_frame(&self) -> ModalFrame { + match self.modal { + Modal::Hidden => ModalFrame::HIDDEN, + Modal::Open => ModalFrame { + visible: true, + progress: 1.0, + }, + Modal::Opening { start_ms } => { + let spec = modal_spec(); + let t = leg_fraction( + self.elapsed_ms() - start_ms, + self.leg_ms(spec.enter.duration), + ); + ModalFrame { + visible: true, + progress: spec.enter.easing.sample(t), + } + } + Modal::Closing { start_ms } => { + let spec = modal_spec(); + let t = leg_fraction( + self.elapsed_ms() - start_ms, + self.leg_ms(spec.exit.duration), + ); + ModalFrame { + visible: true, + // 退场是**反向**走同一条线:1 → 0(与进场共用 `modal_scale` 的映射)。 + progress: 1.0 - spec.exit.easing.sample(t), + } + } + } + } + + // ------------------------------------------------------------ 时间轴 + /// 推进时间轴。返回 `true` = 这一帧真的推进了(进度变了 ⇒ 要重画)。 /// /// 四步:①不满 [`FRAME_MS`] 就不动(采样上限)②**单次最多吃 [`MAX_STEP_MS`]**(掉帧 / /// 被挂起 / 空闲醒来的第一帧都不许一次性跨过时长)③到期的轨道**当场删除**(这是「空闲不烧帧」 - /// 的唯一出口:删掉最后一个轨道之后 [`is_animating`](Self::is_animating) 立刻为假,下一拍订阅表 - /// 里就不再有 `window::frames()`)④钉住相位时时间轴不前进(取证专用)。 + /// 的唯一出口)④钉住相位时时间轴不前进(取证专用)。 pub fn advance(&mut self, dt_ms: f64) -> bool { - if self.tracks.is_empty() { + if !self.is_animating() { self.pending_ms = 0.0; return false; } @@ -323,22 +357,40 @@ impl Pump { self.now_ms += step; } self.samples += 1; - - let now = self.elapsed_ms(); - let finished: Vec = self - .tracks - .iter() - .filter(|(_, track)| { - let duration = f64::from(self.scale.ms(track.spec.duration)); - duration <= 0.0 || now - track.start_ms >= duration - }) - .map(|(slot, _)| *slot) - .collect(); - for slot in finished { - self.tracks.remove(&slot); - } + self.expire(); true } + + /// 到期轨道当场清掉(③)。 + fn expire(&mut self) { + let now = self.elapsed_ms(); + if let Some(track) = self.page { + let enter = self.leg_ms(track.spec.enter.duration); + let exit = self.leg_ms(track.spec.exit.duration); + if now - track.start_ms >= enter.max(exit) { + self.page = None; + } + } + let spec = modal_spec(); + match self.modal { + Modal::Opening { start_ms } if now - start_ms >= self.leg_ms(spec.enter.duration) => { + self.modal = Modal::Open + } + Modal::Closing { start_ms } if now - start_ms >= self.leg_ms(spec.exit.duration) => { + self.modal = Modal::Hidden; + self.close_pending = true; + } + _ => {} + } + } +} + +/// `elapsed / duration` 夹到 `0..=1`(`duration <= 0` ⇒ 直接到位,不产生 NaN)。 +fn leg_fraction(elapsed: f64, duration: f64) -> f32 { + if duration <= 0.0 { + return 1.0; + } + (elapsed / duration).clamp(0.0, 1.0) as f32 } impl Default for Pump { diff --git a/crates/app-iced/src/components/motion/tests.rs b/crates/app-iced/src/components/motion/tests.rs deleted file mode 100644 index 13e09af..0000000 --- a/crates/app-iced/src/components/motion/tests.rs +++ /dev/null @@ -1,345 +0,0 @@ -//! 帧泵的守卫(M192)。全部用**本地** `Pump` 摆时间轴:不起窗口、不等真实时间,所以不会假绿。 -//! -//! 接线级的守卫(真消息 / 真订阅表)在 `app/subscription.rs` 的 `mod tests`:这一组只管算术。 - -use super::pump::{offset, spec, Pump, Slot, FRAME_MS, MAX_STEP_MS}; -use super::*; - -/// 标准档、没有钉相位的泵(出厂态是 `NONE`,见 [`Pump::new`])。 -fn standard() -> Pump { - let mut pump = Pump::new(); - pump.configure(MotionScale::STANDARD, None); - pump -} - -/// 按「每帧 16 ms」推进到全部轨道结束(上限 20 s,防死循环)。 -fn settle(pump: &mut Pump) -> u64 { - let mut frames = 0; - for _ in 0..1250 { - if !pump.is_animating() { - break; - } - if pump.advance(FRAME_MS) { - frames += 1; - } - } - frames -} - -// ------------------------------------------------------------------ 令牌表 - -/// 规格**全部**来自 design 的令牌表,且落在 M3 的常规区间(改表会让这条红)。 -#[test] -fn every_slot_spec_is_a_registered_token_pair() { - for slot in Slot::ALL { - let spec = spec(slot); - let ms = spec.duration.ms(); - assert!( - (250..=500).contains(&ms), - "{}: 时长 {}ms 不在换页类动效的合理区间(克制优先)", - slot.name(), - ms - ); - // 缓动必须是真的曲线(端点 = 0/1) - assert!(spec.easing.sample(0.0).abs() < 1.0e-6); - assert!((spec.easing.sample(1.0) - 1.0).abs() < 1.0e-6); - // 位移必须来自间距栅格(4 的倍数),且不为零 - let (dx, dy) = offset(slot); - assert!( - dx > 0.0 || dy > 0.0, - "{}: 位移为 0 就没有入场感", - slot.name() - ); - assert_eq!(dx % 4.0, 0.0); - assert_eq!(dy % 4.0, 0.0); - assert!(dx.max(dy) <= 24.0, "{}: 位移过大(克制优先)", slot.name()); - } - // 表本身钉住:谁改了这三档,报告与出图都要跟着改 - assert_eq!(spec(Slot::Screen).duration.ms(), 300); - assert_eq!(spec(Slot::Detail).duration.ms(), 350); - assert_eq!(spec(Slot::Boot).duration.ms(), 400); -} - -// ------------------------------------------------------------------ 有界泵 - -/// **空闲不烧帧**:动画跑完 ⇒ `is_animating()` 立刻为假,且不再推进。 -/// -/// 抓得住:把 `advance` 里的「到期即删除」删掉(轨道永远留着 ⇒ 帧订阅永不退订),或把 -/// `is_animating` 改成恒真。两条都让本用例红。 -#[test] -fn a_finished_animation_stops_asking_for_frames() { - let mut pump = standard(); - assert!(pump.play(Slot::Screen), "起跑必须成功"); - assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)"); - - settle(&mut pump); - assert!( - !pump.is_animating(), - "动画结束必须**当场**退订(占着轨道 = 空闲烧帧)" - ); - assert_eq!(pump.progress(Slot::Screen), 1.0, "结束即到位"); - assert_eq!(pump.offsets(), (0.0, 0.0), "结束即归位"); - - // 再推进也不该有任何采样(订阅表里已经没有帧了) - let before = pump.samples(); - for _ in 0..60 { - assert!(!pump.advance(FRAME_MS), "没有轨道时推进必须返回 false"); - } - assert_eq!(pump.samples(), before, "空闲态一次采样都不该发生"); -} - -/// **采样频率上限**:帧到得再快,时间轴也只按 16 ms 结算(≈62.5 次/秒)。 -/// -/// 注意这条**不管整窗重绘**:重绘跟随渲染器(见 [`FRAME_MS`] 文档)。 -/// 抓得住:把 `pending_ms` 那道闸门删掉(每来一帧就推进 ⇒ 软件渲染下几百次/秒的重排)。 -#[test] -fn frames_are_capped_at_sixty_per_second() { - let mut pump = standard(); - pump.play(Slot::Screen); - - // 200 帧、每帧 1 ms(模拟 1000 fps 的渲染器) - let mut advanced = 0; - for _ in 0..200 { - if pump.advance(1.0) { - advanced += 1; - } - } - assert!( - advanced <= 13, - "200 ms 内最多 13 次推进(200/16),实测 {advanced} 次" - ); - assert!( - advanced >= 11, - "也别少得离谱(掉帧会看起来卡),实测 {advanced}" - ); - assert_eq!(pump.samples(), advanced); - // 时间轴按真实时间走,不受「帧被合并」影响 - assert!( - (pump.now_ms() - 192.0).abs() <= 16.0, - "时间轴应约 192 ms(12 × 16),实测 {}", - pump.now_ms() - ); -} - -/// **stale 帧基准不许把轨道一帧杀完**(M192 r1 复审 P1-1 的算术面)。 -/// -/// 运行时真出现过:空闲几分钟后第一条消息起跑轨道,第一帧的 dt 是整段空闲时长 ⇒ 立即到期 ⇒ -/// 动画一帧走完,用户只看到一次偏移闪跳。两道兜底:起跑时重置帧基准(`motion::observe`)与 -/// 单次最大步进 [`MAX_STEP_MS`];这条守的是第二道。 -#[test] -fn a_stale_baseline_cannot_kill_the_track_in_one_sample() { - let mut pump = standard(); - pump.play(Slot::Screen); - - // 60 秒的「空闲间隔」当 dt - assert!(pump.advance(60_000.0)); - assert!( - pump.is_animating(), - "一次超长 dt 不许把轨道当场删掉(那就等于没有动画)" - ); - assert!( - pump.now_ms() <= MAX_STEP_MS + 1.0e-6, - "单次推进最多 {:?} ms,实测 {}", - MAX_STEP_MS, - pump.now_ms() - ); - assert!( - pump.progress(Slot::Screen) < 1.0, - "进度不许一步到位(那就是闪跳)" - ); - - // 正常帧间隔照样把动画跑完(没被抖动卡死) - settle(&mut pump); - assert!(!pump.is_animating()); -} - -/// 采样必须单调、且末尾回到 1.0(回弹类曲线不在本批,见 [`spec`] 的表)。 -#[test] -fn progress_is_monotone_and_lands_on_one() { - let mut pump = standard(); - pump.play(Slot::Detail); - let mut previous = 0.0; - for _ in 0..40 { - let value = pump.progress(Slot::Detail); - assert!(value >= previous, "进度倒退了:{previous} → {value}"); - previous = value; - if !pump.advance(FRAME_MS) { - break; - } - } - assert_eq!(previous, 1.0, "末帧必须到位"); -} - -// ------------------------------------------------------------------ NONE 降级 - -/// **`MotionScale::NONE` 是真归零**:不起轨道、不采样、位移恒为 0。 -/// -/// 抓得住:把 `play` 里的 `is_off()` 分支删掉(变成「跑一遍 0 时长动画」)——那样这个用例里 -/// `is_animating()` 会短暂为真、`samples` 会增长,帧订阅次数就不是 0 了。 -#[test] -fn none_scale_never_starts_a_track() { - let mut pump = Pump::new(); // 出厂即 NONE - assert!(pump.scale().is_off()); - - // 首屏 + 换页 + 换详情:三次状态变化都不许留下轨道 - assert!(!pump.observe(Some(1), None), "关掉动效时不许起跑"); - assert_eq!(pump.offsets(), (0.0, 0.0), "关掉动效必须直接落终态"); - assert!(!pump.is_animating()); - assert!(!pump.observe(Some(2), None)); - assert_eq!(pump.offsets(), (0.0, 0.0)); - assert!(!pump.observe(Some(2), Some("instance-7"))); - assert_eq!(pump.offsets(), (0.0, 0.0)); - assert!(!pump.is_animating()); - - for _ in 0..120 { - assert!(!pump.advance(FRAME_MS), "关掉动效后没有任何可推进的"); - } - assert_eq!(pump.samples(), 0, "NONE 档的帧订阅次数必须是 0"); -} - -/// 运行期把关掉动效(`configure(NONE)`)也要立刻落终态,而不是把在跑的动画「缩到 0 时长」。 -#[test] -fn switching_to_none_mid_flight_settles_immediately() { - let mut pump = standard(); - pump.play(Slot::Screen); - pump.advance(FRAME_MS); - assert!(pump.is_animating()); - - pump.configure(MotionScale::NONE, None); - assert!(!pump.is_animating(), "关掉动效必须当场清空轨道"); - assert_eq!(pump.progress(Slot::Screen), 1.0); - assert_eq!(pump.offsets(), (0.0, 0.0)); -} - -/// 半速档(CPU 渲染兜底)真的把时长减半:同样的时间轴推进,轨道更早结束。 -#[test] -fn half_scale_finishes_in_half_the_frames() { - let mut full = standard(); - full.play(Slot::Screen); - let full_frames = settle(&mut full); - - let mut half = Pump::new(); - half.configure(MotionScale::HALF, None); - half.play(Slot::Screen); - let half_frames = settle(&mut half); - - assert!(half_frames > 0 && full_frames > 0); - assert!( - half_frames * 2 <= full_frames + 2, - "半速档应约用一半帧数:full={full_frames} half={half_frames}" - ); -} - -// ------------------------------------------------------------------ 触发判据 - -/// 三个槽的触发优先级:首屏 Boot → 换屏 Screen → 同屏换详情 Detail。 -#[test] -fn identity_changes_pick_the_right_slot() { - let mut pump = standard(); - - // 首帧:Boot(位移是纵向 16) - assert!(pump.observe(Some(1), None), "首帧要跑 Boot 槽"); - let (dx, dy) = pump.offsets(); - assert_eq!(dx, 0.0); - assert_eq!(dy, offset(Slot::Boot).1, "首屏应有一个向下的入场位移"); - assert_eq!( - pump.progress(Slot::Screen), - 1.0, - "首帧不该顺带起跑 Screen 槽" - ); - settle(&mut pump); - assert!(!pump.observe(Some(1), None), "标识没变 ⇒ 不起跑"); - assert_eq!(pump.offsets(), (0.0, 0.0)); - - // 换屏:Screen(纵向) - assert!(pump.observe(Some(2), None)); - let (dx, dy) = pump.offsets(); - assert_eq!(dx, 0.0); - assert_eq!(dy, offset(Slot::Screen).1); - assert!(pump.progress(Slot::Screen) < 1.0, "换屏要跑 Screen 槽"); - settle(&mut pump); - - // 同屏换详情:Detail(横向) - assert!(pump.observe(Some(2), Some("instance-7"))); - let (dx, dy) = pump.offsets(); - assert_eq!(dx, offset(Slot::Detail).0, "列表↔详情是横向入场"); - assert_eq!(dy, 0.0); - assert!(pump.progress(Slot::Detail) < 1.0); - settle(&mut pump); - assert!(!pump.observe(Some(2), Some("instance-7"))); - assert_eq!(pump.offsets(), (0.0, 0.0)); -} - -/// 不归 pages 画的屏(账户页 / AI 独立页,`screen == None`)**不起跑**;回到某一屏照样跑。 -#[test] -fn screens_rendered_elsewhere_never_start_a_track() { - let mut pump = standard(); - assert!(pump.observe(Some(1), None), "首屏开机跑 Boot"); - settle(&mut pump); - - assert!(!pump.observe(None, None), "没有 pages 树可动 ⇒ 不许白开帧"); - assert!(!pump.is_animating()); - assert_eq!(pump.offsets(), (0.0, 0.0)); - - assert!( - pump.observe(Some(1), None), - "从账户页回到某一屏仍然是换页,要跑" - ); - assert_eq!(pump.offsets().1, offset(Slot::Screen).1); -} - -/// 换页会**取代**首屏那一档:位移不叠加(12px,不是 16+12)。 -#[test] -fn a_new_transition_supersedes_the_previous_one() { - let mut pump = standard(); - assert_eq!( - pump.offsets().1, - 0.0, - "还没 observe 时没有位移(采样是纯函数)" - ); - pump.observe(Some(1), None); - assert_eq!(pump.offsets().1, offset(Slot::Boot).1); - - // 首屏还没跑完就换页:位移必须是 Screen 那一档,而不是两档相加 - pump.observe(Some(2), None); - assert_eq!( - pump.offsets().1, - offset(Slot::Screen).1, - "新的一次内容变化应取代上一次(叠加会让位移变成两档之和)" - ); -} - -/// 同一屏同一详情重复绘制**永远不会**重新起跑(否则每帧都在重启动画 = 永远停不下来)。 -#[test] -fn repeated_paints_never_restart_the_animation() { - let mut pump = standard(); - pump.observe(Some(3), Some("instance-9")); - let frames = settle(&mut pump); - assert!(frames > 0); - - for _ in 0..30 { - assert!(!pump.observe(Some(3), Some("instance-9"))); - assert_eq!(pump.offsets(), (0.0, 0.0)); - } - assert!(!pump.is_animating(), "重复绘制不该起新轨道"); -} - -// ------------------------------------------------------------------ 取证相位 - -/// `ULL_MOTION_AT` 的钉住相位:时间轴不前进,进度恒定(多帧序列图靠它可复现)。 -#[test] -fn pinned_phase_freezes_the_clock() { - let mut pump = Pump::new(); - pump.configure(MotionScale::STANDARD, Some(150.0)); - pump.play(Slot::Screen); // 300 ms 档 ⇒ 钉在 150 = 一半 - - for _ in 0..10 { - pump.advance(FRAME_MS); - } - let progress = pump.progress(Slot::Screen); - assert!( - (progress - spec(Slot::Screen).easing.sample(0.5)).abs() < 1.0e-6, - "钉在 150/300 的进度应等于缓动曲线的中点,实测 {progress}" - ); - assert!(pump.is_animating(), "钉住的相位不会自然结束(取证专用)"); -} diff --git a/crates/app-iced/src/components/motion/tests/mod.rs b/crates/app-iced/src/components/motion/tests/mod.rs new file mode 100644 index 0000000..533aacc --- /dev/null +++ b/crates/app-iced/src/components/motion/tests/mod.rs @@ -0,0 +1,345 @@ +//! 帧泵与过渡表的守卫(M192 建,M256 扩)。全部用**本地** `Pump` 摆时间轴:不起窗口、 +//! 不等真实时间,所以不会假绿。 +//! +//! 接线级的守卫(真消息 / 真订阅表)在 `app/subscription.rs`:这一组只管算术与表。 +//! +//! 每条用例都写清「抓得住什么 / 抓不住什么」(T27 之后的纪律)。判据是「泵帧数 + 端点 + +//! 单调性」,不是像素比对:像素只能证明"某一张图有没有偏移",证明不了"中间有 19 个相位"。 +//! +//! 模态 / 系统偏好 / 降级那一半在 [`modal`]。 + +mod modal; +mod timeline; + +use super::pump::Pump; +use super::transition::{page_spec, Kind, ModalFrame, PageFrame}; +use super::*; +use unlucky_design::motion::MotionScale; + +/// 标准档、没有钉相位的泵(出厂态是 `NONE`,见 [`Pump::new`])。 +fn standard() -> Pump { + let mut pump = Pump::new(); + pump.configure(MotionScale::STANDARD, None); + pump +} + +/// 按「每帧 16 ms」推进到没有轨道为止,返回推进了多少帧(上限 1250 帧 = 20 s,防死循环)。 +fn settle(pump: &mut Pump) -> u64 { + let mut frames = 0; + for _ in 0..1250 { + if !pump.is_animating() { + break; + } + if pump.advance(FRAME_MS) { + frames += 1; + } + } + frames +} + +/// 一路采样所有中间态(含终点),用完自己 settle。 +fn samples(pump: &mut Pump) -> Vec { + let mut out = vec![pump.page_frame()]; + while pump.is_animating() { + pump.advance(FRAME_MS); + out.push(pump.page_frame()); + } + out +} + +// ------------------------------------------------------------------ 规格表 + +/// 规格**全部**来自 design 的令牌表,且落在 M3E 的短档(150~300ms)。 +/// +/// 抓得住:谁把某档改成 400ms 以上("更显眼"的诱惑),或把幅度改成非 4 的倍数。 +/// 抓不住:观感好不好——那要截图(D30),不在这条用例的能力范围里。 +#[test] +fn every_transition_is_built_from_registered_tokens() { + let kinds = [ + Kind::Boot, + Kind::Peer { forward: true }, + Kind::Peer { forward: false }, + Kind::Push, + Kind::Pop, + ]; + for kind in kinds { + let spec = page_spec(kind); + for leg in [spec.enter, spec.exit] { + assert!( + (150..=300).contains(&leg.duration.ms()), + "{}: {}ms 不在 MD3E 短档(150~300)", + kind.name(), + leg.duration.ms() + ); + assert!(leg.easing.sample(0.0).abs() < 1.0e-6); + assert!((leg.easing.sample(1.0) - 1.0).abs() < 1.0e-6); + } + // 进略长于出(M3E:进场要看清,退场只是让位) + assert!( + spec.enter.duration.ms() > spec.exit.duration.ms(), + "{}: 进场必须比退场长(进 {} / 出 {})", + kind.name(), + spec.enter.duration.ms(), + spec.exit.duration.ms() + ); + // 幅度落在 4px 栅格上,且退场幅度小于进场幅度 + let (inx, iny) = spec.enter_from; + let (outx, outy) = spec.exit_to; + for value in [inx, iny, outx, outy] { + assert_eq!(value % 4.0, 0.0, "{}: {value} 不在 4px 栅格上", kind.name()); + } + assert!( + inx.abs().max(iny.abs()) >= outx.abs().max(outy.abs()), + "{}: 退场幅度不该大于进场幅度", + kind.name() + ); + } + // 四档的动作方向钉住(谁改方向,报告与出图都要跟着改) + assert_eq!( + page_spec(Kind::Peer { forward: true }).enter_from, + (12.0, 0.0) + ); + assert_eq!(page_spec(Kind::Peer { forward: true }).exit_to, (-8.0, 0.0)); + assert_eq!( + page_spec(Kind::Peer { forward: false }).enter_from, + (-12.0, 0.0) + ); + assert_eq!(page_spec(Kind::Push).enter_from, (0.0, 16.0)); + assert_eq!(page_spec(Kind::Push).exit_to, (0.0, -8.0)); + assert_eq!(page_spec(Kind::Pop).enter_from, (0.0, -16.0)); +} + +// ------------------------------------------------------------------ 页面切换 + +/// **进入是 12~20 帧、落在端点、中途有严格中间态**(塔给的判据原文)。 +/// +/// 抓得住:①把时长改到 300ms 以外(帧数跑出窗口);②把 `enter_offset` 改成直接返回 0 +/// ("硬跳",中间态消失);③把末帧没归零(`PageFrame::STILL` 的端点断言红)。 +/// 抓不住:这一帧画出来好不好看(截图的事)。 +#[test] +fn a_page_enters_over_twelve_to_twenty_frames_and_lands_on_the_endpoint() { + let mut pump = standard(); + assert!(pump.observe(Some(2), None), "首次观察要起跑 Boot"); + let frames = settle(&mut pump); + assert!( + (12..=20).contains(&frames), + "进入应落在 12~20 帧,实测 {frames} 帧" + ); + + // 重跑一次,逐帧看端点与单调性 + pump.observe(Some(3), None); // 同级换页:medium2 = 300ms + let trace = samples(&mut pump); + assert!(trace.len() >= 12, "中间态太少:{}", trace.len()); + assert_eq!( + trace.last().copied(), + Some(PageFrame::STILL), + "末帧必须归位" + ); + assert_eq!(trace[0].enter, (12.0, 0.0), "首帧必须满幅(向右推进)"); + assert!( + trace + .iter() + .skip(1) + .any(|f| f.enter.0 > 0.0 && f.enter.0 < 12.0), + "中途必须存在**严格介于两端之间**的相位" + ); + // 单调收敛到 0(缓动是单调曲线,取整也不会反弹) + let mut previous = trace[0].enter.0; + for frame in trace.iter().skip(1) { + assert!( + frame.enter.0 <= previous, + "位移必须单调归零:{previous} → {}", + frame.enter.0 + ); + previous = frame.enter.0; + } +} + +/// **旧页反向退场**:同一拍里新页从 +12 归零、旧页向 −8 走;退场腿更短,先结束。 +/// +/// 抓得住:把 `exit` 位移删掉/写成同向("只挪新页"),或让退场腿和进场腿一样长。 +/// 抓不住:旧页**内容**是否正确(那是 `pages::page_with_settings_transition` 的职责, +/// 归 `app` 层的用例)。 +#[test] +fn the_old_page_moves_the_other_way_and_leaves_first() { + let mut pump = standard(); + pump.observe(Some(2), None); + settle(&mut pump); + + assert!(pump.observe(Some(3), None)); + let first = pump.page_frame(); + assert_eq!(first.enter, (12.0, 0.0)); + assert_eq!(first.exit, (0.0, 0.0), "退场起点是 0"); + assert!(first.exiting, "退场腿刚开始"); + + let mut saw_half = false; + let mut exit_finished_at = None; + let mut frames = 0; + while pump.is_animating() { + frames += 1; + pump.advance(FRAME_MS); + let frame = pump.page_frame(); + if frame.exit.0 < 0.0 && frame.exit.0 > -8.0 { + saw_half = true; + } + if !frame.exiting && exit_finished_at.is_none() { + exit_finished_at = Some(frames); + } + } + assert!(saw_half, "旧页必须有严格中间态(不是一步到位)"); + let exit_frames = exit_finished_at.expect("退场腿必须在进场腿之前结束"); + assert!( + exit_frames < frames, + "退场({exit_frames} 帧)必须比进场({frames} 帧)先结束" + ); +} + +/// **方向由导航深度决定**:同级换页走水平、进深层走纵向。 +/// +/// 抓得住:把四档动作表合并成一档("都往右下飘"),或把 `forward` 判据反了(回到左边却向右推)。 +/// 抓不住:导航表本身的顺序对不对(那是 `pages::Screen::ALL` / `screen_key` 的事)。 +#[test] +fn direction_follows_navigation_depth() { + let mut pump = standard(); + pump.observe(Some(1), None); + settle(&mut pump); + + // 向右推进(屏键 1 → 3) + pump.observe(Some(3), None); + assert_eq!(pump.kind(), Some(Kind::Peer { forward: true })); + assert_eq!(pump.page_frame().enter, (12.0, 0.0), "同级往右 = 水平位移"); + settle(&mut pump); + + // 向左退回(3 → 1) + pump.observe(Some(1), None); + assert_eq!(pump.kind(), Some(Kind::Peer { forward: false })); + assert_eq!(pump.page_frame().enter, (-12.0, 0.0), "同级往左 = 镜像"); + settle(&mut pump); + + // 进深层(同屏:列表 → 详情) + pump.observe(Some(1), Some("instance-7")); + assert_eq!(pump.kind(), Some(Kind::Push)); + let push = pump.page_frame(); + assert_eq!(push.enter, (0.0, 16.0), "进深层 = 纵向(从下方上浮)"); + assert_eq!(push.exit, (0.0, 0.0)); + settle(&mut pump); + + // 出深层(详情 → 列表) + pump.observe(Some(1), None); + assert_eq!(pump.kind(), Some(Kind::Pop)); + assert_eq!(pump.page_frame().enter, (0.0, -16.0), "出深层必须镜像"); +} + +/// **`MotionScale::NONE` 下位移与缩放严格为 0**(不是"很小")。 +/// +/// 抓得住:把 `play_page` 的 `is_off()` 分支删掉(变成跑一遍 0 时长动画)——那时 `frame()` +/// 会短暂给出非 0 的位移、`samples` 会增长、帧订阅次数不再是 0。 +/// 抓不住:OS 偏好有没有真的读到(那是 `platform::motion` 的用例与真机取证)。 +#[test] +fn none_scale_is_strictly_zero_not_merely_small() { + let mut pump = Pump::new(); // 出厂即 NONE + assert!(pump.scale().is_off()); + + // 首屏 / 换页 / 进退层:三次状态变化都不许留下轨道,位移必须 `== 0.0`(不是 `abs() < eps`) + for (screen, detail) in [ + (Some(1), None), + (Some(2), None), + (Some(2), Some("instance-7")), + (Some(2), None), + ] { + assert!(!pump.observe(screen, detail), "关掉动效时不许起跑"); + let frame = pump.page_frame(); + assert_eq!(frame.enter.0, 0.0); + assert_eq!(frame.enter.1, 0.0); + assert_eq!(frame.exit.0, 0.0); + assert_eq!(frame.exit.1, 0.0); + assert!(!frame.exiting, "NONE 档不许画旧页"); + } + + // 模态:进场直接到 1.0(缩放严格 1.0),退场直接关 + assert!(!pump.observe_modal(true), "NONE 档不起模态轨道"); + assert_eq!( + pump.modal_frame(), + ModalFrame { + visible: true, + progress: 1.0 + } + ); + assert!((modal_scale(1.0) - 1.0).abs() < 1.0e-6, "缩放严格 1.0"); + assert!(pump.begin_modal_close(), "NONE 档必须**当场**关,不留退场"); + assert_eq!(pump.modal_frame(), ModalFrame::HIDDEN); + + assert!(!pump.is_animating()); + for _ in 0..120 { + assert!(!pump.advance(FRAME_MS), "NONE 档没有任何可推进的"); + } + assert_eq!(pump.samples(), 0, "NONE 档的帧采样次数必须是 0"); +} + +// ------------------------------------------------------------------ 触发判据 + +/// 重复绘制**永远不会**重新起跑(否则每帧都重启动画 = 永远停不下来)。 +#[test] +fn repeated_paints_never_restart_the_animation() { + let mut pump = standard(); + pump.observe(Some(3), Some("instance-9")); + settle(&mut pump); + + for _ in 0..30 { + assert!(!pump.observe(Some(3), Some("instance-9"))); + assert_eq!(pump.page_frame(), PageFrame::STILL); + } + assert!(!pump.is_animating()); +} + +/// 不归 pages 画的屏(账户 / AI 独立页)**不起跑**;回到某一屏照样跑。 +#[test] +fn screens_rendered_elsewhere_never_start_a_track() { + let mut pump = standard(); + pump.observe(Some(1), None); + settle(&mut pump); + + assert!(!pump.observe(None, None), "没有 pages 树可动 ⇒ 不许白开帧"); + assert!(!pump.is_animating()); + + assert!(pump.observe(Some(1), None), "从账户页回来仍然是换页"); + assert_eq!(pump.kind(), Some(Kind::Boot), "没有旧页可退 ⇒ 按首屏方向"); +} + +/// **过渡中途再换页要能重入**(塔的门槛 D):新的进场、旧的立刻被取代,不叠加、不卡死。 +/// +/// 抓得住:把「新轨道取代旧轨道」改成 push(位移会叠加成 24px)、或让旧轨道继续占帧 +/// (帧数会明显超出 12~20 那一档)。 +/// 抓不住:`App::previous_view` 的丢弃(那条在 `app/update/motion_sync.rs` 的用例里)。 +#[test] +fn a_second_navigation_mid_flight_takes_over_cleanly() { + let mut pump = standard(); + pump.observe(Some(1), None); + settle(&mut pump); + + pump.observe(Some(2), None); // 往右推进 + for _ in 0..4 { + pump.advance(FRAME_MS); // 走 3~5 帧就再点一次 + } + let mid = pump.page_frame(); + assert!( + mid.enter.0 < 12.0 && mid.enter.0 > 0.0, + "第一次过渡还在半路" + ); + + pump.observe(Some(1), None); // 立刻退回左边 + let restarted = pump.page_frame(); + assert_eq!(restarted.enter, (-12.0, 0.0), "新方向立刻生效(满幅起点)"); + assert_eq!( + restarted.exit, + (0.0, 0.0), + "旧页从 0 重新开始,不与上一次叠加" + ); + + let frames = settle(&mut pump); + assert!( + (12..=20).contains(&frames), + "重入之后仍要在 12~20 帧内停下,实测 {frames}" + ); + assert_eq!(pump.page_frame(), PageFrame::STILL); +} diff --git a/crates/app-iced/src/components/motion/tests/modal.rs b/crates/app-iced/src/components/motion/tests/modal.rs new file mode 100644 index 0000000..dfb818b --- /dev/null +++ b/crates/app-iced/src/components/motion/tests/modal.rs @@ -0,0 +1,165 @@ +//! 模态 / 系统偏好 / 有界泵的降级守卫(M256)。从 `tests/mod.rs` 拆出来守 400 行硬线。 + +use super::super::pump::FRAME_MS; +use super::super::transition::{modal_spec, page_spec, Kind, ModalFrame, PageFrame}; +use super::super::*; +use super::{settle, standard}; +use unlucky_design::motion::{DurationToken, MotionScale}; + +/// 模态表:进 200 / 出 150,且起点缩放来自 design 令牌(0.96)。 +#[test] +fn modal_spec_is_short_and_asymmetric() { + let spec = modal_spec(); + assert_eq!(spec.enter.duration, DurationToken::Short4); // 200 + assert_eq!(spec.exit.duration, DurationToken::Short3); // 150 + assert!(spec.enter.duration.ms() > spec.exit.duration.ms()); + assert!((modal_scale(0.0) - unlucky_design::motion::DIALOG_ENTER_SCALE).abs() < 1.0e-6); + assert!((modal_scale(1.0) - 1.0).abs() < 1.0e-6); + // 单调(不弹跳:M3E 的对话框不许回弹) + let mut previous = modal_scale(0.0); + for step in 1..=20 { + let value = modal_scale(step as f32 / 20.0); + assert!(value >= previous, "缩放倒退了:{previous} → {value}"); + previous = value; + } +} + +// ------------------------------------------------------------------ 模态 + +/// 模态:进场 12~13 帧(200ms)、展开后**不再要帧**、退场反向且更短、走完交回一次「已关」。 +/// +/// 抓得住:①进场时长被改成 >300ms(帧数跑出去);②`Open` 还占着轨道(`is_animating` 为真 ⇒ +/// 对话框开着就一直在烧帧);③退场方向反了(`progress` 变成递增);④`take_modal_closed` +/// 不返回 `true`(框永远关不掉)。 +#[test] +fn a_modal_opens_stops_burning_frames_and_closes_in_reverse() { + let mut pump = standard(); + assert!(pump.observe_modal(true), "开框要起跑进场"); + assert!(pump.modal_frame().visible); + assert!(pump.modal_frame().progress < 0.5, "起手时是收起状态"); + + let enter_frames = settle(&mut pump) as usize; + assert!( + (10..=14).contains(&enter_frames), + "进场 200ms 应约 12 帧,实测 {enter_frames}" + ); + assert_eq!( + pump.modal_frame(), + ModalFrame { + visible: true, + progress: 1.0 + } + ); + assert!( + !pump.is_animating(), + "**展开后不许占帧**(对话框可能开几分钟,那不是动画)" + ); + assert!(!pump.observe_modal(true), "已经开着 ⇒ 不重新起跑"); + + // 退场:反向(1 → 0),且比进场短 + assert!(!pump.begin_modal_close(), "有动效时不许当场关"); + assert!(pump.modal_closing()); + let mut trace = vec![pump.modal_frame().progress]; + while pump.is_animating() { + pump.advance(FRAME_MS); + trace.push(pump.modal_frame().progress); + } + let exit_frames = trace.len() - 1; + assert!( + exit_frames < enter_frames, + "退场({exit_frames} 帧)必须短于进场({enter_frames} 帧)" + ); + assert!( + trace.windows(2).all(|pair| pair[0] >= pair[1]), + "退场进度必须单调下降:{trace:?}" + ); + assert_eq!(pump.modal_frame(), ModalFrame::HIDDEN, "走完即收起"); + assert!(pump.take_modal_closed(), "必须交回一次「已关」"); + assert!( + !pump.take_modal_closed(), + "只交回一次(重复关会把下一个框也关掉)" + ); +} + +/// 弹窗被别处**硬关**(不是走 `begin_modal_close`)时不玩退场:直接收起、不残留「待关」。 +#[test] +fn a_dialog_closed_elsewhere_does_not_leave_a_pending_close() { + let mut pump = standard(); + pump.observe_modal(true); + settle(&mut pump); + assert!(pump.modal_frame().visible); + + assert!(!pump.observe_modal(false)); + assert!(!pump.is_animating()); + assert_eq!(pump.modal_frame(), ModalFrame::HIDDEN); + assert!(!pump.take_modal_closed(), "没有退场就没有「待关」"); +} + +/// 运行期把关掉动效(`configure(NONE)`)要立刻落终态,而不是把动画「缩到 0 时长」。 +#[test] +fn switching_to_none_mid_flight_settles_immediately() { + let mut pump = standard(); + pump.observe(Some(1), None); + pump.advance(FRAME_MS); + assert!(pump.is_animating()); + + pump.configure(MotionScale::NONE, None); + assert!(!pump.is_animating(), "关掉动效必须当场清空轨道"); + assert_eq!(pump.page_frame(), PageFrame::STILL); +} + +// ------------------------------------------------------------------ 系统偏好合成 + +/// 系统「减少动效」三态与档位的合成(塔的硬约束 C):**`Unknown` 什么都不改**。 +/// +/// 抓得住:把 `Unknown` 也降级成 `NONE`("探针失败就静默关动效"),或把 `Reduced` 漏了。 +/// 抓不住:真机注册表/系统设置里的值(那要改系统设置,见报告"没验的部分")。 +#[test] +fn reduced_motion_only_triggers_on_an_explicit_reduced() { + use crate::platform::motion::ReduceMotion; + + for scale in [MotionScale::STANDARD, MotionScale::HALF, MotionScale::NONE] { + assert_eq!( + effective_scale(scale, ReduceMotion::Reduced, false), + MotionScale::NONE, + "系统明确要求减少动效 ⇒ 归零" + ); + assert_eq!( + effective_scale(scale, ReduceMotion::AnimationsOn, false), + scale, + "系统里动效开着 ⇒ 用原档" + ); + assert_eq!( + effective_scale(scale, ReduceMotion::Unknown, false), + scale, + "**读不到 ≠ 要减少**:探针失败不许静默关掉动效" + ); + assert_eq!( + effective_scale(scale, ReduceMotion::Reduced, true), + scale, + "取证相位钉住时不被系统偏好降级(多帧序列图要可复现)" + ); + } +} + +// ------------------------------------------------------------------ 取证相位 + +/// `ULL_MOTION_AT` 的钉住相位:时间轴不前进,进度恒定(多帧序列图靠它可复现)。 +#[test] +fn pinned_phase_freezes_the_clock() { + let mut pump = Pump::new(); + pump.configure(MotionScale::STANDARD, Some(150.0)); + pump.observe(Some(1), None); // 300ms 档 ⇒ 钉在 150 = 一半 + + for _ in 0..10 { + pump.advance(FRAME_MS); + } + let frame = pump.page_frame(); + let expected = 16.0 * (1.0 - page_spec(Kind::Boot).enter.easing.sample(0.5)); + assert!( + (frame.enter.1 - expected.round()).abs() < 1.0, + "钉在 150/300 的位移应约 {expected},实测 {}", + frame.enter.1 + ); + assert!(pump.is_animating(), "钉住的相位不会自然结束(取证专用)"); +} diff --git a/crates/app-iced/src/components/motion/tests/timeline.rs b/crates/app-iced/src/components/motion/tests/timeline.rs new file mode 100644 index 0000000..24f3b63 --- /dev/null +++ b/crates/app-iced/src/components/motion/tests/timeline.rs @@ -0,0 +1,95 @@ +//! 有界泵的算术守卫(M256 从 `tests/mod.rs` 拆出,守 400 行硬线):采样频率上限、到期即退订、 +//! stale 帧基准不许一帧杀完、以及 `MotionScale::HALF` 的**语义**(时长减半 ≠ 半速)。 +//! +//! 这些全是「本地 `Pump` + 摆时间轴」就能判死的算术性质,所以不需要窗口、不需要真实时间。 + +use super::super::pump::{Pump, FRAME_MS, MAX_STEP_MS}; +use super::super::transition::PageFrame; +use super::{settle, standard}; +use unlucky_design::motion::{DurationToken, MotionScale}; + +// ------------------------------------------------------------------ 有界泵 + +/// **空闲不烧帧**:动画跑完 ⇒ `is_animating()` 立刻为假,且不再推进。 +/// +/// 抓得住:把「到期即删除」删掉(轨道永远留着 ⇒ 帧订阅永不退订)。 +#[test] +fn a_finished_animation_stops_asking_for_frames() { + let mut pump = standard(); + assert!(pump.observe(Some(1), None)); + assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)"); + + settle(&mut pump); + assert!(!pump.is_animating(), "动画结束必须**当场**退订"); + assert_eq!(pump.page_frame(), PageFrame::STILL); + + let before = pump.samples(); + for _ in 0..60 { + assert!(!pump.advance(FRAME_MS), "没有轨道时推进必须返回 false"); + } + assert_eq!(pump.samples(), before, "空闲态一次采样都不该发生"); +} + +/// **采样频率上限**:帧到得再快,时间轴也只按 16 ms 结算(≈62.5 次/秒)。 +#[test] +fn frames_are_capped_at_sixty_per_second() { + let mut pump = standard(); + pump.observe(Some(1), None); + + let mut advanced = 0; + for _ in 0..200 { + if pump.advance(1.0) { + advanced += 1; + } + } + assert!( + (11..=13).contains(&advanced), + "200 ms 内应 12±1 次推进,实测 {advanced}" + ); + assert_eq!(pump.samples(), advanced); + assert!( + (pump.now_ms() - 192.0).abs() <= 16.0, + "时间轴应约 192 ms,实测 {}", + pump.now_ms() + ); +} + +/// **stale 帧基准不许把轨道一帧杀完**(M192 r1 复审 P1-1 的算术面)。 +#[test] +fn a_stale_baseline_cannot_kill_the_track_in_one_sample() { + let mut pump = standard(); + pump.observe(Some(1), None); + + assert!(pump.advance(60_000.0)); // 60 秒的"空闲间隔"当 dt + assert!(pump.is_animating(), "一次超长 dt 不许把轨道当场删掉"); + assert!(pump.now_ms() <= MAX_STEP_MS + 1.0e-6); + assert!(pump.page_frame().enter.1 > 0.0, "进度不许一步到位"); + + settle(&mut pump); + assert!(!pump.is_animating()); +} + +/// 半时长档(CPU 渲染兜底)真的把时长减半:同样的推进,轨道更早结束。 +/// +/// 顺带钉住 `MotionScale::HALF` 的**语义**(M256 核清项②):是「时长 ×0.5」=更快, +/// 不是"半速"。抓得住:把 `HALF.factor` 改成 2.0(半速)或 1.0(无效)。 +#[test] +fn half_scale_halves_the_duration_not_the_speed() { + assert_eq!(MotionScale::HALF.factor(), 0.5); + assert_eq!(MotionScale::HALF.ms(DurationToken::Medium2), 150.0); + + let mut full = standard(); + full.observe(Some(1), None); + let full_frames = settle(&mut full); + + let mut half = Pump::new(); + half.configure(MotionScale::HALF, None); + half.observe(Some(1), None); + let half_frames = settle(&mut half); + + assert!(half_frames > 0 && full_frames > 0); + assert!( + half_frames * 2 <= full_frames + 2, + "HALF 应约用一半帧数:full={full_frames} half={half_frames}" + ); +} diff --git a/crates/app-iced/src/components/motion/transition.rs b/crates/app-iced/src/components/motion/transition.rs new file mode 100644 index 0000000..d3ed342 --- /dev/null +++ b/crates/app-iced/src/components/motion/transition.rs @@ -0,0 +1,230 @@ +//! 动效**规格表**与位移数学(M256):一次页面切换的方向/深度、两腿时长、模态的进出。 +//! +//! 这一层是纯函数:不认识 iced、不认识时钟、不读环境变量。改观感只改这里的一张表。 +//! +//! ## 为什么要有「方向」和「两腿」 +//! +//! M192 只做了一次「新页从下方归位」(三个槽一张表)。M3 Expressive 的页面过渡不是这样: +//! 它是 **shared-axis**——同级换页走水平轴、进深层走纵深轴,而且**旧页反向退场**(两腿同时跑)。 +//! 所以本表对每种切换给出四件东西:进场腿、退场腿、新页起点位移、旧页终点位移。 +//! +//! | 切换 | 轴 | 新页从哪来 | 旧页去哪 | 进场 / 退场 | +//! |---|---|---|---|---| +//! | [`Kind::Boot`] 首屏 | 纵向 | 下方 `+X4`(16) | —— | `medium2` 300 / —— | +//! | [`Kind::Peer`] 同级换页 | 水平 | 右/左 `±X3`(±12) | 反向 `∓X2`(∓8) | `medium2` 300 / `medium1` 250 | +//! | [`Kind::Push`] 进深层(列表→详情) | 纵向 | 下方 `+X4`(16) | 上方 `-X2`(-8) | `medium1` 250 / `short4` 200 | +//! | [`Kind::Pop`] 出深层(详情→列表) | 纵向 | 上方 `-X4`(-16) | 下方 `+X2`(8) | `medium1` 250 / `short4` 200 | +//! +//! 三条口径(都有守卫):①**进略长于出**(300>250、250>200)——M3 的进场要看得清,退场只是让位; +//! ②幅度全部落在 4px 栅格上(`Space` 令牌,不新造数字),且退场幅度**小于**进场幅度; +//! ③进/退曲线不同:进场 `Standard`(屏内元素起止都平滑),退场 `StandardAccelerate` +//! (M3 的「离场加速」口径,让旧页干脆地让出注意力)。 + +use unlucky_design::motion::{DurationToken, Easing, DIALOG_ENTER_SCALE}; +use unlucky_design::spacing::Space; + +/// 一次页面切换的方向与深度来源(判据在 `Pump::observe`)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + /// 首屏进入:进程内第一次看到归 pages 画的屏。没有旧页。 + Boot, + /// **同级**换页:新页在导航表里更靠右(`forward`)或更靠左。外屏(账户 / AI)来回也走这一档。 + Peer { + /// `true` = 新页在导航顺序里更靠后(向右推进)。 + forward: bool, + }, + /// **进深层**:同一屏里从列表进详情(导航深度 +1)。 + Push, + /// **出深层**:详情退回列表(导航深度 -1)。 + Pop, +} + +impl Kind { + /// 稳定名字(日志 / 断言失败信息用;产品路径不读这一档)。 + #[allow(dead_code)] + pub const fn name(self) -> &'static str { + match self { + Kind::Boot => "boot", + Kind::Peer { forward: true } => "peer-forward", + Kind::Peer { forward: false } => "peer-back", + Kind::Push => "push", + Kind::Pop => "pop", + } + } + + /// 这一档有没有旧页参与退场(首屏没有)。 + #[allow(dead_code)] + pub const fn has_old_page(self) -> bool { + !matches!(self, Kind::Boot) + } +} + +/// 一条腿:时长档 × 缓动档(两个都是 design 令牌,不是毫秒字面量)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Leg { + /// 时长档(经 `MotionScale` 缩放后生效)。 + pub duration: DurationToken, + /// 缓动曲线。 + pub easing: Easing, +} + +/// 一次页面切换的完整规格。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PageSpec { + /// 新页那一腿。 + pub enter: Leg, + /// 旧页那一腿(退场)。 + pub exit: Leg, + /// 新页的**起点**位移(终点恒为 `(0, 0)`);正值 = 内容先被推到右下再归位。 + pub enter_from: (f32, f32), + /// 旧页的**终点**位移(起点恒为 `(0, 0)`)。 + pub exit_to: (f32, f32), +} + +/// 切换 → 规格(**唯一**一张页面动效表)。 +pub const fn page_spec(kind: Kind) -> PageSpec { + // 进场腿:屏内整块内容换人,用 Standard(起止都平滑);退场腿用 StandardAccelerate + // (M3 的离场加速,旧页干脆让位)。 + const ENTER: Leg = Leg { + duration: DurationToken::Medium1, + easing: Easing::Standard, + }; + const EXIT: Leg = Leg { + duration: DurationToken::Short4, + easing: Easing::StandardAccelerate, + }; + const PEER_ENTER: Leg = Leg { + duration: DurationToken::Medium2, + easing: Easing::Standard, + }; + const PEER_EXIT: Leg = Leg { + duration: DurationToken::Medium1, + easing: Easing::StandardAccelerate, + }; + + // 幅度:只从间距令牌取(4 的倍数),退场幅度**小于**进场幅度(M3:退场不跟进场抢注意力)。 + let far = Space::X4.dp(); // 16 + let near = Space::X3.dp(); // 12 + let short = Space::X2.dp(); // 8 + + match kind { + Kind::Boot => PageSpec { + enter: PEER_ENTER, + exit: PEER_EXIT, + enter_from: (0.0, far), + exit_to: (0.0, 0.0), + }, + Kind::Peer { forward: true } => PageSpec { + enter: PEER_ENTER, + exit: PEER_EXIT, + enter_from: (near, 0.0), + exit_to: (-short, 0.0), + }, + Kind::Peer { forward: false } => PageSpec { + enter: PEER_ENTER, + exit: PEER_EXIT, + enter_from: (-near, 0.0), + exit_to: (short, 0.0), + }, + Kind::Push => PageSpec { + enter: ENTER, + exit: EXIT, + enter_from: (0.0, far), + exit_to: (0.0, -short), + }, + Kind::Pop => PageSpec { + enter: ENTER, + exit: EXIT, + enter_from: (0.0, -far), + exit_to: (0.0, short), + }, + } +} + +/// 模态的进 / 出两腿。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ModalSpec { + /// 进场:强调减速(M3 里"出现在屏上"的主曲线)。 + pub enter: Leg, + /// 退场:强调加速,且**短于**进场。 + pub exit: Leg, +} + +/// 模态规格(唯一来源)。 +/// +/// 时长取 `short4`(200ms)/ `short3`(150ms):对话框是「用户刚做了一件事的回应」, +/// 属于 M3 的短档;比起页面切换它必须更短,否则会觉得界面在拖。 +#[allow(clippy::missing_const_for_fn)] +pub const fn modal_spec() -> ModalSpec { + ModalSpec { + enter: Leg { + duration: DurationToken::Short4, + easing: Easing::EmphasizedDecelerate, + }, + exit: Leg { + duration: DurationToken::Short3, + easing: Easing::EmphasizedAccelerate, + }, + } +} + +/// 一帧页面采样(**只读**:`pages::render` 每帧取一次,不起轨道)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PageFrame { + /// 新页位移(终点恒 `(0,0)`)。 + pub enter: (f32, f32), + /// 旧页位移(起点恒 `(0,0)`)。 + pub exit: (f32, f32), + /// 旧页是否还该被画出来(退场腿没走完)。`false` ⇒ 调用方**不要**再画旧页。 + pub exiting: bool, +} + +impl PageFrame { + /// 静息:新页在位、没有旧页。`MotionScale::NONE` 下**恒为**这个值(位移严格 0)。 + pub const STILL: Self = Self { + enter: (0.0, 0.0), + exit: (0.0, 0.0), + exiting: false, + }; +} + +/// 一帧模态采样。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ModalFrame { + /// 要不要把这一层画出来(`false` ⇒ 不画遮罩也不画卡片)。 + pub visible: bool, + /// `0` = 刚起手(缩小 + 遮罩最淡),`1` = 完全展开(缩放 1.0 + 遮罩最浓)。 + pub progress: f32, +} + +impl ModalFrame { + /// 没有弹窗。 + pub const HIDDEN: Self = Self { + visible: false, + progress: 0.0, + }; +} + +/// 模态的起始缩放(design 令牌 `DIALOG_ENTER_SCALE` 的再导出:视图侧只认这一条路径)。 +pub const MODAL_SCALE_FROM: f32 = DIALOG_ENTER_SCALE; + +/// 模态当前缩放:`progress = 0` 时是 [`MODAL_SCALE_FROM`],`1` 时是 1.0。 +pub fn modal_scale(progress: f32) -> f32 { + let p = progress.clamp(0.0, 1.0); + MODAL_SCALE_FROM + (1.0 - MODAL_SCALE_FROM) * p +} + +/// 端点固定的线性插值:`progress = 0` → `from`,`progress = 1` → `0`。取整到整像素。 +/// +/// 为什么取整:整页位移落在整像素上,动画期间文字不会因亚像素采样而发虚(M192 的同一口径); +/// 末帧恒为 `(0, 0)` ⇒ 与静息逐像素相同。 +pub fn enter_offset(from: (f32, f32), progress: f32) -> (f32, f32) { + let remaining = 1.0 - progress.clamp(0.0, 1.0); + ((from.0 * remaining).round(), (from.1 * remaining).round()) +} + +/// 从 `(0, 0)` 走到 `to` 的位移(旧页退场)。取整口径同上。 +pub fn exit_offset(to: (f32, f32), progress: f32) -> (f32, f32) { + let done = progress.clamp(0.0, 1.0); + ((to.0 * done).round(), (to.1 * done).round()) +} diff --git a/crates/app-iced/src/components/motion/zoom.rs b/crates/app-iced/src/components/motion/zoom.rs new file mode 100644 index 0000000..c7c712f --- /dev/null +++ b/crates/app-iced/src/components/motion/zoom.rs @@ -0,0 +1,186 @@ +//! 缩放层(M256):把一棵子树整体缩放(模态卡片进出场用)。 +//! +//! ## 为什么自己写一个 +//! +//! iced 0.14 的控件清单里**没有**通用的 transform / opacity 控件(`iced_widget` 只有 `stack` / +//! `opaque` / `float` / `pin`)。但渲染器层是有的:`Renderer::with_transformation` 在**两个后端 +//! 都实现了**——`iced_tiny_skia-0.14.1/src/lib.rs:211-217` 与 `iced_wgpu-0.14.0/src/lib.rs:682-688` +//! 各是一行 `layers.push_transformation(...)`。而且 tiny_skia 对文字也乘了 `transformation`: +//! `layer.rs:73-96` 把字号、行高、bounds 都按 `scale_factor()` 缩过 ⇒ **文字是真缩放**, +//! 不是"只缩背景、字不动"。 +//! +//! ## 静息态**逐像素不变** +//! +//! `scale == 1.0` 时直接 `content.draw(..)`,**不套**变换:出图基线(D30)不受这层影响, +//! 也不给渲染器多压一层矩阵。 +//! +//! ## 已知局限(登记在报告里) +//! +//! 命中测试用的是**布局矩形**(未缩放),所以缩放途中的点击落点仍按 1.0 的框算。 +//! 模态只在开/关那 150~200 ms 里缩放,而那两段本来就不该被点击(`app/update/dialog.rs` +//! 在退场期间直接吞掉弹窗消息),所以这件事没有可观察的后果。 + +use iced::advanced::layout::{self, Layout}; +use iced::advanced::renderer; +use iced::advanced::widget::{tree, Tree}; +use iced::advanced::{mouse, Clipboard, Shell, Widget}; +use iced::{Element, Event, Length, Rectangle, Size, Transformation, Vector}; + +/// 把 `content` 缩放 `scale` 倍(**绕自身中心**),布局尺寸不变。 +pub fn scaled<'a, Message, Theme, Renderer>( + content: impl Into>, + scale: f32, +) -> Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a, + Renderer: iced::advanced::Renderer + 'a, +{ + Element::new(Scaled { + content: content.into(), + scale, + }) +} + +/// 绕**中心**缩放的变换:`T(-c) · S · T(c)`(`Mul` 的乘法序 = 先右后左,见 iced_core 的 +/// `impl Mul for Transformation`)。 +fn about_center(bounds: Rectangle, scale: f32) -> Transformation { + let (cx, cy) = ( + bounds.x + bounds.width / 2.0, + bounds.y + bounds.height / 2.0, + ); + Transformation::translate(-cx, -cy) + * Transformation::scale(scale) + * Transformation::translate(cx, cy) +} + +struct Scaled<'a, Message, Theme, Renderer> { + content: Element<'a, Message, Theme, Renderer>, + scale: f32, +} + +impl Scaled<'_, Message, Theme, Renderer> { + /// 这条变换要不要真的套(`1.0` 时什么都不做)。 + fn transformation(&self, bounds: Rectangle) -> Option { + // 0.1% 以下视为到位:亚像素缩放只会让文字发虚,没有观感收益。 + if (self.scale - 1.0).abs() < 1.0e-3 { + return None; + } + Some(about_center(bounds, self.scale.max(0.01))) + } +} + +impl Widget + for Scaled<'_, Message, Theme, Renderer> +where + Renderer: iced::advanced::Renderer, +{ + fn tag(&self) -> tree::Tag { + self.content.as_widget().tag() + } + + fn state(&self) -> tree::State { + self.content.as_widget().state() + } + + fn children(&self) -> Vec { + self.content.as_widget().children() + } + + fn diff(&self, tree: &mut Tree) { + self.content.as_widget().diff(tree); + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn size_hint(&self) -> Size { + self.content.as_widget().size_hint() + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content.as_widget_mut().layout(tree, renderer, limits) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + // 先算变换(借 layout 的 bounds),再借用 content —— 两个借用互不重叠。 + let transformation = self.transformation(layout.bounds()); + let content = self.content.as_widget(); + + match transformation { + None => content.draw(tree, renderer, theme, style, layout, cursor, viewport), + Some(transformation) => renderer.with_transformation(transformation, |renderer| { + content.draw(tree, renderer, theme, style, layout, cursor, viewport); + }), + } + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn iced::advanced::widget::Operation, + ) { + self.content + .as_widget_mut() + .operate(tree, layout, renderer, operation); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + self.content.as_widget_mut().update( + tree, event, layout, cursor, renderer, clipboard, shell, viewport, + ); + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content + .as_widget() + .mouse_interaction(tree, layout, cursor, viewport, renderer) + } + + fn overlay<'a>( + &'a mut self, + tree: &'a mut Tree, + layout: Layout<'a>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content + .as_widget_mut() + .overlay(tree, layout, renderer, viewport, translation) + } +} diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index 14d4a56..e840033 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -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(), diff --git a/crates/app-iced/src/platform/mod.rs b/crates/app-iced/src/platform/mod.rs index abad10b..72cbf63 100644 --- a/crates/app-iced/src/platform/mod.rs +++ b/crates/app-iced/src/platform/mod.rs @@ -1,6 +1,6 @@ -//! 平台相关的窗口能力(P4 / D36 / M116 / M168 / M182 / M196):整窗透明度与窗口圆角的**判据**、 -//! 两处 `unsafe` 落点、系统语言真值源、以及数据根派生——每一件各归一处(判据不碰 FFI, -//! FFI 只住落点文件)。 +//! 平台相关的窗口能力(P4 / D36 / M116 / M168 / M182 / M196 / M256):整窗透明度与窗口圆角的 +//! **判据**、三处 `unsafe` 落点、系统语言真值源、系统「减少动效」偏好、以及数据根派生——每一件 +//! 各归一处(判据不碰 FFI,FFI 只住落点文件)。 //! //! - 本文件 = 整窗 α 的纯判据 [`effective_window_alpha`]:不读环境变量、不调 FFI、不碰窗口句柄, //! 只回答「按这个渲染后端和用户要的 α,最终该给窗口什么值」; @@ -9,8 +9,11 @@ //! - [`win32::transparency`] = 整窗 α 的 FFI 落点(补 `WS_EX_LAYERED` + //! `SetLayeredWindowAttributes`,以及 M116 的 `DwmSetWindowAttribute`); //! - [`win32::console`] = M196 的控制台附着落点(`AttachConsole`):GUI 子系统进程的输出 -//! 为什么在 cmd 里会静默丢失,以及唯一的补救。两个落点的放行范围各自收在一个文件里, -//! `unsafe-scope` 门禁的期望集合就是这两个路径(集合相等,不是黑名单); +//! 为什么在 cmd 里会静默丢失,以及唯一的补救; +//! - [`motion`] = M256:系统「减少动效」偏好的**三态**探针(Windows 走 +//! `SystemParametersInfoW(SPI_GETCLIENTAREAANIMATION)`,Linux/macOS 走子进程 + 超时)。 +//! 三处落点的放行范围各自收在一个文件里,`unsafe-scope` 门禁的期望集合就是这三个路径 +//! (集合相等,不是黑名单); //! - [`locale`] = 系统语言真值源(D28):`reg.exe` 子进程读注册表,**零 `unsafe` / 零 FFI**。 //! //! 为什么判据要单独拎出来:D36 的降级规则(α 下限 0.08 / `≤0` 一律拒绝 / CPU 档也允许 @@ -26,6 +29,7 @@ //! | [`host`] | [`HostOs`]:平台族的**值**(判据的输入,不是散落的 `cfg`) | 没有 | //! | [`macos`] | macOS 为什么是直角的三环证据 + 「迁移原生标题栏」的未实现配方 | 没有(本轮只有文档) | //! | [`linux`] | X11 / Wayland 谁负责圆角、为什么我们不自绘 | 没有(本轮只有文档) | +//! | [`motion`] | 系统「减少动效」三态探针(M256;缓存 + 子进程超时) | **有**(仅 Windows 一支;Linux/macOS 走子进程) | //! | [`win32`] | Windows 的实现与探针(DWM / 分层 / 控制台附着) | **有**(两处落点:`transparency` + `console`) | //! //! 两个新平台模块**只有文档、没有代码**:M182 的裁决是不实现(macOS 要动手就得先迁移原生标题栏, @@ -41,6 +45,7 @@ pub mod host; pub mod linux; pub mod locale; pub mod macos; +pub mod motion; pub mod win32; /// 窗口圆角的**判定**(M116 / M182)住在 `win32::corner`:判据是用户策略 × 宿主平台 × 渲染档。 diff --git a/crates/app-iced/src/platform/motion.rs b/crates/app-iced/src/platform/motion.rs new file mode 100644 index 0000000..236f2da --- /dev/null +++ b/crates/app-iced/src/platform/motion.rs @@ -0,0 +1,240 @@ +//! 系统「减少动效」偏好(M256):**三态**探针,只在启动时读一次。 +//! +//! ## 为什么要它 +//! +//! `DevDocs/PlanDoc/14-motion-and-animation.md` §6 的第 1 条未核项:`MotionScale` 到底有没有 +//! 被系统偏好驱动。M256 核清的答案是**没有**——在此之前它只有三个来源(`ULL_MOTION` 环境变量、 +//! 截图通道、渲染后端档),操作系统里那句「动画效果」从没人读过。本模块是那条缺失的入口。 +//! +//! ## 三态,不是两态(这条是硬约束) +//! +//! | 值 | 含义 | 调用方该做什么 | +//! |---|---|---| +//! | [`ReduceMotion::Reduced`] | 系统明确要求减少动效 | 把档位降到 `MotionScale::NONE` | +//! | [`ReduceMotion::AnimationsOn`] | 系统里动效是开着的 | 用原来的档位 | +//! | [`ReduceMotion::Unknown`] | 读不到(命令缺失 / 无桌面 / 超时 / 平台未实现) | **用原来的档位**(什么都不改) | +//! +//! `Unknown` **不等于** `Reduced`:探针失败就静默关掉动效,会出现「某些机器上动效神秘消失」, +//! 那种问题比「少一个动效」难查得多。这条有守卫(`components::motion::tests`)。 +//! +//! ## 廉价、一次、可超时 +//! +//! - 结果缓存在 [`std::sync::OnceLock`] 里:**进程内只探一次**(`motion::configure` 是唯一调用点, +//! 它在 `on_boot` 跑一次;配置重载时读的是同一个缓存,不会重复 spawn)。 +//! - 子进程有 [`COMMAND_TIMEOUT`] 上限:`gsettings` / `defaults` 在坏环境里会挂住, +//! 超时按 `Unknown` 处理并 `kill` 掉。 +//! +//! | 平台 | 来源 | `unsafe` | +//! |---|---|---| +//! | Windows | `SystemParametersInfoW(SPI_GETCLIENTAREAANIMATION)` | **有**(收在 [`system_animations_enabled`] 一个函数里,全仓第三处放行,门禁 `unsafe-scope` 已登记) | +//! | Linux | `gsettings get org.gnome.desktop.interface enable-animations` | 没有 | +//! | macOS | `defaults read com.apple.universalaccess reduceMotion` | 没有(**未在真机验证**:本仓无 Mac 实机,键名与权限都可能有差异) | +//! | 其它 | 恒 `Unknown` | —— | + +use std::sync::OnceLock; + +/// 系统偏好:「减少动效」是否要生效。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReduceMotion { + /// 系统要求减少动效(装配层应降到 `MotionScale::NONE`)。 + Reduced, + /// 系统里动效是开着的(装配层按原档位)。 + AnimationsOn, + /// 读不到(**不等于** Reduced:装配层按原档位,见模块文档)。 + Unknown, +} + +impl ReduceMotion { + /// 进程内只探一次的入口(`motion::configure` 唯一调用点)。 + pub fn probe() -> Self { + static CACHE: OnceLock = OnceLock::new(); + *CACHE.get_or_init(detect) + } + + /// 稳定名(启动日志与断言失败信息用)。 + pub const fn as_str(self) -> &'static str { + match self { + ReduceMotion::Reduced => "reduced", + ReduceMotion::AnimationsOn => "animations-on", + ReduceMotion::Unknown => "unknown", + } + } + + /// 是否是「系统要求减少动效」(唯一该改档位的取值)。 + pub const fn is_reduced(self) -> bool { + matches!(self, ReduceMotion::Reduced) + } +} + +/// 子进程读偏好的超时上限(毫秒)。 +/// +/// 为什么是这个量级:`gsettings` / `defaults` 正常在 10~40 ms 内返回;300 ms 已经足够覆盖冷启动 +/// 的 D-Bus / cfprefsd 唤醒,再长就等于让启动卡在这一步。 +pub const COMMAND_TIMEOUT: u128 = 300; + +#[cfg(target_os = "windows")] +fn detect() -> ReduceMotion { + match system_animations_enabled() { + Some(true) => ReduceMotion::AnimationsOn, + Some(false) => ReduceMotion::Reduced, + None => ReduceMotion::Unknown, + } +} + +#[cfg(target_os = "windows")] +const SPI_GETCLIENTAREAANIMATION: u32 = 0x1042; + +/// 系统「动画效果」是不是开着(`None` = 调用失败)。 +/// +/// `SPI_GETCLIENTAREAANIMATION`(`winuser.h`,Win10+ 稳定可用)就是「设置 → 辅助功能 → +/// 视觉效果 → 动画效果」那个开关的真值源;`pvParam` 是 `BOOL*`,非 0 = 动画开着。 +/// +/// ## 为什么允许 unsafe(全仓第三处,已登记进 `unsafe-scope` 门禁) +/// +/// 这个开关**没有**安全的 Rust API 可达(注册表里的对应键不稳定、且与"最佳性能"混在一起), +/// 只有 Win32 一条路。放行范围收在这一个函数里,别的判据照旧是纯函数。 +#[cfg(target_os = "windows")] +#[allow(unsafe_code)] +fn system_animations_enabled() -> Option { + use core::ffi::c_void; + + #[link(name = "user32")] + extern "system" { + /// `BOOL SystemParametersInfoW(UINT, UINT, PVOID, UINT)`。 + fn SystemParametersInfoW(action: u32, param: u32, data: *mut c_void, win_ini: u32) -> i32; + } + + let mut enabled: i32 = 0; + // SAFETY: ①`SPI_GETCLIENTAREAANIMATION` 的 `pvParam` 契约是「指向 BOOL 的可写指针」, + // 这里给的是栈上 `i32` 的地址,类型与对齐都符合(Win32 `BOOL` = 32 位 int); + // ②`uiParam`/`fWinIni` 对这条 action 不使用,传 0;③本调用只写这 4 个字节,不持有指针、 + // 不转移所有权,返回后栈变量即可读;④结果为 0 表示失败,此时按 `None`(Unknown)处理, + // 不去猜 `enabled` 的值。 + let ok = unsafe { + SystemParametersInfoW( + SPI_GETCLIENTAREAANIMATION, + 0, + (&mut enabled as *mut i32).cast(), + 0, + ) + }; + (ok != 0).then_some(enabled != 0) +} + +#[cfg(target_os = "linux")] +fn detect() -> ReduceMotion { + // GNOME 的开关;Cinnamon / MATE 同键名。KDE 的 `kdeglobals` 走另一条路(未实现,记 Unknown)。 + match run( + COMMAND, + &["get", "org.gnome.desktop.interface", "enable-animations"], + ) { + Some(text) => match text.trim() { + "false" => ReduceMotion::Reduced, + "true" => ReduceMotion::AnimationsOn, + _ => ReduceMotion::Unknown, + }, + None => ReduceMotion::Unknown, + } +} + +#[cfg(target_os = "linux")] +const COMMAND: &str = "gsettings"; + +#[cfg(target_os = "macos")] +fn detect() -> ReduceMotion { + match run( + COMMAND, + &["read", "com.apple.universalaccess", "reduceMotion"], + ) { + Some(text) => match text.trim() { + "1" | "true" | "YES" => ReduceMotion::Reduced, + "0" | "false" | "NO" => ReduceMotion::AnimationsOn, + // 键不存在(这台机器没改过这个开关)时 `defaults` 会报错退出 ⇒ 走不到这里; + // 其它输出按 Unknown,不猜。 + _ => ReduceMotion::Unknown, + }, + None => ReduceMotion::Unknown, + } +} + +#[cfg(target_os = "macos")] +const COMMAND: &str = "defaults"; + +#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] +fn detect() -> ReduceMotion { + ReduceMotion::Unknown +} + +/// 跑一条短命令并取回 stdout(**带超时**,超时 `kill` 掉并返回 `None`)。 +/// +/// 为什么自己写而不是 `Command::output()`:`output()` 会一直等到子进程退出,坏环境里(没有 +/// session bus 的 `gsettings`)就是无限期挂住——那会让**启动**卡死,是比"没有动效"严重得多的事故。 +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn run(program: &str, args: &[&str]) -> Option { + use std::io::Read; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + let deadline = Instant::now() + Duration::from_millis(COMMAND_TIMEOUT as u64); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + Ok(None) => std::thread::sleep(Duration::from_millis(5)), + Err(_) => return None, + } + } + + // 输出只有几个字节(`true` / `false` / `1`),管道容量远大于它 ⇒ 先等进程退出再读不会死锁。 + let mut text = String::new(); + child.stdout.take()?.read_to_string(&mut text).ok()?; + Some(text) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 三态必须**三**个值可区分:`Unknown` 不能被当成 `Reduced`(M256 的硬约束 C)。 + #[test] + fn unknown_is_not_reduced() { + assert!(!ReduceMotion::Unknown.is_reduced()); + assert!(ReduceMotion::Reduced.is_reduced()); + assert!(!ReduceMotion::AnimationsOn.is_reduced()); + let names = [ + ReduceMotion::Reduced.as_str(), + ReduceMotion::AnimationsOn.as_str(), + ReduceMotion::Unknown.as_str(), + ]; + let unique: std::collections::BTreeSet<&str> = names.iter().copied().collect(); + assert_eq!(unique.len(), 3, "三个取值的名字必须互不相同:{names:?}"); + } + + /// 探针在**本机**要么给出两个确定值之一,要么 `Unknown`——但它必须有返回值、不许 panic, + /// 且**第二次调用走缓存**(同一进程内结果恒定)。 + /// + /// 抓得住:把缓存去掉、第二次重新 spawn,在坏环境里就会看到不同结果(或让这条用例超时)。 + /// 抓不住:真机上"读到的是不是用户设的那一格"(那要改系统设置,见报告的未验清单)。 + #[test] + fn probe_is_cached_and_total() { + let first = ReduceMotion::probe(); + let second = ReduceMotion::probe(); + assert_eq!(first, second, "同一次进程内探针结果必须恒定(走缓存)"); + assert!(matches!( + first, + ReduceMotion::Reduced | ReduceMotion::AnimationsOn | ReduceMotion::Unknown + )); + } +} diff --git a/crates/design/src/motion/mod.rs b/crates/design/src/motion/mod.rs index df59e11..cc539da 100644 --- a/crates/design/src/motion/mod.rs +++ b/crates/design/src/motion/mod.rs @@ -8,6 +8,15 @@ //! //! **CPU / 减少动效路径**:把 [`MotionScale`] 设成 [`MotionScale::NONE`], //! 所有时长归零、`Easing::sample` 仍返回 0/1 端值(适配层按 `t >= 1` 直接落终态)。 +//! +//! ## [`MotionScale::HALF`] 是什么(读实现的结论,不是注释的措辞) +//! +//! `MotionScale` 只有一个 `factor`,`ms(token) = token.ms() * factor`(见 `spring` 模块的 +//! `motion_scale_scales_or_kills_duration`),所以 `HALF` 的语义是**时长减半**(同一段位移在 +//! 一半的时间里走完 ⇒ 观感上**更快**,不是"半速")。`spring_ms` 同理:收敛时间减半。 +//! +//! `spring.rs` 里 `HALF` 的行内注释写作「半速」,那是**措辞错误**(MOTION1 核清):本行文档是 +//! 事实口径,改 `spring.rs` 的注释不在 M256 的 scope 内,已作为 finding 报给 tower。 mod spring; @@ -19,6 +28,17 @@ pub use spring::{ SPRING_STANDARD_SPATIAL_SLOW, }; +/// 模态(对话框 / 危险确认)**进场**的起始缩放:`0.96 → 1.0`。 +/// +/// 出处:`DevDocs/PlanDoc/14-motion-and-animation.md` §3 的 A3 行——「遮罩淡入 + 卡片 +/// scale 0.96→1(**不弹跳**)」。**退场走同一条线反向**(1.0 → 0.96),所以只需要这一个常量。 +/// +/// 为什么不是 0.8 或 0.9:0.9 以下的缩放会让卡片里的正文在动画期间小到读不清(对话框是 +/// 「必须读」的界面,不是装饰);0.96 是 M3 里 dialog 的形变幅度口径,也让「它在放大」这件事 +/// 恰好可察觉而不夸张。**不弹跳**:曲线只取单调段(`*Decelerate` / `*Accelerate`), +/// 弹簧类曲线不用于模态。 +pub const DIALOG_ENTER_SCALE: f32 = 0.96; + /// 动效时长档位(M3 动效令牌,单位毫秒)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DurationToken {