Split shell, settings, and compositor into focused modules

This commit is contained in:
Samuel Onoja committed 2026-10-01 12:47:26 +01:00
1 parent 2b9f30fa5d
commit cbf048566a
38 files changed
+9998 -9817

No files matched your search

+247
View File
@@ -0,0 +1,247 @@
use cosmic::widget::column;
use super::{
Alignment, App, Element, Field, Kind, Length, Message, button, container, displays, fonts, row, set, slider,
text_input, visuals, widget,
};
impl App {
pub(super) fn label<'a>(
&self,
text: impl Into<std::borrow::Cow<'a, str>> + 'a,
size: f32,
) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
ferese_theme::text(text, self.font).size(size)
}
pub(super) fn note(&self, text: &str) -> Element<'static, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
self.label(text.to_owned(), 12.)
.class(cosmic::theme::Text::Color(palette.muted))
.into()
}
pub(super) fn field(&self, mut field: Field) -> Element<'static, Message> {
if field.path == "theme.material.opacity" {
let appearance = self.resolved.theme.appearance;
let name = match appearance {
ferese_config::theme::Appearance::Light => "light",
ferese_config::theme::Appearance::Dark => "dark",
};
field.path = format!("theme.{name}.material.opacity");
field.description = format!("Opacity for the current {name} appearance. Text and icons stay opaque.");
if let Kind::Range { default, .. } = &mut field.kind {
*default = self.resolved.theme.tokens.material.opacity;
}
}
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let mut labels = column([]).spacing(3).push(self.label(field.label.clone(), 13.));
if !field.description.is_empty() {
labels = labels.push(
self.label(field.description.clone(), 11.)
.class(cosmic::theme::Text::Color(palette.muted)),
);
}
let labels = labels.width(Length::Fill);
let path = field.path.clone();
let control: Element<'static, Message> = match field.kind.clone() {
Kind::Font => {
let value = self
.inputs
.get(&path)
.cloned()
.unwrap_or_else(|| self.draft.string(&path, ""));
let families = fonts::families();
let selected = if value.is_empty() {
Some(0)
} else {
families.iter().position(|family| family == &value)
};
let selection_path = path.clone();
let commit = field.clone();
column([])
.spacing(6)
.push(
cosmic::iced::widget::pick_list(
families,
selected.map(|index| families[index].clone()),
move |family: String| {
let value = if family == "Default font" {
String::new()
} else {
family
};
Message::SelectFont(selection_path.clone(), value)
},
)
.font(self.font)
.text_size(12)
.width(225),
)
.push(
text_input("Default font", value)
.font(self.font)
.style(visuals::input_style(palette))
.on_input(move |value| Message::Draft(path.clone(), value))
.on_submit(move |_| Message::Commit(field.clone()))
.on_unfocus(Message::Commit(commit))
.width(225)
.size(12),
)
.into()
}
Kind::Toggle(default) => {
let enabled = self.draft.boolean(&path, default);
ferese_theme::controls::switch(enabled, palette)
.name(format!("{}: {}", field.label, if enabled { "on" } else { "off" }))
.on_press(Message::Change(set(&path, !enabled)))
.into()
}
Kind::Range {
default,
min,
max,
step,
suffix,
integer,
} => {
let value = self
.ranges
.get(&path)
.copied()
.unwrap_or_else(|| self.draft.number(&path, default));
let display = if integer || step >= 1. {
format!("{value:.0}{suffix}")
} else {
format!("{value:.2}{suffix}")
};
let release = field.clone();
row([])
.align_y(Alignment::Center)
.spacing(14)
.push(
slider(min..=max, value, move |value| Message::Range(field.clone(), value))
.step(step)
.class(ferese_theme::menus::slider(palette.accent, 1.))
.on_release(Message::Release(release))
.width(145),
)
.push(self.label(display, 12.).width(65))
.into()
}
Kind::Choice { default, choices } => {
let value = self.draft.string(&path, default);
let mut options = row([]).spacing(2);
for (key, label) in choices {
options = options.push(
button::custom(self.label(*label, 12.))
.padding([6, 9])
.class(visuals::button_style(palette, value == *key))
.on_press(Message::Change(set(&path, *key))),
);
}
container(options)
.padding(3)
.class(visuals::surface(palette.sidebar, 10.))
.into()
}
Kind::Text { default, argv } => {
let value = self.inputs.get(&path).cloned().unwrap_or_else(|| {
if argv {
self.draft.argv(&path)
} else {
self.draft.string(&path, default)
}
});
let commit = field.clone();
let is_color = path.starts_with("theme.colors.");
let swatch = visuals::color(&value, palette.accent);
let input = text_input(default, value)
.font(self.font)
.style(visuals::input_style(palette))
.on_input(move |value| Message::Draft(path.clone(), value))
.on_submit(move |_| Message::Commit(field.clone()))
.on_unfocus(Message::Commit(commit))
.width(if is_color { 155 } else { 225 })
.size(12);
if is_color {
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(container(widget::Space::new().width(24).height(24)).class(visuals::surface(swatch, 6.)))
.push(input)
.into()
} else {
input.into()
}
}
};
container(
row([])
.spacing(16)
.align_y(Alignment::Center)
.push(labels)
.push(control),
)
.padding([12, 12])
.width(Length::Fill)
.class(visuals::surface(palette.card, 0.))
.into()
}
pub(super) fn refresh_controls(&self, prefix: &str) -> Element<'_, Message> {
let matcher = self.draft.string(&format!("{prefix}.match"), "");
let configured = self.draft.string(&format!("{prefix}.mode"), "");
let automatic = self.draft.boolean(&format!("{prefix}.auto_refresh"), false);
let Some(display) = self
.displays
.iter()
.find(|display| display.connector == matcher || display.identity == matcher)
else {
return self.note("Connect this display to choose a supported refresh rate.");
};
let modes = displays::choices(display, &configured);
let mut controls = row([]).spacing(8).align_y(Alignment::Center);
for mode in &modes {
let selected = !automatic
&& configured
.split('@')
.nth(1)
.and_then(|rate| rate.parse::<f64>().ok())
.is_some_and(|rate| (rate * 1000. - f64::from(mode.refresh)).abs() < 1.);
controls = controls.push(
ferese_theme::controls::text_button(
mode.label(),
self.font,
visuals::Palette::from_resolved(&self.resolved.presented),
selected,
)
.on_press(Message::RefreshRate(prefix.to_owned(), *mode, false)),
);
}
let supports_auto = modes.iter().any(|mode| (59_000..=61_000).contains(&mode.refresh));
if supports_auto && let Some(mode) = modes.last() {
controls = controls.push(
ferese_theme::controls::text_button(
"Auto",
self.font,
visuals::Palette::from_resolved(&self.resolved.presented),
automatic,
)
.on_press(Message::RefreshRate(prefix.to_owned(), *mode, true)),
);
}
let current = display.current.map(|mode| mode.label()).unwrap_or_else(|| "Off".into());
let profile = display.profile.as_deref().unwrap_or("automatic configuration");
column![
self.label("Refresh rate", 14.),
self.note(&format!("Current: {current} · active profile: {profile}")),
controls,
self.note(if supports_auto {
"Auto uses 60 Hz below 30% on battery. Normal refresh returns on AC or at 35%. Switching may briefly blank the display. Choosing a rate turns Auto off."
} else {
"This resolution has no supported 60 Hz mode for automatic switching."
}),
].spacing(8).into()
}
}
File diff suppressed because it is too large. Load diff
+531
View File
@@ -0,0 +1,531 @@
use super::{
Alignment, App, Element, Field, Kind, Length, Message, Page, button, column, container, row, schema, scrollable,
text_input, visuals, widget,
};
impl App {
pub(super) fn page_view(&self) -> Element<'_, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let mut sidebar = column([])
.spacing(3)
.push(
widget::mouse_area(
row([])
.spacing(10)
.align_y(Alignment::Center)
.push(visuals::brand_icon())
.push(self.label("Ferese", 16.))
.width(Length::Fill),
)
.on_press(Message::DragWindow)
.interaction(cosmic::iced::mouse::Interaction::Grab),
)
.push(widget::Space::new().height(14))
.push(
text_input("Search settings", self.search.clone())
.on_input(Message::Search)
.font(self.font)
.style(visuals::input_style(palette))
.size(12),
)
.push(widget::Space::new().height(8));
for page in Page::ALL {
sidebar = sidebar.push(
button::custom(
row([])
.spacing(10)
.align_y(Alignment::Center)
.push(visuals::icon(
page,
if self.page == page {
palette.accent
} else {
palette.muted
},
))
.push(self.label(page.title(), 13.)),
)
.width(Length::Fill)
.padding([8, 8])
.class(visuals::navigation_style(palette, self.page == page))
.on_press(Message::Page(page)),
);
}
let sidebar = container(sidebar.push(widget::Space::new().height(Length::Fill)))
.width(204)
.height(Length::Fill)
.padding([20, 10])
.class(visuals::surface(palette.sidebar, 0.));
let mut heading = row([]).align_y(Alignment::Center).spacing(20).push(
column([])
.spacing(5)
.push(self.label(
if self.search.is_empty() {
self.page.title()
} else {
"Search"
},
24.,
))
.push(
self.label(
if self.search.is_empty() {
self.page.subtitle()
} else {
"Find a setting for your desktop."
},
12.,
)
.class(cosmic::theme::Text::Color(palette.muted)),
)
.width(Length::Fill),
);
if self.page == Page::Appearance && self.search.is_empty() {
let automatic = self.draft.string("theme.mode", "dark") == "auto";
heading = heading.push(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(self.label("Auto", 12.))
.push(
ferese_theme::controls::switch(automatic, palette)
.name(if automatic {
"Auto appearance: on"
} else {
"Auto appearance: off"
})
.on_press(Message::AutoAppearance(!automatic)),
),
);
let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule";
let source = if scheduled { "Schedule" } else { "System" };
let label = if automatic {
format!(
"{source} · {}",
if self.resolved.theme.appearance == ferese_config::theme::Appearance::Light {
"Light"
} else {
"Dark"
},
)
} else {
source.to_owned()
};
let explanation = if scheduled {
format!(
"Auto uses Light from {} and Dark from {} ({})",
self.draft.string("theme.schedule.light_at", "07:00"),
self.draft.string("theme.schedule.dark_at", "19:00"),
self.draft.string("theme.schedule.timezone", "system"),
)
} else {
"Auto follows the GTK/GNOME appearance preference".to_owned()
};
heading = heading.push(widget::tooltip(
ferese_theme::controls::text_button(label, self.font, palette, self.auto_details)
.name("Automatic appearance settings")
.on_press(Message::AutoDetails(!self.auto_details)),
self.label(explanation, 12.),
widget::tooltip::Position::Bottom,
));
}
let mut body = column([]).spacing(12);
if !self.search.is_empty() {
if !Page::ALL.into_iter().any(|p| p.matches(&self.search)) {
body = body.push(self.note("No matching settings. Try wallpaper, keyboard, or motion."));
}
for page in Page::ALL.into_iter().filter(|p| p.matches(&self.search)) {
body = body.push(
button::custom(
column([])
.spacing(5)
.push(self.label(page.title(), 16.))
.push(self.label(page.subtitle(), 12.)),
)
.width(Length::Fill)
.padding(20)
.class(visuals::button_style(palette, false))
.on_press(Message::Page(page)),
);
}
} else {
if matches!(self.page, Page::Bar | Page::Windows) {
body = body.push(visuals::preview_resolved(&self.draft, &self.resolved.presented));
}
if self.page == Page::Appearance {
let split = visuals::split(&self.draft);
if split {
body = body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Light)));
body = body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Dark)));
} else {
body = body.push(self.theme_gallery(None));
}
body = body.push(
widget::checkbox(split)
.label("Use different themes for light and dark")
.text_size(12)
.font(self.font)
.on_toggle(Message::SplitThemes),
);
if let Some(note) = &self.resolved.fallback_note {
body = body.push(self.note(note));
}
}
if self.page == Page::Wallpaper {
if let Some(handle) = &self.thumbnail {
body = body.push(
widget::image(handle.clone())
.width(Length::Fill)
.height(190)
.content_fit(cosmic::iced::ContentFit::Contain),
);
} else {
body = body.push(self.note(if self.thumbnail_loading {
"Loading wallpaper preview…"
} else {
self.thumbnail_error
.as_deref()
.unwrap_or("Choose an image to preview your wallpaper.")
}));
}
body = body.push(button::standard("Choose image…").on_press(Message::PickWallpaper));
}
let fields = schema::fields(self.page);
if !fields.is_empty() {
let mut group = column([]).spacing(1);
if self.page == Page::Appearance && self.auto_details {
group = group.push(self.auto_controls());
}
for field in fields {
if self.page == Page::Appearance
&& (field.path == "theme.mode"
|| field.path.starts_with("theme.schedule.")
|| field.path.ends_with(".file"))
{
continue;
}
group = group.push(self.field(field));
}
if self.page == Page::Appearance {
group = group.push(
button::custom(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(self.label("Advanced customization", 13.))
.push(visuals::action_icon(
if self.advanced_theme {
"M6 15l6-6 6 6"
} else {
"M6 9l6 6 6-6"
},
palette.muted,
)),
)
.name(if self.advanced_theme {
"Collapse advanced customization"
} else {
"Expand advanced customization"
})
.padding(12)
.width(Length::Fill)
.class(visuals::button_style(palette, false))
.on_press(Message::AdvancedTheme(!self.advanced_theme)),
);
if self.advanced_theme {
group = group.push(self.theme_file_picker());
}
}
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
}
match self.page {
Page::LockScreen => {
body = body.push(self.note("Uses your wallpaper, shell colors, font and corner radius from Appearance. Changes apply when the locker or preview opens."));
body = body.push(button::standard("Preview lock screen").on_press(Message::PreviewLock));
body = body.push(self.note("The preview is an ordinary window and does not lock your session. Automatic locking is configured separately in Login items."));
}
Page::Desktop => {
let can_change_list = !self.saving && !self.note_editors.values().any(|e| e.dirty);
body = body.push(self.label("Sticky notes", 16.));
body = body.push(self.note("Edit here; notes save after you pause typing. Desktop cards stay behind windows and are click-through."));
for index in 0..self.draft.records("desktop_widgets.notes") {
let id = self.draft.string(&format!("desktop_widgets.notes.{index}.id"), "note");
let mut group = column([]).spacing(8);
for field in schema::note_fields(index) {
group = group.push(self.field(field));
}
if let Some(editor) = self.note_editors.get(&id) {
let id = id.clone();
group = group.push(
widget::TextEditor::new(&editor.content)
.height(160)
.font(self.font)
.size(14.)
.on_action(move |action| Message::NoteAction(id.clone(), action)),
);
}
group = group.push(button::destructive("Remove note").on_press_maybe(
can_change_list.then_some(Message::Remove("desktop_widgets.notes".into(), index)),
));
body = body.push(container(group).padding(12).class(visuals::surface(palette.card, 14.)));
}
body = body.push(
button::standard("Add note").on_press_maybe(
(can_change_list && self.draft.records("desktop_widgets.notes") < 32)
.then_some(Message::AddNote),
),
);
}
Page::Startup => {
body = body.push(self.note("Login items update live. Disabling or removing an item stops the session-owned process; enabling one starts it."));
for index in 0..self.draft.records("autostart") {
let prefix = format!("autostart.{index}");
let group = column([])
.spacing(1)
.push(self.field(Field::new(
format!("{prefix}.command"),
"Program",
"A program and its arguments, not a shell script.",
Kind::Text {
default: "",
argv: true,
},
)))
.push(self.field(Field::new(
format!("{prefix}.enabled"),
"Open at login",
"",
Kind::Toggle(true),
)))
.push(self.field(Field::new(
format!("{prefix}.restart"),
"Restart if it exits",
"Managed by the Ferese session.",
Kind::Toggle(true),
)))
.push(
container(button::destructive("Remove item").on_press_maybe(
(!self.saving).then_some(Message::Remove("autostart".into(), index)),
))
.padding(12),
);
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
}
body = body.push(
row([])
.spacing(10)
.push(
text_input("Program and arguments", self.new_command.clone())
.on_input(Message::NewCommand),
)
.push(button::standard("Add login item").on_press_maybe(
(!self.saving && !self.new_command.trim().is_empty()).then_some(Message::AddCommand),
)),
);
}
Page::Shortcuts => {
body = body.push(self.note("Keyboard and swipe bindings share the same actions. Use Swipe3Up, Swipe3Down, Swipe3Left, or Swipe3Right as a shortcut (also supports 4 or 5 fingers). Actions include toggle-overview, spawn, focus, move, workspace-next, workspace-previous, workspace-back-and-forth, and none. Leave Argument empty for actions such as toggle-overview or none."));
let mut gestures = row([]).spacing(6);
for (keys, label) in [
("Swipe3Up", "Customize swipe up"),
("Swipe3Down", "Customize swipe down"),
("Swipe3Left", "Customize swipe left"),
("Swipe3Right", "Customize swipe right"),
] {
let exists = (0..self.draft.records("bindings")).any(|index| {
self.draft
.string(&format!("bindings.{index}.keys"), "")
.eq_ignore_ascii_case(keys)
});
if !exists {
gestures = gestures.push(
button::standard(label)
.on_press_maybe((!self.saving).then_some(Message::AddSwipe(keys))),
);
}
}
body = body.push(gestures);
for index in 0..self.draft.records("bindings") {
let prefix = format!("bindings.{index}");
let group = column([])
.spacing(1)
.push(self.field(schema::text(
format!("{prefix}.keys"),
"Shortcut",
"For example: Super+Return or Swipe3Up",
"",
)))
.push(self.field(schema::text(
format!("{prefix}.action"),
"Action",
"Action such as toggle-overview, spawn, focus, move, or none.",
"",
)))
.push(self.field(schema::text(
format!("{prefix}.argument"),
"Argument",
"Command name, direction, or action argument.",
"",
)));
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
}
if self.draft.records("bindings") == 0 {
body = body.push(self.note("You are using the built-in shortcuts. Add custom bindings in config.kdl; they will appear here after Reload."));
}
}
Page::Displays => {
body = body.push(self.note("Display profiles update connected outputs live. Keep your config open for adding profiles or changing display positions."));
for profile in 0..self.draft.records("output_profiles") {
let prefix = format!("output_profiles.{profile}");
body =
body.push(self.label(self.draft.string(&format!("{prefix}.name"), "Display profile"), 17.));
for output in 0..self.draft.records(&format!("{prefix}.outputs")) {
let prefix = format!("{prefix}.outputs.{output}");
let group = column([])
.spacing(1)
.push(self.refresh_controls(&prefix))
.push(self.field(schema::text(
format!("{prefix}.match"),
"Display",
"Output name or matching pattern.",
"",
)))
.push(self.field(schema::text(
format!("{prefix}.mode"),
"Resolution",
"For example: 2560x1440@60. Leave unchanged to retain automatic selection.",
"",
)))
.push(self.field(schema::range(
format!("{prefix}.scale"),
"Scale",
"Logical size of text and controls.",
1.,
0.75,
3.,
0.25,
"×",
false,
)));
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
}
}
if self.draft.records("output_profiles") == 0 {
body =
body.push(self.note(
"No saved profiles. Ferese currently configures connected displays automatically.",
));
}
}
_ => {}
}
}
let mut content = column([]).spacing(16).push(
widget::mouse_area(heading.width(Length::Fill))
.on_press(Message::DragWindow)
.interaction(cosmic::iced::mouse::Interaction::Grab),
);
if let Some(error) = &self.error {
content = content.push(
container(self.label(error.clone(), 12.))
.padding(12)
.width(Length::Fill)
.class(visuals::surface(palette.error, 10.)),
);
}
content = content.push(
scrollable(
container(body)
.padding(cosmic::iced::Padding {
top: 0.,
right: 12.,
bottom: 0.,
left: 2.,
})
.width(Length::Fill),
)
.direction(cosmic::iced::widget::scrollable::Direction::Vertical(
cosmic::iced::widget::scrollable::Scrollbar::new()
.width(10)
.scroller_width(3),
))
.height(Length::Fill),
);
let footer = row([])
.spacing(4)
.align_y(Alignment::Center)
.push(widget::tooltip(
visuals::action_icon(
if self.saving {
"M12 3v9l5 3 M21 12a9 9 0 1 1-9-9"
} else {
"M9 12l2 2 4-4 M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0"
},
palette.muted,
),
self.label(if self.saving { "Saving…" } else { &self.status }, 11.),
widget::tooltip::Position::Top,
))
.push(widget::Space::new().width(Length::Fill))
.push(widget::tooltip(
button::custom(visuals::action_icon(
"M3 10h8 M3 10V3 M3 10c3-7 17-6 17 3a7 7 0 0 1-7 7",
if self.undo.is_some() && !self.saving {
palette.text
} else {
palette.muted
},
))
.padding(4)
.on_press_maybe((self.undo.is_some() && !self.saving).then_some(Message::Undo)),
self.label("Undo last change", 11.),
widget::tooltip::Position::Top,
))
.push(widget::tooltip(
button::custom(visuals::action_icon(
"M21 3v6h-6 M3 21v-6h6 M3 9a9 9 0 0 1 15-6l3 6 M21 15a9 9 0 0 1-15 6l-3-6",
palette.text,
))
.padding(4)
.on_press_maybe((!self.saving).then_some(Message::Reload)),
self.label("Reload configuration", 11.),
widget::tooltip::Position::Top,
));
row([])
.push(sidebar)
.push(
container(
container(column([]).push(content.height(Length::Fill)).push(footer).spacing(6))
.padding(cosmic::iced::Padding {
top: 20.,
right: 20.,
bottom: 8.,
left: 20.,
})
.max_width(840)
.width(Length::Fill)
.height(Length::Fill),
)
.width(Length::Fill)
.center_x(Length::Fill)
.height(Length::Fill)
.class(visuals::surface(palette.background, 0.)),
)
.into()
}
}
+237
View File
@@ -0,0 +1,237 @@
use super::{
Alignment, App, Edit, Element, Kind, Length, Message, Page, PathBuf, button, column, container, gallery, row,
schema, set, text_input, visuals, widget,
};
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(3)
.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([6, 8])
.class(visuals::button_style(palette, index == self.theme_file_target))
.on_press(Message::ThemeFileTarget(index)),
);
}
let mut controls = row([])
.spacing(8)
.align_y(Alignment::Center)
.push(
container(scopes)
.padding(3)
.class(visuals::surface(palette.sidebar, 10.)),
)
.push(
ferese_theme::controls::text_button(
if file.is_empty() { "Choose…" } else { "Replace…" },
self.font,
palette,
false,
)
.on_press(Message::PickThemeFile(path)),
);
if !file.is_empty() {
controls = controls.push(widget::tooltip(
button::custom(visuals::action_icon("M6 6l12 12M6 18L18 6", palette.muted))
.padding(5)
.on_press(Message::Change(Edit::Unset(path.into()))),
self.label("Remove override", 11.),
widget::tooltip::Position::Bottom,
));
}
container(
row([])
.spacing(16)
.align_y(Alignment::Center)
.push(labels)
.push(controls),
)
.padding([12, 12])
.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(
ferese_theme::controls::text_button(label, self.font, palette, scheduled == (source == "schedule"))
.name(format!("Auto source: {label}"))
.on_press(Message::Change(set("theme.schedule.source", source))),
);
}
let description = if scheduled {
"Switch between light and dark at set times."
} else {
"Follow the GTK/GNOME appearance preference."
};
let mut options = column([]).spacing(8).push(
row([])
.spacing(12)
.align_y(Alignment::Center)
.push(
column([])
.spacing(3)
.width(Length::Fill)
.push(self.label("Automatic appearance", 13.))
.push(self.note(description)),
)
.push(sources),
);
if scheduled {
let mut times = row([]).spacing(16).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)
.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(8)
.align_y(Alignment::Center)
.push(self.label(field.label, 12.))
.push(input),
);
}
options = options.push(times);
}
container(options)
.padding(12)
.width(Length::Fill)
.class(visuals::surface(palette.card, 0.))
.into()
}
pub(super) fn theme_gallery(
&self,
appearance: Option<ferese_config::theme::Appearance>,
) -> Element<'static, Message> {
use ferese_config::theme::Appearance::{Dark, Light};
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let selected_id = visuals::family_selection(&self.draft, appearance);
let choices = self.resolved.families.clone();
let ids: Vec<_> = choices.iter().map(|family| family.id.clone()).collect();
let mut heading = row([]).align_y(Alignment::Center).spacing(12).push(
self.label(
match appearance {
Some(Light) => "Light theme",
Some(Dark) => "Dark theme",
None => "Theme",
},
15.,
)
.width(Length::Fill),
);
if appearance != Some(Dark) {
if self.undo.is_some() {
heading = heading.push(
ferese_theme::controls::text_button("Undo", self.font, palette, false)
.on_press_maybe((!self.saving).then_some(Message::Undo)),
);
}
heading = heading.push(
ferese_theme::controls::text_button("Import…", self.font, palette, false)
.on_press(Message::ImportTheme),
);
}
let mut gallery = column([]).spacing(10).push(heading);
for (chunk, families) in choices.chunks(3).enumerate() {
let mut tiles = row([]).spacing(12);
for (offset, family) in families.iter().enumerate() {
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 ids = ids.clone();
let tile_id = gallery_id(&id, appearance);
tiles = tiles.push(gallery::tile(
family,
appearance,
active,
selected,
palette,
self.font,
tile_id,
Message::Family(id, appearance),
move |key| {
gallery::neighbor(index, ids.len(), key)
.map(|index| Message::Family(ids[index].clone(), appearance))
},
));
}
for _ in families.len()..3 {
tiles = tiles.push(widget::Space::new().width(Length::Fill));
}
gallery = gallery.push(tiles);
}
gallery::group(
container(gallery).width(Length::Fill).max_width(640),
cosmic::iced::advanced::widget::Id::new(format!("theme-gallery-{appearance:?}")),
match appearance {
Some(Light) => "Light theme",
Some(Dark) => "Dark theme",
None => "Theme",
},
)
}
}
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}"))
}