用户原话「默认左侧固定,允许折叠」:M297 盘查确认 iced 从来没接过折叠按钮, presenter 的 toggle_nav/nav_collapsed 是 Slint 遗留、唯一调用方在已退役的 crates/app。 ## 令牌(先定数值再动手) - nav_collapsed_width = 56 + 2 × Space::X1 = **64**:胶囊(M283 定稿 56×32,本轮不动) 两侧各留 4px 才不被裁切;旧值 48 的死因写进注释(48 − 2×4 = 40 < 56)。 - 断言:折叠宽 ≥ 胶囊宽 + 2×X1、≥ touch_target 48、在 4px 栅格上。 ## 折叠按钮与折叠态 - 按钮放**轨底、头像位之上**:轨顶一个像素都不动(M283/M299 的展开态对照图仍可比), 轨底本来就是「固定贴底」的槽,两态下位置不变。几何与导航项同一套(56×32 药丸 + 52 命中高)。 - 折叠态:隐藏文字标签与账户名,每项/头像/按钮都补 M3 气泡 tooltip(折叠态不许是一列无名符号); 纵节奏不变(项高仍 52),所以切换时图标不上下跳。五项图标两两不同由用例钉住。 ## 动效(复用底盘、零新常数) - 帧泵加**第二条并列宽度轨**(nav ⇄ rail),时长/曲线复用同一张 rail_spec(); 借用右栗那条会让左轨折叠改掉右栗的目标(暗耦合),tower 已否掉那个方案。 - 起跑在 update 侧;启动按落盘档对表(幂等,仅在没有轨道在跑时);折叠**不置 view_dirty** —— 宽度在 view 里逐帧采样,帧由轨道自己续,跑完 needs_frames() 回 false。 ## 落盘(任务要求「重启读回仍折叠」) - core schema 登记 ui.nav.collapsed(纯增量 bool,不碰 schemaVersion);presenter 桥加 load/persist 一对(走唯一写入口 write_with,现读→改→落盘);启动 construct 读回。 - 截图通道不读用户配置(D30),折叠态出图走 ULL_NAV_COLLAPSED=1。 ## 判据 - app-iced: 1053 passed(新增 3 条接线用例 + 7 条 shell/几何/树结构用例 + 6 条帧泵用例); presenter: 408 passed(新增落盘往返 1 条);fmt / clippy --workspace --all-targets 均 0。 - 变异实验:删 observe_nav ⇒ 宽度停在 88 红;删启动对表 ⇒ 首次展开硬跳红。 - 像素(DevDocs/artifacts/m300/):折叠态内容左缘 112→88(24px 真回收)、药丸两态都 56 宽 且折叠档右缘 59 < 64(不裁切)、图标行是展开态子集(纵节奏不变)。 ## 已知偏离(已登记,tower 批了紧随 mission) crates/app-iced/src/pages/layout.rs 的 rail_width() 仍是 const fn(恒 88),所以折叠态内容块 宽度不变、整块左移 24px ⇒ 右侧多一条 24px 空白带(像素图里保留、未裁)。
359 lines
15 KiB
Rust
359 lines
15 KiB
Rust
//! 帧泵的**纯逻辑**:时间轴 / 页面切换轨道 / 模态轨道 / 采样。
|
||
//!
|
||
//! 这一层不认识 iced、不认识线程、不读环境变量——那些都在 [`super`]。分开的理由很实际:
|
||
//! 「帧间隔上限」「结束后必须清空」这两条守卫是**算术性质**,用本地 `Pump` 直接摆时间轴就能判死,
|
||
//! 不必起窗口、不必等真实时间(那两条最容易写成「看起来在跑」的假绿)。
|
||
//!
|
||
//! ## 四条轨道(M256 / M257 / M300):页面两腿 + 模态进出 + 两条宽度轨,各走各的时间轴(互不替代)
|
||
//!
|
||
//! | 轨道 | 触发 | 结束 |
|
||
//! |---|---|---|
|
||
//! | 页面 | 内容标识变了([`Pump::observe`]) | 两腿里长的那条走完 |
|
||
//! | 模态 | 弹窗开 / 关([`Pump::observe_modal`] / [`Pump::begin_modal_close`]) | 进场转 [`Modal::Open`](**不再要帧**)、退场转 [`Modal::Hidden`] |
|
||
//! | 右栗折叠(M257) | 右栗档位变([`Pump::observe_rail`],拆出档走 [`Pump::snap_rail`]) | 主进度走完一条腿(`medium1` / `short4`) |
|
||
//! | 左轨折叠(M300) | 导航轨折叠档变([`Pump::observe_nav`],启动按落盘档对表走 [`Pump::snap_nav`]) | 同上(**同一张** [`super::transition::rail_spec`]) |
|
||
//!
|
||
//! 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 次/秒)。
|
||
///
|
||
/// ⚠️ **这只卡「采样与布局推进」,不卡整窗重绘。** 两个频率必须分开看:
|
||
///
|
||
/// | 什么 | 频率 | 由谁决定 |
|
||
/// |---|---|---|
|
||
/// | 时间轴推进 / 位移采样(本常量) | ≤62.5 次/秒 | 这里 |
|
||
/// | **整窗重绘** | **跟随渲染器**:软件渲染实测 ≈**970 帧/秒**(一次 300 ms 的换页 ≈**290 次整窗重绘** ≈ **0.30 核·秒**);GPU 档被 AutoVsync 限到 40~60 | `window::frames()` 一订阅,iced_winit 每条消息后都 `request_redraw()`(iced_winit-0.14.1 `src/lib.rs:1222-1240`) |
|
||
pub const FRAME_MS: f64 = 16.0;
|
||
|
||
/// 单次推进的**最大步进**(4 帧 = 64 ms)。
|
||
///
|
||
/// 为什么需要它:`advance` 的 dt 来自「上一帧到现在」,而空闲期根本没有帧——醒来的第一帧如果
|
||
/// 按真实流逝(秒~分钟级)结算,`duration` 当场就被跨过 ⇒ 到期轨道被立刻删除 ⇒ 动画一帧走完
|
||
/// (M192 r1 复审 P1-1 就是这么暴露的)。起点由 `motion::observe` 重置,这里再兜一道:
|
||
/// **任何一次采样最多只吃掉 4 帧的时长**,掉帧/被挂起时宁可把动画放慢,也不许把它一次性抹掉。
|
||
pub const MAX_STEP_MS: f64 = FRAME_MS * 4.0;
|
||
|
||
/// 一条正在跑的页面轨道。
|
||
#[derive(Debug, Clone, Copy)]
|
||
struct PageTrack {
|
||
kind: Kind,
|
||
spec: PageSpec,
|
||
start_ms: f64,
|
||
}
|
||
|
||
/// 帧泵:一条时间轴 + 最多一条页面轨道 + 一个模态状态机。
|
||
///
|
||
/// **默认是关的**(`MotionScale::NONE`):产品启动时由 `App::on_boot` 按档位打开,而
|
||
/// `pages_demo` 这类截图 harness 从不打开——D30 的「同一份代码出同一张图」因此不受动效影响。
|
||
#[derive(Debug)]
|
||
pub struct Pump {
|
||
scale: MotionScale,
|
||
/// `Some(ms)` = 钉住相位(多帧序列图取证;见 `ULL_MOTION_AT`)。
|
||
pin: Option<f64>,
|
||
now_ms: f64,
|
||
pending_ms: f64,
|
||
page: Option<PageTrack>,
|
||
modal: Modal,
|
||
/// 右栗折叠轨道(M257):`Some` = 宽度正在 `48 ⇄ 展开档` 之间走。
|
||
rail: Option<RailTrack>,
|
||
/// 右栗的目标档(`true` = 展开)。没有轨道时进度就是它的终值。
|
||
rail_open: bool,
|
||
/// 左轨(导航轨)折叠轨道(M300):与右栗**并列**的另一条宽度轨(`64 ⇄ 88`)。
|
||
nav: Option<RailTrack>,
|
||
/// 左轨的目标档(`true` = 展开 88)。出厂展开;启动时按落盘档由 [`Pump::snap_nav`] 对表。
|
||
nav_open: bool,
|
||
/// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。
|
||
close_pending: bool,
|
||
screen: Option<u64>,
|
||
/// 该屏在**导航轨**上的次序(`shell::nav::NavItem::ALL` 下标;`None` = 不在轨上)。
|
||
rank: Option<u8>,
|
||
detail: Option<String>,
|
||
painted: bool,
|
||
samples: u64,
|
||
}
|
||
|
||
impl Pump {
|
||
/// 出厂态:关着、没跑过、没有轨道。
|
||
pub const fn new() -> Self {
|
||
Self {
|
||
scale: MotionScale::NONE,
|
||
pin: None,
|
||
now_ms: 0.0,
|
||
pending_ms: 0.0,
|
||
page: None,
|
||
modal: Modal::Hidden,
|
||
rail: None,
|
||
rail_open: false,
|
||
nav: None,
|
||
nav_open: true,
|
||
close_pending: false,
|
||
screen: None,
|
||
rank: None,
|
||
detail: None,
|
||
painted: false,
|
||
samples: 0,
|
||
}
|
||
}
|
||
|
||
/// 定档(启动一拍调一次)。
|
||
///
|
||
/// 顺带**重置内容标识**:iced 会在处理 `Boot` 之前先问一次 `view()`(实测:那一帧早于
|
||
/// `on_boot` 的日志),于是「第一帧」发生在档位还没定下来的时候。不重置的话首屏入场就被那次
|
||
/// 预渲染吃掉了(`painted = true`),之后标识再没变过 ⇒ Boot 槽永远不跑。
|
||
pub fn configure(&mut self, scale: MotionScale, pin: Option<f64>) {
|
||
self.scale = scale;
|
||
self.pin = pin;
|
||
self.painted = false;
|
||
self.screen = None;
|
||
self.rank = None;
|
||
self.detail = None;
|
||
self.pending_ms = 0.0;
|
||
self.modal = Modal::Hidden;
|
||
// 定档时右栗按出厂档(收起)复位:定档之后的第一帧画的就是 48,不会先画一次展开态。
|
||
self.rail = None;
|
||
self.rail_open = false;
|
||
// 左轨出厂是**展开**(`UiPrefs::nav_collapsed` 的出厂值 false)。落盘过折叠档的会话由
|
||
// `app/update/motion_sync.rs` 在定档之后 `snap_nav` 对表(那里的判据是「没有轨在跑」)。
|
||
self.nav = None;
|
||
self.nav_open = true;
|
||
self.close_pending = false;
|
||
if scale.is_off() {
|
||
// 关掉动效 = 直接落终态:把在跑的轨道清掉,而不是让它跑一遍 0 时长。
|
||
self.page = None;
|
||
}
|
||
}
|
||
|
||
/// 当前档位。
|
||
pub fn scale(&self) -> MotionScale {
|
||
self.scale
|
||
}
|
||
|
||
/// 已推进的帧数(守卫与 `ULL_TICK_TRACE` 用)。
|
||
pub fn samples(&self) -> u64 {
|
||
self.samples
|
||
}
|
||
|
||
/// 时间轴(毫秒)。
|
||
#[cfg(test)]
|
||
pub fn now_ms(&self) -> f64 {
|
||
self.now_ms
|
||
}
|
||
|
||
/// 现在有没有动画在跑(= 要不要订阅帧)。
|
||
pub fn is_animating(&self) -> bool {
|
||
self.page.is_some()
|
||
|| self.rail.is_some()
|
||
|| self.nav.is_some()
|
||
|| matches!(self.modal, Modal::Opening { .. } | Modal::Closing { .. })
|
||
}
|
||
|
||
/// 左轨的宽度轨道是否在跑(`motion_sync` 用它判「该不该按落盘档对表」)。
|
||
pub fn nav_animating(&self) -> bool {
|
||
self.nav.is_some()
|
||
}
|
||
|
||
/// 轨道起点的口径:钉住相位时恒为 0(进度只取决于钉住值,与"谁在第几帧起跑"无关)。
|
||
fn start_now(&self) -> f64 {
|
||
if self.pin.is_some() {
|
||
0.0
|
||
} else {
|
||
self.now_ms
|
||
}
|
||
}
|
||
|
||
/// 当前动画时刻(钉住相位时恒为钉住值)。
|
||
fn elapsed_ms(&self) -> f64 {
|
||
self.pin.unwrap_or(self.now_ms)
|
||
}
|
||
|
||
/// 把某一档时长换算成实际毫秒(`MotionScale` 是唯一缩放口)。
|
||
fn leg_ms(&self, duration: DurationToken) -> f64 {
|
||
f64::from(self.scale.ms(duration))
|
||
}
|
||
|
||
// ------------------------------------------------------------ 页面切换
|
||
|
||
/// 起跑一次页面切换(关着动效时什么都不做——**不是**跑一遍 0 时长动画)。
|
||
///
|
||
/// 新轨道**取代**上一条:同一时刻只有一次页面切换(否则位移会叠加)。
|
||
pub fn play_page(&mut self, kind: Kind) -> bool {
|
||
self.page = None;
|
||
if self.scale.is_off() {
|
||
return false;
|
||
}
|
||
let spec = page_spec(kind);
|
||
self.page = Some(PageTrack {
|
||
kind,
|
||
spec,
|
||
start_ms: self.start_now(),
|
||
});
|
||
true
|
||
}
|
||
|
||
/// 当前页面轨道(测试与日志用;产品路径只看采样出来的位移)。
|
||
#[allow(dead_code)]
|
||
pub fn kind(&self) -> Option<Kind> {
|
||
self.page.map(|track| track.kind)
|
||
}
|
||
|
||
/// 页主体的这一帧采样(**只采样,无副作用**)。
|
||
///
|
||
/// 两条腿各走各的时长:进场读 `enter.duration`,退场读 `exit.duration`(更短)。
|
||
/// `exiting` 在退场腿走完的那一帧就变假 ⇒ 调用方不再画旧页(不长留一份幽灵树)。
|
||
pub fn page_frame(&self) -> PageFrame {
|
||
let Some(track) = &self.page else {
|
||
return PageFrame::STILL;
|
||
};
|
||
let elapsed = self.elapsed_ms() - track.start_ms;
|
||
let enter_t = leg_fraction(elapsed, self.leg_ms(track.spec.enter.duration));
|
||
let exit_t = leg_fraction(elapsed, self.leg_ms(track.spec.exit.duration));
|
||
PageFrame {
|
||
enter: enter_offset(
|
||
track.spec.enter_from,
|
||
track.spec.enter.easing.sample(enter_t),
|
||
),
|
||
exit: exit_offset(track.spec.exit_to, track.spec.exit.easing.sample(exit_t)),
|
||
exiting: exit_t < 1.0,
|
||
}
|
||
}
|
||
|
||
/// 内容**标识**变了就起跑对应的一档;**必须在 update 侧调**(见 `App::sync_motion`)。
|
||
///
|
||
/// 三条判据(顺序即优先级):
|
||
/// 1. 第一次看到**归 pages 画的屏** → [`Kind::Boot`];
|
||
/// 2. 屏变了 → [`Kind::Peer`],方向由**屏在导航表里的先后**决定(更靠后的屏 = 向右推进);
|
||
/// 3. 屏没变但详情变了 → 列表→详情 = [`Kind::Push`](深一层)、详情→列表 = [`Kind::Pop`],
|
||
/// 两个不同的详情之间横跳按同级处理。
|
||
///
|
||
/// `screen == None`(账户页 / AI 独立页:它们不走 `pages::render`)**只记标识、不起跑**。
|
||
///
|
||
/// 返回 `true` = 真的起跑了新轨道(调用方要重置帧基准,见 [`MAX_STEP_MS`])。
|
||
pub fn observe(&mut self, screen: Option<u64>, rank: Option<u8>, detail: Option<&str>) -> bool {
|
||
let kind = if !self.painted {
|
||
self.painted = true;
|
||
screen.map(|_| Kind::Boot)
|
||
} else if self.screen != screen {
|
||
match (self.screen, screen, self.rank, rank) {
|
||
// 左右方向 = **用户在导航轨上看到的次序**(`rank`),不是内部屏键的大小:
|
||
// 两者顺序不同(`Screen::ALL` 里 Settings 在 Logs 前,导航轨里 Logs 在 Settings
|
||
// 前)⇒ 用屏键会把 Logs↔Settings 镜像。同 rank(Download / Content 共用导航项)
|
||
// 时退到屏键比大小,方向仍是"向后 / 向前"。
|
||
(Some(old_key), Some(new_key), Some(old_rank), Some(new_rank)) => {
|
||
Some(Kind::Peer {
|
||
forward: (new_rank, new_key) > (old_rank, old_key),
|
||
})
|
||
}
|
||
// 从「不归 pages 画」的屏回来:没有旧页可退,按首屏那一档的入场方向。
|
||
(_, Some(_), _, _) => Some(Kind::Boot),
|
||
_ => None,
|
||
}
|
||
} else if screen.is_some() && self.detail.as_deref() != detail {
|
||
Some(match (self.detail.is_some(), detail.is_some()) {
|
||
(false, true) => Kind::Push,
|
||
(true, false) => Kind::Pop,
|
||
// 两个不同的详情之间横跳:深度没变,按同级换内容处理。
|
||
_ => Kind::Peer { forward: true },
|
||
})
|
||
} else {
|
||
None
|
||
};
|
||
|
||
self.screen = screen;
|
||
self.rank = rank;
|
||
if self.detail.as_deref() != detail {
|
||
self.detail = detail.map(str::to_owned);
|
||
}
|
||
kind.is_some_and(|kind| self.play_page(kind))
|
||
}
|
||
|
||
// ------------------------------------------------------------ 模态
|
||
|
||
// ------------------------------------------------------------ 时间轴
|
||
|
||
/// 推进时间轴。返回 `true` = 这一帧真的推进了(进度变了 ⇒ 要重画)。
|
||
///
|
||
/// 四步:①不满 [`FRAME_MS`] 就不动(采样上限)②**单次最多吃 [`MAX_STEP_MS`]**(掉帧 /
|
||
/// 被挂起 / 空闲醒来的第一帧都不许一次性跨过时长)③到期的轨道**当场删除**(这是「空闲不烧帧」
|
||
/// 的唯一出口)④钉住相位时时间轴不前进(取证专用)。
|
||
pub fn advance(&mut self, dt_ms: f64) -> bool {
|
||
if !self.is_animating() {
|
||
self.pending_ms = 0.0;
|
||
return false;
|
||
}
|
||
if dt_ms.is_finite() && dt_ms > 0.0 {
|
||
// ②:上限不是「省事」,是正确性——stale 的帧基准(空闲整段)会把轨道一帧杀完。
|
||
self.pending_ms += dt_ms.min(MAX_STEP_MS);
|
||
}
|
||
if self.pending_ms < FRAME_MS {
|
||
return false;
|
||
}
|
||
let step = std::mem::take(&mut self.pending_ms);
|
||
if self.pin.is_none() {
|
||
self.now_ms += step;
|
||
}
|
||
self.samples += 1;
|
||
self.expire();
|
||
true
|
||
}
|
||
|
||
/// 到期轨道当场清掉(③)。
|
||
fn expire(&mut self) {
|
||
let now = self.elapsed_ms();
|
||
if let Some(track) = self.page {
|
||
let enter = self.leg_ms(track.spec.enter.duration);
|
||
let exit = self.leg_ms(track.spec.exit.duration);
|
||
if now - track.start_ms >= enter.max(exit) {
|
||
self.page = None;
|
||
}
|
||
}
|
||
if let Some(track) = self.rail {
|
||
if now - track.start_ms >= track.duration_ms {
|
||
self.rail = None;
|
||
}
|
||
}
|
||
if let Some(track) = self.nav {
|
||
if now - track.start_ms >= track.duration_ms {
|
||
self.nav = None;
|
||
}
|
||
}
|
||
let spec = modal_spec();
|
||
match self.modal {
|
||
Modal::Opening { start_ms } if now - start_ms >= self.leg_ms(spec.enter.duration) => {
|
||
self.modal = Modal::Open
|
||
}
|
||
Modal::Closing { start_ms, .. }
|
||
if now - start_ms >= self.leg_ms(spec.exit.duration) =>
|
||
{
|
||
self.modal = Modal::Hidden;
|
||
self.close_pending = true;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `elapsed / duration` 夹到 `0..=1`(`duration <= 0` ⇒ 直接到位,不产生 NaN)。
|
||
fn leg_fraction(elapsed: f64, duration: f64) -> f32 {
|
||
if duration <= 0.0 {
|
||
return 1.0;
|
||
}
|
||
(elapsed / duration).clamp(0.0, 1.0) as f32
|
||
}
|
||
|
||
impl Default for Pump {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|