Replace user configuration with KDL and improve Rust source spacing
This commit is contained in:
1 parent
dbdfc5688e
commit
9e90de4e74
41 files changed
+1668
-559
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
rust-version = "1.93"
|
||||
|
||||
[dependencies]
|
||||
ferese-config = { path = "../../crates/ferese-config" }
|
||||
ferese-core = { path = "../../crates/ferese-core" }
|
||||
ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] }
|
||||
jiff.workspace = true
|
||||
@@ -14,8 +15,6 @@ jiff.workspace = true
|
||||
libcosmic = { workspace = true, features = ["wgpu", "a11y"] }
|
||||
iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" }
|
||||
serde.workspace = true
|
||||
toml.workspace = true
|
||||
toml_edit = "0.25.15"
|
||||
tempfile.workspace = true
|
||||
tokio = { version = "1", features = ["time", "rt"] }
|
||||
wayland-client.workspace = true
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -174,11 +173,13 @@ impl Default for BarConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PopoverConfig {
|
||||
#[serde(default = "default_popover_opacity")]
|
||||
opacity: f32,
|
||||
}
|
||||
|
||||
impl Default for PopoverConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -186,6 +187,7 @@ impl Default for PopoverConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_popover_opacity() -> f32 {
|
||||
0.96
|
||||
}
|
||||
@@ -299,13 +301,13 @@ pub(crate) fn load() -> ShellConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, toml::de::Error> {
|
||||
match toml::from_str::<FereseConfig>(source) {
|
||||
pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, ferese_config::Error> {
|
||||
match ferese_config::from_str::<FereseConfig>(source) {
|
||||
Ok(config) => {
|
||||
config
|
||||
.desktop_widgets
|
||||
.validate()
|
||||
.map_err(<toml::de::Error as serde::de::Error>::custom)?;
|
||||
.map_err(ferese_config::Error::from)?;
|
||||
let mut theme = shell_theme(&config.theme);
|
||||
theme.material_radius =
|
||||
nonnegative_or(config.appearance.corner_radius.unwrap_or(14.0), 14.0);
|
||||
@@ -415,48 +417,63 @@ fn finite_or(value: f32, fallback: f32) -> f32 {
|
||||
fn default_surface_base() -> String {
|
||||
"#111821".to_owned()
|
||||
}
|
||||
|
||||
fn default_text_primary() -> String {
|
||||
"#F4F7FB".to_owned()
|
||||
}
|
||||
|
||||
fn default_text_muted() -> String {
|
||||
"#7F8A98".to_owned()
|
||||
}
|
||||
|
||||
fn default_accent() -> String {
|
||||
"#5B8CFF".to_owned()
|
||||
}
|
||||
|
||||
fn default_border() -> String {
|
||||
"#FFFFFF18".to_owned()
|
||||
}
|
||||
|
||||
fn default_shadow() -> String {
|
||||
"#00000055".to_owned()
|
||||
}
|
||||
|
||||
const fn default_bar_height() -> f32 {
|
||||
28.0
|
||||
}
|
||||
|
||||
const fn default_bar_window_gap() -> i32 {
|
||||
0
|
||||
}
|
||||
|
||||
const fn default_bar_margin_top() -> i32 {
|
||||
0
|
||||
}
|
||||
|
||||
const fn default_bar_margin_horizontal() -> i32 {
|
||||
0
|
||||
}
|
||||
|
||||
const fn default_bar_radius() -> f32 {
|
||||
0.0
|
||||
}
|
||||
|
||||
const fn default_panel_padding() -> f32 {
|
||||
12.0
|
||||
}
|
||||
|
||||
const fn default_control_gap() -> f32 {
|
||||
12.0
|
||||
}
|
||||
|
||||
const fn default_shadow_offset_y() -> f32 {
|
||||
4.0
|
||||
}
|
||||
|
||||
const fn default_shadow_blur() -> f32 {
|
||||
18.0
|
||||
}
|
||||
|
||||
const fn default_shadow_opacity() -> f32 {
|
||||
0.20
|
||||
}
|
||||
@@ -466,42 +483,59 @@ pub(crate) const fn default_background() -> [u8; 3] {
|
||||
}
|
||||
|
||||
pub(crate) fn config_path() -> Option<PathBuf> {
|
||||
if let Some(directory) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
|
||||
return Some(PathBuf::from(directory).join("ferese/config.toml"));
|
||||
}
|
||||
|
||||
env::var_os("HOME")
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.map(|home| home.join(".config/ferese/config.toml"))
|
||||
ferese_config::config_path()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn desktop_clock_parses_and_rejects_invalid_reload_values() {
|
||||
let clock = super::parse_source("[desktop_widgets.clock]\nenabled = true\nanchor = 'bottom_right'\nfont_family = ''\ncolor = ''\ntime_zone = ''").unwrap().desktop_widgets.clock;
|
||||
let clock = super::parse_source(
|
||||
r#"desktop-widgets {
|
||||
clock {
|
||||
enabled #true
|
||||
anchor "bottom_right"
|
||||
font-family ""
|
||||
color ""
|
||||
time-zone ""
|
||||
}
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap()
|
||||
.desktop_widgets
|
||||
.clock;
|
||||
assert!(clock.enabled);
|
||||
assert_eq!(clock.anchor, ferese_core::desktop::Anchor::BottomRight);
|
||||
assert!(super::parse_source("[desktop_widgets.clock]\nopacity = 1.1").is_err());
|
||||
assert!(super::parse_source("[desktop_widgets.clock]\ntime_format = '%'").is_err());
|
||||
assert!(
|
||||
super::parse_source("desktop-widgets {\n clock {\n opacity 1.1\n }\n}\n")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
super::parse_source(
|
||||
"desktop-widgets {\n clock {\n time-format \"%\"\n }\n}\n"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_theme_without_rejecting_compositor_sections() {
|
||||
let config: FereseConfig = toml::from_str(
|
||||
r#"
|
||||
[layout]
|
||||
mode = "scrolling"
|
||||
|
||||
[theme.typography]
|
||||
font_family = "JetBrainsMono Nerd Font"
|
||||
|
||||
[theme.background]
|
||||
path = "/tmp/wallpaper.png"
|
||||
mode = "fit"
|
||||
"#,
|
||||
let config: FereseConfig = ferese_config::from_str(
|
||||
r#"layout {
|
||||
mode "scrolling"
|
||||
}
|
||||
theme {
|
||||
typography {
|
||||
font-family "JetBrainsMono Nerd Font"
|
||||
}
|
||||
background {
|
||||
path "/tmp/wallpaper.png"
|
||||
mode "fit"
|
||||
}
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -518,7 +552,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn defaults_to_the_ferese_visual_profile() {
|
||||
let config: FereseConfig = toml::from_str("").unwrap();
|
||||
let config: FereseConfig = ferese_config::from_str("").unwrap();
|
||||
|
||||
assert_eq!(config.theme.typography.font_family, None);
|
||||
assert_eq!(config.theme.background.path, None);
|
||||
@@ -535,13 +569,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bar_palette_can_be_changed_without_changing_popovers() {
|
||||
let config: FereseConfig = toml::from_str(
|
||||
r##"
|
||||
[theme.surface.bar]
|
||||
background = "#EAECEEDD"
|
||||
text_primary = "#222222"
|
||||
text_muted = "#666666"
|
||||
"##,
|
||||
let config: FereseConfig = ferese_config::from_str(
|
||||
r##"theme {
|
||||
surface {
|
||||
bar {
|
||||
background "#EAECEEDD"
|
||||
text-primary "#222222"
|
||||
text-muted "#666666"
|
||||
}
|
||||
}
|
||||
}
|
||||
"##,
|
||||
)
|
||||
.unwrap();
|
||||
let theme = shell_theme(&config.theme);
|
||||
@@ -553,13 +591,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bar_transparency_and_shadow_opacity_are_independent() {
|
||||
let config: FereseConfig = toml::from_str(
|
||||
r#"
|
||||
[theme.surface.bar]
|
||||
opacity = 0.6
|
||||
[theme.shadow.soft]
|
||||
opacity = 0.07
|
||||
"#,
|
||||
let config: FereseConfig = ferese_config::from_str(
|
||||
r#"theme {
|
||||
surface {
|
||||
bar {
|
||||
opacity 0.6
|
||||
}
|
||||
}
|
||||
shadow {
|
||||
soft {
|
||||
opacity 0.07
|
||||
}
|
||||
}
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let theme = shell_theme(&config.theme);
|
||||
|
||||
@@ -689,6 +689,7 @@ impl FereseShell {
|
||||
}
|
||||
Task::batch(tasks)
|
||||
}
|
||||
|
||||
fn output_event(
|
||||
&mut self,
|
||||
event: wayland::OutputEvent,
|
||||
@@ -2264,6 +2265,7 @@ impl EffectsBinding {
|
||||
self.connection.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_opacity(&self, opacity: f32) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let opacity = (opacity.clamp(0.0, 1.0) * 1000.0).round() as u32;
|
||||
if self.surface.version() >= 3 && self.opacity.get() != Some(opacity) {
|
||||
@@ -2349,6 +2351,7 @@ mod tests {
|
||||
Point::new(200., 100.)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_drag_origin_and_bounds_use_logical_output_coordinates() {
|
||||
use cosmic::iced::Point;
|
||||
@@ -2371,6 +2374,7 @@ mod tests {
|
||||
Point::ORIGIN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_clock_anchor_margins_match_only_anchored_edges() {
|
||||
use ferese_core::desktop::{Anchor as Position, Clock};
|
||||
@@ -2396,6 +2400,7 @@ mod tests {
|
||||
(0, 0, 0, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_uses_lowercase_date_and_twelve_hour_time() {
|
||||
for (stamp, expected) in [
|
||||
|
||||
@@ -13,6 +13,7 @@ pub(crate) struct Settings {
|
||||
reduced_motion: bool,
|
||||
speed: f64,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -22,10 +23,12 @@ impl Default for Settings {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
fn hover_duration(self) -> std::time::Duration {
|
||||
self.duration(120.0)
|
||||
}
|
||||
|
||||
pub(crate) fn duration(self, milliseconds: f64) -> std::time::Duration {
|
||||
if !self.enabled || self.reduced_motion {
|
||||
return std::time::Duration::ZERO;
|
||||
@@ -52,6 +55,7 @@ pub(crate) struct PopupMotion {
|
||||
started: Option<std::time::Instant>,
|
||||
settings: Settings,
|
||||
}
|
||||
|
||||
impl PopupMotion {
|
||||
pub(crate) fn update_settings(&mut self, settings: Settings) {
|
||||
let now = std::time::Instant::now();
|
||||
@@ -59,6 +63,7 @@ impl PopupMotion {
|
||||
self.settings = settings;
|
||||
self.started = Some(now);
|
||||
}
|
||||
|
||||
pub(crate) fn new(settings: Settings) -> Self {
|
||||
Self {
|
||||
start: 0.0,
|
||||
@@ -67,11 +72,13 @@ impl PopupMotion {
|
||||
settings,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn begin(&mut self, now: std::time::Instant) {
|
||||
if self.started.is_none() {
|
||||
self.started = Some(now);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn progress_at(&self, now: std::time::Instant) -> f32 {
|
||||
let duration = self
|
||||
.settings
|
||||
@@ -89,15 +96,19 @@ impl PopupMotion {
|
||||
}
|
||||
self.start + (self.target - self.start) * t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
pub(crate) fn progress(&self) -> f32 {
|
||||
self.progress_at(std::time::Instant::now())
|
||||
}
|
||||
|
||||
pub(crate) fn closing(&self) -> bool {
|
||||
self.target == 0.0
|
||||
}
|
||||
|
||||
pub(crate) fn animating(&self) -> bool {
|
||||
self.progress() != self.target
|
||||
}
|
||||
|
||||
pub(crate) fn retarget(&mut self, target: f32, now: std::time::Instant) {
|
||||
self.start = self.progress_at(now);
|
||||
self.target = target;
|
||||
@@ -157,6 +168,7 @@ struct HoverState {
|
||||
target: f32,
|
||||
started: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
impl HoverState {
|
||||
fn advance(
|
||||
&mut self,
|
||||
@@ -185,11 +197,13 @@ impl HoverState {
|
||||
self.current != target
|
||||
}
|
||||
}
|
||||
|
||||
struct Hover<'a, M> {
|
||||
content: Element<'a, M>,
|
||||
progress: std::rc::Rc<std::cell::Cell<f32>>,
|
||||
duration: std::time::Duration,
|
||||
}
|
||||
|
||||
impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
fn a11y_nodes(
|
||||
&self,
|
||||
@@ -201,21 +215,27 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
.as_widget()
|
||||
.a11y_nodes(layout, &tree.children[0], cursor)
|
||||
}
|
||||
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
widget::tree::Tag::of::<HoverState>()
|
||||
}
|
||||
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(HoverState::default())
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
vec![widget::Tree::new(&self.content)]
|
||||
}
|
||||
|
||||
fn diff(&mut self, tree: &mut widget::Tree) {
|
||||
tree.diff_children(std::slice::from_mut(&mut self.content));
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
@@ -226,6 +246,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
@@ -255,6 +276,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
@@ -277,6 +299,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
@@ -293,6 +316,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
@@ -304,6 +328,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Hover<'_, M> {
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
|
||||
fn overlay<'a>(
|
||||
&'a mut self,
|
||||
tree: &'a mut widget::Tree,
|
||||
@@ -333,37 +358,46 @@ pub fn animated<'a, M: 'a>(
|
||||
regions,
|
||||
})
|
||||
}
|
||||
|
||||
struct Motion<'a, M> {
|
||||
content: Element<'a, M>,
|
||||
progress: f32,
|
||||
regions: Regions,
|
||||
}
|
||||
|
||||
impl<M> Motion<'_, M> {
|
||||
fn translation(&self) -> Vector {
|
||||
Vector::new(0.0, -4.0 * (1.0 - self.progress))
|
||||
}
|
||||
|
||||
fn cursor(&self, cursor: mouse::Cursor) -> mouse::Cursor {
|
||||
cursor.position().map_or(mouse::Cursor::Unavailable, |p| {
|
||||
mouse::Cursor::Available(p - self.translation())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
||||
fn tag(&self) -> widget::tree::Tag {
|
||||
self.content.as_widget().tag()
|
||||
}
|
||||
|
||||
fn state(&self) -> widget::tree::State {
|
||||
self.content.as_widget().state()
|
||||
}
|
||||
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
self.content.as_widget().children()
|
||||
}
|
||||
|
||||
fn diff(&mut self, tree: &mut widget::Tree) {
|
||||
self.content.as_widget_mut().diff(tree);
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
@@ -372,6 +406,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
||||
) -> layout::Node {
|
||||
self.content.as_widget_mut().layout(tree, renderer, limits)
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
@@ -417,6 +452,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
||||
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
@@ -441,6 +477,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
@@ -457,6 +494,7 @@ impl<M> Widget<M, Theme, cosmic::Renderer> for Motion<'_, M> {
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
@@ -475,6 +513,7 @@ impl widget::Operation for CollectRegions {
|
||||
fn traverse(&mut self, children: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
children(self);
|
||||
}
|
||||
|
||||
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
|
||||
if id == Some(&widget::Id::new("ferese-blur-card")) {
|
||||
let contains = |outer: &Rectangle, inner: &Rectangle| {
|
||||
@@ -524,6 +563,7 @@ mod tests {
|
||||
0.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_reversal_and_motion_policy_are_consistent() {
|
||||
let now = std::time::Instant::now();
|
||||
@@ -560,6 +600,7 @@ mod tests {
|
||||
assert_eq!(motion.progress_at(now), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_fades_settle_and_idle_does_not_schedule_frames() {
|
||||
let now = std::time::Instant::now();
|
||||
@@ -578,6 +619,7 @@ mod tests {
|
||||
assert!(!hover.advance(0.0, start + duration * 12, duration));
|
||||
assert_eq!(hover.current, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_reversals_preserve_current_paint() {
|
||||
let now = std::time::Instant::now();
|
||||
@@ -591,6 +633,7 @@ mod tests {
|
||||
hover.advance(0.0, now + duration, duration);
|
||||
assert!(hover.current > 0.0 && hover.current < before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_obeys_global_motion_policy() {
|
||||
assert_eq!(
|
||||
@@ -617,6 +660,7 @@ mod tests {
|
||||
assert_eq!(hover.current, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn material_regions_include_cards_but_not_the_parent_or_gaps() {
|
||||
let mut collector = CollectRegions(Vec::new());
|
||||
|
||||
@@ -12,48 +12,45 @@ pub fn save(path: &Path, edits: &[Edit]) -> Result<(), String> {
|
||||
let canonical = path.canonicalize().map_err(|e| e.to_string())?;
|
||||
let path = canonical.as_path();
|
||||
let source = fs::read_to_string(path).map_err(|e| e.to_string())?;
|
||||
let mut doc: toml_edit::DocumentMut = source
|
||||
.parse()
|
||||
.map_err(|e: toml_edit::TomlError| e.to_string())?;
|
||||
let mut doc = ferese_config::Document::parse(&source).map_err(|e| e.to_string())?;
|
||||
for edit in edits {
|
||||
if let Edit::ClockPosition(x, y) = edit {
|
||||
let clock = doc
|
||||
.get_mut("desktop_widgets")
|
||||
.and_then(|item| item.get_mut("clock"))
|
||||
.ok_or("Clock was removed from the config.")?;
|
||||
clock["anchor"] = toml_edit::value("top_left");
|
||||
clock["margin_x"] = toml_edit::value(i64::from(*x));
|
||||
clock["margin_y"] = toml_edit::value(i64::from(*y));
|
||||
if doc.get("desktop_widgets.clock").is_none() {
|
||||
return Err("Clock was removed from the config.".into());
|
||||
}
|
||||
for (field, value) in [
|
||||
("anchor", "top_left".into()),
|
||||
("margin_x", i64::from(*x).into()),
|
||||
("margin_y", i64::from(*y).into()),
|
||||
] {
|
||||
doc.set(&format!("desktop_widgets.clock.{field}"), value)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let notes = doc
|
||||
.get_mut("desktop_widgets")
|
||||
.and_then(|item| item.get_mut("notes"))
|
||||
.and_then(toml_edit::Item::as_array_of_tables_mut)
|
||||
.ok_or("Notes were removed from the config.")?;
|
||||
let id = match edit {
|
||||
Edit::Text(id, _) | Edit::Position(id, _, _) => id,
|
||||
Edit::ClockPosition(..) => unreachable!(),
|
||||
};
|
||||
let note = notes
|
||||
.iter_mut()
|
||||
.find(|note| {
|
||||
note.get("id")
|
||||
.and_then(toml_edit::Item::as_str)
|
||||
.unwrap_or("note")
|
||||
== id
|
||||
})
|
||||
let index = doc
|
||||
.get("desktop_widgets.notes")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or("Notes were removed from the config.")?
|
||||
.iter()
|
||||
.position(|note| note.get("id").and_then(|v| v.as_str()).unwrap_or("note") == id)
|
||||
.ok_or("This note no longer exists in the config.")?;
|
||||
match edit {
|
||||
Edit::Text(_, value) => {
|
||||
note["text"] = toml_edit::value(value);
|
||||
}
|
||||
Edit::Position(_, x, y) => {
|
||||
note["anchor"] = toml_edit::value("top_left");
|
||||
note["margin_x"] = toml_edit::value(i64::from(*x));
|
||||
note["margin_y"] = toml_edit::value(i64::from(*y));
|
||||
}
|
||||
let fields = match edit {
|
||||
Edit::Text(_, value) => vec![("text", value.clone().into())],
|
||||
Edit::Position(_, x, y) => vec![
|
||||
("anchor", "top_left".into()),
|
||||
("margin_x", i64::from(*x).into()),
|
||||
("margin_y", i64::from(*y).into()),
|
||||
],
|
||||
Edit::ClockPosition(..) => unreachable!(),
|
||||
};
|
||||
for (field, value) in fields {
|
||||
doc.set(&format!("desktop_widgets.notes.{index}.{field}"), value)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
let updated = doc.to_string();
|
||||
@@ -87,10 +84,10 @@ mod tests {
|
||||
#[test]
|
||||
fn clock_position_saves_without_a_notes_table() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
let path = dir.path().join("config.kdl");
|
||||
fs::write(
|
||||
&path,
|
||||
"[desktop_widgets.clock]\nenabled = true\ntime_size = 80\n",
|
||||
"desktop-widgets {\n clock {\n enabled #true\n time-size 80\n }\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
save(&path, &[Edit::ClockPosition(120, 240)]).unwrap();
|
||||
@@ -104,11 +101,25 @@ mod tests {
|
||||
);
|
||||
assert_eq!(config.desktop_widgets.clock.time_size, 80.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_edits_preserve_unrelated_config_and_multiline_content() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
fs::write(&path, "# keep this\n[theme.colors]\naccent = '#123456'\n[[desktop_widgets.notes]]\nid = 'a'\ntext = 'old'\n").unwrap();
|
||||
let path = dir.path().join("config.kdl");
|
||||
fs::write(
|
||||
&path,
|
||||
r##"// keep this
|
||||
theme {
|
||||
colors {
|
||||
accent "#123456"
|
||||
}
|
||||
}
|
||||
desktop-widgets {
|
||||
note id="a" text="old"
|
||||
}
|
||||
"##,
|
||||
)
|
||||
.unwrap();
|
||||
save(
|
||||
&path,
|
||||
&[
|
||||
@@ -118,7 +129,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let source = fs::read_to_string(&path).unwrap();
|
||||
assert!(source.contains("# keep this"));
|
||||
assert!(source.contains("// keep this"));
|
||||
assert!(source.contains("#123456"));
|
||||
let notes = crate::config::parse_source(&source)
|
||||
.unwrap()
|
||||
|
||||
@@ -23,28 +23,33 @@ pub struct Snapshot {
|
||||
pub reboot: bool,
|
||||
pub suspend: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Network {
|
||||
pub enabled: bool,
|
||||
pub connection: Option<String>,
|
||||
pub signal: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Bluetooth {
|
||||
pub enabled: bool,
|
||||
pub devices: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Audio {
|
||||
pub volume: u8,
|
||||
pub muted: bool,
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Battery {
|
||||
pub percent: u8,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Notifications {
|
||||
pub count: u32,
|
||||
@@ -65,22 +70,26 @@ pub enum Action {
|
||||
Reboot,
|
||||
Suspend,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
fn key(&self) -> std::mem::Discriminant<Self> {
|
||||
std::mem::discriminant(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Update {
|
||||
pub snapshot: Snapshot,
|
||||
pub generation: u64,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub struct Service {
|
||||
settings: Arc<Mutex<Option<Vec<String>>>>,
|
||||
tx: SyncSender<(u64, Action)>,
|
||||
rx: Receiver<Update>,
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
impl Service {
|
||||
pub fn start(settings: Option<Vec<String>>) -> Self {
|
||||
let live_settings = Arc::new(Mutex::new(settings));
|
||||
@@ -153,6 +162,7 @@ impl Service {
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send(&mut self, action: Action) -> Result<(), String> {
|
||||
let generation = self.generation + 1;
|
||||
self.tx
|
||||
@@ -161,9 +171,11 @@ impl Service {
|
||||
self.generation = generation;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_settings(&self, settings: Option<Vec<String>>) {
|
||||
*self.settings.lock().unwrap() = settings;
|
||||
}
|
||||
|
||||
pub fn poll(&self) -> Option<Update> {
|
||||
self.rx.try_iter().last()
|
||||
}
|
||||
@@ -222,6 +234,7 @@ fn poll() -> Snapshot {
|
||||
suspend: can_power("CanSuspend"),
|
||||
}
|
||||
}
|
||||
|
||||
fn network() -> Option<Network> {
|
||||
// No Wi-Fi device means no Wi-Fi control (wired connectivity isn't called Wi-Fi).
|
||||
let devices = run("nmcli", &["-t", "-f", "TYPE", "device", "status"]).ok()?;
|
||||
@@ -261,6 +274,7 @@ fn network() -> Option<Network> {
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
fn bluetooth() -> Option<Bluetooth> {
|
||||
let info = run("bluetoothctl", &["show"]).ok()?;
|
||||
let enabled = info
|
||||
@@ -282,6 +296,7 @@ fn bluetooth() -> Option<Bluetooth> {
|
||||
};
|
||||
Some(Bluetooth { enabled, devices })
|
||||
}
|
||||
|
||||
pub fn parse_audio(value: &str) -> Option<(u8, bool)> {
|
||||
let volume = value
|
||||
.strip_prefix("Volume: ")?
|
||||
@@ -297,6 +312,7 @@ pub fn parse_audio(value: &str) -> Option<(u8, bool)> {
|
||||
value.contains("[MUTED]"),
|
||||
))
|
||||
}
|
||||
|
||||
fn audio() -> Option<Audio> {
|
||||
let (volume, muted) =
|
||||
parse_audio(&run("wpctl", &["get-volume", "@DEFAULT_AUDIO_SINK@"]).ok()?)?;
|
||||
@@ -312,11 +328,13 @@ fn audio() -> Option<Audio> {
|
||||
output,
|
||||
})
|
||||
}
|
||||
|
||||
fn read(path: &Path, name: &str) -> Option<String> {
|
||||
fs::read_to_string(path.join(name))
|
||||
.ok()
|
||||
.map(|s| s.trim().to_owned())
|
||||
}
|
||||
|
||||
fn battery() -> Option<Battery> {
|
||||
let entries = fs::read_dir("/sys/class/power_supply").ok()?;
|
||||
// Prefer a system battery, not a mouse/headset battery.
|
||||
@@ -336,6 +354,7 @@ fn battery() -> Option<Battery> {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn brightness() -> Option<u8> {
|
||||
let max = run("brightnessctl", &["--class=backlight", "max"])
|
||||
.ok()?
|
||||
@@ -348,6 +367,7 @@ fn brightness() -> Option<u8> {
|
||||
(max > 0.0 && current.is_finite())
|
||||
.then(|| (100.0 * current / max).round().clamp(0.0, 100.0) as u8)
|
||||
}
|
||||
|
||||
fn notifications() -> Option<Notifications> {
|
||||
// Check service ownership first: swaync-client otherwise waits indefinitely.
|
||||
let owner = run(
|
||||
@@ -375,6 +395,7 @@ fn notifications() -> Option<Notifications> {
|
||||
};
|
||||
Some(Notifications { count, dnd })
|
||||
}
|
||||
|
||||
fn can_power(method: &str) -> bool {
|
||||
run(
|
||||
"busctl",
|
||||
@@ -389,6 +410,7 @@ fn can_power(method: &str) -> bool {
|
||||
)
|
||||
.is_ok_and(|s| s == "s \"yes\"" || s == "s \"challenge\"")
|
||||
}
|
||||
|
||||
fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> {
|
||||
match action {
|
||||
Action::Wifi(on) => run("nmcli", &["radio", "wifi", if *on { "on" } else { "off" }]),
|
||||
@@ -462,6 +484,7 @@ mod tests {
|
||||
assert_eq!(parse_audio(value), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_snapshot_does_not_invent_services() {
|
||||
let s = Snapshot::default();
|
||||
|
||||
@@ -19,6 +19,7 @@ pub enum Menu {
|
||||
Notifications,
|
||||
System,
|
||||
}
|
||||
|
||||
impl Menu {
|
||||
fn width(self) -> f32 {
|
||||
match self {
|
||||
@@ -28,10 +29,12 @@ impl Menu {
|
||||
Self::Audio | Self::Notifications => 290.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn material_role(self) -> Option<ferese_surface_effects_v1::Role> {
|
||||
// Popovers contain independent cards, with no shared material backing.
|
||||
None
|
||||
}
|
||||
|
||||
fn available(self, status: &Snapshot) -> bool {
|
||||
match self {
|
||||
Self::Network => status.network.is_some(),
|
||||
@@ -52,10 +55,12 @@ pub struct OpenMenu {
|
||||
pub effects: Option<EffectsBinding>,
|
||||
pub regions: super::motion::Regions,
|
||||
}
|
||||
|
||||
impl OpenMenu {
|
||||
pub fn progress(&self) -> f32 {
|
||||
self.motion.progress()
|
||||
}
|
||||
|
||||
pub fn animating(&self) -> bool {
|
||||
self.motion.animating()
|
||||
}
|
||||
@@ -115,6 +120,7 @@ impl FereseShell {
|
||||
);
|
||||
destroy.chain(cosmic::task::message(cosmic::Action::Surface(action)))
|
||||
}
|
||||
|
||||
pub fn destroy_menu(&mut self) -> Task<Message> {
|
||||
super::EFFECT_FRAME_PENDING.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
self.menu.take().map_or_else(Task::none, |menu| {
|
||||
@@ -123,6 +129,7 @@ impl FereseShell {
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn close_menu(&mut self) -> Task<Message> {
|
||||
let Some(menu) = &mut self.menu else {
|
||||
return Task::none();
|
||||
@@ -142,6 +149,7 @@ impl FereseShell {
|
||||
}
|
||||
self.destroy_menu()
|
||||
}
|
||||
|
||||
pub fn animate_menu(&mut self) -> Task<Message> {
|
||||
if let Some(menu) = &self.menu {
|
||||
if let Some(effects) = &menu.effects {
|
||||
@@ -153,6 +161,7 @@ impl FereseShell {
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub fn optimistic_status(&mut self, action: &Action) {
|
||||
match action {
|
||||
Action::Volume(v) => {
|
||||
@@ -184,6 +193,7 @@ impl FereseShell {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view_status_bar(&self) -> Element<'_, cosmic::Action<Message>> {
|
||||
let theme = self.config.theme.for_bar();
|
||||
let metrics = BarMetrics::from(theme);
|
||||
@@ -861,6 +871,7 @@ fn menu_button<'a>(
|
||||
opacity,
|
||||
)
|
||||
}
|
||||
|
||||
fn toggle_row<'a>(
|
||||
label: &'a str,
|
||||
on: bool,
|
||||
@@ -987,6 +998,7 @@ fn audio_icon(volume: u8, muted: bool) -> &'static [u8] {
|
||||
include_bytes!("../assets/icons/status/volume-high.svg")
|
||||
}
|
||||
}
|
||||
|
||||
fn status_icon(kind: Menu, s: &Snapshot) -> (&'static [u8], bool) {
|
||||
match kind {
|
||||
Menu::Network => {
|
||||
@@ -1111,6 +1123,7 @@ mod tests {
|
||||
assert!(!Menu::Notifications.available(&s));
|
||||
assert!(Menu::System.available(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icons_follow_real_states() {
|
||||
let mut s = Snapshot::default();
|
||||
|
||||
Reference in new issue
Block a user