1use serde::{Deserialize, Serialize};
10
11use crate::graph::SurfaceGraph;
12use crate::layout::SizeClass;
13use crate::model::{Role, Surface, SurfaceContent};
14
15#[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 MergedIntoTabs,
27}
28
29#[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 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#[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 pub main_min_width: f64,
77 pub aside_min_width: f64,
78}
79
80impl Default for Policy {
81 fn default() -> Self {
82 Self {
83 max_asides_expanded: 3,
85 max_asides_medium: 1,
86 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
105pub 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 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 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 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 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
222fn 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
233fn 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
251pub 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}