diff --git a/crates/app-iced/src/pages/appearance/section/mod.rs b/crates/app-iced/src/pages/appearance/section/mod.rs index 391b337..2501406 100644 --- a/crates/app-iced/src/pages/appearance/section/mod.rs +++ b/crates/app-iced/src/pages/appearance/section/mod.rs @@ -54,6 +54,7 @@ //! CPU 档下面板 α 被强制 1.0(D36),而用户请求值可能是 0.5。滑块位置按**生效值**画、旁边写 //! 「requested 0.50 → forced 1.00」,这样「为什么拖了没反应」在界面上就有答案,而不是只躺在日志里。 +use iced::border::Radius; use iced::widget::{button, column, container, row}; use iced::{ Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme, @@ -67,6 +68,7 @@ use unlucky_design::typography::TypeRole; use unlucky_i18n::catalog::Key; use unlucky_i18n::Lang; +use crate::app::components::interaction::{self, Interaction}; use crate::pages::{frame, Message}; use crate::theme::Ui; @@ -242,6 +244,7 @@ pub(super) fn choice<'a>( change: Change, ) -> Element<'a, Message> { let height = ui.metrics().control_height; + let radius = ui.radius(ShapeScale::Full, height); let content_color = if selected { ui.color(Role::OnSecondaryContainer) } else { @@ -260,30 +263,114 @@ pub(super) fn choice<'a>( ) .height(height) .padding(Padding::from([0.0, ui.space(Space::X3)])) - .style(move |_theme: &IcedTheme, status| { + .style(choice_style(ui, selected, content_color, radius)) + .on_press(Message::Appearance(change)) + .into() +} + +/// [`choice`] 的样式闭包(抽成自由函数:单测要**直接**喂 `Hovered` / `Pressed` 两态)。 +/// +/// 两处口径都在这里,别在控件里再各写一遍: +/// +/// - **α 只从 [`Interaction`] 来**:hover 8% / pressed 10%(M3)。M284 之前两种态合并成一条 +/// `matches!(status, Hovered | Pressed)` 分支、都叠 `StateLayer::Hover`,于是按下被压成 8% +/// —— 两个态在图上逐像素相同; +/// - **合成底是 [`frame::BASE`]**(`MaterialLayer::Card` = `SurfaceContainerLow`),因为 `choice` +/// 住在 [`card`] 里。写死 `Role::Surface` 就是 M234/M237 修过两轮的那个错(M284 是第三处)。 +fn choice_style<'a>( + ui: &'a Ui, + selected: bool, + content_color: Color, + radius: Radius, +) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a { + move |_theme, status| { let background = if selected { Some(Background::Color(ui.color(Role::SecondaryContainer))) - } else if matches!(status, button::Status::Hovered | button::Status::Pressed) { - Some(Background::Color(ui.state_over( - unlucky_design::color::StateLayer::Hover, - Role::OnSurface, - Role::Surface, - ))) } else { - None + interaction::layer_color( + ui, + Interaction::from_status(status), + Role::OnSurface, + frame::BASE.role(), + ) + .map(Background::Color) }; button::Style { background, text_color: content_color, border: Border { - radius: ui.radius(ShapeScale::Full, height), + radius, ..Border::default() }, shadow: Shadow::default(), snap: true, } - }) - .on_press(Message::Appearance(change)) - .into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use unlucky_design::color::{SchemeFlavor, StateLayer}; + use unlucky_design::typography::Platform; + + use crate::theme::Families; + + fn ui() -> Ui { + Ui::with_platform( + SchemeFlavor::Light, + Platform::Windows, + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + ) + } + + fn solid(style: button::Style) -> Color { + match style.background { + Some(Background::Color(color)) => color, + None => panic!("未选中态的 hover/pressed 必须叠状态层"), + Some(other) => panic!("背景必须是实色,得到 {other:?}"), + } + } + + /// hover(8%) 与 pressed(10%) 是**两档**,且各自逐值等于令牌表(把 10% 改成 11% 也会红)。 + /// + /// 抓得住:①回到 `matches!(status, Hovered | Pressed)` 合并分支(两色相等 ⇒ 第一条红); + /// ②pressed 用 hover 的 α、或合成底被换回画布(第二、三条红)。 + /// 抓不住:观感;**过渡中间帧**(forced 路径不套 `tracked`,`layer_color` 直落端点值)。 + /// + /// 底的角色取 `MaterialLayer::Card.surface_role()`(`frame::BASE` 就是它):既盯住「叠的是卡底」 + /// 这条语义,也不往 `settings/*.rs` 的接线点计数里多塞一次字面量(`pages/tests/base_wiring.rs`)。 + #[test] + fn the_choice_button_draws_pressed_darker_than_hover() { + let ui = ui(); + let style = choice_style( + &ui, + false, + ui.color(Role::OnSurfaceVariant), + ui.radius(ShapeScale::Full, ui.metrics().control_height), + ); + let theme = ui.theme(); + let hover = solid(style(&theme, button::Status::Hovered)); + let pressed = solid(style(&theme, button::Status::Pressed)); + + assert_ne!(hover, pressed, "hover 与 pressed 必须是两档(8% / 10%)"); + let base = MaterialLayer::Card.surface_role(); + assert_eq!( + hover, + ui.state_over(StateLayer::Hover, Role::OnSurface, base) + ); + assert_eq!( + pressed, + ui.state_over(StateLayer::Pressed, Role::OnSurface, base) + ); + assert!( + style(&theme, button::Status::Active).background.is_none(), + "静息态不叠状态层" + ); + } } diff --git a/crates/app-iced/src/pages/settings/general.rs b/crates/app-iced/src/pages/settings/general.rs index 4842889..de136a8 100644 --- a/crates/app-iced/src/pages/settings/general.rs +++ b/crates/app-iced/src/pages/settings/general.rs @@ -38,16 +38,20 @@ //! //! 分组元数据只有一份(这里的 [`GROUP`]):`settings/mod.rs` 的 `GROUPS` 直接引用它。 +use iced::border::Radius; use iced::widget::{button, column, container, row}; -use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme}; +use iced::{ + Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme, +}; -use unlucky_design::color::{Role, StateLayer}; +use unlucky_design::color::Role; use unlucky_design::icons::IconKind; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; use unlucky_i18n::catalog::Key; use unlucky_i18n::LangChoice; +use crate::app::components::interaction::{self, Interaction}; use crate::fonts::install::{CjkInstall, FetchClass}; use crate::pages::{frame, Language, Message}; use crate::theme::Ui; @@ -315,6 +319,8 @@ fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> El ui.color(Role::OnSurfaceVariant) }; let border_color = ui.color(Role::OutlineVariant); + let stroke = crate::theme::STROKE_PX; + let radius = ui.radius(box_.shape, height); button( container(frame::text_role( @@ -331,18 +337,42 @@ fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> El 0.0, unlucky_design::inkcat::metrics::CHIP_PAD_X, ])) - .style(move |_theme: &IcedTheme, status| { + .style(chip_style( + ui, + selected, + content_color, + border_color, + stroke, + radius, + )) + .on_press(Message::Language(choice)) + .into() +} + +/// [`chip`] 的样式闭包(抽成自由函数:单测要**直接**喂 `Hovered` / `Pressed` 两态)。 +/// +/// - **α 只从 [`Interaction`] 来**:hover 8% / pressed 10%(M3)。M284 之前两种态合并成一条 +/// `matches!(status, Hovered | Pressed)` 分支、都叠 `StateLayer::Hover`,按下于是被压成 8%; +/// - **合成底是 [`frame::BASE`]**(M237:这一组住在 `frame::card` 里,状态层叠卡底而不是画布)。 +fn chip_style<'a>( + ui: &'a Ui, + selected: bool, + content_color: Color, + border_color: Color, + stroke: f32, + radius: Radius, +) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a { + move |_theme, status| { let background = if selected { Some(Background::Color(ui.color(Role::SecondaryContainer))) - } else if matches!(status, button::Status::Hovered | button::Status::Pressed) { - // M237:这一组整个住在 `frame::card` 里,状态层叠**卡底**(不是画布)。 - Some(Background::Color(ui.state_over( - StateLayer::Hover, + } else { + interaction::layer_color( + ui, + Interaction::from_status(status), Role::OnSurface, frame::BASE.role(), - ))) - } else { - None + ) + .map(Background::Color) }; button::Style { @@ -350,17 +380,11 @@ fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> El text_color: content_color, border: Border { color: border_color, - width: if selected { - 0.0 - } else { - crate::theme::STROKE_PX - }, - radius: ui.radius(box_.shape, height), + width: if selected { 0.0 } else { stroke }, + radius, }, shadow: Shadow::default(), snap: true, } - }) - .on_press(Message::Language(choice)) - .into() + } } diff --git a/crates/app-iced/src/pages/settings/general/theme.rs b/crates/app-iced/src/pages/settings/general/theme.rs index 8c4e6f6..18611f9 100644 --- a/crates/app-iced/src/pages/settings/general/theme.rs +++ b/crates/app-iced/src/pages/settings/general/theme.rs @@ -9,16 +9,20 @@ //! 的 `dark` / `light`;第三档「跟随系统」= `auto`,由系统深浅落到对应色板 //! (`BrandBoard::for_flavor`)。所以这里**不新增配置键**,旧配置读进来语义也一致。 +use iced::border::Radius; use iced::widget::{button, column, container, row}; -use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme}; +use iced::{ + Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme, +}; -use unlucky_design::color::{Role, StateLayer}; +use unlucky_design::color::Role; use unlucky_design::inkcat::metrics as chipbox; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; use unlucky_i18n::catalog::{Catalog, Key}; use unlucky_presenter::prefs::ThemeMode; +use crate::app::components::interaction::{self, Interaction}; use crate::pages::{frame, Message}; use crate::theme::Ui; @@ -103,6 +107,7 @@ fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, mode: ThemeMode) -> Eleme }; let border_color = ui.color(Role::OutlineVariant); let stroke = crate::theme::STROKE_PX; + let radius = ui.radius(box_.shape, height); button( container(frame::text_role( @@ -116,18 +121,42 @@ fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, mode: ThemeMode) -> Eleme ) .height(height) .padding(Padding::from([0.0, chipbox::CHIP_PAD_X])) - .style(move |_theme: &IcedTheme, status| { + .style(chip_style( + ui, + selected, + content_color, + border_color, + stroke, + radius, + )) + .on_press(Message::Theme(mode)) + .into() +} + +/// [`chip`] 的样式闭包(抽成自由函数:单测要**直接**喂 `Hovered` / `Pressed` 两态)。 +/// +/// - **α 只从 [`Interaction`] 来**:hover 8% / pressed 10%(M3)。M284 之前两种态合并成一条 +/// `matches!(status, Hovered | Pressed)` 分支、都叠 `StateLayer::Hover`,按下于是被压成 8%; +/// - **合成底是 [`frame::BASE`]**(M237:主题三档住在设置卡里,状态层叠卡底而不是画布)。 +fn chip_style<'a>( + ui: &'a Ui, + selected: bool, + content_color: Color, + border_color: Color, + stroke: f32, + radius: Radius, +) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a { + move |_theme, status| { let background = if selected { Some(Background::Color(ui.color(Role::SecondaryContainer))) - } else if matches!(status, button::Status::Hovered | button::Status::Pressed) { - // M237:主题三档同样住在设置卡里,状态层叠卡底(`frame::BASE`)。 - Some(Background::Color(ui.state_over( - StateLayer::Hover, + } else { + interaction::layer_color( + ui, + Interaction::from_status(status), Role::OnSurface, frame::BASE.role(), - ))) - } else { - None + ) + .map(Background::Color) }; button::Style { @@ -136,19 +165,41 @@ fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, mode: ThemeMode) -> Eleme border: Border { color: border_color, width: if selected { 0.0 } else { stroke }, - radius: ui.radius(box_.shape, height), + radius, }, shadow: Shadow::default(), snap: true, } - }) - .on_press(Message::Theme(mode)) - .into() + } } #[cfg(test)] mod tests { use super::*; + use unlucky_design::color::{SchemeFlavor, StateLayer}; + use unlucky_design::typography::Platform; + + use crate::theme::Families; + + fn ui() -> Ui { + Ui::with_platform( + SchemeFlavor::Light, + Platform::Windows, + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + ) + } + + fn solid(style: button::Style) -> Color { + match style.background { + Some(Background::Color(color)) => color, + None => panic!("未选中态的 hover/pressed 必须叠状态层"), + Some(other) => panic!("背景必须是实色,得到 {other:?}"), + } + } /// 三档 = 两块板 + 一种跟随,顺序是深 → 浅 → 跟随(换序会让「Ink 在最左」这条观感失守)。 #[test] @@ -161,4 +212,71 @@ mod tests { assert_eq!(mode_label(ThemeMode::Light), Key::SettingsThemeSilver); assert_eq!(mode_label(ThemeMode::Auto), Key::SettingsThemeFollowSystem); } + + /// hover(8%) 与 pressed(10%) 是**两档**,且各自逐值等于令牌表(把 10% 改成 11% 也会红)。 + /// + /// 抓得住:①回到 `matches!(status, Hovered | Pressed)` 合并分支(两色相等 ⇒ 第一条红); + /// ②pressed 用 hover 的 α、或合成底被换回画布(第二、三条红)。 + /// 抓不住:观感;**过渡中间帧**(forced 路径不套 `tracked`,`layer_color` 直落端点值)。 + /// + /// 底的角色取 `MaterialLayer::Card.surface_role()`(`frame::BASE` 就是它):既盯住「叠的是卡底」 + /// 这条语义,也不往 `settings/*.rs` 的接线点计数里多塞一次字面量(`pages/tests/base_wiring.rs`)。 + fn assert_two_layers(ui: &Ui, style: impl Fn(&IcedTheme, button::Status) -> button::Style) { + let theme = ui.theme(); + let hover = solid(style(&theme, button::Status::Hovered)); + let pressed = solid(style(&theme, button::Status::Pressed)); + + assert_ne!(hover, pressed, "hover 与 pressed 必须是两档(8% / 10%)"); + let base = unlucky_design::elevation::MaterialLayer::Card.surface_role(); + assert_eq!( + hover, + ui.state_over(StateLayer::Hover, Role::OnSurface, base) + ); + assert_eq!( + pressed, + ui.state_over(StateLayer::Pressed, Role::OnSurface, base) + ); + assert!( + style(&theme, button::Status::Active).background.is_none(), + "静息态不叠状态层" + ); + } + + #[test] + fn the_theme_chip_draws_pressed_darker_than_hover() { + let ui = ui(); + assert_two_layers( + &ui, + chip_style( + &ui, + false, + ui.color(Role::OnSurfaceVariant), + ui.color(Role::OutlineVariant), + crate::theme::STROKE_PX, + ui.radius(chipbox::CHIP.shape, chipbox::CHIP.height), + ), + ); + } + + /// **语言行那枚 chip**(`general::chip` 的 `chip_style`)同理:hover 8% / pressed 10%。 + /// + /// 为什么这条断言住在 `theme.rs` 而不是 `general.rs`:后者已经 389 行(硬线 400),而 + /// `general` 的私有项对子模块可见(Rust 的可见性包含后代模块),搬过来不用加 `pub`。 + #[test] + fn the_language_chip_draws_pressed_darker_than_hover() { + use super::super::chip_style as language_chip_style; + + let ui = ui(); + assert_two_layers( + &ui, + language_chip_style( + &ui, + false, + ui.color(Role::OnSurfaceVariant), + ui.color(Role::OutlineVariant), + crate::theme::STROKE_PX, + ui.radius(chipbox::CHIP.shape, chipbox::CHIP.height), + ), + ); + } } diff --git a/crates/app-iced/src/pages/settings/logs_row.rs b/crates/app-iced/src/pages/settings/logs_row.rs index b543034..d006c90 100644 --- a/crates/app-iced/src/pages/settings/logs_row.rs +++ b/crates/app-iced/src/pages/settings/logs_row.rs @@ -12,15 +12,19 @@ //! 为什么单独一个文件:`pages/settings/general.rs` 贴着 400 行硬线(语言行 + 字体行 + 三行只读), //! 这一行自带按钮与样式,塞回去必破线。 +use iced::border::Radius; use iced::widget::{button, column, container, row as row_widget}; -use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme}; +use iced::{ + Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Theme as IcedTheme, +}; -use unlucky_design::color::{Role, StateLayer}; +use unlucky_design::color::Role; use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; use unlucky_i18n::catalog::{Catalog, Key}; +use crate::app::components::interaction::{self, Interaction}; use crate::pages::{frame, Action, Message}; use crate::theme::Ui; @@ -97,33 +101,112 @@ fn action_button<'a>(ui: &'a Ui, label: &'a str, message: Message) -> Element<'a let height = ui.metrics().control_height; let content = ui.color(Role::OnSurface); let outline = ui.color(Role::OutlineVariant); + let radius = ui.radius(ShapeScale::Full, height); button( container(frame_text(ui, TypeRole::LabelLarge, label, content)).align_y(Alignment::Center), ) .height(height) .padding(Padding::from([0.0, ui.space(Space::X3)])) - .style(move |_theme: &IcedTheme, status| { - let background = - matches!(status, button::Status::Hovered | button::Status::Pressed).then(|| { - // M237:这一行住在设置卡里(`frame::card`),hover 状态层叠卡底。 - Background::Color(ui.state_over( - StateLayer::Hover, - Role::OnSurface, - frame::BASE.role(), - )) - }); + .style(action_style(ui, content, outline, radius)) + .on_press(message) + .into() +} + +/// [`action_button`] 的样式闭包(抽成自由函数:单测要**直接**喂 `Hovered` / `Pressed` 两态)。 +/// +/// - **α 只从 [`Interaction`] 来**:hover 8% / pressed 10%(M3)。M284 之前两种态合并成一条 +/// `matches!(status, Hovered | Pressed)` 分支、都叠 `StateLayer::Hover`,按下于是被压成 8%; +/// - **合成底是 [`frame::BASE`]**(M237:这一行住在设置卡里,状态层叠卡底而不是画布)。 +fn action_style<'a>( + ui: &'a Ui, + content: Color, + outline: Color, + radius: Radius, +) -> impl Fn(&IcedTheme, button::Status) -> button::Style + 'a { + move |_theme, status| { + let background = interaction::layer_color( + ui, + Interaction::from_status(status), + Role::OnSurface, + frame::BASE.role(), + ) + .map(Background::Color); + button::Style { background, text_color: content, border: Border { color: outline, width: 1.0, - radius: ui.radius(ShapeScale::Full, height), + radius, }, shadow: Shadow::default(), snap: true, } - }) - .on_press(message) - .into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use unlucky_design::color::{SchemeFlavor, StateLayer}; + use unlucky_design::typography::Platform; + + use crate::theme::Families; + + fn ui() -> Ui { + Ui::with_platform( + SchemeFlavor::Light, + Platform::Windows, + Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", + }, + ) + } + + fn solid(style: button::Style) -> Color { + match style.background { + Some(Background::Color(color)) => color, + None => panic!("hover/pressed 必须叠状态层"), + Some(other) => panic!("背景必须是实色,得到 {other:?}"), + } + } + + /// hover(8%) 与 pressed(10%) 是**两档**,且各自逐值等于令牌表(把 10% 改成 11% 也会红)。 + /// + /// 抓得住:①回到 `matches!(status, Hovered | Pressed)` 合并分支(两色相等 ⇒ 第一条红); + /// ②pressed 用 hover 的 α、或合成底被换回画布(第二、三条红)。 + /// 抓不住:观感;**过渡中间帧**(forced 路径不套 `tracked`,`layer_color` 直落端点值)。 + /// + /// 底取 `MaterialLayer::Card.surface_role()`(`frame::BASE` 就是它),不重复写字面量。 + #[test] + fn the_logs_action_button_draws_pressed_darker_than_hover() { + let ui = ui(); + let style = action_style( + &ui, + ui.color(Role::OnSurface), + ui.color(Role::OutlineVariant), + ui.radius(ShapeScale::Full, ui.metrics().control_height), + ); + let theme = ui.theme(); + let hover = solid(style(&theme, button::Status::Hovered)); + let pressed = solid(style(&theme, button::Status::Pressed)); + + assert_ne!(hover, pressed, "hover 与 pressed 必须是两档(8% / 10%)"); + let base = unlucky_design::elevation::MaterialLayer::Card.surface_role(); + assert_eq!( + hover, + ui.state_over(StateLayer::Hover, Role::OnSurface, base) + ); + assert_eq!( + pressed, + ui.state_over(StateLayer::Pressed, Role::OnSurface, base) + ); + assert!( + style(&theme, button::Status::Active).background.is_none(), + "静息态不叠状态层" + ); + } }