1use std::collections::BTreeSet;
8use std::sync::{Arc, Mutex, MutexGuard};
9
10use gpui::{
11 App, Bounds, FocusHandle, Global, InteractiveElement, IntoElement, ParentElement, Pixels,
12 SharedString, StatefulInteractiveElement, Styled, Toggled, canvas,
13};
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
17#[serde(rename_all = "kebab-case")]
18pub enum Role {
19 Window,
20 #[default]
21 Region,
22 Group,
23 List,
24 Row,
25 Button,
26 Link,
27 Tab,
28 TabPanel,
29 Input,
30 MultilineInput,
31 PasswordInput,
32 Text,
33 Heading,
34 Dialog,
35 Menu,
36 MenuItem,
37 Status,
38 Checkbox,
39 Radio,
40 Switch,
41 Slider,
42 Table,
43 TreeGrid,
44 Cell,
45 GridCell,
46 Tree,
47 TreeItem,
48 Progress,
49 Toast,
50 Tooltip,
51 Separator,
52 Splitter,
53 Toolbar,
54 Scrollbar,
55 Combobox,
56 Option,
57 Form,
58 Field,
59 Image,
60 Drag,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "kebab-case")]
66pub enum LiveRegion {
67 Polite,
68 Assertive,
69}
70
71#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
72pub struct Rect {
73 pub x: f32,
74 pub y: f32,
75 pub width: f32,
76 pub height: f32,
77}
78
79impl Rect {
80 pub fn area(self) -> f32 {
81 self.width.max(0.0) * self.height.max(0.0)
82 }
83
84 pub fn center(self) -> (f32, f32) {
85 (self.x + self.width / 2.0, self.y + self.height / 2.0)
86 }
87
88 pub fn overlaps(self, other: Self) -> bool {
89 self.x < other.x + other.width
90 && other.x < self.x + self.width
91 && self.y < other.y + other.height
92 && other.y < self.y + self.height
93 }
94}
95
96#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
102#[serde(default)]
103pub struct Node {
104 pub id: String,
105 pub role: Role,
106 pub parent: Option<String>,
107 #[serde(skip_serializing_if = "Option::is_none")]
110 pub labels: Option<String>,
111 #[serde(skip_serializing_if = "Option::is_none")]
114 pub describes: Option<String>,
115 pub text: Option<String>,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 pub description: Option<String>,
118 pub bounds: Rect,
119 pub visible: bool,
120 pub focused: bool,
121 pub disabled: bool,
122 #[serde(skip_serializing_if = "is_false")]
123 pub read_only: bool,
124 pub selected: bool,
125 pub hovered: bool,
126 pub pressed: bool,
127 #[serde(skip_serializing_if = "Option::is_none")]
128 pub checked: Option<bool>,
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub expanded: Option<bool>,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub value: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub placeholder: Option<String>,
135 #[serde(skip_serializing_if = "Option::is_none")]
136 pub value_min: Option<f32>,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub value_max: Option<f32>,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub value_now: Option<f32>,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub level: Option<u32>,
143 #[serde(skip_serializing_if = "is_false")]
144 pub busy: bool,
145 #[serde(skip_serializing_if = "is_false")]
146 pub invalid: bool,
147 #[serde(skip_serializing_if = "is_false")]
148 pub required: bool,
149 #[serde(skip_serializing_if = "Option::is_none")]
150 pub live: Option<LiveRegion>,
151 #[serde(skip_serializing_if = "is_false")]
152 pub live_atomic: bool,
153 #[serde(skip_serializing_if = "is_false")]
154 pub modal: bool,
155}
156
157fn is_false(value: &bool) -> bool {
158 !*value
159}
160
161#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
162pub struct Snapshot {
163 pub generation: u64,
164 pub nodes: Vec<Node>,
165}
166
167impl Snapshot {
168 pub fn find(&self, id: &str) -> Option<&Node> {
169 self.nodes.iter().find(|node| node.id == id)
170 }
171
172 pub fn contains(&self, id: &str) -> bool {
173 self.find(id).is_some()
174 }
175
176 pub fn ids(&self) -> Vec<&str> {
177 self.nodes.iter().map(|node| node.id.as_str()).collect()
178 }
179
180 pub fn under(&self, prefix: &str) -> Vec<&Node> {
181 self.nodes
182 .iter()
183 .filter(|node| node.id.starts_with(prefix))
184 .collect()
185 }
186
187 pub fn children_of(&self, parent: &str) -> Vec<&Node> {
188 self.nodes
189 .iter()
190 .filter(|node| node.parent.as_deref() == Some(parent))
191 .collect()
192 }
193
194 pub fn descendants_of(&self, parent: &str) -> Vec<&Node> {
195 let mut found = Vec::new();
196 let mut frontier = vec![parent];
197 let mut visited = BTreeSet::from([parent.to_string()]);
198 while let Some(next) = frontier.pop() {
199 for node in self.children_of(next) {
200 if visited.insert(node.id.clone()) {
201 found.push(node);
202 frontier.push(&node.id);
203 }
204 }
205 }
206 found
207 }
208
209 pub fn redacted(mut self) -> Self {
214 for node in &mut self.nodes {
215 if let Some(text) = &mut node.text {
216 *text = redact_sensitive_text(text);
217 }
218 if let Some(description) = &mut node.description {
219 *description = redact_sensitive_text(description);
220 }
221 if let Some(value) = &mut node.value {
222 *value = redact_sensitive_text(value);
223 }
224 }
225 self
226 }
227}
228
229#[derive(Clone, Default)]
230pub struct SemanticRegistry {
231 inner: Arc<Mutex<Inner>>,
232}
233
234#[derive(Default)]
235struct Inner {
236 generation: u64,
237 nodes: Vec<(u64, Node)>,
241}
242
243impl std::fmt::Debug for SemanticRegistry {
244 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 formatter
246 .debug_struct("SemanticRegistry")
247 .field("generation", &self.generation())
248 .field("nodes", &self.snapshot().nodes.len())
249 .finish()
250 }
251}
252
253impl SemanticRegistry {
254 pub fn new() -> Self {
255 Self::default()
256 }
257
258 pub fn begin_frame(&self) {
264 let mut inner = self.lock();
265 let generation = inner.generation;
266 inner.nodes.retain(|(frame, _)| *frame == generation);
267 inner.generation = generation + 1;
268 }
269
270 pub fn generation(&self) -> u64 {
271 self.lock().generation
272 }
273
274 pub fn snapshot(&self) -> Snapshot {
276 let inner = self.lock();
277 let published = inner.generation;
278 let nodes = inner
279 .nodes
280 .iter()
281 .filter(|(frame, _)| *frame == published)
282 .map(|(_, node)| node.clone())
283 .collect();
284 Snapshot {
285 generation: published,
286 nodes,
287 }
288 }
289
290 pub fn global(cx: &App) -> Self {
295 Self::try_global(cx)
296 .expect("call gpui_kit_semantics::install(cx) before rendering components")
297 }
298
299 pub fn try_global(cx: &App) -> Option<Self> {
300 cx.try_global::<GlobalRegistry>()
301 .map(|global| global.0.clone())
302 }
303
304 fn record(&self, node: Node) {
305 let mut inner = self.lock();
306 let generation = inner.generation;
307 inner.nodes.push((generation, node));
308 }
309
310 fn lock(&self) -> MutexGuard<'_, Inner> {
311 self.inner
312 .lock()
313 .unwrap_or_else(|poisoned| poisoned.into_inner())
314 }
315}
316
317#[derive(Debug, Clone)]
318pub struct NodeSpec {
319 id: SharedString,
320 role: Role,
321 parent: Option<SharedString>,
322 labels: Option<SharedString>,
323 describes: Option<SharedString>,
324 text: Option<SharedString>,
325 description: Option<SharedString>,
326 focus: Option<FocusHandle>,
327 disabled: bool,
328 read_only: bool,
329 selected: bool,
330 hovered: bool,
331 pressed: bool,
332 checked: Option<bool>,
333 expanded: Option<bool>,
334 value: Option<SharedString>,
335 placeholder: Option<SharedString>,
336 range: Option<(f32, f32, f32)>,
337 orientation: Option<gpui::accesskit::Orientation>,
338 level: Option<u32>,
339 busy: bool,
340 invalid: bool,
341 required: bool,
342 live: Option<LiveRegion>,
343 live_atomic: bool,
344 modal: bool,
345}
346
347impl NodeSpec {
348 pub fn new(id: impl Into<SharedString>, role: Role) -> Self {
349 Self {
350 id: id.into(),
351 role,
352 parent: None,
353 labels: None,
354 describes: None,
355 text: None,
356 description: None,
357 focus: None,
358 disabled: false,
359 read_only: false,
360 selected: false,
361 hovered: false,
362 pressed: false,
363 checked: None,
364 expanded: None,
365 value: None,
366 placeholder: None,
367 range: None,
368 orientation: None,
369 level: None,
370 busy: false,
371 invalid: false,
372 required: false,
373 live: None,
374 live_atomic: false,
375 modal: false,
376 }
377 }
378
379 pub fn checked(mut self, checked: bool) -> Self {
380 self.checked = Some(checked);
381 self
382 }
383
384 pub fn tristate(mut self, checked: Option<bool>) -> Self {
385 self.checked = checked;
386 self
387 }
388
389 pub fn expanded(mut self, expanded: bool) -> Self {
390 self.expanded = Some(expanded);
391 self
392 }
393
394 pub fn value(mut self, value: impl Into<SharedString>) -> Self {
399 self.value = Some(value.into());
400 self
401 }
402
403 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
404 self.placeholder = Some(placeholder.into());
405 self
406 }
407
408 pub fn range(mut self, min: f32, max: f32, now: f32) -> Self {
409 self.range = Some((min, max, now));
410 self
411 }
412
413 pub fn orientation(mut self, orientation: gpui::accesskit::Orientation) -> Self {
414 self.orientation = Some(orientation);
415 self
416 }
417
418 pub fn level(mut self, level: u32) -> Self {
419 self.level = Some(level);
420 self
421 }
422
423 pub fn busy(mut self, busy: bool) -> Self {
424 self.busy = busy;
425 self
426 }
427
428 pub fn invalid(mut self, invalid: bool) -> Self {
429 self.invalid = invalid;
430 self
431 }
432
433 pub fn required(mut self, required: bool) -> Self {
434 self.required = required;
435 self
436 }
437
438 pub fn live(mut self, live: LiveRegion) -> Self {
439 self.live = Some(live);
440 self
441 }
442
443 pub fn live_atomic(mut self, atomic: bool) -> Self {
444 self.live_atomic = atomic;
445 self
446 }
447
448 pub fn modal(mut self, modal: bool) -> Self {
449 self.modal = modal;
450 self
451 }
452
453 pub fn parent(mut self, parent: impl Into<SharedString>) -> Self {
454 self.parent = Some(parent.into());
455 self
456 }
457
458 pub fn text(mut self, text: impl Into<SharedString>) -> Self {
459 self.text = Some(text.into());
460 self
461 }
462
463 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
468 self.description = Some(description.into());
469 self
470 }
471
472 pub fn labels(mut self, control: impl Into<SharedString>) -> Self {
477 self.labels = Some(control.into());
478 self
479 }
480
481 pub fn describes(mut self, control: impl Into<SharedString>) -> Self {
484 self.describes = Some(control.into());
485 self
486 }
487
488 pub fn focus(mut self, focus: &FocusHandle) -> Self {
489 self.focus = Some(focus.clone());
490 self
491 }
492
493 pub fn disabled(mut self, disabled: bool) -> Self {
494 self.disabled = disabled;
495 self
496 }
497
498 pub fn read_only(mut self, read_only: bool) -> Self {
499 self.read_only = read_only;
500 self
501 }
502
503 pub fn selected(mut self, selected: bool) -> Self {
504 self.selected = selected;
505 self
506 }
507
508 pub fn hovered(mut self, hovered: bool) -> Self {
509 self.hovered = hovered;
510 self
511 }
512
513 pub fn pressed(mut self, pressed: bool) -> Self {
514 self.pressed = pressed;
515 self
516 }
517}
518
519#[derive(Debug, Clone, Default)]
520struct GlobalRegistry(SemanticRegistry);
521
522impl Global for GlobalRegistry {}
523
524pub fn install(cx: &mut App) {
526 if !cx.has_global::<GlobalRegistry>() {
527 cx.set_global(GlobalRegistry(SemanticRegistry::new()));
528 }
529}
530
531pub trait Semantic: Sized {
532 type Output;
533
534 fn semantic(self, registry: &SemanticRegistry, spec: NodeSpec) -> Self::Output;
535
536 fn semantic_in(self, cx: &App, spec: NodeSpec) -> Self::Output;
539}
540
541impl Semantic for gpui::Div {
542 type Output = gpui::Stateful<gpui::Div>;
543
544 fn semantic(self, registry: &SemanticRegistry, spec: NodeSpec) -> Self::Output {
545 let mut self_ = self.id(spec.id.clone());
546 if self_.style().position.is_none() {
547 self_ = self_.relative();
548 }
549 self_ = platform_accessible(self_, &spec);
550 self_.child(diagnostic_probe(Some(registry), spec))
551 }
552
553 fn semantic_in(self, cx: &App, spec: NodeSpec) -> Self::Output {
554 let mut self_ = self.id(spec.id.clone());
555 if self_.style().position.is_none() {
556 self_ = self_.relative();
557 }
558 self_ = platform_accessible(self_, &spec);
559 let registry = SemanticRegistry::try_global(cx);
560 self_.child(diagnostic_probe(registry.as_ref(), spec))
561 }
562}
563
564impl Semantic for gpui::Stateful<gpui::Div> {
565 type Output = Self;
566
567 fn semantic(mut self, registry: &SemanticRegistry, spec: NodeSpec) -> Self::Output {
568 if self.style().position.is_none() {
569 self = self.relative();
570 }
571 self = platform_accessible(self, &spec);
572 self.child(diagnostic_probe(Some(registry), spec))
573 }
574
575 fn semantic_in(mut self, cx: &App, spec: NodeSpec) -> Self::Output {
576 if self.style().position.is_none() {
577 self = self.relative();
578 }
579 self = platform_accessible(self, &spec);
580 let registry = SemanticRegistry::try_global(cx);
581 self.child(diagnostic_probe(registry.as_ref(), spec))
582 }
583}
584
585fn platform_accessible<E>(mut element: E, spec: &NodeSpec) -> E
586where
587 E: StatefulInteractiveElement,
588{
589 let expected = gpui::ElementId::Name(spec.id.clone());
590 let actual = element.interactivity().element_id.as_ref();
591 assert_eq!(
592 actual,
593 Some(&expected),
594 "semantic and GPUI element ids must match"
595 );
596 let Some(role) = platform_role(spec.role) else {
597 return element;
598 };
599 element = element.role(role);
600 if let Some(text) = &spec.text {
601 element = element.aria_label(redact_sensitive_text(text));
602 }
603 if let Some(description) = &spec.description {
604 element = element.aria_description(redact_sensitive_text(description));
605 }
606 if let Some(focus) = &spec.focus {
607 element = element.track_focus(focus);
608 }
609 if let Some(expanded) = spec.expanded {
610 element = element.aria_expanded(expanded);
611 }
612 if supports_selection(spec.role) {
613 element = element.aria_selected(spec.selected);
614 }
615 if let Some(checked) = spec.checked {
616 element = element.aria_toggled(if checked {
617 Toggled::True
618 } else {
619 Toggled::False
620 });
621 }
622 if let Some(value) = &spec.value {
623 element = element.aria_value(redact_sensitive_text(value));
624 }
625 if let Some(placeholder) = &spec.placeholder {
626 element = element.aria_placeholder(placeholder.clone());
627 }
628 if let Some((min, max, now)) = spec.range {
629 element = element
630 .aria_min_numeric_value(min.into())
631 .aria_max_numeric_value(max.into())
632 .aria_numeric_value(now.into());
633 }
634 if let Some(orientation) = spec.orientation {
635 element = element.aria_orientation(orientation);
636 }
637 if let Some(level) = spec.level {
638 element = element.aria_level(level as usize);
639 }
640 if let Some(live) = spec.live {
641 element = element.aria_live(match live {
642 LiveRegion::Polite => gpui::accesskit::Live::Polite,
643 LiveRegion::Assertive => gpui::accesskit::Live::Assertive,
644 });
645 }
646 element
647 .aria_disabled(spec.disabled)
648 .aria_read_only(spec.read_only)
649 .aria_invalid(spec.invalid)
650 .aria_required(spec.required)
651 .aria_busy(spec.busy)
652 .aria_live_atomic(spec.live_atomic)
653 .aria_modal(spec.modal)
654}
655
656fn supports_selection(role: Role) -> bool {
657 matches!(
658 role,
659 Role::Row | Role::Tab | Role::Cell | Role::GridCell | Role::TreeItem | Role::Option
660 )
661}
662
663fn platform_role(role: Role) -> Option<gpui::Role> {
664 Some(match role {
665 Role::Window => gpui::Role::Window,
666 Role::Region => gpui::Role::Region,
667 Role::Group | Role::Field | Role::Drag => gpui::Role::Group,
668 Role::List => gpui::Role::List,
669 Role::Row => gpui::Role::Row,
670 Role::Button => gpui::Role::Button,
671 Role::Link => gpui::Role::Link,
672 Role::Tab => gpui::Role::Tab,
673 Role::TabPanel => gpui::Role::TabPanel,
674 Role::Input => gpui::Role::TextInput,
675 Role::MultilineInput => gpui::Role::MultilineTextInput,
676 Role::PasswordInput => gpui::Role::PasswordInput,
677 Role::Text => gpui::Role::Label,
678 Role::Heading => gpui::Role::Heading,
679 Role::Dialog => gpui::Role::Dialog,
680 Role::Menu => gpui::Role::Menu,
681 Role::MenuItem => gpui::Role::MenuItem,
682 Role::Status | Role::Toast => gpui::Role::Status,
683 Role::Checkbox => gpui::Role::CheckBox,
684 Role::Radio => gpui::Role::RadioButton,
685 Role::Switch => gpui::Role::Switch,
686 Role::Slider => gpui::Role::Slider,
687 Role::Table => gpui::Role::Table,
688 Role::TreeGrid => gpui::Role::TreeGrid,
689 Role::Cell => gpui::Role::Cell,
690 Role::GridCell => gpui::Role::GridCell,
691 Role::Tree => gpui::Role::Tree,
692 Role::TreeItem => gpui::Role::TreeItem,
693 Role::Progress => gpui::Role::ProgressIndicator,
694 Role::Tooltip => gpui::Role::Tooltip,
695 Role::Separator => return None,
696 Role::Splitter => gpui::Role::Splitter,
697 Role::Toolbar => gpui::Role::Toolbar,
698 Role::Scrollbar => gpui::Role::ScrollBar,
699 Role::Combobox => gpui::Role::ComboBox,
700 Role::Option => gpui::Role::ListBoxOption,
701 Role::Form => gpui::Role::Form,
702 Role::Image => gpui::Role::Image,
703 })
704}
705
706fn diagnostic_probe(registry: Option<&SemanticRegistry>, spec: NodeSpec) -> impl IntoElement {
707 let registry = registry.cloned();
708 canvas(
709 move |bounds: Bounds<Pixels>, window, _| {
710 let Some(registry) = ®istry else {
711 return;
712 };
713 let rect = Rect {
714 x: f32::from(bounds.origin.x),
715 y: f32::from(bounds.origin.y),
716 width: f32::from(bounds.size.width),
717 height: f32::from(bounds.size.height),
718 };
719 registry.record(Node {
720 id: spec.id.to_string(),
721 role: spec.role,
722 parent: spec.parent.as_ref().map(ToString::to_string),
723 labels: spec.labels.as_ref().map(ToString::to_string),
724 describes: spec.describes.as_ref().map(ToString::to_string),
725 text: spec.text.as_ref().map(|text| redact_sensitive_text(text)),
726 description: spec
727 .description
728 .as_ref()
729 .map(|description| redact_sensitive_text(description)),
730 bounds: rect,
731 visible: rect.area() > 0.0,
732 focused: spec
733 .focus
734 .as_ref()
735 .is_some_and(|handle| handle.is_focused(window)),
736 disabled: spec.disabled,
737 read_only: spec.read_only,
738 selected: spec.selected,
739 hovered: spec.hovered,
740 pressed: spec.pressed,
741 checked: spec.checked,
742 expanded: spec.expanded,
743 value: spec
744 .value
745 .as_ref()
746 .map(|value| redact_sensitive_text(value)),
747 placeholder: spec.placeholder.as_ref().map(ToString::to_string),
748 value_min: spec.range.map(|(min, _, _)| min),
749 value_max: spec.range.map(|(_, max, _)| max),
750 value_now: spec.range.map(|(_, _, now)| now),
751 level: spec.level,
752 busy: spec.busy,
753 invalid: spec.invalid,
754 required: spec.required,
755 live: spec.live,
756 live_atomic: spec.live_atomic,
757 modal: spec.modal,
758 });
759 },
760 |_, _, _, _| {},
761 )
762 .absolute()
763 .inset_0()
764}
765
766pub fn redact_sensitive_text(text: &str) -> String {
767 let sensitive_prefixes = ["sk-", "xai-", "ogp_", "Bearer "];
768 if sensitive_prefixes
769 .iter()
770 .any(|prefix| text.contains(prefix))
771 || looks_like_jwt(text)
772 || looks_like_secret_assignment(text)
773 {
774 "[REDACTED]".into()
775 } else {
776 text.into()
777 }
778}
779
780fn looks_like_jwt(text: &str) -> bool {
781 text.split('.').count() == 3 && text.len() >= 32
782}
783
784fn looks_like_secret_assignment(text: &str) -> bool {
785 let lower = text.to_ascii_lowercase();
786 ["api_key=", "apikey=", "token=", "password=", "secret="]
787 .iter()
788 .any(|needle| lower.contains(needle))
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794 use gpui::{
795 AnyWindowHandle, AppContext as _, Context, Render, TestAppContext, Window, div, px,
796 };
797 use std::cell::Cell;
798 use std::rc::Rc;
799
800 fn node(id: &str, parent: Option<&str>) -> Node {
801 Node {
802 id: id.into(),
803 role: Role::Region,
804 parent: parent.map(str::to_string),
805 bounds: Rect {
806 x: 0.0,
807 y: 0.0,
808 width: 10.0,
809 height: 10.0,
810 },
811 visible: true,
812 ..Node::default()
813 }
814 }
815
816 #[test]
817 fn absent_nodes_leave_the_next_frame() {
818 let registry = SemanticRegistry::new();
819 registry.begin_frame();
820 registry.record(node("window", None));
821 registry.record(node("old", Some("window")));
822 assert!(registry.snapshot().contains("old"));
823
824 registry.begin_frame();
825 registry.record(node("window", None));
826 assert!(!registry.snapshot().contains("old"));
827 }
828
829 #[test]
830 fn registration_order_is_stable_and_not_alphabetical() {
831 let registry = SemanticRegistry::new();
832 registry.begin_frame();
833 registry.record(node("z", None));
834 registry.record(node("a", None));
835 assert_eq!(registry.snapshot().ids(), vec!["z", "a"]);
836 }
837
838 #[test]
839 fn duplicate_registrations_remain_visible_to_automation() {
840 let registry = SemanticRegistry::new();
841 registry.begin_frame();
842 registry.record(node("row", None));
843 registry.record(node("row", None));
844 assert_eq!(registry.snapshot().ids(), vec!["row", "row"]);
845 }
846
847 #[test]
848 fn descendants_follow_the_declared_parent_chain() {
849 let snapshot = Snapshot {
850 generation: 1,
851 nodes: vec![
852 node("root", None),
853 node("child", Some("root")),
854 node("grandchild", Some("child")),
855 ],
856 };
857 assert_eq!(
858 snapshot
859 .descendants_of("root")
860 .iter()
861 .map(|node| node.id.as_str())
862 .collect::<Vec<_>>(),
863 vec!["child", "grandchild"]
864 );
865 }
866
867 #[test]
868 fn descendants_do_not_loop_on_a_malformed_parent_cycle() {
869 let snapshot = Snapshot {
870 generation: 1,
871 nodes: vec![node("a", Some("b")), node("b", Some("a"))],
872 };
873 assert_eq!(
874 snapshot
875 .descendants_of("a")
876 .iter()
877 .map(|node| node.id.as_str())
878 .collect::<Vec<_>>(),
879 vec!["b"]
880 );
881 }
882
883 #[test]
884 fn adjacent_bounds_do_not_overlap() {
885 let left = Rect {
886 x: 0.0,
887 y: 0.0,
888 width: 100.0,
889 height: 100.0,
890 };
891 let right = Rect { x: 100.0, ..left };
892 assert!(!left.overlaps(right));
893 }
894
895 #[test]
896 fn exported_text_redacts_credential_shapes() {
897 for secret in [
898 "sk-secret-value",
899 "Bearer credential",
900 "api_key=hunter2",
901 "eyJaaaaaaaaaa.bbbbbbbbbbbb.cccccccccccc",
902 ] {
903 assert_eq!(redact_sensitive_text(secret), "[REDACTED]");
904 }
905 assert_eq!(redact_sensitive_text("Token usage"), "Token usage");
906 }
907
908 #[test]
909 fn a_frame_that_publishes_nothing_reports_an_empty_tree() {
910 let registry = SemanticRegistry::new();
911 registry.begin_frame();
912 registry.record(node("toast", None));
913 assert_eq!(registry.snapshot().ids(), vec!["toast"]);
914
915 registry.begin_frame();
916 assert!(
917 registry.snapshot().nodes.is_empty(),
918 "a removed element must not linger in the next frame"
919 );
920 }
921
922 #[test]
923 fn a_node_that_stops_rendering_leaves_the_snapshot() {
924 let registry = SemanticRegistry::new();
925 registry.begin_frame();
926 registry.record(node("row.a", None));
927 registry.record(node("row.b", None));
928
929 registry.begin_frame();
930 registry.record(node("row.a", None));
931 assert_eq!(registry.snapshot().ids(), vec!["row.a"]);
932 }
933
934 #[test]
935 fn snapshots_are_serializable_protocol_data() {
936 let snapshot = Snapshot {
937 generation: 4,
938 nodes: vec![node("window", None)],
939 };
940 let encoded = serde_json::to_string(&snapshot).expect("serialize");
941 let decoded: Snapshot = serde_json::from_str(&encoded).expect("deserialize");
942 assert_eq!(decoded, snapshot);
943 }
944
945 #[test]
946 fn unused_state_fields_stay_out_of_serialized_snapshots() {
947 let encoded = serde_json::to_string(&node("window", None)).expect("serialize");
948 for absent in ["checked", "expanded", "value", "busy", "invalid", "level"] {
949 assert!(!encoded.contains(absent), "{absent} must not be emitted");
950 }
951 }
952
953 #[test]
954 fn older_snapshots_still_deserialize() {
955 let legacy = r#"{
956 "generation": 2,
957 "nodes": [{
958 "id": "run", "role": "button", "parent": null, "text": "Run",
959 "bounds": {"x": 0, "y": 0, "width": 10, "height": 10},
960 "visible": true, "focused": false, "disabled": false,
961 "selected": false, "hovered": false, "pressed": false
962 }]
963 }"#;
964 let snapshot: Snapshot = serde_json::from_str(legacy).expect("legacy snapshot");
965 let node = snapshot.find("run").expect("node");
966 assert_eq!(node.checked, None);
967 assert!(!node.busy);
968 }
969
970 #[test]
971 fn recorded_values_are_redacted_like_text() {
972 assert_eq!(redact_sensitive_text("sk-live-value"), "[REDACTED]");
973 }
974
975 #[test]
976 fn host_constructed_snapshot_text_descriptions_and_values_are_redacted() {
977 let mut exposed = node("credential", None);
978 exposed.text = Some("Bearer text-secret".into());
979 exposed.description = Some("sk-description-secret".into());
980 exposed.value = Some("xai-value-secret".into());
981
982 let snapshot = Snapshot {
983 generation: 1,
984 nodes: vec![exposed],
985 }
986 .redacted();
987 let protected = snapshot.find("credential").expect("protected node");
988 assert_eq!(protected.text.as_deref(), Some("[REDACTED]"));
989 assert_eq!(protected.description.as_deref(), Some("[REDACTED]"));
990 assert_eq!(protected.value.as_deref(), Some("[REDACTED]"));
991 }
992
993 struct PlatformTreeFixture {
994 focus: FocusHandle,
995 clicks: Rc<Cell<usize>>,
996 }
997
998 impl Render for PlatformTreeFixture {
999 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1000 div()
1001 .child(
1002 div().w(px(160.0)).h(px(24.0)).semantic_in(
1003 cx,
1004 NodeSpec::new("volume", Role::Slider)
1005 .text("Volume")
1006 .value("40 percent")
1007 .range(0.0, 100.0, 40.0)
1008 .focus(&self.focus)
1009 .disabled(true)
1010 .invalid(true)
1011 .required(true)
1012 .busy(true),
1013 ),
1014 )
1015 .child(
1016 div()
1017 .child({
1018 let clicks = self.clicks.clone();
1019 div()
1020 .id("choice")
1021 .on_click(move |_, _, _| clicks.set(clicks.get() + 1))
1022 .w(px(100.0))
1023 .h(px(24.0))
1024 .semantic_in(
1025 cx,
1026 NodeSpec::new("choice", Role::Checkbox)
1027 .text("Use system setting")
1028 .checked(true),
1029 )
1030 })
1031 .semantic_in(cx, NodeSpec::new("settings", Role::Group).text("Settings")),
1032 )
1033 .child(
1034 div().w(px(100.0)).h(px(24.0)).semantic_in(
1035 cx,
1036 NodeSpec::new("quality", Role::Option)
1037 .text("High quality")
1038 .selected(true),
1039 ),
1040 )
1041 }
1042 }
1043
1044 struct DiagnosticFixture;
1045
1046 impl Render for DiagnosticFixture {
1047 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1048 SemanticRegistry::global(cx).begin_frame();
1049 div().w(px(120.0)).h(px(24.0)).semantic_in(
1050 cx,
1051 NodeSpec::new("diagnostic", Role::Status).text("Diagnostic"),
1052 )
1053 }
1054 }
1055
1056 #[gpui::test]
1057 fn diagnostics_work_before_and_after_accessibility_activation(cx: &mut TestAppContext) {
1058 cx.update(install);
1059 let window = AnyWindowHandle::from(cx.add_window(|_, _| DiagnosticFixture));
1060
1061 cx.update_window(window, |_, window, cx| {
1062 assert!(!window.is_a11y_active());
1063 window.draw(cx).clear(cx);
1064 assert!(
1065 SemanticRegistry::global(cx)
1066 .snapshot()
1067 .contains("diagnostic")
1068 );
1069 assert!(window.debug_a11y_tree_json().is_none());
1070 })
1071 .expect("inactive test window");
1072
1073 cx.activate_accessibility(window);
1074 cx.update_window(window, |_, window, cx| {
1075 window.draw(cx).clear(cx);
1076 assert!(window.is_a11y_active());
1077 assert!(
1078 SemanticRegistry::global(cx)
1079 .snapshot()
1080 .contains("diagnostic")
1081 );
1082 let tree = window
1083 .debug_a11y_tree_json()
1084 .expect("committed active accessibility tree");
1085 let tree: serde_json::Value = serde_json::from_str(&tree).expect("valid tree JSON");
1086 assert!(tree["nodes"].as_object().is_some_and(|nodes| {
1087 nodes.values().any(|node| {
1088 node["aria"]["role"] == "Status" && node["aria"]["label"] == "Diagnostic"
1089 })
1090 }));
1091 })
1092 .expect("active test window");
1093 }
1094
1095 #[gpui::test]
1096 fn semantics_reach_the_deterministic_platform_tree(cx: &mut TestAppContext) {
1097 let clicks = Rc::new(Cell::new(0));
1098 let fixture_clicks = clicks.clone();
1099 let window = cx.add_window(|window, cx| {
1100 let focus = cx.focus_handle();
1101 window.focus(&focus, cx);
1102 PlatformTreeFixture {
1103 focus,
1104 clicks: fixture_clicks,
1105 }
1106 });
1107 let window = AnyWindowHandle::from(window);
1108
1109 cx.activate_accessibility(window);
1110 let json = cx
1111 .update_window(window, |_, window, cx| {
1112 window.draw(cx).clear(cx);
1113 window
1114 .debug_a11y_tree_json()
1115 .expect("active accessibility tree")
1116 })
1117 .expect("test window");
1118 let tree: serde_json::Value = serde_json::from_str(&json).expect("valid tree JSON");
1119 let (node_id, node) = tree["nodes"]
1120 .as_object()
1121 .and_then(|nodes| {
1122 nodes.iter().find(|(_, node)| {
1123 node["aria"]["role"] == "Slider" && node["aria"]["label"] == "Volume"
1124 })
1125 })
1126 .unwrap_or_else(|| panic!("semantic node missing from AccessKit tree: {json}"));
1127
1128 assert_eq!(tree["gpui_focus"], node_id.as_str());
1129 assert_eq!(node["aria"]["role"], "Slider");
1130 assert_eq!(node["aria"]["label"], "Volume");
1131 assert_eq!(node["aria"]["value"], "40 percent");
1132 assert_eq!(node["aria"]["numeric_value"], 40.0);
1133 assert_eq!(node["aria"]["min_numeric_value"], 0.0);
1134 assert_eq!(node["aria"]["max_numeric_value"], 100.0);
1135 assert_eq!(node["aria"]["disabled"], true);
1136 assert_eq!(node["aria"]["invalid"], "True");
1137 assert_eq!(node["aria"]["required"], true);
1138 assert_eq!(node["aria"]["busy"], true);
1139
1140 let nodes = tree["nodes"].as_object().expect("nodes object");
1141 let choice = nodes
1142 .iter()
1143 .find(|(_, node)| node["aria"]["label"] == "Use system setting")
1144 .expect("stateful checkbox node");
1145 assert_eq!(choice.1["aria"]["role"], "CheckBox");
1146 assert_eq!(choice.1["aria"]["toggled"], "True");
1147 assert!(
1148 choice.1["aria"]["on_action"]
1149 .as_array()
1150 .is_some_and(|actions| actions.iter().any(|action| action == "Click"))
1151 );
1152 let settings = nodes
1153 .iter()
1154 .find(|(_, node)| node["aria"]["label"] == "Settings")
1155 .expect("plain semantic parent is a platform node");
1156 assert_eq!(settings.1["children"][0], choice.0.as_str());
1157
1158 cx.dispatch_accessibility_action(
1159 window,
1160 gpui::accesskit::ActionRequest {
1161 action: gpui::accesskit::Action::Click,
1162 target_tree: gpui::accesskit::TreeId::ROOT,
1163 target_node: gpui::accesskit::NodeId(
1164 choice.1["accesskit_id"]
1165 .as_str()
1166 .expect("raw AccessKit node id")
1167 .parse()
1168 .expect("numeric node id"),
1169 ),
1170 data: None,
1171 },
1172 );
1173 assert_eq!(clicks.get(), 1);
1174
1175 let option = nodes
1176 .values()
1177 .find(|node| node["aria"]["label"] == "High quality")
1178 .expect("selected option node");
1179 assert_eq!(option["aria"]["role"], "ListBoxOption");
1180 assert_eq!(option["aria"]["selected"], true);
1181 }
1182
1183 #[test]
1184 #[should_panic(expected = "semantic and GPUI element ids must match")]
1185 fn stateful_semantics_reject_mismatched_identity() {
1186 let element = div().id("actual");
1187 let _ = platform_accessible(element, &NodeSpec::new("claimed", Role::Button));
1188 }
1189
1190 #[test]
1191 fn static_status_is_not_a_live_region_without_explicit_ownership() {
1192 assert_eq!(NodeSpec::new("count", Role::Status).live, None);
1193 assert_eq!(NodeSpec::new("toast", Role::Toast).live, None);
1194 assert_eq!(
1195 NodeSpec::new("announcement", Role::Status)
1196 .live(LiveRegion::Polite)
1197 .live,
1198 Some(LiveRegion::Polite)
1199 );
1200 }
1201}