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::content::SurfaceContent;
12use crate::graph::SurfaceGraph;
13use crate::layout::SizeClass;
14use crate::model::{Role, Surface};
15
16/// Structured outcome of a request (§3.1).
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    /// The aside joined an already-open slot of its kind as a tab (or, for a
25    /// repeat web URL / aside id, focused the existing tab). Asides form one
26    /// region per content kind — lxapp, browser, native — with tabs inside.
27    MergedIntoTabs,
28}
29
30/// Result of opening a surface after reuse/arbitration has resolved its
31/// identity and role. Callers must bind handles to `resolved_surface_id`, not
32/// to the request id: a reused URL aside keeps the original runtime id.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct OpenOutcome {
36    pub decision: Decision,
37    pub resolved_surface_id: crate::model::SurfaceId,
38    pub resolved_role: Role,
39    /// The requested aside must cover the main rather than dock. Compact
40    /// always has this form; physical admission can add it at wider classes.
41    pub overlay: bool,
42}
43
44impl OpenOutcome {
45    fn new(
46        decision: Decision,
47        resolved_surface_id: crate::model::SurfaceId,
48        resolved_role: Role,
49        overlay: bool,
50    ) -> Self {
51        Self {
52            decision,
53            resolved_surface_id,
54            resolved_role,
55            overlay,
56        }
57    }
58}
59
60impl PartialEq<Decision> for OpenOutcome {
61    fn eq(&self, other: &Decision) -> bool {
62        self.decision == *other
63    }
64}
65
66/// Tunable arbitration policy. Defaults are the spec's cross-platform defaults.
67#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct Policy {
70    pub max_asides_expanded: usize,
71    pub max_asides_medium: usize,
72    pub max_asides_compact: usize,
73    /// Physical admission tokens (§3.3): a slot is admitted only when the main
74    /// keeps `main_min_width` and each admitted left/right slot keeps
75    /// `aside_min_width` within the container. Size class is the *ceiling*, not
76    /// a guarantee — a technically-expanded but narrow window admits fewer.
77    pub main_min_width: f64,
78    pub aside_min_width: f64,
79}
80
81impl Default for Policy {
82    fn default() -> Self {
83        Self {
84            // One visible slot per aside kind: lxapp, browser, native.
85            max_asides_expanded: 3,
86            max_asides_medium: 1,
87            // Compact shows one active slot full-screen over the main. This is
88            // a projection limit, not dock capacity.
89            max_asides_compact: 1,
90            main_min_width: 360.0,
91            aside_min_width: 240.0,
92        }
93    }
94}
95
96impl Policy {
97    pub fn max_asides(&self, size_class: SizeClass) -> usize {
98        match size_class {
99            SizeClass::Expanded => self.max_asides_expanded,
100            SizeClass::Medium => self.max_asides_medium,
101            SizeClass::Compact => self.max_asides_compact,
102        }
103    }
104}
105
106/// Run arbitration. Pure: clones the graph, applies the resolved request, and
107/// returns the new graph plus the decision. The result graph is always valid.
108pub fn arbitrate(
109    graph: &SurfaceGraph,
110    request: Surface,
111    policy: &Policy,
112    size_class: SizeClass,
113) -> (SurfaceGraph, OpenOutcome) {
114    let mut next = graph.clone();
115    let request_id = request.id.clone();
116
117    // The first main is the window's stable navigation root. Opening the same
118    // identity with another role must not silently replace that root or leave
119    // the graph without a primary; resolve the request back to its main role.
120    if graph.is_root_main(&request_id) && request.role != Role::Main {
121        next.set_active_main(&request_id);
122        next.set_focus(&request_id);
123        return (
124            next,
125            OpenOutcome::new(Decision::DowngradedRole, request_id, Role::Main, false),
126        );
127    }
128
129    match request.role {
130        // main / float are not bound by the split limit.
131        Role::Main | Role::Float => {
132            let role = request.role;
133            next.insert(request);
134            (
135                next,
136                OpenOutcome::new(Decision::Accepted, request_id, role, false),
137            )
138        }
139        Role::Aside => {
140            let max = policy.max_asides(size_class);
141            let has_main = !next.mains().is_empty();
142
143            // An aside needs a primary. Compact still preserves the aside role:
144            // the skin projects it as a full-screen overlay over that primary.
145            if !has_main {
146                let promoted_id = request.id.clone();
147                next.insert(promote_to_main(request));
148                next.set_active_main(&promoted_id);
149                next.set_focus(&promoted_id);
150                return (
151                    next,
152                    OpenOutcome::new(Decision::DowngradedRole, promoted_id, Role::Main, false),
153                );
154            }
155
156            // Asides group into ONE region (slot) per content kind — lxapp,
157            // browser, native — and multiple contents of a kind live inside
158            // that region as tabs. Opening a second content of an open kind
159            // therefore never consumes extra budget and never evicts anything:
160            // it joins the slot. Over-limit slots are hidden by the plan's
161            // admission, not evicted from the graph.
162            //
163            // Web asides dedupe by URL — reopening a URL focuses the existing
164            // tab instead of adding a duplicate.
165            if let Some(url) = web_url(&request)
166                && let Some(existing) = existing_web_aside_with_url(&next, &request.id, url)
167            {
168                next.set_focus(&existing);
169                return (
170                    next,
171                    OpenOutcome::new(
172                        Decision::MergedIntoTabs,
173                        existing,
174                        Role::Aside,
175                        size_class == SizeClass::Compact,
176                    ),
177                );
178            }
179            // Reopening an existing aside id (an lxapp's appId, the terminal)
180            // focuses its tab.
181            if next
182                .get(&request.id)
183                .is_some_and(|existing| existing.role == Role::Aside)
184            {
185                let id = request.id.clone();
186                next.insert(request);
187                next.set_focus(&id);
188                return (
189                    next,
190                    OpenOutcome::new(
191                        Decision::MergedIntoTabs,
192                        id,
193                        Role::Aside,
194                        size_class == SizeClass::Compact,
195                    ),
196                );
197            }
198
199            let slot = request.content.slot_kind();
200            let open_kinds: std::collections::HashSet<crate::SlotKind> = next
201                .asides()
202                .iter()
203                .map(|s| s.content.slot_kind())
204                .collect();
205            let joins_open_slot = open_kinds.contains(&slot);
206            let id = request.id.clone();
207            next.insert(request);
208            next.set_focus(&id);
209            let decision = if size_class == SizeClass::Compact {
210                Decision::FullScreenFallback
211            } else if joins_open_slot {
212                Decision::MergedIntoTabs
213            } else {
214                Decision::Accepted
215            };
216            (
217                next,
218                OpenOutcome::new(
219                    decision,
220                    id,
221                    Role::Aside,
222                    size_class == SizeClass::Compact || max == 0,
223                ),
224            )
225        }
226    }
227}
228
229fn promote_to_main(mut request: Surface) -> Surface {
230    request.role = Role::Main;
231    request.placement.edge = None;
232    request
233}
234
235/// The web URL of a surface, if it is web content.
236fn web_url(surface: &Surface) -> Option<&str> {
237    match &surface.content {
238        SurfaceContent::Browser {
239            initial_url,
240            reuse_by_url: true,
241        } => Some(initial_url.as_str()),
242        _ => None,
243    }
244}
245
246/// An existing web-content aside serving `url` (other than `exclude_id`).
247fn existing_web_aside_with_url(
248    graph: &SurfaceGraph,
249    exclude_id: &str,
250    url: &str,
251) -> Option<String> {
252    let key = normalize_initial_url(url);
253    graph
254        .surfaces()
255        .iter()
256        .find(|surface| {
257            surface.id != exclude_id
258                && surface.role == Role::Aside
259                && web_url(surface).is_some_and(|candidate| normalize_initial_url(candidate) == key)
260        })
261        .map(|s| s.id.clone())
262}
263
264/// Stable key for a URL aside's initial URL. Navigation never mutates the
265/// graph's stored URL, so reuse remains tied to the initial request. Query and
266/// fragment bytes remain part of the key.
267pub fn normalize_initial_url(raw: &str) -> String {
268    let raw = raw.trim();
269    let (before_fragment, fragment) = raw
270        .split_once('#')
271        .map_or((raw, None), |(head, tail)| (head, Some(tail)));
272    let (before_query, query) = before_fragment
273        .split_once('?')
274        .map_or((before_fragment, None), |(head, tail)| (head, Some(tail)));
275    let Some((scheme, rest)) = before_query.split_once("://") else {
276        return raw.to_string();
277    };
278    let scheme = scheme.to_ascii_lowercase();
279    let (authority, path) = rest
280        .find('/')
281        .map(|index| (&rest[..index], &rest[index..]))
282        .unwrap_or((rest, "/"));
283    let authority = normalize_authority(authority, &scheme);
284    let mut normalized = format!("{scheme}://{authority}{path}");
285    if let Some(query) = query {
286        normalized.push('?');
287        normalized.push_str(query);
288    }
289    if let Some(fragment) = fragment {
290        normalized.push('#');
291        normalized.push_str(fragment);
292    }
293    normalized
294}
295
296fn normalize_authority(authority: &str, scheme: &str) -> String {
297    if let Some(rest) = authority.strip_prefix('[')
298        && let Some((host, suffix)) = rest.split_once(']')
299    {
300        let suffix = suffix
301            .strip_prefix(':')
302            .filter(|port| !is_default_port(scheme, port))
303            .map_or(String::new(), |port| format!(":{port}"));
304        return format!("[{}]{suffix}", host.to_ascii_lowercase());
305    }
306    let (host, port) = authority
307        .rsplit_once(':')
308        .filter(|(_, port)| port.bytes().all(|byte| byte.is_ascii_digit()))
309        .map_or((authority, None), |(host, port)| (host, Some(port)));
310    let suffix = port
311        .filter(|port| !is_default_port(scheme, port))
312        .map_or(String::new(), |port| format!(":{port}"));
313    format!("{}{suffix}", host.to_ascii_lowercase())
314}
315
316fn is_default_port(scheme: &str, port: &str) -> bool {
317    matches!((scheme, port), ("https", "443") | ("http", "80"))
318}