Files
Unlucky-Launcher/crates/app-iced/src/components/feedback/progress.rs
T
AskaEth e9af27c8f1 fix(iced/feedback): 不定态两条 p1 —— 线性先排序合并(P1-A)+ 环形补全局旋转(P1-B)
复审 r1 的两条 p1 都是「报告写了 ✓、图上没有」,逐条修:

P1-A(视觉)线性不定态把两段画成一段:
- 根因:`phase_segments` 的两段**不保证有序**(第一线前缘抢跑 250ms、第二线从 0 起),
  `linear()` 却按返回顺序单调铺 `FillPortion`,且份额之和在 34/100 相位上 >1(最大 1.99),
  iced 按总和归一 ⇒ phase 0.55 的两段被压成一段 100px 台阶。
- 修:新增 `layout_spans`(排序 + 合并重叠 + 显式铺间隙)与 `portion_shares`(累计舍入,
  份额之和**恒为 1000**),`linear()` 只消费这两者。
- 断言:`raw_segments_are_unordered_and_layout_sorts_them`(原始输出不有序、排序后位置对得上
  A1 keyframes)、`portion_shares_always_sum_to_the_full_track`(200 个相位全 = 1000)、
  以及渲染层 `12.4:phase 0.55 必须数出两段`(旧实现会数到 1 段)。

P1-B(代码)环形不定态少一条全局旋转 + keyframes 首点语义:
- 官方是两条旋转相加:`CircularGlobalRotationDegreesTarget = 1080f`(`tween(6000, LinearEasing)`,
  javap 偏移 5125)与 8 关键帧旋转(0→360°),绘制时 `fadd` 后 rotate ⇒ 每周期 1440°(4 圈)。
  我们只做了 360°,转速是官方的 1/4。
- `ROTATION` 首点在 300ms/90°:Compose 会自己补 t=0(值 = 动画初值 0),我们没补 ⇒ `[0,0.05)`
  恒 90°,且周期末尾 360° → 下周期 90° 瞬时倒跳 270°。现在显式补 `KeyPoint{ms:0, value:0}`
  并在 `Keyframes::sample` 加 `debug_assert!(首点 ms == 0)`,同一类坑不再靠自觉。
- `CircularGeometry::indeterminate` 改返回 `(弧长, 圈数)`,圈数 = 3.0×phase + 关键帧(可为 >1),
  `progress.rs` 直接乘 360、不做 rem_euclid。
- 断言:`circular_indeterminate_rotation_is_four_turns_and_continuous`(每周期 4 圈、
  跨周期跳变 ≤ 一帧 = 4/60 圈,修前是 0.75 圈 = 270°、旋转单调)。

probe 拆成 `probe.rs` + `probe/circular.rs`(新断言会把单文件顶过 400 行)。
2026-11-02 15:56:43 +08:00

