Skip to main content

gpui_kit_semantics/
lib.rs

1//! A per-frame semantic tree for native GPUI applications.
2//!
3//! Native windows have no DOM. Views attach a zero-paint probe to meaningful
4//! elements; prepaint records the bounds GPUI actually produced. Nodes absent
5//! from the next frame disappear instead of lingering as stale claims.
6
7use 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    /// A drag in flight: what is being carried, and where it would land.
61    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/// A single assertion target published for one frame.
97///
98/// Fields added after the initial protocol are omitted from serialized
99/// snapshots unless a component sets them, so recorded baselines stay stable
100/// as new roles gain state.
101#[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    /// The control this node names, for a label that belongs to a field it is
108    /// not the parent of. A test finds the field by reading its label.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub labels: Option<String>,
111    /// The diagnostic identity this node describes. This does not imply
112    /// native tree parentage or a platform described-by relationship.
113    #[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    /// Re-applies redaction.
210    ///
211    /// The probe already redacts recorded text and values; this covers nodes a
212    /// host constructed directly, and is idempotent.
213    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    // Keep registration order and duplicate ids. Collapsing this into a map
238    // would make the duplicate-id audit incapable of observing the error it
239    // exists to report.
240    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    /// Opens a frame, discarding everything the previous one published.
259    ///
260    /// Without the discard, a frame that publishes nothing would still report
261    /// the previous tree, and a test asserting that an element disappeared
262    /// would pass against a stale snapshot.
263    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    /// The tree published by the most recent completed frame.
275    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    /// The registry components self-register into.
291    ///
292    /// Panics when [`install`] has not run, because a missing registry would
293    /// otherwise silently produce empty snapshots that tests read as passes.
294    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    /// Records the committed value of an editable control.
395    ///
396    /// Values pass through [`redact_sensitive_text`] before publication so a
397    /// snapshot never carries a credential typed by a user.
398    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    /// Publishes supplementary literal help on the same native node.
464    ///
465    /// This maps to AccessKit's description property. It does not claim a
466    /// cross-tree described-by relationship.
467    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
468        self.description = Some(description.into());
469        self
470    }
471
472    /// Names the control this node labels.
473    ///
474    /// A label is rarely an ancestor of the field it names, so the
475    /// association is published rather than inferred from the tree.
476    pub fn labels(mut self, control: impl Into<SharedString>) -> Self {
477        self.labels = Some(control.into());
478        self
479    }
480
481    /// Records which semantic node this node describes without claiming a
482    /// native relationship or changing actual tree topology.
483    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
524/// Installs the process-wide registry that components self-register into.
525pub 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    /// Registers into the global registry when installed. Platform
537    /// accessibility remains active when a host opts out of test semantics.
538    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) = &registry 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}