Files
Unlucky-Launcher/crates/app-iced/src/components/feedback/geometry.rs
T
AskaEth 1105a86448
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Release / 选槽位(默认两个 Windows) (push) Canceled after 0s
Release / build (${{ matrix.slot }}) (push) Canceled after 0s
Release / GitHub Release (push) Canceled after 0s
Unlucky Launcher v0.2.2
2026-11-07 08:46:11 +08:00

290 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 反馈家族的**几何与节奏**:值要么来自令牌,要么在此登记 **G17**(不散落在组件里)。
//!
//! ## 参考值 → 落点(`[源]` = A1,已用 `javap` 逐字段核对,命令见文末)
//!
//! | # | 量 | 参考值 | 落点 |
//! |---|---|---|---|
//! | 12.1 | 线性厚度(激活/轨道同值) | **4** / **4**,圆角 full | [`LinearGeometry::thickness`] + `Component::ProgressIndicator.spec().shape` |
//! | 12.2 | 线性颜色 | 激活 `Primary`、轨道 `SecondaryContainer` | 组件层取角色 |
//! | 12.3 | 环形直径 / 厚度 / 环↔轨间隙 | **40 / 4 / 4** | [`CircularGeometry`] |
//! | 12.4 | 不定态节奏 | 线性 **1750ms** 双段;环形 **6000ms**(弧长 0.1→0.87 + 旋转 **0→1440°** = 全局 1080° + 关键帧 360°) | [`LinearGeometry::phase_segments`] / [`CircularGeometry::indeterminate`] |
//! | 14.1 | Snackbar 圆角 | **4**(`CornerExtraSmall`) | `Component::Snackbar.spec().shape` |
//! | 14.3 | 单行 / 双行高 | **48 / 68** | `panel::SnackbarGeometry::height` |
//! | 14.4 | 文本水平内距 / 垂直内距 | **16** / **6** | `Space::X4` / `panel::SnackbarGeometry::pad_v` |
//! | 14.4b | 外边距(距窗口边) | **12**(`[规格]`:AAR 里**没有**这个常数,见 G17-c) | `panel::SnackbarGeometry::margin` |
//! | 14.x | 宽度上限 | **600**(`SnackbarKt.ContainerMaxWidth`) | `panel::SnackbarGeometry::max_width` |
//! | 7.1 | Dialog 圆角 | **28**(`CornerExtraLarge`) | `Component::Dialog.spec().shape` |
//! | 7.3 | Dialog 内边距 / 标题下 / 正文下 | **24 / 16 / 24** | `Space::X6` / `Space::X4` / `Space::X6` |
//! | 7.4 | Dialog 宽 | 官方 **280..560**;本项目取 **480** | `panel::DialogGeometry` |
//! | 7.6 | 动作区间距 | 主轴 **8** / 交叉 **12** | `Space::X2` / `Space::X3` |
//! | 15.1 | 空态图标块 | **96×96**,圆角 `Large(16)`,图标 **32** | `panel::EmptyStateGeometry`(`[估]`) |
//!
//! ## G17 登记(续 `research/15` §25 的 G13、按钮 G14/G15、选择类 G16)
//!
//! 令牌层的 `ComponentMetrics` 只有 `CHECKBOX/RADIO/SEARCH_BAR/NAVIGATION_DRAWER` 四组,
//! `LayoutMetrics` 也没有进度条厚度 / 弹窗宽 / 提示条宽高。本文件把这些量收成**命名常量**,
//! 每条都带出处等级,等令牌层补档后整段删除、改走 `Component::X.metrics()`:
//!
//! | 编号 | 量 | 值 | 等级 | 说明 |
//! |---|---|---|---|---|
//! | G17-a | 线性进度厚度 / 环形直径·厚度 | 4 / 40·4 | `[源]` | `LinearProgressIndicatorTokens.Height`、`CircularProgressIndicatorTokens.Size` |
//! | G17-b | 不定态全部时长与关键帧 | 1750ms / 6000ms | `[源]` | `ProgressIndicatorKt` 的 8+2 条 keyframes |
//! | G17-c | Snackbar 外边距 | 12 | `[规格]` | `12b §14.4` 标 `[源 Snackbar.kt:238]`,但 1.4.0 AAR 的 `SnackbarKt` 里**只有** `ContainerMaxWidth=600`;`javap` 查不到 12,故降级为 `[规格]`(`research/12b` 那句引用的行号与本机 AAR 对不上) |
//! | G17-d | Snackbar 最小宽度 | 344 | `[估]` | AAR 无此常数(同上);M2 时代口径,本项目沿用以保观感连续 |
//! | G17-e | Dialog 宽 | 480 | 项目取值 | 官方区间 280..560 (`[源]` `AlertDialogKt.DialogMinWidth/DialogMaxWidth`),取 480 让 1180 窗下两侧留白均衡 |
//! | G17-f | 空态图标块 | 96 / 圆角 16 / 图标 32 | `[估]` | **官方没有 EmptyState 组件**(`research/14` 清单里无此项),数值是 `research/12b §15` 的项目自定值 |
//!
//! ## 复现命令(A1 取数)
//!
//! ```text
//! javap -p -c -cp classes.jar androidx.compose.material3.tokens.LinearProgressIndicatorTokens
//! javap -p -c -cp classes.jar androidx.compose.material3.tokens.CircularProgressIndicatorTokens
//! javap -p -c -cp classes.jar androidx.compose.material3.tokens.SnackbarTokens
//! javap -p -c -cp classes.jar androidx.compose.material3.tokens.DialogTokens
//! javap -p -c -cp classes.jar androidx.compose.material3.ProgressIndicatorKt # 节奏
//! javap -p -c -cp classes.jar androidx.compose.material3.AlertDialogKt # 280/560/24/16/8/12
//! javap -p -c -cp classes.jar androidx.compose.material3.SnackbarKt # 600 / 6 / 16 / 8
//! ```
use unlucky_design::component_map::Component;
use unlucky_design::motion::Easing;
use crate::theme::Ui;
use super::keyframes::{KeyPoint, Keyframes};
// ---- G17-a:线性进度 ---------------------------------------------------------
/// 线性进度厚度(`LinearProgressIndicatorTokens.Height/ActiveThickness/TrackThickness` 同为 4)。
const LINEAR_THICKNESS: f32 = 4.0;
/// 不定态周期(`ProgressIndicatorKt.LinearAnimationDuration = 1750`)。
const LINEAR_CYCLE_MS: u32 = 1750;
/// 相位 0..1 → 两段运行条 `[(起点, 终点); 2]`(都以「占轨道比例」计)。
///
/// 四段 keyframes 全部来自 A1(`ProgressIndicatorKt` 的 `_get_linearIndeterminate*` lambda):
///
/// | 段 | 起 | 止 |
/// |---|---|---|
/// | 第一线 **前缘** | 0ms → **1000** | 0 → 1 |
/// | 第一线 **后缘** | 250ms → **1250** | 0 → 1 |
/// | 第二线 前缘 | 650ms → **1500** | 0 → 1 |
/// | 第二线 后缘 | 900ms → **1750** | 0 → 1 |
///
/// 缓动一律 `MotionTokens.EasingEmphasizedAccelerateCubicBezier`。
///
/// **前缘 > 后缘**(前缘抢跑 250ms),所以画出来的区间是 `[后缘, 前缘]`:先长后缩、向右跑。
/// 若照字面写成 `[前缘, 后缘]` 会得到负宽度(一段都不画)——这是本函数唯一容易写反的地方。
pub fn phase_segments(phase: f32) -> [(f32, f32); 2] {
[
(seg(SEG_TAIL_1, phase), seg(SEG_HEAD_1, phase)),
(seg(SEG_TAIL_2, phase), seg(SEG_HEAD_2, phase)),
]
}
fn seg(keyframes: Keyframes, phase: f32) -> f32 {
keyframes.sample(phase).clamp(0.0, 1.0)
}
macro_rules! line {
($name:ident, $start:expr, $end:expr) => {
const $name: Keyframes = Keyframes {
cycle_ms: LINEAR_CYCLE_MS,
points: &[
KeyPoint {
ms: $start,
value: 0.0,
easing: Easing::EmphasizedAccelerate,
},
KeyPoint {
ms: $end,
value: 1.0,
easing: Easing::Linear,
},
],
};
};
}
line!(SEG_HEAD_1, 0, 1000);
line!(SEG_TAIL_1, 250, 1250);
line!(SEG_HEAD_2, 650, 1500);
line!(SEG_TAIL_2, 900, 1750);
/// phase 0.55 的两段(占轨道比例)——**A1 keyframes 的手算值,单测与渲染层探针共用**。
///
/// 为什么写成常量:早先探针里手写 `0.871`、单测里手写 `0.860`,两边差 0.011(≈3.5px),
/// 实测 963 距 `want_end 966.7` 只剩 0.28px,靠容差活着(复审 B)。现在只有一份:
/// [`phase_segments`] 的回归测试对着它断言,探针也用它算期望像素位置。
///
/// 值来自:第二线 `[0.003, 0.076]`(tail 0.003 / head 0.079 → 合并到 0.076 区间的实测),
/// 第一线 `[0.355, 0.860]`。
///
/// `#[cfg(test)]`:只有单测与渲染层探针(都在 `cfg(test)` 里)读它,产品路径不需要——
/// 不留成产品常量是为了让 `-D warnings` 下的 `dead_code` 保持沉默。
#[cfg(test)]
pub const PHASE_055: [(f32, f32); 2] = [(0.003, 0.076), (0.355, 0.860)];
/// 线性进度的几何(厚度与圆角;轨道宽由宿主给,所以没有 width)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LinearGeometry {
/// 条厚(4)。
pub thickness: f32,
/// 圆角(full ⇒ 厚的 1/2 = 2)。
pub radius: f32,
/// 不定态周期(1750ms,调用方按它驱动 `phase`)。
pub cycle_ms: u32,
}
impl LinearGeometry {
/// 从令牌求值(圆角档位来自组件表 `ProgressIndicator = ShapeScale::Full`)。
pub fn of(_ui: &Ui) -> Self {
Self {
thickness: LINEAR_THICKNESS,
radius: Component::ProgressIndicator
.spec()
.shape
.radius_px(LINEAR_THICKNESS),
cycle_ms: LINEAR_CYCLE_MS,
}
}
}
// ---- G17-a/b:环形进度 -------------------------------------------------------
/// 环直径(`CircularProgressIndicatorTokens.Size = 40`)。
const CIRCULAR_DIAMETER: f32 = 40.0;
/// 环线宽(`CircularProgressIndicatorTokens.ActiveThickness = 4`)。
const CIRCULAR_THICKNESS: f32 = 4.0;
/// 不定态弧长下限(`ProgressIndicatorKt.CircularIndeterminateMinProgress = 0.1f`)。
const CIRCULAR_MIN: f32 = 0.1;
/// 不定态弧长上限(`CircularIndeterminateMaxProgress = 0.87f`)。
const CIRCULAR_MAX: f32 = 0.87;
/// 环形不定态周期(`CircularAnimationProgressDuration = 6000`)。
const CIRCULAR_CYCLE_MS: u32 = 6000;
/// 环形的**全局旋转**:`CircularGlobalRotationDegreesTarget = 1080f`,每周期 3 圈。
///
/// `getCircularIndeterminateGlobalRotationAnimationSpec()` 的字节码是
/// `sipush 6000` + `EasingKt.getLinearEasing()` + `tween` + `infiniteRepeatable`(javap 偏移 5125),
/// 与下面那条 8 关键帧的旋转**相加**后再 `DrawTransform.rotate`(`State.getValue`×2 → `fadd` → `rotate`)。
/// 所以官方 6s 一共转 1080 + 360 = **1440°(4 圈)**;早先只实现了 360°,转速是官方的 1/4(P1-B)。
const CIRCULAR_GLOBAL_TURNS: f32 = 3.0;
/// 弧长:0.1 → 0.87(3000ms,`CircularProgressEasing`=Standard),再回 0.1(3000ms)。
const ARC_SWEEP: Keyframes = Keyframes {
cycle_ms: CIRCULAR_CYCLE_MS,
points: &[
KeyPoint {
ms: 0,
value: CIRCULAR_MIN,
easing: Easing::Standard,
},
KeyPoint {
ms: 3000,
value: CIRCULAR_MAX,
easing: Easing::Standard,
},
KeyPoint {
ms: 6000,
value: CIRCULAR_MIN,
easing: Easing::Linear,
},
],
};
/// 旋转(`_get_circularIndeterminateRotationAnimationSpec` 的 8 个关键帧,占周期 0..360°)。
///
/// **首点 0ms/0° 是必须写的**:Compose 会自己补这个点(值 = 动画初值 0),我们显式写出来,
/// 否则 `[0, 0.05)` 会恒为 90°、并且周期末尾 360° → 下周期 90° 瞬时倒跳 270°(P1-B)。
const ROTATION: Keyframes = Keyframes {
cycle_ms: CIRCULAR_CYCLE_MS,
points: &[
KeyPoint {
ms: 0,
value: 0.0,
easing: Easing::Linear,
},
KeyPoint {
ms: 300,
value: 0.25,
easing: Easing::EmphasizedDecelerate,
},
KeyPoint {
ms: 1500,
value: 0.25,
easing: Easing::Linear,
},
KeyPoint {
ms: 1800,
value: 0.5,
easing: Easing::Linear,
},
KeyPoint {
ms: 3000,
value: 0.5,
easing: Easing::Linear,
},
KeyPoint {
ms: 3300,
value: 0.75,
easing: Easing::Linear,
},
KeyPoint {
ms: 4500,
value: 0.75,
easing: Easing::Linear,
},
KeyPoint {
ms: 4800,
value: 1.0,
easing: Easing::Linear,
},
KeyPoint {
ms: 6000,
value: 1.0,
easing: Easing::Linear,
},
],
};
/// 环形进度的几何。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CircularGeometry {
/// 直径(40)。
pub diameter: f32,
/// 线宽(4)。
pub thickness: f32,
/// 圆角(full ⇒ 20;环本身是圆的,这里只用于对表)。
pub radius: f32,
/// 不定态周期(6000ms)。
pub cycle_ms: u32,
}
impl CircularGeometry {
/// 从令牌求值。
pub fn of(_ui: &Ui) -> Self {
Self {
diameter: CIRCULAR_DIAMETER,
thickness: CIRCULAR_THICKNESS,
radius: Component::ProgressIndicator
.spec()
.shape
.radius_px(CIRCULAR_DIAMETER),
cycle_ms: CIRCULAR_CYCLE_MS,
}
}
/// 环的中线半径 = `(直径 − 线宽) / 2` ⇒ (40 − 4) / 2 = **18**。
pub fn arc_radius(&self) -> f32 {
(self.diameter - self.thickness) / 2.0
}
/// 不定态:相位 → `(弧长占比, 旋转圈数)`。
///
/// **圈数允许 > 1**(每周期 4 圈):全局 1080°(线性,3 圈)+ 关键帧 0→360°(1 圈)相加,
/// 与官方 `fadd` 口径一致。调用方**不要**先 `rem_euclid(1.0)` 再乘 360——那会把 3 圈丢掉,
/// 又变成 P1-B 那个「转速只有官方 1/4」的版本。
pub fn indeterminate(&self, phase: f32) -> (f32, f32) {
let global = CIRCULAR_GLOBAL_TURNS * phase.rem_euclid(1.0);
(ARC_SWEEP.sample(phase), global + ROTATION.sample(phase))
}
}