309 lines
12 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.
//! 进度指示器(`research/12b` §12):`linear` 与 `circular`,各有确定态与不定态。
//!
//! ## 两种形态、四种态
//!
//! | 形态 | 确定态 | 不定态 |
//! |---|---|---|
//! | [`linear`] | 轨道 `SecondaryContainer` + 指示条 `Primary`,厚 **4**、圆角 full | 官方 1750ms **双段**追逐([`phase_segments`]) |
//! | [`circular`] | 40 环 + 4 线宽,弧长 = 数值 × 360° | 官方 6000ms(弧长 0.1↔0.87 + 旋转 0↔360,[`CircularGeometry::indeterminate`]) |
//!
//! ## 为什么不定态要调用方给 `phase`
//!
//! 契约 §2:组件不持有时钟。`phase`(0..1 的一个周期)由页面按 `Subscription` 推进;
//! harness 因此能拍**确定**的中间帧(否则每张图都是抓阄)。周期时长在几何里
//! ([`LinearGeometry::cycle_ms`] / [`CircularGeometry::cycle_ms`]),调用方按它换算。
//!
//! ## CPU 降级路径(D27)
//!
//! 本组件**零半透明、零模糊**:轨道/指示条都是不透明实色,环形是 `stroke` 实色。
//! 波浪形态(线性振幅 3 / 波长 40,环形振幅 1.6 / 波长 15,`[源]`)**未实现**,
//! 用直线段/等弧长替代——这是 `12b §12.4` 明确允许的登记项(见报告「偏离登记」)。
//!
//! ## 环形为什么手搓 SVG
//!
//! iced 的 `canvas` feature 没开(`Cargo.toml` 不归本 mission),而圆弧用容器边框画不出来。
//! 令牌图标的做法(24 网格 SVG path + `currentColor` + 内容哈希缓存)同样适用于几何图元:
//! 一份 handle 渲染两层(轨道一层、弧一层),相位不变时 iced 侧命中缓存。
use iced::border::Radius;
use iced::widget::{container, row, svg, Space};
use iced::{Background, Border, Element, Length};
use unlucky_design::color::Role;
use crate::theme::Ui;
use super::super::layout;
use super::geometry::{phase_segments, CircularGeometry, LinearGeometry};
/// `FillPortion` 的比例分辨率:0..1 的占比 × 1000 取整(1/1000 像素级,够用且不溢出 u16)。
const PORTIONS: f32 = 1000.0;
/// 弧长小于这个角度就不画激活层(圆头在 0 弧长上会画出一个点,看着像「已完成」)。
const MIN_SWEEP_DEG: f32 = 1.0;
/// 100% 时弧首尾重合,`A` 命令画不出整圆 ⇒ 超过这个角度改画整环。
const FULL_SWEEP_DEG: f32 = 359.9;
/// 进度的取值:确定(0..1)或不定(相位 0..1)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Amount {
/// 确定态:0..1。
Determinate(f32),
/// 不定态:一个周期内的相位(0..1,可越界,采样时按周期取模)。
Indeterminate(f32),
}
/// 一个进度指示器的取值(**不持有任何状态**)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Progress {
amount: Amount,
}
impl Progress {
/// 确定态:`value` 越界会被夹到 0..1(0% 只画轨道)。
pub fn determinate(value: f32) -> Self {
Self {
amount: Amount::Determinate(value.clamp(0.0, 1.0)),
}
}
/// 不定态:`phase` 是周期内的相位(调用方按 `cycle_ms` 推进)。
pub fn indeterminate(phase: f32) -> Self {
Self {
amount: Amount::Indeterminate(phase),
}
}
/// 取值(断言用;生产路径不读它,内部直接 match 字段)。
#[cfg(test)]
pub fn amount(self) -> Amount {
self.amount
}
/// 本帧要画的占轨道比例区间(0..1)。
///
/// 确定态 1 段、不定态 2 段(官方双线);区间可能为空(相位在「两段都在屏幕外」的时刻),
/// 调用方按 `end > start` 过滤。
pub fn spans(self) -> Vec<(f32, f32)> {
match self.amount {
Amount::Determinate(value) => vec![(0.0, value.clamp(0.0, 1.0))],
Amount::Indeterminate(phase) => phase_segments(phase).to_vec(),
}
}
}
/// 把 0..1 的区间**排序 + 合并重叠**,摊成「间隙 / 指示条」交替的列表(总长恒为 1.0)。
///
/// ## 为什么必须排序(复审 P1-A,视觉 r1 的 p1)
///
/// [`phase_segments`] 返回的两段**不保证有序**:第一线的前缘抢跑 250ms,所以它的区间在右,
/// 第二线从 0 起跑的区间在左。照顺序铺 `FillPortion` 会把左段接到右段**之后**,并且
/// `FillPortion` 之和一旦 ≠ 1000,iced 就按总和归一 —— 位置和宽度一起被压扁。
/// 实测 phase 0.55:本应两段 `[0.003,0.079]` + `[0.355,0.860]`(x 689..713 / 802..963),
/// 实际被画成一段 100px 的台阶(x 749..848),且 **34/100 个相位**上总和 >1(最大 1.99)。
///
/// 返回的每项是 `(是不是指示条, 占比)`;间隙项也显式给出来,才能断言「总和 = 1.0」。
pub fn layout_spans(spans: &[(f32, f32)]) -> Vec<(bool, f32)> {
let mut clipped: Vec<(f32, f32)> = spans
.iter()
.map(|(start, end)| (start.clamp(0.0, 1.0), end.clamp(0.0, 1.0)))
.filter(|(start, end)| end > start)
.collect();
clipped.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut merged: Vec<(f32, f32)> = Vec::new();
for (start, end) in clipped {
match merged.last_mut() {
Some(last) if start <= last.1 => last.1 = last.1.max(end),
_ => merged.push((start, end)),
}
}
let mut items = Vec::new();
let mut cursor = 0.0_f32;
for (start, end) in merged {
if start > cursor {
items.push((false, start - cursor));
}
items.push((true, end - start));
cursor = end;
}
if cursor < 1.0 {
items.push((false, 1.0 - cursor));
}
items
}
/// 占比 → `FillPortion` 整数份额,**总和恒为 `PORTIONS`**(累计舍入,最后一项吃掉残差)。
///
/// 为什么不用「每项各自 round」:各项独立取整后总和可能是 999 或 1001,iced 按总和归一,
/// 就又回到 P1-A 那个「整条被压扁」的坑。这里让每个**累计边界**取整,最后一项补足,
/// 于是任意相位下份额之和都精确等于 1000。
pub fn portion_shares(items: &[(bool, f32)]) -> Vec<(bool, u16)> {
let mut shares = Vec::with_capacity(items.len());
let mut accumulated = 0.0_f32;
let mut previous = 0_u16;
for (index, (is_bar, fraction)) in items.iter().enumerate() {
accumulated += fraction;
let target = if index + 1 == items.len() {
PORTIONS as u16
} else {
(accumulated * PORTIONS).round() as u16
};
let share = target.saturating_sub(previous);
previous = target;
shares.push((*is_bar, share));
}
shares
}
/// 线性进度条(轨道 + 指示条,厚 4、圆角 full)。
///
/// 轨道宽 `Fill`(由宿主决定),指示条按占比分到 `FillPortion` 上——**不需要知道轨道像素宽**,
/// 所以 50% 在任何宿主宽度下都精确是半条。
pub fn linear<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> {
let geometry = LinearGeometry::of(ui);
let track_color = ui.color(Role::SecondaryContainer);
let active_color = ui.color(Role::Primary);
let mut bar = row![]
.width(Length::Fill)
.height(Length::Fixed(geometry.thickness));
for (is_bar, share) in portion_shares(&layout_spans(&progress.spans())) {
if share == 0 {
continue;
}
bar = bar.push(if is_bar {
container(Space::new().width(Length::Fill).height(Length::Fill))
.width(Length::FillPortion(share))
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(active_color)),
border: Border {
radius: Radius::new(geometry.radius),
..Border::default()
},
..container::Style::default()
})
.into()
} else {
spacer(share)
});
}
container(bar)
.width(Length::Fill)
.height(Length::Fixed(geometry.thickness))
.style(move |_theme| container::Style {
background: Some(Background::Color(track_color)),
border: Border {
radius: Radius::new(geometry.radius),
..Border::default()
},
..container::Style::default()
})
.into()
}
/// 环形进度(40 直径 / 4 线宽;确定态画弧,不定态按官方节奏扫)。
pub fn circular<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> {
let geometry = CircularGeometry::of(ui);
let (sweep, rotation) = match progress.amount {
Amount::Determinate(value) => (value.clamp(0.0, 1.0) * 360.0, 0.0),
Amount::Indeterminate(phase) => {
// 圈数直接乘 360(不做 rem_euclid):每周期 4 圈是官方口径,截断就丢转速。
let (sweep_fraction, turns) = geometry.indeterminate(phase);
(sweep_fraction * 360.0, turns * 360.0)
}
};
let track = glyph(
&geometry,
ring_source(&geometry),
ui.color(Role::SecondaryContainer),
);
// 0% 只画轨道(不画弧,也不画圆头小点);100% 画整环(`A` 命令的首尾重合画不出整圆)
let active = if sweep < MIN_SWEEP_DEG {
None
} else if sweep >= FULL_SWEEP_DEG {
Some(glyph(
&geometry,
ring_source(&geometry),
ui.color(Role::Primary),
))
} else {
Some(glyph(
&geometry,
arc_source(&geometry, sweep, rotation),
ui.color(Role::Primary),
))
};
let mut layers = vec![track];
layers.extend(active);
// Q9 的同族:几何图元也必须落在**固定尺寸**的盒子里,否则 usvg 会按 Contain 撑满父级。
layout::fixed(
geometry.diameter,
geometry.diameter,
iced::widget::stack(layers),
)
}
/// 两段之间/两端的透明占位。
fn spacer<'a, M: 'a>(share: u16) -> Element<'a, M> {
Space::new()
.width(Length::FillPortion(share))
.height(Length::Fill)
.into()
}
/// 一层几何图元(固定尺寸 + `currentColor` 上色)。
fn glyph<'a, M: 'a>(
geometry: &CircularGeometry,
source: String,
color: iced::Color,
) -> Element<'a, M> {
svg(svg::Handle::from_memory(source.into_bytes()))
.width(Length::Fixed(geometry.diameter))
.height(Length::Fixed(geometry.diameter))
.style(move |_theme, _status| svg::Style { color: Some(color) })
.into()
}
/// 整环(轨道用,也用作 100% 的激活层)。
fn ring_source(geometry: &CircularGeometry) -> String {
let (size, thickness) = (geometry.diameter, geometry.thickness);
let center = size / 2.0;
let radius = geometry.arc_radius();
format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{size}\" height=\"{size}\" \
viewBox=\"0 0 {size} {size}\"><circle cx=\"{center}\" cy=\"{center}\" r=\"{radius}\" \
fill=\"none\" stroke=\"currentColor\" stroke-width=\"{thickness}\"/></svg>"
)
}
/// 一段圆弧(`rotation` 起、扫 `sweep_deg`;0° = 12 点、顺时针 = M3 口径)。
fn arc_source(geometry: &CircularGeometry, sweep_deg: f32, rotation_deg: f32) -> String {
let (size, thickness) = (geometry.diameter, geometry.thickness);
let center = size / 2.0;
let radius = geometry.arc_radius();
let (x0, y0) = point(center, radius, rotation_deg);
let (x1, y1) = point(center, radius, rotation_deg + sweep_deg);
let large = u8::from(sweep_deg > 180.0);
format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{size}\" height=\"{size}\" \
viewBox=\"0 0 {size} {size}\"><path d=\"M {x0:.3} {y0:.3} A {radius} {radius} 0 \
{large} 1 {x1:.3} {y1:.3}\" fill=\"none\" stroke=\"currentColor\" \
stroke-width=\"{thickness}\" stroke-linecap=\"round\"/></svg>"
)
}
/// 极角(0° = 12 点,顺时针)→ 画布坐标(y 向下,所以向上取负)。
fn point(center: f32, radius: f32, deg: f32) -> (f32, f32) {
let rad = deg.to_radians();
(center + radius * rad.sin(), center - radius * rad.cos())
}