use ferese_panel::{GroupId, Item, ItemId, ItemKind, Panel}; use serde_json::Value; use crate::store::{Edit, Snapshot, set}; #[derive(Clone, Debug, PartialEq, Eq)] pub(super) struct Destination { pub zone: &'static str, pub group: GroupId, } impl std::fmt::Display for Destination { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let zone = match self.zone { "start" => "Start", "center" => "Center", _ => "End", }; write!( formatter, "{zone} ยท {}", crate::panel_controls::group_name(&self.group.0) ) } } #[derive(Clone, Debug)] pub(super) enum Action { ApplyPreset(ferese_panel::PanelStyle), ResetShape, ClearGeometry(String), SetEdge(ferese_panel::Edge), SetGeometry(String, Value), SetRadius(String), SetCorner(usize, f32), SetBorder(bool), SetPeekOnHover(bool), SetMorphAttached(bool), SetOpacity(f32), ClearOpacity, ClearRadius, Earlier(ItemId), Later(ItemId), Move(ItemId, Destination), Place(ItemId, Destination, Option), PlaceInZone(ItemId, Zone), Remove(ItemId), Set(ItemId, String, Value), Add(ItemKind, Destination), AddGroup(Zone), MoveGroup(GroupId, Zone), EarlierGroup(GroupId), LaterGroup(GroupId), RemoveGroup(GroupId), SetGroup(GroupId, String, Value), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum Zone { Start, Center, End, } impl Zone { pub const ALL: [Self; 3] = [Self::Start, Self::Center, Self::End]; pub fn key(self) -> &'static str { match self { Self::Start => "start", Self::Center => "center", Self::End => "end", } } pub fn definition(self, panel: &Panel) -> &ferese_panel::Zone { match self { Self::Start => &panel.start, Self::Center => &panel.center, Self::End => &panel.end, } } } impl std::fmt::Display for Zone { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str(match self { Self::Start => "Start", Self::Center => "Center", Self::End => "End", }) } } fn locate_group(panel: &Panel, id: &GroupId) -> Result<(Zone, usize), String> { Zone::ALL .into_iter() .find_map(|zone| { zone.definition(panel) .groups .iter() .position(|group| &group.id == id) .map(|index| (zone, index)) }) .ok_or_else(|| "This group no longer exists. Reload settings.".into()) } pub(super) fn group_record_path(panel: &Panel, id: &GroupId) -> Result { let (zone, index) = locate_group(panel, id)?; Ok(format!("panels.0.{}.groups.{index}", zone.key())) } pub(super) fn destinations(panel: &Panel) -> Vec { [ ("start", &panel.start), ("center", &panel.center), ("end", &panel.end), ] .into_iter() .flat_map(|(zone, definition)| { definition.groups.iter().map(move |group| Destination { zone, group: group.id.clone(), }) }) .collect() } fn group_path(panel: &Panel, destination: &Destination) -> Result { // The zone is a display label; a queued selection follows the group's ID // even if another edit has since moved it to a different zone. Ok(format!( "{}.items", group_record_path(panel, &destination.group)? )) } fn locate(panel: &Panel, id: &ItemId) -> Result<(String, usize), String> { for destination in destinations(panel) { let path = group_path(panel, &destination)?; let zone = match destination.zone { "start" => &panel.start, "center" => &panel.center, _ => &panel.end, }; let group = zone .groups .iter() .find(|group| group.id == destination.group) .expect("the selected destination group belongs to its resolved zone"); if let Some(index) = group.items.iter().position(|item| &item.id == id) { return Ok((path, index)); } } Err("This item no longer exists. Reload settings.".into()) } fn records(snapshot: &Snapshot, path: &str) -> Vec { snapshot .item(path) .and_then(Value::as_array) .cloned() .unwrap_or_default() } /// Resolve stable IDs against the latest draft, then validate the complete edit /// before handing it to Settings' existing single-save/undo path. pub(super) fn plan(snapshot: &Snapshot, action: Action) -> Result, String> { if matches!(action, Action::ClearRadius) && snapshot.item("panels.0.corner_radius").is_none() { return Ok(Vec::new()); } // Browsing the generated composition is read-only. Materialize it together // with the first real edit so validation, save and Undo remain atomic. if snapshot.item("panels").is_none() { let initialize = crate::panel_controls::initialize(snapshot)?; let mut candidate = snapshot.clone(); candidate.edit(&initialize)?; let mut edits = vec![initialize]; edits.extend(plan(&candidate, action)?); return Ok(edits); } let panels: Vec = serde_json::from_value( snapshot .item("panels") .ok_or("Customize items first.")? .clone(), ) .map_err(|error| error.to_string())?; ferese_panel::validate(&panels)?; let panel = &panels[0]; let earlier = matches!(&action, Action::Earlier(_)); let earlier_group = matches!(&action, Action::EarlierGroup(_)); let edits = match action { Action::ApplyPreset(style) => vec![set("panels.0.style", style.key())], Action::ResetShape => vec![ Edit::Unset("panels.0.geometry".into()), Edit::Unset("panels.0.corner_radius".into()), ], Action::ClearGeometry(path) => { if !matches!( path.as_str(), "panels.0.geometry.height" | "panels.0.geometry.edge_margin" | "panels.0.geometry.side_margins" | "panels.0.geometry.window_clearance" | "panels.0.geometry.inner_padding" ) { return Err("Unknown panel geometry setting.".into()); } vec![Edit::Unset(path)] } Action::SetEdge(edge) => vec![set("panels.0.edge", edge.key())], Action::SetGeometry(field, value) => { if !matches!( field.as_str(), "height" | "edge_margin" | "side_margins" | "window_clearance" | "inner_padding" ) { return Err("Unknown panel geometry setting.".into()); } vec![set(&format!("panels.0.geometry.{field}"), value)] } Action::SetOpacity(value) => vec![set("panels.0.background_opacity", value)], Action::ClearOpacity => vec![Edit::Unset("panels.0.background_opacity".into())], Action::SetMorphAttached(enabled) => vec![set("panels.0.morph.attached", enabled)], Action::SetPeekOnHover(enabled) => vec![set("panels.0.morph.peek_on_hover", enabled)], Action::SetBorder(enabled) => vec![set("panels.0.border", enabled)], Action::SetCorner(index, value) => { let mut radii = panel.corner_radius.ok_or("Customize corners first.")?; *radii.0.get_mut(index).ok_or("Invalid panel corner.")? = value; radii.validate()?; vec![set("panels.0.corner_radius", radii.to_string())] } Action::SetRadius(value) => { let radii: ferese_panel::CornerRadii = value.parse()?; vec![set("panels.0.corner_radius", radii.to_string())] } Action::ClearRadius => vec![Edit::Unset("panels.0.corner_radius".into())], Action::Earlier(id) | Action::Later(id) => { let (path, index) = locate(panel, &id)?; let mut items = records(snapshot, &path); let next = if earlier { index.checked_sub(1) } else { (index + 1 < items.len()).then_some(index + 1) }; let Some(next) = next else { return Ok(Vec::new()); }; items.swap(index, next); vec![set(&path, items)] } Action::Place(id, destination, before) => { let (source, index) = locate(panel, &id)?; let target = group_path(panel, &destination)?; if before.as_ref() == Some(&id) && source == target { return Ok(Vec::new()); } let mut from = records(snapshot, &source); let original = from.clone(); let item = from.remove(index); let mut to = if source == target { from.clone() } else { records(snapshot, &target) }; let insertion = match before { Some(before) => to .iter() .position(|item| item["id"].as_str() == Some(&before.0)) .ok_or("The drop target changed. Try dragging again.")?, None => to.len(), }; to.insert(insertion, item); if source == target { if to == original { Vec::new() } else { vec![set(&target, to)] } } else { vec![set(&source, from), set(&target, to)] } } Action::Move(id, destination) => { let (source, _) = locate(panel, &id)?; if source == group_path(panel, &destination)? { return Ok(Vec::new()); } return plan(snapshot, Action::Place(id, destination, None)); } Action::PlaceInZone(id, zone) => { if let Some(group) = zone.definition(panel).groups.last() { return plan( snapshot, Action::Place( id, Destination { zone: zone.key(), group: group.id.clone(), }, None, ), ); } let mut edits = plan(snapshot, Action::AddGroup(zone))?; let mut candidate = snapshot.clone(); for edit in &edits { candidate.edit(edit)?; } let group = records(&candidate, &format!("panels.0.{}.groups", zone.key()))[0]["id"] .as_str() .expect("validated panel groups and item kinds have string identifiers") .to_owned(); edits.extend(plan( &candidate, Action::Place( id, Destination { zone: zone.key(), group: GroupId(group), }, None, ), )?); edits } Action::Remove(id) => { let (path, index) = locate(panel, &id)?; vec![Edit::Remove(path, index)] } Action::Set(id, field, value) => { if !matches!( field.as_str(), "visible" | "overflow" | "representation" | "percentage" | "style" ) { return Err("Unknown panel item setting.".into()); } let (path, index) = locate(panel, &id)?; if field == "style" && !matches!( panel .item(&id) .expect("the item edit resolved its stable ID before checking its kind") .kind, ItemKind::Workspaces { .. } ) { return Err("Workspace style belongs to Workspaces items.".into()); } vec![set(&format!("{path}.{index}.{field}"), value)] } Action::Add(kind, destination) => { if kind == ItemKind::Overflow { return Err("Overflow is supplied by the panel.".into()); } let path = group_path(panel, &destination)?; let name = serde_json::to_value(kind).map_err(|error| error.to_string())?; let name = name["kind"] .as_str() .expect("validated panel groups and item kinds have string identifiers"); let count = destinations(panel) .iter() .map(|destination| { let path = group_path(panel, destination) .expect("catalog destinations refer to existing panel groups"); records(snapshot, &path).len() }) .sum::(); let id = (1..=count + 1) .map(|number| { if number == 1 { name.to_owned() } else { format!("{name}-{number}") } }) .find(|id| panel.item(&ItemId(id.clone())).is_none()) .expect("one more candidate ID than existing items guarantees an unused ID"); let mut items = records(snapshot, &path); items.push( serde_json::to_value(Item::new(&id, kind)).map_err(|error| error.to_string())?, ); vec![set(&path, items)] } Action::AddGroup(zone) => { let count = Zone::ALL .iter() .map(|zone| zone.definition(panel).groups.len()) .sum::(); let id = (1..=count + 1) .map(|number| format!("{}-{number}", zone.key())) .find(|id| locate_group(panel, &GroupId(id.clone())).is_err()) .expect("one more candidate ID than existing items guarantees an unused ID"); let path = format!("panels.0.{}.groups", zone.key()); let mut groups = records(snapshot, &path); groups.push(serde_json::json!({ "id": id, "items": [] })); vec![set(&path, groups)] } Action::MoveGroup(id, target) => { let (source, index) = locate_group(panel, &id)?; if source == target { return Ok(Vec::new()); } let source = format!("panels.0.{}.groups", source.key()); let target = format!("panels.0.{}.groups", target.key()); let mut from = records(snapshot, &source); let mut to = records(snapshot, &target); to.push(from.remove(index)); vec![set(&source, from), set(&target, to)] } Action::EarlierGroup(id) | Action::LaterGroup(id) => { let (zone, index) = locate_group(panel, &id)?; let path = format!("panels.0.{}.groups", zone.key()); let mut groups = records(snapshot, &path); let next = if earlier_group { index.checked_sub(1) } else { (index + 1 < groups.len()).then_some(index + 1) }; let Some(next) = next else { return Ok(Vec::new()); }; groups.swap(index, next); vec![set(&path, groups)] } Action::RemoveGroup(id) => { let (zone, index) = locate_group(panel, &id)?; if !zone.definition(panel).groups[index].items.is_empty() { return Err("Move or remove this group's items before removing the group.".into()); } vec![Edit::Remove( format!("panels.0.{}.groups", zone.key()), index, )] } Action::SetGroup(id, field, value) => { let (field, value) = match field.as_str() { "padding_vertical" | "padding_horizontal" => { let (zone, index) = locate_group(panel, &id)?; let mut padding = serde_json::to_value(zone.definition(panel).groups[index].padding) .expect("typed configuration and IPC values must serialize to JSON"); padding[usize::from(field == "padding_horizontal")] = value; ("padding".to_owned(), padding) } _ => (field, value), }; if !matches!(field.as_str(), "spacing" | "padding" | "island_padding") { return Err("Unknown panel group setting.".into()); } vec![set( &format!("{}.{field}", group_record_path(panel, &id)?), value, )] } }; let mut candidate = snapshot.clone(); for edit in &edits { candidate.edit(edit)?; } Snapshot::parse(candidate.source)?; Ok(edits) } #[cfg(test)] mod tests { use super::*; #[test] fn first_geometry_edit_materializes_the_panel_atomically() { let mut snapshot = Snapshot::parse("// keep\nanimations { speed 0.8; }".into()).unwrap(); let edits = plan( &snapshot, Action::SetGeometry("side_margins".into(), 18.into()), ) .unwrap(); assert_eq!( edits.len(), 2, "initialize and edit share one save/undo transaction" ); for edit in edits { snapshot.edit(&edit).unwrap(); } let panels: Vec = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap(); assert_eq!(panels[0].resolved_geometry().height, 36.); assert_eq!(panels[0].resolved_geometry().edge_margin, 0); assert_eq!(panels[0].geometry.side_margins, Some(18)); assert_eq!(panels[0].resolved_geometry().side_margins, 0); let mut floating = panels[0].clone(); floating.apply_preset(ferese_panel::PanelStyle::Floating); assert_eq!(floating.resolved_geometry().side_margins, 18); assert!(snapshot.source.contains("// keep")); assert!(plan(&snapshot, Action::SetGeometry("height".into(), 12.into())).is_err()); } #[test] fn group_capacity_is_checked_before_saving_and_removal_reopens_a_slot() { let mut snapshot = Snapshot::parse("panel main {}".into()).unwrap(); for _ in 0..ferese_panel::MAX_GROUPS { apply(&mut snapshot, Action::AddGroup(Zone::End)); } let source = snapshot.source.clone(); assert!( plan(&snapshot, Action::AddGroup(Zone::Start)) .unwrap_err() .contains("31 groups") ); assert_eq!(snapshot.source, source); apply(&mut snapshot, Action::RemoveGroup(GroupId("end-1".into()))); apply(&mut snapshot, Action::AddGroup(Zone::Start)); assert_eq!(snapshot.records("panels.0.start.groups"), 1); } fn snapshot() -> Snapshot { Snapshot::parse( r#"// preserve config panel "main" { start { group "navigation" { item "overview" kind="overview"; }; } center { group "title" { item "title" kind="focused-window"; }; } end { group "status" { // first clock item "clock" kind="clock" { metadata { custom-note "keep"; }; } item "clock-2" kind="clock" item "network" kind="network" overflow="always" }; } } animations { speed 0.8; } "# .into(), ) .unwrap() } fn apply(snapshot: &mut Snapshot, action: Action) { for edit in plan(snapshot, action).unwrap() { snapshot.edit(&edit).unwrap(); } *snapshot = Snapshot::parse(snapshot.source.clone()).unwrap(); } fn ids(snapshot: &Snapshot, path: &str) -> Vec { records(snapshot, path) .iter() .map(|record| record["id"].as_str().unwrap().to_owned()) .collect() } fn target(zone: &'static str, group: &str) -> Destination { Destination { zone, group: GroupId(group.into()), } } #[test] fn morph_attachment_restores_floating_margin_and_preserves_clearance() { let mut snapshot = Snapshot::parse( "panel main { style morph; geometry { edge-margin 9; window-clearance 3; }; }".into(), ) .unwrap(); let before = serde_json::from_value::>(snapshot.item("panels").unwrap().clone()).unwrap(); apply(&mut snapshot, Action::SetMorphAttached(true)); let attached = serde_json::from_value::>(snapshot.item("panels").unwrap().clone()).unwrap(); assert_eq!(attached[0].resolved_geometry().edge_margin, 0); assert_eq!(attached[0].resolved_geometry().window_clearance, 3); assert_eq!( attached[0].exclusive_zone(false), before[0].exclusive_zone(false) ); assert!(attached[0].shape_parameters().attached); apply(&mut snapshot, Action::SetMorphAttached(false)); assert_eq!( serde_json::from_value::>(snapshot.item("panels").unwrap().clone()).unwrap(), before ); } #[test] fn hover_preference_round_trips_without_changing_shape_or_items() { let mut snapshot = Snapshot::parse("panel main { style morph; morph { idle-width 180; }; }".into()) .unwrap(); let before = serde_json::from_value::>(snapshot.item("panels").unwrap().clone()).unwrap(); apply(&mut snapshot, Action::SetPeekOnHover(false)); let after = serde_json::from_value::>(snapshot.item("panels").unwrap().clone()).unwrap(); assert!(!after[0].morph.peek_on_hover); let mut expected = before.clone(); expected[0].morph.peek_on_hover = false; assert_eq!(after, expected); apply(&mut snapshot, Action::SetPeekOnHover(true)); assert_eq!( serde_json::from_value::>(snapshot.item("panels").unwrap().clone()).unwrap(), before ); } #[test] fn shared_group_style_and_opacity_edit_only_the_panel_and_can_restore_inheritance() { let mut snapshot = Snapshot::parse(String::new()).unwrap(); apply(&mut snapshot, Action::SetBorder(false)); let groups = snapshot.item("panels.0.end.groups").unwrap().clone(); apply(&mut snapshot, Action::SetOpacity(0.6)); assert!((snapshot.number("panels.0.background_opacity", 0.) - 0.6).abs() < 0.0001); assert!(!snapshot.boolean("panels.0.border", true)); assert_eq!(snapshot.item("panels.0.end.groups"), Some(&groups)); let source = snapshot.source.clone(); for value in [-0.1, 1.1, f32::NAN] { assert!(plan(&snapshot, Action::SetOpacity(value)).is_err()); assert_eq!(snapshot.source, source); } apply(&mut snapshot, Action::ClearOpacity); assert!(snapshot.item("panels.0.background_opacity").is_none()); assert_eq!(snapshot.item("panels.0.end.groups"), Some(&groups)); } #[test] fn border_toggle_materializes_once_and_preserves_every_group() { let mut snapshot = Snapshot::parse(String::new()).unwrap(); let original: Vec = { let Edit::Set(_, value) = crate::panel_controls::initialize(&snapshot).unwrap() else { unreachable!() }; serde_json::from_value(value).unwrap() }; apply(&mut snapshot, Action::SetBorder(false)); let panels: Vec = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap(); assert!(!panels[0].border); assert_eq!(panels[0].start, original[0].start); assert_eq!(panels[0].center, original[0].center); assert_eq!(panels[0].end, original[0].end); apply(&mut snapshot, Action::SetBorder(true)); assert!(snapshot.boolean("panels.0.border", false)); } #[test] fn corner_edits_preserve_other_corners_and_validate_before_publication() { let mut snapshot = Snapshot::parse(String::new()).unwrap(); apply(&mut snapshot, Action::SetRadius("4px 8px 12px 16px".into())); apply(&mut snapshot, Action::SetCorner(0, 9.)); apply(&mut snapshot, Action::SetCorner(3, 21.)); let radii: ferese_panel::CornerRadii = serde_json::from_value(snapshot.item("panels.0.corner_radius").unwrap().clone()) .unwrap(); assert_eq!(radii.0, [9., 8., 12., 21.]); let previous = snapshot.source.clone(); for action in [ Action::SetCorner(4, 3.), Action::SetCorner(0, -1.), Action::SetCorner(1, f32::NAN), ] { assert!(plan(&snapshot, action).is_err()); assert_eq!(snapshot.source, previous); } } #[test] fn panel_corner_override_is_independent_and_clearing_or_removing_restores_inheritance() { let mut snapshot = Snapshot::parse("theme { geometry { shell-radius 14; }; }".into()).unwrap(); assert!(plan(&snapshot, Action::ClearRadius).unwrap().is_empty()); apply(&mut snapshot, Action::SetRadius("4px 8px 12px 16px".into())); let resolve = |snapshot: &Snapshot, radius| { let panels: Vec = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap(); panels[0].resolved_radius(radius).0 }; assert_eq!(resolve(&snapshot, 14.), [4., 8., 12., 16.]); snapshot .edit(&set("theme.geometry.shell_radius", 20)) .unwrap(); assert_eq!(resolve(&snapshot, 20.), [4., 8., 12., 16.]); let original = snapshot.source.clone(); assert!(plan(&snapshot, Action::SetRadius("-4px".into())).is_err()); assert_eq!(snapshot.source, original); apply(&mut snapshot, Action::ClearRadius); assert_eq!(resolve(&snapshot, 20.), [20.; 4]); apply(&mut snapshot, Action::SetRadius("8px".into())); snapshot.edit(&Edit::Unset("panels".into())).unwrap(); let Edit::Set(_, panel) = crate::panel_controls::initialize(&snapshot).unwrap() else { unreachable!() }; let panels: Vec = serde_json::from_value(panel).unwrap(); assert_eq!(panels[0].resolved_radius(20.).0, [20.; 4]); assert_eq!(snapshot.number("theme.geometry.shell_radius", 0.), 20.); } #[test] fn changing_edge_materializes_preferences_and_round_trips_without_moving_items() { let mut snapshot = Snapshot::parse("".into()).unwrap(); apply(&mut snapshot, Action::SetEdge(ferese_panel::Edge::Bottom)); let before = snapshot.item("panels.0.start").unwrap().clone(); assert_eq!(snapshot.string("panels.0.edge", ""), "bottom"); let panels: Vec = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap(); let battery = panels[0].item(&ItemId("battery".into())).unwrap(); assert_eq!(battery.kind, ItemKind::Battery { percentage: true }); apply(&mut snapshot, Action::SetEdge(ferese_panel::Edge::Top)); assert_eq!(snapshot.string("panels.0.edge", ""), "top"); assert_eq!(snapshot.item("panels.0.start"), Some(&before)); Snapshot::parse(snapshot.source).unwrap(); } #[test] fn first_edit_materializes_default_settings_and_the_change_together() { let mut snapshot = Snapshot::parse("// preserved\n\n".into()).unwrap(); let original = snapshot.source.clone(); assert!( plan( &snapshot, Action::Set(ItemId("missing".into()), "visible".into(), false.into()) ) .is_err() ); assert_eq!(snapshot.source, original); let edits = plan( &snapshot, Action::Set(ItemId("battery".into()), "visible".into(), false.into()), ) .unwrap(); assert_eq!(edits.len(), 2); for edit in edits { snapshot.edit(&edit).unwrap(); } let panels: Vec = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap(); let battery = panels[0].item(&ItemId("battery".into())).unwrap(); assert_eq!(battery.kind, ItemKind::Battery { percentage: true }); assert!(!battery.visible); assert!(snapshot.source.contains("// preserved")); Snapshot::parse(snapshot.source).unwrap(); } #[test] fn reordering_and_cross_zone_moves_keep_identity_settings_and_custom_fields() { let mut snapshot = snapshot(); apply(&mut snapshot, Action::Earlier(ItemId("network".into()))); assert_eq!( ids(&snapshot, "panels.0.end.groups.0.items"), ["clock", "network", "clock-2"] ); apply( &mut snapshot, Action::Move(ItemId("clock".into()), target("start", "navigation")), ); assert_eq!( ids(&snapshot, "panels.0.start.groups.0.items"), ["overview", "clock"] ); assert_eq!( snapshot.string("panels.0.start.groups.0.items.1.metadata.custom_note", ""), "keep" ); assert_eq!( snapshot.string("panels.0.end.groups.0.items.0.overflow", ""), "always" ); assert!(snapshot.source.contains("// first clock")); assert_eq!(snapshot.number("animations.speed", 1.0), 0.8); } #[test] fn repeated_kind_additions_get_unique_ids_and_remove_only_the_addressed_instance() { let mut snapshot = snapshot(); apply( &mut snapshot, Action::Add(ItemKind::Clock, target("end", "status")), ); assert_eq!( ids(&snapshot, "panels.0.end.groups.0.items"), ["clock", "clock-2", "network", "clock-3"] ); apply(&mut snapshot, Action::Remove(ItemId("clock-2".into()))); assert_eq!( ids(&snapshot, "panels.0.end.groups.0.items"), ["clock", "network", "clock-3"] ); apply( &mut snapshot, Action::Add(ItemKind::Clock, target("end", "status")), ); assert_eq!( ids(&snapshot, "panels.0.end.groups.0.items"), ["clock", "network", "clock-3", "clock-2"] ); } #[test] fn pending_actions_resolve_ids_after_a_prior_move_and_boundaries_are_noops() { let mut snapshot = snapshot(); let pending = Action::Later(ItemId("network".into())); apply( &mut snapshot, Action::Move(ItemId("network".into()), target("start", "navigation")), ); assert!(plan(&snapshot, pending).unwrap().is_empty()); apply(&mut snapshot, Action::Earlier(ItemId("network".into()))); assert_eq!( ids(&snapshot, "panels.0.start.groups.0.items"), ["network", "overview"] ); assert!( plan(&snapshot, Action::Earlier(ItemId("network".into()))) .unwrap() .is_empty() ); } #[test] fn item_setting_changes_follow_the_instance_after_reordering() { let mut snapshot = snapshot(); let pending = Action::Set(ItemId("network".into()), "overflow".into(), "never".into()); apply( &mut snapshot, Action::Move(ItemId("network".into()), target("start", "navigation")), ); apply(&mut snapshot, pending); assert_eq!( snapshot.string("panels.0.start.groups.0.items.1.id", ""), "network" ); assert_eq!( snapshot.string("panels.0.start.groups.0.items.1.overflow", ""), "never" ); assert_eq!( snapshot.string("panels.0.end.groups.0.items.1.id", ""), "clock-2" ); assert!( plan( &snapshot, Action::Set(ItemId("clock".into()), "visible".into(), "invalid".into()) ) .is_err() ); } #[test] fn invalid_targets_do_not_change_the_draft_and_last_item_removal_keeps_an_empty_group() { let mut snapshot = snapshot(); let original = snapshot.source.clone(); assert!( plan( &snapshot, Action::Move(ItemId("clock".into()), target("start", "missing")) ) .is_err() ); assert!(plan(&snapshot, Action::Remove(ItemId("missing".into()))).is_err()); assert_eq!(snapshot.source, original); apply(&mut snapshot, Action::Remove(ItemId("title".into()))); assert_eq!(snapshot.records("panels.0.center.groups"), 1); assert_eq!(snapshot.records("panels.0.center.groups.0.items"), 0); apply( &mut snapshot, Action::Add(ItemKind::Clock, target("center", "title")), ); assert_eq!(snapshot.records("panels.0.center.groups.0.items"), 1); } #[test] fn the_whole_move_is_validated_before_a_save_and_an_undo_restores_composition() { let mut snapshot = snapshot(); let original = snapshot.clone(); let edits = plan( &snapshot, Action::Move(ItemId("clock".into()), target("center", "title")), ) .unwrap(); assert_eq!(edits.len(), 2); for edit in edits { snapshot.edit(&edit).unwrap(); } let complete = Snapshot::parse(snapshot.source).unwrap(); assert_eq!( ids(&complete, "panels.0.center.groups.0.items"), ["title", "clock"] ); let restored = Snapshot::parse(original.source).unwrap(); assert_eq!(restored.doc.value(), original.doc.value()); } #[test] fn groups_move_and_reorder_without_losing_item_identity_or_authored_data() { let mut snapshot = snapshot(); let id = GroupId("status".into()); apply(&mut snapshot, Action::SetBorder(false)); apply( &mut snapshot, Action::SetGroup(id.clone(), "spacing".into(), 7.5.into()), ); apply(&mut snapshot, Action::SetOpacity(0.45)); apply(&mut snapshot, Action::MoveGroup(id.clone(), Zone::Start)); apply(&mut snapshot, Action::EarlierGroup(id.clone())); assert_eq!( ids(&snapshot, "panels.0.start.groups"), ["status", "navigation"] ); assert_eq!( ids(&snapshot, "panels.0.start.groups.0.items"), ["clock", "clock-2", "network"] ); assert_eq!( snapshot.string("panels.0.start.groups.0.items.0.metadata.custom_note", ""), "keep" ); assert_eq!(snapshot.number("panels.0.start.groups.0.spacing", 0.), 7.5); assert!((snapshot.number("panels.0.background_opacity", 1.) - 0.45).abs() < 0.0001); assert!(snapshot.source.contains("// first clock")); assert!( plan(&snapshot, Action::EarlierGroup(id.clone())) .unwrap() .is_empty() ); apply(&mut snapshot, Action::LaterGroup(id.clone())); assert_eq!( ids(&snapshot, "panels.0.start.groups"), ["navigation", "status"] ); assert!( plan(&snapshot, Action::LaterGroup(id.clone())) .unwrap() .is_empty() ); assert!( plan(&snapshot, Action::MoveGroup(id, Zone::Start)) .unwrap() .is_empty() ); assert_eq!(snapshot.number("animations.speed", 1.), 0.8); } #[test] fn queued_item_and_group_edits_follow_groups_across_zones() { let mut snapshot = snapshot(); let id = GroupId("status".into()); let pending = Action::Add(ItemKind::Clock, target("end", "status")); apply(&mut snapshot, Action::MoveGroup(id.clone(), Zone::Center)); apply(&mut snapshot, pending); apply( &mut snapshot, Action::SetGroup(id.clone(), "padding_vertical".into(), 9.into()), ); apply( &mut snapshot, Action::SetGroup(id, "padding_horizontal".into(), 11.into()), ); assert_eq!( snapshot.item("panels.0.center.groups.1.padding"), Some(&serde_json::json!([9, 11])) ); assert_eq!( ids(&snapshot, "panels.0.center.groups.1.items"), ["clock", "clock-2", "network", "clock-3"] ); apply( &mut snapshot, Action::Move(ItemId("overview".into()), target("end", "status")), ); assert_eq!( ids(&snapshot, "panels.0.center.groups.1.items"), ["clock", "clock-2", "network", "clock-3", "overview"] ); } #[test] fn empty_zones_can_gain_unique_groups_and_nonempty_groups_cannot_be_removed() { let mut snapshot = Snapshot::parse("panel \"main\" { style floating; }".into()).unwrap(); for _ in 0..3 { apply(&mut snapshot, Action::AddGroup(Zone::End)); } assert_eq!( ids(&snapshot, "panels.0.end.groups"), ["end-1", "end-2", "end-3"] ); apply(&mut snapshot, Action::SetBorder(false)); assert!(!snapshot.boolean("panels.0.border", true)); apply( &mut snapshot, Action::Add(ItemKind::Clock, target("end", "end-2")), ); let original = snapshot.source.clone(); assert!(plan(&snapshot, Action::RemoveGroup(GroupId("end-2".into()))).is_err()); assert_eq!(snapshot.source, original); apply(&mut snapshot, Action::RemoveGroup(GroupId("end-1".into()))); assert_eq!(ids(&snapshot, "panels.0.end.groups"), ["end-2", "end-3"]); apply(&mut snapshot, Action::Remove(ItemId("clock".into()))); apply(&mut snapshot, Action::RemoveGroup(GroupId("end-2".into()))); apply(&mut snapshot, Action::RemoveGroup(GroupId("end-3".into()))); apply(&mut snapshot, Action::AddGroup(Zone::Start)); assert_eq!(ids(&snapshot, "panels.0.start.groups"), ["start-1"]); } #[test] fn invalid_group_settings_fail_before_mutating_the_draft() { let snapshot = snapshot(); let original = snapshot.source.clone(); for (field, value) in [ ("spacing", serde_json::json!(65)), ("background_opacity", serde_json::json!(-0.1)), ("background_opacity", serde_json::json!(1.1)), ("padding_horizontal", serde_json::json!(33)), ("surface", serde_json::json!("unknown")), ("items", serde_json::json!([])), ] { assert!( plan( &snapshot, Action::SetGroup(GroupId("status".into()), field.into(), value) ) .is_err() ); } assert!( plan( &snapshot, Action::MoveGroup(GroupId("missing".into()), Zone::Start) ) .is_err() ); assert_eq!(snapshot.source, original); } #[test] fn drop_reorders_exact_instances_and_preserves_authored_fields() { let mut snapshot = snapshot(); let id = ItemId("clock".into()); apply( &mut snapshot, Action::Place(id.clone(), target("end", "status"), None), ); assert_eq!( ids(&snapshot, "panels.0.end.groups.0.items"), ["clock-2", "network", "clock"] ); assert_eq!( snapshot.string("panels.0.end.groups.0.items.2.metadata.custom_note", ""), "keep" ); assert!( plan( &snapshot, Action::Place(id.clone(), target("end", "status"), None) ) .unwrap() .is_empty() ); assert!( plan( &snapshot, Action::Place(id.clone(), target("end", "status"), Some(id.clone())) ) .unwrap() .is_empty() ); apply( &mut snapshot, Action::Place( id.clone(), target("end", "status"), Some(ItemId("clock-2".into())), ), ); assert_eq!( ids(&snapshot, "panels.0.end.groups.0.items"), ["clock", "clock-2", "network"] ); apply( &mut snapshot, Action::Place(id, target("center", "title"), Some(ItemId("title".into()))), ); assert_eq!( ids(&snapshot, "panels.0.center.groups.0.items"), ["clock", "title"] ); assert_eq!( ids(&snapshot, "panels.0.end.groups.0.items"), ["clock-2", "network"] ); assert!(snapshot.source.contains("// first clock")); } #[test] fn stale_drop_targets_reject_the_whole_edit_and_empty_zone_drop_creates_one_group() { let mut snapshot = snapshot(); let original = snapshot.source.clone(); assert!( plan( &snapshot, Action::Place( ItemId("clock".into()), target("start", "navigation"), Some(ItemId("missing".into())) ) ) .is_err() ); assert_eq!(snapshot.source, original); apply(&mut snapshot, Action::Remove(ItemId("title".into()))); apply(&mut snapshot, Action::RemoveGroup(GroupId("title".into()))); let edits = plan( &snapshot, Action::PlaceInZone(ItemId("clock".into()), Zone::Center), ) .unwrap(); assert_eq!(edits.len(), 3, "create and move through one save"); for edit in edits { snapshot.edit(&edit).unwrap(); } let snapshot = Snapshot::parse(snapshot.source).unwrap(); assert_eq!(snapshot.records("panels.0.center.groups"), 1); assert_eq!(ids(&snapshot, "panels.0.center.groups.0.items"), ["clock"]); assert_eq!( snapshot.string("panels.0.center.groups.0.items.0.metadata.custom_note", ""), "keep" ); } #[test] fn workspace_style_selection_preserves_other_preferences_and_moves_with_the_item() { let mut snapshot = Snapshot::parse("// keep\n".into()).unwrap(); apply( &mut snapshot, Action::Set(ItemId("battery".into()), "percentage".into(), false.into()), ); for style in ferese_panel::WorkspaceStyle::ALL { apply( &mut snapshot, Action::Set( ItemId("workspaces".into()), "style".into(), style.key().into(), ), ); let panels: Vec = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap(); let panel = &panels[0]; assert_eq!( panel.item(&ItemId("workspaces".into())).unwrap().kind, ItemKind::Workspaces { style } ); assert!(snapshot.source.contains("// keep")); assert_eq!( panel.item(&ItemId("battery".into())).unwrap().kind, ItemKind::Battery { percentage: false } ); Snapshot::parse(snapshot.source.clone()).unwrap(); } apply( &mut snapshot, Action::Move(ItemId("workspaces".into()), target("end", "status")), ); let panels: Vec = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap(); let panel = &panels[0]; assert_eq!( panel.item(&ItemId("workspaces".into())).unwrap().kind, ItemKind::Workspaces { style: ferese_panel::WorkspaceStyle::AppIcons } ); } #[test] fn editing_item_settings_preserves_comments_and_metadata() { let mut snapshot = Snapshot::parse("// keep\npanel main { start { group nav { item spaces kind=\"workspaces\" style=\"numbers\" { metadata { custom-note \"keep\"; }; }; }; }; center { group title { item title kind=\"focused-window\" visible=#false; }; }; }".into()).unwrap(); apply( &mut snapshot, Action::Set(ItemId("spaces".into()), "style".into(), "dots".into()), ); apply( &mut snapshot, Action::Set(ItemId("title".into()), "visible".into(), true.into()), ); assert!(snapshot.source.contains("// keep") && snapshot.source.contains("custom-note")); let panels: Vec = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap(); assert!(panels[0].item(&ItemId("title".into())).unwrap().visible); } #[test] fn resetting_one_geometry_pin_preserves_composition_and_other_pins() { let mut snapshot = Snapshot::parse("// keep\npanel main { style floating; geometry { height 32; side-margins 18; }; corner-radius \"8px 9px 10px 11px\"; end { group status { item clock kind=clock; }; }; }".into()).unwrap(); let groups = snapshot.item("panels.0.end.groups").unwrap().clone(); apply( &mut snapshot, Action::ClearGeometry("panels.0.geometry.side_margins".into()), ); assert_eq!(snapshot.item("panels.0.geometry.side_margins"), None); assert_eq!( snapshot.item("panels.0.geometry.height"), Some(&serde_json::json!(32)) ); assert!(snapshot.item("panels.0.corner_radius").is_some()); apply(&mut snapshot, Action::ResetShape); assert_eq!(snapshot.item("panels.0.geometry"), None); assert_eq!(snapshot.item("panels.0.corner_radius"), None); assert_eq!(snapshot.item("panels.0.end.groups"), Some(&groups)); assert!(snapshot.source.contains("// keep")); } #[test] fn panel_presets_preserve_group_layout_items_and_comments() { let mut snapshot = Snapshot::parse("// keep\npanel main { style floating; center { group title { padding 2 7; item title kind=focused-window; }; }; end { group media { item media kind=media; }; }; }".into()).unwrap(); let groups = snapshot.item("panels.0.center.groups").unwrap().clone(); apply( &mut snapshot, Action::ApplyPreset(ferese_panel::PanelStyle::Flat), ); assert_eq!(snapshot.string("panels.0.style", ""), "flat"); assert_eq!(snapshot.item("panels.0.center.groups"), Some(&groups)); assert!(snapshot.source.contains("// keep")); apply( &mut snapshot, Action::ApplyPreset(ferese_panel::PanelStyle::Floating), ); assert_eq!(snapshot.item("panels.0.center.groups"), Some(&groups)); let panels: Vec = serde_json::from_value(snapshot.item("panels").unwrap().clone()).unwrap(); assert_eq!(panels[0].preset(), Some(ferese_panel::PanelStyle::Floating)); for surface in ["none", "inset", "island"] { assert!( plan( &snapshot, Action::SetGroup(GroupId("title".into()), "surface".into(), surface.into()) ) .is_err() ); } } }