Files
feresaria/shell/ferese-settings/src/theme_controls.rs
T
Samuel Onoja 3533fac521 Update branding
Update the Ferese symbols, appearance-specific wallpapers, and rounded wallpaper and theme previews. Bundle Figtree and add the Ferese name to the website header.

Verified configuration, theme, Settings, wallpaper and installer tests, both UI renderers, the static site build, and browser layouts.
2026-10-04 11:11:14 +01:00

384 lines
15 KiB
Rust

use super::{
Alignment, App, Edit, Element, Kind, Length, Message, Page, PathBuf, button, column, container, gallery, row,
schema, set, text_input, visuals, widget,
};
#[derive(Clone, Default)]
pub(super) struct FamilySections {
built_in: std::sync::Arc<Vec<usize>>,
custom: std::sync::Arc<Vec<usize>>,
}
impl FamilySections {
pub(super) fn new(families: &[ferese_config::families::Family], snapshot: &crate::store::Snapshot) -> Self {
let (custom, built_in) = (0..families.len()).partition(|&index| {
snapshot
.item(&format!("theme.custom_themes.{}", families[index].id))
.is_some()
});
Self {
built_in: std::sync::Arc::new(built_in),
custom: std::sync::Arc::new(custom),
}
}
fn indices(&self, custom: bool) -> &std::sync::Arc<Vec<usize>> {
if custom { &self.custom } else { &self.built_in }
}
pub(super) fn position(&self, index: usize) -> Option<(bool, usize)> {
[false, true].into_iter().find_map(|custom| {
self.indices(custom)
.iter()
.position(|&family| family == index)
.map(|position| (custom, position))
})
}
}
impl App {
pub(super) fn theme_file_picker(&self) -> Element<'static, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let path = ["theme.file", "theme.light.file", "theme.dark.file"][self.theme_file_target];
let file = self.draft.string(path, "");
let filename = if file.is_empty() {
"No override file".to_owned()
} else {
PathBuf::from(&file)
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
};
let labels = column([])
.spacing(2)
.width(Length::Fill)
.push(self.label("Theme overrides", 13.))
.push(widget::tooltip(
self.label(filename, 11.)
.class(cosmic::theme::Text::Color(palette.muted)),
self.label(
if file.is_empty() {
"Choose a partial KDL theme file".to_owned()
} else {
file.clone()
},
11.,
),
widget::tooltip::Position::Bottom,
));
let mut scopes = row([]).spacing(2);
for (index, label) in ["Shared", "Light", "Dark"].into_iter().enumerate() {
scopes = scopes.push(
button::custom(self.label(label, 12.))
.name(format!("Theme file: {label}"))
.padding([4, 8])
.class(visuals::button_style(palette, index == self.theme_file_target))
.on_press(Message::ThemeFileTarget(index)),
);
}
let mut controls = row([])
.spacing(6)
.align_y(Alignment::Center)
.push(
container(scopes)
.padding(3)
.class(visuals::surface(palette.sidebar, 10.)),
)
.push(self.settings_button(
if file.is_empty() { "Choose…" } else { "Replace…" },
"M3 7V5h6l2 2h10v13H3z",
Some(Message::PickThemeFile(path)),
false,
));
if !file.is_empty() {
controls = controls.push(widget::tooltip(
button::custom(visuals::action_icon("M6 6l12 12M6 18L18 6", palette.muted))
.padding(4)
.on_press(Message::Change(Edit::Unset(path.into()))),
self.label("Remove override", 11.),
widget::tooltip::Position::Bottom,
));
}
container(
row([])
.spacing(6)
.align_y(Alignment::Center)
.push(labels)
.push(controls),
)
.padding([7, 10])
.width(Length::Fill)
.class(visuals::surface(palette.card, 0.))
.into()
}
pub(super) fn auto_controls(&self) -> Element<'static, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule";
let mut sources = row([]).spacing(4);
for (source, label) in [("system", "System"), ("schedule", "Schedule")] {
sources = sources.push(self.settings_button(
label,
if source == "schedule" {
"M12 3a9 9 0 1 0 0 18a9 9 0 0 0 0-18 M12 7v5l3 2"
} else {
"M3 4h18v13H3z M12 17v4 M8 21h8"
},
Some(Message::Change(set("theme.schedule.source", source))),
scheduled == (source == "schedule"),
));
}
let description = if scheduled {
"Switch between light and dark at set times."
} else {
"Follow the GTK/GNOME appearance preference."
};
let mut options = column([]).spacing(6).push(
row([])
.spacing(6)
.align_y(Alignment::Center)
.push(
column([])
.spacing(2)
.width(Length::Fill)
.push(self.label("Automatic appearance", 13.))
.push(self.note(description)),
)
.push(sources),
);
if scheduled {
let mut times = row([]).spacing(6).align_y(Alignment::Center);
for field in schema::fields(Page::Appearance)
.into_iter()
.filter(|field| field.path.starts_with("theme.schedule."))
{
let path = field.path.clone();
let Kind::Text { default, .. } = &field.kind else {
continue;
};
let value = self
.inputs
.get(&path)
.cloned()
.unwrap_or_else(|| self.draft.string(&path, default));
let submit = field.clone();
let input = text_input(*default, value)
.font(self.font)
.padding([4, 8])
.size(12)
.style(visuals::input_style(palette))
.on_input(move |value| Message::Draft(path.clone(), value))
.on_submit(move |_| Message::Commit(submit.clone()))
.on_unfocus(Message::Commit(field.clone()))
.width(if field.path.ends_with("timezone") { 150 } else { 75 });
times = times.push(
row([])
.spacing(6)
.align_y(Alignment::Center)
.push(self.label(field.label, 12.))
.push(input),
);
}
options = options.push(times);
}
container(options)
.padding([7, 10])
.width(Length::Fill)
.class(visuals::surface(palette.card, 0.))
.into()
}
pub(super) fn theme_gallery(&self, appearance: Option<ferese_config::theme::Appearance>) -> Element<'_, Message> {
use ferese_config::theme::Appearance::{Dark, Light};
let mut heading = row([]).align_y(Alignment::Center).spacing(6).push(
self.label(
match appearance {
Some(Light) => "Light theme",
Some(Dark) => "Dark theme",
None => "Theme",
},
14.,
)
.width(Length::Fill),
);
if appearance != Some(Dark) && self.undo.is_some() {
heading = heading.push(self.settings_icon_button(
"Undo",
"M4 10h8 M4 10V3 M4 10c3-7 16-6 16 3a7 7 0 0 1-7 7",
(!self.saving).then_some(Message::Undo),
));
}
let mut custom_heading = row([])
.align_y(Alignment::Center)
.spacing(6)
.push(self.label("Custom themes", 14.).width(Length::Fill));
if appearance != Some(Dark) {
custom_heading = custom_heading.push(self.settings_button(
"Import…",
"M3 7V5h6l2 2h10v13H3z M12 10v7 M9 14l3 3 3-3",
Some(Message::ImportTheme),
false,
));
}
let mut sections = column([]).spacing(12).push(
column([])
.spacing(6)
.push(heading)
.push(self.theme_gallery_grid(appearance, false)),
);
if appearance != Some(Dark) || !self.family_sections.custom.is_empty() {
sections = sections.push(
column([])
.spacing(6)
.push(custom_heading)
.push(self.theme_gallery_grid(appearance, true)),
);
}
sections.into()
}
fn theme_gallery_grid(
&self,
appearance: Option<ferese_config::theme::Appearance>,
custom: bool,
) -> Element<'_, Message> {
let mut rows = column([]).spacing(6);
let list = crate::navigation::gallery_list(appearance, custom);
for chunk in 0..self.family_sections.indices(custom).len().div_ceil(3) {
let content = widget::responsive(move |size| {
// Three borderless previews, with names overlaid inside each image.
let height = ((size.width - 12.) / 3.).max(0.) * 126. / 200.;
let content: Element<'_, Message> = if self.visible_rows.contains(&(list, chunk)) {
self.theme_gallery_row(chunk, appearance, custom)
} else {
widget::Space::new().height(height).width(Length::Fill).into()
};
cosmic::iced::widget::sensor(
container(content)
.height(height)
.id(crate::navigation::gallery_row_id(list, chunk)),
)
.key((list, chunk))
.anticipate(200)
.on_show(move |_| Message::RowVisibility(list, chunk, true))
.on_hide(Message::RowVisibility(list, chunk, false))
.into()
})
.height(Length::Shrink);
rows = rows.push(content);
}
gallery::group(
container(rows).width(Length::Fill),
cosmic::iced::advanced::widget::Id::new(format!("{list}-gallery")),
if custom {
"Custom themes"
} else {
match appearance {
Some(ferese_config::theme::Appearance::Light) => "Light theme",
Some(ferese_config::theme::Appearance::Dark) => "Dark theme",
None => "Theme",
}
},
)
}
pub(super) fn gallery_position(
&self,
id: &str,
appearance: Option<ferese_config::theme::Appearance>,
) -> Option<(&'static str, usize)> {
let index = self.family_ids.iter().position(|family| family == id)?;
let (custom, position) = self.family_sections.position(index)?;
Some((crate::navigation::gallery_list(appearance, custom), position / 3))
}
fn theme_gallery_row(
&self,
chunk: usize,
appearance: Option<ferese_config::theme::Appearance>,
custom: bool,
) -> Element<'static, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let selected_id = visuals::family_selection(&self.draft, appearance);
let indices = self.family_sections.indices(custom);
let families = &indices[chunk * 3..indices.len().min(chunk * 3 + 3)];
let has_selection = indices
.iter()
.any(|&index| self.resolved.families[index].id == selected_id);
let ids = &self.family_ids;
let mut tiles = row([]).spacing(6);
for (offset, &family_index) in families.iter().enumerate() {
let family = &self.resolved.families[family_index];
let selected = family.id == selected_id;
let index = chunk * 3 + offset;
let id = family.id.clone();
let active = selected
.then_some(self.resolved.theme.appearance)
.filter(|a| appearance.is_none_or(|variant| variant == *a));
let tile_id = gallery_id(&id, appearance);
let ids = ids.clone();
let indices = indices.clone();
tiles = tiles.push(gallery::tile(
family,
gallery::TileOptions {
variant: appearance,
active,
selected,
tab_stop: selected || (!has_selection && index == 0),
palette,
font: self.font,
id: tile_id,
},
Message::Family(id, appearance),
move |key| {
gallery::neighbor(index, indices.len(), key)
.map(|index| Message::Family(ids[indices[index]].clone(), appearance))
},
));
}
for _ in families.len()..3 {
tiles = tiles.push(widget::Space::new().width(Length::Fill));
}
tiles.into()
}
}
pub(super) fn gallery_id(
id: &str,
appearance: Option<ferese_config::theme::Appearance>,
) -> cosmic::iced::advanced::widget::Id {
cosmic::iced::advanced::widget::Id::new(format!("theme-{appearance:?}-{id}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sections_use_registered_imports_not_id_prefixes_or_catalog_position() {
let snapshot = crate::store::Snapshot::parse(
"theme { custom-themes { sunset { file \"sunset.kdl\"; }; dracula { file \"mine.kdl\"; }; }; }".into(),
)
.unwrap();
let mut families = ferese_config::families::builtins();
let mut imported = families[0].clone();
imported.id = "sunset".into();
families.insert(1, imported.clone());
imported.id = "dracula".into();
families.push(imported);
let mut legacy = families[0].clone();
legacy.id = "monokai".into();
families.push(legacy);
let sections = FamilySections::new(&families, &snapshot);
assert_eq!(sections.custom.as_slice(), &[1, 7]);
assert_eq!(sections.built_in.as_slice(), &[0, 2, 3, 4, 5, 6, 8]);
assert_eq!(sections.position(7), Some((true, 1)));
assert_eq!(sections.position(8), Some((false, 6)));
}
}