/ src / watch.rs
watch.rs
  1  //! Shared watch infrastructure for monitoring AT-SPI events.
  2  //!
  3  //! This module provides a unified system for watching accessibility objects and
  4  //! receiving notifications when events occur on those objects or their children.
  5  
  6  use std::{
  7      sync::{Arc, OnceLock},
  8      time::Instant,
  9  };
 10  
 11  use atspi::{
 12      AccessibilityConnection, State, events::Event, events::object::Property,
 13      object_ref::ObjectRefOwned, proxy::accessible::ObjectRefExt,
 14  };
 15  use chrono::Local;
 16  use futures::StreamExt;
 17  use tokio::{
 18      sync::{Mutex, mpsc},
 19      task::{self, JoinHandle},
 20  };
 21  
 22  use crate::{error::Result, path::strip_atspi_prefix};
 23  
 24  /// Capacity for broadcast channels (required by `tokio::sync::broadcast`).
 25  /// Unbounded mpsc channels are used elsewhere; broadcast requires a fixed capacity.
 26  /// If receivers lag behind by more than this many events, they receive a Lagged error.
 27  pub const BROADCAST_CHANNEL_CAPACITY: usize = 256;
 28  
 29  /// Global watchlist
 30  static WATCHLIST: OnceLock<Arc<Mutex<Vec<WatchHandle>>>> = OnceLock::new();
 31  
 32  /// Get the global watchlist, initializing it if necessary
 33  fn get_watchlist() -> &'static Arc<Mutex<Vec<WatchHandle>>> {
 34      WATCHLIST.get_or_init(Default::default)
 35  }
 36  
 37  /// A handle representing an active watch on an accessibility object.
 38  #[derive(Clone)]
 39  struct WatchHandle {
 40      /// The object reference for the watched object.
 41      object_ref: ObjectRefOwned,
 42      /// Cached reference path for display (e.g., "firefox-1.74")
 43      reference: String,
 44      /// Timestamp of the last event received for this watch.
 45      last_event: Arc<tokio::sync::Mutex<Option<Instant>>>,
 46  }
 47  
 48  impl WatchHandle {
 49      /// Check if an event object matches this watch or is a child of it.
 50      ///
 51      /// IMPORTANT: This function makes D-Bus calls and must NOT be called from
 52      /// the event collector task. Only call from the matcher task.
 53      async fn matches(
 54          &self,
 55          event_obj: &ObjectRefOwned,
 56          connection: &AccessibilityConnection,
 57      ) -> Result<bool> {
 58          // Exact match
 59          if event_obj == &self.object_ref {
 60              return Ok(true);
 61          }
 62  
 63          // Optimization: if watching an app root, any event from the same bus matches.
 64          if self.object_ref.path_as_str() == "/org/a11y/atspi/accessible/root"
 65              && event_obj.name_as_str() == self.object_ref.name_as_str()
 66          {
 67              return Ok(true);
 68          }
 69  
 70          // For events from a different app, walk up the parent chain to check ancestry.
 71          let mut current = event_obj.clone();
 72  
 73          loop {
 74              let proxy = current
 75                  .into_accessible_proxy(connection.connection())
 76                  .await?;
 77              let parent = proxy.parent().await?;
 78  
 79              // Check if we've reached the root or a null object
 80              if parent.name_as_str().unwrap_or("").is_empty()
 81                  && parent.path_as_str() == "/org/a11y/atspi/null"
 82              {
 83                  // Reached null, no match found
 84                  return Ok(false);
 85              }
 86  
 87              // Check if the parent matches the watched object
 88              if parent == self.object_ref {
 89                  return Ok(true);
 90              }
 91  
 92              // Continue up the tree
 93              current = parent;
 94          }
 95      }
 96  }
 97  
 98  /// Start background tasks that monitor AT-SPI events and call the handler
 99  /// when events match watches in the watchlist.
