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

@@ -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)
}
}