feat(app-iced): 页面切换按导航深度定向 + 模态缩放淡入(M256 MOTION1)
- transition.rs:唯一一张动效表。同级换页走水平(±12 / 旧页 ∓8)、进深层走纵向
(新页 +16 上浮 / 旧页 -8),进腿 medium2/medium1(300/250),退腿 short4→medium1(更短),
进用 Standard、退用 StandardAccelerate。幅度全部取自 Space 令牌。
- pump.rs:Slot 三互斥槽 → Kind(Boot/Peer{forward}/Push/Pop)+ 两腿轨道;新增模态状态机
(Opening/Open/Closing,展开后不占帧,退场走完交回一次 take_modal_closed)。
- components/motion/zoom.rs:绕中心缩放的自绘控件(renderer.with_transformation,
tiny-skia 与 wgpu 都实现了,文字也真的跟着缩放);scale==1.0 时不套变换 ⇒ 出图基线不变。
- pages/mod.rs:page_with_settings_transition(ui, current, previous, …) 叠
stack![opaque(旧页@退场位移), 新页@进场位移];旧函数只剩一句委托(blast radius 不变)。
- App.previous_view:换页时用 std::mem::replace **移动**旧模型(冻结快照),过渡结束当拍
置 None(不随换页次数增长)。
- 模态:逻辑状态当拍关(state.dialogs 立刻空),画面多留 150ms;退场期间弹窗消息被闸门吞掉。
- platform/motion.rs:系统「减少动效」三态探针(Windows=SystemParametersInfoW,
Linux/macOS=子进程+300ms 超时),OnceLock 缓存;Unknown **不降级**(探针失败不静默关动效)。
- MotionScale::HALF 语义核清并写进 design 文档:是**时长减半**(更快),不是半速。
This commit is contained in:
1 parent
5a182e80de
commit
b1c09c4c91
21 files changed
+2168
-658
No files matched your search
@@ -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 里
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<pages::Model>,
|
||||
/// 视图模型是否过期(P2-1):只有 `apply` / `navigate` / resize / 主题这类**真的改了状态**
|
||||
/// 的事情才置位,`advance()` 每拍只在置位时重建模型(否则 30 Hz × N 实例的白克隆)。
|
||||
pub(super) view_dirty: bool,
|
||||
|
||||
@@ -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::<window::Id>,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<crate::app::Message> {
|
||||
// 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<crate::ap
|
||||
// 崩溃卡片的「导出诊断包」也拿不走它——否则就是一次静默删世界。
|
||||
let current = app.state.dialogs.current().map(|request| request.id);
|
||||
if let Some(dir) = current.and_then(|id| app.state.saves.take_confirm(id)) {
|
||||
app.close_dialog();
|
||||
// M256:走统一出口(逻辑当拍关,画面多留 150ms 退场)。
|
||||
// 目标已经在上一行核销 ⇒ 退场期间再点一次主按钮不会删第二个世界
|
||||
// (那一拍的消息还会被 `modal_closing` 那道闸门吞掉)。
|
||||
close(app);
|
||||
if let Some(instance) = app.selected_instance_id() {
|
||||
app.state
|
||||
.saves
|
||||
@@ -31,12 +57,11 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
|
||||
unlucky_presenter::dto::SavesCommand::DeleteWorld { id: instance, dir },
|
||||
)));
|
||||
}
|
||||
app.refresh_view();
|
||||
return Task::none();
|
||||
}
|
||||
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
|
||||
let Some(card) = app.dialog_card.clone() else {
|
||||
app.close_dialog();
|
||||
close(app);
|
||||
return Task::none();
|
||||
};
|
||||
let done = export::export(&card.report, app.lang());
|
||||
@@ -64,10 +89,9 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
|
||||
if let Some(current) = app.state.dialogs.current().map(|request| request.id) {
|
||||
app.state.saves.close_confirm(current);
|
||||
}
|
||||
app.close_dialog();
|
||||
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
|
||||
// (r1 复审 P3-1)。
|
||||
app.refresh_view();
|
||||
// M256:统一出口(有动效时先退场)。「立即重画」这条要求由 `close` 里的
|
||||
// `refresh_view` 满足:退场的第一帧必须在这一拍画出来。
|
||||
close(app);
|
||||
Task::none()
|
||||
}
|
||||
DialogMessage::Toggle => {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<u64> {
|
||||
#[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(), "退场结束后帧泵必须停");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<f64>) {
|
||||
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<u64>, 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<Kind> {
|
||||
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 一换就重建整棵子树。若只在动画期间才套,
|
||||
|
||||
@@ -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<f64>,
|
||||
now_ms: f64,
|
||||
pending_ms: f64,
|
||||
tracks: BTreeMap<Slot, Track>,
|
||||
page: Option<PageTrack>,
|
||||
modal: Modal,
|
||||
/// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。
|
||||
close_pending: bool,
|
||||
screen: Option<u64>,
|
||||
detail: Option<String>,
|
||||
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<Kind> {
|
||||
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<u64>, 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<Slot> = 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 {
|
||||
|
||||
@@ -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(), "钉住的相位不会自然结束(取证专用)");
|
||||
}
|
||||
@@ -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<PageFrame> {
|
||||
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);
|
||||
}
|
||||
@@ -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(), "钉住的相位不会自然结束(取证专用)");
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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<Element<'a, Message, Theme, Renderer>>,
|
||||
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<Message, Theme, Renderer> Scaled<'_, Message, Theme, Renderer> {
|
||||
/// 这条变换要不要真的套(`1.0` 时什么都不做)。
|
||||
fn transformation(&self, bounds: Rectangle) -> Option<Transformation> {
|
||||
// 0.1% 以下视为到位:亚像素缩放只会让文字发虚,没有观感收益。
|
||||
if (self.scale - 1.0).abs() < 1.0e-3 {
|
||||
return None;
|
||||
}
|
||||
Some(about_center(bounds, self.scale.max(0.01)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
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<Tree> {
|
||||
self.content.as_widget().children()
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
self.content.as_widget().diff(tree);
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
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<iced::overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.overlay(tree, layout, renderer, viewport, translation)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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`:判据是用户策略 × 宿主平台 × 渲染档。
|
||||
|
||||
@@ -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<ReduceMotion> = 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<bool> {
|
||||
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<String> {
|
||||
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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user