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