use std::sync::Arc;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use crate::app::LOOKUP_POLL_INTERVAL;
use crate::element::{Element, ElementData, TreeNode};
use crate::error::{Diagnosis, Error, Result};
use crate::locator::Locator;
use crate::provider::Provider;
const DIAG_SURFACE_LIST_LIMIT: usize = 20;
const SHELL_KIND_RAW_KEY: &str = "shell_kind";
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
strum::EnumString,
strum::IntoStaticStr,
)]
#[cfg_attr(test, derive(strum::EnumIter))]
#[strum(serialize_all = "snake_case")]
#[non_exhaustive]
pub enum ShellSurfaceKind {
MenuBar,
StatusItems,
Taskbar,
Panel,
Dock,
Desktop,
Flyout,
Unknown,
}
impl ShellSurfaceKind {
pub const ALL: &'static [ShellSurfaceKind] = &[
ShellSurfaceKind::MenuBar,
ShellSurfaceKind::StatusItems,
ShellSurfaceKind::Taskbar,
ShellSurfaceKind::Panel,
ShellSurfaceKind::Dock,
ShellSurfaceKind::Desktop,
ShellSurfaceKind::Flyout,
ShellSurfaceKind::Unknown,
];
pub fn from_snake_case(s: &str) -> Option<Self> {
s.parse::<ShellSurfaceKind>().ok()
}
pub fn to_snake_case(self) -> &'static str {
self.into()
}
}
impl std::fmt::Display for ShellSurfaceKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_snake_case())
}
}
fn surface_candidates(surfaces: &[ShellSurface]) -> Vec<String> {
let total = surfaces.len();
let mut out: Vec<String> = surfaces
.iter()
.take(DIAG_SURFACE_LIST_LIMIT)
.map(|s| {
let pid = s.pid.map(|p| format!(" (pid={p})")).unwrap_or_default();
format!("{} \"{}\"{pid}", s.kind, s.name)
})
.collect();
if total > DIAG_SURFACE_LIST_LIMIT {
out.push(format!("… (+{} more)", total - DIAG_SURFACE_LIST_LIMIT));
}
out
}
#[allow(
clippy::exhaustive_structs,
reason = "The private `provider` field already forbids construction and \
exhaustive destructuring from other crates, so `#[non_exhaustive]` \
would add nothing and growth is not breaking. Recorded rather than \
left implicit: the lint stays quiet only because of that field, so \
without this the decision reads as never having been made."
)]
pub struct ShellSurface {
pub kind: ShellSurfaceKind,
pub name: String,
pub pid: Option<u32>,
pub data: ElementData,
provider: Arc<dyn Provider>,
}
impl ShellSurface {
pub fn list_with(provider: Arc<dyn Provider>) -> Result<Vec<Self>> {
let entries = provider.list_shell_surfaces()?;
Ok(entries
.into_iter()
.map(|(kind, mut data)| {
data.raw.insert(
SHELL_KIND_RAW_KEY.to_string(),
serde_json::Value::String(kind.to_snake_case().to_string()),
);
let name = data
.name
.clone()
.filter(|n| !n.is_empty())
.unwrap_or_else(|| kind.to_snake_case().to_string());
let pid = data.pid;
Self {
kind,
name,
pid,
data,
provider: Arc::clone(&provider),
}
})
.collect())
}
pub fn by_kind_with(
provider: Arc<dyn Provider>,
kind: ShellSurfaceKind,
timeout: Duration,
) -> Result<Self> {
let selector = format!("shell_surface[kind={kind}]");
let start = Instant::now();
loop {
let mut matched: Vec<Self> = Vec::new();
let mut others: Vec<Self> = Vec::new();
for surface in Self::list_with(Arc::clone(&provider))? {
if surface.kind == kind {
matched.push(surface);
} else {
others.push(surface);
}
}
if matched.len() > 1 {
return Err(Error::selector_not_matched(selector).diagnose(
Diagnosis::new()
.condition(format!("exactly one {kind} shell surface"))
.last_observed(format!(
"{} {kind} surfaces are present; disambiguate with \
ShellSurface::list() and pick by pid",
matched.len()
))
.candidates(surface_candidates(&matched)),
));
}
if let Some(surface) = matched.pop() {
return Ok(surface);
}
if start.elapsed() >= timeout {
return Err(Error::selector_not_matched(selector).diagnose(
Diagnosis::new()
.condition(format!("a {kind} shell surface"))
.last_observed(format!(
"no {kind} surface present; {} other shell surface(s) enumerated",
others.len()
))
.candidates(surface_candidates(&others)),
));
}
std::thread::sleep(LOOKUP_POLL_INTERVAL);
}
}
pub fn locator(&self, selector: &str) -> Locator {
Locator::new(
Arc::clone(&self.provider),
Some(self.data.clone()),
selector,
)
}
pub fn children(&self) -> Result<Vec<Element>> {
let children = self.provider.get_children(Some(&self.data))?;
Ok(children
.into_iter()
.map(|d| Element::new(d, Arc::clone(&self.provider)))
.collect())
}
pub fn tree(&self, max_depth: Option<usize>) -> Result<TreeNode> {
self.as_element().tree(max_depth)
}
pub fn dump(&self, max_depth: Option<usize>) -> Result<String> {
self.as_element().dump(max_depth)
}
pub fn as_element(&self) -> Element {
Element::new(self.data.clone(), Arc::clone(&self.provider))
}
}
impl std::fmt::Display for ShellSurface {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} \"{}\"", self.kind, self.name)
}
}
impl std::fmt::Debug for ShellSurface {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ShellSurface")
.field("kind", &self.kind)
.field("name", &self.name)
.field("pid", &self.pid)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event_provider::Subscription;
use crate::mock::{build_provider, MockProvider, MOCK_SHELL_PID};
use crate::role::Role;
fn surfaces() -> Vec<ShellSurface> {
let provider: Arc<dyn Provider> = build_provider();
ShellSurface::list_with(provider).expect("the mock must list its shell surfaces")
}
struct DuplicateSurfaceProvider {
inner: Arc<MockProvider>,
}
impl DuplicateSurfaceProvider {
fn new() -> Self {
Self {
inner: build_provider(),
}
}
}
impl Provider for DuplicateSurfaceProvider {
fn list_shell_surfaces(&self) -> Result<Vec<(ShellSurfaceKind, ElementData)>> {
let mut all = self.inner.list_shell_surfaces()?;
let dup = all
.iter()
.find(|(k, _)| *k == ShellSurfaceKind::Taskbar)
.cloned()
.expect("the mock fixture must contain a taskbar surface");
all.push(dup);
Ok(all)
}
fn get_children(&self, e: Option<&ElementData>) -> Result<Vec<ElementData>> {
self.inner.get_children(e)
}
fn get_parent(&self, e: &ElementData) -> Result<Option<ElementData>> {
self.inner.get_parent(e)
}
fn list_apps(&self) -> Result<Vec<ElementData>> {
self.inner.list_apps()
}
fn focused_app(&self) -> Result<ElementData> {
self.inner.focused_app()
}
fn press(&self, e: &ElementData) -> Result<()> {
self.inner.press(e)
}
fn focus(&self, e: &ElementData) -> Result<()> {
self.inner.focus(e)
}
fn blur(&self, e: &ElementData) -> Result<()> {
self.inner.blur(e)
}
fn toggle(&self, e: &ElementData) -> Result<()> {
self.inner.toggle(e)
}
fn select(&self, e: &ElementData) -> Result<()> {
self.inner.select(e)
}
fn expand(&self, e: &ElementData) -> Result<()> {
self.inner.expand(e)
}
fn collapse(&self, e: &ElementData) -> Result<()> {
self.inner.collapse(e)
}
fn show_menu(&self, e: &ElementData) -> Result<()> {
self.inner.show_menu(e)
}
fn increment(&self, e: &ElementData) -> Result<()> {
self.inner.increment(e)
}
fn decrement(&self, e: &ElementData) -> Result<()> {
self.inner.decrement(e)
}
fn scroll_into_view(&self, e: &ElementData) -> Result<()> {
self.inner.scroll_into_view(e)
}
fn set_value(&self, e: &ElementData, v: &str) -> Result<()> {
self.inner.set_value(e, v)
}
fn set_numeric_value(&self, e: &ElementData, v: f64) -> Result<()> {
self.inner.set_numeric_value(e, v)
}
fn type_text(&self, e: &ElementData, t: &str) -> Result<()> {
self.inner.type_text(e, t)
}
fn set_text_selection(&self, e: &ElementData, s: u32, end: u32) -> Result<()> {
self.inner.set_text_selection(e, s, end)
}
fn perform_action(&self, e: &ElementData, a: &str) -> Result<()> {
self.inner.perform_action(e, a)
}
fn subscribe(&self, e: &ElementData) -> Result<Subscription> {
self.inner.subscribe(e)
}
}
struct BrokenShellProvider {
inner: Arc<MockProvider>,
}
impl Provider for BrokenShellProvider {
fn list_shell_surfaces(&self) -> Result<Vec<(ShellSurfaceKind, ElementData)>> {
Err(Error::Platform {
code: 55,
message: "shell enumeration failed".to_string(),
})
}
fn get_children(&self, e: Option<&ElementData>) -> Result<Vec<ElementData>> {
self.inner.get_children(e)
}
fn get_parent(&self, e: &ElementData) -> Result<Option<ElementData>> {
self.inner.get_parent(e)
}
fn list_apps(&self) -> Result<Vec<ElementData>> {
self.inner.list_apps()
}
fn focused_app(&self) -> Result<ElementData> {
self.inner.focused_app()
}
fn press(&self, e: &ElementData) -> Result<()> {
self.inner.press(e)
}
fn focus(&self, e: &ElementData) -> Result<()> {
self.inner.focus(e)
}
fn blur(&self, e: &ElementData) -> Result<()> {
self.inner.blur(e)
}
fn toggle(&self, e: &ElementData) -> Result<()> {
self.inner.toggle(e)
}
fn select(&self, e: &ElementData) -> Result<()> {
self.inner.select(e)
}
fn expand(&self, e: &ElementData) -> Result<()> {
self.inner.expand(e)
}
fn collapse(&self, e: &ElementData) -> Result<()> {
self.inner.collapse(e)
}
fn show_menu(&self, e: &ElementData) -> Result<()> {
self.inner.show_menu(e)
}
fn increment(&self, e: &ElementData) -> Result<()> {
self.inner.increment(e)
}
fn decrement(&self, e: &ElementData) -> Result<()> {
self.inner.decrement(e)
}
fn scroll_into_view(&self, e: &ElementData) -> Result<()> {
self.inner.scroll_into_view(e)
}
fn set_value(&self, e: &ElementData, v: &str) -> Result<()> {
self.inner.set_value(e, v)
}
fn set_numeric_value(&self, e: &ElementData, v: f64) -> Result<()> {
self.inner.set_numeric_value(e, v)
}
fn type_text(&self, e: &ElementData, t: &str) -> Result<()> {
self.inner.type_text(e, t)
}
fn set_text_selection(&self, e: &ElementData, s: u32, end: u32) -> Result<()> {
self.inner.set_text_selection(e, s, end)
}
fn perform_action(&self, e: &ElementData, a: &str) -> Result<()> {
self.inner.perform_action(e, a)
}
fn subscribe(&self, e: &ElementData) -> Result<Subscription> {
self.inner.subscribe(e)
}
}
#[test]
fn every_variant_is_in_all() {
use strum::IntoEnumIterator;
let declared: Vec<ShellSurfaceKind> = ShellSurfaceKind::iter().collect();
assert_eq!(
ShellSurfaceKind::ALL,
declared.as_slice(),
"ShellSurfaceKind::ALL must list every variant, in declaration order — \
every advertised kind list (the CLI's --shell help and error text, MCP's \
`shell` enum, both bindings' parse errors) is derived from it"
);
for kind in ShellSurfaceKind::ALL {
let named = match kind {
ShellSurfaceKind::MenuBar => "menu_bar",
ShellSurfaceKind::StatusItems => "status_items",
ShellSurfaceKind::Taskbar => "taskbar",
ShellSurfaceKind::Panel => "panel",
ShellSurfaceKind::Dock => "dock",
ShellSurfaceKind::Desktop => "desktop",
ShellSurfaceKind::Flyout => "flyout",
ShellSurfaceKind::Unknown => "unknown",
};
assert_eq!(kind.to_snake_case(), named);
}
let unique: std::collections::BTreeSet<&str> = ShellSurfaceKind::ALL
.iter()
.map(|k| k.to_snake_case())
.collect();
assert_eq!(unique.len(), ShellSurfaceKind::ALL.len());
}
#[test]
fn all_kinds_roundtrip() {
for &kind in ShellSurfaceKind::ALL {
let s = kind.to_snake_case();
assert_eq!(
ShellSurfaceKind::from_snake_case(s),
Some(kind),
"roundtrip failed for {s}"
);
assert_eq!(format!("{kind}"), s);
}
assert_eq!(
ShellSurfaceKind::StatusItems.to_snake_case(),
"status_items"
);
assert_eq!(ShellSurfaceKind::from_snake_case("not_a_kind"), None);
}
#[test]
fn list_with_returns_the_mock_fixture_surfaces() {
let surfaces = surfaces();
let kinds: Vec<ShellSurfaceKind> = surfaces.iter().map(|s| s.kind).collect();
assert_eq!(
kinds,
vec![ShellSurfaceKind::Taskbar, ShellSurfaceKind::Desktop]
);
assert_eq!(surfaces[0].name, "Taskbar");
assert_eq!(surfaces[0].pid, Some(MOCK_SHELL_PID));
}
#[test]
fn list_with_stamps_the_kind_onto_the_root() {
let surfaces = surfaces();
for surface in &surfaces {
assert_eq!(
surface.data.raw.get(SHELL_KIND_RAW_KEY),
Some(&serde_json::Value::String(
surface.kind.to_snake_case().to_string()
)),
"{surface} must carry its kind in raw"
);
}
}
#[test]
fn list_with_falls_back_to_the_kind_for_an_unnamed_root() {
struct UnnamedRootProvider {
inner: Arc<MockProvider>,
}
impl Provider for UnnamedRootProvider {
fn list_shell_surfaces(&self) -> Result<Vec<(ShellSurfaceKind, ElementData)>> {
let mut all = self.inner.list_shell_surfaces()?;
for (_, data) in all.iter_mut() {
data.name = None;
}
Ok(all)
}
fn get_children(&self, e: Option<&ElementData>) -> Result<Vec<ElementData>> {
self.inner.get_children(e)
}
fn get_parent(&self, e: &ElementData) -> Result<Option<ElementData>> {
self.inner.get_parent(e)
}
fn list_apps(&self) -> Result<Vec<ElementData>> {
self.inner.list_apps()
}
fn focused_app(&self) -> Result<ElementData> {
self.inner.focused_app()
}
fn press(&self, e: &ElementData) -> Result<()> {
self.inner.press(e)
}
fn focus(&self, e: &ElementData) -> Result<()> {
self.inner.focus(e)
}
fn blur(&self, e: &ElementData) -> Result<()> {
self.inner.blur(e)
}
fn toggle(&self, e: &ElementData) -> Result<()> {
self.inner.toggle(e)
}
fn select(&self, e: &ElementData) -> Result<()> {
self.inner.select(e)
}
fn expand(&self, e: &ElementData) -> Result<()> {
self.inner.expand(e)
}
fn collapse(&self, e: &ElementData) -> Result<()> {
self.inner.collapse(e)
}
fn show_menu(&self, e: &ElementData) -> Result<()> {
self.inner.show_menu(e)
}
fn increment(&self, e: &ElementData) -> Result<()> {
self.inner.increment(e)
}
fn decrement(&self, e: &ElementData) -> Result<()> {
self.inner.decrement(e)
}
fn scroll_into_view(&self, e: &ElementData) -> Result<()> {
self.inner.scroll_into_view(e)
}
fn set_value(&self, e: &ElementData, v: &str) -> Result<()> {
self.inner.set_value(e, v)
}
fn set_numeric_value(&self, e: &ElementData, v: f64) -> Result<()> {
self.inner.set_numeric_value(e, v)
}
fn type_text(&self, e: &ElementData, t: &str) -> Result<()> {
self.inner.type_text(e, t)
}
fn set_text_selection(&self, e: &ElementData, s: u32, end: u32) -> Result<()> {
self.inner.set_text_selection(e, s, end)
}
fn perform_action(&self, e: &ElementData, a: &str) -> Result<()> {
self.inner.perform_action(e, a)
}
fn subscribe(&self, e: &ElementData) -> Result<Subscription> {
self.inner.subscribe(e)
}
}
let provider: Arc<dyn Provider> = Arc::new(UnnamedRootProvider {
inner: build_provider(),
});
let surfaces = ShellSurface::list_with(provider).expect("list must succeed");
assert_eq!(surfaces[0].name, "taskbar");
}
#[test]
fn list_with_propagates_enumeration_failures() {
let provider: Arc<dyn Provider> = Arc::new(BrokenShellProvider {
inner: build_provider(),
});
let err = ShellSurface::list_with(provider).expect_err("enumeration failure must surface");
assert!(matches!(err, Error::Platform { code: 55, .. }));
}
#[test]
fn locator_is_rooted_at_the_surface() {
let provider: Arc<dyn Provider> = build_provider();
let taskbar =
ShellSurface::by_kind_with(provider, ShellSurfaceKind::Taskbar, Duration::ZERO)
.expect("the mock must vend a taskbar");
let el = taskbar
.locator("button[name='Show Hidden Icons']")
.element()
.expect("the taskbar's overflow chevron must be reachable from the surface root");
assert_eq!(el.data().role, Role::Button);
assert!(matches!(
taskbar.locator("button[name='Back']").element(),
Err(Error::SelectorNotMatched { .. })
));
}
#[test]
fn surface_tree_and_dump_are_rooted_at_the_surface() {
let surfaces = surfaces();
let node = surfaces[0].tree(None).expect("tree must succeed");
assert_eq!(node.name.as_deref(), Some("Taskbar"));
assert_eq!(node.children.len(), 2);
let dump = surfaces[0].dump(None).expect("dump must succeed");
assert!(
dump.contains("Show Hidden Icons"),
"dump must render the surface subtree: {dump}"
);
}
#[test]
fn children_and_as_element_expose_the_surface_root() {
let surfaces = surfaces();
let el = surfaces[0].as_element();
assert_eq!(el.data().name.as_deref(), Some("Taskbar"));
let children = surfaces[0].children().expect("children must succeed");
let names: Vec<Option<&str>> = children.iter().map(|c| c.data().name.as_deref()).collect();
assert_eq!(names, vec![Some("Show Hidden Icons"), Some("Volume")]);
}
#[test]
fn by_kind_with_resolves_a_unique_surface() {
let provider: Arc<dyn Provider> = build_provider();
let desktop =
ShellSurface::by_kind_with(provider, ShellSurfaceKind::Desktop, Duration::ZERO)
.expect("the mock must vend a desktop surface");
assert_eq!(desktop.kind, ShellSurfaceKind::Desktop);
assert_eq!(desktop.name, "Desktop");
}
#[test]
fn by_kind_with_reports_the_surfaces_that_were_present() {
let provider: Arc<dyn Provider> = build_provider();
let err = ShellSurface::by_kind_with(provider, ShellSurfaceKind::Dock, Duration::ZERO)
.expect_err("the mock has no dock surface");
let Error::SelectorNotMatched { selector, .. } = &err else {
panic!("expected SelectorNotMatched, got: {err:?}");
};
assert_eq!(selector, "shell_surface[kind=dock]");
let diagnosis = err.diagnosis().expect("the terminal failure must diagnose");
assert_eq!(
diagnosis.candidates,
vec![
format!("taskbar \"Taskbar\" (pid={MOCK_SHELL_PID})"),
format!("desktop \"Desktop\" (pid={MOCK_SHELL_PID})"),
]
);
}
#[test]
fn by_kind_with_refuses_ambiguity_immediately() {
let provider: Arc<dyn Provider> = Arc::new(DuplicateSurfaceProvider::new());
let start = Instant::now();
let err = ShellSurface::by_kind_with(
provider,
ShellSurfaceKind::Taskbar,
Duration::from_secs(30),
)
.expect_err("two taskbars must be refused, not first-matched");
assert!(matches!(err, Error::SelectorNotMatched { .. }));
let diagnosis = err.diagnosis().expect("ambiguity must diagnose");
assert_eq!(diagnosis.candidates.len(), 2);
assert!(
diagnosis
.last_observed
.as_deref()
.is_some_and(|s| s.contains("ShellSurface::list()")),
"the diagnosis must say how to disambiguate: {diagnosis:?}"
);
assert!(
start.elapsed() < Duration::from_secs(1),
"ambiguity is terminal, not a retry"
);
}
#[test]
fn by_kind_with_propagates_enumeration_failures_and_fails_fast() {
let provider: Arc<dyn Provider> = Arc::new(BrokenShellProvider {
inner: build_provider(),
});
let start = Instant::now();
let err = ShellSurface::by_kind_with(
provider,
ShellSurfaceKind::Taskbar,
Duration::from_secs(30),
)
.expect_err("a real enumeration error must propagate");
assert!(matches!(err, Error::Platform { code: 55, .. }));
assert!(
start.elapsed() < Duration::from_secs(1),
"a real enumeration error must fail fast, not wait out the timeout"
);
}
}