feat(app-iced): 页面切换按导航深度定向 + 模态缩放淡入(M256 MOTION1)
- transition.rs:唯一一张动效表。同级换页走水平(±12 / 旧页 ∓8)、进深层走纵向
(新页 +16 上浮 / 旧页 -8),进腿 medium2/medium1(300/250),退腿 short4→medium1(更短),
进用 Standard、退用 StandardAccelerate。幅度全部取自 Space 令牌。
- pump.rs:Slot 三互斥槽 → Kind(Boot/Peer{forward}/Push/Pop)+ 两腿轨道;新增模态状态机
(Opening/Open/Closing,展开后不占帧,退场走完交回一次 take_modal_closed)。
- components/motion/zoom.rs:绕中心缩放的自绘控件(renderer.with_transformation,
tiny-skia 与 wgpu 都实现了,文字也真的跟着缩放);scale==1.0 时不套变换 ⇒ 出图基线不变。
- pages/mod.rs:page_with_settings_transition(ui, current, previous, …) 叠
stack![opaque(旧页@退场位移), 新页@进场位移];旧函数只剩一句委托(blast radius 不变)。
- App.previous_view:换页时用 std::mem::replace **移动**旧模型(冻结快照),过渡结束当拍
置 None(不随换页次数增长)。
- 模态:逻辑状态当拍关(state.dialogs 立刻空),画面多留 150ms;退场期间弹窗消息被闸门吞掉。
- platform/motion.rs:系统「减少动效」三态探针(Windows=SystemParametersInfoW,
Linux/macOS=子进程+300ms 超时),OnceLock 缓存;Unknown **不降级**(探针失败不静默关动效)。
- MotionScale::HALF 语义核清并写进 design 文档:是**时长减半**(更快),不是半速。
This commit is contained in:
1 parent
5a182e80de
commit
b1c09c4c91
21 files changed
+2168
-658
No files matched your search
@@ -27,6 +27,7 @@ use unlucky_design::typography::TypeRole;
|
||||
use crate::app::components::buttons;
|
||||
use crate::app::components::feedback::dialog::scrim;
|
||||
use crate::app::components::feedback::panel::DialogGeometry;
|
||||
use crate::app::components::motion;
|
||||
use crate::pages::frame;
|
||||
use crate::theme::Ui;
|
||||
|
||||
@@ -58,37 +59,68 @@ pub(crate) fn layer<'a>(
|
||||
base: Element<'a, Message>,
|
||||
modal: &'a Modal,
|
||||
) -> Element<'a, Message> {
|
||||
// M256:模态进出只由**一个**进度驱动——遮罩做画布色→scrim 色的**预合成**插值(不透明),
|
||||
// 卡片做 0.96→1.0 的缩放。`progress = 1` 时两者都退化成改造前的原值(出图基线不变)。
|
||||
let progress = motion::modal().progress;
|
||||
|
||||
let backdrop = mouse_area(
|
||||
container(Space::new())
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(backdrop_color(ui))),
|
||||
background: Some(Background::Color(backdrop_color_at(ui, progress))),
|
||||
..container::Style::default()
|
||||
}),
|
||||
)
|
||||
.on_press(Message::Dialog(DialogMessage::Dismiss));
|
||||
|
||||
// 透明的一层只负责居中;会吃点击的 `opaque` 只包卡片。
|
||||
let centered = container(card_plane(ui, modal))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.padding(ui.space(Sp::X6));
|
||||
let centered = container(motion::scaled(
|
||||
card_plane(ui, modal),
|
||||
motion::modal_scale(progress),
|
||||
))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.padding(ui.space(Sp::X6));
|
||||
|
||||
iced::widget::stack![base, backdrop, centered].into()
|
||||
}
|
||||
|
||||
/// 遮罩色:**组件层那一份不透明预合成**(黑 @0.32 叠在画布上)。
|
||||
/// 遮罩色(静息 = 完全展开):**组件层那一份不透明预合成**(黑 @0.32 叠在画布上)。
|
||||
///
|
||||
/// 为什么不自己拿 `Role::OnSurface` 加 α:它在深色档是 **tone 90(近白)**,32% 铺上去会把
|
||||
/// 整窗**提亮**(r1 复审实测 (9,21,28) → (129,138,145));而且 α 混色在 wgpu/tiny-skia 两档
|
||||
/// 合成口径不同(linear vs sRGB),D27 要求 CPU 档零半透明。组件层 [`scrim`] 两件事都解决了。
|
||||
/// 不自己拿 `Role::OnSurface` 加 α:它在深色档是 **tone 90(近白)**,32% 铺上去会把整窗
|
||||
/// **提亮**(r1 实测 (9,21,28) → (129,138,145));且 α 混色在 wgpu/tiny-skia 两档合成口径不同
|
||||
/// (linear vs sRGB),D27 要求 CPU 档零半透明。组件层 [`scrim`] 两件事都解决了。
|
||||
///
|
||||
/// 单独成函数是为了让 [`super::tests`] 断言**视图真正用的那一份**(P1 的守卫)。
|
||||
/// 只在测试构建里存在:让 [`super::tests`] 断言**视图真正用的那一份**;产品路径一律走
|
||||
/// [`backdrop_color_at`](`progress = 1` 那一端逐位相同)。
|
||||
#[cfg(test)]
|
||||
pub(super) fn backdrop_color(ui: &Ui) -> Color {
|
||||
scrim(ui)
|
||||
backdrop_color_at(ui, 1.0)
|
||||
}
|
||||
|
||||
/// 遮罩色在**动画进度** `progress` 上的取值(M256):画布色 → scrim 色的预合成插值。
|
||||
///
|
||||
/// **为什么是"插值颜色"而不是"调 α"**:D27 禁 CPU 渲染路径上的面板级半透明(D36 只对**窗口级**
|
||||
/// 统一 α 开过口子,面板 α 仍强制 1.0)。所以「淡入」不能靠 `Color { a: 0.1 }`,只能把**已经
|
||||
/// 合成好的不透明色**从画布色插值到 scrim 色:`0` 时与背后画布同色(看不见)、`1` 时是原遮罩,
|
||||
/// 全程 `a = 1.0`,两个后端出图一致。`1` 那一端必须**逐位等于**静息遮罩色,否则出图基线会漂。
|
||||
pub(super) fn backdrop_color_at(ui: &Ui, progress: f32) -> Color {
|
||||
let t = progress.clamp(0.0, 1.0);
|
||||
if t >= 1.0 {
|
||||
return scrim(ui);
|
||||
}
|
||||
let from = ui.layer(MaterialLayer::Canvas);
|
||||
let to = scrim(ui);
|
||||
Color {
|
||||
r: from.r + (to.r - from.r) * t,
|
||||
g: from.g + (to.g - from.g) * t,
|
||||
b: from.b + (to.b - from.b) * t,
|
||||
// 预合成色两端都是不透明的:这里显式钉死 1.0,不给"淡入=半透明"留口子。
|
||||
a: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 卡片层:`opaque` 让「卡片内的点击」被吃掉,不再冒到遮罩(点遮罩才关)。
|
||||
@@ -297,3 +329,66 @@ fn wrapped<'a>(ui: &'a Ui, content: &'a str, color: Color) -> Element<'a, Messag
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod motion_tests {
|
||||
//! 模态进场的**可判读数值取证**(M256 / tower 门槛 E):缩放值与遮罩色逐档钉住。
|
||||
//!
|
||||
//! 抓得住:把淡入做回「调 α」(`a < 1` 即红,D27)、把 `progress = 1` 做成近似值(与
|
||||
//! `backdrop_color` 不等即红)、把缩放方向做反。抓不住:**观感**(截图见 artifacts/motion1)。
|
||||
|
||||
use super::*;
|
||||
use crate::app::components::motion;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::typography::Platform;
|
||||
|
||||
const FAMILIES: crate::theme::Families = crate::theme::Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
fn luma(color: Color) -> f32 {
|
||||
0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b
|
||||
}
|
||||
|
||||
/// 遮罩:`0` 是画布色(看不见)、`1` 是原遮罩(逐位相同)、全程不透明且单调变暗。
|
||||
#[test]
|
||||
fn the_scrim_fades_by_lerping_opaque_colors_never_by_alpha() {
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let ui = crate::theme::Ui::with_platform(flavor, Platform::Windows, FAMILIES);
|
||||
let canvas = ui.layer(MaterialLayer::Canvas);
|
||||
let rest = backdrop_color(&ui);
|
||||
assert_eq!(
|
||||
backdrop_color_at(&ui, 0.0),
|
||||
canvas,
|
||||
"{flavor:?}: 起手同画布"
|
||||
);
|
||||
assert_eq!(
|
||||
backdrop_color_at(&ui, 1.0),
|
||||
rest,
|
||||
"{flavor:?}: 到位逐位相同"
|
||||
);
|
||||
|
||||
let mut previous = luma(canvas);
|
||||
for step in [0.0_f32, 0.25, 0.5, 0.75, 1.0] {
|
||||
let color = backdrop_color_at(&ui, step);
|
||||
assert_eq!(color.a, 1.0, "{flavor:?}: 遮罩必须全程不透明(D27)");
|
||||
let current = luma(color);
|
||||
assert!(
|
||||
current <= previous + 1.0e-6,
|
||||
"{flavor:?}: 遮罩必须单调变暗({previous} → {current})"
|
||||
);
|
||||
previous = current;
|
||||
println!(
|
||||
"[motion1] {flavor:?} t={step:.2} scale={:.3} rgb({:.3},{:.3},{:.3})",
|
||||
motion::modal_scale(step),
|
||||
color.r,
|
||||
color.g,
|
||||
color.b
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,15 @@ pub struct App {
|
||||
pub(super) width: f32,
|
||||
/// 展示转换缓存(presenter 快照 → 页面视图模型)。
|
||||
pub(super) view: pages::Model,
|
||||
/// **换页过渡期的旧页快照**(M256):页面切换时把上一帧的 `view` 移进来,退场腿走完就丢掉。
|
||||
///
|
||||
/// 为什么必须存在 `App` 里而不是线程局部:`pages::page_with_settings_transition` 返回的
|
||||
/// `Element<'a>` **借**模型里的字符串,`'a` 由调用方决定 ⇒ 线程局部的借用守卫活不过函数体,
|
||||
/// 只有 `App` 的字段能给出 `&'a Model`。
|
||||
///
|
||||
/// **用完即焚**:`App::sync_motion` 在 `motion::needs_frames()` 为假的那一拍把它置 `None`——
|
||||
/// 这是长时间常驻的启动器,留着就是「每换一次页多一份模型」的线性增长。
|
||||
pub(super) previous_view: Option<pages::Model>,
|
||||
/// 视图模型是否过期(P2-1):只有 `apply` / `navigate` / resize / 主题这类**真的改了状态**
|
||||
/// 的事情才置位,`advance()` 每拍只在置位时重建模型(否则 30 Hz × N 实例的白克隆)。
|
||||
pub(super) view_dirty: bool,
|
||||
|
||||
@@ -126,6 +126,8 @@ impl App {
|
||||
last_tick: std::time::Instant::now(),
|
||||
width,
|
||||
view,
|
||||
// M256:换页过渡期的「旧页」快照。启动时没有旧页。
|
||||
previous_view: None,
|
||||
view_dirty: false,
|
||||
watch_until: None,
|
||||
window: None::<window::Id>,
|
||||
|
||||
@@ -9,6 +9,7 @@ use unlucky_presenter::dto::{AppCommand, ModsCommand, SavesCommand};
|
||||
use super::{mods_shot, saves_shot};
|
||||
use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
use crate::app::components::motion;
|
||||
use crate::app::view;
|
||||
use crate::pages;
|
||||
|
||||
@@ -81,7 +82,7 @@ impl App {
|
||||
// 右栏占掉的宽度先扣掉:面板展开时页面按更窄的宽度排版(「收缩而不是溢出」,M104)
|
||||
let width = self.agent.content_width(self.width);
|
||||
// M106:语言形参跟着 `LangState` 走(`draft` 那一位是 M105 的安装草稿)。
|
||||
self.view = super::super::view::model(
|
||||
let next = super::super::view::model(
|
||||
&self.state,
|
||||
width,
|
||||
self.height,
|
||||
@@ -91,6 +92,15 @@ impl App {
|
||||
self.lang(),
|
||||
&self.content,
|
||||
);
|
||||
// M256:换页 / 换层(屏 或 详情 id 变了)时才把**上一帧的模型**移进 `previous_view`。
|
||||
// 用 `replace`(移动)而不是克隆:旧页要的是**转换开始那一刻的快照**,之后 presenter 再怎么
|
||||
// 刷新都不该动它(否则会看到"两个页面同时在动内容")。
|
||||
// 同一屏的普通刷新(下载进度 / 主题 / 语言)不换——否则每来一条事件就多留一份模型。
|
||||
if (self.view.screen, detail_key(&self.view)) != (next.screen, detail_key(&next)) {
|
||||
self.previous_view = Some(std::mem::replace(&mut self.view, next));
|
||||
} else {
|
||||
self.view = next;
|
||||
}
|
||||
// M157:启动参数表单的重播种(换实例必重播种、同实例的脏草稿不覆盖)。
|
||||
self.sync_launch_form();
|
||||
// 账户页(M114):与 `view` 同一次门控重建(实现见 `view/account_page.rs`,
|
||||
@@ -98,7 +108,15 @@ impl App {
|
||||
self.rebuild_account_page(width);
|
||||
// M187:弹窗的视图模型也在这一处重建——`view()` 只借 `&self`,放不进局部变量
|
||||
// (Element 借字符串,局部一死树就悬空)。全仓「快照 → 视图模型」只在这里发生。
|
||||
self.dialog_model = self.dialog_modal();
|
||||
//
|
||||
// M256:**退场期间要留住旧框**。逻辑状态(`state.dialogs`)在用户点下去的那一拍就关掉了
|
||||
// (所以 `dialog_modal()` 变 `None`),但画面还要缩着退出去 150ms——那一份模型必须留到
|
||||
// 退场跑完(`App::sync_motion` 收到 `take_modal_closed()` 时置 `None`)。
|
||||
// 判据用 `motion::modal_closing()` 而不是"有没有动效":关着动效时它恒为假 ⇒ 当场清。
|
||||
let next_modal = self.dialog_modal();
|
||||
if next_modal.is_some() || !motion::modal_closing() {
|
||||
self.dialog_model = next_modal;
|
||||
}
|
||||
self.view_dirty = false;
|
||||
self.refreshes += 1;
|
||||
}
|
||||
@@ -232,3 +250,12 @@ impl App {
|
||||
self.mods_failed_seen = Some(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// 详情的身份键(`None` = 不在详情页):与 `app/update/motion_sync.rs` 的 `detail` 同一判据。
|
||||
fn detail_key(model: &pages::Model) -> Option<&str> {
|
||||
model
|
||||
.instances
|
||||
.detail
|
||||
.as_ref()
|
||||
.map(|detail| detail.id.as_str())
|
||||
}
|
||||
@@ -115,7 +115,7 @@ mod tests {
|
||||
//! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。
|
||||
|
||||
use super::*;
|
||||
use crate::app::components::motion::{Slot, FRAME_MS};
|
||||
use crate::app::components::motion::FRAME_MS;
|
||||
use crate::cli::Options;
|
||||
use crate::pages;
|
||||
use crate::theme::Families;
|
||||
@@ -341,10 +341,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 槽枚举的可见性检查:`Slot` 只经帧泵导出(页面拿不到「给我 250ms」这种口子)。
|
||||
/// 「**页面拿不到时长**」的源码守卫(M256 取代 M192 的 `Slot` 三条断言)。
|
||||
///
|
||||
/// 口径:`pages/**` 的产品代码里不许出现时长的任何入口(`DurationToken` / `page_spec` /
|
||||
/// `modal_spec` / `FRAME_MS` / `advance(`):页面只能说「我要换页了」,用哪一档、多久由
|
||||
/// `components/motion/transition.rs` 那张表决定(D31)。
|
||||
///
|
||||
/// **抓得住**:页面里写 `DurationToken::Medium2` / `motion::FRAME_MS` / 自己推时间轴
|
||||
/// (变异:在 `pages/download.rs` 顶上加 `use unlucky_design::motion::DurationToken;` ⇒ 红)。
|
||||
/// **抓不住**:①表里某一档的数字写错(那是 `components/motion/tests` 的登记用例);②绕过
|
||||
/// 符号名的路径(转一手、通配导入)——只按符号名扫,不做类型分析。
|
||||
#[test]
|
||||
fn slots_are_the_three_documented_ones() {
|
||||
assert_eq!(Slot::ALL.len(), 3);
|
||||
assert_eq!(Slot::ALL.map(Slot::name), ["boot", "screen", "detail"]);
|
||||
fn pages_cannot_reach_for_a_duration() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("src")
|
||||
.join("pages");
|
||||
let mut files = Vec::new();
|
||||
collect_rust_files(&dir, &mut files);
|
||||
assert!(
|
||||
files.len() >= 20,
|
||||
"只扫到 {} 个页面文件:扫描逻辑坏了",
|
||||
files.len()
|
||||
);
|
||||
|
||||
const FORBIDDEN: [&str; 5] = [
|
||||
"DurationToken",
|
||||
"page_spec",
|
||||
"modal_spec",
|
||||
"FRAME_MS",
|
||||
"advance(",
|
||||
];
|
||||
let mut hits = Vec::new();
|
||||
for path in files {
|
||||
let text = std::fs::read_to_string(&path).expect("读源文件");
|
||||
// 只看产品代码(测试与文档注释不算:`#[cfg(test)]` 之后整段跳过)。
|
||||
let product = text.split("#[cfg(test)]").next().unwrap_or(&text);
|
||||
let code = product
|
||||
.lines()
|
||||
.filter(|line| !line.trim_start().starts_with("//"));
|
||||
for symbol in FORBIDDEN {
|
||||
if code.clone().any(|line| line.contains(symbol)) {
|
||||
let name = path
|
||||
.strip_prefix(&dir)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.replace(std::path::MAIN_SEPARATOR, "/");
|
||||
hits.push((name, symbol));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
hits.is_empty(),
|
||||
"页面层不许直接碰时长/时间轴(用哪一档是 `transition.rs` 的表决定的):{hits:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,33 @@
|
||||
use iced::Task;
|
||||
use unlucky_presenter::toast::ToastKind;
|
||||
|
||||
use crate::app::components::motion;
|
||||
use crate::app::dialog::{export, Message as DialogMessage};
|
||||
use crate::app::state::App;
|
||||
|
||||
/// 关掉当前这个框(**唯一出口**,M256)。
|
||||
///
|
||||
/// 逻辑状态是**当场**关的(`close_dialog()` 立刻清 `state.dialogs`)——延迟关闭会让队列里那个
|
||||
/// 框在退场期间仍然"活着"(`current()` 还是它),所有按"有没有框"判断的代码都会看到一个已经
|
||||
/// 点过「确定」的框。所以退场只发生在**画面上**:`refresh_view` 在 `motion::modal_closing()`
|
||||
/// 期间保留旧框的视图模型,`App::sync_motion` 在退场走完那一拍把它置 `None`。
|
||||
///
|
||||
/// 关着动效(`MotionScale::NONE` / 截图通道)时 `begin_modal_close()` 返回 `true`,
|
||||
/// `modal_closing()` 恒假 ⇒ 当场清,退回改造前的行为。
|
||||
fn close(app: &mut App) {
|
||||
let _ = motion::begin_modal_close();
|
||||
app.close_dialog();
|
||||
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
|
||||
// (r1 复审 P3-1)。退场的第一帧必须在这一拍画出来。
|
||||
app.refresh_view();
|
||||
}
|
||||
|
||||
pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> {
|
||||
// M256:退场动画期间**吞掉**弹窗消息。框正在缩小,这时再点一次「删除世界」不该再生效
|
||||
// (主按钮的 `take_confirm` 已经在上一次点击里核销过目标,但用户看到的仍是"框还在")。
|
||||
if motion::modal_closing() {
|
||||
return Task::none();
|
||||
}
|
||||
match message {
|
||||
DialogMessage::Primary => {
|
||||
// M247:删除存档的二次确认——主按钮就是**真删**(这是本页唯一不可逆的动作)。
|
||||
@@ -20,7 +43,10 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
|
||||
// 崩溃卡片的「导出诊断包」也拿不走它——否则就是一次静默删世界。
|
||||
let current = app.state.dialogs.current().map(|request| request.id);
|
||||
if let Some(dir) = current.and_then(|id| app.state.saves.take_confirm(id)) {
|
||||
app.close_dialog();
|
||||
// M256:走统一出口(逻辑当拍关,画面多留 150ms 退场)。
|
||||
// 目标已经在上一行核销 ⇒ 退场期间再点一次主按钮不会删第二个世界
|
||||
// (那一拍的消息还会被 `modal_closing` 那道闸门吞掉)。
|
||||
close(app);
|
||||
if let Some(instance) = app.selected_instance_id() {
|
||||
app.state
|
||||
.saves
|
||||
@@ -31,12 +57,11 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
|
||||
unlucky_presenter::dto::SavesCommand::DeleteWorld { id: instance, dir },
|
||||
)));
|
||||
}
|
||||
app.refresh_view();
|
||||
return Task::none();
|
||||
}
|
||||
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
|
||||
let Some(card) = app.dialog_card.clone() else {
|
||||
app.close_dialog();
|
||||
close(app);
|
||||
return Task::none();
|
||||
};
|
||||
let done = export::export(&card.report, app.lang());
|
||||
@@ -64,10 +89,9 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
|
||||
if let Some(current) = app.state.dialogs.current().map(|request| request.id) {
|
||||
app.state.saves.close_confirm(current);
|
||||
}
|
||||
app.close_dialog();
|
||||
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
|
||||
// (r1 复审 P3-1)。
|
||||
app.refresh_view();
|
||||
// M256:统一出口(有动效时先退场)。「立即重画」这条要求由 `close` 里的
|
||||
// `refresh_view` 满足:退场的第一帧必须在这一拍画出来。
|
||||
close(app);
|
||||
Task::none()
|
||||
}
|
||||
DialogMessage::Toggle => {
|
||||
@@ -129,8 +153,8 @@ pub(super) fn escape(app: &mut App) -> bool {
|
||||
// 现在还有 id 绑定兜一道(`take_confirm(current)`),这里是纵深防御的第二层。
|
||||
// 复审 r2 加固:清的时候**只清这个框自己的目标**(`close_confirm`),不碰别人的。
|
||||
app.state.saves.close_confirm(current);
|
||||
app.close_dialog();
|
||||
app.refresh_view();
|
||||
// M256:统一出口(Esc 也走退场动画,而不是硬跳)。
|
||||
close(app);
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@ use super::*;
|
||||
impl App {
|
||||
/// 每条消息处理完之后同步一次内容标识(**必须在 `update` 里**,理由见模块文档)。
|
||||
pub(in crate::app) fn sync_motion(&mut self) {
|
||||
// M256:模态退场走完 ⇒ **这一刻**才把保留的框模型丢掉。
|
||||
// 逻辑状态早就在用户点下去的那一拍关了(`state.dialogs` 已清),这里丢的只是"还在缩小的
|
||||
// 那一份画面"。同一拍里置 `view_dirty`,下一帧就没有模态层了。
|
||||
if motion::take_modal_closed() {
|
||||
self.dialog_model = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
|
||||
// **还没有窗口就什么都不做**(也不记标识):动效是画出来的东西,没有窗口时
|
||||
// `window::frames()` 一条消息也不会有,起跑只会白占一档。更要紧的是「首屏入场」的语义——
|
||||
// 等窗口到了再算「第一眼看到的内容」,否则首屏那一下会被启动期的空树吃掉。
|
||||
@@ -50,6 +58,16 @@ impl App {
|
||||
_ => None,
|
||||
};
|
||||
motion::observe(screen, detail);
|
||||
// M256:模态的**逻辑**开关(队列里有没有框)。用 presenter 的队列而不是 `dialog_model`:
|
||||
// 退场期间框还画着(`dialog_model` 仍是 `Some`),但逻辑上已经在关的路上了。
|
||||
motion::observe_modal(self.state.dialogs.current().is_some());
|
||||
|
||||
// M256:过渡结束(没有轨道了)当拍就**丢掉旧页快照**。
|
||||
// 这条是硬要求:`previous_view` 是一份完整的页面模型,留着就是「每换一次页多一份」的
|
||||
// 线性增长——长时间常驻的启动器上这是明确的缺陷,不是"小浪费"。
|
||||
if !motion::needs_frames() {
|
||||
self.previous_view = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`super::boot_env::MotionFixture`])。
|
||||
@@ -136,6 +154,57 @@ fn screen_key(route: &Route) -> Option<u64> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::components::motion::FRAME_MS;
|
||||
use crate::app::message::Message;
|
||||
use crate::cli::Options;
|
||||
use crate::theme::Families;
|
||||
use unlucky_design::color::SchemeFlavor;
|
||||
use unlucky_design::motion::MotionScale;
|
||||
use unlucky_presenter::mock::MockBackend;
|
||||
use unlucky_presenter::settings_bridge::SettingsBridge;
|
||||
|
||||
const FAMILIES: Families = Families {
|
||||
text: "Segoe UI",
|
||||
display: "Segoe UI",
|
||||
mono: "Consolas",
|
||||
};
|
||||
|
||||
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
|
||||
fn headless() -> App {
|
||||
App::with_presenter(
|
||||
Options::default(),
|
||||
FAMILIES,
|
||||
SchemeFlavor::Light,
|
||||
Box::new(MockBackend::new()),
|
||||
SettingsBridge::memory_only(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 推时间轴到没有轨道(每步 16 ms,上限 20 s)。
|
||||
fn run_until_idle() {
|
||||
for _ in 0..1250 {
|
||||
if !motion::needs_frames() {
|
||||
return;
|
||||
}
|
||||
motion::advance_by(FRAME_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个有窗口、首屏动效已经跑完的 App(`sync_motion` 在没有窗口时直接返回)。
|
||||
fn idle_app() -> App {
|
||||
motion::reset();
|
||||
motion::configure(MotionScale::STANDARD, None);
|
||||
let mut app = headless();
|
||||
app.window = Some(iced::window::Id::unique());
|
||||
let _ = app.update(Message::Boot);
|
||||
app.watch_until = None;
|
||||
run_until_idle();
|
||||
// 再走一条消息:`sync_motion` 在这一拍把启动期那次换页的 `previous_view` 烧掉
|
||||
// (首屏从「构造时的模型」换到真路由,本身就是一次内容标识变化)。
|
||||
let _ = app.update(Message::Frame);
|
||||
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
|
||||
app
|
||||
}
|
||||
|
||||
/// 路由 → 屏键:五个一级页各自唯一,`Detail` 归「实例」,账户/AI 不归 pages。
|
||||
#[test]
|
||||
@@ -163,4 +232,159 @@ mod tests {
|
||||
assert_eq!(keys[6], None, "账户页不归 pages 画");
|
||||
assert_eq!(keys[7], None, "AI 独立页不归 pages 画");
|
||||
}
|
||||
|
||||
/// **旧页模型用完即焚**(M256 门槛 B):过渡结束的那一拍 `previous_view` 必须是 `None`。
|
||||
///
|
||||
/// 抓得住:把 `sync_motion` 末尾的 `if !needs_frames() { previous_view = None }` 删掉
|
||||
/// ——那样每换一次页就永久多留一份完整模型,长时间跑下去是线性增长。
|
||||
/// 抓不住:模型有多大(这条只看"有没有留")。
|
||||
#[test]
|
||||
fn the_previous_page_model_is_dropped_when_the_transition_ends() {
|
||||
let mut app = idle_app();
|
||||
assert!(app.previous_view.is_none(), "前提:静息态没有旧页");
|
||||
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
|
||||
assert!(
|
||||
app.previous_view.is_some(),
|
||||
"换页那一拍必须留下旧页(退场要画它)"
|
||||
);
|
||||
assert_eq!(
|
||||
app.previous_view.as_ref().map(|model| model.screen),
|
||||
Some(pages::Screen::Instances),
|
||||
"旧页必须是**切换前**那一屏"
|
||||
);
|
||||
|
||||
run_until_idle();
|
||||
// 下一拍消息(真机上就是最后一帧)收尾。
|
||||
let _ = app.update(Message::Frame);
|
||||
assert!(
|
||||
app.previous_view.is_none(),
|
||||
"过渡结束必须当拍丢掉旧页模型(不许随换页次数线性增长)"
|
||||
);
|
||||
}
|
||||
|
||||
/// **连续多次换页也只保有 1 份旧模型**(门槛 B 的另一半)。
|
||||
#[test]
|
||||
fn repeated_page_changes_never_accumulate_models() {
|
||||
let mut app = idle_app();
|
||||
let routes = [
|
||||
Route::Download,
|
||||
Route::Content,
|
||||
Route::Settings,
|
||||
Route::Instances,
|
||||
];
|
||||
for route in routes {
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(route)));
|
||||
// 中间只走 4 帧再点下一次(模拟快速连点导航轨)
|
||||
for _ in 0..4 {
|
||||
motion::advance_by(FRAME_MS);
|
||||
}
|
||||
let count = usize::from(app.previous_view.is_some());
|
||||
assert!(count <= 1, "同一时刻最多只有 1 份旧页模型,实测 {count}");
|
||||
}
|
||||
run_until_idle();
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Logs)));
|
||||
run_until_idle();
|
||||
let _ = app.update(Message::Frame);
|
||||
assert!(
|
||||
app.previous_view.is_none(),
|
||||
"连续 5 次换页之后仍应是 None(不是攒了 5 份)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 旧页是**冻结快照**(门槛 C):过渡期间的刷新不许把它换成新内容。
|
||||
///
|
||||
/// 抓得住:把 `refresh.rs` 的「只在屏/详情变了才换」改成"每次刷新都换"——那样旧页会跟着
|
||||
/// presenter 变,画面上就是两个页面同时动内容。
|
||||
#[test]
|
||||
fn the_previous_page_is_a_frozen_snapshot() {
|
||||
let mut app = idle_app();
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
|
||||
let before = app
|
||||
.previous_view
|
||||
.as_ref()
|
||||
.map(|model| (model.screen, model.window_width));
|
||||
assert!(before.is_some());
|
||||
|
||||
// 过渡期间来一次「同屏刷新」(主题/下载进度这一类的形状:不换屏)
|
||||
app.view_dirty = true;
|
||||
app.refresh_view();
|
||||
motion::advance_by(FRAME_MS);
|
||||
|
||||
assert_eq!(
|
||||
app.previous_view
|
||||
.as_ref()
|
||||
.map(|model| (model.screen, model.window_width)),
|
||||
before,
|
||||
"同屏刷新不该换掉旧页快照"
|
||||
);
|
||||
}
|
||||
|
||||
/// **退场窗口内那个框已经关掉,且不可再交互**(tower 的硬要求)。
|
||||
///
|
||||
/// 三件事分别断言:①逻辑状态当拍就空(`state.dialogs` 里没有它);②画面还在退场
|
||||
/// (`dialog_model` 仍 `Some` ⇒ 不是硬跳);③这 150ms 内再点一次**什么都不发生**
|
||||
/// (`on_dialog` 的 `modal_closing` 闸门 → 连一次重画都不该有)。
|
||||
///
|
||||
/// **抓得住**:把 `on_dialog` 顶部的 `if motion::modal_closing()` 删掉(第二次点击会走进
|
||||
/// `close(app)`,`refreshes` 涨一次 ⇒ 红);或让逻辑关闭延后(①红);或让退场直接清模型(②红)。
|
||||
/// **抓不住**:几何级的命中测试——「点在那个坐标上会不会命中幽灵框」。本仓的单元测试没有
|
||||
/// iced 的布局树,`zoom.rs` 里的缩放也**不改**命中矩形(记在它自己的"已知局限"里)。
|
||||
/// 这一条由闸门保证:**无论命中在哪,退场期间的弹窗消息一律不生效**。
|
||||
#[test]
|
||||
fn the_exiting_dialog_is_already_closed_and_swallows_clicks() {
|
||||
use crate::app::dialog::Message as DialogMessage;
|
||||
use crate::app::update::dialog::on_dialog;
|
||||
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
|
||||
|
||||
let mut app = idle_app();
|
||||
let _ = app.state.dialogs.push(
|
||||
DialogSource::UserAction,
|
||||
"删除世界",
|
||||
"不可撤销",
|
||||
DialogKind::Confirm {
|
||||
primary: "删除".to_string(),
|
||||
secondary: "取消".to_string(),
|
||||
},
|
||||
);
|
||||
app.view_dirty = true;
|
||||
app.refresh_view();
|
||||
// 一条消息让 `sync_motion` 看见「队列里有框」⇒ 起跑进场(与真机的顺序一致)
|
||||
let _ = app.update(Message::Frame);
|
||||
assert!(motion::needs_frames(), "开框必须在同一拍起跑进场");
|
||||
run_until_idle(); // 进场跑完(`Open` 稳态)
|
||||
assert!(app.dialog_model.is_some(), "前提:框已经画出来了");
|
||||
|
||||
// 用户点「取消」:逻辑当拍关闭,画面开始退场
|
||||
let _ = on_dialog(&mut app, DialogMessage::Secondary);
|
||||
assert!(
|
||||
app.state.dialogs.current().is_none(),
|
||||
"退场窗口内逻辑上必须**已经**关掉(任何状态读取者都该看到没有框)"
|
||||
);
|
||||
assert!(motion::modal_closing(), "画面还在退场");
|
||||
assert!(
|
||||
app.dialog_model.is_some(),
|
||||
"退场窗口内仍画得到旧框(否则就是一次硬跳)"
|
||||
);
|
||||
assert!(motion::needs_frames(), "退场要帧");
|
||||
|
||||
// 150ms 内再点一次原来的主按钮位置:必须被闸门吞掉
|
||||
let refreshes = app.refreshes;
|
||||
let _ = on_dialog(&mut app, DialogMessage::Primary);
|
||||
assert_eq!(
|
||||
app.refreshes, refreshes,
|
||||
"退场窗口内的点击必须被吞掉(连一次重画都不该发生)"
|
||||
);
|
||||
assert!(
|
||||
app.state.dialogs.current().is_none(),
|
||||
"幽灵点击不许复活那个框"
|
||||
);
|
||||
assert!(app.dialog_model.is_some(), "也不许把它重新打开");
|
||||
|
||||
// 退场走完:下一拍的 `sync_motion` 把它丢掉,帧泵停
|
||||
run_until_idle();
|
||||
let _ = app.update(Message::Frame);
|
||||
assert!(app.dialog_model.is_none(), "退场结束后不许留残影");
|
||||
assert!(!motion::needs_frames(), "退场结束后帧泵必须停");
|
||||
}
|
||||
}
|
||||
@@ -142,9 +142,11 @@ impl App {
|
||||
APP_TITLE,
|
||||
background,
|
||||
// M101 r2:设置页的只读状态(外观运行期状态 + 各组自己的状态)一次递进去。
|
||||
pages::page_with_settings(
|
||||
pages::page_with_settings_transition(
|
||||
&self.ui,
|
||||
&self.view,
|
||||
// M256:换页过渡期的旧页快照(`App::sync_motion` 在动画结束当拍把它丢掉)。
|
||||
self.previous_view.as_ref(),
|
||||
pages::settings::States {
|
||||
appearance: Some(&self.appearance),
|
||||
groups: &self.settings,
|
||||
|
||||
Reference in new issue
Block a user