Skip to main content

lingxia_surface/
arbitrate.rs

1//! Host arbitration (§3.4): a deterministic, **infallible** pure function that
2//! decides how an open-request lands in the graph and always leaves it valid.
3//! `(graph, request, policy) -> (graph', decision)`.
4//!
5//! Rejection (caps / permissions) is a *separate* host-policy gate applied
6//! before this core runs (§3.1); the pure layout core never rejects — it
7//! resolves by degrading.
8
9use serde::{Deserialize, Serialize};
10
11use crate::graph::SurfaceGraph;
12use crate::layout::SizeClass;
13use crate::model::{Edge, Role, Surface, SurfaceContent};
14
15/// Structured outcome of a request (§3.1). A subset of the spec's full set;
16/// `mergedIntoTabs` lands when tab-grouping is implemented.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub enum Decision {
20    Accepted,
21    DowngradedRole,
22    ReplacedExisting,
23    FullScreenFallback,
24}
25
26/// Tunable arbitration policy. Defaults are the spec's cross-platform defaults.
27#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct Policy {
30    pub max_asides_expanded: usize,
31    pub max_asides_medium: usize,
32    pub max_asides_compact: usize,
33}
34
35impl Default for Policy {
36    fn default() -> Self {
37        Self {
38            max_asides_expanded: 2,
39            max_asides_medium: 1,
40            max_asides_compact: 0,
41        }
42    }
43}
44
45impl Policy {
46    pub fn max_asides(&self, size_class: SizeClass) -> usize {
47        match size_class {
48            SizeClass::Expanded => self.max_asides_expanded,
49            SizeClass::Medium => self.max_asides_medium,
50            SizeClass::Compact => self.max_asides_compact,
51        }
52    }
53}
54
55/// Run arbitration. Pure: clones the graph, applies the resolved request, and
56/// returns the new graph plus the decision. The result graph is always valid.
57pub fn arbitrate(
58    graph: &SurfaceGraph,
59    request: Surface,
60    policy: &Policy,
61    size_class: SizeClass,
62) -> (SurfaceGraph, Decision) {
63    let mut next = graph.clone();
64
65    match request.role {
66        // main / float are not bound by the split limit.
67        Role::Main | Role::Float => {
68            next.insert(request);
69            (next, Decision::Accepted)
70        }
71        Role::Aside => {
72            let max = policy.max_asides(size_class);
73            let has_main = !next.mains().is_empty();
74
75            // Can't be an aside without a primary, and no side-by-side room in
76            // compact (max==0): in both cases promote to a main.
77            if !has_main || max == 0 {
78                let decision = if max == 0 {
79                    Decision::FullScreenFallback
80                } else {
81                    Decision::DowngradedRole
82                };
83                let promoted_id = request.id.clone();
84                next.insert(promote_to_main(request));
85                next.set_active_main(&promoted_id);
86                next.set_focus(&promoted_id);
87                return (next, decision);
88            }
89
90            // A web-content (in-app browser) aside is a per-window singleton: a
91            // new browser aside replaces any existing one, independent of the
92            // generic aside cap. The browser aside is a single companion pane,
93            // not an unbounded set.
94            if is_web(&request)
95                && let Some(victim) = existing_web_aside_id(&next, &request.id)
96            {
97                next.remove(&victim);
98                next.insert(request);
99                return (next, Decision::ReplacedExisting);
100            }
101
102            // Under the limit: accept as-is.
103            if next.asides().len() < max {
104                next.insert(request);
105                return (next, Decision::Accepted);
106            }
107
108            // Over the limit: replace the oldest aside (preferring same edge).
109            if let Some(victim) = oldest_aside_id(&next, request.placement.edge) {
110                next.remove(&victim);
111                next.insert(request);
112                (next, Decision::ReplacedExisting)
113            } else {
114                next.insert(promote_to_main(request));
115                (next, Decision::DowngradedRole)
116            }
117        }
118    }
119}
120
121fn promote_to_main(mut request: Surface) -> Surface {
122    request.role = Role::Main;
123    request.placement.edge = None;
124    request
125}
126
127fn is_web(surface: &Surface) -> bool {
128    matches!(surface.content, SurfaceContent::Web { .. })
129}
130
131/// The existing web-content aside (if any) other than `exclude_id`.
132fn existing_web_aside_id(graph: &SurfaceGraph, exclude_id: &str) -> Option<String> {
133    graph
134        .surfaces()
135        .iter()
136        .find(|s| s.id != exclude_id && s.role == Role::Aside && is_web(s))
137        .map(|s| s.id.clone())
138}
139
140/// Oldest (insertion-order-first) aside, preferring the same edge if given.
141fn oldest_aside_id(graph: &SurfaceGraph, edge: Option<Edge>) -> Option<String> {
142    if let Some(edge) = edge
143        && let Some(s) = graph
144            .surfaces()
145            .iter()
146            .find(|s| s.role == Role::Aside && s.placement.edge == Some(edge))
147    {
148        return Some(s.id.clone());
149    }
150    graph
151        .surfaces()
152        .iter()
153        .find(|s| s.role == Role::Aside)
154        .map(|s| s.id.clone())
155}