fix(design,app-iced): 图标尺寸与禁用 α 收敛到官方字节码,两族拆开(M86)
图标:IconSize::Button 16→18(AAR ButtonDefaults.<clinit> 的 bipush 18);新增 IconSize::SwitchThumb=16(SwitchTokens.Selected/UnselectedIconSize)承接开关滑块,按钮与 滑块不再借同一档;IconSize::Nav 的值不动但文档订正(无消费,官方导航/列表图标是 24)。 ButtonSmallTokens.IconSize=20 在整包 AAR 里零消费(javap 搜 getIconSize 无外部调用), 是死令牌,不再作为取值依据。 禁用 α:官方本来就分两族——按钮容器 0.10(*ButtonTokens.DisabledContainerOpacity)与 面状控件 0.12(chip 描边 / slider 轨道 / switch 轨道)。原来的缺陷是一个全局 12% 同时 服务两族(按钮那半边一直错、另半边一直对),现拆成 disabled_button_colors/…_content 与 disabled_colors/disabled_content_over_role 两个入口;interaction 首次超 400 行, 禁用口径与测试搬到 interaction/disabled.rs。 标题栏 caption 三键:官方 SmallIconButtonTokens.IconSize=24,按用户点名缩到 20 (容器 46×48 不动),登记为项目偏离;icons.rs 加按令牌取尺寸的入口 view_at,按 (种类,尺寸) 分别烘焙 SVG,20px 的 stroke 取 1.8 保视觉线宽 1.5px。
This commit is contained in:
1 parent
c825e1aa3d
commit
b566097107
16 files changed
+664
-175
No files matched your search
+103
-29
@@ -10,29 +10,52 @@
|
||||
//!
|
||||
//! 令牌的 `Icon::stroke_width(render_px)` 已经把「视觉 1.5px」折算成 **viewBox 单位**
|
||||
//! (`1.5 × 24 / render_px`),本模块**不重算也不手抄常数**。
|
||||
//! 本骨架所有图标都按 24px 渲染(12b 2.2 标题栏 / 5.5 导航),所以每个图标只有一份位图缓存。
|
||||
//!
|
||||
//! ## ⚠️ 渲染尺寸**不是**一个全局值(M86 收口)
|
||||
//!
|
||||
//! 骨架里绝大多数图标按 [`RENDER_PX`](24)渲染,但标题栏 caption 三键按
|
||||
//! [`CAPTION_PX`](20,项目偏离)渲染。**尺寸只有一个来源:`unlucky_design::icons` 的令牌**
|
||||
//! ([`IconSize`] / [`unlucky_design::icons::TITLEBAR_CAPTION_ICON_PX`])——
|
||||
//! 本文件里不许出现 `20.0` / `24.0` 这类字面量,也不许把某个组件的尺寸「凑」进 [`RENDER_PX`]。
|
||||
//!
|
||||
//! 之所以要按尺寸分别烘焙 SVG:`stroke-width` 是 viewBox 单位,同一个 `1.5` 在 20px 与 24px 渲染下
|
||||
//! 视觉线宽不同(20px 要 1.8、24px 要 1.5)。**把 24px 烘焙的位图塞进 20px 盒子里只会得到 1.25px**,
|
||||
//! 这就是「缩图标顺手把线宽带细」的老毛病,所以每个渲染尺寸各缓存一份。
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use iced::widget::svg;
|
||||
use iced::Color;
|
||||
|
||||
use unlucky_design::icons::{IconKind, IconPaint, IconSize};
|
||||
|
||||
/// 骨架期图标的统一渲染尺寸(12b:标题栏与导航轨都是 24)。
|
||||
/// 图标缓存键里的尺寸精度(px × 100 取整),避免拿 f32 做哈希键。
|
||||
const PX_KEY_SCALE: f32 = 100.0;
|
||||
|
||||
/// 骨架期图标的默认渲染尺寸(12b:标题栏与导航轨都是 24;`SmallIconButtonTokens.IconSize = 24`)。
|
||||
pub const RENDER_PX: f32 = IconSize::Toolbar.px();
|
||||
|
||||
/// 标题栏 caption 三键(最小化 / 最大化 / 关闭)的图标边长。
|
||||
///
|
||||
/// **项目偏离**(用户点名):官方 `SmallIconButtonTokens.IconSize = 24`,这里 20;容器 46×48 不动。
|
||||
/// 值只在令牌层定义一次(`unlucky_design::icons::TITLEBAR_CAPTION_ICON_PX`),本文件只是转口。
|
||||
pub const CAPTION_PX: f32 = unlucky_design::icons::TITLEBAR_CAPTION_ICON_PX;
|
||||
|
||||
/// 生成一个图标的 SVG 源码(`viewBox` 24×24,颜色用 `currentColor` 交给 iced 的色滤镜)。
|
||||
///
|
||||
/// 之所以用 `currentColor` 而不是把令牌色写进 SVG:色滤镜(`svg::Style::color`)会按
|
||||
/// **alpha 蒙版**重涂 RGB,这样一份 SVG 位图能被浅/深两套主题复用,缓存不会翻倍。
|
||||
pub fn source(kind: IconKind) -> String {
|
||||
///
|
||||
/// `render_px` 决定两件事:SVG 的 `width`/`height` 属性与 `stroke-width` 的 viewBox 折算,
|
||||
/// 两者必须同源,否则线宽会被二次缩放(见模块文档最后一段)。
|
||||
pub fn source(kind: IconKind, render_px: f32) -> String {
|
||||
let icon = kind.icon();
|
||||
let paint = match icon.paint {
|
||||
IconPaint::Stroke => format!(
|
||||
"fill=\"none\" stroke=\"currentColor\" stroke-width=\"{}\" \
|
||||
stroke-linecap=\"round\" stroke-linejoin=\"round\"",
|
||||
icon.stroke_width(RENDER_PX)
|
||||
icon.stroke_width(render_px)
|
||||
),
|
||||
IconPaint::Fill => "fill=\"currentColor\" stroke=\"none\"".to_owned(),
|
||||
};
|
||||
@@ -40,20 +63,13 @@ pub fn source(kind: IconKind) -> String {
|
||||
format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{0}\" height=\"{0}\" \
|
||||
viewBox=\"0 0 24 24\"><path d=\"{1}\" {paint}/></svg>",
|
||||
RENDER_PX, icon.path
|
||||
render_px, icon.path
|
||||
)
|
||||
}
|
||||
|
||||
/// 图标的 SVG 句柄(按 `IconKind` 缓存;句柄 id 是内容哈希,所以 iced 侧也能命中缓存)。
|
||||
fn handle(kind: IconKind) -> svg::Handle {
|
||||
static HANDLES: OnceLock<Vec<svg::Handle>> = OnceLock::new();
|
||||
|
||||
let handles = HANDLES.get_or_init(|| {
|
||||
IconKind::ALL
|
||||
.iter()
|
||||
.map(|kind| svg::Handle::from_memory(source(*kind).into_bytes()))
|
||||
.collect()
|
||||
});
|
||||
/// 图标的 SVG 句柄(按 `(种类, 渲染尺寸)` 缓存;句柄 id 是内容哈希,所以 iced 侧也能命中缓存)。
|
||||
fn handle(kind: IconKind, render_px: f32) -> svg::Handle {
|
||||
static HANDLES: OnceLock<Mutex<HashMap<(IconKind, u32), svg::Handle>>> = OnceLock::new();
|
||||
|
||||
// 依赖「枚举下标 == IconKind::ALL 下标」(令牌层用 `GLYPHS[self as usize]` 同一约定)。
|
||||
// 这条断言把约定钉在测试与 debug 构建里,`IconKind` 一旦乱序插入就会立刻炸。
|
||||
@@ -63,14 +79,34 @@ fn handle(kind: IconKind) -> svg::Handle {
|
||||
"IconKind::ALL 必须与枚举声明顺序一致"
|
||||
);
|
||||
|
||||
handles[kind as usize].clone()
|
||||
let key = (kind, (render_px * PX_KEY_SCALE).round() as u32);
|
||||
let cache = HANDLES.get_or_init(|| Mutex::new(HashMap::new()));
|
||||
let mut cache = cache
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
cache
|
||||
.entry(key)
|
||||
.or_insert_with(|| svg::Handle::from_memory(source(kind, render_px).into_bytes()))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 一个图标控件(尺寸走令牌,颜色走令牌角色)。
|
||||
/// 一个图标控件:按**令牌默认尺寸**([`RENDER_PX`])渲染,颜色走令牌角色。
|
||||
pub fn view<'a, Message: 'a>(kind: IconKind, color: Color) -> iced::Element<'a, Message> {
|
||||
svg(handle(kind))
|
||||
.width(RENDER_PX)
|
||||
.height(RENDER_PX)
|
||||
view_at(kind, color, RENDER_PX)
|
||||
}
|
||||
|
||||
/// 一个图标控件:按调用方给的**令牌尺寸**渲染(`render_px` 必须来自 `unlucky_design::icons`)。
|
||||
///
|
||||
/// 只有需要「组件自己的图标尺寸不同」时才用这个入口(目前只有标题栏 caption 三键)。
|
||||
/// 别为了摆一个位置而拿它调字面量:位置交给布局(`layout::fixed` / `centered_fixed`)。
|
||||
pub fn view_at<'a, Message: 'a>(
|
||||
kind: IconKind,
|
||||
color: Color,
|
||||
render_px: f32,
|
||||
) -> iced::Element<'a, Message> {
|
||||
svg(handle(kind, render_px))
|
||||
.width(render_px)
|
||||
.height(render_px)
|
||||
.style(move |_theme, _status| svg::Style { color: Some(color) })
|
||||
.into()
|
||||
}
|
||||
@@ -82,7 +118,7 @@ mod tests {
|
||||
#[test]
|
||||
fn every_icon_produces_wellformed_svg() {
|
||||
for kind in IconKind::ALL {
|
||||
let source = source(kind);
|
||||
let source = source(kind, RENDER_PX);
|
||||
assert!(source.starts_with("<svg"), "{} 的 SVG 头不对", kind.name());
|
||||
assert!(source.ends_with("</svg>"), "{} 的 SVG 尾不对", kind.name());
|
||||
assert!(source.contains("viewBox=\"0 0 24 24\""), "{}", kind.name());
|
||||
@@ -97,7 +133,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn stroke_icons_carry_the_token_stroke_width() {
|
||||
let stroke = source(IconKind::Settings);
|
||||
let stroke = source(IconKind::Settings, RENDER_PX);
|
||||
let expected = IconKind::Settings.icon().stroke_width(RENDER_PX);
|
||||
assert!(
|
||||
stroke.contains(&format!("stroke-width=\"{expected}\"")),
|
||||
@@ -108,19 +144,57 @@ mod tests {
|
||||
assert!((expected - 1.5).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
/// 默认尺寸**只从令牌取**:谁把 `RENDER_PX` 改成字面量或改成别的档,这条会红。
|
||||
#[test]
|
||||
fn default_render_size_comes_from_the_token() {
|
||||
assert_eq!(
|
||||
RENDER_PX,
|
||||
IconSize::Toolbar.px(),
|
||||
"默认 24 来自 IconSize::Toolbar"
|
||||
);
|
||||
assert_eq!(RENDER_PX, 24.0);
|
||||
assert!(
|
||||
source(IconKind::Close, RENDER_PX).contains("width=\"24\""),
|
||||
"默认渲染尺寸必须是 24"
|
||||
);
|
||||
}
|
||||
|
||||
/// caption 三键的 20:SVG 尺寸与线宽窗口**同源**,视觉线宽仍是 1.5px(不是 1.25)。
|
||||
#[test]
|
||||
fn caption_icons_keep_the_visual_stroke_at_their_own_size() {
|
||||
assert_eq!(CAPTION_PX, 20.0, "项目偏离值(官方 24)");
|
||||
let svg = source(IconKind::Close, CAPTION_PX);
|
||||
assert!(
|
||||
svg.contains("width=\"20\"") && svg.contains("height=\"20\""),
|
||||
"caption 图标必须按 20 烘焙:{svg}"
|
||||
);
|
||||
// 20px 渲染要 1.8 个 viewBox 单位(= 1.5px 视觉);拿 24px 的 1.5 塞进 20 盒只有 1.25px。
|
||||
assert!(
|
||||
svg.contains("stroke-width=\"1.8\""),
|
||||
"20px 渲染的 stroke-width 必须是 1.8:{svg}"
|
||||
);
|
||||
let visual = unlucky_design::icons::visual_stroke_px(CAPTION_PX, 1.8);
|
||||
assert!(
|
||||
(visual - 1.5).abs() < 1.0e-6,
|
||||
"视觉线宽应仍是 1.5,实际 {visual}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filled_icons_do_not_get_a_stroke() {
|
||||
// `more` 是三个填充圆点
|
||||
let filled = source(IconKind::More);
|
||||
let filled = source(IconKind::More, RENDER_PX);
|
||||
assert!(filled.contains("fill=\"currentColor\""));
|
||||
assert!(filled.contains("stroke=\"none\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_are_cached_per_kind() {
|
||||
let first = handle(IconKind::Close);
|
||||
let second = handle(IconKind::Close);
|
||||
assert_eq!(first.id(), second.id());
|
||||
assert_ne!(first.id(), handle(IconKind::Minimize).id());
|
||||
fn handles_are_cached_per_kind_and_size() {
|
||||
let first = handle(IconKind::Close, RENDER_PX);
|
||||
let second = handle(IconKind::Close, RENDER_PX);
|
||||
assert_eq!(first.id(), second.id(), "同尺寸同图标必须复用句柄");
|
||||
assert_ne!(first.id(), handle(IconKind::Minimize, RENDER_PX).id());
|
||||
// 同一图标、不同渲染尺寸 = 不同位图(线宽不同),不能共用。
|
||||
assert_ne!(first.id(), handle(IconKind::Close, CAPTION_PX).id());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user