1use serde::{Deserialize, Serialize};
10
11use crate::content::SurfaceContent;
12use crate::graph::SurfaceGraph;
13use crate::layout::SizeClass;
14use crate::model::{Role, Surface};
15
16#[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 MergedIntoTabs,
28}
29
30#[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 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#[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 pub main_min_width: f64,
78 pub aside_min_width: f64,
79}
80
81impl Default for Policy {
82 fn default() -> Self {
83 Self {
84 max_asides_expanded: 3,
86 max_asides_medium: 1,
87 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
106pub 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 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 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 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 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 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
235fn 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
246fn 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
264pub 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}