use cosmic::iced::{Background, Border, Color, Vector}; use cosmic::widget::{button, container}; use cosmic::{theme, widget}; use crate::{Palette, mix}; pub fn surface(background: Color, radius: f32) -> theme::Container<'static> { theme::Container::custom(move |_| surface_appearance(background, radius)) } pub fn button_style(p: Palette, selected: bool) -> theme::Button { styled_button(p, selected, false, 1.0) } /// Selection cards share native button states while revealing the modal material. pub fn material_button_style(p: Palette, selected: bool, material_opacity: f32) -> theme::Button { let opacity = if material_opacity.is_finite() { material_opacity.clamp(0.0, 1.0) } else { 1.0 }; let fill = if opacity < 1.0 { opacity * if selected { 0.65 } else { 0.25 } } else { 1.0 }; styled_button(p, selected, false, fill) } pub fn text_button<'a, M: Clone + 'a>( label: impl Into> + 'a, font: cosmic::font::Font, palette: Palette, selected: bool, ) -> button::Button<'a, M> { let theme = cosmic::theme::active(); let native = theme.cosmic(); let content = widget::row![ crate::text(label, font) .size(14) .line_height(cosmic::iced::widget::text::LineHeight::Absolute(20.into())), ] .height(native.space_l()) .padding([0, native.space_s()]) .align_y(cosmic::iced::Alignment::Center); button::custom(content) .padding(0) .class(button_style(palette, selected)) } pub fn switch<'a, M: Clone + 'a>(enabled: bool, palette: Palette) -> button::Button<'a, M> { let paint = move |focused| button::Style { background: None, text_color: Some(palette.text), icon_color: Some(palette.text), border_radius: 12.into(), border_width: if focused { 1. } else { 0. }, border_color: palette.accent, ..Default::default() }; button::custom(crate::menus::switch(enabled, palette, 1.)) .padding(2) .class(theme::Button::Custom { active: Box::new(move |focused, _| paint(focused)), hovered: Box::new(move |focused, _| paint(focused)), pressed: Box::new(move |focused, _| paint(focused)), disabled: Box::new(move |_| paint(false)), }) } pub(crate) fn switch_colors(palette: Palette, enabled: bool, hovered: bool) -> (Color, Color) { let track = if enabled { palette.accent } else { mix(palette.card, palette.text, if hovered { 0.18 } else { 0.12 }) }; let thumb = crate::foreground(track, palette.text); (track, thumb) } pub fn navigation_style(p: Palette, selected: bool) -> theme::Button { styled_button(p, selected, true, 1.0) } pub fn settings_input(p: Palette) -> theme::TextInput { let appearance = move |focused: bool, hovered: bool| cosmic::widget::text_input::Appearance { background: mix(p.card, p.text, if hovered { 0.06 } else { 0.035 }).into(), border_radius: p.radius.min(7.).into(), border_width: 1., border_offset: None, border_color: if focused { p.accent } else { mix(p.card, p.text, if hovered { 0.26 } else { 0.18 }) }, icon_color: Some(p.muted), text_color: Some(p.text), placeholder_color: p.muted, selected_text_color: p.on_accent, selected_fill: p.accent, label_color: p.muted, }; theme::TextInput::Custom { active: Box::new(move |_| appearance(false, false)), hovered: Box::new(move |_| appearance(false, true)), focused: Box::new(move |_| appearance(true, true)), error: Box::new(move |_| appearance(true, false)), disabled: Box::new(move |_| appearance(false, false)), } } /// Give transparent native selectors the same resting surface as other controls. pub fn select<'a, M: 'a>( content: impl Into>, p: Palette, ) -> container::Container<'a, M, cosmic::Theme> { widget::container(content).class(theme::Container::custom(move |_| container::Style { background: Some(mix(p.card, p.text, 0.035).into()), border: Border { width: 1., color: mix(p.card, p.text, 0.18), radius: p.radius.min(7.).into(), }, ..Default::default() })) } fn styled_button(p: Palette, selected: bool, navigation: bool, opacity: f32) -> theme::Button { let style = move |hover: bool, focused: bool| { let background = if selected { mix(p.sidebar, p.accent, if hover { 0.24 } else { 0.17 }) } else if hover { if navigation { mix(p.card, crate::surface_shade(p.sidebar), 0.06) } else { mix(p.card, p.text, 0.08) } } else if navigation { p.sidebar } else { mix(p.card, p.text, 0.035) }; let (background, on) = if selected { crate::accent_pair(background, p.text) } else { (background, p.text) }; let mut background = background; background.a *= if hover && opacity < 1.0 { (opacity + 0.12).min(1.0) } else { opacity }; button::Style { background: Some(Background::Color(background)), text_color: Some(on), icon_color: Some(on), border_radius: p.radius.min(9.).into(), border_width: if selected || !navigation { 1. } else { 0. }, border_color: if selected { p.accent } else if !navigation { mix(p.card, p.text, 0.18) } else { Color::TRANSPARENT }, outline_width: if focused { 2. } else { 0. }, outline_color: p.accent, overlay: None, shadow_offset: Vector::ZERO, } }; theme::Button::Custom { active: Box::new(move |focused, _| style(false, focused)), hovered: Box::new(move |focused, _| style(true, focused)), pressed: Box::new(move |focused, _| style(true, focused)), disabled: Box::new(move |_| style(false, false)), } } /// Authentication actions use normal text while enabled and muted text while disabled. /// Keep the disabled fill neutral so contrast correction cannot make it look active. pub fn authentication_button(palette: Palette) -> theme::Button { let paint = move |enabled: bool, interaction: f32, focused: bool| { let (fill, foreground) = if enabled { let candidate = mix(palette.accent, palette.text, interaction); crate::accent_pair(crate::composite(candidate, palette.card), palette.text) } else { (crate::composite(palette.card, palette.background), palette.muted) }; button::Style { background: Some(Background::Color(fill)), text_color: Some(foreground), icon_color: Some(foreground), border_radius: palette.radius.into(), outline_width: if focused { 1. } else { 0. }, outline_color: foreground, ..Default::default() } }; theme::Button::Custom { active: Box::new(move |focused, _| paint(true, 0., focused)), hovered: Box::new(move |focused, _| paint(true, 0.06, focused)), pressed: Box::new(move |focused, _| paint(true, 0.12, focused)), disabled: Box::new(move |_| paint(false, 0., false)), } } pub fn authentication_input(palette: Palette) -> theme::TextInput { let text = palette.text; let muted = palette.muted; let accent = palette.accent; let radius = palette.radius.min(10.); let appearance = move |focused: bool| widget::text_input::Appearance { background: Color::from_rgba(text.r, text.g, text.b, 0.045).into(), border_radius: radius.into(), border_width: 1., border_offset: None, border_color: if focused { accent.scale_alpha(0.82) } else { muted.scale_alpha(0.26) }, icon_color: Some(muted), text_color: Some(text), placeholder_color: muted, selected_text_color: text, selected_fill: accent.scale_alpha(0.35), label_color: text, }; theme::TextInput::Custom { active: Box::new(move |_| appearance(false)), hovered: Box::new(move |_| appearance(true)), focused: Box::new(move |_| appearance(true)), error: Box::new(move |_| appearance(false)), disabled: Box::new(move |_| appearance(false)), } } pub fn lock_input(radius: f32, accent: cosmic::iced::Color, surface: cosmic::iced::Color) -> theme::TextInput { let appearance = move |focused: bool| widget::text_input::Appearance { background: cosmic::iced::Color::from_rgba( (surface.r + 1.) * 0.5, (surface.g + 1.) * 0.5, (surface.b + 1.) * 0.5, 0.24, ) .into(), border_radius: (radius * 2.).min(26.).into(), border_width: 1., border_offset: None, border_color: if focused { accent.scale_alpha(0.7) } else { cosmic::iced::Color::WHITE.scale_alpha(0.15) }, icon_color: Some(cosmic::iced::Color::WHITE.scale_alpha(0.8)), text_color: Some(cosmic::iced::Color::WHITE), placeholder_color: cosmic::iced::Color::WHITE.scale_alpha(0.55), selected_text_color: cosmic::iced::Color::WHITE, selected_fill: accent.scale_alpha(0.5), label_color: cosmic::iced::Color::WHITE, }; theme::TextInput::Custom { active: Box::new(move |_| appearance(false)), hovered: Box::new(move |_| appearance(true)), focused: Box::new(move |_| appearance(true)), error: Box::new(move |_| appearance(false)), disabled: Box::new(move |_| appearance(false)), } } pub fn shell_button( foreground: Color, selected: bool, opacity: f32, radius: f32, progress: f32, pressed: bool, ) -> button::Style { button::Style { text_color: Some(foreground), icon_color: Some(foreground), border_radius: radius.into(), background: Some(Background::Color(Color { a: if pressed { 0.20 * opacity } else { ((if selected { 0.14 } else { 0.0 }) + progress * if selected { 0.02 } else { 0.08 }) * opacity }, ..foreground })), ..Default::default() } } pub fn surface_appearance(background: Color, radius: f32) -> container::Style { container::Style { background: Some(Background::Color(background)), border: Border { radius: radius.into(), ..Default::default() }, ..Default::default() } } pub fn notification_button(foreground: Color, hover: Color, radius: f32, filled: bool) -> theme::Button { let style = move |active: bool| button::Style { text_color: Some(foreground), icon_color: Some(foreground), background: (active || filled).then_some(Background::Color(hover)), border_radius: radius.into(), ..Default::default() }; theme::Button::Custom { active: Box::new(move |_, _| style(false)), hovered: Box::new(move |_, _| style(true)), pressed: Box::new(move |_, _| style(true)), disabled: Box::new(move |_| style(false)), } } pub fn filled_button(fill: Color, foreground: Color, radius: f32, opacity: f32) -> theme::Button { let paint = move |outline| button::Style { background: Some(Color { a: opacity, ..fill }.into()), text_color: Some(foreground), icon_color: Some(foreground), border_radius: radius.into(), outline_width: outline, outline_color: foreground, ..Default::default() }; theme::Button::Custom { active: Box::new(move |focused, _| paint(if focused { 1. } else { 0. })), hovered: Box::new(move |_, _| paint(1.)), pressed: Box::new(move |_, _| paint(2.)), disabled: Box::new(move |_| paint(0.)), } } #[cfg(test)] mod tests { use super::*; #[test] fn authentication_button_does_not_reverse_enabled_and_disabled_text() { use cosmic::widget::button::Catalog; let palette = Palette { appearance: ferese_theme_model::Appearance::Dark, high_contrast: false, background: Color::from_rgb8(17, 24, 33), sidebar: Color::from_rgb8(17, 24, 33), card: Color::from_rgb8(17, 24, 33), accent: Color::from_rgb8(61, 123, 230), on_accent: Color::from_rgb8(17, 24, 33), text: Color::from_rgb8(244, 247, 251), muted: Color::from_rgb8(135, 147, 162), error: Color::from_rgb8(235, 98, 98), radius: 10., }; let theme = palette.native_theme(); let class = authentication_button(palette); for style in [ theme.active(false, false, &class), theme.hovered(false, false, &class), theme.pressed(false, false, &class), ] { assert_eq!(style.text_color, Some(palette.text)); let Some(Background::Color(fill)) = style.background else { panic!("No fill") }; assert!(crate::contrast(fill, palette.text) >= 4.5); } let disabled = theme.disabled(&class); assert_eq!(disabled.text_color, Some(palette.muted)); assert_eq!(disabled.background, Some(Background::Color(palette.card))); } #[test] fn switch_thumb_remains_readable_in_every_preset_and_state() { for preset in ferese_config::presets::PRESETS { let resolved = ferese_theme_model::ResolvedTheme { appearance: preset.appearance, tokens: ferese_config::theme::preset(preset.id, preset.appearance).unwrap(), ..ferese_config::theme::default_theme() }; let palette = Palette::from_resolved(&resolved); for enabled in [false, true] { for hovered in [false, true] { let (track, thumb) = switch_colors(palette, enabled, hovered); assert!( crate::contrast(track, thumb) >= 4.5, "{}: enabled={enabled}, hovered={hovered}", preset.name, ); } } } } }