Replace user configuration with KDL and improve Rust source spacing

This commit is contained in:
Samuel Onoja committed 2026-09-27 05:46:51 +01:00
1 parent dbdfc5688e
commit 9e90de4e74
41 files changed
+1668 -559

No files matched your search

+1 -1
View File
@@ -6,10 +6,10 @@ license.workspace = true
rust-version = "1.93"
[dependencies]
ferese-config = { path = "../../crates/ferese-config" }
libcosmic = { workspace = true, features = ["wgpu", "a11y", "xdg-portal"] }
ferese-ipc = { path = "../../crates/ferese-ipc" }
serde_json.workspace = true
toml_edit = "0.25.15"
tempfile.workspace = true
shlex = "1.3.0"
notify = "8.2.0"
+68 -11
View File
@@ -21,6 +21,7 @@ fn main() -> cosmic::iced::Result {
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" => {
@@ -35,6 +36,7 @@ fn main() -> cosmic::iced::Result {
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()
.theme(theme)
@@ -107,14 +109,18 @@ impl cosmic::Application for App {
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 {
@@ -152,15 +158,18 @@ impl cosmic::Application for App {
.main_window_id()
.map(|id| app.set_window_title("Ferese Settings".into(), id))
.unwrap_or_else(Task::none);
app.sync_notes();
(app, task)
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::NoteAction(id, action) => {
if let Some(editor) = self.note_editors.get_mut(&id) {
let edited = action.is_edit();
editor.content.perform(action);
if edited {
editor.dirty = true;
editor.revision = editor.revision.wrapping_add(1);
@@ -176,6 +185,7 @@ impl cosmic::Application for App {
let Some(index) = self.note_index(&id) else {
return Task::none();
};
if let Some(editor) = self.note_editors.get_mut(&id)
&& editor.dirty
&& editor.revision == revision
@@ -203,6 +213,7 @@ impl cosmic::Application for App {
("text".into(), "".into()),
],
));
self.sync_notes();
return task;
}
@@ -229,6 +240,7 @@ impl cosmic::Application for App {
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(),
@@ -266,9 +278,12 @@ impl cosmic::Application for App {
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>())
}
Some(args) => set(
&field.path,
args.into_iter()
.map(serde_json::Value::from)
.collect::<Vec<_>>(),
),
None => {
self.error = Some("Enter a program and arguments with balanced quotes. Commands are not run through a shell.".into());
return Task::none();
@@ -289,11 +304,13 @@ impl cosmic::Application for App {
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.sync_notes();
self.status = if live {
"Saved · desktop updated"
@@ -303,7 +320,9 @@ impl cosmic::Application for App {
"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(),
@@ -331,6 +350,7 @@ impl cosmic::Application for App {
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(),
@@ -349,6 +369,7 @@ impl cosmic::Application for App {
self.error = Some(error);
return Task::none();
}
self.pending.push(edit);
}
return self.flush();
@@ -420,7 +441,10 @@ impl cosmic::Application for App {
vec![
(
"command".into(),
args.into_iter().collect::<toml_edit::Array>().into(),
args.into_iter()
.map(serde_json::Value::from)
.collect::<Vec<_>>()
.into(),
),
("enabled".into(), true.into()),
("restart".into(), true.into()),
@@ -444,9 +468,11 @@ impl cosmic::Application for App {
("keys".into(), keys.into()),
("action".into(), action.into()),
];
if let Some(argument) = argument {
fields.push(("argument".into(), argument.into()));
}
return self.change(Edit::Add("bindings".into(), fields));
}
Message::Remove(table, index) if !self.saving => {
@@ -459,9 +485,11 @@ impl cosmic::Application for App {
}
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([])
@@ -538,6 +566,7 @@ impl cosmic::Application for App {
.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
@@ -586,6 +615,7 @@ impl cosmic::Application for App {
}
body = body.push(presets);
}
if self.page == Page::Wallpaper {
if let Some(handle) = &self.thumbnail {
body = body.push(
@@ -607,6 +637,7 @@ impl cosmic::Application for App {
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 {
@@ -618,6 +649,7 @@ impl cosmic::Application for App {
.class(visuals::surface(palette.card, 14.)),
);
}
match self.page {
Page::Desktop => {
let can_change_list =
@@ -629,9 +661,11 @@ impl cosmic::Application for App {
.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(
@@ -666,6 +700,7 @@ impl cosmic::Application for App {
}
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([])
@@ -735,6 +770,7 @@ impl cosmic::Application for App {
.string(&format!("bindings.{index}.keys"), "")
.eq_ignore_ascii_case(keys)
});
if !exists {
gestures =
gestures.push(button::standard(label).on_press_maybe(
@@ -742,7 +778,9 @@ impl cosmic::Application for App {
));
}
}
body = body.push(gestures);
for index in 0..self.draft.records("bindings") {
let prefix = format!("bindings.{index}");
let group = column([])
@@ -772,7 +810,7 @@ impl cosmic::Application for App {
);
}
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."));
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 => {
@@ -786,6 +824,7 @@ impl cosmic::Application for App {
17.,
),
);
for output in 0..self.draft.records(&format!("{prefix}.outputs")) {
let prefix = format!("{prefix}.outputs.{output}");
let group = column([]).spacing(1)
@@ -806,7 +845,9 @@ impl cosmic::Application for App {
_ => {}
}
}
let mut content = column([]).spacing(16).push(heading);
if let Some(error) = &self.error {
content = content.push(
container(self.label(error.clone(), 12.))
@@ -815,6 +856,7 @@ impl cosmic::Application for App {
.class(visuals::surface(palette.error, 10.)),
);
}
content = content.push(
scrollable(container(body).padding([0, 2]).width(Length::Fill)).height(Length::Fill),
);
@@ -902,8 +944,10 @@ impl App {
== id
})
}
fn sync_notes(&mut self) {
let mut ids = Vec::new();
for index in 0..self.draft.records("desktop_widgets.notes") {
let prefix = format!("desktop_widgets.notes.{index}");
let id = self.draft.string(&format!("{prefix}.id"), "note");
@@ -921,8 +965,10 @@ impl App {
}
ids.push(id);
}
self.note_editors.retain(|id, _| ids.contains(id));
}
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 {
@@ -934,6 +980,7 @@ impl App {
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();
@@ -970,6 +1017,7 @@ impl App {
)
})
}
fn label<'a>(
&self,
text: impl Into<std::borrow::Cow<'a, str>> + 'a,
@@ -977,12 +1025,14 @@ impl App {
) -> 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([])
@@ -1096,6 +1146,7 @@ impl App {
.class(visuals::surface(palette.card, 0.))
.into()
}
fn change(&mut self, edit: Edit) -> Task<Message> {
match self.draft.edit(&edit) {
Ok(()) => {
@@ -1122,6 +1173,7 @@ impl App {
}
}
}
fn flush(&mut self) -> Task<Message> {
if self.saving || self.pending.is_empty() {
return Task::none();
@@ -1132,6 +1184,7 @@ impl App {
}
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();
@@ -1164,12 +1217,13 @@ mod tests {
App::init(
Core::default(),
(
PathBuf::from("/unused/settings-test.toml"),
PathBuf::from("/unused/settings-test.kdl"),
Snapshot::parse(String::new()),
),
)
.0
}
#[test]
fn notes_save_only_the_latest_revision_and_preserve_multiline_text() {
let mut app = app();
@@ -1210,11 +1264,12 @@ mod tests {
app.sync_notes();
app.note_editors.get_mut("test").unwrap().dirty = true;
let _ = app.update(Message::ExternalConfig(Snapshot::parse(
"[animations]\nspeed = 0.5".into(),
"animations {\n speed 0.5\n}\n".into(),
)));
assert!(app.note_editors.contains_key("test"));
assert!(app.status.contains("externally"));
}
#[test]
fn rapid_edits_are_serialized_and_not_lost_when_a_save_finishes() {
let mut app = app();
@@ -1230,24 +1285,26 @@ mod tests {
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 external = Snapshot::parse("animations {\n speed 0.5\n}\n".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 newer = Snapshot::parse("animations {\n speed 0.8\n}\n".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())));
let _ = app.update(Message::ExternalConfig(Err("invalid KDL".into())));
assert_eq!(app.current.source, external.source);
assert!(app.error.unwrap().contains("invalid TOML"));
assert!(app.error.unwrap().contains("invalid KDL"));
}
#[test]
fn settings_has_no_native_headerbar() {
assert!(!app().core.window.show_headerbar);
+11
View File
@@ -11,6 +11,7 @@ pub enum Page {
Startup,
Displays,
}
impl Page {
pub const ALL: [Self; 10] = [
Self::Appearance,
@@ -24,6 +25,7 @@ impl Page {
Self::Startup,
Self::Displays,
];
pub fn title(self) -> &'static str {
match self {
Self::Appearance => "Appearance",
@@ -38,6 +40,7 @@ impl Page {
Self::Displays => "Displays",
}
}
pub fn subtitle(self) -> &'static str {
match self {
Self::Appearance => "A desktop that feels like yours.",
@@ -52,6 +55,7 @@ impl Page {
Self::Displays => "A place for every screen.",
}
}
pub fn icon(self) -> &'static str {
match self {
Self::Appearance => {
@@ -72,6 +76,7 @@ impl Page {
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)
@@ -103,6 +108,7 @@ pub enum Kind {
argv: bool,
},
}
#[derive(Clone, Debug)]
pub struct Field {
pub path: String,
@@ -110,6 +116,7 @@ pub struct Field {
pub description: String,
pub kind: Kind,
}
impl Field {
pub fn new(
path: impl Into<String>,
@@ -125,9 +132,11 @@ impl Field {
}
}
}
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>,
@@ -153,6 +162,7 @@ pub fn range(
},
)
}
pub fn text(
path: impl Into<String>,
label: impl Into<String>,
@@ -169,6 +179,7 @@ pub fn text(
},
)
}
fn choice(
path: &str,
label: &str,
+72 -103
View File
@@ -1,3 +1,5 @@
use ferese_config::Document;
use serde_json::Value;
use std::{
fs,
io::{Read, Write},
@@ -5,23 +7,24 @@ use std::{
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,
pub doc: Document,
}
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())?;
let doc = Document::parse(&source).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) => {
@@ -36,39 +39,35 @@ impl Snapshot {
};
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 item(&self, path: &str) -> Option<&Value> {
self.doc.get(path)
}
pub fn string(&self, path: &str, fallback: &str) -> String {
self.item(path)
.and_then(Item::as_str)
.and_then(Value::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)
self.item(path).and_then(|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)
self.item(path).and_then(Value::as_bool).unwrap_or(fallback)
}
pub fn records(&self, path: &str) -> usize {
self.item(path)
.and_then(Item::as_array_of_tables)
.and_then(Value::as_array)
.map_or(0, |a| a.len())
}
pub fn argv(&self, path: &str) -> String {
self.item(path)
.and_then(Item::as_array)
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
@@ -82,79 +81,26 @@ impl Snapshot {
})
.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) => {
let item = nested_item_mut(self.doc.as_item_mut(), table)?;
if item.is_none() {
*item = Item::ArrayOfTables(Default::default());
}
let array = item
.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 = nested_item_mut(self.doc.as_item_mut(), 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);
}
Edit::Set(path, value) => self.doc.set(path, value.clone()),
Edit::Add(path, fields) => self.doc.add(path, fields.clone()),
Edit::Remove(path, index) => self.doc.remove(path, *index),
}
.map_err(|e| e.to_string())?;
self.source = self.doc.to_string();
Ok(())
}
}
fn nested_item_mut<'a>(mut item: &'a mut Item, path: &str) -> Result<&'a mut Item, String> {
for key in path.split('.') {
if item.is_none() {
*item = Item::Table(toml_edit::Table::new());
}
let table = item
.as_table_like_mut()
.ok_or("Expected a settings table.")?;
item = table.entry(key).or_insert(Item::None);
}
Ok(item)
}
#[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())
}
@@ -164,7 +110,7 @@ pub fn config_path() -> PathBuf {
.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");
let path = directory.join("ferese/config.kdl");
path.canonicalize().unwrap_or(path)
}
@@ -187,6 +133,7 @@ fn validate(path: &Path) -> Result<(), 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() {
@@ -199,11 +146,13 @@ fn validate(path: &Path) -> Result<(), String> {
.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));
}
}
@@ -213,6 +162,7 @@ fn validate(path: &Path) -> Result<(), String> {
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,
@@ -251,7 +201,7 @@ fn save_with(
.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"))
.persist(path.with_extension("kdl.settings-backup"))
.map_err(|e| e.to_string())?;
staged.persist(path).map_err(|e| e.to_string())?;
Ok(snapshot)
@@ -285,53 +235,68 @@ 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 source = r#"// personal theme
// keep this
animations {
speed 0.75
}
custom {
future #true
}
window-rule app-id="mine" floating=#true
"#;
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!(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());
let path = dir.path().join("config.kdl");
fs::write(&path, "// external edit\n").unwrap();
assert!(save_with(&path, "", "animations {\n speed 1.0\n}\n", |_| Ok(())).is_err());
assert!(
save_with(
&path,
"# external edit\n",
"[animations]\nspeed=1.0",
"// external edit\n",
"animations {\n speed 1.0\n}\n",
|_| Err("invalid".into())
)
.is_err()
);
assert_eq!(fs::read_to_string(&path).unwrap(), "# external edit\n");
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();
let path = dir.path().join("config.kdl");
fs::write(&path, "// initial\n").unwrap();
assert!(
save_with(&path, "# initial\n", "# desired\n", |_| {
fs::write(&path, "# another editor\n").unwrap();
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();
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"
fs::read_to_string(path.with_extension("kdl.settings-backup")).unwrap(),
"// another editor\n"
);
assert_eq!(fs::read_to_string(path).unwrap(), "# desired\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();
let mut snapshot = Snapshot::parse(
"output-profile name=\"desk\" {\n output match=\"HDMI-A-1\" scale=1.5\n}\n".into(),
)
.unwrap();
snapshot
.edit(&set("output_profiles.0.outputs.0.scale", 1.75))
.unwrap();
@@ -347,7 +312,8 @@ mod tests {
"command".into(),
["program", "argument with space"]
.into_iter()
.collect::<toml_edit::Array>()
.map(Value::from)
.collect::<Vec<_>>()
.into(),
),
("enabled".into(), false.into()),
@@ -362,10 +328,13 @@ mod tests {
snapshot.edit(&Edit::Remove("autostart".into(), 0)).unwrap();
assert_eq!(snapshot.records("autostart"), 0);
}
#[test]
fn nested_note_lists_keep_existing_clock_settings() {
let mut snapshot =
Snapshot::parse("[desktop_widgets.clock]\nenabled = true\n".into()).unwrap();
let mut snapshot = Snapshot::parse(
"desktop-widgets {\n clock {\n enabled #true\n }\n}\n".into(),
)
.unwrap();
snapshot
.edit(&Edit::Add(
"desktop_widgets.notes".into(),
@@ -392,7 +361,7 @@ mod tests {
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 path = directory.path().join("config.kdl");
let validate = |snapshot: &Snapshot| {
fs::write(&path, &snapshot.source).unwrap();
let result = Command::new(&binary)
+19
View File
@@ -20,17 +20,21 @@ pub struct Palette {
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(
@@ -57,6 +61,7 @@ impl Palette {
}
}
}
fn mix(a: Color, b: Color, t: f32) -> Color {
Color::from_rgb(
a.r + (b.r - a.r) * t,
@@ -64,6 +69,7 @@ fn mix(a: Color, b: Color, t: f32) -> Color {
a.b + (b.b - a.b) * t,
)
}
fn hex(c: Color) -> String {
format!(
"#{:02x}{:02x}{:02x}",
@@ -72,6 +78,7 @@ fn hex(c: Color) -> String {
(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)),
@@ -82,12 +89,15 @@ pub fn surface(background: Color, radius: f32) -> theme::Container<'static> {
..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(),
@@ -110,6 +120,7 @@ pub fn input_style(p: Palette) -> theme::TextInput {
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 {
@@ -131,6 +142,7 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button
overlay: None,
shadow_offset: Vector::ZERO,
};
theme::Button::Custom {
active: Box::new(move |_, _| style(false)),
hovered: Box::new(move |_, _| style(true)),
@@ -138,6 +150,7 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button
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 {
@@ -149,6 +162,7 @@ pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme {
}
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>>,
@@ -165,15 +179,18 @@ pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font {
fonts.insert(family, font);
font
}
pub fn icon(page: Page, tint: Color) -> svg_icon::Icon {
svg_icon::from_svg_bytes(format!(r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="{}" fill="none" stroke="{}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>"##, page.icon(),hex(tint)).into_bytes()).symbolic(false).icon().size(19)
}
pub fn brand_icon() -> svg_icon::Icon {
svg_icon::from_svg_bytes(include_bytes!("../../../packaging/icons/ferese.svg").as_slice())
.symbolic(false)
.icon()
.size(32)
}
pub fn action_icon(path: &str, tint: Color) -> svg_icon::Icon {
svg_icon::from_svg_bytes(format!(r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="{path}" fill="none" stroke="{}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>"##, hex(tint)).into_bytes())
.symbolic(false)
@@ -199,6 +216,7 @@ pub fn preset(index: usize) -> Vec<Edit> {
set("theme.focus_ring.gradient.to", end),
]
}
pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool {
let (accent, base, text, muted, _) = PRESETS[index];
[
@@ -214,6 +232,7 @@ pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool {
.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);
+4 -4
View File
@@ -65,22 +65,22 @@ mod tests {
use cosmic::iced::futures::{StreamExt, pin_mut};
#[test]
fn watches_atomic_replacement_and_retains_subscription_after_bad_toml() {
fn watches_atomic_replacement_and_retains_subscription_after_bad_kdl() {
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 path = directory.path().join("config.kdl");
std::fs::write(&path, "animations {\n speed 1\n}\n").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"] {
for source in ["bad [", "animations {\n speed 0.75\n}\n"] {
let temporary = directory.path().join("replacement");
std::fs::write(&temporary, source).unwrap();
std::fs::rename(temporary, &path).unwrap();