diff --git a/crates/coldvox-text-injection/src/focus.rs b/crates/coldvox-text-injection/src/focus.rs index 9283640b..d3fdebce 100644 --- a/crates/coldvox-text-injection/src/focus.rs +++ b/crates/coldvox-text-injection/src/focus.rs @@ -87,15 +87,106 @@ impl FocusProvider for FocusTracker { #[derive(Default, Clone)] pub struct SystemFocusAdapter; +#[cfg(not(feature = "atspi"))] #[async_trait] impl FocusBackend for SystemFocusAdapter { async fn query_focus(&self) -> Result { - // Temporarily disabled due to AT-SPI API changes - // TODO(#38): Update to work with current atspi crate API + // Stub implementation when AT-SPI is not enabled. Ok(FocusStatus::Unknown) } } +#[cfg(feature = "atspi")] +#[async_trait] +impl FocusBackend for SystemFocusAdapter { + async fn query_focus(&self) -> Result { + use crate::log_throttle::log_atspi_connection_failure; + use atspi::{ + connection::AccessibilityConnection, proxy::accessible::AccessibleProxy, + proxy::collection::CollectionProxy, Interface, MatchType, ObjectMatchRule, SortOrder, + State, + }; + + let conn = match AccessibilityConnection::new().await { + Ok(c) => c, + Err(e) => { + log_atspi_connection_failure(&e.to_string()); + return Ok(FocusStatus::Unknown); + } + }; + + let zbus_conn = conn.connection(); + + let collection = match CollectionProxy::builder(zbus_conn) + .destination("org.a11y.atspi.Registry") + .map_err(|e| InjectionError::Other(format!("Collection destination failed: {e}")))? + .path("/org/a11y/atspi/accessible/root") + .map_err(|e| InjectionError::Other(format!("Collection path failed: {e}")))? + .build() + .await + { + Ok(p) => p, + Err(e) => { + debug!("Failed to build CollectionProxy: {}", e); + return Ok(FocusStatus::Unknown); + } + }; + + let mut rule = ObjectMatchRule::default(); + rule.states = State::Focused.into(); + rule.states_mt = MatchType::All; + + let matches = match collection + .get_matches(rule, SortOrder::Canonical, 1, false) + .await + { + Ok(m) => m, + Err(e) => { + debug!("Failed to get matches from CollectionProxy: {}", e); + return Ok(FocusStatus::Unknown); + } + }; + + if let Some(obj_ref) = matches.first() { + let accessible = match AccessibleProxy::builder(zbus_conn) + .destination(obj_ref.name.clone()) + .map_err(|e| { + InjectionError::Other(format!("AccessibleProxy destination failed: {e}")) + })? + .path(obj_ref.path.clone()) + .map_err(|e| InjectionError::Other(format!("AccessibleProxy path failed: {e}")))? + .build() + .await + { + Ok(p) => p, + Err(e) => { + debug!("Failed to build AccessibleProxy: {}", e); + return Ok(FocusStatus::Unknown); + } + }; + + let ifaces = match accessible.get_interfaces().await { + Ok(i) => i, + Err(e) => { + debug!("Failed to get interfaces: {}", e); + return Ok(FocusStatus::Unknown); + } + }; + + if ifaces.contains(Interface::EditableText) { + debug!("Focused element is editable: {:?}", obj_ref.name); + Ok(FocusStatus::EditableText) + } else { + debug!("Focused element is not editable: {:?}", obj_ref.name); + Ok(FocusStatus::NonEditable) + } + } else { + debug!("No focused element found"); + Ok(FocusStatus::Unknown) + } + } +} + #[async_trait] impl FocusBackend for std::sync::Arc where diff --git a/crates/coldvox-text-injection/src/tests/focus_backend_test.rs b/crates/coldvox-text-injection/src/tests/focus_backend_test.rs new file mode 100644 index 00000000..02d553e3 --- /dev/null +++ b/crates/coldvox-text-injection/src/tests/focus_backend_test.rs @@ -0,0 +1,40 @@ + +use crate::focus::{FocusStatus, FocusTracker}; +use crate::tests::test_harness::{TestAppManager, TestEnvironment}; +use crate::types::InjectionConfig; + +#[tokio::test] +#[cfg_attr( + not(feature = "live-hardware-tests"), + ignore = "Skipping live hardware test for focus backend" +)] +async fn test_atspi_focus_backend_identifies_editable_text() { + let env = TestEnvironment::current(); + if !env.can_run_real_tests() { + println!("Skipping focus backend test: No display available."); + return; + } + + // Launch the GTK test application. + let _test_app = TestAppManager::launch_gtk_app() + .expect("Failed to launch GTK test app. Is 'build.rs' configured correctly?"); + + // Give the app a moment to launch and gain focus. + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + // Create a FocusTracker and check the focus status. + let config = InjectionConfig::default(); + let mut focus_tracker = FocusTracker::new(config); + + let status = focus_tracker + .get_focus_status() + .await + .expect("Failed to get focus status"); + + // The GTK test app should have a focused, editable text field. + assert_eq!( + status, + FocusStatus::EditableText, + "Focus backend should have identified the GTK app's focused text field as editable." + ); +} diff --git a/crates/coldvox-text-injection/src/tests/mod.rs b/crates/coldvox-text-injection/src/tests/mod.rs index b576f697..d82968eb 100644 --- a/crates/coldvox-text-injection/src/tests/mod.rs +++ b/crates/coldvox-text-injection/src/tests/mod.rs @@ -1,7 +1,11 @@ //! Test modules for coldvox-text-injection // pub mod real_injection; +pub mod test_harness; pub mod test_utils; pub mod wl_copy_basic_test; pub mod wl_copy_simple_test; pub mod wl_copy_stdin_test; + +#[cfg(all(test, feature = "atspi"))] +mod focus_backend_test;