Unlucky Launcher v0.2.2
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

This commit is contained in:
AskaEth committed 2026-11-07 08:46:11 +08:00
commit 1105a86448
1128 files changed
+217164

No files matched your search

@@ -0,0 +1,289 @@
//! 反馈家族的**几何与节奏**:值要么来自令牌,要么在此登记 **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))
}
}