1use crate::diagnostics::hydrate_session_bus_env;
2use anyhow::{anyhow, Context, Result};
3use atspi::{
4 proxy::{
5 accessible::{AccessibleProxy, ObjectRefExt},
6 proxy_ext::ProxyExt,
7 },
8 CoordType, ObjectRef, ObjectRefOwned, StateSet,
9};
10use atspi_connection::AccessibilityConnection;
13use schemars::JsonSchema;
14use serde::Serialize;
15use std::collections::VecDeque;
16use zbus::{
17 fdo::DBusProxy,
18 names::{BusName, UniqueName},
19 zvariant::ObjectPath,
20};
21
22#[derive(Debug, Clone, Serialize, JsonSchema)]
23pub struct AccessibleAppSummary {
24 pub object_ref: String,
25 pub name: Option<String>,
26 pub pid: Option<u32>,
27 pub role: String,
28 pub child_count: i32,
29 pub bounds: Option<Bounds>,
30}
31
32#[derive(Debug, Clone, Serialize, JsonSchema)]
33pub struct AccessibilityNode {
34 pub index: u32,
35 pub parent_index: Option<u32>,
36 pub depth: u32,
37 pub object_ref: String,
38 pub role: String,
39 pub name: Option<String>,
40 pub description: Option<String>,
41 pub child_count: i32,
42 pub bounds: Option<Bounds>,
43 pub states: Vec<String>,
44 pub actions: Vec<AccessibilityAction>,
45 pub value: Option<AccessibilityValue>,
46 pub text: Option<AccessibilityText>,
47 pub supports_editable_text: bool,
48}
49
50#[derive(Debug, Clone, Serialize, JsonSchema)]
51pub struct Bounds {
52 pub x: i32,
53 pub y: i32,
54 pub width: i32,
55 pub height: i32,
56}
57
58#[derive(Debug, Clone, Serialize, JsonSchema)]
59pub struct AccessibilityAction {
60 pub index: i32,
61 pub name: String,
62 pub description: String,
63 pub keybinding: String,
64}
65
66#[derive(Debug, Clone, Serialize, JsonSchema)]
67pub struct AccessibilityValue {
68 pub current: f64,
69 pub minimum: f64,
70 pub maximum: f64,
71 pub minimum_increment: f64,
72 pub text: Option<String>,
73}
74
75#[derive(Debug, Clone, Serialize, JsonSchema)]
76pub struct AccessibilityText {
77 pub character_count: i32,
78 pub caret_offset: Option<i32>,
79 pub content: Option<String>,
80 pub truncated: bool,
81 pub selections: Vec<AccessibilityTextSelection>,
82}
83
84#[derive(Debug, Clone, Serialize, JsonSchema)]
85pub struct AccessibilityTextSelection {
86 pub start_offset: i32,
87 pub end_offset: i32,
88}
89
90#[derive(Debug, Clone)]
91pub struct ActionInvocation {
92 pub action_index: i32,
93 pub action_name: Option<String>,
94 pub ok: bool,
95}
96
97#[derive(Debug, Clone)]
98pub enum ValueSetInvocation {
99 Numeric { value: f64 },
100 EditableText,
101}
102
103const MAX_TEXT_READBACK_CHARS: i32 = 4096;
104const MAX_TEXT_SELECTIONS: i32 = 8;
105const DEFAULT_SNAPSHOT_MAX_NODES: usize = 1_000;
106const HARD_SNAPSHOT_MAX_NODES: usize = 2_000;
107const DEFAULT_SNAPSHOT_MAX_DEPTH: u32 = 32;
108const HARD_SNAPSHOT_MAX_DEPTH: u32 = 64;
109
110pub(crate) fn snapshot_limits(
111 requested_max_nodes: Option<usize>,
112 requested_max_depth: Option<u32>,
113) -> (usize, u32) {
114 (
115 requested_max_nodes
116 .unwrap_or(DEFAULT_SNAPSHOT_MAX_NODES)
117 .clamp(1, HARD_SNAPSHOT_MAX_NODES),
118 requested_max_depth
119 .unwrap_or(DEFAULT_SNAPSHOT_MAX_DEPTH)
120 .min(HARD_SNAPSHOT_MAX_DEPTH),
121 )
122}
123
124pub async fn list_accessible_apps(limit: usize) -> Result<Vec<AccessibleAppSummary>> {
125 let conn = connect().await?;
126 let roots = registry_children(&conn).await?;
127 let dbus = DBusProxy::new(conn.connection()).await.ok();
128 let mut apps = Vec::new();
129
130 for object_ref in roots.into_iter().take(limit) {
131 if let Ok(proxy) = open_accessible(&conn, &object_ref).await {
132 apps.push(read_app_summary(&proxy, &object_ref, dbus.as_ref()).await);
133 }
134 }
135
136 Ok(apps)
137}
138
139pub async fn snapshot_tree(
140 app_name_or_bundle_identifier: Option<&str>,
141 target_pid: Option<u32>,
142 max_nodes: usize,
143 max_depth: u32,
144) -> Result<Vec<AccessibilityNode>> {
145 let conn = connect().await?;
146 let roots = registry_children(&conn).await?;
147 let selected_roots =
148 select_roots(&conn, roots, app_name_or_bundle_identifier, target_pid).await;
149 let mut nodes = Vec::new();
150 let mut queue = VecDeque::new();
151
152 for object_ref in selected_roots {
153 queue.push_back((object_ref, 0_u32, None));
154 }
155
156 while let Some((object_ref, depth, parent_index)) = queue.pop_front() {
157 if nodes.len() >= max_nodes {
158 break;
159 }
160
161 let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
162 continue;
163 };
164 let index = nodes.len() as u32;
165 let child_refs = if depth < max_depth {
166 proxy.get_children().await.unwrap_or_default()
167 } else {
168 Vec::new()
169 };
170
171 nodes.push(read_node(&proxy, &object_ref, index, parent_index, depth).await);
172
173 for child in child_refs {
174 queue.push_back((child, depth + 1, Some(index)));
175 }
176 }
177
178 Ok(nodes)
179}
180
181#[derive(Debug, Clone, Serialize, JsonSchema)]
184pub struct FocusedElementSummary {
185 pub role: String,
186 pub name: Option<String>,
187 pub editable: bool,
188 pub states: Vec<String>,
189}
190
191const FOCUS_PROBE_MAX_NODES: usize = 400;
192const FOCUS_PROBE_MAX_DEPTH: u32 = 16;
193
194pub async fn focused_element_summary(
199 target_pid: Option<u32>,
200) -> Result<Option<FocusedElementSummary>> {
201 let conn = connect().await?;
202 let roots = registry_children(&conn).await?;
203 let selected_roots = select_roots(&conn, roots, None, target_pid).await;
204 let mut visited = 0_usize;
205 let mut queue = VecDeque::new();
206
207 for object_ref in selected_roots {
208 queue.push_back((object_ref, 0_u32));
209 }
210
211 while let Some((object_ref, depth)) = queue.pop_front() {
212 if visited >= FOCUS_PROBE_MAX_NODES {
213 break;
214 }
215 visited += 1;
216
217 let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
218 continue;
219 };
220 let Ok(state) = proxy.get_state().await else {
221 continue;
222 };
223 if state.contains(atspi::State::Focused) {
224 let proxies = proxy.proxies().await.ok();
225 return Ok(Some(FocusedElementSummary {
226 role: role_name(&proxy).await,
227 name: optional_string(proxy.name().await.ok()),
228 editable: supports_editable_text(proxies.as_ref()).await,
229 states: state_labels(state),
230 }));
231 }
232 if depth < FOCUS_PROBE_MAX_DEPTH {
233 for child in proxy.get_children().await.unwrap_or_default() {
234 queue.push_back((child, depth + 1));
235 }
236 }
237 }
238
239 Ok(None)
240}
241
242pub async fn perform_action(
243 object_ref_id: &str,
244 requested_action: Option<&str>,
245) -> Result<ActionInvocation> {
246 let conn = connect().await?;
247 let object_ref = object_ref_from_id(object_ref_id)?;
248 let proxy = open_accessible(&conn, &object_ref)
249 .await
250 .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
251 let action = proxy
252 .proxies()
253 .await?
254 .action()
255 .await
256 .context("element does not expose the AT-SPI Action interface")?;
257 let actions = action.get_actions().await.unwrap_or_default();
258 let action_index = select_action_index(&actions, requested_action)?;
259 let action_name = actions
260 .get(action_index as usize)
261 .map(|action| action.name.clone());
262 let ok = action
263 .do_action(action_index)
264 .await
265 .with_context(|| format!("failed to invoke AT-SPI action {action_index}"))?;
266
267 Ok(ActionInvocation {
268 action_index,
269 action_name,
270 ok,
271 })
272}
273
274pub async fn set_element_value(object_ref_id: &str, value: &str) -> Result<ValueSetInvocation> {
275 let conn = connect().await?;
276 let object_ref = object_ref_from_id(object_ref_id)?;
277 let proxy = open_accessible(&conn, &object_ref)
278 .await
279 .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
280 let proxies = proxy.proxies().await?;
281
282 if let Ok(numeric_value) = value.parse::<f64>() {
283 if let Ok(value_proxy) = proxies.value().await {
284 value_proxy
285 .set_current_value(numeric_value)
286 .await
287 .with_context(|| {
288 format!("failed to set AT-SPI numeric value to {numeric_value}")
289 })?;
290 return Ok(ValueSetInvocation::Numeric {
291 value: numeric_value,
292 });
293 }
294 }
295
296 if let Ok(editable_text) = proxies.editable_text().await {
297 let ok = editable_text
298 .set_text_contents(value)
299 .await
300 .context("failed to set AT-SPI editable text contents")?;
301 if ok {
302 return Ok(ValueSetInvocation::EditableText);
303 }
304 return Err(anyhow!("AT-SPI EditableText rejected the new contents"));
305 }
306
307 if value.parse::<f64>().is_err() && proxies.value().await.is_ok() {
308 return Err(anyhow!(
309 "element exposes the AT-SPI Value interface, but the requested value is not numeric"
310 ));
311 }
312
313 Err(anyhow!(
314 "element does not expose AT-SPI Value or EditableText interfaces"
315 ))
316}
317
318async fn connect() -> Result<AccessibilityConnection> {
319 hydrate_session_bus_env();
320 AccessibilityConnection::new()
321 .await
322 .context("failed to connect to AT-SPI bus")
323}
324
325async fn open_accessible<'r>(
338 conn: &AccessibilityConnection,
339 object_ref: &'r ObjectRefOwned,
340) -> Result<AccessibleProxy<'r>, atspi::AtspiError> {
341 object_ref.as_accessible_proxy(conn.connection()).await
342}
343
344async fn registry_children(conn: &AccessibilityConnection) -> Result<Vec<ObjectRefOwned>> {
345 let root = conn
346 .root_accessible_on_registry()
347 .await
348 .context("failed to open AT-SPI registry root")?;
349 root.get_children()
350 .await
351 .context("failed to read AT-SPI registry children")
352}
353
354async fn select_roots(
355 conn: &AccessibilityConnection,
356 roots: Vec<ObjectRefOwned>,
357 app_name_or_bundle_identifier: Option<&str>,
358 target_pid: Option<u32>,
359) -> Vec<ObjectRefOwned> {
360 let needle = app_name_or_bundle_identifier
361 .map(str::trim)
362 .filter(|value| !value.is_empty())
363 .map(|value| value.to_ascii_lowercase());
364 let dbus = DBusProxy::new(conn.connection()).await.ok();
365 let mut remaining = roots;
366
367 if let Some(target_pid) = target_pid {
368 let mut pid_and_filter_matches = Vec::new();
369 let mut pid_matches = Vec::new();
370 let mut non_pid_matches = Vec::new();
371
372 for object_ref in remaining {
373 if object_ref_pid(dbus.as_ref(), &object_ref).await == Some(target_pid) {
374 if let Some(needle) = needle.as_deref() {
375 if root_matches(conn, &object_ref, needle).await {
376 pid_and_filter_matches.push(object_ref);
377 } else {
378 pid_matches.push(object_ref);
379 }
380 } else {
381 pid_matches.push(object_ref);
382 }
383 } else {
384 non_pid_matches.push(object_ref);
385 }
386 }
387
388 if !pid_and_filter_matches.is_empty() {
389 return pid_and_filter_matches;
390 }
391 if !pid_matches.is_empty() {
392 return pid_matches;
393 }
394
395 remaining = non_pid_matches;
396 }
397
398 let Some(needle) = needle.as_deref() else {
399 return remaining;
400 };
401
402 let mut selected = Vec::new();
403 for object_ref in remaining {
404 if root_matches(conn, &object_ref, needle).await {
405 selected.push(object_ref);
406 }
407 }
408
409 selected
410}
411
412async fn root_matches(
413 conn: &AccessibilityConnection,
414 object_ref: &ObjectRefOwned,
415 needle: &str,
416) -> bool {
417 let Ok(proxy) = open_accessible(conn, object_ref).await else {
418 return object_ref_id(object_ref)
419 .to_ascii_lowercase()
420 .contains(needle);
421 };
422
423 if proxy_matches(&proxy, object_ref, needle).await {
424 return true;
425 }
426
427 let children = proxy.get_children().await.unwrap_or_default();
428 for child_ref in children.into_iter().take(8) {
429 let Ok(child_proxy) = open_accessible(conn, &child_ref).await else {
430 continue;
431 };
432 if proxy_matches(&child_proxy, &child_ref, needle).await {
433 return true;
434 }
435 }
436
437 false
438}
439
440async fn proxy_matches(
441 proxy: &AccessibleProxy<'_>,
442 object_ref: &ObjectRefOwned,
443 needle: &str,
444) -> bool {
445 let name = proxy.name().await.unwrap_or_default();
446 let role = proxy.get_role_name().await.unwrap_or_default();
447 format!("{} {} {}", object_ref_id(object_ref), name, role)
448 .to_ascii_lowercase()
449 .contains(needle)
450}
451
452async fn read_app_summary(
453 proxy: &AccessibleProxy<'_>,
454 object_ref: &ObjectRefOwned,
455 dbus: Option<&DBusProxy<'_>>,
456) -> AccessibleAppSummary {
457 AccessibleAppSummary {
458 object_ref: object_ref_id(object_ref),
459 name: optional_string(proxy.name().await.ok()),
460 pid: object_ref_pid(dbus, object_ref).await,
461 role: role_name(proxy).await,
462 child_count: proxy.child_count().await.unwrap_or_default(),
463 bounds: bounds(proxy).await,
464 }
465}
466
467async fn read_node(
468 proxy: &AccessibleProxy<'_>,
469 object_ref: &ObjectRefOwned,
470 index: u32,
471 parent_index: Option<u32>,
472 depth: u32,
473) -> AccessibilityNode {
474 let proxies = proxy.proxies().await.ok();
475
476 AccessibilityNode {
477 index,
478 parent_index,
479 depth,
480 object_ref: object_ref_id(object_ref),
481 role: role_name(proxy).await,
482 name: optional_string(proxy.name().await.ok()),
483 description: optional_string(proxy.description().await.ok()),
484 child_count: proxy.child_count().await.unwrap_or_default(),
485 bounds: bounds_from_proxies(proxies.as_ref(), proxy).await,
486 states: states_from_proxy(proxy).await,
487 actions: actions_from_proxies(proxies.as_ref()).await,
488 value: value_from_proxies(proxies.as_ref()).await,
489 text: text_from_proxies(proxies.as_ref()).await,
490 supports_editable_text: supports_editable_text(proxies.as_ref()).await,
491 }
492}
493
494async fn role_name(proxy: &AccessibleProxy<'_>) -> String {
495 if let Ok(role) = proxy.get_role_name().await {
496 if !role.trim().is_empty() {
497 return role;
498 }
499 }
500 proxy
501 .get_role()
502 .await
503 .map(|role| format!("{role:?}"))
504 .unwrap_or_else(|_| "unknown".to_string())
505}
506
507async fn bounds(proxy: &AccessibleProxy<'_>) -> Option<Bounds> {
508 bounds_from_proxies(proxy.proxies().await.ok().as_ref(), proxy).await
509}
510
511async fn object_ref_pid(dbus: Option<&DBusProxy<'_>>, object_ref: &ObjectRefOwned) -> Option<u32> {
512 let dbus = dbus?;
513 let bus_name = BusName::try_from(object_ref.name_as_str()?.to_string()).ok()?;
514 dbus.get_connection_unix_process_id(bus_name).await.ok()
515}
516
517async fn bounds_from_proxies(
518 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
519 proxy: &AccessibleProxy<'_>,
520) -> Option<Bounds> {
521 let owned_proxies;
522 let proxies = if let Some(proxies) = proxies {
523 proxies
524 } else {
525 owned_proxies = proxy.proxies().await.ok()?;
526 &owned_proxies
527 };
528 let component = proxies.component().await.ok()?;
529 let (x, y, width, height) = component.get_extents(CoordType::Screen).await.ok()?;
530 normalize_bounds(Bounds {
531 x,
532 y,
533 width,
534 height,
535 })
536}
537
538fn normalize_bounds(bounds: Bounds) -> Option<Bounds> {
539 if bounds.width <= 0 || bounds.height <= 0 {
540 return None;
541 }
542 if bounds.x <= i32::MIN / 2 || bounds.y <= i32::MIN / 2 {
543 return None;
544 }
545 Some(bounds)
546}
547
548async fn actions_from_proxies(
549 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
550) -> Vec<AccessibilityAction> {
551 let Some(proxies) = proxies else {
552 return Vec::new();
553 };
554 let Ok(action_proxy) = proxies.action().await else {
555 return Vec::new();
556 };
557
558 action_proxy
559 .get_actions()
560 .await
561 .unwrap_or_default()
562 .into_iter()
563 .enumerate()
564 .map(|(index, action)| AccessibilityAction {
565 index: index as i32,
566 name: action.name,
567 description: action.description,
568 keybinding: action.keybinding,
569 })
570 .collect()
571}
572
573async fn states_from_proxy(proxy: &AccessibleProxy<'_>) -> Vec<String> {
574 proxy
575 .get_state()
576 .await
577 .map(state_labels)
578 .unwrap_or_default()
579}
580
581async fn value_from_proxies(
582 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
583) -> Option<AccessibilityValue> {
584 let value = proxies?.value().await.ok()?;
585 Some(AccessibilityValue {
586 current: value.current_value().await.ok()?,
587 minimum: value.minimum_value().await.ok()?,
588 maximum: value.maximum_value().await.ok()?,
589 minimum_increment: value.minimum_increment().await.ok()?,
590 text: optional_string(value.text().await.ok()),
591 })
592}
593
594async fn text_from_proxies(
595 proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
596) -> Option<AccessibilityText> {
597 let text = proxies?.text().await.ok()?;
598 let character_count = text.character_count().await.ok()?.max(0);
599 let caret_offset = text.caret_offset().await.ok();
600 let capped_count = character_count.min(MAX_TEXT_READBACK_CHARS);
601 let content = if capped_count > 0 {
602 optional_string(text.get_text(0, capped_count).await.ok())
603 } else {
604 None
605 };
606 let selection_count = text
607 .get_nselections()
608 .await
609 .unwrap_or_default()
610 .clamp(0, MAX_TEXT_SELECTIONS);
611 let mut selections = Vec::new();
612 for index in 0..selection_count {
613 if let Ok((start_offset, end_offset)) = text.get_selection(index).await {
614 selections.push(AccessibilityTextSelection {
615 start_offset,
616 end_offset,
617 });
618 }
619 }
620
621 Some(AccessibilityText {
622 character_count,
623 caret_offset,
624 content,
625 truncated: character_count > MAX_TEXT_READBACK_CHARS,
626 selections,
627 })
628}
629
630async fn supports_editable_text(proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>) -> bool {
631 let Some(proxies) = proxies else {
632 return false;
633 };
634 proxies.editable_text().await.is_ok()
635}
636
637fn state_labels(state_set: StateSet) -> Vec<String> {
638 state_set.iter().map(|state| state.to_string()).collect()
639}
640
641fn select_action_index(actions: &[atspi::Action], requested_action: Option<&str>) -> Result<i32> {
642 if actions.is_empty() {
643 return Err(anyhow!("element exposes no AT-SPI actions"));
644 }
645
646 if let Some(requested_action) = requested_action
647 .map(str::trim)
648 .filter(|value| !value.is_empty())
649 {
650 let requested_action = requested_action.to_ascii_lowercase();
651 if let Some((index, _)) = actions.iter().enumerate().find(|(_, action)| {
652 action.name.to_ascii_lowercase() == requested_action
653 || action.description.to_ascii_lowercase() == requested_action
654 }) {
655 return Ok(index as i32);
656 }
657
658 if let Ok(index) = requested_action.parse::<usize>() {
659 if index < actions.len() {
660 return Ok(index as i32);
661 }
662 }
663
664 return Err(anyhow!(
665 "requested AT-SPI action was not found; available actions: {}",
666 actions
667 .iter()
668 .map(|action| action.name.as_str())
669 .collect::<Vec<_>>()
670 .join(", ")
671 ));
672 }
673
674 Ok(if actions.len() > 1 { 1 } else { 0 })
675}
676
677fn optional_string(value: Option<String>) -> Option<String> {
678 value
679 .map(|value| value.trim().to_string())
680 .filter(|value| !value.is_empty())
681}
682
683fn object_ref_from_id(object_ref_id: &str) -> Result<ObjectRefOwned> {
684 let (name, path) = split_object_ref_id(object_ref_id)?;
685 let name = UniqueName::try_from(name.to_string())
686 .with_context(|| format!("invalid AT-SPI bus name in object ref {object_ref_id}"))?;
687 let path = ObjectPath::try_from(path.to_string())
688 .with_context(|| format!("invalid AT-SPI object path in object ref {object_ref_id}"))?;
689 Ok(ObjectRef::new_owned(name, path))
690}
691
692fn split_object_ref_id(object_ref_id: &str) -> Result<(&str, &str)> {
693 let Some(path_start) = object_ref_id.find('/') else {
694 return Err(anyhow!(
695 "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
696 ));
697 };
698 let (name, path) = object_ref_id.split_at(path_start);
699 if name.is_empty() || path.is_empty() {
700 return Err(anyhow!(
701 "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
702 ));
703 }
704 Ok((name, path))
705}
706
707fn object_ref_id(object_ref: &ObjectRefOwned) -> String {
708 format!(
709 "{}{}",
710 object_ref.name_as_str().unwrap_or(""),
711 object_ref.path_as_str()
712 )
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718
719 #[test]
720 fn split_object_ref_id_separates_bus_name_and_path() {
721 let (name, path) = split_object_ref_id(":1.42/org/a11y/atspi/accessible/7").unwrap();
722
723 assert_eq!(name, ":1.42");
724 assert_eq!(path, "/org/a11y/atspi/accessible/7");
725 }
726
727 #[test]
728 fn select_action_index_uses_named_action() {
729 let actions = vec![
730 atspi::Action {
731 name: "click".to_string(),
732 description: "Clicks".to_string(),
733 keybinding: String::new(),
734 },
735 atspi::Action {
736 name: "show-menu".to_string(),
737 description: "Shows menu".to_string(),
738 keybinding: String::new(),
739 },
740 ];
741
742 assert_eq!(select_action_index(&actions, Some("show-menu")).unwrap(), 1);
743 }
744
745 #[test]
746 fn select_action_index_defaults_to_secondary_when_available() {
747 let actions = vec![
748 atspi::Action {
749 name: "click".to_string(),
750 description: String::new(),
751 keybinding: String::new(),
752 },
753 atspi::Action {
754 name: "show-menu".to_string(),
755 description: String::new(),
756 keybinding: String::new(),
757 },
758 ];
759
760 assert_eq!(select_action_index(&actions, None).unwrap(), 1);
761 }
762
763 #[test]
764 fn state_labels_serialize_in_bit_order() {
765 let labels = state_labels(StateSet::new(atspi::State::Focused | atspi::State::Checked));
766
767 assert_eq!(labels, vec!["checked".to_string(), "focused".to_string()]);
768 }
769
770 #[test]
771 fn default_snapshot_limits_cover_deep_gtk4_trees() {
772 assert_eq!(snapshot_limits(None, None), (1_000, 32));
775 }
776
777 #[test]
778 fn requested_snapshot_limits_remain_bounded() {
779 assert_eq!(snapshot_limits(Some(0), Some(0)), (1, 0));
780 assert_eq!(snapshot_limits(Some(10_000), Some(128)), (2_000, 64));
781 }
782}