Files
Unlucky-Launcher/crates/app-iced/src/components/selection/radio.rs
T
AskaEth 9bb3c33b8c fix(app-iced): 复审 r1 四条 P2 收口(M234)
- P2-1 判据恒绿:`gallery/probe/bases.rs` 的「反向检查」把字段档计数(`:172` 遮蔽后的
  `hits`)拿去比按钮档 stray ⇒ 恒绿。改成 `button_hits` / `field_hits` 两个计数,各比各的
  stray,失败信息的数值与单位对齐量测对象。变异自证:卡列里留一颗忘了 `.base(..)` 的按钮
  ⇒ 该断言单独红(2137 px vs 3178 px,退出码 101)。
- P2-2 FAB 的底错了:禁用态背景仍是满档 `PrimaryContainer`(`Disabled.layer() == None`),
  38% 内容却合在画布上。改成 `Base::of(PrimaryContainer)` + `on_container = false`
  (那一层 10% 在 FAB 身上不存在),两处调用收进一个 `content_color()`;新增值判据
  `disabled_fab_ink_composites_over_its_own_container`(修前 #8CA0AA / 修后 #548FBC 量级)。
- P2-3 漏改:`selection::paint::state_layer()`(hover/focus/pressed)与 `list_item` 的
  状态层叠底仍写死 `Role::Surface`,改成跟 `base`;同类两处(`assemble::transparent_state_color`
  的 Outlined/Text hover、drawer 项药丸)一并跟上。
- P2-4 文档失真:`feedback/{action,mod}.rs` 删掉「本地 α 合成 / 规则只认两档」的旧描述
  与失效链接;`list_item` 的旧签名;三处 `probe/base.rs` → `probe/bases.rs`;
  新增 `surfaces::elevated::tests::the_card_base_constant_is_the_role_the_card_paints`
  真断言(原来只在文档里声称那条测试守 `BASE`),并把这句描述改准。
- nit:`disabledbase.rs` 字符串里塌成一行的续行空格、probe 汇总行把列左缘横坐标说成
  「像素计数」的措辞。

门禁(CARGO_TARGET_DIR=C:/Temp/ull-target/wt-234):fmt / check --all-targets /
test -p unlucky-app-iced(896 + 6 通过,0 失败)/ clippy --workspace --all-targets -D warnings /
文件 ≤400 全绿;probe 浅色与深色都过。
2026-11-11 06:34:24 +08:00

168 lines
6.0 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.
//! Radio button(`research/15` §19 + 规格卡):外圈 **18** / 环线 **2** / 内点 **10**(不是 12)/
//! 选中环+点 `Primary` / 未选中环 `OnSurfaceVariant`(hover 提到 `OnSurface`)/ Ø40 状态层。
//!
//! ## 为什么它不是「基于 Checkbox 画的」
//!
//! §19.6 明令:Radio **只能**是「2px 环 + 实心内点」,Checkbox **只能**是「2px 方框 + 勾/横」;
//! 两者尺寸相同(**18**)但形状语言不同,禁止混用。所以本文件是独立实现,**没有** import
//! `super::checkbox`(gallery 的 probe 另有一条断言:radio 的墨迹里不得出现方角像素)。
//!
//! ## 几何怎么落到像素
//!
//! 环:**18×18** 容器 + `Border { width: 2, radius: 9 }`。iced 的 quad 渲染器把描边路径向内缩
//! 半个线宽(半径 9−1=8、路径外接 16),再以 2 描边 → 外缘正好 18、内缘 14。这与 Compose
//! `RadioButton.kt` 的「环半径 = side/2 − 1 且描边居中 → 外缘正好 side」逐像素同源,
//! 只看 side 取 18(规格卡 `r=9`)而不是官方的 20。
//! 内点:独立 10×10 圆容器居中在环里(绘制半径 12/2 − 2/2 = 5,即**直径 10**,§19.2)。
use iced::widget::{container, row, text};
use iced::{Alignment, Element, Length, Padding};
use unlucky_design::typography::TypeRole;
use crate::theme::Ui;
use super::super::interaction::{Base, Interaction};
use super::geometry::ControlGeometry;
use super::paint::{self, Frame};
use super::shell::{self, StateSource};
/// 一个单选框(环 + 可选标签;`on_select` 为 `None` = 禁用)。
#[derive(Debug, Clone)]
pub struct Radio<'a, M> {
label: &'a str,
selected: bool,
on_select: Option<M>,
state: Option<Interaction>,
/// 脚下那一层的底(M234,禁用态合成用)。
base: Base,
}
impl<'a, M> Radio<'a, M> {
/// 一个可选的单选框。
pub fn new(label: &'a str, selected: bool, on_select: M) -> Self {
Self {
label,
selected,
on_select: Some(on_select),
state: None,
base: Base::CANVAS,
}
}
/// 禁用的单选框(无消息)。
pub fn disabled(label: &'a str, selected: bool) -> Self {
Self {
label,
selected,
on_select: None,
state: None,
base: Base::CANVAS,
}
}
/// 强制视觉态(gallery 取证用;运行期不要调)。
pub fn state(mut self, state: Interaction) -> Self {
self.state = Some(state);
self
}
/// **告诉单选框它踩在什么底上**(M234):禁用态的 38% 环/内点叠在这个底上。
pub fn base(mut self, base: impl Into<Base>) -> Self {
self.base = base.into();
self
}
}
/// 画一个单选框(环 + 内点 + 标签)。
pub fn radio<'a, M: Clone + 'a>(ui: &'a Ui, spec: Radio<'a, M>) -> Element<'a, M> {
let geometry = ControlGeometry::radio(ui);
let selected = spec.selected;
let radius = geometry.radius;
let stroke = geometry.stroke;
let dot_side = geometry.inner_dot;
let base = spec.base;
let source = StateSource::new(spec.state, spec.on_select.is_some());
// 内点与环的颜色与 hover/pressed 无关(A1:选中态四态都是 `Primary`),
// 只随「选中 + 禁用」变 —— 所以点的颜色在这里静态求值即可。
let at_rest = paint::radio(ui, base, selected, source.get(), radius, stroke);
// Ø10 内点(半径 = 直径/2)
let dot: Element<'a, M> = match at_rest.dot {
Some(ink) => container(iced::widget::Space::new())
.width(Length::Fixed(dot_side))
.height(Length::Fixed(dot_side))
.style(move |_theme| {
Frame {
fill: Some(ink),
outline: None,
stroke: 0.0,
radius: dot_side / 2.0,
}
.container_style()
})
.into(),
None => iced::widget::Space::new().into(),
};
// 20 外圈:环(未选中时环就是唯一的墨迹;选中时环 + 点同色)
let ring = container(dot)
.width(Length::Fixed(geometry.side))
.height(Length::Fixed(geometry.side))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(source.container_style(move |state| {
paint::radio(ui, base, selected, state, radius, stroke)
.ring
.container_style()
}));
// Ø40 状态层
let layer_side = geometry.state_layer;
let layer = container(ring)
.width(Length::Fixed(layer_side))
.height(Length::Fixed(layer_side))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(source.container_style(move |state| {
Frame {
fill: paint::state_layer(ui, base, state, paint::radio_layer_role(selected)),
outline: None,
stroke: 0.0,
radius: layer_side / 2.0,
}
.container_style()
}));
let mut content = row![layer].align_y(Alignment::Center).spacing(geometry.gap);
if !spec.label.is_empty() {
let style = ui.style(TypeRole::BodyLarge);
content = content.push(
text(spec.label)
.size(style.size_px)
.font(ui.font_of(style))
.color(paint::label(ui, base, source.get())),
);
}
// 命中区 48×48:左右各留 `hit_padding` 透明内边距(无标签时行宽才不是 40;复审 P2-2)
let pad = geometry.hit_padding();
let body = container(content)
.height(Length::Fill)
.align_y(Alignment::Center)
.padding(Padding {
left: pad,
right: pad,
..Padding::default()
});
shell::shell(
&source,
Length::Shrink,
Length::Fixed(geometry.hit),
spec.on_select,
body.into(),
)
}