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