Files
feresaria/shell/ferese-settings/src/visuals.rs
T

294 lines
11 KiB
Rust

use crate::{
Message,
schema::Page,
store::{Edit, Snapshot, set},
};
use cosmic::{
Element,
iced::{Background, Border, Color, Length, Vector},
theme,
widget::{self, button, container, icon as svg_icon},
};
#[derive(Clone, Copy)]
pub struct Palette {
pub background: Color,
pub sidebar: Color,
pub card: Color,
pub accent: Color,
pub text: Color,
pub muted: Color,
pub error: Color,
}
pub fn color(value: &str, fallback: Color) -> Color {
let Some(value) = value.strip_prefix('#') else {
return fallback;
};
if value.len() != 6 {
return fallback;
}
u32::from_str_radix(value, 16)
.map(|rgb| Color::from_rgb8((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8))
.unwrap_or(fallback)
}
impl Palette {
pub fn from(snapshot: &Snapshot) -> Self {
let base = color(
&snapshot.string("theme.colors.surface_base", "#111821"),
Color::from_rgb8(15, 16, 20),
);
Self {
background: mix(base, Color::WHITE, 0.025),
sidebar: base,
card: mix(base, Color::WHITE, 0.055),
accent: color(
&snapshot.string("theme.colors.accent", "#5B8CFF"),
Color::from_rgb8(229, 200, 144),
),
text: color(
&snapshot.string("theme.colors.text_primary", "#e2e0e6"),
Color::WHITE,
),
muted: color(
&snapshot.string("theme.colors.text_muted", "#96959f"),
Color::from_rgb8(150, 149, 159),
),
error: mix(base, Color::from_rgb8(210, 80, 80), 0.2),
}
}
}
fn mix(a: Color, b: Color, t: f32) -> Color {
Color::from_rgb(
a.r + (b.r - a.r) * t,
a.g + (b.g - a.g) * t,
a.b + (b.b - a.b) * t,
)
}
fn hex(c: Color) -> String {
format!(
"#{:02x}{:02x}{:02x}",
(c.r * 255.) as u8,
(c.g * 255.) as u8,
(c.b * 255.) as u8
)
}
pub fn surface(background: Color, radius: f32) -> theme::Container<'static> {
theme::Container::custom(move |_| container::Style {
background: Some(Background::Color(background)),
border: Border {
radius: radius.into(),
..Default::default()
},
..Default::default()
})
}
pub fn button_style(p: Palette, selected: bool) -> theme::Button {
styled_button(p, selected, false)
}
pub fn navigation_style(p: Palette, selected: bool) -> theme::Button {
styled_button(p, selected, true)
}
pub fn input_style(p: Palette) -> theme::TextInput {
let appearance = move |focused: bool, hovered: bool| cosmic::widget::text_input::Appearance {
background: mix(p.sidebar, p.card, if hovered { 0.65 } else { 0.4 }).into(),
border_radius: 7.into(),
border_width: if focused { 1. } else { 0. },
border_offset: None,
border_color: p.accent,
icon_color: Some(p.muted),
text_color: Some(p.text),
placeholder_color: p.muted,
selected_text_color: p.sidebar,
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)),
}
}
fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button {
let style = move |hover: bool| button::Style {
background: Some(Background::Color(if selected {
mix(p.sidebar, p.accent, if hover { 0.24 } else { 0.17 })
} else if hover {
mix(p.card, Color::WHITE, 0.06)
} else if navigation {
p.sidebar
} else {
p.card
})),
text_color: Some(if selected { p.accent } else { p.text }),
icon_color: None,
border_radius: 9.into(),
border_width: 0.,
border_color: Color::TRANSPARENT,
outline_width: 0.,
outline_color: Color::TRANSPARENT,
overlay: None,
shadow_offset: Vector::ZERO,
};
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 native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme {
let mut theme = cosmic::theme::COSMIC_DARK.clone();
if let Some(snapshot) = snapshot {
let p = Palette::from(snapshot);
let accent = cosmic::theme::CosmicColor::new(p.accent.r, p.accent.g, p.accent.b, 1.);
theme.accent.base = accent;
theme.accent_button.base = accent;
theme.text_button.on = accent;
}
cosmic::Theme::custom(std::sync::Arc::new(theme))
}
pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font {
static FONTS: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, cosmic::font::Font>>,
> = std::sync::OnceLock::new();
let family = snapshot.string("theme.typography.font_family", "Inter");
let mut fonts = FONTS.get_or_init(Default::default).lock().unwrap();
if let Some(font) = fonts.get(&family) {
return *font;
}
if fonts.len() >= 32 {
return cosmic::font::default();
}
let font = cosmic::font::Font::with_name(Box::leak(family.clone().into_boxed_str()));
fonts.insert(family, font);
font
}
pub fn icon(page: Page, tint: Color) -> svg_icon::Icon {
svg_icon::from_svg_bytes(format!(r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="{}" fill="none" stroke="{}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>"##, page.icon(),hex(tint)).into_bytes()).symbolic(false).icon().size(19)
}
pub fn brand_icon() -> svg_icon::Icon {
svg_icon::from_svg_bytes(include_bytes!("../../../packaging/icons/ferese.svg").as_slice())
.symbolic(false)
.icon()
.size(32)
}
pub fn action_icon(path: &str, tint: Color) -> svg_icon::Icon {
svg_icon::from_svg_bytes(format!(r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="{path}" fill="none" stroke="{}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>"##, hex(tint)).into_bytes())
.symbolic(false)
.icon()
.size(16)
}
const PRESETS: [(&str, &str, &str, &str, &str); 3] = [
("#e5c890", "#0f1014", "#c9c7cd", "#9998a8", "#795e40"),
("#e5e5e5", "#101012", "#ededf0", "#97979f", "#696973"),
("#cba6f7", "#1e1e2e", "#cdd6f4", "#a6adc8", "#89b4fa"),
];
pub fn preset(index: usize) -> Vec<Edit> {
let (accent, base, text, muted, end) = PRESETS[index.min(2)];
vec![
set("theme.colors.accent", accent),
set("theme.colors.surface_base", base),
set("theme.colors.text_primary", text),
set("theme.colors.text_muted", muted),
set("theme.surface.bar.background", base),
set("theme.surface.bar.text_primary", text),
set("theme.surface.bar.text_muted", muted),
set("theme.focus_ring.gradient.from", accent),
set("theme.focus_ring.gradient.to", end),
]
}
pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool {
let (accent, base, text, muted, _) = PRESETS[index];
[
("accent", accent),
("surface_base", base),
("text_primary", text),
("text_muted", muted),
]
.into_iter()
.all(|(key, value)| {
snapshot
.string(&format!("theme.colors.{key}"), "")
.eq_ignore_ascii_case(value)
})
}
pub fn swatches(index: usize) -> Element<'static, Message> {
let (accent, base, _, _, end) = PRESETS[index];
let mut row = widget::row([]).spacing(3);
for value in [base, end, accent] {
row = row.push(
container(widget::Space::new().width(12).height(22))
.class(surface(color(value, Color::WHITE), 4.)),
);
}
row.into()
}
// A deliberately stylized preview, built as vector geometry at the actual
// output scale. No full-resolution wallpaper buffers or screenshot polling.
pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> {
let p = Palette::from(snapshot);
let base = hex(p.sidebar);
let card = hex(p.card);
let accent = hex(p.accent);
let muted = hex(p.muted);
let gap = snapshot.number("layout.inner_gap", 8.).clamp(0., 32.);
let radius = snapshot
.number("theme.geometry.window_radius", 14.)
.clamp(0., 28.);
let bar_y = snapshot.number("theme.geometry.top_bar_margin_top", 0.) * 0.5 + 12.;
let bar_margin = snapshot.number("theme.geometry.top_bar_margin_horizontal", 0.) * 0.5 + 14.;
let bar_height = snapshot.number("theme.geometry.top_bar_height", 30.) * 0.65;
let bar_radius = snapshot.number("theme.geometry.top_bar_radius", 0.) * 0.65;
let opacity = snapshot.number("theme.surface.bar.opacity", 0.78);
let right = 340. + gap / 2.;
let left_width = 288. - gap / 2.;
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="680" height="210" viewBox="0 0 680 210">
<defs><linearGradient id="wall" x2="1" y2="1"><stop stop-color="{accent}" stop-opacity=".20"/><stop offset="1" stop-color="{base}"/></linearGradient></defs>
<rect width="680" height="210" rx="14" fill="{base}"/><rect width="680" height="210" rx="14" fill="url(#wall)"/>
<path d="M0 180Q160 30 350 145T680 100V210H0Z" fill="{accent}" opacity=".055"/>
<rect x="{bar_margin}" y="{bar_y}" width="{}" height="{bar_height}" rx="{bar_radius}" fill="{base}" opacity="{opacity}"/>
<rect x="30" y="{}" width="12" height="5" rx="2.5" fill="{accent}"/><circle cx="51" cy="{}" r="2.5" fill="{muted}"/>
<path d="M595 {}h10m10 0h10m10 0h10" stroke="{muted}" stroke-width="3" stroke-linecap="round"/>
<rect x="44" y="60" width="{left_width}" height="132" rx="{radius}" fill="{card}" stroke="{accent}" stroke-width="1.2"/>
<rect x="{right}" y="60" width="{left_width}" height="132" rx="{radius}" fill="{card}"/>
<path d="M64 80h65M64 100h170M64 115h120M64 130h152" stroke="{muted}" opacity=".45" stroke-width="4" stroke-linecap="round"/>
<rect x="{}" y="78" width="50" height="94" rx="5" fill="{base}"/>
<path d="M420 82h85M420 103h175M420 121h150M420 139h160" stroke="{muted}" opacity=".32" stroke-width="4" stroke-linecap="round"/>
</svg>"##,
680. - 2. * bar_margin,
bar_y + bar_height / 2. - 2.5,
bar_y + bar_height / 2.,
bar_y + bar_height / 2.,
right + 16.
);
svg_icon::from_svg_bytes(svg.into_bytes())
.symbolic(false)
.icon()
.width(Length::Fill)
.height(Length::Fixed(132.))
.content_fit(cosmic::iced::ContentFit::Contain)
.into()
}