Fix compositor output routing and pointer hints; format imports
This commit is contained in:
1 parent
88e4f3c135
commit
46509f880a
108 files changed
+3682
-8371
No files matched your search
@@ -1,4 +1,5 @@
|
||||
use std::{process::Command, sync::OnceLock};
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub fn families() -> &'static [String] {
|
||||
static FAMILIES: OnceLock<Vec<String>> = OnceLock::new();
|
||||
|
||||
+123
-192
@@ -4,22 +4,20 @@ mod store;
|
||||
mod visuals;
|
||||
mod watch;
|
||||
|
||||
use cosmic::{
|
||||
ApplicationExt, Element,
|
||||
app::{Core, Settings, Task},
|
||||
iced::{Alignment, Length},
|
||||
widget::{self, button, column, container, row, scrollable, slider, text_input, toggler},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cosmic::app::{Core, Settings, Task};
|
||||
use cosmic::iced::{Alignment, Length};
|
||||
use cosmic::widget::{button, column, container, row, scrollable, slider, text_input, toggler};
|
||||
use cosmic::{ApplicationExt, Element, widget};
|
||||
use schema::{Field, Kind, Page};
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
use store::{Edit, Snapshot, set};
|
||||
|
||||
fn main() -> cosmic::iced::Result {
|
||||
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(arg) if arg == "--config" => PathBuf::from(args.next().expect("--config requires a path")),
|
||||
Some(_) => {
|
||||
eprintln!("Usage: ferese-settings [--config PATH]");
|
||||
return Ok(());
|
||||
@@ -195,9 +193,7 @@ impl cosmic::Application for App {
|
||||
return self.change(set(&format!("desktop_widgets.notes.{index}.text"), value));
|
||||
}
|
||||
}
|
||||
Message::AddNote
|
||||
if !self.saving && self.draft.records("desktop_widgets.notes") < 32 =>
|
||||
{
|
||||
Message::AddNote if !self.saving && self.draft.records("desktop_widgets.notes") < 32 => {
|
||||
let id = format!(
|
||||
"note-{}",
|
||||
std::time::SystemTime::now()
|
||||
@@ -223,9 +219,7 @@ impl cosmic::Application for App {
|
||||
}
|
||||
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 snapshot.source == self.current.source || snapshot.source == self.draft.source => {}
|
||||
Ok(snapshot) => {
|
||||
if self.saving
|
||||
|| self.note_editors.values().any(|editor| editor.dirty)
|
||||
@@ -233,9 +227,7 @@ impl cosmic::Application for App {
|
||||
|| !self.ranges.is_empty()
|
||||
|| !self.pending.is_empty()
|
||||
{
|
||||
self.status =
|
||||
"Config changed externally · Reload when your edits are finished"
|
||||
.into();
|
||||
self.status = "Config changed externally · Reload when your edits are finished".into();
|
||||
} else {
|
||||
self.current = snapshot.clone();
|
||||
self.draft = snapshot;
|
||||
@@ -307,9 +299,7 @@ impl cosmic::Application for App {
|
||||
match shlex::split(&value).filter(|v| !v.is_empty()) {
|
||||
Some(args) => set(
|
||||
&field.path,
|
||||
args.into_iter()
|
||||
.map(serde_json::Value::from)
|
||||
.collect::<Vec<_>>(),
|
||||
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());
|
||||
@@ -348,11 +338,7 @@ impl cosmic::Application for App {
|
||||
}
|
||||
.into();
|
||||
|
||||
return Task::batch([
|
||||
self.update_theme(),
|
||||
self.flush(),
|
||||
self.load_thumbnail(),
|
||||
]);
|
||||
return Task::batch([self.update_theme(), self.flush(), self.load_thumbnail()]);
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
@@ -410,13 +396,20 @@ impl cosmic::Application for App {
|
||||
"--file-filter=All files | *",
|
||||
])
|
||||
.output()
|
||||
.map_err(|error| format!("Could not open the file chooser: {error}. Install zenity or enter an image path."))
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"Could not open the file chooser: {error}. Install zenity or enter an image path."
|
||||
)
|
||||
})
|
||||
.and_then(|output| {
|
||||
if output.status.code() == Some(1) {
|
||||
return Ok(None); // User cancelled.
|
||||
}
|
||||
if !output.status.success() {
|
||||
return Err(format!("File chooser failed: {}", String::from_utf8_lossy(&output.stderr).trim()));
|
||||
return Err(format!(
|
||||
"File chooser failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
let path = String::from_utf8(output.stdout)
|
||||
.map_err(|_| "The image path is not valid UTF-8.".to_owned())?;
|
||||
@@ -458,31 +451,23 @@ impl cosmic::Application for App {
|
||||
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()
|
||||
.map(serde_json::Value::from)
|
||||
.collect::<Vec<_>>()
|
||||
.into(),
|
||||
),
|
||||
("enabled".into(), true.into()),
|
||||
("restart".into(), true.into()),
|
||||
],
|
||||
));
|
||||
}
|
||||
None => {
|
||||
self.error =
|
||||
Some("Enter a program and arguments with balanced quotes.".into())
|
||||
}
|
||||
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().map(serde_json::Value::from).collect::<Vec<_>>().into(),
|
||||
),
|
||||
("enabled".into(), true.into()),
|
||||
("restart".into(), true.into()),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
None => self.error = Some("Enter a program and arguments with balanced quotes.".into()),
|
||||
},
|
||||
Message::AddSwipe(keys) if !self.saving => {
|
||||
let (action, argument) = match keys {
|
||||
"Swipe3Up" => ("workspace-next", None),
|
||||
@@ -490,10 +475,7 @@ impl cosmic::Application for App {
|
||||
"Swipe3Left" => ("focus", Some("right")),
|
||||
_ => ("focus", Some("left")),
|
||||
};
|
||||
let mut fields = vec![
|
||||
("keys".into(), keys.into()),
|
||||
("action".into(), action.into()),
|
||||
];
|
||||
let mut fields = vec![("keys".into(), keys.into()), ("action".into(), action.into())];
|
||||
|
||||
if let Some(argument) = argument {
|
||||
fields.push(("argument".into(), argument.into()));
|
||||
@@ -596,8 +578,7 @@ impl cosmic::Application for App {
|
||||
|
||||
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."));
|
||||
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(
|
||||
@@ -682,8 +663,7 @@ impl cosmic::Application for App {
|
||||
.unwrap_or("Choose an image to preview your wallpaper.")
|
||||
}));
|
||||
}
|
||||
body =
|
||||
body.push(button::standard("Choose image…").on_press(Message::PickWallpaper));
|
||||
body = body.push(button::standard("Choose image…").on_press(Message::PickWallpaper));
|
||||
}
|
||||
let fields = schema::fields(self.page);
|
||||
|
||||
@@ -692,30 +672,21 @@ impl cosmic::Application for App {
|
||||
for field in fields {
|
||||
group = group.push(self.field(field));
|
||||
}
|
||||
body = body.push(
|
||||
container(group)
|
||||
.padding(8)
|
||||
.class(visuals::surface(palette.card, 14.)),
|
||||
);
|
||||
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(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);
|
||||
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 id = self.draft.string(&format!("desktop_widgets.notes.{index}.id"), "note");
|
||||
let mut group = column([]).spacing(8);
|
||||
|
||||
for field in schema::note_fields(index) {
|
||||
@@ -729,23 +700,13 @@ impl cosmic::Application for App {
|
||||
.height(160)
|
||||
.font(self.font)
|
||||
.size(14.)
|
||||
.on_action(move |action| {
|
||||
Message::NoteAction(id.clone(), action)
|
||||
}),
|
||||
.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.)),
|
||||
);
|
||||
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(
|
||||
@@ -783,19 +744,12 @@ impl cosmic::Application for App {
|
||||
Kind::Toggle(true),
|
||||
)))
|
||||
.push(
|
||||
container(
|
||||
button::destructive("Remove item").on_press_maybe(
|
||||
(!self.saving)
|
||||
.then_some(Message::Remove("autostart".into(), index)),
|
||||
),
|
||||
)
|
||||
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(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
|
||||
}
|
||||
body = body.push(
|
||||
row([])
|
||||
@@ -804,12 +758,9 @@ impl cosmic::Application for App {
|
||||
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),
|
||||
),
|
||||
),
|
||||
.push(button::standard("Add login item").on_press_maybe(
|
||||
(!self.saving && !self.new_command.trim().is_empty()).then_some(Message::AddCommand),
|
||||
)),
|
||||
);
|
||||
}
|
||||
Page::Shortcuts => {
|
||||
@@ -828,10 +779,10 @@ impl cosmic::Application for App {
|
||||
});
|
||||
|
||||
if !exists {
|
||||
gestures =
|
||||
gestures.push(button::standard(label).on_press_maybe(
|
||||
(!self.saving).then_some(Message::AddSwipe(keys)),
|
||||
));
|
||||
gestures = gestures.push(
|
||||
button::standard(label)
|
||||
.on_press_maybe((!self.saving).then_some(Message::AddSwipe(keys))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -859,11 +810,7 @@ impl cosmic::Application for App {
|
||||
"Command name, direction, or action argument.",
|
||||
"",
|
||||
)));
|
||||
body = body.push(
|
||||
container(group)
|
||||
.padding(8)
|
||||
.class(visuals::surface(palette.card, 14.)),
|
||||
);
|
||||
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."));
|
||||
@@ -873,29 +820,44 @@ impl cosmic::Application for App {
|
||||
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.,
|
||||
),
|
||||
);
|
||||
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.)),
|
||||
);
|
||||
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."));
|
||||
body =
|
||||
body.push(self.note(
|
||||
"No saved profiles. Ferese currently configures connected displays automatically.",
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -947,14 +909,7 @@ impl cosmic::Application for App {
|
||||
},
|
||||
palette.muted,
|
||||
),
|
||||
self.label(
|
||||
if self.saving {
|
||||
"Saving…"
|
||||
} else {
|
||||
&self.status
|
||||
},
|
||||
11.,
|
||||
),
|
||||
self.label(if self.saving { "Saving…" } else { &self.status }, 11.),
|
||||
widget::tooltip::Position::Top,
|
||||
))
|
||||
.push(widget::Space::new().width(Length::Fill))
|
||||
@@ -986,21 +941,16 @@ impl cosmic::Application for App {
|
||||
.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),
|
||||
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)
|
||||
@@ -1022,11 +972,8 @@ impl App {
|
||||
}
|
||||
|
||||
fn note_index(&self, id: &str) -> Option<usize> {
|
||||
(0..self.draft.records("desktop_widgets.notes")).find(|index| {
|
||||
self.draft
|
||||
.string(&format!("desktop_widgets.notes.{index}.id"), "note")
|
||||
== id
|
||||
})
|
||||
(0..self.draft.records("desktop_widgets.notes"))
|
||||
.find(|index| self.draft.string(&format!("desktop_widgets.notes.{index}.id"), "note") == id)
|
||||
}
|
||||
|
||||
fn sync_notes(&mut self) {
|
||||
@@ -1036,14 +983,11 @@ impl App {
|
||||
let prefix = format!("desktop_widgets.notes.{index}");
|
||||
let id = self.draft.string(&format!("{prefix}.id"), "note");
|
||||
let text = self.draft.string(&format!("{prefix}.text"), "");
|
||||
let editor = self
|
||||
.note_editors
|
||||
.entry(id.clone())
|
||||
.or_insert_with(|| NoteEditor {
|
||||
content: widget::text_editor::Content::with_text(&text),
|
||||
revision: 0,
|
||||
dirty: false,
|
||||
});
|
||||
let editor = self.note_editors.entry(id.clone()).or_insert_with(|| NoteEditor {
|
||||
content: widget::text_editor::Content::with_text(&text),
|
||||
revision: 0,
|
||||
dirty: false,
|
||||
});
|
||||
if !editor.dirty && editor.content.text() != text {
|
||||
editor.content = widget::text_editor::Content::with_text(&text);
|
||||
}
|
||||
@@ -1121,9 +1065,7 @@ impl App {
|
||||
|
||||
fn field(&self, field: Field) -> Element<'static, Message> {
|
||||
let palette = visuals::Palette::from_document(Some(&self.draft.doc));
|
||||
let mut labels = column([])
|
||||
.spacing(3)
|
||||
.push(self.label(field.label.clone(), 13.));
|
||||
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.)
|
||||
@@ -1205,12 +1147,10 @@ impl App {
|
||||
.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),
|
||||
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()
|
||||
@@ -1254,10 +1194,7 @@ impl App {
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(
|
||||
container(widget::Space::new().width(24).height(24))
|
||||
.class(visuals::surface(swatch, 6.)),
|
||||
)
|
||||
.push(container(widget::Space::new().width(24).height(24)).class(visuals::surface(swatch, 6.)))
|
||||
.push(input)
|
||||
.into()
|
||||
} else {
|
||||
@@ -1342,8 +1279,9 @@ impl App {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cosmic::Application;
|
||||
|
||||
use super::*;
|
||||
fn app() -> App {
|
||||
App::init(
|
||||
Core::default(),
|
||||
@@ -1361,10 +1299,7 @@ mod tests {
|
||||
app.draft
|
||||
.edit(&Edit::Add(
|
||||
"desktop_widgets.notes".into(),
|
||||
vec![
|
||||
("id".into(), "test".into()),
|
||||
("text".into(), "first\nsecond".into()),
|
||||
],
|
||||
vec![("id".into(), "test".into()), ("text".into(), "first\nsecond".into())],
|
||||
))
|
||||
.unwrap();
|
||||
app.sync_notes();
|
||||
@@ -1377,10 +1312,7 @@ mod tests {
|
||||
let _ = app.update(Message::SaveNote("test".into(), 2));
|
||||
assert!(app.saving);
|
||||
assert!(!app.note_editors["test"].dirty);
|
||||
assert_eq!(
|
||||
app.draft.string("desktop_widgets.notes.0.text", ""),
|
||||
"first\nsecond"
|
||||
);
|
||||
assert_eq!(app.draft.string("desktop_widgets.notes.0.text", ""), "first\nsecond");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1424,8 +1356,7 @@ mod tests {
|
||||
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());
|
||||
app.inputs.insert("theme.colors.accent".into(), "#ffffff".into());
|
||||
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);
|
||||
|
||||
@@ -129,12 +129,7 @@ pub struct Field {
|
||||
}
|
||||
|
||||
impl Field {
|
||||
pub fn new(
|
||||
path: impl Into<String>,
|
||||
label: impl Into<String>,
|
||||
description: impl Into<String>,
|
||||
kind: Kind,
|
||||
) -> Self {
|
||||
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(),
|
||||
@@ -180,15 +175,7 @@ pub fn text(
|
||||
description: impl Into<String>,
|
||||
default: &'static str,
|
||||
) -> Field {
|
||||
Field::new(
|
||||
path,
|
||||
label,
|
||||
description,
|
||||
Kind::Text {
|
||||
default,
|
||||
argv: false,
|
||||
},
|
||||
)
|
||||
Field::new(path, label, description, Kind::Text { default, argv: false })
|
||||
}
|
||||
|
||||
fn choice(
|
||||
@@ -400,18 +387,8 @@ pub fn note_fields(index: usize) -> Vec<Field> {
|
||||
"Drag the title and edit directly. Off makes the card click-through.",
|
||||
Kind::Toggle(true),
|
||||
),
|
||||
Field::new(
|
||||
format!("{prefix}.enabled"),
|
||||
"Show note",
|
||||
"",
|
||||
Kind::Toggle(true),
|
||||
),
|
||||
text(
|
||||
format!("{prefix}.title"),
|
||||
"Title",
|
||||
"Empty hides the title.",
|
||||
"Note",
|
||||
),
|
||||
Field::new(format!("{prefix}.enabled"), "Show note", "", Kind::Toggle(true)),
|
||||
text(format!("{prefix}.title"), "Title", "Empty hides the title.", "Note"),
|
||||
text(
|
||||
format!("{prefix}.anchor"),
|
||||
"Position",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ferese_config::Document;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs,
|
||||
io::{Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub const LIMIT: usize = 60 * 1024;
|
||||
|
||||
@@ -45,10 +44,7 @@ impl Snapshot {
|
||||
}
|
||||
|
||||
pub fn string(&self, path: &str, fallback: &str) -> String {
|
||||
self.item(path)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(fallback)
|
||||
.to_owned()
|
||||
self.item(path).and_then(Value::as_str).unwrap_or(fallback).to_owned()
|
||||
}
|
||||
|
||||
pub fn number(&self, path: &str, fallback: f64) -> f64 {
|
||||
@@ -60,9 +56,7 @@ impl Snapshot {
|
||||
}
|
||||
|
||||
pub fn records(&self, path: &str) -> usize {
|
||||
self.item(path)
|
||||
.and_then(Value::as_array)
|
||||
.map_or(0, |a| a.len())
|
||||
self.item(path).and_then(Value::as_array).map_or(0, |a| a.len())
|
||||
}
|
||||
|
||||
pub fn argv(&self, path: &str) -> String {
|
||||
@@ -172,9 +166,7 @@ fn save_with(
|
||||
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(),
|
||||
);
|
||||
return Err("Your config changed in another app. Reload settings before saving.".into());
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
@@ -188,17 +180,13 @@ fn save_with(
|
||||
.set_permissions(metadata.permissions())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
staged
|
||||
.write_all(desired.as_bytes())
|
||||
.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.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("kdl.settings-backup"))
|
||||
@@ -212,8 +200,7 @@ pub fn reload_running() -> bool {
|
||||
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"))
|
||||
let Ok(mut socket) = std::os::unix::net::UnixStream::connect(PathBuf::from(runtime).join("ferese/control.sock"))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
@@ -293,17 +280,11 @@ window-rule app-id="mine" floating=#true
|
||||
|
||||
#[test]
|
||||
fn nested_array_edits_and_login_items_roundtrip() {
|
||||
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();
|
||||
assert_eq!(
|
||||
snapshot.number("output_profiles.0.outputs.0.scale", 1.),
|
||||
1.75
|
||||
);
|
||||
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();
|
||||
assert_eq!(snapshot.number("output_profiles.0.outputs.0.scale", 1.), 1.75);
|
||||
snapshot
|
||||
.edit(&Edit::Add(
|
||||
"autostart".into(),
|
||||
@@ -331,28 +312,18 @@ window-rule app-id="mine" floating=#true
|
||||
|
||||
#[test]
|
||||
fn nested_note_lists_keep_existing_clock_settings() {
|
||||
let mut snapshot = Snapshot::parse(
|
||||
"desktop-widgets {\n clock {\n enabled #true\n }\n}\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(),
|
||||
vec![
|
||||
("id".into(), "first".into()),
|
||||
("text".into(), "one\ntwo".into()),
|
||||
],
|
||||
vec![("id".into(), "first".into()), ("text".into(), "one\ntwo".into())],
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(snapshot.records("desktop_widgets.notes"), 1);
|
||||
assert!(snapshot.boolean("desktop_widgets.clock.enabled", false));
|
||||
assert_eq!(
|
||||
snapshot.string("desktop_widgets.notes.0.text", ""),
|
||||
"one\ntwo"
|
||||
);
|
||||
snapshot
|
||||
.edit(&Edit::Remove("desktop_widgets.notes".into(), 0))
|
||||
.unwrap();
|
||||
assert_eq!(snapshot.string("desktop_widgets.notes.0.text", ""), "one\ntwo");
|
||||
snapshot.edit(&Edit::Remove("desktop_widgets.notes".into(), 0)).unwrap();
|
||||
assert_eq!(snapshot.records("desktop_widgets.notes"), 0);
|
||||
}
|
||||
|
||||
@@ -364,11 +335,7 @@ window-rule app-id="mine" floating=#true
|
||||
let path = directory.path().join("config.kdl");
|
||||
let validate = |snapshot: &Snapshot| {
|
||||
fs::write(&path, &snapshot.source).unwrap();
|
||||
let result = Command::new(&binary)
|
||||
.arg("--check-config")
|
||||
.arg(&path)
|
||||
.output()
|
||||
.unwrap();
|
||||
let result = Command::new(&binary).arg("--check-config").arg(&path).output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}\n{}",
|
||||
@@ -386,9 +353,7 @@ window-rule app-id="mine" floating=#true
|
||||
let values: Vec<Value> = match field.kind {
|
||||
Kind::Font => vec!["".into(), "sans-serif".into()],
|
||||
Kind::Toggle(_) => vec![true.into(), false.into()],
|
||||
Kind::Range {
|
||||
min, max, integer, ..
|
||||
} => {
|
||||
Kind::Range { min, max, integer, .. } => {
|
||||
if integer {
|
||||
vec![(min as i64).into(), (max as i64).into()]
|
||||
} else {
|
||||
@@ -396,9 +361,7 @@ window-rule app-id="mine" floating=#true
|
||||
}
|
||||
}
|
||||
Kind::Text { default, .. } => vec![default.into()],
|
||||
Kind::Choice { choices, .. } => {
|
||||
choices.iter().map(|(v, _)| (*v).into()).collect()
|
||||
}
|
||||
Kind::Choice { choices, .. } => choices.iter().map(|(v, _)| (*v).into()).collect(),
|
||||
};
|
||||
for value in values {
|
||||
let mut snapshot = Snapshot::parse(String::new()).unwrap();
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
use crate::{
|
||||
Message,
|
||||
schema::Page,
|
||||
store::{Edit, Snapshot, set},
|
||||
};
|
||||
use cosmic::{
|
||||
Element,
|
||||
iced::{Color, Length},
|
||||
widget::icon as svg_icon,
|
||||
};
|
||||
|
||||
pub use ferese_theme::controls::{
|
||||
button_style, navigation_style, settings_input as input_style, surface,
|
||||
};
|
||||
use cosmic::Element;
|
||||
use cosmic::iced::{Color, Length};
|
||||
use cosmic::widget::icon as svg_icon;
|
||||
pub use ferese_theme::controls::{button_style, navigation_style, settings_input as input_style, surface};
|
||||
pub use ferese_theme::{Palette, mix, surface_shade};
|
||||
|
||||
use crate::Message;
|
||||
use crate::schema::Page;
|
||||
use crate::store::{Edit, Snapshot, set};
|
||||
|
||||
pub fn color(value: &str, fallback: Color) -> Color {
|
||||
ferese_theme::parse_color(value).unwrap_or(fallback)
|
||||
}
|
||||
@@ -32,9 +26,7 @@ pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme {
|
||||
}
|
||||
|
||||
pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font {
|
||||
ferese_theme::font(Some(
|
||||
&snapshot.string("theme.typography.font_family", "Inter"),
|
||||
))
|
||||
ferese_theme::font(Some(&snapshot.string("theme.typography.font_family", "Inter")))
|
||||
}
|
||||
|
||||
pub fn icon(page: Page, tint: Color) -> svg_icon::Icon {
|
||||
@@ -91,20 +83,14 @@ pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool {
|
||||
("theme.surface.bar.text_primary", preset.text, "#F0F3FA"),
|
||||
("theme.surface.bar.text_muted", preset.muted, "#AAB4C7"),
|
||||
("theme.focus_ring.gradient.from", preset.accent, "#3D7BE6"),
|
||||
(
|
||||
"theme.focus_ring.gradient.to",
|
||||
preset.gradient_end,
|
||||
"#3D7BE6",
|
||||
),
|
||||
("theme.focus_ring.gradient.to", preset.gradient_end, "#3D7BE6"),
|
||||
]
|
||||
.into_iter()
|
||||
.all(|(path, expected, fallback)| {
|
||||
if index == 0 && snapshot.item(path).is_none() {
|
||||
return true;
|
||||
}
|
||||
snapshot
|
||||
.string(path, fallback)
|
||||
.eq_ignore_ascii_case(expected)
|
||||
snapshot.string(path, fallback).eq_ignore_ascii_case(expected)
|
||||
}) && snapshot.number("theme.focus_ring.gradient.angle", 0.) == 0.
|
||||
}
|
||||
|
||||
@@ -164,19 +150,14 @@ pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> {
|
||||
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 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.shell_radius", 14.) * 0.65;
|
||||
let opacity = if snapshot.string("theme.material.style", "solid") == "translucent" {
|
||||
snapshot
|
||||
.number(
|
||||
"theme.material.opacity",
|
||||
ferese_config::DEFAULT_MATERIAL_OPACITY,
|
||||
)
|
||||
.number("theme.material.opacity", ferese_config::DEFAULT_MATERIAL_OPACITY)
|
||||
.clamp(0., 1.)
|
||||
} else {
|
||||
1.
|
||||
@@ -235,16 +216,8 @@ mod tests {
|
||||
let snapshot = configured_preset(index);
|
||||
let palette = Palette::from_document(Some(&snapshot.doc));
|
||||
for background in [palette.background, palette.sidebar, palette.card] {
|
||||
assert!(
|
||||
contrast(palette.text, background) >= 4.5,
|
||||
"{} text",
|
||||
preset.name
|
||||
);
|
||||
assert!(
|
||||
contrast(palette.muted, background) >= 4.5,
|
||||
"{} muted text",
|
||||
preset.name
|
||||
);
|
||||
assert!(contrast(palette.text, background) >= 4.5, "{} text", preset.name);
|
||||
assert!(contrast(palette.muted, background) >= 4.5, "{} muted text", preset.name);
|
||||
}
|
||||
for other in PRESETS.iter().skip(index + 1) {
|
||||
assert_ne!(preset.accent, other.accent);
|
||||
@@ -263,17 +236,12 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(!preset_selected(&snapshot, index));
|
||||
let mut snapshot = configured_preset(index);
|
||||
snapshot
|
||||
.edit(&set("theme.focus_ring.gradient.to", "#123456"))
|
||||
.unwrap();
|
||||
snapshot.edit(&set("theme.focus_ring.gradient.to", "#123456")).unwrap();
|
||||
assert!(!preset_selected(&snapshot, index));
|
||||
}
|
||||
assert!(preset_selected(&Snapshot::parse(String::new()).unwrap(), 0));
|
||||
assert!(preset(usize::MAX).is_empty());
|
||||
assert!(!preset_selected(
|
||||
&Snapshot::parse(String::new()).unwrap(),
|
||||
usize::MAX
|
||||
));
|
||||
assert!(!preset_selected(&Snapshot::parse(String::new()).unwrap(), usize::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -283,10 +251,7 @@ mod tests {
|
||||
|
||||
for (index, item) in PRESETS.iter().enumerate() {
|
||||
let snapshot = configured_preset(index);
|
||||
assert_eq!(
|
||||
native_theme(Some(&snapshot)).cosmic().is_dark,
|
||||
item.name != "Ayu Light"
|
||||
);
|
||||
assert_eq!(native_theme(Some(&snapshot)).cosmic().is_dark, item.name != "Ayu Light");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
//! Watch the parent directory so atomic editor saves keep working.
|
||||
use crate::{Message, store::Snapshot};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use cosmic::iced::futures::{SinkExt, Stream};
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
|
||||
use crate::Message;
|
||||
use crate::store::Snapshot;
|
||||
|
||||
pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
|
||||
let path = path.clone();
|
||||
@@ -21,9 +25,7 @@ pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
|
||||
let mut watcher = match watcher {
|
||||
Ok(watcher) => watcher,
|
||||
Err(error) => {
|
||||
let _ = output
|
||||
.send(Message::ExternalConfig(Err(error.to_string())))
|
||||
.await;
|
||||
let _ = output.send(Message::ExternalConfig(Err(error.to_string()))).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -32,9 +34,7 @@ pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
|
||||
path.parent().unwrap_or(std::path::Path::new(".")),
|
||||
RecursiveMode::NonRecursive,
|
||||
) {
|
||||
let _ = output
|
||||
.send(Message::ExternalConfig(Err(error.to_string())))
|
||||
.await;
|
||||
let _ = output.send(Message::ExternalConfig(Err(error.to_string()))).await;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -44,11 +44,7 @@ pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
|
||||
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()
|
||||
{
|
||||
if output.send(Message::ExternalConfig(snapshot)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
if events.recv().await.is_none() {
|
||||
@@ -64,9 +60,10 @@ pub fn changes(path: &PathBuf) -> impl Stream<Item = Message> + use<> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cosmic::iced::futures::{StreamExt, pin_mut};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn watches_atomic_replacement_and_retains_subscription_after_bad_kdl() {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
@@ -79,10 +76,7 @@ mod tests {
|
||||
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(_)))
|
||||
));
|
||||
assert!(matches!(stream.next().await, Some(Message::ExternalConfig(Ok(_)))));
|
||||
for source in ["bad [", "animations {\n speed 0.75\n}\n"] {
|
||||
let temporary = directory.path().join("replacement");
|
||||
std::fs::write(&temporary, source).unwrap();
|
||||
|
||||
Reference in new issue
Block a user