use serde::{Deserialize, Serialize};
use crate::graph::SurfaceGraph;
use crate::layout::SizeClass;
use crate::model::{Role, Surface, SurfaceContent};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Decision {
Accepted,
DowngradedRole,
ReplacedExisting,
FullScreenFallback,
MergedIntoTabs,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenOutcome {
pub decision: Decision,
pub resolved_surface_id: crate::model::SurfaceId,
pub resolved_role: Role,
pub overlay: bool,
}
impl OpenOutcome {
fn new(
decision: Decision,
resolved_surface_id: crate::model::SurfaceId,
resolved_role: Role,
overlay: bool,
) -> Self {
Self {
decision,
resolved_surface_id,
resolved_role,
overlay,
}
}
}
impl PartialEq<Decision> for OpenOutcome {
fn eq(&self, other: &Decision) -> bool {
self.decision == *other
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Policy {
pub max_asides_expanded: usize,
pub max_asides_medium: usize,
pub max_asides_compact: usize,
pub main_min_width: f64,
pub aside_min_width: f64,
}
impl Default for Policy {
fn default() -> Self {
Self {
max_asides_expanded: 3,
max_asides_medium: 1,
max_asides_compact: 1,
main_min_width: 360.0,
aside_min_width: 240.0,
}
}
}
impl Policy {
pub fn max_asides(&self, size_class: SizeClass) -> usize {
match size_class {
SizeClass::Expanded => self.max_asides_expanded,
SizeClass::Medium => self.max_asides_medium,
SizeClass::Compact => self.max_asides_compact,
}
}
}
pub fn arbitrate(
graph: &SurfaceGraph,
request: Surface,
policy: &Policy,
size_class: SizeClass,
) -> (SurfaceGraph, OpenOutcome) {
let mut next = graph.clone();
let request_id = request.id.clone();
match request.role {
Role::Main | Role::Float => {
let role = request.role;
next.insert(request);
(
next,
OpenOutcome::new(Decision::Accepted, request_id, role, false),
)
}
Role::Aside => {
let max = policy.max_asides(size_class);
let has_main = !next.mains().is_empty();
if !has_main {
let promoted_id = request.id.clone();
next.insert(promote_to_main(request));
next.set_active_main(&promoted_id);
next.set_focus(&promoted_id);
return (
next,
OpenOutcome::new(Decision::DowngradedRole, promoted_id, Role::Main, false),
);
}
if let Some(url) = web_url(&request)
&& let Some(existing) = existing_web_aside_with_url(&next, &request.id, url)
{
next.set_focus(&existing);
return (
next,
OpenOutcome::new(
Decision::MergedIntoTabs,
existing,
Role::Aside,
size_class == SizeClass::Compact,
),
);
}
if next
.get(&request.id)
.is_some_and(|existing| existing.role == Role::Aside)
{
let id = request.id.clone();
next.insert(request);
next.set_focus(&id);
return (
next,
OpenOutcome::new(
Decision::MergedIntoTabs,
id,
Role::Aside,
size_class == SizeClass::Compact,
),
);
}
let slot = request.content.slot_kind();
let open_kinds: std::collections::HashSet<crate::model::SlotKind> = next
.asides()
.iter()
.map(|s| s.content.slot_kind())
.collect();
let joins_open_slot = open_kinds.contains(&slot);
let id = request.id.clone();
next.insert(request);
next.set_focus(&id);
let decision = if size_class == SizeClass::Compact {
Decision::FullScreenFallback
} else if joins_open_slot {
Decision::MergedIntoTabs
} else {
Decision::Accepted
};
(
next,
OpenOutcome::new(
decision,
id,
Role::Aside,
size_class == SizeClass::Compact || max == 0,
),
)
}
}
}
fn promote_to_main(mut request: Surface) -> Surface {
request.role = Role::Main;
request.placement.edge = None;
request
}
fn web_url(surface: &Surface) -> Option<&str> {
match &surface.content {
SurfaceContent::Web {
url,
reuse_by_url: true,
} => Some(url.as_str()),
_ => None,
}
}
fn existing_web_aside_with_url(
graph: &SurfaceGraph,
exclude_id: &str,
url: &str,
) -> Option<String> {
let key = normalize_initial_url(url);
graph
.surfaces()
.iter()
.find(|surface| {
surface.id != exclude_id
&& surface.role == Role::Aside
&& web_url(surface).is_some_and(|candidate| normalize_initial_url(candidate) == key)
})
.map(|s| s.id.clone())
}
pub fn normalize_initial_url(raw: &str) -> String {
let raw = raw.trim();
let (before_fragment, fragment) = raw
.split_once('#')
.map_or((raw, None), |(head, tail)| (head, Some(tail)));
let (before_query, query) = before_fragment
.split_once('?')
.map_or((before_fragment, None), |(head, tail)| (head, Some(tail)));
let Some((scheme, rest)) = before_query.split_once("://") else {
return raw.to_string();
};
let scheme = scheme.to_ascii_lowercase();
let (authority, path) = rest
.find('/')
.map(|index| (&rest[..index], &rest[index..]))
.unwrap_or((rest, "/"));
let authority = normalize_authority(authority, &scheme);
let mut normalized = format!("{scheme}://{authority}{path}");
if let Some(query) = query {
normalized.push('?');
normalized.push_str(query);
}
if let Some(fragment) = fragment {
normalized.push('#');
normalized.push_str(fragment);
}
normalized
}
fn normalize_authority(authority: &str, scheme: &str) -> String {
if let Some(rest) = authority.strip_prefix('[')
&& let Some((host, suffix)) = rest.split_once(']')
{
let suffix = suffix
.strip_prefix(':')
.filter(|port| !is_default_port(scheme, port))
.map_or(String::new(), |port| format!(":{port}"));
return format!("[{}]{suffix}", host.to_ascii_lowercase());
}
let (host, port) = authority
.rsplit_once(':')
.filter(|(_, port)| port.bytes().all(|byte| byte.is_ascii_digit()))
.map_or((authority, None), |(host, port)| (host, Some(port)));
let suffix = port
.filter(|port| !is_default_port(scheme, port))
.map_or(String::new(), |port| format!(":{port}"));
format!("{}{suffix}", host.to_ascii_lowercase())
}
fn is_default_port(scheme: &str, port: &str) -> bool {
matches!((scheme, port), ("https", "443") | ("http", "80"))
}