feat(motion2): 右栗折叠过渡 + 状态层 hover/pressed 过渡
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 未改)
This commit is contained in:
1 parent
c4706c759c
commit
b61f342431
22 files changed
+1837
-145
No files matched your search
@@ -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<iced::Color> {
|
||||
interaction::overlay_color(ui, state, content, base.role())
|
||||
interaction::layer_color(ui, state, content, base.role())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Element<'a, Message, Theme, Renderer>>,
|
||||
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<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
for Tracked<'_, Message, Theme, Renderer>
|
||||
where
|
||||
Renderer: renderer::Renderer,
|
||||
{
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<State>()
|
||||
}
|
||||
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(State::default())
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(&self.content)]
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
|
||||
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(&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::<State>();
|
||||
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::<State>();
|
||||
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<iced::overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<f32> = const { Cell::new(0.0) };
|
||||
/// 还没到期的过渡(时刻表)。到期的**当场剔除**,否则空闲也一直要帧。
|
||||
#[allow(clippy::missing_const_for_thread_local)]
|
||||
static DEADLINES: RefCell<Vec<f32>> = const { RefCell::new(Vec::new()) };
|
||||
/// 正在画的控件栈([`with_current`] 压、包装控件的 `draw` 用)。
|
||||
#[allow(clippy::missing_const_for_thread_local)]
|
||||
static STACK: RefCell<Vec<Layer>> = 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<R>(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;
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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<Color> {
|
||||
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);
|
||||
|
||||
@@ -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<Element<'a, Message, Theme, Renderer>>,
|
||||
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<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||
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<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 {
|
||||
// 排版宽**只**认 `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<iced::overlay::Element<'a, Message, Theme, Renderer>> {
|
||||
let child = layout.children().next()?;
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.overlay(tree, child, renderer, viewport, translation)
|
||||
}
|
||||
}
|
||||
@@ -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` 读真实时钟,单元测试不能等它)。
|
||||
|
||||
@@ -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<PageTrack>,
|
||||
modal: Modal,
|
||||
/// 右栗折叠轨道(M257):`Some` = 宽度正在 `48 ⇄ 展开档` 之间走。
|
||||
rail: Option<RailTrack>,
|
||||
/// 右栗的目标档(`true` = 展开)。没有轨道时进度就是它的终值。
|
||||
rail_open: bool,
|
||||
/// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。
|
||||
close_pending: bool,
|
||||
screen: Option<u64>,
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
//! 模态 / 系统偏好 / 降级那一半在 [`modal`]。
|
||||
|
||||
mod modal;
|
||||
mod rail;
|
||||
mod timeline;
|
||||
|
||||
use super::pump::Pump;
|
||||
|
||||
@@ -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<f32> {
|
||||
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();
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Color>,
|
||||
/// 图标色。
|
||||
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<Color> {
|
||||
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::<M>(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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// 禁用是**修饰器**:只清消息,图标 / 选中态全部保留。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Element<'a, Message>> {
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 展开档:拖拽缝 + 面板
|
||||
|
||||
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::<IcedTheme>(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 {
|
||||
|
||||
@@ -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::<IcedTheme>(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()
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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<f32> {
|
||||
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();
|
||||
}
|
||||
@@ -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"), "折叠条上没画标签");
|
||||
|
||||
Reference in new issue
Block a user