100  ///
101  /// This uses a two-task architecture to avoid D-Bus deadlocks:
102  /// 1. **Event collector**: Reads from `event_stream()`, forwards to channel (never blocks on D-Bus)
103  /// 2. **Matcher**: Reads from channel, does parent traversal, calls handler
104  ///    (safe to do D-Bus calls)
105  ///
106  /// # Arguments
107  /// * `connection` - The AT-SPI connection
108  /// * `handler` - Closure called with `(event, reference_path)` when an event matches
109  ///
110  /// # Returns
111  /// A join handle for the matcher task (the one that calls the handler)
112  ///
113  /// Also calls `defunct_handler` when an object becomes defunct.
114  /// Use this to clean up resources (e.g., inode mappings).
115  pub fn spawn_watch_task_with_defunct<F, D>(
116      connection: &AccessibilityConnection,
117      handler: F,
118      defunct_handler: D,
119  ) -> JoinHandle<()>
120  where
121      F: Fn(&Event, &str) + Send + Sync + 'static,
122      D: Fn(&ObjectRefOwned) + Send + Sync + 'static,
123  {
124      let (tx, rx) = mpsc::unbounded_channel::<Event>();
125  
126      // Spawn the event collector task
127      spawn_event_collector(connection, tx);
128  
129      // Spawn and return the matcher task
130      spawn_matcher_task(connection, rx, handler, defunct_handler)
131  }
132  
133  /// Event collector task: reads from `event_stream` and forwards to channel.
134  ///
135  /// This task NEVER makes D-Bus method calls to avoid deadlocking the event stream.
136  fn spawn_event_collector(
137      connection: &AccessibilityConnection,
138      tx: mpsc::UnboundedSender<Event>,
139  ) -> JoinHandle<()> {
140      let connection = connection.clone();
141      task::spawn(async move {
142          let mut events = connection.event_stream();
143  
144          tracing::debug!("Event collector started");
145  
146          while let Some(event) = events.next().await {
147              let Ok(event) = event else { continue };
148  
149              // Send to matcher task (unbounded, so only fails if receiver dropped)
150              if tx.send(event).is_err() {
151                  tracing::debug!("Matcher task gone, stopping collector");
152                  break;
153              }
154          }
155  
156          tracing::debug!("Event collector ended");
157      })
158  }
159  
160  /// Matcher task: reads events from channel and does matching.
161  ///
162  /// This task is safe to make D-Bus calls because it's decoupled from the event stream.
163  fn spawn_matcher_task<F, D>(
164      connection: &AccessibilityConnection,
165      mut rx: mpsc::UnboundedReceiver<Event>,
166      handler: F,
167      defunct_handler: D,
168  ) -> JoinHandle<()>
169  where
170      F: Fn(&Event, &str) + Send + Sync + 'static,
171      D: Fn(&ObjectRefOwned) + Send + Sync + 'static,
172  {
173      let watchlist = get_watchlist().clone();
174      let connection = connection.clone();
175      task::spawn(async move {
176          tracing::debug!("Matcher task started");
177  
178          while let Some(event) = rx.recv().await {
179              // Handle defunct state changes
180              if let Event::Object(atspi::ObjectEvents::StateChanged(ref ev)) = event
181                  && ev.state == State::Defunct
182              {
183                  remove_defunct_watch(&ev.item, &connection).await;
184                  defunct_handler(&ev.item);
185              }
186  
187              // Extract the object reference from the event
188              let event_obj = event_item(&event);
189  
190              let watches = watchlist.lock().await.clone();
191  
192              // Skip if no watches
193              if watches.is_empty() {
194                  continue;
195              }
196  
197              // Find the first watch that matches this event
198              for handle in &watches {
199                  // Ok(false): no match, continue to next watch
200                  // Err(_): errors during parent traversal are normal for transient objects
201                  if let Ok(true) = handle.matches(event_obj, &connection).await {
202                      handler(&event, &handle.reference);
203                      *handle.last_event.lock().await = Some(Instant::now());
204                      break;
205                  }
206              }
207          }
208  
209          tracing::debug!("Matcher task ended");
210      })
211  }
212  
213  /// Add a watch using a pre-computed reference string.
214  ///
215  /// Use this when the reference is already known (e.g., from FUSE path traversal)
216  /// to avoid D-Bus calls.
217  pub async fn add_watch_with_ref(obj_ref: ObjectRefOwned, reference: String) {
218      let handle = WatchHandle {
219          object_ref: obj_ref,
220          reference,
221          last_event: Arc::new(tokio::sync::Mutex::new(None)),
222      };
223      get_watchlist().lock().await.push(handle);
224  }
225  
226  /// Remove a watch by object reference.
227  ///
228  /// Use this when you have the object reference but not a proxy.
229  pub async fn remove_watch_by_ref(obj_ref: &ObjectRefOwned) -> Option<String> {
230      let mut guard = get_watchlist().lock().await;
231      if let Some(pos) = guard.iter().position(|h| &h.object_ref == obj_ref) {
232          let handle = guard.remove(pos);
233          Some(handle.reference)
234      } else {
235          None
236      }
237  }
238  
239  /// Remove a watch by its index in the watchlist.
240  ///
241  /// Returns the removed watch handle if the index was valid.
242  async fn remove_watch_by_index(index: usize) -> Option<WatchHandle> {
243      let mut guard = get_watchlist().lock().await;
244      if index < guard.len() {
245          Some(guard.remove(index))
246      } else {
247          None
248      }
249  }
250  
251  /// Remove a watch for a defunct object.
252  async fn remove_defunct_watch(defunct_obj: &ObjectRefOwned, connection: &AccessibilityConnection) {
253      let index = {
254          let guard = get_watchlist().lock().await;
255          guard
256              .iter()
257              .position(|handle| &handle.object_ref == defunct_obj)
258      };
259  
260      if let Some(index) = index
261          && let Some(handle) = remove_watch_by_index(index).await
262      {
263          // Try to create a proxy to get the reference path for logging
264          if let Ok(proxy) = handle
265              .object_ref
266              .into_accessible_proxy(connection.connection())
267              .await
268              && let Ok(ref_path) = crate::path::generate_reference(&proxy).await
269          {
270              tracing::error!("Removing defunct watch: {}", ref_path);
271          }
272      }
273  }
274  
275  /// Extract the `ObjectRef` from an event.
276  #[allow(clippy::too_many_lines)]
277  fn event_item(event: &Event) -> &ObjectRefOwned {
278      match event {
279          Event::Object(obj) => {
280              use atspi::ObjectEvents;
281              match obj {
282                  ObjectEvents::PropertyChange(e) => &e.item,
283                  ObjectEvents::BoundsChanged(e) => &e.item,
284                  ObjectEvents::LinkSelected(e) => &e.item,
285                  ObjectEvents::StateChanged(e) => &e.item,
286                  ObjectEvents::ChildrenChanged(e) => &e.item,
287                  ObjectEvents::VisibleDataChanged(e) => &e.item,
288                  ObjectEvents::SelectionChanged(e) => &e.item,
289                  ObjectEvents::ModelChanged(e) => &e.item,
290                  ObjectEvents::ActiveDescendantChanged(e) => &e.item,
291                  ObjectEvents::Announcement(e) => &e.item,
292                  ObjectEvents::AttributesChanged(e) => &e.item,
293                  ObjectEvents::RowInserted(e) => &e.item,
294                  ObjectEvents::RowReordered(e) => &e.item,
295                  ObjectEvents::RowDeleted(e) => &e.item,
296                  ObjectEvents::ColumnInserted(e) => &e.item,
297                  ObjectEvents::ColumnReordered(e) => &e.item,
298                  ObjectEvents::ColumnDeleted(e) => &e.item,
299                  ObjectEvents::TextBoundsChanged(e) => &e.item,
300                  ObjectEvents::TextSelectionChanged(e) => &e.item,
301                  ObjectEvents::TextChanged(e) => &e.item,
302                  ObjectEvents::TextAttributesChanged(e) => &e.item,
303                  ObjectEvents::TextCaretMoved(e) => &e.item,
304              }
305          }
306          Event::Focus(focus) => {
307              use atspi::FocusEvents;
308              match focus {
309                  FocusEvents::Focus(e) => &e.item,
310              }
311          }
312          Event::Window(win) => {
313              use atspi::WindowEvents;
314              match win {
315                  WindowEvents::PropertyChange(e) => &e.item,
316                  WindowEvents::Activate(e) => &e.item,
317                  WindowEvents::Close(e) => &e.item,
318                  WindowEvents::Create(e) => &e.item,
319                  WindowEvents::Deactivate(e) => &e.item,
320                  WindowEvents::DesktopCreate(e) => &e.item,
321                  WindowEvents::DesktopDestroy(e) => &e.item,
322                  WindowEvents::Destroy(e) => &e.item,
323                  WindowEvents::Lower(e) => &e.item,
324                  WindowEvents::Maximize(e) => &e.item,
325                  WindowEvents::Minimize(e) => &e.item,
326                  WindowEvents::Move(e) => &e.item,
327                  WindowEvents::Raise(e) => &e.item,
328                  WindowEvents::Reparent(e) => &e.item,
329                  WindowEvents::Resize(e) => &e.item,
330                  WindowEvents::Restore(e) => &e.item,
331                  WindowEvents::Restyle(e) => &e.item,
332                  WindowEvents::Shade(e) => &e.item,
333                  WindowEvents::UUshade(e) => &e.item,
334              }
335          }
336          Event::Document(doc) => {
337              use atspi::DocumentEvents;
338              match doc {
339                  DocumentEvents::LoadComplete(e) => &e.item,
340                  DocumentEvents::Reload(e) => &e.item,
341                  DocumentEvents::LoadStopped(e) => &e.item,
342                  DocumentEvents::ContentChanged(e) => &e.item,
343                  DocumentEvents::AttributesChanged(e) => &e.item,
344                  DocumentEvents::PageChanged(e) => &e.item,
345              }
346          }
347          Event::Terminal(term) => {
348              use atspi::TerminalEvents;
349              match term {
350                  TerminalEvents::LineChanged(e) => &e.item,
351                  TerminalEvents::ColumnCountChanged(e) => &e.item,
352                  TerminalEvents::LineCountChanged(e) => &e.item,
353                  TerminalEvents::ApplicationChanged(e) => &e.item,
354                  TerminalEvents::CharWidthChanged(e) => &e.item,
355              }
356          }
357          Event::Mouse(mouse) => {
358              use atspi::MouseEvents;
359              match mouse {
360                  MouseEvents::Abs(e) => &e.item,
361                  MouseEvents::Rel(e) => &e.item,
362                  MouseEvents::Button(e) => &e.item,
363              }
364          }
365          Event::Keyboard(kb) => {
366              use atspi::KeyboardEvents;
367              match kb {
368                  KeyboardEvents::Modifiers(e) => &e.item,
369              }
370          }
371          Event::Cache(cache) => {
372              use atspi::CacheEvents;
373              match cache {
374                  CacheEvents::Add(e) => &e.item,
375                  CacheEvents::LegacyAdd(e) => &e.item,
376                  CacheEvents::Remove(e) => &e.item,
377              }
378          }
379          _ => unreachable!("Unhandled event type - update event_item(): {event:?}"),
380      }
381  }
382  
383  /// Canonical event representation used for both JSON serialization and display.
384  ///
385  /// This type provides a unified representation of AT-SPI events that can be:
386  /// - Serialized to JSON for FUSE filesystem streaming
387  /// - Displayed in human-readable format via the `Display` trait
388  #[derive(serde::Serialize)]
389  pub struct EventInfo {
390      pub timestamp: String,
391      pub event_type: String,
392      pub source: String,
393      #[serde(skip_serializing_if = "Option::is_none")]
394      pub details: Option<serde_json::Value>,
395  }
396  
397  impl From<&Event> for EventInfo {
398      /// Create an `EventInfo` from an AT-SPI event.
399      #[allow(clippy::too_many_lines)]
400      fn from(event: &Event) -> Self {
401          let timestamp = Local::now().format("%Y-%m-%dT%H:%M:%S%.3f").to_string();
402          let item = event_item(event);
403          let source = format!(
404              "{}:{}",
405              item.name_as_str().unwrap_or(""),
406              strip_atspi_prefix(item.path_as_str())
407          );
408  
409          let (event_type, details) = match event {
410              Event::Object(obj_event) => {
411                  use atspi::ObjectEvents;
412                  match obj_event {
413                      ObjectEvents::StateChanged(e) => (
414                          "StateChanged".to_string(),
415                          Some(serde_json::json!({
416                              "state": e.state.to_string(),
417                              "enabled": e.enabled
418                          })),
419                      ),
420                      ObjectEvents::TextChanged(e) => (
421                          "TextChanged".to_string(),
422                          Some(serde_json::json!({
423                              "operation": format!("{:?}", e.operation),
424                              "start_pos": e.start_pos,
425                              "length": e.length,
426                              "text": e.text
427                          })),
428                      ),
429                      ObjectEvents::PropertyChange(e) => {
430                          let value = match &e.value {
431                              Property::Name(s) => serde_json::json!({"type": "name", "value": s}),
432                              Property::Description(s) => {
433                                  serde_json::json!({"type": "description", "value": s})
434                              }
435                              Property::Role(r) => {
436                                  serde_json::json!({"type": "role", "value": r.to_string()})
437                              }
438                              Property::Parent(p) => serde_json::json!({
439                                  "type": "parent",
440                                  "value": format!("{}:{}", p.name_as_str().unwrap_or(""), strip_atspi_prefix(p.path_as_str()))
441                              }),
442                              Property::TableCaption(s) => {
443                                  serde_json::json!({"type": "table_caption", "value": s})
444                              }
445                              Property::TableColumnDescription(s) => {
446                                  serde_json::json!({"type": "table_column_description", "value": s})
447                              }
448                              Property::TableColumnHeader(s) => {
449                                  serde_json::json!({"type": "table_column_header", "value": s})
450                              }
451                              Property::TableRowDescription(s) => {
452                                  serde_json::json!({"type": "table_row_description", "value": s})
453                              }
454                              Property::TableRowHeader(s) => {
455                                  serde_json::json!({"type": "table_row_header", "value": s})
456                              }
457                              Property::TableSummary(s) => {
458                                  serde_json::json!({"type": "table_summary", "value": s})
459                              }
460                              Property::HelpText(s) => {
461                                  serde_json::json!({"type": "help_text", "value": s})
462                              }
463                              Property::Other((name, val)) => {
464                                  serde_json::json!({"type": "other", "name": name, "value": format!("{:?}", val)})
465                              }
466                              _ => {
467                                  serde_json::json!({"type": "unknown", "value": format!("{:?}", e.value)})
468                              }
469                          };
470                          (
471                              "PropertyChange".to_string(),
472                              Some(serde_json::json!({
473                                  "property": e.property,
474                                  "value": value
475                              })),
476                          )
477                      }
478                      ObjectEvents::TextCaretMoved(e) => (
479                          "TextCaretMoved".to_string(),
480                          Some(serde_json::json!({
481                              "position": e.position
482                          })),
483                      ),
484                      ObjectEvents::ChildrenChanged(e) => (
485                          "ChildrenChanged".to_string(),
486                          Some(serde_json::json!({
487                              "operation": format!("{:?}", e.operation),
488                              "index": e.index_in_parent,
489                              "child": format!("{}:{}", e.child.name_as_str().unwrap_or(""), strip_atspi_prefix(e.child.path_as_str()))
490                          })),
491                      ),
492                      ObjectEvents::Announcement(e) => (
493                          "Announcement".to_string(),
494                          Some(serde_json::json!({
495                              "text": e.text,
496                              "live": format!("{:?}", e.live)
497                          })),
498                      ),
499                      ObjectEvents::BoundsChanged(_) => ("BoundsChanged".to_string(), None),
500                      ObjectEvents::SelectionChanged(_) => ("SelectionChanged".to_string(), None),
501                      ObjectEvents::ActiveDescendantChanged(e) => (
502                          "ActiveDescendantChanged".to_string(),
503                          Some(serde_json::json!({
504                              "descendant": format!(
505                                  "{}:{}",
506                                  e.descendant.name_as_str().unwrap_or(""),
507                                  strip_atspi_prefix(e.descendant.path_as_str())
508                              )
509                          })),
510                      ),
511                      ObjectEvents::LinkSelected(_) => ("LinkSelected".to_string(), None),
512                      ObjectEvents::VisibleDataChanged(_) => ("VisibleDataChanged".to_string(), None),
513                      ObjectEvents::ModelChanged(_) => ("ModelChanged".to_string(), None),
514                      ObjectEvents::AttributesChanged(_) => ("AttributesChanged".to_string(), None),
515                      ObjectEvents::RowInserted(_) => ("RowInserted".to_string(), None),
516                      ObjectEvents::RowReordered(_) => ("RowReordered".to_string(), None),
517                      ObjectEvents::RowDeleted(_) => ("RowDeleted".to_string(), None),
518                      ObjectEvents::ColumnInserted(_) => ("ColumnInserted".to_string(), None),
519                      ObjectEvents::ColumnReordered(_) => ("ColumnReordered".to_string(), None),
520                      ObjectEvents::ColumnDeleted(_) => ("ColumnDeleted".to_string(), None),
521                      ObjectEvents::TextBoundsChanged(_) => ("TextBoundsChanged".to_string(), None),
522                      ObjectEvents::TextSelectionChanged(_) => {
523                          ("TextSelectionChanged".to_string(), None)
524                      }
525                      ObjectEvents::TextAttributesChanged(_) => {
526                          ("TextAttributesChanged".to_string(), None)
527                      }
528                  }
529              }
530              Event::Focus(_) => ("Focus".to_string(), None),
531              Event::Window(win) => {
532                  use atspi::WindowEvents;
533                  let name = match win {
534                      WindowEvents::PropertyChange(e) => {
535                          return Self {
536                              timestamp,
537                              event_type: "Window::PropertyChange".to_string(),
538                              source,
539                              details: Some(serde_json::json!({
540                                  "property": e.property
541                              })),
542                          };
543                      }
544                      WindowEvents::Minimize(_) => "Window::Minimize",
545                      WindowEvents::Maximize(_) => "Window::Maximize",
546                      WindowEvents::Restore(_) => "Window::Restore",
547                      WindowEvents::Close(_) => "Window::Close",
548                      WindowEvents::Create(_) => "Window::Create",
549                      WindowEvents::Reparent(_) => "Window::Reparent",
550                      WindowEvents::DesktopCreate(_) => "Window::DesktopCreate",
551                      WindowEvents::DesktopDestroy(_) => "Window::DesktopDestroy",
552                      WindowEvents::Destroy(_) => "Window::Destroy",
553                      WindowEvents::Activate(_) => "Window::Activate",
554                      WindowEvents::Deactivate(_) => "Window::Deactivate",
555                      WindowEvents::Raise(_) => "Window::Raise",
556                      WindowEvents::Lower(_) => "Window::Lower",
557                      WindowEvents::Move(_) => "Window::Move",
558                      WindowEvents::Resize(_) => "Window::Resize",
559                      WindowEvents::Shade(_) => "Window::Shade",
560                      WindowEvents::UUshade(_) => "Window::Unshade",
561                      WindowEvents::Restyle(_) => "Window::Restyle",
562                  };
563                  (name.to_string(), None)
564              }
565              Event::Document(doc) => {
566                  use atspi::DocumentEvents;
567                  let name = match doc {
568                      DocumentEvents::LoadComplete(_) => "Document::LoadComplete",
569                      DocumentEvents::Reload(_) => "Document::Reload",
570                      DocumentEvents::LoadStopped(_) => "Document::LoadStopped",
571                      DocumentEvents::ContentChanged(_) => "Document::ContentChanged",
572                      DocumentEvents::AttributesChanged(_) => "Document::AttributesChanged",
573                      DocumentEvents::PageChanged(_) => "Document::PageChanged",
574                  };
575                  (name.to_string(), None)
576              }
577              Event::Terminal(term) => {
578                  use atspi::TerminalEvents;
579                  let name = match term {
580                      TerminalEvents::LineChanged(_) => "Terminal::LineChanged",
581                      TerminalEvents::ColumnCountChanged(_) => "Terminal::ColumnCountChanged",
582                      TerminalEvents::LineCountChanged(_) => "Terminal::LineCountChanged",
583                      TerminalEvents::ApplicationChanged(_) => "Terminal::ApplicationChanged",
584                      TerminalEvents::CharWidthChanged(_) => "Terminal::CharWidthChanged",
585                  };
586                  (name.to_string(), None)
587              }
588              Event::Mouse(mouse) => {
589                  use atspi::MouseEvents;
590                  match mouse {
591                      MouseEvents::Abs(e) => (
592                          "Mouse::Abs".to_string(),
593                          Some(serde_json::json!({"x": e.x, "y": e.y})),
594                      ),
595                      MouseEvents::Rel(e) => (
596                          "Mouse::Rel".to_string(),
597                          Some(serde_json::json!({"x": e.x, "y": e.y})),
598                      ),
599                      MouseEvents::Button(e) => (
600                          "Mouse::Button".to_string(),
601                          Some(serde_json::json!({
602                              "detail": e.detail,
603                              "x": e.mouse_x,
604                              "y": e.mouse_y
605                          })),
606                      ),
607                  }
608              }
609              Event::Keyboard(kb) => {
610                  use atspi::KeyboardEvents;
611                  match kb {
612                      KeyboardEvents::Modifiers(e) => (
613                          "Keyboard::Modifiers".to_string(),
614                          Some(serde_json::json!({
615                              "previous": e.previous_modifiers,
616                              "current": e.current_modifiers
617                          })),
618                      ),
619                  }
620              }
621              Event::Cache(cache) => {
622                  use atspi::CacheEvents;
623                  let name = match cache {
624                      CacheEvents::Add(_) => "Cache::Add",
625                      CacheEvents::LegacyAdd(_) => "Cache::LegacyAdd",
626                      CacheEvents::Remove(_) => "Cache::Remove",
627                  };
628                  (name.to_string(), None)
629              }
630              _ => ("Unknown".to_string(), None),
631          };
632  
633          Self {
634              timestamp,
635              event_type,
636              source,
637              details,
638          }
639      }
640  }
641  
642  /// Format a JSON value for human-readable display.
643  fn format_json_value(key: &str, value: &serde_json::Value) -> String {
644      match value {
645          serde_json::Value::String(s) => format!("{key}=\"{s}\""),
646          serde_json::Value::Number(n) => format!("{key}={n}"),
647          serde_json::Value::Bool(b) => format!("{key}={b}"),
648          _ => format!("{key}={value}"),
649      }
650  }
651  
652  impl std::fmt::Display for EventInfo {
653      fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
654          // Format: [timestamp] [source] event_type: key=value, key=value
655          write!(
656              f,
657              "[{}] [{}] {}",
658              self.timestamp, self.source, self.event_type
659          )?;
660  
661          if let Some(serde_json::Value::Object(map)) = &self.details {
662              let parts: Vec<String> = map.iter().map(|(k, v)| format_json_value(k, v)).collect();
663              if !parts.is_empty() {
664                  write!(f, ": {}", parts.join(", "))?;
665              }
666          }
667          Ok(())
668      }
669  }