Files
feresaria/shell/ferese-settings/src/panel_edit.rs
T

1237 lines
47 KiB
Rust

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<ItemId>),
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<String, String> {
let (zone, index) = locate_group(panel, id)?;
Ok(format!("panels.0.{}.groups.{index}", zone.key()))
}
pub(super) fn destinations(panel: &Panel) -> Vec<Destination> {
[
("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<String, String> {
// 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<Value> {
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<Vec<Edit>, 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<Panel> = 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::<usize>();
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::<usize>();
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<Panel> =
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<String> {
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::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
apply(&mut snapshot, Action::SetMorphAttached(true));
let attached =
serde_json::from_value::<Vec<Panel>>(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::<Vec<Panel>>(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::<Vec<Panel>>(snapshot.item("panels").unwrap().clone()).unwrap();
apply(&mut snapshot, Action::SetPeekOnHover(false));
let after =
serde_json::from_value::<Vec<Panel>>(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::<Vec<Panel>>(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<Panel> = {
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<Panel> =
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<Panel> =
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<Panel> = 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<Panel> =
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<Panel> =
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<Panel> =
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<Panel> =
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<Panel> =
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<Panel> =
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()
);
}
}
}