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

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

No files matched your search

+108 -13
View File
@@ -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
);
}
}
}
}