Files
feresaria/crates/ferese-theme/src/controls.rs
T
Samuel Onoja 96f5ee442e Add display mode chooser and align screen-sharing styles
Open the four display modes from XF86Display or the external-monitor bar icon. Reuse output reconciliation and confirmation for manual layout changes, and notify the shell when monitor state changes.

Release backdrop locks before render-element queries to prevent blurred popups from freezing the compositor. Cover popup open, update and close with regression tests.

Share translucent card styles between display modes and capture sources, show focus rings during keyboard navigation, and align picker buttons, label sizing and footer spacing with system modals.
2026-10-02 13:25:39 +01:00

410 lines
14 KiB
Rust

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<std::borrow::Cow<'a, str>> + '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<cosmic::Element<'a, M>>,
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_config::theme::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 crate::PRESETS {
let resolved = ferese_config::theme::ResolvedTheme {
appearance: preset.appearance,
tokens: ferese_config::theme::preset(preset.id, preset.appearance).unwrap(),
..Default::default()
};
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,
);
}
}
}
}
}