Add native Settings and complete live configuration reload

Add compact branded Settings with safe atomic edits, external config refresh and a draggable sidebar. Reconcile services, update connected displays and input devices, apply live layout policy and window rules, and resolve initial floating placement before tiled insertion. Package the Settings launcher and shared Ferese icon.
This commit is contained in:
Samuel Onoja committed 2026-09-26 20:31:05 +01:00
1 parent 672b4e2b60
commit 23991d5bd8
27 files changed
+3083 -61

No files matched your search

+1000
View File
@@ -0,0 +1,1000 @@
mod schema;
mod store;
mod visuals;
mod watch;
use cosmic::{
ApplicationExt, Element,
app::{Core, Settings, Task},
iced::{Alignment, Length, Size},
widget::{self, button, column, container, row, scrollable, slider, text_input, toggler},
};
use schema::{Field, Kind, Page};
use std::{collections::HashMap, path::PathBuf};
use store::{Edit, Snapshot, set};
fn main() -> cosmic::iced::Result {
// Set before libcosmic creates worker threads. The software backend is
// efficient for this mostly-static interface; WGPU remains a fallback.
if std::env::var_os("ICED_BACKEND").is_none() {
unsafe {
std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu");
}
}
let mut args = std::env::args_os().skip(1);
let path = match args.next() {
Some(arg) if arg == "--config" => {
PathBuf::from(args.next().expect("--config requires a path"))
}
Some(_) => {
eprintln!("Usage: ferese-settings [--config PATH]");
return Ok(());
}
None => store::config_path(),
};
let path = path.canonicalize().unwrap_or(path);
let initial = Snapshot::read(&path);
let theme = visuals::native_theme(initial.as_ref().ok());
cosmic::app::run::<App>(
Settings::default()
.size(Size::new(960., 720.))
.size_limits(cosmic::iced::Limits::NONE.min_width(820.).min_height(560.))
.theme(theme)
.is_daemon(false)
.antialiasing(true)
.default_text_size(14.),
(path, initial),
)
}
#[derive(Clone, Debug)]
enum Message {
DragWindow,
Page(Page),
Search(String),
Change(Edit),
Draft(String, String),
Commit(Field),
Range(Field, f64),
Release(Field),
Saved(Result<(Snapshot, bool), String>),
Reload,
ExternalConfig(Result<Snapshot, String>),
Undo,
Preset(usize),
PickWallpaper,
WallpaperPicked(Result<Option<String>, String>),
NewCommand(String),
AddCommand,
Remove(String, usize),
Thumbnail(String, Result<widget::image::Handle, String>),
}
struct App {
core: Core,
path: PathBuf,
current: Snapshot,
draft: Snapshot,
pending: Vec<Edit>,
saving: bool,
undo: Option<String>,
saving_previous: String,
error: Option<String>,
status: String,
page: Page,
search: String,
inputs: HashMap<String, String>,
ranges: HashMap<String, f64>,
new_command: String,
font: cosmic::font::Font,
thumbnail: Option<widget::image::Handle>,
thumbnail_path: String,
thumbnail_loading: bool,
thumbnail_error: Option<String>,
}
impl cosmic::Application for App {
type Executor = cosmic::executor::Default;
type Flags = (PathBuf, Result<Snapshot, String>);
type Message = Message;
const APP_ID: &'static str = "dev.ferese.Settings";
fn core(&self) -> &Core {
&self.core
}
fn core_mut(&mut self) -> &mut Core {
&mut self.core
}
fn init(mut core: Core, (path, initial): Self::Flags) -> (Self, Task<Message>) {
core.window.show_headerbar = false;
let status = if path == store::config_path() {
"Changes save automatically"
} else {
"Preview config · changes do not update your desktop"
}
.into();
let error = initial.as_ref().err().cloned();
let current = initial.unwrap_or_else(|_| Snapshot::parse(String::new()).unwrap());
let font = visuals::configured_font(&current);
let mut app = Self {
core,
path,
draft: current.clone(),
current,
pending: vec![],
saving: false,
undo: None,
saving_previous: String::new(),
error,
status,
page: Page::Appearance,
search: String::new(),
inputs: HashMap::new(),
ranges: HashMap::new(),
new_command: String::new(),
font,
thumbnail: None,
thumbnail_path: String::new(),
thumbnail_loading: false,
thumbnail_error: None,
};
let task = app
.core
.main_window_id()
.map(|id| app.set_window_title("Ferese Settings".into(), id))
.unwrap_or_else(Task::none);
(app, task)
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::DragWindow => return self.core.drag(None),
Message::ExternalConfig(result) => match result {
Ok(snapshot)
if snapshot.source == self.current.source
|| snapshot.source == self.draft.source => {}
Ok(snapshot) => {
if self.saving
|| !self.inputs.is_empty()
|| !self.ranges.is_empty()
|| !self.pending.is_empty()
{
self.status =
"Config changed externally · Reload when your edits are finished"
.into();
} else {
self.current = snapshot.clone();
self.draft = snapshot;
self.font = visuals::configured_font(&self.current);
self.undo = None;
self.error = None;
self.status = "Updated from your config".into();
return Task::batch([
cosmic::command::set_theme(visuals::native_theme(Some(&self.draft))),
self.load_thumbnail(),
]);
}
}
Err(error) => self.error = Some(format!("Config reload failed: {error}")),
},
Message::Page(page) => {
self.page = page;
self.search.clear();
if page == Page::Wallpaper {
return self.load_thumbnail();
}
}
Message::Search(query) => self.search = query,
Message::Draft(path, value) => {
self.inputs.insert(path, value);
}
Message::Range(field, value) => {
self.ranges.insert(field.path, value);
}
Message::Release(field) => {
if let Some(value) = self.ranges.remove(&field.path) {
let integer = matches!(field.kind, Kind::Range { integer: true, .. });
return self.change(if integer {
set(&field.path, value.round() as i64)
} else {
set(&field.path, value)
});
}
}
Message::Commit(field) => {
if let Some(value) = self.inputs.remove(&field.path) {
let edit = if matches!(field.kind, Kind::Text { argv: true, .. }) {
match shlex::split(&value).filter(|v| !v.is_empty()) {
Some(args) => {
set(&field.path, args.into_iter().collect::<toml_edit::Array>())
}
None => {
self.error = Some("Enter a program and arguments with balanced quotes. Commands are not run through a shell.".into());
return Task::none();
}
}
} else {
set(&field.path, value)
};
return self.change(edit);
}
}
Message::Change(edit) => return self.change(edit),
Message::Saved(result) => {
self.saving = false;
match result {
Ok((snapshot, live)) => {
self.undo = Some(std::mem::take(&mut self.saving_previous));
self.current = snapshot;
self.font = visuals::configured_font(&self.current);
self.draft = self.current.clone();
for edit in &self.pending {
if let Err(error) = self.draft.edit(edit) {
self.error = Some(error);
}
}
self.status = if live {
"Saved · desktop updated"
} else if self.path != store::config_path() {
"Saved to preview config · desktop unchanged"
} else {
"Saved · could not confirm desktop reload"
}
.into();
let theme = visuals::native_theme(Some(&self.draft));
return Task::batch([
cosmic::command::set_theme(theme),
self.flush(),
self.load_thumbnail(),
]);
}
Err(error) => {
self.error = Some(error);
self.pending.clear();
self.draft = self.current.clone();
self.status = "Not saved".into();
}
}
}
Message::Reload if !self.saving => match Snapshot::read(&self.path) {
Ok(snapshot) => {
self.current = snapshot.clone();
self.draft = snapshot;
self.font = visuals::configured_font(&self.current);
self.pending.clear();
self.inputs.clear();
self.ranges.clear();
self.error = None;
self.undo = None;
self.status = "Reloaded from your config".into();
return Task::batch([
cosmic::command::set_theme(visuals::native_theme(Some(&self.draft))),
self.load_thumbnail(),
]);
}
Err(error) => self.error = Some(error),
},
Message::Undo if !self.saving => {
if let Some(source) = self.undo.take() {
return self.start_save(source);
}
}
Message::Preset(index) => {
for edit in visuals::preset(index) {
if let Err(error) = self.draft.edit(&edit) {
self.error = Some(error);
return Task::none();
}
self.pending.push(edit);
}
return self.flush();
}
Message::PickWallpaper => {
return cosmic::task::future(async {
use cosmic::dialog::file_chooser::{self, FileFilter};
let result = file_chooser::open::Dialog::new()
.title("Choose a wallpaper")
.filter(
FileFilter::new("Images")
.glob("*.png")
.glob("*.jpg")
.glob("*.jpeg")
.glob("*.webp"),
)
.open_file()
.await;
Message::WallpaperPicked(match result {
Ok(response) => response
.url()
.to_file_path()
.map(|p| Some(p.to_string_lossy().into_owned()))
.map_err(|_| "Choose a local image.".into()),
Err(file_chooser::Error::Cancelled) => Ok(None),
Err(error) => Err(format!(
"Image picker unavailable: {error}. You can enter the image path instead."
)),
})
});
}
Message::WallpaperPicked(Ok(Some(path))) => {
return self.change(set("theme.background.path", path));
}
Message::WallpaperPicked(Err(error)) => self.error = Some(error),
Message::Thumbnail(path, result) => {
self.thumbnail_loading = false;
if path == self.current.string("theme.background.path", "") {
self.thumbnail_path = path;
match result {
Ok(handle) => {
self.thumbnail = Some(handle);
self.thumbnail_error = None;
}
Err(error) => {
self.thumbnail = None;
self.thumbnail_error = Some(error);
}
}
}
return self.load_thumbnail();
}
Message::NewCommand(value) => self.new_command = value,
Message::AddCommand if !self.saving => {
match shlex::split(&self.new_command).filter(|a| !a.is_empty()) {
Some(args) => {
self.new_command.clear();
return self.change(Edit::Add(
"autostart".into(),
vec![
(
"command".into(),
args.into_iter().collect::<toml_edit::Array>().into(),
),
("enabled".into(), true.into()),
("restart".into(), true.into()),
],
));
}
None => {
self.error =
Some("Enter a program and arguments with balanced quotes.".into())
}
}
}
Message::Remove(table, index) if !self.saving => {
self.inputs.clear();
return self.change(Edit::Remove(table, index));
}
_ => {}
}
Task::none()
}
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
cosmic::iced::Subscription::run_with(self.path.clone(), watch::changes)
}
fn view(&self) -> Element<'_, Message> {
let palette = visuals::Palette::from(&self.draft);
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_drag(Message::DragWindow),
)
.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 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),
);
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::Appearance | Page::Bar | Page::Windows) {
body = body.push(visuals::preview(&self.draft));
}
if self.page == Page::Appearance {
let mut presets = row([]).spacing(10);
for (index, title) in ["Amberwood", "Monochrome", "Catppuccin"]
.into_iter()
.enumerate()
{
presets = presets.push(
button::custom(
row([])
.spacing(10)
.align_y(Alignment::Center)
.push(visuals::swatches(index))
.push(self.label(title, 12.)),
)
.width(Length::Fill)
.padding(10)
.class(visuals::button_style(
palette,
visuals::preset_selected(&self.draft, index),
))
.on_press(Message::Preset(index)),
);
}
body = body.push(presets);
}
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);
for field in fields {
group = group.push(self.field(field));
}
body = body.push(
container(group)
.padding(8)
.class(visuals::surface(palette.card, 14.)),
);
}
match self.page {
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("Custom shortcuts from your config. Built-in shortcuts remain available unless overridden. Edit a field, then press Enter or move focus to save."));
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",
"",
)))
.push(self.field(schema::text(
format!("{prefix}.action"),
"Action",
"Ferese command, such as spawn or focus.",
"",
)))
.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.toml; 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.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(heading);
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([0, 2]).width(Length::Fill)).height(Length::Fill),
);
let footer = row([])
.spacing(10)
.align_y(Alignment::Center)
.push(
self.label(
if self.saving {
"Saving…"
} else {
&self.status
},
11.,
)
.class(cosmic::theme::Text::Color(palette.muted))
.width(Length::Fill),
)
.push(
button::text("Undo")
.on_press_maybe((self.undo.is_some() && !self.saving).then_some(Message::Undo)),
)
.push(button::text("Reload").on_press_maybe((!self.saving).then_some(Message::Reload)));
row([])
.push(sidebar)
.push(
container(
container(content.push(footer))
.padding(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()
}
}
impl App {
fn load_thumbnail(&mut self) -> Task<Message> {
let path = self.current.string("theme.background.path", "");
if self.page != Page::Wallpaper || self.thumbnail_loading || path == self.thumbnail_path {
return Task::none();
}
if path.is_empty() {
self.thumbnail = None;
self.thumbnail_path.clear();
self.thumbnail_error = None;
return Task::none();
}
self.thumbnail_loading = true;
cosmic::task::future(async move {
let (send, receive) = cosmic::iced::futures::channel::oneshot::channel();
let file = path.clone();
std::thread::spawn(move || {
let result = (|| -> Result<_, String> {
let mut reader = image::ImageReader::open(&file)
.map_err(|e| e.to_string())?
.with_guessed_format()
.map_err(|e| e.to_string())?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(256 * 1024 * 1024);
limits.max_image_width = Some(16384);
limits.max_image_height = Some(16384);
reader.limits(limits);
let pixels = reader
.decode()
.map_err(|e| e.to_string())?
.thumbnail(1000, 500)
.into_rgba8();
Ok(widget::image::Handle::from_rgba(
pixels.width(),
pixels.height(),
pixels.into_raw(),
))
})();
let _ = send.send(result);
});
Message::Thumbnail(
path,
receive
.await
.unwrap_or_else(|_| Err("Could not load wallpaper preview.".into())),
)
})
}
fn label<'a>(
&self,
text: impl Into<std::borrow::Cow<'a, str>> + 'a,
size: f32,
) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
widget::text(text).size(size).font(self.font)
}
fn note(&self, text: &str) -> Element<'static, Message> {
let palette = visuals::Palette::from(&self.draft);
self.label(text.to_owned(), 12.)
.class(cosmic::theme::Text::Color(palette.muted))
.into()
}
fn field(&self, field: Field) -> Element<'static, Message> {
let palette = visuals::Palette::from(&self.draft);
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::Toggle(default) => toggler(self.draft.boolean(&path, default))
.size(22)
.on_toggle(move |value| Message::Change(set(&path, value)))
.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)
.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()
}
fn change(&mut self, edit: Edit) -> Task<Message> {
match self.draft.edit(&edit) {
Ok(()) => {
// When a gradient exists, changing Accent also changes its
// leading stop; otherwise the visible focus border would stay
// on the old palette despite the control saying it changed.
if let Edit::Set(path, value) = &edit {
if path == "theme.colors.accent"
&& self.draft.item("theme.focus_ring.gradient").is_some()
{
let gradient = set("theme.focus_ring.gradient.from", value.clone());
if self.draft.edit(&gradient).is_ok() {
self.pending.push(gradient);
}
}
}
self.pending.push(edit);
self.error = None;
self.flush()
}
Err(error) => {
self.error = Some(error);
Task::none()
}
}
}
fn flush(&mut self) -> Task<Message> {
if self.saving || self.pending.is_empty() {
return Task::none();
}
self.pending.clear();
if self.draft.source == self.current.source {
return Task::none();
}
self.start_save(self.draft.source.clone())
}
fn start_save(&mut self, desired: String) -> Task<Message> {
self.saving = true;
self.saving_previous = self.current.source.clone();
let expected = self.current.source.clone();
let path = self.path.clone();
cosmic::task::future(async move {
// Parsing XKB, disk fsync, and IPC must never block native UI events.
let (send, receive) = cosmic::iced::futures::channel::oneshot::channel();
std::thread::spawn(move || {
let result = store::save(&path, &expected, &desired).map(|snapshot| {
let live = path == store::config_path() && store::reload_running();
(snapshot, live)
});
let _ = send.send(result);
});
Message::Saved(
receive
.await
.unwrap_or_else(|_| Err("Settings worker stopped unexpectedly.".into())),
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::Application;
fn app() -> App {
App::init(
Core::default(),
(
PathBuf::from("/unused/settings-test.toml"),
Snapshot::parse(String::new()),
),
)
.0
}
#[test]
fn rapid_edits_are_serialized_and_not_lost_when_a_save_finishes() {
let mut app = app();
let _ = app.change(set("animations.speed", 0.75));
assert!(app.saving);
let completed = app.draft.clone();
let _ = app.change(set("layout.inner_gap", 6.));
assert_eq!(app.pending.len(), 1);
let _ = app.update(Message::Saved(Ok((completed.clone(), true))));
assert!(app.saving);
assert!(app.pending.is_empty());
assert_eq!(app.saving_previous, completed.source);
assert_eq!(app.draft.number("layout.inner_gap", 0.), 6.);
assert_eq!(app.draft.number("animations.speed", 0.), 0.75);
}
#[test]
fn external_config_refreshes_idle_settings_but_preserves_active_edits() {
let mut app = app();
let external = Snapshot::parse("[animations]\nspeed = 0.5".into()).unwrap();
let _ = app.update(Message::ExternalConfig(Ok(external.clone())));
assert_eq!(app.current.source, external.source);
assert_eq!(app.draft.number("animations.speed", 0.), 0.5);
app.inputs
.insert("theme.colors.accent".into(), "#ffffff".into());
let newer = Snapshot::parse("[animations]\nspeed = 0.8".into()).unwrap();
let _ = app.update(Message::ExternalConfig(Ok(newer)));
assert_eq!(app.current.source, external.source);
assert_eq!(app.inputs["theme.colors.accent"], "#ffffff");
assert!(app.status.contains("externally"));
let _ = app.update(Message::ExternalConfig(Err("invalid TOML".into())));
assert_eq!(app.current.source, external.source);
assert!(app.error.unwrap().contains("invalid TOML"));
}
#[test]
fn settings_has_no_native_headerbar() {
assert!(!app().core.window.show_headerbar);
}
#[test]
fn rejected_save_restores_committed_values_without_claiming_success() {
let mut app = app();
let _ = app.change(set("animations.speed", -1.));
let _ = app.change(set("layout.inner_gap", 6.));
let _ = app.update(Message::Saved(Err("invalid speed".into())));
assert!(!app.saving);
assert!(app.pending.is_empty());
assert_eq!(app.current.source, app.draft.source);
assert_eq!(app.status, "Not saved");
assert_eq!(app.error.as_deref(), Some("invalid speed"));
}
}
+496
View File
@@ -0,0 +1,496 @@
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Page {
Appearance,
Wallpaper,
Bar,
Windows,
Motion,
Keyboard,
Shortcuts,
Startup,
Displays,
}
impl Page {
pub const ALL: [Self; 9] = [
Self::Appearance,
Self::Wallpaper,
Self::Bar,
Self::Windows,
Self::Motion,
Self::Keyboard,
Self::Shortcuts,
Self::Startup,
Self::Displays,
];
pub fn title(self) -> &'static str {
match self {
Self::Appearance => "Appearance",
Self::Wallpaper => "Wallpaper",
Self::Bar => "Menu bar",
Self::Windows => "Windows",
Self::Motion => "Motion",
Self::Keyboard => "Keyboard & mouse",
Self::Shortcuts => "Shortcuts",
Self::Startup => "Login items",
Self::Displays => "Displays",
}
}
pub fn subtitle(self) -> &'static str {
match self {
Self::Appearance => "A desktop that feels like yours.",
Self::Wallpaper => "Set the scene for your workspace.",
Self::Bar => "Everything you need, within reach.",
Self::Windows => "Make room for the way you work.",
Self::Motion => "Find your rhythm.",
Self::Keyboard => "Fine-tune the everyday details.",
Self::Shortcuts => "Your most-used actions, a keystroke away.",
Self::Startup => "Ready when you sign in.",
Self::Displays => "A place for every screen.",
}
}
pub fn icon(self) -> &'static str {
match self {
Self::Appearance => {
"M12 3a9 9 0 1 0 0 18h1a2 2 0 0 0 0-4h-1a1 1 0 0 1 0-2h3a6 6 0 0 0 0-12z M7 9h.01 M10 6h.01 M15 6h.01 M18 10h.01"
}
Self::Wallpaper => "M4 4h16v16H4z M4 16l5-5 4 4 3-3 4 4 M15 8h.01",
Self::Bar => "M3 5h18v14H3z M3 9h18 M6 7h.01 M18 7h.01",
Self::Windows => "M3 4h12v12H3z M8 9h13v12H8z",
Self::Motion => "M3 8h9a3 3 0 1 0-3-3 M3 12h15a3 3 0 1 1-3 3 M3 16h5",
Self::Keyboard => {
"M3 6h18v12H3z M6 9h.01 M10 9h.01 M14 9h.01 M18 9h.01 M6 12h.01 M10 12h.01 M14 12h.01 M18 12h.01 M8 15h8"
}
Self::Shortcuts => {
"M8 8h8v8H8z M8 8H5a3 3 0 1 1 3-3v3z M16 8V5a3 3 0 1 1 3 3h-3z M16 16h3a3 3 0 1 1-3 3v-3z M8 16v3a3 3 0 1 1-3-3h3z"
}
Self::Startup => "M12 3v9 M7 5a9 9 0 1 0 10 0",
Self::Displays => "M3 4h18v13H3z M12 17v4 M8 21h8",
}
}
pub fn matches(self, query: &str) -> bool {
let query = query.to_lowercase();
self.title().to_lowercase().contains(&query)
|| fields(self).iter().any(|f| {
format!("{} {} {}", f.label, f.description, f.path)
.to_lowercase()
.contains(&query)
})
}
}
#[derive(Clone, Debug)]
pub enum Kind {
Toggle(bool),
Range {
default: f64,
min: f64,
max: f64,
step: f64,
suffix: &'static str,
integer: bool,
},
Choice {
default: &'static str,
choices: &'static [(&'static str, &'static str)],
},
Text {
default: &'static str,
argv: bool,
},
}
#[derive(Clone, Debug)]
pub struct Field {
pub path: String,
pub label: String,
pub description: String,
pub kind: Kind,
}
impl Field {
pub fn new(
path: impl Into<String>,
label: impl Into<String>,
description: impl Into<String>,
kind: Kind,
) -> Self {
Self {
path: path.into(),
label: label.into(),
description: description.into(),
kind,
}
}
}
fn toggle(path: &str, label: &str, description: &str, default: bool) -> Field {
Field::new(path, label, description, Kind::Toggle(default))
}
pub fn range(
path: impl Into<String>,
label: impl Into<String>,
description: impl Into<String>,
default: f64,
min: f64,
max: f64,
step: f64,
suffix: &'static str,
integer: bool,
) -> Field {
Field::new(
path,
label,
description,
Kind::Range {
default,
min,
max,
step,
suffix,
integer,
},
)
}
pub fn text(
path: impl Into<String>,
label: impl Into<String>,
description: impl Into<String>,
default: &'static str,
) -> Field {
Field::new(
path,
label,
description,
Kind::Text {
default,
argv: false,
},
)
}
fn choice(
path: &str,
label: &str,
description: &str,
default: &'static str,
choices: &'static [(&'static str, &'static str)],
) -> Field {
Field::new(path, label, description, Kind::Choice { default, choices })
}
pub fn fields(page: Page) -> Vec<Field> {
match page {
Page::Appearance => vec![
text(
"theme.colors.accent",
"Accent color",
"Focus rings and selected controls. Use a hex color.",
"#5B8CFF",
),
text(
"theme.colors.surface_base",
"Surface color",
"The base color for desktop surfaces.",
"#111821",
),
choice(
"theme.material.style",
"Surface style",
"Choose the finish for the bar and menus.",
"solid",
&[("solid", "Solid"), ("translucent", "Translucent")],
),
range(
"theme.material.blur_radius",
"Background blur",
"Softness behind translucent surfaces.",
12.0,
0.0,
32.0,
1.0,
" px",
false,
),
text(
"theme.typography.font_family",
"Interface font",
"Use the name of an installed font family.",
"Inter",
),
range(
"theme.geometry.focus_ring_width",
"Focus border",
"A subtle outline around the active window.",
2.0,
0.0,
4.0,
0.5,
" px",
false,
),
],
Page::Wallpaper => vec![
text(
"theme.background.path",
"Image location",
"Choose an image above, or enter its full path.",
"",
),
choice(
"theme.background.mode",
"Image placement",
"Fill crops to the screen. Fit keeps the whole image.",
"fill",
&[("fill", "Fill"), ("fit", "Fit")],
),
],
Page::Bar => vec![
range(
"theme.geometry.top_bar_height",
"Height",
"Keeps icon and text sizes unchanged.",
28.0,
24.0,
48.0,
1.0,
" px",
false,
),
range(
"theme.geometry.top_bar_margin_top",
"Top margin",
"Set to zero for an edge-to-edge bar.",
0.0,
0.0,
24.0,
1.0,
" px",
true,
),
range(
"theme.geometry.top_bar_margin_horizontal",
"Side margins",
"Space between the bar and the display edges.",
0.0,
0.0,
32.0,
1.0,
" px",
true,
),
range(
"theme.geometry.top_bar_radius",
"Corner radius",
"The shape of the floating bar.",
0.0,
0.0,
24.0,
1.0,
" px",
false,
),
range(
"theme.surface.bar.opacity",
"Opacity",
"Lower values reveal more of the background.",
0.78,
0.2,
1.0,
0.01,
"",
false,
),
range(
"theme.geometry.top_bar_window_gap",
"Window clearance",
"Space below the menu bar.",
0.0,
0.0,
24.0,
1.0,
" px",
true,
),
toggle(
"status.battery_percentage",
"Battery percentage",
"Show the remaining charge beside the battery icon.",
true,
),
],
Page::Windows => vec![
choice(
"layout.mode",
"Layout for new workspaces",
"Existing workspaces keep their current layout.",
"scrolling",
&[("scrolling", "Scrolling"), ("tree", "Tiling")],
),
choice(
"scrolling.focus_strategy",
"Scroll behavior",
"How the viewport follows the focused column.",
"minimal",
&[
("minimal", "Minimal"),
("center_on_focus", "Centered"),
("paged", "Paged"),
],
),
range(
"scrolling.default_column_width",
"New window width",
"Fraction of the available workspace.",
0.5,
0.25,
1.0,
0.05,
"",
false,
),
range(
"layout.inner_gap",
"Between windows",
"Space between neighboring windows.",
10.0,
0.0,
32.0,
1.0,
" px",
false,
),
range(
"layout.outer_gap",
"Workspace edges",
"Space around the outside of the layout.",
4.0,
0.0,
32.0,
1.0,
" px",
false,
),
range(
"theme.geometry.window_radius",
"Corner radius",
"Rounded corners for application windows.",
14.0,
0.0,
28.0,
1.0,
" px",
false,
),
toggle(
"appearance.inactive_dim.enabled",
"Dim inactive windows",
"Keep attention on the window you are using.",
false,
),
range(
"appearance.inactive_dim.amount",
"Dimming amount",
"How much inactive windows recede.",
0.15,
0.0,
0.5,
0.01,
"",
false,
),
],
Page::Motion => vec![
toggle(
"animations.enabled",
"Animations",
"Animate window changes and shell transitions.",
true,
),
range(
"animations.speed",
"Animation speed",
"Lower values give transitions more time.",
1.0,
0.25,
2.0,
0.05,
"×",
false,
),
toggle(
"animations.reduced_motion",
"Reduce motion",
"Use immediate changes in place of animations.",
false,
),
range(
"appearance.inactive_dim.duration_ms",
"Focus transition",
"Time taken to dim an inactive window.",
150.0,
0.0,
600.0,
10.0,
" ms",
false,
),
],
Page::Keyboard => vec![
text(
"input.xkb_layout",
"Keyboard layouts",
"Layout codes, separated by commas. For example: us,ru.",
"us",
),
text(
"input.xkb_variant",
"Layout variant",
"Leave empty to use the standard variant.",
"",
),
range(
"input.repeat_rate",
"Key repeat",
"Characters repeated each second while a key is held.",
25.0,
1.0,
70.0,
1.0,
" /s",
true,
),
range(
"input.repeat_delay_ms",
"Delay before repeat",
"How long to hold a key before it starts repeating.",
600.0,
150.0,
1000.0,
25.0,
" ms",
true,
),
toggle(
"input.focus_follows_mouse",
"Focus follows pointer",
"Focus windows as the pointer moves over them.",
false,
),
toggle(
"input.touchpad.tap",
"Tap to click",
"Tap the touchpad to click. Updates connected devices immediately.",
true,
),
toggle(
"input.touchpad.natural_scroll",
"Natural scrolling",
"Move content in the direction of your fingers. Updates immediately.",
true,
),
toggle(
"input.touchpad.disable_while_typing",
"Ignore touchpad while typing",
"Avoid accidental pointer movement.",
true,
),
],
Page::Shortcuts | Page::Startup | Page::Displays => vec![],
}
}
+405
View File
@@ -0,0 +1,405 @@
use std::{
fs,
io::{Read, Write},
path::{Path, PathBuf},
process::{Command, Stdio},
time::{Duration, Instant},
};
use toml_edit::{DocumentMut, Item, Value};
pub const LIMIT: usize = 60 * 1024;
#[derive(Clone, Debug)]
pub struct Snapshot {
pub source: String,
pub doc: DocumentMut,
}
impl Snapshot {
pub fn parse(source: String) -> Result<Self, String> {
if source.len() > LIMIT {
return Err("Configuration exceeds the 60 KiB limit.".into());
}
let doc = source.parse::<DocumentMut>().map_err(|e| e.to_string())?;
Ok(Self { source, doc })
}
pub fn read(path: &Path) -> Result<Self, String> {
let source = match fs::File::open(path) {
Ok(file) => {
let mut source = String::new();
file.take((LIMIT + 1) as u64)
.read_to_string(&mut source)
.map_err(|e| e.to_string())?;
source
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.to_string()),
};
Self::parse(source)
}
pub fn item(&self, path: &str) -> Option<&Item> {
let mut item = self.doc.as_item();
for key in path.split('.') {
item = if let Ok(index) = key.parse::<usize>() {
item.get(index)?
} else {
item.get(key)?
};
}
Some(item)
}
pub fn string(&self, path: &str, fallback: &str) -> String {
self.item(path)
.and_then(Item::as_str)
.unwrap_or(fallback)
.to_owned()
}
pub fn number(&self, path: &str, fallback: f64) -> f64 {
self.item(path)
.and_then(|v| v.as_float().or_else(|| v.as_integer().map(|v| v as f64)))
.unwrap_or(fallback)
}
pub fn boolean(&self, path: &str, fallback: bool) -> bool {
self.item(path).and_then(Item::as_bool).unwrap_or(fallback)
}
pub fn records(&self, path: &str) -> usize {
self.item(path)
.and_then(Item::as_array_of_tables)
.map_or(0, |a| a.len())
}
pub fn argv(&self, path: &str) -> String {
self.item(path)
.and_then(Item::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(|s| {
shlex::try_quote(s)
.map(|s| s.into_owned())
.unwrap_or_else(|_| s.to_owned())
})
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default()
}
pub fn edit(&mut self, edit: &Edit) -> Result<(), String> {
match edit {
Edit::Set(path, value) => {
let mut item = self.doc.as_item_mut();
for key in path.split('.') {
if let Ok(index) = key.parse::<usize>() {
item = item
.get_mut(index)
.ok_or("This item no longer exists. Reload settings.")?;
} else {
if item.is_none() {
*item = Item::Table(toml_edit::Table::new());
}
let table = item
.as_table_like_mut()
.ok_or("This setting is not a table.")?;
item = table.entry(key).or_insert(Item::None);
}
}
let mut value = value.clone();
if let Some(previous) = item.as_value() {
*value.decor_mut() = previous.decor().clone();
}
*item = Item::Value(value);
}
Edit::Add(table, fields) => {
if self.doc.get(table).is_none() {
self.doc[table] = Item::ArrayOfTables(Default::default());
}
let array = self.doc[table]
.as_array_of_tables_mut()
.ok_or("Expected a list of settings.")?;
let mut record = toml_edit::Table::new();
for (key, value) in fields {
record[key] = Item::Value(value.clone());
}
array.push(record);
}
Edit::Remove(table, index) => {
let array = self.doc[table]
.as_array_of_tables_mut()
.ok_or("This list no longer exists.")?;
if *index >= array.len() {
return Err("This item no longer exists. Reload settings.".into());
}
array.remove(*index);
}
}
self.source = self.doc.to_string();
Ok(())
}
}
#[derive(Clone, Debug)]
pub enum Edit {
Set(String, Value),
Add(String, Vec<(String, Value)>),
Remove(String, usize),
}
pub fn set(path: &str, value: impl Into<Value>) -> Edit {
Edit::Set(path.to_owned(), value.into())
}
pub fn config_path() -> PathBuf {
let directory = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
.unwrap_or_else(|| PathBuf::from("."));
let path = directory.join("ferese/config.toml");
path.canonicalize().unwrap_or(path)
}
pub fn sibling(name: &str) -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|p| p.join(name)))
.filter(|p| p.is_file())
.unwrap_or_else(|| name.into())
}
fn validate(path: &Path) -> Result<(), String> {
let errors = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?;
let mut child = Command::new(sibling("ferese"))
.arg("--check-config")
.arg(path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(errors.reopen().map_err(|e| e.to_string())?)
.spawn()
.map_err(|e| format!("Cannot validate configuration: {e}"))?;
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {
return if status.success() {
Ok(())
} else {
Err(fs::read_to_string(errors.path())
.unwrap_or_else(|_| "Configuration was rejected.".into())
.chars()
.take(700)
.collect())
};
}
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err("Configuration validation timed out.".into());
}
std::thread::sleep(Duration::from_millis(20));
}
}
// Compare-and-replace refuses stale drafts. A second check after validation also
// catches edits made while the validation process was running.
pub fn save(path: &Path, expected: &str, desired: &str) -> Result<Snapshot, String> {
save_with(path, expected, desired, validate)
}
fn save_with(
path: &Path,
expected: &str,
desired: &str,
validate: impl FnOnce(&Path) -> Result<(), String>,
) -> Result<Snapshot, String> {
let snapshot = Snapshot::parse(desired.to_owned())?;
let unchanged = || -> Result<(), String> {
if Snapshot::read(path)?.source != expected {
return Err(
"Your config changed in another app. Reload settings before saving.".into(),
);
}
Ok(())
};
unchanged()?;
let parent = path.parent().ok_or("Invalid configuration path.")?;
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
let mut staged = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
if let Ok(metadata) = fs::metadata(path) {
staged
.as_file()
.set_permissions(metadata.permissions())
.map_err(|e| e.to_string())?;
}
staged
.write_all(desired.as_bytes())
.map_err(|e| e.to_string())?;
staged.as_file().sync_all().map_err(|e| e.to_string())?;
validate(staged.path())?;
unchanged()?;
// Keep the last working source, including comments and unknown keys.
let mut backup = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
backup
.write_all(expected.as_bytes())
.map_err(|e| e.to_string())?;
backup.as_file().sync_all().map_err(|e| e.to_string())?;
backup
.persist(path.with_extension("toml.settings-backup"))
.map_err(|e| e.to_string())?;
staged.persist(path).map_err(|e| e.to_string())?;
Ok(snapshot)
}
pub fn reload_running() -> bool {
use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") else {
return false;
};
let Ok(mut socket) =
std::os::unix::net::UnixStream::connect(PathBuf::from(runtime).join("ferese/control.sock"))
else {
return false;
};
let _ = socket.set_read_timeout(Some(Duration::from_secs(2)));
let _ = socket.set_write_timeout(Some(Duration::from_secs(2)));
let request = Request {
version: VERSION,
id: 1,
kind: "command".into(),
command: "reload-config".into(),
args: serde_json::json!({}),
};
write_frame(&mut socket, &request).is_ok()
&& read_frame::<Response>(&mut socket).is_ok_and(|response| response.error.is_none())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edits_preserve_comments_custom_keys_and_other_rules() {
let source = "# personal theme\n[animations]\nspeed = 0.75 # keep this\n[custom]\nfuture = true\n[[window_rules]]\napp_id = 'mine'\nfloating = true\n";
let mut snapshot = Snapshot::parse(source.into()).unwrap();
snapshot.edit(&set("animations.speed", 0.8)).unwrap();
assert!(snapshot.source.contains("# keep this"));
assert!(snapshot.source.contains("# personal theme"));
assert_eq!(snapshot.string("window_rules.0.app_id", ""), "mine");
assert!(snapshot.boolean("custom.future", false));
}
#[test]
fn rejects_conflicts_and_failed_validation_without_overwriting() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "# external edit\n").unwrap();
assert!(save_with(&path, "", "[animations]\nspeed=1.0", |_| Ok(())).is_err());
assert!(
save_with(
&path,
"# external edit\n",
"[animations]\nspeed=1.0",
|_| Err("invalid".into())
)
.is_err()
);
assert_eq!(fs::read_to_string(&path).unwrap(), "# external edit\n");
}
#[test]
fn catches_edits_during_validation_and_keeps_a_backup_on_success() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "# initial\n").unwrap();
assert!(
save_with(&path, "# initial\n", "# desired\n", |_| {
fs::write(&path, "# another editor\n").unwrap();
Ok(())
})
.is_err()
);
save_with(&path, "# another editor\n", "# desired\n", |_| Ok(())).unwrap();
assert_eq!(
fs::read_to_string(path.with_extension("toml.settings-backup")).unwrap(),
"# another editor\n"
);
assert_eq!(fs::read_to_string(path).unwrap(), "# desired\n");
}
#[test]
fn nested_array_edits_and_login_items_roundtrip() {
let mut snapshot = Snapshot::parse("[[output_profiles]]\nname='desk'\n[[output_profiles.outputs]]\nmatch='HDMI-A-1'\nscale=1.5\n".into()).unwrap();
snapshot
.edit(&set("output_profiles.0.outputs.0.scale", 1.75))
.unwrap();
assert_eq!(
snapshot.number("output_profiles.0.outputs.0.scale", 1.),
1.75
);
snapshot
.edit(&Edit::Add(
"autostart".into(),
vec![
(
"command".into(),
["program", "argument with space"]
.into_iter()
.collect::<toml_edit::Array>()
.into(),
),
("enabled".into(), false.into()),
],
))
.unwrap();
assert_eq!(
shlex::split(&snapshot.argv("autostart.0.command")).unwrap(),
vec!["program", "argument with space"]
);
assert!(!snapshot.boolean("autostart.0.enabled", true));
snapshot.edit(&Edit::Remove("autostart".into(), 0)).unwrap();
assert_eq!(snapshot.records("autostart"), 0);
}
#[test]
#[ignore = "requires a built compositor; run with FERESE_TEST_BINARY=target/debug/ferese"]
fn all_gui_controls_and_presets_pass_compositor_validation() {
let binary = std::env::var_os("FERESE_TEST_BINARY").expect("Set FERESE_TEST_BINARY");
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml");
let validate = |snapshot: &Snapshot| {
fs::write(&path, &snapshot.source).unwrap();
let result = Command::new(&binary)
.arg("--check-config")
.arg(&path)
.output()
.unwrap();
assert!(
result.status.success(),
"{}\n{}",
snapshot.source,
String::from_utf8_lossy(&result.stderr)
);
};
for page in crate::schema::Page::ALL {
for field in crate::schema::fields(page) {
use crate::schema::Kind;
let values: Vec<Value> = match field.kind {
Kind::Toggle(_) => vec![true.into(), false.into()],
Kind::Range {
min, max, integer, ..
} => {
if integer {
vec![(min as i64).into(), (max as i64).into()]
} else {
vec![min.into(), max.into()]
}
}
Kind::Text { default, .. } => vec![default.into()],
Kind::Choice { choices, .. } => {
choices.iter().map(|(v, _)| (*v).into()).collect()
}
};
for value in values {
let mut snapshot = Snapshot::parse(String::new()).unwrap();
snapshot.edit(&set(&field.path, value)).unwrap();
validate(&snapshot);
}
}
}
for preset in 0..3 {
let mut snapshot = Snapshot::parse(String::new()).unwrap();
for edit in crate::visuals::preset(preset) {
snapshot.edit(&edit).unwrap();
}
validate(&snapshot);
}
}
}
+268
View File
@@ -0,0 +1,268 @@
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)
}
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()
}
+101
View File
@@ -0,0 +1,101 @@
//! Watch the parent directory so atomic editor saves keep working.
use crate::{Message, store::Snapshot};
use cosmic::iced::futures::{SinkExt, Stream};
use notify::{RecursiveMode, Watcher};
use std::{path::PathBuf, time::Duration};
pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
let path = path.clone();
cosmic::iced::stream::channel(1, async move |mut output| {
let (send, mut events) = tokio::sync::mpsc::channel(1);
let target = path.clone();
let watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
if let Ok(event) = event
&& !event.kind.is_access()
&& event.paths.iter().any(|p| p == &target)
{
let _ = send.try_send(());
}
});
let mut watcher = match watcher {
Ok(watcher) => watcher,
Err(error) => {
let _ = output
.send(Message::ExternalConfig(Err(error.to_string())))
.await;
return;
}
};
if let Err(error) = watcher.watch(
path.parent().unwrap_or(std::path::Path::new(".")),
RecursiveMode::NonRecursive,
) {
let _ = output
.send(Message::ExternalConfig(Err(error.to_string())))
.await;
return;
}
// Read once after installing the watch to cover the startup race.
loop {
let file = path.clone();
let snapshot = tokio::task::spawn_blocking(move || Snapshot::read(&file))
.await
.unwrap_or_else(|e| Err(e.to_string()));
if output
.send(Message::ExternalConfig(snapshot))
.await
.is_err()
{
return;
}
if events.recv().await.is_none() {
return;
}
while tokio::time::timeout(Duration::from_millis(150), events.recv())
.await
.is_ok()
{}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic::iced::futures::{StreamExt, pin_mut};
#[test]
fn watches_atomic_replacement_and_retains_subscription_after_bad_toml() {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml");
std::fs::write(&path, "[animations]\nspeed = 1").unwrap();
let stream = changes(&path);
pin_mut!(stream);
assert!(matches!(
stream.next().await,
Some(Message::ExternalConfig(Ok(_)))
));
for source in ["bad [", "[animations]\nspeed = 0.75"] {
let temporary = directory.path().join("replacement");
std::fs::write(&temporary, source).unwrap();
std::fs::rename(temporary, &path).unwrap();
let message = tokio::time::timeout(Duration::from_secs(3), stream.next())
.await
.unwrap()
.unwrap();
match message {
Message::ExternalConfig(Ok(snapshot)) => {
assert_eq!(snapshot.source, source)
}
Message::ExternalConfig(Err(_)) => assert_eq!(source, "bad ["),
_ => panic!("unexpected watcher message"),
}
}
});
}
}