use std::collections::HashMap;
use blitz_control_protocol::SemanticNode;
pub use crate::app::SurfaceSpec as Surface;
pub fn surfaces() -> &'static [crate::app::SurfaceSpec] {
&profile().surfaces
}
pub const DYNAMIC_DOCUMENT: &str = "\u{0}dynamic-document";
pub fn document_opener(nodes: &[SemanticNode]) -> Option<String> {
let closes: Vec<String> = nodes
.iter()
.filter(|n| n.role == "button" && onscreen(n))
.filter_map(|n| {
profile()
.close_prefixes
.iter()
.find_map(|prefix| n.name.strip_prefix(prefix.as_str()))
.map(str::to_owned)
})
.collect();
let tab = nodes
.iter()
.filter(|n| n.role == "button" && onscreen(n))
.filter(|n| !profile().is_permanent(&n.name))
.find(|n| {
doubled(&n.name).is_some_and(|label| !profile().is_permanent(label))
|| closes
.iter()
.any(|subject| n.name == format!("{subject}{subject}"))
});
if let Some(tab) = tab {
return Some(tab.name.clone());
}
let rows = || {
nodes
.iter()
.filter(|n| n.role == "button" && onscreen(n))
.filter(|n| {
let skip = |prefixes: &Vec<String>| {
prefixes.iter().any(|p| n.name.starts_with(p.as_str()))
};
!skip(&profile().close_prefixes) && !skip(&profile().row_action_prefixes)
})
};
let markers = &profile().document_row_markers;
let populated = markers.first().and_then(|first| {
rows().find(|n| {
n.name
.split(first.as_str())
.next()
.and_then(|head| head.rsplit(')').next())
.and_then(|count| count.trim().rsplit(' ').next())
.and_then(|count| count.parse::<u32>().ok())
.is_some_and(|open| open > 0)
})
});
if let Some(row) = populated {
return Some(row.name.clone());
}
rows()
.find(|n| {
markers
.iter()
.any(|marker| n.name.contains(marker.as_str()))
})
.map(|n| n.name.clone())
}
pub fn onscreen(node: &SemanticNode) -> bool {
node.visible && node.bounds.is_some_and(|b| b[2] > 0.0 && b[3] > 0.0)
}
pub fn reveal_chain(nodes: &[SemanticNode], target: u64) -> Vec<u64> {
let by_id: HashMap<u64, &SemanticNode> = nodes.iter().map(|node| (node.id, node)).collect();
let mut inner_to_outer = Vec::new();
let mut cursor = Some(target);
for _ in 0..32 {
let Some(id) = cursor else { break };
let Some(node) = by_id.get(&id) else { break };
if node.role == "main" {
break;
}
inner_to_outer.push(id);
cursor = node.parent;
}
inner_to_outer.reverse();
inner_to_outer
}
pub fn interactive(node: &SemanticNode) -> bool {
(node.role == "option" && node.visible)
|| matches!(
node.role.as_str(),
"button"
| "checkbox"
| "combobox"
| "link"
| "menuitem"
| "menuitemcheckbox"
| "menuitemradio"
| "radio"
| "slider"
| "spinbutton"
| "switch"
| "tab"
| "textbox"
| "treeitem"
)
}
pub fn navigates(name: &str) -> bool {
if profile().is_permanent(name) {
return true;
}
if profile()
.navigation_controls
.iter()
.any(|control| name.eq_ignore_ascii_case(control))
{
return true;
}
doubled(name).is_some()
}
pub fn opens_document_row(nodes: &[SemanticNode], id: u64) -> bool {
opens_document_row_for(profile(), nodes, id)
}
fn opens_document_row_for(
profile: &crate::app::AppProfile,
nodes: &[SemanticNode],
id: u64,
) -> bool {
let Some(candidate) = nodes
.iter()
.find(|node| node.id == id && node.role == "button" && onscreen(node))
else {
return false;
};
profile.row_action_prefixes.iter().any(|prefix| {
nodes.iter().any(|action| {
action.id != candidate.id
&& action.role == "button"
&& onscreen(action)
&& action
.name
.strip_prefix(prefix.as_str())
.is_some_and(|subject| subject == candidate.name)
})
})
}
fn doubled(name: &str) -> Option<&str> {
if name.is_empty() || !name.len().is_multiple_of(2) {
return None;
}
let (left, right) = name.split_at(name.len() / 2);
(left == right && !left.trim().is_empty()).then_some(left)
}
pub fn on_surface(nodes: &[SemanticNode], surface: &Surface) -> bool {
let Some(marker) = surface.marker.as_deref() else {
return true;
};
nodes.iter().any(|n| onscreen(n) && n.name.contains(marker))
}
pub fn on_surface_subtree(nodes: &[SemanticNode], surface: &Surface) -> Vec<u64> {
let Some(marker) = surface.marker.as_deref() else {
return nodes.iter().map(|n| n.id).collect();
};
let by_id: HashMap<u64, &SemanticNode> = nodes.iter().map(|n| (n.id, n)).collect();
let Some(anchor) = nodes
.iter()
.find(|n| onscreen(n) && n.name.contains(marker))
else {
return Vec::new();
};
let onscreen_total = nodes
.iter()
.filter(|n| n.role == "button" && onscreen(n))
.count();
let mut children: HashMap<u64, Vec<u64>> = HashMap::new();
for node in nodes {
if let Some(parent) = node.parent {
children.entry(parent).or_default().push(node.id);
}
}
let subtree_of = |root: u64| -> Vec<u64> {
let mut out = Vec::new();
let mut stack = vec![root];
while let Some(id) = stack.pop() {
out.push(id);
if let Some(kids) = children.get(&id) {
stack.extend(kids.iter().copied());
}
}
out
};
let mut cursor = anchor.id;
let mut best: Vec<u64> = Vec::new();
let mut best_covered = 0usize;
for _ in 0..12 {
let Some(parent) = by_id.get(&cursor).and_then(|n| n.parent) else {
break;
};
cursor = parent;
let kept = subtree_of(cursor);
let covered = kept
.iter()
.filter(|id| {
by_id
.get(id)
.is_some_and(|n| n.role == "button" && onscreen(n))
})
.count();
if onscreen_total > 0 && covered >= onscreen_total {
break;
}
if covered > best_covered {
best_covered = covered;
best = kept;
}
}
best
}
pub fn requires_manual_release_check(name: &str) -> bool {
profile()
.manual_controls
.iter()
.any(|exception| name.starts_with(exception.label.as_str()))
}
pub fn modal_open(nodes: &[SemanticNode]) -> bool {
nodes.iter().any(|node| {
onscreen(node)
&& (matches!(node.role.as_str(), "dialog" | "alertdialog")
|| (node.role == "button" && profile().dismisses_dialog(&node.name)))
})
}
pub fn dismissers(nodes: &[SemanticNode]) -> Vec<(u64, String)> {
let mut found: Vec<(u64, String)> = nodes
.iter()
.filter(|n| n.role == "button" && onscreen(n))
.filter(|n| profile().dismisses_dialog(&n.name))
.map(|n| (n.id, n.name.clone()))
.collect();
found.sort_by_key(|(_, name)| {
profile()
.dismiss_controls
.iter()
.position(|control| name.eq_ignore_ascii_case(control))
.unwrap_or(usize::MAX)
});
found
}
pub fn closes_a_surface(name: &str) -> bool {
profile()
.close_prefixes
.iter()
.any(|prefix| name.starts_with(prefix.as_str()))
}
pub fn is_inert_control(name: &str) -> bool {
profile()
.inert_controls
.iter()
.any(|prefix| name.starts_with(prefix.as_str()))
}
pub fn requires_isolated_outcome(name: &str) -> bool {
profile()
.isolated_controls
.iter()
.any(|control| name.eq_ignore_ascii_case(control))
}
pub fn enclosing_dialog(nodes: &[SemanticNode], dismiss_id: u64) -> Vec<u64> {
let by_id: HashMap<u64, &SemanticNode> = nodes.iter().map(|n| (n.id, n)).collect();
let mut children: HashMap<u64, Vec<u64>> = HashMap::new();
for node in nodes {
if let Some(parent) = node.parent {
children.entry(parent).or_default().push(node.id);
}
}
let subtree_of = |root: u64| -> Vec<u64> {
let mut out = Vec::new();
let mut stack = vec![root];
while let Some(id) = stack.pop() {
out.push(id);
if let Some(kids) = children.get(&id) {
stack.extend(kids.iter().copied());
}
}
out
};
let mut cursor = dismiss_id;
let mut best = vec![dismiss_id];
for _ in 0..8 {
let Some(parent) = by_id.get(&cursor).and_then(|n| n.parent) else {
break;
};
cursor = parent;
let kept = subtree_of(cursor);
let buttons = kept
.iter()
.filter(|id| by_id.get(id).is_some_and(|n| n.role == "button"))
.count();
if buttons > 12 {
break;
}
best = kept;
}
best
}
pub fn folds_a_section(name: &str) -> bool {
profile().folds_a_section(name)
}
pub fn profile() -> &'static crate::app::AppProfile {
static PROFILE: std::sync::OnceLock<crate::app::AppProfile> = std::sync::OnceLock::new();
PROFILE.get_or_init(|| match crate::app::AppProfile::load(None) {
Ok(profile) => profile,
#[cfg(test)]
Err(_) => crate::app::AppProfile::default(),
#[cfg(not(test))]
Err(error) => panic!("no application profile: {error}"),
})
}
pub fn expanders(nodes: &[SemanticNode]) -> Vec<(u64, String)> {
nodes
.iter()
.filter(|node| node.role == "button" && onscreen(node))
.filter(|node| node.name.to_lowercase().starts_with("expand "))
.map(|node| (node.id, node.name.clone()))
.collect()
}
pub fn hover_row_ids(nodes: &[SemanticNode], row_role: &str, window: (f64, f64)) -> Vec<u64> {
nodes
.iter()
.filter(|node| node.role == row_role && onscreen(node))
.filter_map(|node| {
let b = node.bounds?;
let (x, y) = (b[0] + b[2] / 2.0, b[1] + b[3] / 2.0);
(y >= window.0 && y <= window.1 && x >= 0.0).then_some(node.id)
})
.collect()
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Coverage {
pub in_tree: usize,
pub swept: usize,
pub outcome_declared: usize,
pub unreachable: usize,
pub hidden: usize,
pub vanished: usize,
pub navigation: usize,
pub manual: usize,
pub isolated: usize,
pub blocked: usize,
pub revealed: usize,
}
impl Coverage {
pub fn accounted(&self) -> bool {
self.bucketed() == self.total()
}
pub fn total(&self) -> usize {
self.in_tree + self.revealed
}
fn bucketed(&self) -> usize {
self.swept
+ self.outcome_declared
+ self.unreachable
+ self.hidden
+ self.vanished
+ self.navigation
+ self.manual
+ self.isolated
+ self.blocked
+ self.revealed
}
pub fn line(&self) -> String {
format!(
"{} buttons{}: {} swept, {} outcome-declared, {} unreachable, {} hidden, {} vanished, {} nav, {} manual, {} isolated, {} blocked{}",
self.total(),
if self.revealed > 0 {
format!(" ({} on open, {} revealed)", self.in_tree, self.revealed)
} else {
String::new()
},
self.swept,
self.outcome_declared,
self.unreachable,
self.hidden,
self.vanished,
self.navigation,
self.manual,
self.isolated,
self.blocked,
if self.accounted() {
String::new()
} else {
format!(
" (UNACCOUNTED {})",
self.total() as i64 - self.bucketed() as i64
)
}
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn node(id: u64, role: &str, name: &str, bounds: Option<[f64; 4]>) -> SemanticNode {
SemanticNode {
id,
parent: None,
role: role.to_owned(),
name: name.to_owned(),
value: None,
enabled: true,
visible: true,
selected: false,
bounds,
slot: None,
}
}
#[test]
fn a_zero_box_control_is_not_onscreen() {
assert!(!onscreen(&node(1, "button", "Row action", Some([0.0; 4]))));
assert!(onscreen(&node(
2,
"button",
"Row action",
Some([10.0, 10.0, 20.0, 20.0])
)));
}
#[test]
fn reveal_walks_nested_containers_from_outer_to_inner() {
let mut root = node(1, "main", "", Some([0.0, 0.0, 200.0, 200.0]));
root.parent = None;
let mut panel = node(2, "group", "", Some([0.0, 0.0, 200.0, 400.0]));
panel.parent = Some(1);
let mut list = node(3, "list", "", Some([0.0, 0.0, 200.0, 600.0]));
list.parent = Some(2);
let mut button = node(4, "button", "More", Some([0.0, 560.0, 80.0, 20.0]));
button.parent = Some(3);
assert_eq!(reveal_chain(&[root, panel, list, button], 4), vec![2, 3, 4]);
}
#[test]
fn component_inventory_is_not_button_only() {
for role in [
"button", "checkbox", "combobox", "link", "menuitem", "radio", "slider", "switch",
"tab", "textbox", "treeitem",
] {
assert!(interactive(&node(
1,
role,
"Named",
Some([0.0, 0.0, 20.0, 20.0])
)));
}
assert!(!interactive(&node(
2,
"heading",
"Not interactive",
Some([0.0, 0.0, 20.0, 20.0])
)));
assert!(interactive(&node(
3,
"option",
"Choice",
Some([0.0, 0.0, 20.0, 20.0])
)));
let mut hidden_option = node(4, "option", "Retained choice", Some([0.0, 0.0, 20.0, 20.0]));
hidden_option.visible = false;
assert!(!interactive(&hidden_option));
}
#[test]
fn only_onscreen_expanders_are_offered() {
let nodes = vec![
node(1, "button", "Expand Records", Some([0.0, 0.0, 20.0, 20.0])),
node(2, "button", "Expand Hidden", Some([0.0; 4])),
node(
3,
"button",
"Collapse Running",
Some([0.0, 0.0, 20.0, 20.0]),
),
];
let found = expanders(&nodes);
assert_eq!(found.len(), 1);
assert_eq!(found[0].1, "Expand Records");
}
#[test]
fn navigation_is_recognised_without_swallowing_its_neighbours() {
assert!(navigates("ee"));
assert!(navigates("delta/east/cobaltdelta/east/cobalt"));
assert!(!navigates("Close Dashboard"));
assert!(!navigates("Import data"));
assert!(!navigates("Rename document"));
assert!(!navigates("Send"));
assert!(!navigates("Copy"));
}
#[test]
fn a_row_opener_is_derived_from_profile_owned_action_prefixes() {
let profile = crate::app::AppProfile {
row_action_prefixes: vec!["Rename ".to_owned()],
..Default::default()
};
let nodes = vec![
node(1, "button", "Report", Some([0.0, 0.0, 80.0, 20.0])),
node(2, "button", "Rename Report", Some([90.0, 0.0, 20.0, 20.0])),
];
assert!(opens_document_row_for(&profile, &nodes, 1));
assert!(!opens_document_row_for(&profile, &nodes, 2));
}
#[test]
fn a_permanent_tab_navigates_when_the_profile_names_it() {
let profile = crate::app::AppProfile {
permanent_surfaces: vec!["Dashboard".to_owned()],
..Default::default()
};
assert!(profile.is_permanent("Dashboard"));
assert!(profile.is_permanent("DashboardDashboard"));
assert!(!profile.is_permanent("Close Dashboard"));
}
#[test]
fn only_application_documented_controls_are_exempt() {
let profile = crate::app::AppProfile {
manual_controls: vec![
crate::app::ManualControl {
label: "Import data".to_owned(),
command: "open_native_picker".to_owned(),
},
crate::app::ManualControl {
label: "Open http".to_owned(),
command: "openExternal".to_owned(),
},
],
..Default::default()
};
let exempt = |name: &str| {
profile
.manual_controls
.iter()
.any(|e| name.starts_with(e.label.as_str()))
};
assert!(exempt("Import data"));
assert!(exempt("Open https://example.invalid/pull/1"));
assert!(!exempt("Send"));
assert!(!exempt("Cancel"));
assert!(!exempt("Create record"));
assert!(
profile
.manual_controls
.iter()
.all(|e| !e.command.is_empty())
);
assert!(crate::app::AppProfile::default().manual_controls.is_empty());
}
#[test]
fn a_modal_is_recognised_and_offers_its_dismissers() {
let dialog = vec![node(1, "dialog", "Setup", Some([0.0, 0.0, 200.0, 200.0]))];
assert!(modal_open(&dialog));
let ordinary = vec![node(1, "button", "Send", Some([0.0, 0.0, 20.0, 20.0]))];
assert!(!modal_open(&ordinary));
}
#[test]
fn surface_tabs_are_not_closed_out_from_under_the_sweep() {
let profile = crate::app::AppProfile {
close_prefixes: vec!["Dismiss ".to_owned()],
..Default::default()
};
let closes = |name: &str| {
profile
.close_prefixes
.iter()
.any(|prefix| name.starts_with(prefix.as_str()))
};
assert!(closes("Dismiss Preferences"));
assert!(closes("Dismiss some/document/name"));
assert!(!closes("Collapse Recent"));
assert!(!closes("Rename thing"));
let bare = crate::app::AppProfile::default();
assert!(bare.close_prefixes.is_empty());
}
#[test]
fn rows_off_the_top_of_a_transcript_are_not_hovered() {
let nodes = vec![
node(
1,
"listitem",
"visible row",
Some([10.0, 100.0, 200.0, 40.0]),
),
node(
2,
"listitem",
"scrolled off",
Some([10.0, -9726.0, 200.0, 40.0]),
),
node(
3,
"listitem",
"below the fold",
Some([10.0, 5000.0, 200.0, 40.0]),
),
];
let ids = hover_row_ids(&nodes, "listitem", (0.0, 900.0));
assert_eq!(ids, vec![1]);
}
#[test]
fn coverage_reports_an_unaccounted_gap() {
let full = Coverage {
in_tree: 11,
swept: 3,
outcome_declared: 0,
unreachable: 2,
hidden: 1,
vanished: 1,
navigation: 1,
manual: 1,
isolated: 1,
blocked: 1,
revealed: 0,
};
assert!(full.accounted());
assert!(!full.line().contains("UNACCOUNTED"));
let declared = Coverage {
in_tree: 2,
outcome_declared: 2,
..Default::default()
};
assert!(declared.accounted());
assert!(declared.line().contains("2 outcome-declared"));
let leaky = Coverage {
in_tree: 10,
swept: 6,
..Default::default()
};
assert!(!leaky.accounted());
assert!(leaky.line().contains("UNACCOUNTED 4"));
}
#[test]
fn a_dialogs_own_controls_do_not_overflow_the_total() {
let with_a_dialog = Coverage {
in_tree: 10,
swept: 10,
revealed: 4,
..Default::default()
};
assert!(
with_a_dialog.accounted(),
"revealed controls extend the total: {}",
with_a_dialog.line()
);
assert_eq!(with_a_dialog.total(), 14);
assert!(with_a_dialog.line().contains("(10 on open, 4 revealed)"));
}
#[test]
fn a_surplus_cannot_hide_a_real_gap() {
let both = Coverage {
in_tree: 10,
swept: 6,
revealed: 4,
..Default::default()
};
assert!(!both.accounted());
assert!(both.line().contains("UNACCOUNTED 4"), "{}", both.line());
}
}