mod arbitrate;
mod graph;
mod layout;
mod manager;
mod model;
pub use arbitrate::{Decision, OpenOutcome, Policy, arbitrate, normalize_initial_url};
pub use graph::SurfaceGraph;
pub use layout::PlanAsideSlot;
pub use layout::{
Axis, BottomOwner, DEFAULT_HYSTERESIS, DerivedLayout, LayoutPresentationPlan, LayoutTree,
PlanAside, PlanFloat, SizeClass, SplitForm, SwitcherForm,
};
pub use manager::SurfaceManager;
pub use model::{
Edge, FloatAnchor, FloatDismiss, FloatSpec, Placement, Role, SlotKind, Surface, SurfaceContent,
SurfaceId, SurfaceInteraction, SurfaceOwner, SurfaceState,
};
#[cfg(test)]
mod tests {
use super::*;
fn main_s(id: &str) -> Surface {
Surface::entry(id, Role::Main, id)
}
fn aside_s(id: &str, edge: Edge) -> Surface {
let mut s = Surface::entry(id, Role::Aside, id);
s.placement.edge = Some(edge);
s
}
fn web_aside_s(id: &str, url: &str, edge: Edge) -> Surface {
let mut s = aside_s(id, edge);
s.content = SurfaceContent::Web {
url: url.to_string(),
reuse_by_url: true,
};
s
}
fn non_reusable_web_aside_s(id: &str, url: &str, edge: Edge) -> Surface {
let mut surface = web_aside_s(id, url, edge);
if let SurfaceContent::Web { reuse_by_url, .. } = &mut surface.content {
*reuse_by_url = false;
}
surface
}
fn terminal_aside_s(id: &str, edge: Edge) -> Surface {
let mut s = Surface::entry(id, Role::Aside, "terminal");
s.placement.edge = Some(edge);
s
}
#[test]
fn empty_graph_is_valid() {
let g = SurfaceGraph::new();
assert!(g.is_valid());
assert_eq!(g.active_main_id, None);
assert_eq!(g.focused_surface_id, None);
}
#[test]
fn first_main_becomes_active_and_focused() {
let mut g = SurfaceGraph::new();
g.insert(main_s("home"));
assert_eq!(g.active_main_id.as_deref(), Some("home"));
assert_eq!(g.focused_surface_id.as_deref(), Some("home"));
assert!(g.is_valid());
}
#[test]
fn aside_requires_a_main_invariant() {
let mut g = SurfaceGraph::new();
g.insert(main_s("home"));
g.insert(aside_s("assistant", Edge::Right));
assert!(g.is_valid());
let removed = g.remove("home");
assert!(removed.contains(&"assistant".to_string()));
assert!(g.asides().is_empty());
assert_eq!(g.active_main_id, None);
assert!(g.is_valid());
}
#[test]
fn closing_active_main_picks_adjacent_successor() {
let mut g = SurfaceGraph::new();
g.insert(main_s("a"));
g.insert(main_s("b"));
g.insert(main_s("c"));
g.set_active_main("b");
g.remove("b");
assert_eq!(g.active_main_id.as_deref(), Some("c"));
assert!(g.is_valid());
}
#[test]
fn modal_float_restores_focus_on_close() {
let mut g = SurfaceGraph::new();
g.insert(main_s("home"));
assert_eq!(g.focused_surface_id.as_deref(), Some("home"));
let mut modal = Surface::entry("dialog", Role::Float, "confirm");
modal.float = Some(FloatSpec {
modal: true,
..Default::default()
});
g.insert(modal);
g.set_focus("dialog");
g.remove("dialog");
assert_eq!(g.focused_surface_id.as_deref(), Some("home"));
assert!(g.is_valid());
}
#[test]
fn single_main_no_switcher_no_split() {
let mut g = SurfaceGraph::new();
g.insert(main_s("home"));
let d = g.derive_layout(SizeClass::Expanded);
assert_eq!(d.switcher_form, SwitcherForm::None);
assert_eq!(d.split_form, SplitForm::None);
assert!(matches!(d.layout_tree, Some(LayoutTree::Leaf { .. })));
}
#[test]
fn switcher_only_with_multiple_mains() {
let mut g = SurfaceGraph::new();
g.insert(main_s("a"));
assert_eq!(
g.derive_layout(SizeClass::Expanded).switcher_form,
SwitcherForm::None
);
g.insert(main_s("b"));
assert_eq!(
g.derive_layout(SizeClass::Expanded).switcher_form,
SwitcherForm::Sidebar
);
}
#[test]
fn aside_splits_on_expanded_fullscreen_on_compact() {
let mut g = SurfaceGraph::new();
g.insert(main_s("home"));
g.insert(aside_s("assistant", Edge::Right));
assert_eq!(
g.derive_layout(SizeClass::Expanded).split_form,
SplitForm::Split
);
assert_eq!(
g.derive_layout(SizeClass::Compact).split_form,
SplitForm::FullScreen
);
}
#[test]
fn compact_plan_keeps_existing_aside_desired() {
let mut g = SurfaceGraph::new();
g.insert(main_s("home"));
g.insert(aside_s("assistant", Edge::Right));
let plan = g.presentation_plan(
SizeClass::Compact,
390.0,
&crate::arbitrate::Policy::default(),
);
assert_eq!(plan.split_form, SplitForm::FullScreen);
assert!(plan.asides.iter().any(|aside| aside.id == "assistant"));
assert!(
plan.tree
.as_ref()
.is_some_and(|tree| tree.surface_ids().iter().any(|id| id == "assistant"))
);
}
#[test]
fn compact_bottom_owner_stays_app() {
let mut g = SurfaceGraph::new();
g.insert(main_s("a"));
assert_eq!(
g.derive_layout(SizeClass::Compact).bottom_owner,
BottomOwner::App
);
g.insert(main_s("b"));
assert_eq!(
g.derive_layout(SizeClass::Compact).bottom_owner,
BottomOwner::App
);
}
#[test]
fn canonical_layout_validates() {
let mut g = SurfaceGraph::new();
g.insert(main_s("a"));
g.insert(main_s("b"));
g.insert(aside_s("assistant", Edge::Right));
let tree = g.canonical_layout(SizeClass::Expanded).unwrap();
tree.validate().expect("canonical tree must be valid");
g.insert(Surface::entry("toast", Role::Float, "toast"));
let ids = g
.canonical_layout(SizeClass::Expanded)
.unwrap()
.surface_ids();
assert!(!ids.contains(&"toast".to_string()));
}
#[test]
fn breakpoints_align_to_material() {
assert_eq!(SizeClass::from_width(599.0), SizeClass::Compact);
assert_eq!(SizeClass::from_width(600.0), SizeClass::Medium);
assert_eq!(SizeClass::from_width(840.0), SizeClass::Medium);
assert_eq!(SizeClass::from_width(841.0), SizeClass::Expanded);
}
#[test]
fn hysteresis_holds_class_near_boundary() {
let held = SizeClass::resolve(Some(SizeClass::Medium), 590.0, DEFAULT_HYSTERESIS);
assert_eq!(held, SizeClass::Medium);
let switched = SizeClass::resolve(Some(SizeClass::Medium), 500.0, DEFAULT_HYSTERESIS);
assert_eq!(switched, SizeClass::Compact);
}
#[test]
fn hysteresis_does_not_hold_across_two_classes() {
assert_eq!(
SizeClass::resolve(Some(SizeClass::Expanded), 590.0, DEFAULT_HYSTERESIS),
SizeClass::Compact
);
assert_eq!(
SizeClass::resolve(Some(SizeClass::Compact), 850.0, DEFAULT_HYSTERESIS),
SizeClass::Expanded
);
}
#[test]
fn same_kind_asides_join_their_slot() {
let mut g = SurfaceGraph::new();
g.insert(main_s("home"));
g.insert(aside_s("a1", Edge::Right));
let (next, decision) = arbitrate(
&g,
aside_s("a2", Edge::Right),
&Policy::default(),
SizeClass::Expanded,
);
assert_eq!(decision, Decision::MergedIntoTabs);
assert!(next.get("a1").is_some());
assert!(next.get("a2").is_some());
let slots = next.aside_slots(SizeClass::Expanded);
assert_eq!(slots.len(), 1);
assert_eq!(slots[0].kind, SlotKind::Lxapp);
assert_eq!(slots[0].children, vec!["a1".to_string(), "a2".to_string()]);
assert_eq!(slots[0].active_child.as_deref(), Some("a2"));
assert!(slots[0].visible);
assert!(next.is_valid());
}
#[test]
fn slots_hide_beyond_admission_never_evict() {
let mut next = SurfaceGraph::new();
next.insert(main_s("home"));
for request in [
aside_s("chat", Edge::Right),
terminal_aside_s("terminal", Edge::Bottom),
web_aside_s("b1", "https://a.example", Edge::Right),
] {
let (n, d) = arbitrate(&next, request, &Policy::default(), SizeClass::Expanded);
assert_eq!(d, Decision::Accepted);
next = n;
}
let expanded = next.aside_slots(SizeClass::Expanded);
assert_eq!(expanded.len(), 3);
assert!(expanded.iter().all(|slot| slot.visible));
let medium = next.aside_slots(SizeClass::Medium);
assert_eq!(medium.iter().filter(|slot| slot.visible).count(), 1);
assert!(
medium
.iter()
.find(|slot| slot.visible)
.is_some_and(|slot| slot.kind == SlotKind::Browser)
);
assert_eq!(next.asides().len(), 3);
assert!(next.is_valid());
}
#[test]
fn physical_admission_caps_below_the_count_ceiling() {
let mut next = SurfaceGraph::new();
next.insert(main_s("home"));
for request in [
aside_s("chat", Edge::Right),
web_aside_s("b1", "https://a.example", Edge::Right),
terminal_aside_s("terminal", Edge::Bottom),
] {
let (n, _) = arbitrate(&next, request, &Policy::default(), SizeClass::Expanded);
next = n;
}
let policy = Policy::default();
let wide = next.aside_slots_admitted(SizeClass::Expanded, 1200.0, &policy);
assert_eq!(wide.iter().filter(|s| s.visible).count(), 3);
let narrow = next.aside_slots_admitted(SizeClass::Expanded, 700.0, &policy);
let visible_right = narrow
.iter()
.filter(|s| s.visible && !matches!(s.edge, Some(Edge::Bottom)))
.count();
assert_eq!(visible_right, 1, "only one right slot fits at 700pt");
assert!(
narrow
.iter()
.any(|s| s.visible && matches!(s.edge, Some(Edge::Bottom)))
);
assert_eq!(next.asides().len(), 3);
}
#[test]
fn slot_admission_uses_policy_and_true_focus_recency() {
let mut graph = SurfaceGraph::new();
graph.insert(main_s("home"));
graph.insert(aside_s("chat", Edge::Right));
graph.insert(web_aside_s("browser", "https://example.com", Edge::Right));
let medium = graph.aside_slots(SizeClass::Medium);
assert_eq!(
medium
.iter()
.find(|slot| slot.visible)
.map(|slot| slot.kind),
Some(SlotKind::Browser)
);
assert!(graph.set_focus("chat"));
let medium = graph.aside_slots(SizeClass::Medium);
assert_eq!(
medium
.iter()
.find(|slot| slot.visible)
.map(|slot| slot.kind),
Some(SlotKind::Lxapp)
);
let policy = Policy {
max_asides_expanded: 1,
..Policy::default()
};
let expanded = graph.aside_slots_admitted(SizeClass::Expanded, 1200.0, &policy);
assert_eq!(expanded.iter().filter(|slot| slot.visible).count(), 1);
assert_eq!(
expanded
.iter()
.find(|slot| slot.visible)
.map(|slot| slot.kind),
Some(SlotKind::Lxapp)
);
}
#[test]
fn physical_admission_keeps_the_most_recent_horizontal_slot() {
let mut graph = SurfaceGraph::new();
graph.insert(main_s("home"));
graph.insert(aside_s("chat", Edge::Right));
graph.insert(web_aside_s("browser", "https://example.com", Edge::Right));
graph.insert(terminal_aside_s("terminal", Edge::Right));
let admitted = graph.aside_slots_admitted(SizeClass::Expanded, 700.0, &Policy::default());
let visible: Vec<_> = admitted
.iter()
.filter(|slot| slot.visible)
.map(|slot| slot.kind)
.collect();
assert_eq!(visible, vec![SlotKind::Native]);
}
#[test]
fn physical_admission_falls_back_to_an_older_fitting_slot() {
let mut graph = SurfaceGraph::new();
graph.insert(main_s("home"));
let mut terminal = terminal_aside_s("terminal", Edge::Top);
terminal.placement.edge = Some(Edge::Top);
graph.insert(terminal);
graph.insert(aside_s("chat", Edge::Right));
let admitted = graph.aside_slots_admitted(SizeClass::Medium, 500.0, &Policy::default());
let visible: Vec<_> = admitted
.iter()
.filter(|slot| slot.visible)
.map(|slot| slot.kind)
.collect();
assert_eq!(visible, vec![SlotKind::Native]);
}
#[test]
fn web_asides_coexist_as_tabs() {
let mut g = SurfaceGraph::new();
g.insert(main_s("home"));
g.insert(web_aside_s("browser-1", "https://a.example", Edge::Right));
let (next, decision) = arbitrate(
&g,
web_aside_s("browser-2", "https://b.example", Edge::Right),
&Policy::default(),
SizeClass::Expanded,
);
assert_eq!(decision, Decision::MergedIntoTabs);
assert_eq!(decision.resolved_surface_id, "browser-2");
assert!(next.get("browser-1").is_some());
assert!(next.get("browser-2").is_some());
assert_eq!(
next.asides()
.iter()
.filter(|s| matches!(s.content, SurfaceContent::Web { .. }))
.count(),
2
);
assert!(next.is_valid());
}
#[test]
fn hiding_active_aside_selects_recent_visible_sibling() {
let mut graph = SurfaceGraph::new();
graph.insert(main_s("home"));
graph.insert(aside_s("first", Edge::Right));
graph.insert(aside_s("second", Edge::Right));
graph.set_focus("first");
graph.set_focus("second");
assert!(graph.hide("second"));
assert_eq!(graph.focused_surface_id.as_deref(), Some("first"));
assert_eq!(
graph.get("second").map(|surface| surface.state),
Some(SurfaceState::Hidden)
);
let slots = graph.aside_slots(SizeClass::Expanded);
assert_eq!(slots[0].active_child.as_deref(), Some("first"));
assert!(graph.show("second"));
assert_eq!(graph.focused_surface_id.as_deref(), Some("second"));
assert_eq!(
graph.get("second").map(|surface| surface.state),
Some(SurfaceState::Mounted)
);
}
#[test]
fn closing_active_aside_selects_recent_visible_sibling() {
let mut graph = SurfaceGraph::new();
graph.insert(main_s("home"));
graph.insert(web_aside_s("first", "https://one.example", Edge::Right));
graph.insert(web_aside_s("second", "https://two.example", Edge::Right));
graph.set_focus("first");
graph.set_focus("second");
assert_eq!(graph.remove("second"), vec!["second"]);
assert_eq!(graph.focused_surface_id.as_deref(), Some("first"));
assert_eq!(
graph.aside_slots(SizeClass::Expanded)[0]
.active_child
.as_deref(),
Some("first")
);
}
#[test]
fn web_aside_dedups_by_url() {
let mut g = SurfaceGraph::new();
g.insert(main_s("home"));
g.insert(web_aside_s("browser-1", "https://a.example", Edge::Right));
let (next, decision) = arbitrate(
&g,
web_aside_s("browser-2", "https://a.example", Edge::Right),
&Policy::default(),
SizeClass::Expanded,
);
assert_eq!(decision, Decision::MergedIntoTabs);
assert_eq!(decision.resolved_surface_id, "browser-1");
assert_eq!(decision.resolved_role, Role::Aside);
assert!(next.get("browser-1").is_some());
assert!(next.get("browser-2").is_none());
assert_eq!(
next.asides()
.iter()
.filter(|s| matches!(s.content, SurfaceContent::Web { .. }))
.count(),
1
);
assert!(next.is_valid());
}
#[test]
fn non_reusable_web_asides_never_dedup_by_url() {
let mut graph = SurfaceGraph::new();
graph.insert(main_s("home"));
graph.insert(web_aside_s("ordinary", "https://a.example", Edge::Right));
let (graph, callback_outcome) = arbitrate(
&graph,
non_reusable_web_aside_s("callback", "https://a.example", Edge::Right),
&Policy::default(),
SizeClass::Expanded,
);
assert_eq!(callback_outcome.resolved_surface_id, "callback");
assert!(graph.get("ordinary").is_some());
assert!(graph.get("callback").is_some());
let (graph, ordinary_outcome) = arbitrate(
&graph,
web_aside_s("ordinary-2", "https://a.example", Edge::Right),
&Policy::default(),
SizeClass::Expanded,
);
assert_eq!(ordinary_outcome.resolved_surface_id, "ordinary");
assert!(graph.get("ordinary-2").is_none());
assert!(graph.get("callback").is_some());
}
#[test]
fn web_aside_url_key_normalizes_origin_and_empty_path_only() {
assert_eq!(
normalize_initial_url("HTTPS://Example.COM:443"),
"https://example.com/"
);
assert_eq!(
normalize_initial_url("https://example.com/?q=One#Top"),
"https://example.com/?q=One#Top"
);
assert_ne!(
normalize_initial_url("https://example.com/?q=One#Top"),
normalize_initial_url("https://example.com/?q=one#Top")
);
let mut graph = SurfaceGraph::new();
graph.insert(main_s("home"));
let (graph, _) = arbitrate(
&graph,
web_aside_s("browser-1", "HTTPS://Example.COM:443", Edge::Right),
&Policy::default(),
SizeClass::Expanded,
);
let (graph, outcome) = arbitrate(
&graph,
web_aside_s("browser-2", "https://example.com/", Edge::Right),
&Policy::default(),
SizeClass::Expanded,
);
assert_eq!(outcome.resolved_surface_id, "browser-1");
assert!(graph.get("browser-2").is_none());
}
#[test]
fn browser_asides_never_evict_a_declared_aside() {
let mut next = SurfaceGraph::new();
next.insert(main_s("home"));
next.insert(aside_s("chat", Edge::Right)); for (i, url) in [
"https://a.example",
"https://b.example",
"https://c.example",
]
.iter()
.enumerate()
{
let (n, d) = arbitrate(
&next,
web_aside_s(&format!("b{i}"), url, Edge::Right),
&Policy::default(),
SizeClass::Expanded,
);
let expected = if i == 0 {
Decision::Accepted
} else {
Decision::MergedIntoTabs
};
assert_eq!(d, expected);
next = n;
}
assert!(next.get("chat").is_some(), "declared aside must survive");
assert_eq!(
next.asides()
.iter()
.filter(|s| matches!(s.content, SurfaceContent::Web { .. }))
.count(),
3
);
assert!(next.is_valid());
}
#[test]
fn web_aside_coexists_with_a_page_aside() {
let mut g = SurfaceGraph::new();
g.insert(main_s("home"));
g.insert(aside_s("assistant", Edge::Right));
let (next, decision) = arbitrate(
&g,
web_aside_s("browser-1", "https://a.example", Edge::Left),
&Policy::default(),
SizeClass::Expanded,
);
assert_eq!(decision, Decision::Accepted);
assert!(next.get("assistant").is_some());
assert!(next.get("browser-1").is_some());
assert_eq!(next.asides().len(), 2);
assert!(next.is_valid());
}
#[test]
fn aside_fullscreen_fallback_on_compact() {
let mut g = SurfaceGraph::new();
g.insert(main_s("home"));
let (next, decision) = arbitrate(
&g,
aside_s("assistant", Edge::Right),
&Policy::default(),
SizeClass::Compact,
);
assert_eq!(decision, Decision::FullScreenFallback);
assert!(decision.overlay);
assert_eq!(next.role_of("assistant"), Some(Role::Aside));
assert_eq!(next.active_main_id.as_deref(), Some("home"));
assert_eq!(
next.presentation_plan(
SizeClass::Compact,
390.0,
&crate::arbitrate::Policy::default()
)
.active_main_id
.as_deref(),
Some("home")
);
assert!(next.is_valid());
}
#[test]
fn aside_without_primary_promotes_to_main() {
let g = SurfaceGraph::new();
let (next, decision) = arbitrate(
&g,
aside_s("assistant", Edge::Right),
&Policy::default(),
SizeClass::Expanded,
);
assert_eq!(decision, Decision::DowngradedRole);
assert_eq!(next.role_of("assistant"), Some(Role::Main));
assert!(next.is_valid());
}
#[test]
fn arbitrate_is_pure_and_keeps_graph_valid() {
let mut g = SurfaceGraph::new();
g.insert(main_s("home"));
let before = g.surfaces().len();
let (next, _) = arbitrate(
&g,
aside_s("x", Edge::Left),
&Policy::default(),
SizeClass::Expanded,
);
assert_eq!(g.surfaces().len(), before);
assert!(next.is_valid());
}
#[test]
fn surface_json_round_trip() {
let s = aside_s("assistant", Edge::Right);
let json = serde_json::to_string(&s).unwrap();
let back: Surface = serde_json::from_str(&json).unwrap();
assert_eq!(s, back);
}
#[test]
fn web_surface_json_preserves_reuse_policy_and_legacy_default() {
let ordinary = web_aside_s("ordinary", "https://a.example", Edge::Right);
let ordinary_json = serde_json::to_string(&ordinary).unwrap();
assert!(!ordinary_json.contains("reuse_by_url"));
assert_eq!(
serde_json::from_str::<Surface>(&ordinary_json).unwrap(),
ordinary
);
let callback = non_reusable_web_aside_s("callback", "https://a.example", Edge::Right);
let callback_json = serde_json::to_string(&callback).unwrap();
assert!(callback_json.contains("\"reuse_by_url\":false"));
assert_eq!(
serde_json::from_str::<Surface>(&callback_json).unwrap(),
callback
);
}
}