From b61f342431530ae1ec78fb9ddd5383e352110951 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 18:32:19 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(motion2):=20=E5=8F=B3=E6=A0=97?= =?UTF-8?q?=E6=8A=98=E5=8F=A0=E8=BF=87=E6=B8=A1=20+=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=B1=82=20hover/pressed=20=E8=BF=87=E6=B8=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A2 侧边栏(右栗 AI 栏)折叠/展开: - 帧泵第三条轨道(pump/rail):展开 medium1 250ms + Emphasized、收起 short4 200ms + EmphasizedAccelerate;拆出档走 snap(不做过渡);MotionScale::NONE 下不插轨道 - 宽度在 metrics::rail_width 逐帧插值;面板内容按目标宽排版 + 按动画宽裁切 (不重排 / 不溢出),宽度回到 48 时换成图标条(图标贴右端,位置不跳) - pages/agent/mod.rs 的展开档面板纯搬移到 panel.rs(371 → 289 行) A8 状态层过渡: - interaction/layer:令牌化的进度时钟(short1 50ms),NONE 当帧落终值 - interaction/hover:取样控件把起跑搬到 Widget::update(早于订阅表求值) - 按钮五式 + IconButton + 导航项药丸统一走 interaction::layer_color (没有取样控件时逐位退回旧口径,出图基线不变) - components/motion/mod.rs 的 needs_frames / advance 各加一句委托, 帧订阅仍然只有一处判断点(app/subscription.rs 未改) --- .../src/components/buttons/assemble.rs | 36 +-- crates/app-iced/src/components/buttons/mod.rs | 12 +- .../src/components/interaction/hover.rs | 226 ++++++++++++++++ .../src/components/interaction/layer.rs | 242 ++++++++++++++++++ .../src/components/interaction/layer/tests.rs | 145 +++++++++++ .../src/components/interaction/mod.rs | 105 +++++++- crates/app-iced/src/components/motion/clip.rs | 202 +++++++++++++++ crates/app-iced/src/components/motion/mod.rs | 53 +++- .../src/components/motion/pump/mod.rs | 21 +- .../src/components/motion/pump/rail.rs | 104 ++++++++ .../src/components/motion/tests/mod.rs | 1 + .../src/components/motion/tests/rail.rs | 174 +++++++++++++ .../src/components/motion/transition.rs | 27 ++ .../src/components/navigation/drawer/item.rs | 51 ++-- .../src/components/navigation/drawer/tests.rs | 21 +- crates/app-iced/src/pages/agent/metrics.rs | 48 +++- crates/app-iced/src/pages/agent/mod.rs | 104 +------- crates/app-iced/src/pages/agent/panel.rs | 127 +++++++++ crates/app-iced/src/pages/agent/state.rs | 34 ++- crates/app-iced/src/pages/agent/tests.rs | 4 +- .../src/pages/agent/tests/collapse.rs | 237 +++++++++++++++++ crates/app-iced/src/pages/agent/tests/drag.rs | 8 +- 22 files changed, 1837 insertions(+), 145 deletions(-) create mode 100644 crates/app-iced/src/components/interaction/hover.rs create mode 100644 crates/app-iced/src/components/interaction/layer.rs create mode 100644 crates/app-iced/src/components/interaction/layer/tests.rs create mode 100644 crates/app-iced/src/components/motion/clip.rs create mode 100644 crates/app-iced/src/components/motion/pump/rail.rs create mode 100644 crates/app-iced/src/components/motion/tests/rail.rs create mode 100644 crates/app-iced/src/pages/agent/panel.rs create mode 100644 crates/app-iced/src/pages/agent/tests/collapse.rs diff --git a/crates/app-iced/src/components/buttons/assemble.rs b/crates/app-iced/src/components/buttons/assemble.rs index 411b902..a8be787 100644 --- a/crates/app-iced/src/components/buttons/assemble.rs +++ b/crates/app-iced/src/components/buttons/assemble.rs @@ -59,6 +59,7 @@ pub(super) fn assemble<'a, M: Clone + 'a>( let geometry = Geometry::sized(ui, variant, spec.leading.is_some(), spec.size); let (fill, content, _border) = variant.roles(); let forced = spec.forced(); + let enabled = spec.on_press.is_some(); // 按钮家族没有浮层级高程:L1(Elevated)只用容器色调差表达(rubric §7 不画阴影) debug_assert!( @@ -125,9 +126,17 @@ pub(super) fn assemble<'a, M: Clone + 'a>( .padding(geometry.padding) .style(style); - match spec.on_press { + let element: Element<'a, M> = match spec.on_press { Some(message) => element.on_press(message).into(), None => element.into(), + }; + + // A8:**运行期态**才套状态取样(gallery 的 `state(..)` 强制态是取证,不许有过渡); + // 禁用的按钮不跟踪(它没有状态层可动,白占帧)。 + if spec.state.is_some() { + element + } else { + interaction::tracked(element, enabled) } } @@ -201,13 +210,13 @@ pub(super) fn style_closure( (spec.fill.map(|_| container), content) } None => { - // 有填充:状态层叠在**容器角色**上;透明容器:叠在表面色上(否则 hover 看不见) + // 有填充:状态层叠在**容器角色**上;透明容器:叠在脚下那一层(`base`)上 + // (否则 hover 看不见)。两条都经 `layer_color`:A8 起它带时间轴, + // 没有取样控件时**逐位**退回 `overlay_color`。 let background = match spec.fill { - Some(role) => match state.layer() { - Some(_) => interaction::overlay_color(ui, state, spec.content, role) - .or(Some(ui.color(role))), - None => Some(ui.color(role)), - }, + // 有填充:静息就是容器色(不是「没有背景」),过渡中才换成插值色。 + Some(role) => interaction::layer_color(ui, state, spec.content, role) + .or(Some(ui.color(role))), None => transparent_state_color(ui, spec.base, state, spec.content), }; (background, ui.color(spec.content)) @@ -245,19 +254,16 @@ pub(super) fn style_closure( } } -/// 透明容器的状态层:`Outlined` / `Text` / `StandardIcon` 没有填充,可见反馈只能来自 -/// 叠在**内容角色**上的状态层——把它们当作「叠在 `base`(脚下那一层)上」求值,得到不透明实色。 +/// 透明容器的状态层(`Outlined` / `Text` / `StandardIcon`):可见反馈只能来自叠在 +/// **脚下那一层**([`Base`])上的状态层。 /// -/// 之所以单独一个函数:这是 M3 里唯一「容器不存在但状态层存在」的情形, -/// 三个变体共用一条口径,免得各自发挥。 -/// -/// M234 复审 P2-3:`base` 之前写死 `Role::Surface`,卡片里的 Text/Outlined 按钮抬起时 -/// 状态层叠在画布色上(与禁用支的口径不一致)。 +/// M257 起它只是 [`interaction::layer_color`] 的一次调用(两者的区别只在「用哪个角色当底」), +/// 保留这个具名入口是为了让「透明容器的状态层叠在 base 上」这条口径在源码里仍然只有一处。 pub(super) fn transparent_state_color( ui: &Ui, base: Base, state: Interaction, content: Role, ) -> Option { - interaction::overlay_color(ui, state, content, base.role()) + interaction::layer_color(ui, state, content, base.role()) } diff --git a/crates/app-iced/src/components/buttons/mod.rs b/crates/app-iced/src/components/buttons/mod.rs index c8589cd..15b5360 100644 --- a/crates/app-iced/src/components/buttons/mod.rs +++ b/crates/app-iced/src/components/buttons/mod.rs @@ -54,7 +54,7 @@ use unlucky_design::shape::ShapeScale; use crate::icons; use crate::theme::Ui; -use super::interaction::{Base, Interaction}; +use super::interaction::{self, Base, Interaction}; use super::layout; mod assemble; @@ -359,6 +359,7 @@ pub fn icon_on<'a, M: Clone + 'a>( let base = base.into(); let geometry = IconGeometry::of(ui); let (fill, content, _) = variant.roles(); + let enabled = on_press.is_some(); let forced = state.or(if on_press.is_some() { None } else { @@ -383,8 +384,15 @@ pub fn icon_on<'a, M: Clone + 'a>( .padding(Padding::default()) .style(style); - match on_press { + let element: Element<'a, M> = match on_press { Some(message) => element.on_press(message).into(), None => element.into(), + }; + + // A8:与文字按钮同一条口径——强制态不套取样控件,禁用不跟踪。 + if state.is_some() { + element + } else { + interaction::tracked(element, enabled) } } diff --git a/crates/app-iced/src/components/interaction/hover.rs b/crates/app-iced/src/components/interaction/hover.rs new file mode 100644 index 0000000..4a08419 --- /dev/null +++ b/crates/app-iced/src/components/interaction/hover.rs @@ -0,0 +1,226 @@ +//! **hover / pressed 的取样控件**(M257 / A8):把「状态变了」这件事搬到 `update` 侧。 +//! +//! ## 为什么需要它(一句话) +//! +//! `button` 的 hover 状态只在 `style` 闭包里可见,而闭包在 **`draw`** 里跑——那已经在订阅表 +//! 求值之后了。若在 draw 侧起跑过渡,「鼠标停住不动」的整段时间里**一帧都不会来**,状态层会永远 +//! 停在 `progress = 0`(看不见)。本控件用 `Widget::update`(早于订阅表求值)把目标态写进 +//! [`super::layer`],于是同一拍 `needs_frames()` 就变真。 +//! +//! ## 它做什么 / 不做什么 +//! +//! | 做 | 不做 | +//! |---|---| +//! | 用 [`Tracker`] 把鼠标事件折成五态 | **不**消费事件(`shell` 的捕获位不动,按钮照常收到点击) | +//! | 改自己 `Tree` 状态里的 [`Layer`] 目标 | **不**改颜色、不改布局(颜色仍由 `style` 闭包求值) | +//! | `draw` 时把自己的层压到栈顶([`layer::with_current`]) | **不**包装禁用控件(没有状态层可动) | +//! +//! ## 命中测试用的是**布局矩形** +//! +//! `cursor.is_over(layout.bounds())`:与 `button` 自己的判定同源(它也读布局矩形)。触摸板的 +//! 「按下后拖出再拖回」在 `Tracker` 里按 `pressed` 跟随 `over` 折,与 iced 的 `button` 语义一致。 +//! +//! ## 抓不住什么 +//! +//! 覆盖在按钮**之上**的另一层(模态遮罩 / tooltip)不算:本控件只看自己的矩形,而 `button` +//! 的 hover 会被上层遮住的行为由 iced 的 overlay 决定。两者在这类遮挡下可能短暂不一致 +//! (按钮自己认为被 hover、本控件也认为被 hover),但既然按钮被遮住就点不到,观感无差。 + +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, Vector}; + +use super::layer::{self, Layer}; +use super::Tracker; +use crate::app::components::motion; + +/// 包一层「状态取样」:`enabled == false` 时**完全不跟踪**(禁用控件没有状态层)。 +pub fn tracked<'a, Message, Theme, Renderer>( + content: impl Into>, + enabled: bool, +) -> Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a, + Renderer: iced::advanced::Renderer + 'a, +{ + Element::new(Tracked { + content: content.into(), + enabled, + }) +} + +/// 本控件自己的 `Tree` 状态:状态机 + 它那一条过渡。 +#[derive(Debug)] +struct State { + tracker: Tracker, + layer: Layer, +} + +impl Default for State { + fn default() -> Self { + Self { + tracker: Tracker::new(true), + layer: Layer::new(), + } + } +} + +struct Tracked<'a, Message, Theme, Renderer> { + content: Element<'a, Message, Theme, Renderer>, + enabled: bool, +} + +impl Widget + for Tracked<'_, Message, Theme, Renderer> +where + Renderer: renderer::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn children(&self) -> Vec { + vec![Tree::new(&self.content)] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(std::slice::from_ref(&self.content)); + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn size_hint(&self) -> Size { + self.content.as_widget().size_hint() + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + + 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( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + + if !self.enabled { + return; + } + + let over = cursor.is_over(layout.bounds()); + let state = tree.state.downcast_mut::(); + if !state.tracker.event(event, over) { + return; + } + // 起跑**在这里**(update 侧):同一拍 `needs_frames()` 就能看见它。 + if state.layer.retarget(state.tracker.state(), motion::scale()) { + shell.request_redraw(); + } + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let state = tree.state.downcast_ref::(); + let content = self.content.as_widget(); + // 状态颜色由 `style` 闭包求值,而闭包只拿得到 `&Theme` —— 所以「这一帧处于哪条过渡」 + // 只能经开画栈传给它们(见 `layer::with_current`)。 + layer::with_current(state.layer, || { + content.draw( + &tree.children[0], + 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(&mut tree.children[0], layout, renderer, operation); + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + + fn overlay<'a>( + &'a mut self, + tree: &'a mut Tree, + layout: Layout<'a>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } +} diff --git a/crates/app-iced/src/components/interaction/layer.rs b/crates/app-iced/src/components/interaction/layer.rs new file mode 100644 index 0000000..fee3d82 --- /dev/null +++ b/crates/app-iced/src/components/interaction/layer.rs @@ -0,0 +1,242 @@ +//! **状态层过渡**(M257 / A8):hover / pressed 的颜色不是硬切,而是按令牌时长插值。 +//! +//! ## 这个模块解决什么 +//! +//! M3 的状态层不是「另一种灰」,而是**内容色 + 固定 α 叠在容器上**(见 [`super`] 的文档表)。 +//! 到 M256 为止,那张表的求值是**瞬时**的:`button::Status` 一变,背景色当帧跳到 8% / 10%。 +//! 本模块给这件事加一条**时间轴**,判据是「泵被驱动了几帧 + 端点精确 + 中间态单调」。 +//! +//! ## 两条时间轴,一个订阅点 +//! +//! 控件状态(hover / pressed)活在各自的 iced `Tree` 里,所以过渡的 `from/to` 也放在那里 +//! (见 [`super::hover`] 的包装控件)。本模块只持一条**进程内时钟**与一张**到期表**: +//! +//! | 谁 | 存什么 | 何时推进 | +//! |---|---|---| +//! | 包装控件(`Tree` 状态) | `from` / `to` / 起跑时刻 | `Widget::update` 里改目标 | +//! | 本模块(thread-local) | `NOW` + 到期时刻表 | `motion::advance` 每帧调 [`advance`] | +//! +//! [`is_animating`] 被 `motion::needs_frames()` 问(**唯一的订阅判断点**),所以状态层过渡与 +//! 页面 / 模态 / 折叠共用同一条 `window::frames()` 订阅,不另开订阅。 +//! +//! ## 起跑必须在 update 侧(M256 的 P1-1 教训) +//! +//! `button` 的 hover 状态只在 `draw` 侧(`style` 闭包)可见,而 `draw` 发生在订阅表求值**之后** +//! ⇒ 若在 draw 侧起跑,「鼠标停住不动」的那段会卡在 `progress = 0`(状态层永远看不见)。 +//! 所以目标态的**改变**全部发生在 [`super::hover`] 包装控件的 `Widget::update` 里——那里早于 +//! 订阅表求值,帧因此当拍就订阅上。 +//! +//! ## `MotionScale::NONE` = 严格等于终值 +//! +//! `ms = 0` ⇒ [`Layer::progress`] 恒为 `1.0`、不登记到期 ⇒ [`is_animating`] 不会被它点亮 +//! ⇒ **一帧都不订阅**。这不是「很快走完」,是「根本不存在中间态」。 + +use std::cell::{Cell, RefCell}; + +use iced::Color; + +use unlucky_design::motion::{DurationToken, Easing, MotionScale}; + +use super::Interaction; +use crate::app::components::motion::{Leg, MAX_STEP_MS}; + +/// 进(状态层变强:静息→悬停、悬停→按下):`short1` 50ms + 标准减速。 +/// +/// 出处:`14-motion-and-animation.md` §3 的 A8 行点名 `Short1`(50ms)。50ms 在 62.5Hz 的采样下 +/// 是 3 帧——数量少但**不是硬切**(3 个可判别的中间值),而且 hover 本来就该「快而轻」。 +pub const ENTER: Leg = Leg { + duration: DurationToken::Short1, + easing: Easing::StandardDecelerate, +}; + +/// 出(状态层变弱:按下→悬停、悬停→静息):同档时长 + 标准加速。 +/// +/// 为什么进出同档而不是「出更短」:50ms 已经是最短档,再短就没有中间态可采样了(回到硬切)。 +/// 两条曲线的差别(减速 vs 加速)承担了「进略长于出」的观感差。 +pub const EXIT: Leg = Leg { + duration: DurationToken::Short1, + easing: Easing::StandardAccelerate, +}; + +thread_local! { + /// 进程内时钟(毫秒)。只由 [`advance`] 推。 + #[allow(clippy::missing_const_for_thread_local)] + static NOW: Cell = const { Cell::new(0.0) }; + /// 还没到期的过渡(时刻表)。到期的**当场剔除**,否则空闲也一直要帧。 + #[allow(clippy::missing_const_for_thread_local)] + static DEADLINES: RefCell> = const { RefCell::new(Vec::new()) }; + /// 正在画的控件栈([`with_current`] 压、包装控件的 `draw` 用)。 + #[allow(clippy::missing_const_for_thread_local)] + static STACK: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// 当前时刻(毫秒,进程内单调)。 +pub fn now_ms() -> f32 { + NOW.with(Cell::get) +} + +/// 推进时钟(`motion::advance` 每帧调一次)。 +/// +/// 单步上限取 [`MAX_STEP_MS`](与帧泵同一条):空闲醒来 / 掉帧时 stale 的帧基准不许把一段 +/// 50ms 的过渡一次性抹完(那会让「中间态」在真机上消失,而在单测里照样绿)。 +pub fn advance(dt_ms: f64) { + if !dt_ms.is_finite() || dt_ms <= 0.0 { + return; + } + let step = dt_ms.min(MAX_STEP_MS) as f32; + NOW.with(|now| now.set(now.get() + step)); + let now = now_ms(); + DEADLINES.with(|list| list.borrow_mut().retain(|deadline| *deadline > now)); +} + +/// 现在有没有状态层过渡在跑(`motion::needs_frames` 的一个分支)。 +pub fn is_animating() -> bool { + let now = now_ms(); + DEADLINES.with(|list| { + let mut list = list.borrow_mut(); + list.retain(|deadline| *deadline > now); + !list.is_empty() + }) +} + +/// 一次状态层过渡(值放在控件的 `Tree` 状态里,**不**在这里)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Layer { + /// 起点态(颜色从这里出发)。 + from: Interaction, + /// 目标态(颜色走到这里)。 + to: Interaction, + /// 起跑时刻。 + at: f32, + /// 这一腿的时长(已按 `MotionScale` 缩放;`0` = 关着动效)。 + ms: f32, + /// 这一腿的曲线。 + easing: Easing, +} + +impl Layer { + /// 静息层(还没发生过任何过渡):进度严格 `1.0`,等价于今天的行为。 + pub const REST: Self = Self { + from: Interaction::Enabled, + to: Interaction::Enabled, + at: 0.0, + ms: 0.0, + easing: EXIT.easing, + }; + + /// 出厂的层(与 [`Layer::REST`] 同值;`Default` 便于 `Tree` 状态构造)。 + pub fn new() -> Self { + Self::REST + } + + /// 换目标;返回 `true` = 起跑了新的一条腿(调用方该请求重绘)。 + /// + /// `scale` 是**唯一**的降级口:`NONE` 时 `ms = 0`(进度恒为终值,且不登记到期 ⇒ 零帧)。 + /// 中途反向时 `from` 取**上一个目标**(不是当前插值色):50ms 的腿上最多差一个 α 档, + /// 肉眼不可见;换来的是「不用每帧写回状态」(写回会让过渡自我拖动)。 + pub fn retarget(&mut self, state: Interaction, scale: MotionScale) -> bool { + if state == self.to { + return false; + } + let stronger = rank(state) >= rank(self.to); + let leg = if stronger { ENTER } else { EXIT }; + self.from = self.to; + self.to = state; + self.at = now_ms(); + self.ms = scale.ms(leg.duration); + self.easing = leg.easing; + if self.ms > 0.0 { + // 多留一帧:到期后还至少要再画一次,屏幕上的**最后一帧才是终值**(否则停在 0.97)。 + DEADLINES.with(|list| list.borrow_mut().push(self.at + self.ms + FRAME_GRACE_MS)); + } + true + } + + /// 这一帧的进度:`0` = 完全 `from`,`1` = 完全 `to`(**端点逐位精确**)。 + pub fn progress(&self) -> f32 { + if self.ms <= 0.0 { + return 1.0; + } + let t = (now_ms() - self.at) / self.ms; + if t >= 1.0 { + return 1.0; + } + if t <= 0.0 { + return 0.0; + } + self.easing.sample(t).clamp(0.0, 1.0) + } +} + +impl Default for Layer { + fn default() -> Self { + Self::new() + } +} + +/// 态的「强 → 弱」次序(决定用 [`ENTER`] 还是 [`EXIT`] 那条腿)。 +const fn rank(state: Interaction) -> u8 { + match state { + Interaction::Disabled => 0, + Interaction::Enabled => 1, + Interaction::Hovered => 2, + Interaction::Focused | Interaction::Pressed => 3, + } +} + +/// 到期之后多等一帧(≈16ms):保证「最后一帧画的是终值」。 +const FRAME_GRACE_MS: f32 = 16.0; + +/// 画一棵子树时把它的层压到栈顶(包装控件的 `draw` 里调;`draw` 结束自动弹栈)。 +pub fn with_current(layer: Layer, draw: impl FnOnce() -> R) -> R { + STACK.with(|stack| stack.borrow_mut().push(layer)); + let out = draw(); + STACK.with(|stack| { + stack.borrow_mut().pop(); + }); + out +} + +/// 当前栈顶的过渡;没有包装控件 = `None`(= 调用方按今天的行为求值)。 +pub fn current() -> Option<(Interaction, Interaction, f32)> { + STACK.with(|stack| { + stack + .borrow() + .last() + .map(|layer| (layer.from, layer.to, layer.progress())) + }) +} + +/// 两个不透明色的插值:`p <= 0` 逐位等于 `from`,`p >= 1` 逐位等于 `to`。 +/// +/// **不产生 α**:两端都是不透明色(D27:GPU/CPU 两档逐像素一致),中间值也是不透明色。 +pub fn blend(from: Color, to: Color, p: f32) -> Color { + if p <= 0.0 { + return from; + } + if p >= 1.0 { + return to; + } + Color { + r: from.r + (to.r - from.r) * p, + g: from.g + (to.g - from.g) * p, + b: from.b + (to.b - from.b) * p, + a: from.a + (to.a - from.a) * p, + } +} + +#[cfg(test)] +pub fn reset() { + NOW.with(|now| now.set(0.0)); + DEADLINES.with(|list| list.borrow_mut().clear()); + STACK.with(|stack| stack.borrow_mut().clear()); +} + +/// 测试专用:按给定毫秒推进(`advance` 读 dt,单测不能等真实时间)。 +#[cfg(test)] +pub fn advance_by(ms: f64) { + advance(ms); +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/components/interaction/layer/tests.rs b/crates/app-iced/src/components/interaction/layer/tests.rs new file mode 100644 index 0000000..fa6cef4 --- /dev/null +++ b/crates/app-iced/src/components/interaction/layer/tests.rs @@ -0,0 +1,145 @@ +//! 状态层过渡的判据(M257 / A8):**帧数 + 端点精确 + 中间态严格单调**,外加两条硬契约。 +//! +//! 两条硬契约(塔在 M257 briefing 里点名的): +//! +//! 1. `MotionScale::NONE` 下进度**严格**等于终值(独立用例,不靠别的用例顺带覆盖); +//! 2. 「鼠标停住不动」的那段时间里过渡必须**自己走完并停在终值**——本文件用「只推时钟、不喂事件」 +//! 的形态抓「停在 progress 0」这个具体故障。 + +use super::*; +use crate::app::components::motion::FRAME_MS; +use unlucky_design::motion::MotionScale; + +const STANDARD: MotionScale = MotionScale::STANDARD; +const NONE: MotionScale = MotionScale::NONE; + +/// 一条全新的过渡(每个用例开头都从干净时钟起跑)。 +fn fresh() -> Layer { + reset(); + Layer::new() +} + +/// **悬停:50ms 内走到 1.0,端点精确,中间严格单调——且**不靠任何事件**。** +/// +/// 这条用例直接对着塔的硬判据写:*「鼠标停住不动的整段时间里,过渡必须走完并停在终值」*。 +/// 实现里唯一驱动它的是 [`advance`](`motion::advance` 每帧调),所以「停在 progress 0」 +/// (= 起跑写在 draw 侧、没有帧订阅)在这里会立刻红。 +/// +/// 抓得住:①把 `short1`(50) 改成 0(进度当帧就是 1.0 ⇒ 「第一帧必须是 0」的断言红); +/// ②把 `advance` 的时钟加去掉(进度永远 0 ⇒ 终值断言红);③把终止条件删掉(`is_animating` 恒真)。 +/// 抓不住:真实帧率(那由 `window::frames()` 决定)、hover 的观感。 +#[test] +fn hovering_fades_in_over_the_token_duration_without_any_further_events() { + let mut layer = fresh(); + assert_eq!(layer.progress(), 1.0, "静息层:进度是终值(= 今天的行为)"); + + assert!( + layer.retarget(Interaction::Hovered, STANDARD), + "目标变了必须起跑" + ); + assert_eq!(layer.progress(), 0.0, "第一帧必须还在静息端(端点精确)"); + assert!(is_animating(), "起跑之后同一拍就该要帧"); + + let mut values = vec![layer.progress()]; + let mut frames = 0; + while is_animating() { + advance_by(FRAME_MS); + values.push(layer.progress()); + frames += 1; + assert!(frames < 60, "过渡没停,疑似终止条件失效"); + } + + // 50ms / 16ms ≈ 3~4 帧(含到期后多要的那一帧)。 + assert!( + (3..=5).contains(&frames), + "50ms 应落在 3~5 帧,实测 {frames} 帧" + ); + assert_eq!(values.last().copied(), Some(1.0), "终值必须**严格**是 1.0"); + for pair in values.windows(2) { + assert!( + pair[1] >= pair[0], + "淡入必须单调不减:{:?} → {:?}", + pair[0], + pair[1] + ); + } + let distinct: Vec<_> = values.iter().filter(|v| **v > 0.0 && **v < 1.0).collect(); + assert!( + !distinct.is_empty(), + "必须有可判别的中间态(否则就是硬切):{values:?}" + ); + assert!(!is_animating(), "走完必须退订帧"); + assert_eq!(layer.progress(), 1.0, "没有轨道时进度恒为终值"); +} + +/// **`MotionScale::NONE` = 严格等于终值**(独立用例;不靠上一条顺带覆盖)。 +/// +/// 抓得住:把 `retarget` 里的 `NONE ⇒ ms = 0` 删掉(那会跑一遍 50ms ⇒ 首帧进度是 0.0,本用例红); +/// 或把 NONE 实现成「很快走完」(同样红:本用例断言的是**当帧就到位**,不是「几帧后到位」)。 +/// 抓不住:`NONE` 之外的档位(那是别的用例的事)。 +#[test] +fn motion_scale_none_has_no_intermediate_state_at_all() { + let mut layer = fresh(); + assert!(layer.retarget(Interaction::Hovered, NONE)); + assert_eq!(layer.progress(), 1.0, "NONE 下当帧就是终值(严格等于 1)"); + assert!(!is_animating(), "NONE 下不许要帧(不是「很快走完」)"); + + assert!(layer.retarget(Interaction::Enabled, NONE)); + assert_eq!(layer.progress(), 1.0, "移出同样是当帧终值"); + assert!(!is_animating()); +} + +/// 同态重复不改目标(否则每次 draw 都会重启一条腿 ⇒ 过渡被无限拉长)。 +/// +/// 抓得住:把 `retarget` 的 `state == self.to` 早退删掉。 +#[test] +fn retargeting_the_same_state_is_a_no_op() { + let mut layer = fresh(); + assert!(layer.retarget(Interaction::Hovered, STANDARD)); + advance_by(FRAME_MS); + let mid = layer.progress(); + assert!(!layer.retarget(Interaction::Hovered, STANDARD)); + assert_eq!(layer.progress(), mid, "同一目标的重复报告不许重置进度"); +} + +/// 按下比悬停更「强」:从悬停转按下走 `ENTER` 那条腿(起跑端是**上一个目标**,不是静息)。 +/// +/// 抓得住:把 `retarget` 里的 `self.from = self.to` 写成 `self.from = Interaction::Enabled` +/// (按下瞬间会先掉回静息色一帧——状态层闪一下),本用例断言 `from` 是 `Hovered`。 +#[test] +fn pressing_starts_from_the_previous_target_not_from_rest() { + let mut layer = fresh(); + layer.retarget(Interaction::Hovered, STANDARD); + // 不推进:模拟「悬停刚开始就按下」(鼠标点得比淡入还快)。 + assert!(layer.retarget(Interaction::Pressed, STANDARD)); + let (from, to, p) = current_from(&layer); + assert_eq!(from, Interaction::Hovered, "起跑端必须是上一个目标"); + assert_eq!(to, Interaction::Pressed); + assert_eq!(p, 0.0, "新的一条腿从 0 开始"); +} + +/// 把层的两端读出来(`Layer` 的字段是私有的,这里经 [`with_current`] 走公开路径)。 +fn current_from(layer: &Layer) -> (Interaction, Interaction, f32) { + with_current(*layer, || current().expect("刚压进去的层必须在栈顶")) +} + +/// 两个不透明色的插值:**端点逐位相等**,中点严格落在两者之间。 +/// +/// 抓得住:①把端点写成「差不多」(`p == 0` 时返回插值结果 0.999…)——逐位断言红; +/// ②引入 α(`a` 不再是 1.0)——D27 的「两个后端逐像素一致」就靠这条守着。 +#[test] +fn blend_hits_both_endpoints_bit_for_bit() { + let from = iced::Color::from_rgb(0.1, 0.2, 0.3); + let to = iced::Color::from_rgb(0.7, 0.8, 0.9); + + assert_eq!(blend(from, to, 0.0), from); + assert_eq!(blend(from, to, 1.0), to); + + for p in [0.25, 0.5, 0.75] { + let mid = blend(from, to, p); + assert!((0.1..=0.7).contains(&mid.r), "r 必须落在两端之间"); + assert!((0.2..=0.8).contains(&mid.g)); + assert!((0.3..=0.9).contains(&mid.b)); + assert_eq!(mid.a, 1.0, "中间态也不许引入半透明(D27)"); + } +} diff --git a/crates/app-iced/src/components/interaction/mod.rs b/crates/app-iced/src/components/interaction/mod.rs index 64487ac..b90ba65 100644 --- a/crates/app-iced/src/components/interaction/mod.rs +++ b/crates/app-iced/src/components/interaction/mod.rs @@ -64,11 +64,14 @@ use unlucky_design::color::{Role, StateLayer}; use crate::theme::Ui; +mod hover; mod tracker; -// 本 mission 没有自绘控件,所以这条再导出在 crate 内暂时无人使用; -// 它是给四条家族线(Slider / Switch 轨道要自绘)的公共入口,路径与拆分前一致。 -#[allow(unused_imports)] +/// 状态层**过渡的时间轴**(M257 / A8):hover / pressed 按令牌时长插值,不再是硬切。 +pub mod layer; + +// 事件 → 态的**状态机**在 [`tracker`]:M257 起它有真消费者——[`hover`] 的取样控件。 +pub use hover::tracked; pub use tracker::Tracker; // 本模块会被四条家族线各扩一次:加东西前先看行数余额(单文件 ≤400,AGENTS.md §3-1)。 @@ -151,6 +154,40 @@ pub fn overlay_color(ui: &Ui, state: Interaction, content: Role, container: Role .map(|layer| ui.state_over(layer, content, container)) } +/// 这一帧的状态层颜色(**组件唯一入口**,A8 起带时间轴)。 +/// +/// 三种情形,前两种都**逐位等于** M257 之前的输出: +/// +/// | 情形 | 返回 | 为什么 | +/// |---|---|---| +/// | 没有取样控件(gallery 强制态 / 未包装路径) | [`overlay_color`] | 取证图与旧基线一字不差 | +/// | 有取样控件且过渡已到位(`p >= 1`) | 目标态的颜色 | 静息与终态都是**精确**的令牌求值 | +/// | 过渡中(`0 < p < 1`) | 两态不透明色的插值 | 中间帧既不是起点也不是终点(可判别的中间态) | +/// +/// 中间态为什么能插值而不产生半透明:两端都是 `state_over` 合成的**不透明**色,线性插值的结果 +/// 也不透明(D27:GPU/CPU 两档逐像素一致)。`back` 是状态层合成的底(填充按钮 = 容器角色, +/// 透明容器 = [`Base`]),当前态不叠层时以它为端点色(那正是「没画状态层」时看到的颜色)。 +pub fn layer_color(ui: &Ui, state: Interaction, content: Role, back: Role) -> Option { + let Some((from, to, p)) = layer::current() else { + return overlay_color(ui, state, content, back); + }; + let source = overlay_color(ui, from, content, back); + let target = overlay_color(ui, to, content, back); + if p >= 1.0 { + return target; + } + if p <= 0.0 { + return source; + } + // 端点缺失(静息态不叠层)时用**底**补一个不透明色:那正是用户眼里的「没有状态层」。 + let backdrop = ui.color(back); + Some(layer::blend( + source.unwrap_or(backdrop), + target.unwrap_or(backdrop), + p, + )) +} + mod base; mod disabled; @@ -235,6 +272,68 @@ mod tests { } } + /// **没有取样控件时,[`layer_color`] 逐位等于 M257 之前的 [`overlay_color`]**。 + /// + /// 抓得住:有人把时间轴接进求值路径时顺手改了静息/终态的颜色(D30 的旧基线会漂)。 + /// 抓不住:过渡中间帧(那是下一条用例与 `layer::tests` 的事)。 + #[test] + fn layer_color_without_a_sampler_is_bit_identical_to_overlay_color() { + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let ui = ui(flavor); + for state in Interaction::ALL { + for (content, back) in [ + (Role::OnPrimary, Role::Primary), + (Role::OnSurface, Role::Surface), + (Role::OnSurfaceVariant, Role::SurfaceContainerLow), + ] { + assert_eq!( + layer_color(&ui, state, content, back), + overlay_color(&ui, state, content, back), + "{flavor:?}/{state:?}/{content:?} 必须逐位退回旧口径" + ); + } + } + } + } + + /// **过渡中间帧:颜色落在两端之间,且不透明**(D27:不许把 α 交给渲染器)。 + /// + /// 抓得住:①中间帧被写成硬切(`p` 被忽略 ⇒ 起点色或终点色,`assert_ne` 红); + /// ②有人为了「淡出」改用 `Color { a: 0.5 }`(末尾的「必须不透明」红)。 + /// 抓不住:观感(8%/10% 的差在图上只有几级色阶,好不好看得看图)。 + #[test] + fn a_running_transition_yields_an_opaque_colour_between_the_two_ends() { + use super::layer; + + let ui = ui(SchemeFlavor::Light); + layer::reset(); + let resting = overlay_color(&ui, Interaction::Enabled, Role::OnSurface, Role::Surface); + let hovered = overlay_color(&ui, Interaction::Hovered, Role::OnSurface, Role::Surface) + .expect("hover 有颜色"); + + let mut state = layer::Layer::new(); + state.retarget( + Interaction::Hovered, + unlucky_design::motion::MotionScale::STANDARD, + ); + layer::advance_by(16.0); + + let mid = layer::with_current(state, || { + layer_color(&ui, Interaction::Hovered, Role::OnSurface, Role::Surface) + }) + .expect("过渡中必须有颜色"); + + assert_eq!(mid.a, 1.0, "中间帧也必须不透明(D27)"); + assert!( + resting.is_none(), + "前提:静息态本来不叠层(透明容器的情形)" + ); + assert_ne!(mid, hovered, "中间帧不许等于终点色(那就是硬切)"); + let backdrop = ui.color(Role::Surface); + assert_ne!(mid, backdrop, "中间帧也不许还停在起点色的底上"); + layer::reset(); + } + #[test] fn hover_is_weaker_than_pressed_on_the_same_container() { let ui = ui(SchemeFlavor::Light); diff --git a/crates/app-iced/src/components/motion/clip.rs b/crates/app-iced/src/components/motion/clip.rs new file mode 100644 index 0000000..06bc031 --- /dev/null +++ b/crates/app-iced/src/components/motion/clip.rs @@ -0,0 +1,202 @@ +//! **裁切容器**(M257):可见宽可以逐帧变,**内容排版宽不变**。 +//! +//! ## 为什么不能直接 `container(..).clip(true)` +//! +//! iced 的 `container::layout` 把父级 `Limits` 的 **max** 一路传给子级,而 `Limits::resolve` 会把 +//! `Length::Fixed(w)` 夹到那个 max 上。于是「外层宽逐帧变 + 内层固定 320」的实际排版宽是 +//! `min(320, 这一帧的外层宽)`:正文每帧重新折行 ⇒ 折叠那 200ms 里文字会「沸腾」(M257 的 +//! 第 02 张取证图就是这么暴露的:空态文案在过渡中折成两行)。 +//! +//! 本控件把两件事拆开: +//! +//! | 谁 | 用什么宽 | +//! |---|---| +//! | 子内容的**排版** | `content_width`(目标宽,整段过渡里不变 ⇒ 不重排) | +//! | 自己占的**格子**(= 裁剪框) | `visible`(逐帧变,由折叠进度给) | +//! +//! 绘制走 `Renderer::with_layer(bounds)`(两个后端都实现:tiny-skia 是 `push_clip`, +//! wgpu 是 `push_transformation` 同族的层栈),所以内容超出 `visible` 的部分**画不出来**—— +//! 这就是「不溢出」在像素上的落点,不需要半透明。 +//! +//! ## 内容锚在**右边** +//! +//! 子节点被放在 `visible − content_width`(负数)处:右栗贴在窗口右缘,折叠时**右边不动、左边 +//! 被裁**。于是图标条 / 面板头部的右端元素不位移(「折叠后图标位置不跳」的另一半也在这一句里)。 +//! +//! ## 已知局限(与 `zoom.rs` 同类,登记在报告里) +//! +//! **命中测试用排版矩形**:过渡期间内容的可点区域比可见区域宽(多出来的那一段在左侧、看不见)。 +//! 只在 `visible != content_width` 的 200ms 里成立,收尾(或展开到位)时两者相等、逐像素与静息 +//! 相同。 + +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, Point, Rectangle, Size, Vector}; + +/// 把 `content` 按 `content_width` 排版、按 `visible` 裁切显示(内容**右端对齐**)。 +pub fn clipped<'a, Message, Theme, Renderer>( + content: impl Into>, + visible: f32, + content_width: f32, +) -> Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a, + Renderer: iced::advanced::Renderer + 'a, +{ + Element::new(Clipped { + content: content.into(), + visible, + content_width, + }) +} + +struct Clipped<'a, Message, Theme, Renderer> { + content: Element<'a, Message, Theme, Renderer>, + /// 这一帧占的格子宽(= 裁剪框宽)。 + visible: f32, + /// 内容排版宽(整段过渡里不变)。 + content_width: f32, +} + +impl Widget + for Clipped<'_, Message, Theme, Renderer> +where + Renderer: iced::advanced::Renderer, +{ + fn tag(&self) -> tree::Tag { + self.content.as_widget().tag() + } + + fn state(&self) -> tree::State { + self.content.as_widget().state() + } + + fn children(&self) -> Vec { + self.content.as_widget().children() + } + + fn diff(&self, tree: &mut Tree) { + self.content.as_widget().diff(tree); + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn size_hint(&self) -> Size { + self.content.as_widget().size_hint() + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + // 排版宽**只**认 `content_width`:父级 max 不参与(那正是 container 会夹住它的地方)。 + let child_limits = layout::Limits::new( + Size::ZERO, + Size::new(self.content_width, limits.max().height), + ); + let node = self + .content + .as_widget_mut() + .layout(tree, renderer, &child_limits); + let size = node.size(); + + // 右对齐:内容右端贴在裁剪框右缘。 + let dx = self.visible - size.width; + layout::Node::with_children( + Size::new(self.visible, size.height), + vec![node.move_to(Point::new(dx, 0.0))], + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + let Some(child) = layout.children().next() else { + return; + }; + let bounds = layout.bounds(); + let content = self.content.as_widget(); + // `with_layer` 在两个后端都裁到 `bounds`(见模块文档的表)。 + renderer.with_layer(bounds, |renderer| { + content.draw(tree, renderer, theme, style, child, cursor, viewport); + }); + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn iced::advanced::widget::Operation, + ) { + let Some(child) = layout.children().next() else { + return; + }; + self.content + .as_widget_mut() + .operate(tree, child, 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, + ) { + let Some(child) = layout.children().next() else { + return; + }; + self.content.as_widget_mut().update( + tree, event, child, cursor, renderer, clipboard, shell, viewport, + ); + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + let Some(child) = layout.children().next() else { + return mouse::Interaction::None; + }; + self.content + .as_widget() + .mouse_interaction(tree, child, cursor, viewport, renderer) + } + + fn overlay<'a>( + &'a mut self, + tree: &'a mut Tree, + layout: Layout<'a>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + let child = layout.children().next()?; + self.content + .as_widget_mut() + .overlay(tree, child, renderer, viewport, translation) + } +} diff --git a/crates/app-iced/src/components/motion/mod.rs b/crates/app-iced/src/components/motion/mod.rs index 9eb182d..f623248 100644 --- a/crates/app-iced/src/components/motion/mod.rs +++ b/crates/app-iced/src/components/motion/mod.rs @@ -48,6 +48,7 @@ //! 不做逐像素 α(D27:CPU 档禁面板半透明)也不做模糊(同上),所以两个渲染后端出图一致。 //! 模态的**缩放**走 `renderer.with_transformation`(两个后端都实现,见 [`scaled`])。 +mod clip; mod pump; mod transition; mod zoom; @@ -58,10 +59,11 @@ mod zoom; pub use pump::{Pump, FRAME_MS, MAX_STEP_MS}; // 规格表与采样形状全部经这里导出:页面只拿得到「这一帧怎么画」,拿不到「用哪一档、多久」。 // (`page_spec`/`Leg`/`*Spec` 只被测试与文档引用,产品路径只用 `Kind` 与采样形状。) +pub use clip::clipped; #[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, + enter_offset, exit_offset, modal_scale, modal_spec, page_spec, rail_spec, Kind, Leg, + ModalFrame, ModalSpec, PageFrame, PageSpec, RailSpec, MODAL_SCALE_FROM, }; pub use zoom::scaled; @@ -138,8 +140,12 @@ pub fn samples() -> u64 { } /// 现在有没有动画在跑(`App::needs_frames` 的一个分支)。 +/// +/// **两条时间轴都问**:本模块的轨道(页面 / 模态 / 折叠)与 `interaction::layer` 的状态层过渡。 +/// 后者有自己的时钟(状态层要按控件逐个推进,而控件状态在各自的 `Tree` 里),所以它只借这里的 +/// 帧订阅:`App::subscription` 仍然只认这一个函数(**没有第二个判断点**)。 pub fn needs_frames() -> bool { - PUMP.with(|pump| pump.borrow().is_animating()) + PUMP.with(|pump| pump.borrow().is_animating()) || super::interaction::layer::is_animating() } /// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。 @@ -219,6 +225,45 @@ pub fn advance() { PUMP.with(|pump| { pump.borrow_mut().advance(elapsed_ms); }); + // A8:状态层过渡的时钟(单步上限与本模块同一条 [`MAX_STEP_MS`],掉帧不许把过渡一帧抹掉)。 + super::interaction::layer::advance(elapsed_ms); +} + +/// 右栗折叠的**目标档**变了(`pages/agent/state.rs` 的三个档位迁移里调,都在 update 侧)。 +/// +/// 返回 `true` = 起跑了过渡(调用方不必做事:帧基准由本模块自己重置)。 +pub fn observe_rail(open: bool) -> bool { + let started = PUMP.with(|pump| pump.borrow_mut().observe_rail(open)); + if started { + LAST_FRAME.with(|last| last.set(Some(Instant::now()))); + } + started +} + +/// 瞬时落档(拆分成独立页 / 窄窗退化):**不**起轨道,进度严格等于终值。 +pub fn snap_rail(open: bool) { + let was_running = PUMP.with(|pump| pump.borrow_mut().snap_rail(open)); + if was_running { + LAST_FRAME.with(|last| last.set(Some(Instant::now()))); + } +} + +/// 这一帧的折叠进度(0 = 48 图标条,1 = 展开档)。**只采样、无副作用**。 +/// +/// `open` 是调用方**此刻**的目标档。泵记的目标与它不一致,说明有人改了档位却没走 +/// [`observe_rail`](截图 harness / 单测直接构造状态)——那时直接给终值: +/// 宁可无动效,也不许把界面卡在中间。 +pub fn rail(open: bool) -> f32 { + PUMP.with(|pump| { + let pump = pump.borrow(); + if pump.rail_open() == open { + pump.rail_progress() + } else if open { + 1.0 + } else { + 0.0 + } + }) } /// 把位移套到树上。 @@ -249,6 +294,8 @@ pub fn reset() { LAST_FRAME.with(|last| last.set(None)); // 钉住也要清:「故意走真探针」的那条用例靠 `reset()` 回到未钉住状态。 FORCED_SCALE.with(|forced| forced.set(None)); + // 状态层那条时钟是另一个 thread-local(`interaction::layer`),一起清。 + super::interaction::layer::reset(); } /// 测试专用:按给定毫秒推进(`advance` 读真实时钟,单元测试不能等它)。 diff --git a/crates/app-iced/src/components/motion/pump/mod.rs b/crates/app-iced/src/components/motion/pump/mod.rs index 9eb462e..933831f 100644 --- a/crates/app-iced/src/components/motion/pump/mod.rs +++ b/crates/app-iced/src/components/motion/pump/mod.rs @@ -10,17 +10,20 @@ //! |---|---|---| //! | 页面 | 内容标识变了([`Pump::observe`]) | 两腿里长的那条走完 | //! | 模态 | 弹窗开 / 关([`Pump::observe_modal`] / [`Pump::begin_modal_close`]) | 进场转 [`Modal::Open`](**不再要帧**)、退场转 [`Modal::Hidden`] | +//! | 折叠(M257) | 右栗档位变([`Pump::observe_rail`],拆出档走 [`Pump::snap_rail`]) | 主进度走完一条腿(`medium1` / `short4`) | //! //! D31:时长/缓动是**表**([`super::transition`]),页面只许按表取——它只能说「我要换页了」。 use unlucky_design::motion::{DurationToken, MotionScale}; mod modal; +mod rail; use super::transition::{ enter_offset, exit_offset, modal_spec, page_spec, Kind, PageFrame, PageSpec, }; pub(crate) use modal::Modal; +pub(crate) use rail::RailTrack; /// 逐帧**推进时间轴**的最短间隔(16 ms ≈ 60 次/秒)。 /// @@ -61,6 +64,10 @@ pub struct Pump { pending_ms: f64, page: Option, modal: Modal, + /// 右栗折叠轨道(M257):`Some` = 宽度正在 `48 ⇄ 展开档` 之间走。 + rail: Option, + /// 右栗的目标档(`true` = 展开)。没有轨道时进度就是它的终值。 + rail_open: bool, /// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。 close_pending: bool, screen: Option, @@ -81,6 +88,8 @@ impl Pump { pending_ms: 0.0, page: None, modal: Modal::Hidden, + rail: None, + rail_open: false, close_pending: false, screen: None, rank: None, @@ -104,6 +113,9 @@ impl Pump { self.detail = None; self.pending_ms = 0.0; self.modal = Modal::Hidden; + // 定档时右栗按出厂档(收起)复位:定档之后的第一帧画的就是 48,不会先画一次展开态。 + self.rail = None; + self.rail_open = false; self.close_pending = false; if scale.is_off() { // 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。 @@ -129,7 +141,9 @@ impl Pump { /// 现在有没有动画在跑(= 要不要订阅帧)。 pub fn is_animating(&self) -> bool { - self.page.is_some() || matches!(self.modal, Modal::Opening { .. } | Modal::Closing { .. }) + self.page.is_some() + || self.rail.is_some() + || matches!(self.modal, Modal::Opening { .. } | Modal::Closing { .. }) } /// 轨道起点的口径:钉住相位时恒为 0(进度只取决于钉住值,与"谁在第几帧起跑"无关)。 @@ -286,6 +300,11 @@ impl Pump { self.page = None; } } + if let Some(track) = self.rail { + if now - track.start_ms >= track.duration_ms { + self.rail = None; + } + } let spec = modal_spec(); match self.modal { Modal::Opening { start_ms } if now - start_ms >= self.leg_ms(spec.enter.duration) => { diff --git a/crates/app-iced/src/components/motion/pump/rail.rs b/crates/app-iced/src/components/motion/pump/rail.rs new file mode 100644 index 0000000..ed77be5 --- /dev/null +++ b/crates/app-iced/src/components/motion/pump/rail.rs @@ -0,0 +1,104 @@ +//! 右栗折叠轨道(M257 / A2):`Collapsed 48 ⇄ Expanded` 那一段的**时间轴**。 +//! +//! 与页面 / 模态两条轨道并列的第三条:它只管「这一帧折叠到几成」,不碰宽度怎么算 +//! (宽度是 `pages/agent/metrics.rs` 的纯函数)、也不碰画什么(那是视图的事)。 +//! +//! | 项 | 值 | 出处 | +//! |---|---|---| +//! | 展开(进) | `medium1` 250ms + `Emphasized` | `14-motion-and-animation.md` §3 A2(token 列)+ §7-1「强调曲线优先」「进略长于出」 | +//! | 收起(出) | `short4` 200ms + `EmphasizedAccelerate` | 同上(退场比进场短 50ms) | +//! +//! ## 为什么拆出去档(`Detached`)**不**走这条轨道 +//! +//! 拆分是**布局切换**(右栗消失、会话搬到独立页),不是宽度的连续变化:把 0 也塞进这条轨道, +//! 「48 → 0」会变成一次毫无意义的缩水动画,而且中途页面会按一个不存在的宽度重排。所以 +//! [`Pump::snap_rail`] 是它的入口:目标记下来、轨道清空,进度**严格**是终值。 +//! +//! ## 起跑只在 update 侧 +//! +//! [`Pump::observe_rail`] 由 `pages/agent/state.rs` 的三个档位迁移(`collapse` / `expand` / +//! `dock`)调用——那三处都在 `update` 里发生。`view` 侧只经 `motion::rail(open)` **采样**。 + +use super::super::transition::rail_spec; +use super::{leg_fraction, Pump}; + +/// 一条正在跑的折叠轨道(0 = 图标条,1 = 展开档)。 +#[derive(Debug, Clone, Copy)] +pub(crate) struct RailTrack { + /// 起跑时已经在几成(中途反向时不为 0/1,保证**不跳变**)。 + pub(crate) from: f32, + /// 目标(0 或 1)。 + pub(crate) to: f32, + /// 这一腿的时长(已按 `MotionScale` 缩放)。 + pub(crate) duration_ms: f64, + /// 这一腿的曲线。 + pub(crate) leg_easing: unlucky_design::motion::Easing, + pub(crate) start_ms: f64, +} + +impl RailTrack { + /// `elapsed` 毫秒之后的进度(端点**逐位**等于 `from` / `to`)。 + pub(crate) fn sample(&self, elapsed_ms: f64) -> f32 { + let t = leg_fraction(elapsed_ms, self.duration_ms); + if t >= 1.0 { + return self.to; + } + if t <= 0.0 { + return self.from; + } + self.from + (self.to - self.from) * self.leg_easing.sample(t) + } +} + +impl Pump { + /// 折叠目标态(`true` = 展开档)。出厂是收起(`pages/agent/state.rs` 的默认档)。 + pub fn rail_open(&self) -> bool { + self.rail_open + } + + /// 起跑一次折叠 / 展开。返回 `true` = 真的起跑了轨道(调用方要重置帧基准)。 + /// + /// **关掉动效时不插轨道**(`MotionScale::NONE`):进度从此恒为终值,`needs_frames()` 不会 + /// 因为折叠而变真——这是「NONE = 严格等于终值」在那一条轨道上的落点。 + pub fn observe_rail(&mut self, open: bool) -> bool { + if self.rail_open == open && self.rail.is_none() { + return false; + } + let from = self.rail_progress(); + self.rail_open = open; + if self.scale.is_off() { + // 关掉动效 = 直接落终态(不是跑一遍 0 时长)。 + self.rail = None; + return false; + } + let spec = rail_spec(); + let leg = if open { spec.enter } else { spec.exit }; + self.rail = Some(RailTrack { + from, + to: if open { 1.0 } else { 0.0 }, + duration_ms: f64::from(self.scale.ms(leg.duration)), + leg_easing: leg.easing, + start_ms: self.start_now(), + }); + true + } + + /// 瞬时落档(拆分成独立页 / 窄窗退化):**不**起轨道,进度严格等于终值。 + /// + /// 返回 `true` = 原来有轨道被掐掉了(那一帧要重画成终值)。 + pub fn snap_rail(&mut self, open: bool) -> bool { + self.rail_open = open; + self.rail.take().is_some() + } + + /// 这一帧的折叠进度(0 = 48 图标条,1 = 展开档)。 + /// + /// 没有轨道时是**严格**的 `0.0` / `1.0`(不是「差不多到位」)。 + pub fn rail_progress(&self) -> f32 { + match self.rail { + Some(track) => track.sample(self.elapsed_ms() - track.start_ms), + None if self.rail_open => 1.0, + None => 0.0, + } + } +} diff --git a/crates/app-iced/src/components/motion/tests/mod.rs b/crates/app-iced/src/components/motion/tests/mod.rs index 77a960f..9ee2360 100644 --- a/crates/app-iced/src/components/motion/tests/mod.rs +++ b/crates/app-iced/src/components/motion/tests/mod.rs @@ -9,6 +9,7 @@ //! 模态 / 系统偏好 / 降级那一半在 [`modal`]。 mod modal; +mod rail; mod timeline; use super::pump::Pump; diff --git a/crates/app-iced/src/components/motion/tests/rail.rs b/crates/app-iced/src/components/motion/tests/rail.rs new file mode 100644 index 0000000..52695e4 --- /dev/null +++ b/crates/app-iced/src/components/motion/tests/rail.rs @@ -0,0 +1,174 @@ +//! 右栗折叠轨道(M257 / A2)的判据:泵帧数 + 端点精确 + 中间态严格单调。 +//! +//! 判据口径同 M256(`14-motion-and-animation.md` §7-7):**不靠像素比对**。像素只能证明「某一张图 +//! 里宽度是多少」,证明不了「中间有 12 个相位」与「结束时不多要一帧」。 + +use super::{settle, standard}; +use crate::app::components::motion::*; +use unlucky_design::motion::MotionScale; + +/// 一路采样折叠进度(含终点),用完自己 settle。 +fn samples(pump: &mut Pump) -> Vec { + let mut out = vec![pump.rail_progress()]; + let mut guard = 0; + while pump.is_animating() { + pump.advance(FRAME_MS); + out.push(pump.rail_progress()); + guard += 1; + assert!(guard < 200, "折叠轨道没在跑完,疑似终止条件失效"); + } + out +} + +/// **展开:帧数落在 12~20 之间、端点精确、中间严格单调**。 +/// +/// 抓得住:①有人把 `medium1`(250) 改成 600ms(帧数窜到 38)或改成 0(硬跳,帧数 0); +/// ②终止条件被删掉(`while is_animating` 直接跑满 200 帧的兜底断言红);③插值写成阶跃 +/// (单调性断言里的 `>` 会变成 `==`)。 +/// 抓不住:观感是否「像 M3E」——那要出图(D30),本用例只保证相位数量与端点。 +#[test] +fn expanding_runs_the_expected_number_of_frames_and_lands_exactly_on_one() { + let mut pump = standard(); + assert!(pump.observe_rail(true), "目标变了必须起跑"); + assert_eq!(pump.rail_progress(), 0.0, "第一帧还在折叠档(端点精确)"); + + let values = samples(&mut pump); + let frames = values.len() - 1; + assert!( + (12..=20).contains(&frames), + "250ms / 16ms 应落在 12~20 帧,实测 {frames} 帧" + ); + assert_eq!( + values.last().copied(), + Some(1.0), + "结束时必须**严格**是 1.0(不是 0.99x)" + ); + for pair in values.windows(2) { + assert!( + pair[1] > pair[0], + "展开必须严格单调:{:?} → {:?}", + pair[0], + pair[1] + ); + } + assert!(!pump.is_animating(), "跑完必须清空轨道(否则空闲烧帧)"); + assert_eq!(pump.rail_progress(), 1.0, "没有轨道时是终值"); +} + +/// **收起:同一条判据,反向**(`short4` 200ms ⇒ 12~13 帧)。 +#[test] +fn collapsing_is_strictly_monotone_downwards_and_stops_at_zero() { + let mut pump = standard(); + pump.observe_rail(true); + settle(&mut pump); + assert_eq!(pump.rail_progress(), 1.0); + + assert!(pump.observe_rail(false)); + let values = samples(&mut pump); + let frames = values.len() - 1; + assert!( + (10..=20).contains(&frames), + "200ms / 16ms 应落在 10~20 帧,实测 {frames} 帧" + ); + assert_eq!(values.last().copied(), Some(0.0), "结束时必须严格是 0.0"); + for pair in values.windows(2) { + assert!(pair[1] < pair[0], "收起必须严格单调下降"); + } + assert!(!pump.is_animating()); +} + +/// **`MotionScale::NONE`:严格等于终值,且一帧都不要**(独立的用例,不靠别的用例顺带覆盖)。 +/// +/// 抓得住:把 `observe_rail` 里的 `is_off()` 分支删掉(那会插一条 0 时长轨道 ⇒ 进度仍是 1.0, +/// 但 `is_animating()` 会有一拍为真 ⇒ 本用例的「一帧都不要」断言红);或把 `is_off()` +/// 写成「跑一遍 0 时长」(同样红)。 +/// 抓不住:中间帧**该不该**存在(NONE 下不该有中间帧,这是定义,不是观感)。 +#[test] +fn motion_scale_none_puts_the_rail_strictly_at_the_end_value() { + let mut pump = Pump::new(); + pump.configure(MotionScale::NONE, None); + + assert!( + !pump.observe_rail(true), + "NONE 下不许起跑轨道(不是什么都没发生,是**不许有中间态**)" + ); + assert_eq!(pump.rail_progress(), 1.0, "展开档的终值必须逐位是 1.0"); + assert!(!pump.is_animating(), "NONE 下一帧都不许要"); + + assert!(!pump.observe_rail(false)); + assert_eq!(pump.rail_progress(), 0.0, "折叠档的终值必须逐位是 0.0"); + assert!(!pump.is_animating()); +} + +/// **中途反向不跳变**:折叠到一半改主意展开,下一帧的进度必须从当前值继续(不是回 0/1)。 +/// +/// 抓得住:把 `observe_rail` 里的 `from` 写成常量 0 / 1(那会让宽度在半路跳一下)。 +/// 抓不住:真实手感(那个「拐点」顺不顺)——只保证数值连续。 +#[test] +fn reversing_mid_flight_continues_from_the_current_progress() { + let mut pump = standard(); + pump.observe_rail(true); + for _ in 0..4 { + pump.advance(FRAME_MS); + } + let before = pump.rail_progress(); + assert!( + before > 0.1 && before < 0.9, + "前提:应该处在中间(实测 {before})" + ); + + pump.observe_rail(false); + let after = pump.rail_progress(); + assert!( + (after - before).abs() < 1e-6, + "反向那一帧不许跳变:{before} → {after}" + ); +} + +/// **拆出档不走过渡**:`snap_rail` 直接落终值,且把在跑的轨道掐掉。 +/// +/// 抓得住:有人把 `Detached` 也接进这条轨道(那会做一次「48 → 0」的缩水动画)。 +/// 抓不住:拆分本身的观感(那是布局切换,不在动效范围里)。 +#[test] +fn detaching_snaps_instead_of_animating() { + let mut pump = standard(); + pump.observe_rail(true); + pump.advance(FRAME_MS); + assert!(pump.is_animating()); + + assert!(pump.snap_rail(false), "有轨道在跑时落档要报「掐掉了」"); + assert_eq!(pump.rail_progress(), 0.0); + assert!(!pump.is_animating()); +} + +/// **帧订阅的委托**:状态层过渡在跑时 `motion::needs_frames()` 必须为真。 +/// +/// 这是 `app/subscription.rs` 唯一的判断点(`motion::needs_frames()`):A8 的时钟若没被委托进 +/// 这里,产品里就一帧都不会来(状态层卡在起始值),而单元测试照样能靠 `advance_by` 摆出绿。 +/// +/// 抓得住:删掉 `motion::needs_frames` 里对 `interaction::layer::is_animating()` 的那一句。 +/// 抓不住:真实窗口的帧率(那由 `window::frames()` 决定,见 `app/subscription.rs` 的表)。 +#[test] +fn the_state_layer_clock_feeds_the_single_subscription_point() { + use crate::app::components::interaction::layer; + + reset(); + assert!(!needs_frames(), "前提:静息时不要帧"); + + let mut state = layer::Layer::new(); + state.retarget( + crate::app::components::interaction::Interaction::Hovered, + MotionScale::STANDARD, + ); + assert!( + needs_frames(), + "悬停起跑之后**同一拍**就要帧(否则 draw 侧起跑的 0 进度会一直画不出来)" + ); + + // 推进到 50ms 之后:必须自己退订(不是「一直要帧」)。 + for _ in 0..6 { + layer::advance_by(FRAME_MS); + } + assert!(!needs_frames(), "过渡走完必须立刻不要帧"); + layer::reset(); +} diff --git a/crates/app-iced/src/components/motion/transition.rs b/crates/app-iced/src/components/motion/transition.rs index d3ed342..07df578 100644 --- a/crates/app-iced/src/components/motion/transition.rs +++ b/crates/app-iced/src/components/motion/transition.rs @@ -168,6 +168,33 @@ pub const fn modal_spec() -> ModalSpec { } } +/// 右栗折叠的进 / 出两腿(M257 / A2,唯一来源)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RailSpec { + /// 展开(进):`medium1` 250ms + `Emphasized`(M3E 强调曲线;进略长于出)。 + pub enter: Leg, + /// 收起(出):`short4` 200ms + `EmphasizedAccelerate`(退场比进场短 50ms,不拖)。 + pub exit: Leg, +} + +/// 右栗折叠规格。 +/// +/// 时长口径:`14-motion-and-animation.md` §3 的 A2 行点名 `Medium1`(250)——展开用它; +/// 收起取下一档 `Short4`(200),落实 §7-1 的「进略长于出」。两条都 ≤300ms(§7-1 的硬线)。 +/// 曲线取 `Emphasized*` 而不是 `Standard*`:宽度变化是「注意力转移」,不是屏内元素的常规起止。 +pub const fn rail_spec() -> RailSpec { + RailSpec { + enter: Leg { + duration: DurationToken::Medium1, + easing: Easing::Emphasized, + }, + exit: Leg { + duration: DurationToken::Short4, + easing: Easing::EmphasizedAccelerate, + }, + } +} + /// 一帧页面采样(**只读**:`pages::render` 每帧取一次,不起轨道)。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct PageFrame { diff --git a/crates/app-iced/src/components/navigation/drawer/item.rs b/crates/app-iced/src/components/navigation/drawer/item.rs index 376c0da..4c818fb 100644 --- a/crates/app-iced/src/components/navigation/drawer/item.rs +++ b/crates/app-iced/src/components/navigation/drawer/item.rs @@ -96,17 +96,38 @@ impl<'a, M> Default for NavGroup<'a, M> { } } -/// 一项的颜色(按态 + 选中求值)。 +/// 一项的颜色(按态 + 选中求值)。**药丸不在这里**:它随过渡逐帧变化,见 [`pill_color`]。 #[derive(Debug, Clone, Copy, PartialEq)] pub(super) struct ItemPalette { - /// 药丸底色(`None` = 不画药丸 / 不叠状态层)。 - pub(super) pill: Option, /// 图标色。 pub(super) icon: Color, /// 标签色。 pub(super) label: Color, } +/// 药丸底色(**唯一**实现点;M257 起带时间轴)。 +/// +/// 与 M256 之前的**唯一**差别是它经 [`interaction::layer_color`] 求值:没有取样控件时逐位等于 +/// 原来的 `overlay_color(...).or(...)`,有取样控件时按 `short1`(50ms) 的进度插值。 +/// 选中项的静息药丸是**实色**(`SecondaryContainer`),所以缺层时要兜底到那个角色; +/// 未选中项静息不画药丸,兜底就必须是 `None`——这两条不许合成一条。 +pub(super) fn pill_color(ui: &Ui, base: Base, state: Interaction, selected: bool) -> Option { + if state.is_disabled() { + return None; + } + if selected { + interaction::layer_color( + ui, + state, + Role::OnSecondaryContainer, + Role::SecondaryContainer, + ) + .or_else(|| Some(ui.color(Role::SecondaryContainer))) + } else { + interaction::layer_color(ui, state, Role::OnSurface, base.role()) + } +} + pub(super) fn palette( ui: &Ui, base: Base, @@ -117,22 +138,13 @@ pub(super) fn palette( if state.is_disabled() { let ink = interaction::disabled_content_over_role(ui, base, Role::OnSurface, false); return ItemPalette { - pill: None, icon: ink, label: ink, }; } if selected { - let pill = interaction::overlay_color( - ui, - state, - Role::OnSecondaryContainer, - Role::SecondaryContainer, - ) - .unwrap_or_else(|| ui.color(Role::SecondaryContainer)); return ItemPalette { - pill: Some(pill), icon: ui.color(Role::OnSecondaryContainer), // rail 的选中标签是 `Secondary`(12b 5.6);drawer 是 `OnSecondaryContainer`(§21.7) label: match layout { @@ -148,7 +160,6 @@ pub(super) fn palette( _ => ui.color(Role::OnSurface), }; ItemPalette { - pill: interaction::overlay_color(ui, state, Role::OnSurface, base.role()), icon: ink, label: ink, } @@ -186,6 +197,7 @@ pub(super) fn nav_row<'a, M: Clone + 'a>( }); let state = forced.unwrap_or(Interaction::Enabled); let colors = palette(ui, base, geometry.layout, state, item.selected); + let item_selected = item.selected; let radius = iced::border::Radius::new(geometry.pill_height / 2.0); let icon = layout::fixed( @@ -194,16 +206,16 @@ pub(super) fn nav_row<'a, M: Clone + 'a>( icons::view::(item.icon, colors.icon), ); - // 药丸本体:宽高都来自几何(rail 56×32 / drawer 216×56),居中交给父级 align_x + // 药丸本体:宽高都来自几何(rail 56×32 / drawer 216×56),居中交给父级 align_x。 + // 底色在 `style` 闭包里求值(不是构造时算好):状态层过渡要逐帧问「这一帧到几成了」。 let pill = move |content: Element<'a, M>| -> Element<'a, M> { - let fill = colors.pill; container(content) .width(Length::Fixed(geometry.pill_width)) .height(Length::Fixed(geometry.pill_height)) .align_x(Alignment::Center) .align_y(Alignment::Center) .style(move |_theme| container::Style { - background: fill.map(Background::Color), + background: pill_color(ui, base, state, item_selected).map(Background::Color), border: Border { radius, ..Border::default() @@ -277,10 +289,17 @@ pub(super) fn nav_row<'a, M: Clone + 'a>( shadow: Shadow::default(), snap: true, }); + let enabled = item.on_press.is_some(); let element: Element<'a, M> = match item.on_press { Some(message) => clickable.on_press(message).into(), None => clickable.into(), }; + // A8:运行期态套状态取样控件(强制态 = 取证,不套;禁用不跟踪)。 + let element = if item.state.is_some() { + element + } else { + interaction::tracked(element, enabled) + }; // drawer 的项外距 12(`ItemPadding`):药丸两侧由内缩给出,所以整行再包一层水平内边距 match geometry.layout { diff --git a/crates/app-iced/src/components/navigation/drawer/tests.rs b/crates/app-iced/src/components/navigation/drawer/tests.rs index 660244e..466d0c8 100644 --- a/crates/app-iced/src/components/navigation/drawer/tests.rs +++ b/crates/app-iced/src/components/navigation/drawer/tests.rs @@ -1,7 +1,7 @@ //! `drawer` 的单元测试(单文件 ≤400 行,AGENTS.md §3-1 拆出来)。 use super::super::super::interaction::{self, Interaction}; -use super::item::palette; +use super::item::{palette, pill_color}; use super::*; use unlucky_design::color::SchemeFlavor; @@ -27,7 +27,10 @@ fn ui() -> Ui { fn drawer_selected_uses_secondary_container() { let ui = ui(); let palette = palette(&ui, BASE, NavLayout::Drawer, Interaction::Enabled, true); - assert_eq!(palette.pill, Some(ui.color(Role::SecondaryContainer))); + assert_eq!( + pill_color(&ui, BASE, Interaction::Enabled, true), + Some(ui.color(Role::SecondaryContainer)) + ); assert_eq!(palette.icon, ui.color(Role::OnSecondaryContainer)); assert_eq!(palette.label, ui.color(Role::OnSecondaryContainer)); } @@ -42,7 +45,10 @@ fn rail_selected_label_is_secondary() { assert_ne!(rail.label, drawer.label, "两条线的选中标签角色不同"); // 但图标与药丸相同(同一套语言) assert_eq!(rail.icon, drawer.icon); - assert_eq!(rail.pill, drawer.pill); + assert_eq!( + pill_color(&ui, BASE, Interaction::Enabled, true), + pill_color(&ui, BASE, Interaction::Enabled, true) + ); } /// 未选中:静息 `OnSurfaceVariant`;hover/focus/pressed 提到 `OnSurface` 且叠状态层。 @@ -51,7 +57,7 @@ fn inactive_lifts_to_on_surface() { let ui = ui(); let resting = palette(&ui, BASE, NavLayout::Drawer, Interaction::Enabled, false); assert_eq!(resting.label, ui.color(Role::OnSurfaceVariant)); - assert_eq!(resting.pill, None); + assert_eq!(pill_color(&ui, BASE, Interaction::Enabled, false), None); for state in [ Interaction::Hovered, Interaction::Focused, @@ -60,7 +66,10 @@ fn inactive_lifts_to_on_surface() { let active = palette(&ui, BASE, NavLayout::Drawer, state, false); assert_eq!(active.label, ui.color(Role::OnSurface), "{state:?}"); assert_eq!(active.icon, ui.color(Role::OnSurface), "{state:?}"); - assert!(active.pill.is_some(), "{state:?} 必须叠状态层"); + assert!( + pill_color(&ui, BASE, state, false).is_some(), + "{state:?} 必须叠状态层" + ); } } @@ -74,7 +83,7 @@ fn disabled_draws_no_pill() { interaction::disabled_content_over_role(&ui, BASE, Role::OnSurface, false) ); assert_eq!(palette.label, palette.icon); - assert_eq!(palette.pill, None); + assert_eq!(pill_color(&ui, BASE, Interaction::Disabled, true), None); } /// 禁用是**修饰器**:只清消息,图标 / 选中态全部保留。 diff --git a/crates/app-iced/src/pages/agent/metrics.rs b/crates/app-iced/src/pages/agent/metrics.rs index 8c09f34..144a002 100644 --- a/crates/app-iced/src/pages/agent/metrics.rs +++ b/crates/app-iced/src/pages/agent/metrics.rs @@ -158,11 +158,53 @@ pub fn layout_for( } /// 右栗面板宽度(`App::view` 与 shell 都用这一个入口,避免两处各算一套)。 -pub fn rail_width(window_width: f32, state: crate::shell::agent::RailState, expanded: f32) -> f32 { - layout_for(window_width, state, expanded).rail +/// +/// `progress` 是 M257 的折叠进度(0 = 折叠档 48,1 = 展开档):宽度在 `48 → 夹紧后的展开档` +/// 这一条线上**线性**插值,端点逐位等于静息值。为什么插值放在这里而不是视图里:宽度的**唯一** +/// 派生点是本文件,视图不该再写一次 `48 + (target − 48) * p`(那会变成第二份宽度公式)。 +/// +/// ⚠️ 方向只看 `progress`,**不看** `state` 的档位名:收起的**目标**档也是 `Collapsed`,若拿目标 +/// 档去查静息宽(`layout_for`),收起那一路会当场返回 48(= 宽度硬跳,A2 一秒退回原形)。 +/// `state` 只用来说明「拆出档根本不占宽」这一件事。 +pub fn rail_width( + window_width: f32, + state: crate::shell::agent::RailState, + expanded: f32, + progress: f32, +) -> f32 { + use crate::shell::agent::RailState; + + // 拆出档不占宽(布局切换,不是过渡对象)。 + if state == RailState::Detached { + return 0.0; + } + // 展开档的目标宽(夹紧后)。窄窗里「展开」不成立 ⇒ 目标就是 48,没有可动的空间。 + let target = layout_for(window_width, RailState::Expanded, expanded).rail; + if target <= COLLAPSED_WIDTH { + return COLLAPSED_WIDTH; + } + COLLAPSED_WIDTH + (target - COLLAPSED_WIDTH) * progress.clamp(0.0, 1.0) } -/// **交给页面布局系统的宽度** = 窗口 − 右栗 − 拖拽缝。 +/// 拖拽缝在**这一帧**存不存在:只有面板比折叠档宽时才有那 8px。 +/// +/// 折叠到底时右栗就是 48 的图标条(没有可拖的东西),所以缝必须跟着宽度一起退场—— +/// 否则 `48 + 8 = 56` 会和静息态的 48 差 8px,收尾那一帧看起来像「跳了一下」。 +pub fn gutter_at(rail_width: f32) -> f32 { + if rail_width > COLLAPSED_WIDTH { + RESIZE_GUTTER + } else { + 0.0 + } +} + +/// **交给页面布局系统的宽度** = 窗口 − 右栗 − 拖拽缝(**静息档**,不跟动画走)。 +/// +/// 为什么它**不**吃折叠进度:这一条决定的是页面**模型**(断点 / 列数 / 每列宽),而模型每帧重建 +/// 会让正文在 250ms 里重排十几次(折行跳动 = 任务里禁止的「布局抖动」)。物理宽度由 `row!` 的 +/// 兄弟关系自动收缩,两边不会打架:动画期间页面只是「模型比实际宽」,右栗画在它右边且不透明, +/// 收窄的那一段被右栗盖住,看不到溢出。代价(已登记):收窄到一半时页面右侧可能短暂被盖住, +/// 想要「页面跟着重排」的 shared-axis 效果是另一条 mission 的事。 pub fn content_width( window_width: f32, state: crate::shell::agent::RailState, diff --git a/crates/app-iced/src/pages/agent/mod.rs b/crates/app-iced/src/pages/agent/mod.rs index e370868..0abea03 100644 --- a/crates/app-iced/src/pages/agent/mod.rs +++ b/crates/app-iced/src/pages/agent/mod.rs @@ -42,6 +42,7 @@ mod metrics; mod state; mod conversation; +mod panel; mod strip; #[cfg(test)] @@ -49,14 +50,9 @@ mod tests; pub use state::{AgentUi, Focus, RailState}; -use iced::widget::{column, container, mouse_area, row, rule, space, text, tooltip}; -use iced::{mouse, Alignment, Background, Border, Element, Length, Padding, Theme as IcedTheme}; +use iced::widget::{container, mouse_area, space}; +use iced::{mouse, Alignment, Element, Length}; -use unlucky_design::color::Role; -use unlucky_design::elevation::MaterialLayer; -use unlucky_design::shape::ShapeScale; -use unlucky_design::spacing::Space; -use unlucky_design::typography::TypeRole; use unlucky_i18n::{Catalog, Key}; use unlucky_presenter::agent::AgentRail; @@ -66,7 +62,6 @@ use crate::pages::{self, layout}; use crate::theme::Ui; use conversation::{Conversation, Host}; -use metrics::RESIZE_GUTTER; /// 助手栏消息(**唯一**的消息类型;`app/view.rs` 用 `Message::Agent(..)` 包一层)。 #[derive(Debug, Clone)] @@ -203,9 +198,15 @@ pub fn docked<'a>( // 判档用**这一窗宽下实际生效**的档位:窗窄到放不下 280 时,`Expanded` 事实上就是折叠条 // (否则会画一条 48 宽、还白扣 8px 拖拽缝的「面板」)。 match session.agent.effective_rail(window_width) { + // 拆出档不占宽(会话在独立页里)——M257 一字未动。 RailState::Detached => None, - RailState::Collapsed => Some(strip::strip(ui, session.rail, catalog)), - RailState::Expanded => Some(panel(ui, session, catalog, window_width)), + // M257:**按这一帧的宽度**决定画哪一个,而不是按目标档。折叠末段(宽度回到 48)换成 + // 图标条,展开初段(宽度刚过 48)换成面板——这就是任务里的「两段」里的第二段。 + // 图标条是最右端的固定 48 列,所以它出现时**位置不会跳**(见 `metrics::rail_width`)。 + _ if session.agent.rail_width(window_width) <= metrics::COLLAPSED_WIDTH + 0.5 => { + Some(strip::strip(ui, session.rail, catalog)) + } + _ => Some(panel::panel(ui, session, catalog, window_width)), } } @@ -268,89 +269,6 @@ pub fn drag_overlay<'a>(agent: &AgentUi) -> Option> { ) } -// ---------------------------------------------------------------- 展开档:拖拽缝 + 面板 - -fn panel<'a>( - ui: &'a Ui, - session: Session<'a>, - catalog: Catalog, - window_width: f32, -) -> Element<'a, Message> { - let width = session.agent.rail_width(window_width); - - let body = container(conversation::view( - ui, - spec(session, catalog, Host::Rail), - catalog, - )) - .width(Length::Fixed(width)) - .height(Length::Fill) - .padding(Padding { - top: ui.space(Space::X6), - right: ui.space(Space::X4), - bottom: ui.space(Space::X4), - left: ui.space(Space::X4), - }) - .style(move |_theme| container::Style { - background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))), - ..container::Style::default() - }); - - row![splitter(ui, width, catalog), body] - .width(Length::Shrink) - .height(Length::Fill) - .into() -} - -/// 8px 拖拽缝:1px 分隔线居中 + 水平缩放光标 + 按下即开拖 + **可读标签**(tooltip)。 -/// -/// 复审 P3-5:这条 8px 的缝此前没有任何可读文字,而 `AgentResize` / `AgentWidthLabel` 两条 key -/// 在视图里一次没用(只在测试里被断言「不空」)——那是空头支票。现在 tooltip 同时给出 -/// 「拖它做什么」(`AgentResize`)与「当前多宽」(`AgentWidthLabel`),两个 key 都真用上了。 -fn splitter<'a>(ui: &'a Ui, width: f32, catalog: Catalog) -> Element<'a, Message> { - let line = container( - rule::vertical::(ui.metrics().divider_thickness).style(move |_theme| { - rule::Style { - color: ui.color(Role::OutlineVariant), - radius: ui.radius_dp(ShapeScale::None), - fill_mode: rule::FillMode::Full, - snap: true, - } - }), - ) - .width(Length::Fixed(RESIZE_GUTTER)) - .height(Length::Fill) - .align_x(Alignment::Center); - - let grab = mouse_area(line) - .interaction(mouse::Interaction::ResizingHorizontally) - .on_press(Message::DragStart); - - let tip_style = ui.style(TypeRole::BodySmall); - let pixels = format!("{width:.0}"); - let tip = container(column![ - text(catalog.text(Key::AgentResize)) - .size(tip_style.size_px) - .font(ui.font_of(tip_style)) - .color(ui.color(Role::OnSurface)), - text(catalog.render(Key::AgentWidthLabel, &[("width", pixels.as_str())])) - .size(tip_style.size_px) - .font(ui.font_of(tip_style)) - .color(ui.color(Role::OnSurfaceVariant)), - ]) - .padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)])) - .style(move |_theme| container::Style { - background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))), - border: Border { - radius: ui.radius_dp(ShapeScale::Small), - ..Border::default() - }, - ..container::Style::default() - }); - - tooltip(grab, tip, tooltip::Position::Left).into() -} - /// 会话视图的入参(**唯一**的组装点:两个宿主都走这里,不许各拼一份)。 fn spec<'a>(session: Session<'a>, catalog: Catalog, host: Host) -> Conversation<'a> { Conversation { diff --git a/crates/app-iced/src/pages/agent/panel.rs b/crates/app-iced/src/pages/agent/panel.rs new file mode 100644 index 0000000..e1999f2 --- /dev/null +++ b/crates/app-iced/src/pages/agent/panel.rs @@ -0,0 +1,127 @@ +//! 右栗展开档的**面板 + 拖拽缝**(M257 从 `mod.rs` 纯搬移,加上折叠过渡的裁切层)。 +//! +//! ## 折叠过渡在这里落地的两件事 +//! +//! 1. **宽度**:`rail_width(window, .., progress)` 逐帧给宽度(端点逐位等于静息值); +//! 2. **内容**:会话视图按**目标宽**排版(每帧不重排 ⇒ 不抖),外面那层按**动画宽**裁切 +//! (⇒ 不溢出)。宽度缩到折叠档时本函数根本不会被调——`docked` 已经切成 48 的图标条 +//! (那 48 列贴在右栗最右端,位置不随宽度变,所以图标不跳)。 +//! +//! 为什么不把内容也做成「淡出」:渲染器没有 opacity 原语(`motion/zoom.rs` 已登记:iced 0.14 +//! 只有 clip / transformation),D27 又不许为此引入半透明面板。所以第二段是**裁切收尾 + +//! 换挡**,不是逐像素 α。这条在报告里明确划进「没做到的面」。 + +use iced::widget::{column, container, mouse_area, row, rule, text, tooltip}; +use iced::{mouse, Alignment, Background, Border, Element, Length, Padding, Theme as IcedTheme}; + +use unlucky_design::color::Role; +use unlucky_design::elevation::MaterialLayer; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; +use unlucky_i18n::{Catalog, Key}; + +use crate::app::components::motion; +use crate::theme::Ui; + +use super::conversation::{self, Host}; +use super::metrics::{gutter_at, rail_width, RESIZE_GUTTER}; +use super::{spec, Message, Session}; + +/// 展开档的面板(外面包一层按动画宽裁切的容器)。 +pub(super) fn panel<'a>( + ui: &'a Ui, + session: Session<'a>, + catalog: Catalog, + window_width: f32, +) -> Element<'a, Message> { + let agent = session.agent; + // 这一帧的宽度(过渡中)与目标宽度(静息)。两者只在过渡期间不同。 + let width = agent.rail_width(window_width); + let settled = rail_width(window_width, agent.rail, agent.expanded(), 1.0); + + let body = container(conversation::view( + ui, + spec(session, catalog, Host::Rail), + catalog, + )) + // **目标宽**:内容按最终可用宽排版,动画期间不重排(否则每帧折行 = 抖动)。 + .width(Length::Fixed(settled)) + .height(Length::Fill) + .padding(Padding { + top: ui.space(Space::X6), + right: ui.space(Space::X4), + bottom: ui.space(Space::X4), + left: ui.space(Space::X4), + }) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Canvas))), + ..container::Style::default() + }); + + // 动画宽:只负责**裁切**(内容比它宽的部分画不出来 ⇒ 不溢出到中栏)。 + // + // 为什么不用 `container(..).clip(true)`:iced 会把父级 max 传给子级,正文于是每帧重新折行 + // (第 02 张取证图实测:空态文案在过渡中折成两行)。[`motion::clipped`] 把「排版宽」与 + // 「裁剪框宽」拆开,过渡期间正文的折行**一次都不变**。 + let clipped = motion::clipped(body, width, settled); + + // 拖拽缝跟着宽度走:宽度回到 48 时它是 0(那时右栗是图标条,没有可拖的东西)。 + if gutter_at(width) > 0.0 { + row![splitter(ui, settled, catalog), clipped] + .width(Length::Shrink) + .height(Length::Fill) + .into() + } else { + clipped + } +} + +/// 8px 拖拽缝:1px 分隔线居中 + 水平缩放光标 + 按下即开拖 + **可读标签**(tooltip)。 +/// +/// 复审 P3-5:这条 8px 的缝此前没有任何可读文字,而 `AgentResize` / `AgentWidthLabel` 两条 key +/// 在视图里一次没用(只在测试里被断言「不空」)——那是空头支票。现在 tooltip 同时给出 +/// 「拖它做什么」(`AgentResize`)与「当前多宽」(`AgentWidthLabel`),两个 key 都真用上了。 +fn splitter<'a>(ui: &'a Ui, width: f32, catalog: Catalog) -> Element<'a, Message> { + let line = container( + rule::vertical::(ui.metrics().divider_thickness).style(move |_theme| { + rule::Style { + color: ui.color(Role::OutlineVariant), + radius: ui.radius_dp(ShapeScale::None), + fill_mode: rule::FillMode::Full, + snap: true, + } + }), + ) + .width(Length::Fixed(RESIZE_GUTTER)) + .height(Length::Fill) + .align_x(Alignment::Center); + + let grab = mouse_area(line) + .interaction(mouse::Interaction::ResizingHorizontally) + .on_press(Message::DragStart); + + let tip_style = ui.style(TypeRole::BodySmall); + let pixels = format!("{width:.0}"); + let tip = container(column![ + text(catalog.text(Key::AgentResize)) + .size(tip_style.size_px) + .font(ui.font_of(tip_style)) + .color(ui.color(Role::OnSurface)), + text(catalog.render(Key::AgentWidthLabel, &[("width", pixels.as_str())])) + .size(tip_style.size_px) + .font(ui.font_of(tip_style)) + .color(ui.color(Role::OnSurfaceVariant)), + ]) + .padding(Padding::from([ui.space(Space::X1), ui.space(Space::X2)])) + .style(move |_theme| container::Style { + background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))), + border: Border { + radius: ui.radius_dp(ShapeScale::Small), + ..Border::default() + }, + ..container::Style::default() + }); + + tooltip(grab, tip, tooltip::Position::Left).into() +} diff --git a/crates/app-iced/src/pages/agent/state.rs b/crates/app-iced/src/pages/agent/state.rs index 651a8d9..214a182 100644 --- a/crates/app-iced/src/pages/agent/state.rs +++ b/crates/app-iced/src/pages/agent/state.rs @@ -31,6 +31,8 @@ use super::metrics::{ content_width, effective_state, magnet_width, rail_width, DEFAULT_EXPANDED_WIDTH, }; +use crate::app::components::motion; + /// 三档状态(顺序 = 展开程度,测试与文件名按它遍历)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RailState { @@ -204,13 +206,30 @@ impl AgentUi { effective_state(window_width, self.rail, self.expanded) } + /// 折叠过渡的**目标档**(`true` = 展开)。 + pub fn rail_open(&self) -> bool { + self.rail == RailState::Expanded + } + + /// 折叠进度(0 = 48 图标条,1 = 展开档)。**只采样**,起跑在下面四个档位迁移里。 + pub fn collapse_progress(&self) -> f32 { + motion::rail(self.rail_open()) + } + /// 右栗当前宽度(窗口宽度是判据之一:窗口变窄时展开档会被夹住)。 /// + /// M257 起它是**逐帧宽度**:档位切换的那 200~250ms 里返回过渡中的值(端点与静息值逐位相同)。 + /// /// 名字故意不叫 `width`:`tests/no_hardcoded_tokens.rs` 会把任何 `.width(数字)` 当成 /// 「布局尺寸写死」拦下来,而测试里 `agent.width(1180.0)` 会被误判(那条门禁扫的是控件调用点, /// 分不清是谁的方法)。 pub fn rail_width(&self, window_width: f32) -> f32 { - rail_width(window_width, self.rail, self.expanded) + rail_width( + window_width, + self.rail, + self.expanded, + self.collapse_progress(), + ) } /// **交给页面布局系统的宽度**(窗口 − 右栗 − 拖拽缝)。 @@ -227,6 +246,8 @@ impl AgentUi { self.keyboard_mode = false; if self.rail == RailState::Expanded { self.rail = RailState::Collapsed; + // 起跑在 update 侧(本函数由 `app/update/agent.rs` 调)——折叠那 200ms 的宽度轨道。 + motion::observe_rail(false); self.focus = Focus::Collapse; } else if self.rail == RailState::Detached { self.dock(); @@ -236,7 +257,14 @@ impl AgentUi { /// 展开(点图标条)。展开即视为读过——**清零动作在 app 层**(`mark_read`),这里只改档位。 pub fn expand(&mut self) { self.keyboard_mode = false; + // 只有「从折叠档展开」才是宽度的连续变化;从独立页收回(`dock`)与首次落档都是**布局切换**。 + let animate = self.rail == RailState::Collapsed; self.rail = RailState::Expanded; + if animate { + motion::observe_rail(true); + } else { + motion::snap_rail(true); + } self.focus = Focus::Collapse; } @@ -244,6 +272,8 @@ impl AgentUi { pub fn detach(&mut self) { self.keyboard_mode = false; self.rail = RailState::Detached; + // 拆出档**不做过渡**(右栗消失、会话搬到独立页,是布局切换):目标落终值。 + motion::snap_rail(false); self.focus = Focus::Detach; } @@ -251,6 +281,8 @@ impl AgentUi { pub fn dock(&mut self) { self.keyboard_mode = false; self.rail = RailState::Expanded; + // 从独立页收回到「常驻展开」:右栗是从 0 出现的布局切换,不是 48→320 的宽度过渡。 + motion::snap_rail(true); self.focus = Focus::Collapse; } diff --git a/crates/app-iced/src/pages/agent/tests.rs b/crates/app-iced/src/pages/agent/tests.rs index b01d3c7..233af6e 100644 --- a/crates/app-iced/src/pages/agent/tests.rs +++ b/crates/app-iced/src/pages/agent/tests.rs @@ -14,6 +14,7 @@ use unlucky_presenter::dto::{ChatMessage, ChatRole, ConfirmRequest, Sensitivity} use crate::theme::{Families, Ui}; +mod collapse; mod disclaimer; mod drag; @@ -136,7 +137,8 @@ fn the_empty_session_has_a_guidance_line() { /// 抓不住:`conversation::view` **内部**再分叉成两套(那要人读那个文件)。 #[test] fn both_hosts_render_the_same_conversation() { - let hosts = include_str!("mod.rs"); + // M257:右栗面板整段搬去了 `panel.rs`,所以「两个宿主各调一次」的扫描面是两个文件。 + let hosts = concat!(include_str!("mod.rs"), include_str!("panel.rs")); let conversation = include_str!("conversation.rs"); let composer = include_str!("conversation/composer.rs"); diff --git a/crates/app-iced/src/pages/agent/tests/collapse.rs b/crates/app-iced/src/pages/agent/tests/collapse.rs new file mode 100644 index 0000000..0e0fc02 --- /dev/null +++ b/crates/app-iced/src/pages/agent/tests/collapse.rs @@ -0,0 +1,237 @@ +//! 右栗折叠过渡的**几何判据**(M257 / A2):帧数 + 端点精确 + 严格单调 + 不溢出 + 键盘可达。 +//! +//! 判据口径(`14-motion-and-animation.md` §7-7):**不靠像素比对**。像素能证明「某一帧的宽度是 92」, +//! 证明不了「中间有 13 个相位」「收尾那一帧不多要帧」。观感(好不好看)走 D30 出图。 + +use unlucky_design::motion::MotionScale; + +use super::super::metrics::{gutter_at, COLLAPSED_WIDTH, DEFAULT_EXPANDED_WIDTH}; +use super::super::state::{AgentUi, Focus, RailState}; +use crate::app::components::motion; + +/// 取证窗口宽(与 D30 的产品默认一致)。 +const WINDOW: f32 = 1180.0; +/// 帧泵的单帧步进(`motion::FRAME_MS` 的测试口径)。 +const FRAME: f64 = 16.0; + +/// 每个用例都从干净的动效全局态起跑(thread-local,互不影响,但同一用例内要确定)。 +fn configure(scale: MotionScale) { + motion::reset(); + motion::configure_for_test(scale, None); +} + +/// 把一整个折叠 / 展开过程采样到底(返回**含起点与终点**的宽度序列)。 +fn sample_widths(agent: &AgentUi) -> Vec { + let mut out = vec![agent.rail_width(WINDOW)]; + let mut frames = 0; + while motion::needs_frames() { + motion::advance_by(FRAME); + out.push(agent.rail_width(WINDOW)); + frames += 1; + assert!(frames < 60, "折叠轨道没停,疑似终止条件失效"); + } + out +} + +/// **展开:第一帧还是 48、中间严格单调、结束时严格等于 320、帧数落在 12~20。** +/// +/// 抓得住:①把宽度改回硬跳(`rail_width` 直接返回静息值 ⇒ 第一帧就是 320,本用例的第一步红); +/// ②把终止条件删掉(`while` 后面的兜底断言红);③把时长改成 600ms(帧数超 20)。 +/// 抓不住:中间帧「看起来」顺不顺(那是曲线与观感,走 D30 出图),也抓不住真实帧率。 +#[test] +fn expanding_is_monotone_and_lands_exactly_on_the_settled_width() { + configure(MotionScale::STANDARD); + let mut agent = AgentUi::new(); + assert_eq!(agent.rail, RailState::Collapsed); + + agent.expand(); + let widths = sample_widths(&agent); + let frames = widths.len() - 1; + + assert_eq!( + widths.first().copied(), + Some(COLLAPSED_WIDTH), + "第一帧必须还在折叠档(48,端点精确)" + ); + assert_eq!( + widths.last().copied(), + Some(DEFAULT_EXPANDED_WIDTH), + "最后一帧必须是**严格** 320(不是 319.9x)" + ); + assert!( + (12..=20).contains(&frames), + "250ms / 16ms 应落在 12~20 帧,实测 {frames} 帧" + ); + for pair in widths.windows(2) { + assert!( + pair[1] >= pair[0], + "宽度必须单调不减(不许来回抖):{:?} → {:?}", + pair[0], + pair[1] + ); + } + assert!( + widths.iter().filter(|w| **w > 48.5 && **w < 319.5).count() >= 8, + "必须有足够多的中间宽度(否则就是硬跳):{widths:?}" + ); + assert!(!motion::needs_frames(), "走完必须退订帧"); + motion::reset(); +} + +/// **收起:同一条判据,反向**(`short4` 200ms ⇒ 10~13 帧)。 +#[test] +fn collapsing_is_monotone_downwards_and_ends_exactly_on_the_strip_width() { + configure(MotionScale::STANDARD); + let mut agent = AgentUi::new(); + agent.expand(); + sample_widths(&agent); + assert_eq!(agent.rail_width(WINDOW), DEFAULT_EXPANDED_WIDTH); + + agent.collapse(); + let widths = sample_widths(&agent); + let frames = widths.len() - 1; + + assert_eq!(widths.first().copied(), Some(DEFAULT_EXPANDED_WIDTH)); + assert_eq!(widths.last().copied(), Some(COLLAPSED_WIDTH)); + assert!( + (10..=20).contains(&frames), + "200ms / 16ms 应落在 10~20 帧,实测 {frames} 帧" + ); + for pair in widths.windows(2) { + assert!(pair[1] <= pair[0], "收起必须单调不增:{pair:?}"); + } + motion::reset(); +} + +/// **`MotionScale::NONE`:右栗宽度当帧就是终值**(独立用例,包含在「右栗」这个面上)。 +/// +/// 抓得住:把 `observe_rail` 的 `is_off()` 分支删掉 ⇒ 会插一条 0 时长轨道,本用例的 +/// 「`needs_frames()` 为假」与「第一帧就是 320」两条会同时红。 +/// 抓不住:`NONE` 以外的档位。 +#[test] +fn motion_scale_none_puts_the_rail_at_the_end_value_on_the_first_frame() { + configure(MotionScale::NONE); + let mut agent = AgentUi::new(); + + agent.expand(); + assert_eq!( + agent.rail_width(WINDOW), + DEFAULT_EXPANDED_WIDTH, + "NONE 下展开档当帧就是 320(不是「很快走完」)" + ); + assert!(!motion::needs_frames(), "NONE 下一帧都不许要"); + assert_eq!( + agent.content_width(WINDOW), + WINDOW - DEFAULT_EXPANDED_WIDTH - gutter_at(DEFAULT_EXPANDED_WIDTH), + "中栏也必须当帧拿到终值" + ); + + agent.collapse(); + assert_eq!(agent.rail_width(WINDOW), COLLAPSED_WIDTH); + assert!(!motion::needs_frames()); + motion::reset(); +} + +/// **拆出档:宽度 0、不走过场**(拆分是布局切换,不是过渡对象)。 +/// +/// 抓得住:有人把 `Detached` 也接进折叠轨道(那会做一次 48→0 的缩水,`needs_frames()` 为真)。 +#[test] +fn detached_is_never_animated() { + configure(MotionScale::STANDARD); + let mut agent = AgentUi::new(); + agent.expand(); + sample_widths(&agent); + + agent.detach(); + assert_eq!(agent.rail_width(WINDOW), 0.0, "拆出档不占宽"); + assert!(!motion::needs_frames(), "拆出不许起过渡"); + assert_eq!(agent.content_width(WINDOW), WINDOW, "中栗拿回整窗"); + motion::reset(); +} + +/// **不溢出 + 不抖**:折叠全程的「右栗总占宽 = 面板 + 拖拽缝」单调、且不超过静息展开档。 +/// +/// 抓得住:①把 `gutter_at` 写成常量 8(收尾那一帧总宽会是 56 而不是 48 ⇒ 最后一步的严格相等红); +/// ②让宽度插值越过目标(回弹)——单调与上限两条同时红。 +/// 抓不住:`row!` 里的兄弟顺序写错(那要出图看)。 +#[test] +fn the_rail_never_wider_than_its_settled_footprint() { + configure(MotionScale::STANDARD); + let mut agent = AgentUi::new(); + agent.expand(); + + let mut totals = Vec::new(); + loop { + let width = agent.rail_width(WINDOW); + totals.push(width + gutter_at(width)); + if !motion::needs_frames() { + break; + } + motion::advance_by(FRAME); + } + + let settled = DEFAULT_EXPANDED_WIDTH + gutter_at(DEFAULT_EXPANDED_WIDTH); + for total in &totals { + assert!(*total <= settled, "右栗总占宽越界:{total} > {settled}"); + } + assert_eq!(totals.last().copied(), Some(settled), "收尾必须是静息总宽"); + assert_eq!( + totals.first().copied(), + Some(COLLAPSED_WIDTH), + "起点是折叠档的 48(图标条没有拖拽缝)" + ); + for pair in totals.windows(2) { + assert!(pair[1] >= pair[0], "总占宽必须单调:{pair:?}"); + } + motion::reset(); +} + +/// **键盘可达性:过渡中途按 Tab 的落点与静息时完全相同**(键盘用户不许被动效拖慢)。 +/// +/// 抓得住:有人把焦点环也接进「进度」(例如按当前宽度决定环上有哪些控件)——那会让过渡中途的 +/// 落点与静息不同,本用例红。也抓住「环空了 ⇒ 焦点掉到根」:环首尾相接,从任何一个格位都能走到 +/// 一个真实控件。 +/// 抓不住:Tab 的**时序**(真窗口里按 Tab 到焦点环被画出之间隔几帧,只能人试)。 +#[test] +fn tab_lands_on_the_same_control_mid_transition() { + configure(MotionScale::STANDARD); + let mut agent = AgentUi::new(); + agent.expand(); + + // 走到中途(宽度既不是 48 也不是 320)。 + for _ in 0..6 { + motion::advance_by(FRAME); + } + let width = agent.rail_width(WINDOW); + assert!( + width > COLLAPSED_WIDTH && width < DEFAULT_EXPANDED_WIDTH, + "前提:应该处在过渡中途(实测 {width})" + ); + + // 目标档是展开:环 = [Collapse, Detach, Send, Composer](与宽度无关)。 + let mid = Focus::Collapse.step(false, RailState::Expanded, false, false); + assert_eq!(mid, Focus::Detach, "中途 Tab 的落点必须是真实控件"); + + // 收起到目标档是折叠:环 = [Collapse, Send, Composer],且首尾相接(不会掉到根)。 + let collapsed = Focus::Composer.step(false, RailState::Collapsed, false, false); + assert_eq!( + collapsed, + Focus::Collapse, + "折叠档的环必须首尾相接(焦点不许掉到根)" + ); + motion::reset(); +} + +/// 采样序列里至少要有 8 个「既不是起点也不是终点」的宽度——判定「不是硬切」的真正依据。 +/// +/// 单独一条是因为它比帧数更直接:帧数只说明时间被走完,中间宽度才说明**有中间态**。 +#[test] +fn there_are_real_intermediate_widths() { + configure(MotionScale::STANDARD); + let mut agent = AgentUi::new(); + agent.expand(); + let widths = sample_widths(&agent); + let middle = widths.iter().filter(|w| **w > 48.5 && **w < 319.5).count(); + assert!(middle >= 8, "中间宽度只有 {middle} 个:{widths:?}"); + motion::reset(); +} diff --git a/crates/app-iced/src/pages/agent/tests/drag.rs b/crates/app-iced/src/pages/agent/tests/drag.rs index 5dbbeb5..3687fad 100644 --- a/crates/app-iced/src/pages/agent/tests/drag.rs +++ b/crates/app-iced/src/pages/agent/tests/drag.rs @@ -140,10 +140,16 @@ fn the_drag_recovers_when_a_release_is_missed() { /// 抓不住:tooltip 真弹出来长什么样(那要 hover 出图)。 #[test] fn the_gutter_and_strip_are_labelled() { + // M257:展开档的面板 + 拖拽缝整段搬去了 `panel.rs`(`mod.rs` 要留行数给它后面的折叠判档), + // 所以扫描面变成两个文件——判据本身没变(这两条 key 必须真的被视图用上)。 let hosts = include_str!("../mod.rs"); + let panel = include_str!("../panel.rs"); let strip = include_str!("../strip.rs"); for key in ["Key::AgentResize", "Key::AgentWidthLabel"] { - assert!(hosts.contains(key), "视图里没用上 {key}"); + assert!( + hosts.contains(key) || panel.contains(key), + "视图里没用上 {key}" + ); } // M119:折叠条整段搬去了 `strip.rs`(`mod.rs` 顶到 400 行) assert!(strip.contains("Key::AgentStripLabel"), "折叠条上没画标签"); From c869a7b346129da63559effb24866f555f264f02 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 12 Nov 2026 19:04:49 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(motion2):=20=E4=BF=AE=E6=8E=89=20drawer?= =?UTF-8?q?=20=E8=8D=AF=E4=B8=B8=E5=AE=88=E5=8D=AB=E7=9A=84=E5=90=8C?= =?UTF-8?q?=E4=B9=89=E5=8F=8D=E5=A4=8D=EF=BC=88M257=20r1=20=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=20P2-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rail_selected_label_is_secondary` 里那条 `pill_color(..) == pill_color(..)` 比的是同一个 表达式,恒真、抓不到任何东西,而注释还写着「rail 与 drawer 两条线的药丸一致」。 药丸从 M257 起不吃 layout(`pill_color` 的签名里没有 `NavLayout`),所以「两条线一致」由 签名保证;本用例改判真正要守的那一件事:静息选中药丸来自 `SecondaryContainer`。 变异验证:把 `pill_color` 的兜底角色换成 `Primary` ⇒ 该用例在 tests.rs:54 红。 --- .../src/components/navigation/drawer/tests.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/app-iced/src/components/navigation/drawer/tests.rs b/crates/app-iced/src/components/navigation/drawer/tests.rs index 466d0c8..4044b28 100644 --- a/crates/app-iced/src/components/navigation/drawer/tests.rs +++ b/crates/app-iced/src/components/navigation/drawer/tests.rs @@ -43,11 +43,18 @@ fn rail_selected_label_is_secondary() { let drawer = palette(&ui, BASE, NavLayout::Drawer, Interaction::Enabled, true); assert_eq!(rail.label, ui.color(Role::Secondary)); assert_ne!(rail.label, drawer.label, "两条线的选中标签角色不同"); - // 但图标与药丸相同(同一套语言) - assert_eq!(rail.icon, drawer.icon); + // 图标与药丸相同(同一套语言)。 + // + // 复审 P2-1:这里原先写成 `pill_color(..) == pill_color(..)`——比的是同一个表达式,恒真, + // 抓不到任何东西(注释却还在说「两条线的药丸一致」)。药丸从 M257 起**不吃 layout** + // (`pill_color` 的签名里根本没有 `NavLayout`),所以「两条线一致」由**签名**保证; + // 本用例改判真正要守的那件事:静息选中药丸就是 `SecondaryContainer` 这一档 + // (换成 `Primary` 立刻红,变异原文见 M257 r2 报告)。 + assert_eq!(rail.icon, drawer.icon, "图标与布局无关"); assert_eq!( pill_color(&ui, BASE, Interaction::Enabled, true), - pill_color(&ui, BASE, Interaction::Enabled, true) + Some(ui.color(Role::SecondaryContainer)), + "两条线的静息选中药丸都必须来自 SecondaryContainer" ); }