Skip to main content

gpuikit/elements/
form.rs

1//! Grouping and label association — and deliberately nothing else.
2//!
3//! This module is #164's answer, and its title is the whole of the scope:
4//! *grouping and label association, not form state*. There is no submission,
5//! no validation, no dirty tracking and no form-level value store here.
6//! Every one of those is a decision about the consuming application's
7//! architecture, and a toolkit that takes it is one people fight rather than
8//! use — the line Headless UI draws, and the one #164 argued for.
9//!
10//! What it does provide is two things a control genuinely cannot work out for
11//! itself:
12//!
13//! - **A group.** [`Fieldset`] draws a legend, a description and a
14//!   *group-level* error — the summary that previously had nowhere to go,
15//!   because [`Field`](crate::elements::field::Field)'s error is about one
16//!   control — and announces itself as a named `Role::Group`.
17//! - **An ambient [`FormContext`].** A `Fieldset`'s `disabled` reaches every
18//!   control inside it, and a `Field`'s label reaches the control beside it,
19//!   without either being threaded through a prop by hand.
20//!
21//! # Why the context is ambient
22//!
23//! A `Field`'s child is an `AnyElement`: by the time the field has it, the
24//! control inside is opaque and no builder can reach it. Threading a prop
25//! would mean every container taking a `disabled` and every caller passing it
26//! down — which is the thing the cascade exists to remove, reintroduced one
27//! call site at a time.
28//!
29//! The mechanism is a thread-local stack, pushed around the child's layout and
30//! paint by [`WithFormContext`]. It works because gpui renders a subtree
31//! *inside* its ancestor's layout pass: `ViewElement::request_layout` calls
32//! `RenderOnce::render` and then lays out the result, and `Div` does the same
33//! for each of its children, so a whole descendant subtree is built while the
34//! ancestor's `request_layout` is on the Rust stack. The scope is opened in
35//! `prepaint` and `paint` too, since those walk the subtree again.
36//!
37//! A `gpui::Global` was considered and rejected: reading one needs `&mut App`,
38//! which puts a `cx` argument on [`disabled_here`] and takes the ambient value
39//! straight back to being threaded by hand.
40//!
41//! # Reading it from a control
42//!
43//! One line in `render`, before the element's fields are moved: bind
44//! [`disabled_here`]'s result and pass it down.
45//!
46//! `render` runs during `request_layout`, which is *inside* the scope. That is
47//! the only place a control should read it.
48//!
49//! ## The one rule about out-of-line draws
50//!
51//! **Read the ambient value in `render` and pass what you read into anything
52//! you draw out of line; never call [`disabled_here`] from inside a
53//! draw-deferring closure.** `Window::defer_draw` clones and restores gpui's
54//! *element-id* stack, not this one, so a closure that runs after the frame's
55//! layout has unwound reads an empty stack and gets `false`.
56//!
57//! This is narrower than "out-of-line draws are broken". A popover inside a
58//! disabled `Fieldset` reads `disabled_here` in its own `render` — inside the
59//! scope, correct — and captures the answer; only a read performed *within*
60//! the closure sees nothing. Hand-threading `disabled(true)` into every popup
61//! is not the fix, and would be the threading this design exists to remove.
62//!
63//! The case that genuinely cannot be handled this way is a closure that is
64//! built once and drawn against a *different* ambient value later — a panel
65//! cached across frames, or one whose content is produced by a callback the
66//! containing element stores rather than calls during layout. Such a callback
67//! has to be given the value as an argument, because there is no frame in
68//! which it and the group are on the stack together.
69
70use std::cell::RefCell;
71use std::collections::HashMap;
72use std::panic::Location;
73
74use crate::a11y::{A11y, Announce};
75use crate::layout::v_stack;
76use crate::theme::{ActiveTheme, ControlSize, Themeable};
77use crate::traits::accessible::Accessible;
78use crate::traits::control_sized::ControlSized;
79use crate::traits::disableable::Disableable;
80use gpui::{
81    AnyElement, App, Bounds, ElementId, FocusHandle, GlobalElementId, InspectorElementId,
82    InteractiveElement, IntoElement, LayoutId, ParentElement, Pixels, RenderOnce, Role,
83    SharedString, Styled, Window, div, prelude::FluentBuilder, rems,
84};
85
86/// What a group tells the controls inside it.
87///
88/// Three fields, each answering a question a control cannot answer alone:
89/// whether an enclosing group has disabled it, what an enclosing
90/// [`Field`](crate::elements::field::Field) calls it, and which focus handle
91/// that field's label clicks.
92#[derive(Clone, Default, PartialEq)]
93pub struct FormContext {
94    /// Whether an enclosing group has disabled everything inside it.
95    pub disabled: bool,
96    /// The accessible name an enclosing field publishes for the control beside
97    /// its label.
98    pub name: Option<SharedString>,
99    /// The handle a label click focuses, for the control that agrees to track
100    /// it.
101    pub focus_handle: Option<FocusHandle>,
102}
103
104impl std::fmt::Debug for FormContext {
105    /// `FocusHandle` is not `Debug`, and the useful fact about it here is
106    /// whether there is one at all.
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        f.debug_struct("FormContext")
109            .field("disabled", &self.disabled)
110            .field("name", &self.name)
111            .field("focus_handle", &self.focus_handle.is_some())
112            .finish()
113    }
114}
115
116impl FormContext {
117    /// An empty context — nothing disabled, nothing named.
118    pub fn new() -> Self {
119        Self::default()
120    }
121
122    /// Disable everything in this scope.
123    pub fn disabled(mut self, disabled: bool) -> Self {
124        self.disabled = disabled;
125        self
126    }
127
128    /// Publish an accessible name for the control in this scope.
129    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
130        self.name = Some(name.into());
131        self
132    }
133
134    /// Publish the focus handle a label click should land on.
135    pub fn focus_handle(mut self, handle: FocusHandle) -> Self {
136        self.focus_handle = Some(handle);
137        self
138    }
139
140    /// This context as it reads when nested inside `outer`.
141    ///
142    /// `disabled` is OR'd: an inner scope can disable, and cannot re-enable.
143    /// A `Fieldset` that says nothing about `disabled` inside a disabled one
144    /// is still disabled, and a `Field` cannot rescue a control from the group
145    /// around it — which is the only reading of "the group is disabled" that
146    /// is worth anything.
147    ///
148    /// `name` and `focus_handle` are *inherited when unset*: the nearest
149    /// enclosing answer wins, and a scope that has none passes the outer one
150    /// through.
151    pub fn over(mut self, outer: &FormContext) -> Self {
152        self.disabled |= outer.disabled;
153        if self.name.is_none() {
154            self.name = outer.name.clone();
155        }
156        if self.focus_handle.is_none() {
157            self.focus_handle = outer.focus_handle.clone();
158        }
159        self
160    }
161}
162
163thread_local! {
164    /// The open scopes, innermost last.
165    ///
166    /// Thread-local rather than global: gpui builds a window's element tree on
167    /// one thread, and a value that crossed threads would be a value two
168    /// windows could disagree about.
169    static SCOPES: RefCell<Vec<FormContext>> = const { RefCell::new(Vec::new()) };
170}
171
172/// An open scope, which closes when this is dropped.
173///
174/// A guard rather than a matched pair of calls: a child that panics mid-layout
175/// would otherwise leave the stack one deep for every element drawn after it,
176/// and the symptom — an unrelated control announcing itself disabled — would
177/// point nowhere near the cause.
178#[must_use = "the scope closes as soon as this guard is dropped"]
179pub struct FormScope {
180    _private: (),
181}
182
183impl Drop for FormScope {
184    fn drop(&mut self) {
185        SCOPES.with(|scopes| {
186            scopes.borrow_mut().pop();
187        });
188    }
189}
190
191/// Open a scope carrying `context`, nested inside whatever is already open.
192///
193/// Public because [`WithFormContext`] is not the only thing that could
194/// legitimately open one — a container that is a hand-written `Element`
195/// already can — but a control should never need to call it.
196pub fn push(context: FormContext) -> FormScope {
197    SCOPES.with(|scopes| {
198        let mut scopes = scopes.borrow_mut();
199        let nested = match scopes.last() {
200            Some(outer) => context.over(outer),
201            None => context,
202        };
203        scopes.push(nested);
204    });
205    FormScope { _private: () }
206}
207
208/// Run `f` inside a scope carrying `context`.
209pub fn scope<R>(context: FormContext, f: impl FnOnce() -> R) -> R {
210    let _guard = push(context);
211    f()
212}
213
214/// The innermost open [`FormContext`], if any.
215pub fn current() -> Option<FormContext> {
216    SCOPES.with(|scopes| scopes.borrow().last().cloned())
217}
218
219/// Whether a control with its own `own` disabled flag is disabled here.
220///
221/// The whole of a control's adoption cost. `own` wins where it is `true`, and
222/// the ambient value supplies the answer where it is not.
223pub fn disabled_here(own: bool) -> bool {
224    own || SCOPES.with(|scopes| scopes.borrow().last().is_some_and(|scope| scope.disabled))
225}
226
227/// The accessible name an enclosing group publishes, if any.
228///
229/// A control announces this only when it has no name of its own — its own
230/// visible text always wins, per `a11y`'s section 2.
231pub fn name_here() -> Option<SharedString> {
232    SCOPES.with(|scopes| scopes.borrow().last().and_then(|scope| scope.name.clone()))
233}
234
235/// The focus handle an enclosing label clicks, if any.
236///
237/// A control that tracks this is one whose label can be clicked to focus it.
238/// A control that does not simply leaves the click inert.
239pub fn focus_handle_here() -> Option<FocusHandle> {
240    SCOPES.with(|scopes| {
241        scopes
242            .borrow()
243            .last()
244            .and_then(|scope| scope.focus_handle.clone())
245    })
246}
247
248thread_local! {
249    /// One [`FocusHandle`] per field id, so that the handle a label publishes
250    /// in one frame is the handle a control tracked in the last one.
251    ///
252    /// `Window::use_keyed_state` — the obvious home for this — cannot be used:
253    /// it goes through `with_element_state`, which `debug_assert!`s that it is
254    /// called during prepaint or paint, and `RenderOnce::render` runs during
255    /// `request_layout`.
256    ///
257    /// **This never evicts.** Its size is the number of *distinct field ids
258    /// ever rendered on this thread*, not the number of fields on screen. For
259    /// a form whose ids are written down in the source that is a fixed, small
260    /// number. For a caller that derives a field id from a row, a record or a
261    /// task — the ordinary way an id gets made in a list-driven application —
262    /// it grows without limit, one `FocusHandle` per id, for the life of the
263    /// process.
264    ///
265    /// Evicting an entry whose only remaining reference is this map was
266    /// considered and not done: `FocusHandle` exposes no reference count, so
267    /// "nobody else holds this" is not a question that can be asked from here,
268    /// and dropping a handle a control still tracks would silently unfocus it.
269    /// The fix that is actually available is an explicit one:
270    /// [`clear_field_focus_handles`], which a long-lived list view calls when
271    /// its backing collection changes.
272    static FIELD_FOCUS_HANDLES: RefCell<HashMap<ElementId, FocusHandle>> =
273        RefCell::new(HashMap::new());
274}
275
276/// The focus handle belonging to the field with this id, minting one the first
277/// time it is asked for.
278///
279/// Stable across frames, which is the whole point: a label click focuses the
280/// same handle the control tracked when it was drawn.
281pub fn field_focus_handle(id: &ElementId, cx: &mut App) -> FocusHandle {
282    FIELD_FOCUS_HANDLES.with(|handles| {
283        let mut handles = handles.borrow_mut();
284        handles
285            .entry(id.clone())
286            .or_insert_with(|| cx.focus_handle())
287            .clone()
288    })
289}
290
291/// Drop every cached field focus handle on this thread.
292///
293/// The explicit eviction `FIELD_FOCUS_HANDLES` describes: a long-lived view
294/// that derives field ids from a changing collection (a row, a record, a task)
295/// calls this when that collection changes, so the map does not grow one handle
296/// per id ever seen for the life of the process. The next
297/// [`field_focus_handle`] for any id mints a fresh handle; call it only when no
298/// field currently on screen still needs its handle to stay put (e.g. between
299/// tearing down one list and building the next), since a control that is still
300/// tracking a dropped handle would be silently unfocused.
301pub fn clear_field_focus_handles() {
302    FIELD_FOCUS_HANDLES.with(|handles| handles.borrow_mut().clear());
303}
304
305/// Wraps one child so that everything drawn inside it sees `context`.
306///
307/// The one hand-written [`gpui::Element`] under `src/elements/`, and it is one
308/// because the scope has to be open across `request_layout`, `prepaint` and
309/// `paint` — three moments a `RenderOnce` cannot get between. It reports no id
310/// and no role, so it adds nothing to the accessibility tree and nothing to
311/// the element-id path: wrapping a subtree in it cannot change what any
312/// element inside it is called.
313pub struct WithFormContext {
314    context: FormContext,
315    child: AnyElement,
316}
317
318impl WithFormContext {
319    /// Wrap `child` so that it, and everything it draws, reads `context`.
320    pub fn new(context: FormContext, child: impl IntoElement) -> Self {
321        Self {
322            context,
323            child: child.into_any_element(),
324        }
325    }
326}
327
328impl IntoElement for WithFormContext {
329    type Element = Self;
330
331    fn into_element(self) -> Self::Element {
332        self
333    }
334}
335
336// `gpui::Element` rather than an imported `Element`: the trait's own `id(&self)`
337// would otherwise shadow `InteractiveElement::id(self, …)` on every `div()` in
338// this module.
339impl gpui::Element for WithFormContext {
340    type RequestLayoutState = ();
341    type PrepaintState = ();
342
343    fn id(&self) -> Option<ElementId> {
344        None
345    }
346
347    fn source_location(&self) -> Option<&'static Location<'static>> {
348        None
349    }
350
351    fn request_layout(
352        &mut self,
353        _id: Option<&GlobalElementId>,
354        _inspector_id: Option<&InspectorElementId>,
355        window: &mut Window,
356        cx: &mut App,
357    ) -> (LayoutId, ()) {
358        let _scope = push(self.context.clone());
359        (self.child.request_layout(window, cx), ())
360    }
361
362    fn prepaint(
363        &mut self,
364        _id: Option<&GlobalElementId>,
365        _inspector_id: Option<&InspectorElementId>,
366        _bounds: Bounds<Pixels>,
367        _request_layout: &mut (),
368        window: &mut Window,
369        cx: &mut App,
370    ) {
371        let _scope = push(self.context.clone());
372        self.child.prepaint(window, cx);
373    }
374
375    fn paint(
376        &mut self,
377        _id: Option<&GlobalElementId>,
378        _inspector_id: Option<&InspectorElementId>,
379        _bounds: Bounds<Pixels>,
380        _request_layout: &mut (),
381        _prepaint: &mut (),
382        window: &mut Window,
383        cx: &mut App,
384    ) {
385        let _scope = push(self.context.clone());
386        self.child.paint(window, cx);
387    }
388}
389
390/// A group of related controls, with a legend and a `disabled` that cascades.
391///
392/// # Example
393///
394/// ```
395/// # use gpui::{Context, IntoElement, Render, Window, div, prelude::*};
396/// use gpuikit::elements::field::field;
397/// use gpuikit::elements::form::fieldset;
398/// use gpuikit::traits::disableable::Disableable;
399/// use gpuikit::traits::labelable::Labelable;
400/// # struct D { editable: bool }
401/// # impl Render for D { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
402/// # let (street_input, city_input) = (div(), div());
403/// fieldset("billing")
404///     .legend("Billing address")
405///     .error("This address could not be verified")
406///     .disabled(!self.editable)
407///     .child(field("street").label("Street").child(street_input))
408///     .child(field("city").label("City").child(city_input))
409/// # }}
410/// # let mut tcx = gpui::TestAppContext::single();
411/// # tcx.update(gpuikit::init);
412/// # let _ = tcx.add_window_view(|_, _| D { editable: true });
413/// ```
414///
415/// The `disabled(!self.editable)` is the point: neither field, and neither
416/// control inside them, says anything about `disabled` at all.
417#[derive(IntoElement)]
418pub struct Fieldset {
419    id: ElementId,
420    legend: Option<SharedString>,
421    description: Option<SharedString>,
422    error: Option<SharedString>,
423    disabled: bool,
424    size: ControlSize,
425    children: Vec<AnyElement>,
426}
427
428impl Fieldset {
429    /// A fieldset with the given id.
430    ///
431    /// The id is required, and required to be unique among everything drawn in
432    /// a frame — see [`crate::element_id`]. It carries the group's
433    /// accessibility node.
434    pub fn new(id: impl Into<ElementId>) -> Self {
435        Self {
436            id: id.into(),
437            legend: None,
438            description: None,
439            error: None,
440            disabled: false,
441            size: ControlSize::default(),
442            children: Vec::new(),
443        }
444    }
445
446    /// The group's visible heading, which is also its accessible name.
447    pub fn legend(mut self, legend: impl Into<SharedString>) -> Self {
448        self.legend = Some(legend.into());
449        self
450    }
451
452    /// Help text for the group as a whole.
453    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
454        self.description = Some(description.into());
455        self
456    }
457
458    /// An error about the group rather than about one control in it.
459    ///
460    /// "These dates overlap" belongs to neither date field. Before this there
461    /// was nowhere to put it but one of the two, which said something untrue
462    /// about that control.
463    pub fn error(mut self, error: impl Into<SharedString>) -> Self {
464        self.error = Some(error.into());
465        self
466    }
467}
468
469/// Creates a new [`Fieldset`] with the given id.
470pub fn fieldset(id: impl Into<ElementId>) -> Fieldset {
471    Fieldset::new(id)
472}
473
474impl Disableable for Fieldset {
475    fn is_disabled(&self) -> bool {
476        self.disabled
477    }
478
479    /// Disables **everything inside the group**, through the ambient
480    /// [`FormContext`] — not only the legend and the error this element draws
481    /// itself. A control inside that reads [`disabled_here`] needs no
482    /// `disabled` of its own.
483    fn disabled(mut self, disabled: bool) -> Self {
484        self.disabled = disabled;
485        self
486    }
487}
488
489impl ControlSized for Fieldset {
490    fn control_size(mut self, size: ControlSize) -> Self {
491        self.size = size;
492        self
493    }
494}
495
496impl ParentElement for Fieldset {
497    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
498        self.children.extend(elements);
499    }
500}
501
502impl Accessible for Fieldset {
503    /// A named `Role::Group`. `Group` is read rather than operated, so it
504    /// needs neither a required name nor a focus decision — but a legend is a
505    /// name, and where there is one it is the group's.
506    fn a11y(&self) -> A11y {
507        let a11y = A11y::new(Role::Group);
508        match &self.legend {
509            Some(legend) => a11y.name(legend.clone()),
510            None => a11y,
511        }
512    }
513}
514
515impl RenderOnce for Fieldset {
516    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
517        let a11y = self.a11y();
518        let theme = cx.theme();
519        let metrics = theme.control(self.size);
520        // Its own `disabled`, over any group already around it: a fieldset
521        // inside a disabled fieldset is disabled, and dims its own legend to
522        // say so.
523        let disabled = disabled_here(self.disabled);
524
525        let legend = self.legend.map(|legend| {
526            div()
527                .text_size(metrics.text_size)
528                .font_weight(gpui::FontWeight::SEMIBOLD)
529                .text_color(if disabled {
530                    theme.fg_disabled()
531                } else {
532                    theme.fg()
533                })
534                .child(legend)
535        });
536
537        let description = self.description.map(|description| {
538            div()
539                .text_xs()
540                .text_color(if disabled {
541                    theme.fg_disabled()
542                } else {
543                    theme.fg_muted()
544                })
545                .child(description)
546        });
547
548        let error = self
549            .error
550            .map(|error| div().text_xs().text_color(theme.danger()).child(error));
551
552        // The legend, the description and the error sit *outside* the context
553        // wrapper. They are the group talking about itself, not controls in
554        // it, and an error that dimmed itself when the group was disabled
555        // would be least readable exactly when it mattered most.
556        v_stack()
557            .id(self.id)
558            .announce(a11y)
559            .gap(rems(0.5))
560            .when_some(legend, |this, legend| this.child(legend))
561            .when_some(description, |this, description| this.child(description))
562            .child(WithFormContext::new(
563                FormContext::new().disabled(disabled),
564                v_stack().gap(rems(0.75)).children(self.children),
565            ))
566            .when_some(error, |this, error| this.child(error))
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use crate::a11y::test_support::announced;
574    use crate::elements::checkbox::{Checkbox, checkbox};
575    use crate::elements::field::field;
576    use crate::traits::labelable::Labelable;
577    use gpui::{
578        AnyElement, AppContext, Bounds, Context, Modifiers, Render, TestAppContext,
579        VisualTestContext, px, size,
580    };
581
582    /// Draws whatever the test's closure builds, every frame.
583    ///
584    /// A real *view* rather than `VisualTestContext::draw`: registering a
585    /// mouse listener reads `Window::current_view`, which is only set while a
586    /// view renders. The same note `elements::button`'s and
587    /// `elements::sidebar`'s harnesses carry.
588    struct Harness {
589        build: Box<dyn Fn(&mut Window, &mut App) -> AnyElement>,
590    }
591
592    impl Render for Harness {
593        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
594            (self.build)(window, cx)
595        }
596    }
597
598    fn draw(
599        cx: &mut TestAppContext,
600        build: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
601    ) -> &'static mut VisualTestContext {
602        let window = cx.open_window(size(px(600.), px(400.)), move |_window, _cx| Harness {
603            build: Box::new(build),
604        });
605        let cx = VisualTestContext::from_window(*std::ops::Deref::deref(&window), cx).into_mut();
606        cx.run_until_parked();
607        cx
608    }
609
610    fn bounds(cx: &mut VisualTestContext, selector: &'static str) -> Bounds<Pixels> {
611        cx.debug_bounds(selector)
612            .unwrap_or_else(|| panic!("`{selector}` was never laid out"))
613    }
614
615    fn click(cx: &mut VisualTestContext, selector: &'static str) {
616        let target = bounds(cx, selector).center();
617        cx.simulate_click(target, Modifiers::default());
618        cx.run_until_parked();
619    }
620
621    /// The state every read API has to survive: nothing open.
622    #[test]
623    fn an_empty_stack_reads_as_nothing() {
624        assert_eq!(current(), None);
625        assert!(!disabled_here(false));
626        assert!(disabled_here(true), "a control's own flag still wins");
627        assert_eq!(name_here(), None);
628        assert!(focus_handle_here().is_none());
629    }
630
631    #[test]
632    fn a_scope_is_visible_inside_it_and_not_after_it() {
633        scope(FormContext::new().disabled(true).name("Street"), || {
634            assert!(disabled_here(false));
635            assert_eq!(name_here().as_deref(), Some("Street"));
636        });
637
638        assert!(!disabled_here(false));
639        assert_eq!(name_here(), None);
640    }
641
642    /// The rule that makes "the group is disabled" mean anything.
643    #[test]
644    fn nesting_cannot_re_enable() {
645        scope(FormContext::new().disabled(true), || {
646            scope(FormContext::new().disabled(false), || {
647                assert!(
648                    disabled_here(false),
649                    "an inner scope saying nothing about disabled must not re-enable"
650                );
651            });
652        });
653    }
654
655    #[test]
656    fn a_name_is_inherited_until_something_nearer_answers() {
657        scope(FormContext::new().name("outer"), || {
658            scope(FormContext::new(), || {
659                assert_eq!(name_here().as_deref(), Some("outer"));
660            });
661            scope(FormContext::new().name("inner"), || {
662                assert_eq!(name_here().as_deref(), Some("inner"));
663            });
664        });
665    }
666
667    /// A child that panics mid-layout must not leave the stack one deep — the
668    /// failure that would make an unrelated control announce itself disabled.
669    #[test]
670    fn the_guard_closes_the_scope_through_a_panic() {
671        let panicked = std::panic::catch_unwind(|| {
672            scope(FormContext::new().disabled(true), || {
673                panic!("a child blew up mid-layout");
674            })
675        });
676
677        assert!(panicked.is_err());
678        assert_eq!(current(), None, "the guard popped the scope anyway");
679        assert!(!disabled_here(false));
680    }
681
682    #[gpui::test]
683    fn a_fieldset_announces_its_legend_as_a_group(cx: &mut TestAppContext) {
684        cx.update(crate::theme::init);
685        let cx = cx.add_empty_window();
686
687        let announced = cx.update(|window, cx| {
688            announced(fieldset("billing").legend("Billing address"), window, cx)
689        });
690
691        assert_eq!(announced.role, Some(Role::Group));
692        assert_eq!(announced.name(), Some("Billing address"));
693        assert_eq!(announced.id, Some(ElementId::Name("billing".into())));
694    }
695
696    /// `Role::Group` is read rather than operated, so a legend-less fieldset is
697    /// a nameless node rather than a `debug_assert!`.
698    #[gpui::test]
699    fn a_fieldset_without_a_legend_still_announces(cx: &mut TestAppContext) {
700        cx.update(crate::theme::init);
701        let cx = cx.add_empty_window();
702
703        let announced = cx.update(|window, cx| announced(fieldset("billing"), window, cx));
704
705        assert_eq!(announced.role, Some(Role::Group));
706        assert_eq!(announced.name(), None);
707    }
708
709    // ============================================================
710    // END TO END
711    // ============================================================
712    //
713    // Everything above proves the stack. These two draw a real window with a
714    // real `Checkbox` entity in it, because that is the only way to see the
715    // three read APIs actually land: the scope is opened by
716    // `WithFormContext` during layout and paint, and no unit test over a
717    // `Vec` can tell you whether the element tree ever entered it.
718
719    /// A checkbox inside a disabled fieldset, where **nothing between them
720    /// says `disabled`** — not the fieldset's field, not the checkbox.
721    ///
722    /// With no `aria_disabled` in gpui, the click action a node does not offer
723    /// is what tells the two apart; that is the same test `Button`'s
724    /// `a_disabled_button_offers_no_click_action` runs, one layer further out.
725    #[gpui::test]
726    fn a_group_disables_a_checkbox_that_says_nothing_about_it(cx: &mut TestAppContext) {
727        cx.update(crate::init);
728
729        let (enabled, disabled) = cx.update(|cx| {
730            (
731                cx.new(|_cx| checkbox("consent-open", false).label("Consent")),
732                cx.new(|_cx| checkbox("consent-locked", false).label("Consent")),
733            )
734        });
735        let (loose, grouped) = (enabled.clone(), disabled.clone());
736
737        let cx = draw(cx, move |_window, _cx| {
738            v_stack()
739                .child(
740                    fieldset("open")
741                        .legend("Open")
742                        .child(field("consent-open").label("Consent").child(loose.clone())),
743                )
744                .child(
745                    fieldset("locked").legend("Locked").disabled(true).child(
746                        field("consent-locked")
747                            .label("Consent")
748                            .child(grouped.clone()),
749                    ),
750                )
751                .into_any_element()
752        });
753
754        let hit = |handle: &gpui::Entity<Checkbox>, cx: &mut VisualTestContext| {
755            cx.update(|_window, cx| handle.read(cx).is_checked())
756        };
757
758        // The enabled one toggles.
759        click(cx, r#"gpuikit-checkbox-Name("consent-open")"#);
760        assert!(
761            hit(&enabled, cx),
762            "the checkbox outside the disabled group still toggles"
763        );
764
765        // The one in the disabled group does not, and it was never told.
766        click(cx, r#"gpuikit-checkbox-Name("consent-locked")"#);
767        assert!(
768            !hit(&disabled, cx),
769            "a checkbox inside a disabled fieldset must not toggle, even though neither it \
770             nor its field says `disabled`"
771        );
772    }
773
774    /// The other half of label association: a click on a `Field`'s label lands
775    /// focus on the control beside it, through the handle the field published
776    /// and the checkbox tracked.
777    #[gpui::test]
778    fn a_click_on_a_field_label_focuses_the_control_it_names(cx: &mut TestAppContext) {
779        cx.update(crate::init);
780
781        let control = cx.update(|cx| cx.new(|_cx| checkbox("consent", false).label("Consent")));
782        let drawn = control.clone();
783
784        let cx = draw(cx, move |_window, _cx| {
785            fieldset("billing")
786                .legend("Billing address")
787                .child(field("street").label("Street").child(drawn.clone()))
788                .into_any_element()
789        });
790
791        assert!(
792            cx.update(|window, cx| window.focused(cx)).is_none(),
793            "nothing is focused before the click"
794        );
795
796        click(cx, "gpuikit-field-label");
797
798        let focused = cx.update(|window, cx| window.focused(cx));
799        let expected =
800            cx.update(|_window, cx| field_focus_handle(&ElementId::Name("street".into()), cx));
801        assert_eq!(
802            focused,
803            Some(expected),
804            "the label click focused the handle the field published and the checkbox tracked"
805        );
806        assert!(
807            !control.read_with(cx, |checkbox, _| checkbox.is_checked()),
808            "focusing a control is not operating it"
809        );
810    }
811
812    /// Two calls for the same id hand back the same handle, which is what
813    /// makes a label click land on the control the last frame drew.
814    #[gpui::test]
815    fn a_field_focus_handle_is_stable_per_id(cx: &mut TestAppContext) {
816        let (first, second, other) = cx.update(|cx| {
817            let id = ElementId::Name("street".into());
818            let other = ElementId::Name("city".into());
819            (
820                field_focus_handle(&id, cx),
821                field_focus_handle(&id, cx),
822                field_focus_handle(&other, cx),
823            )
824        });
825
826        assert_eq!(first, second);
827        assert_ne!(first, other);
828    }
829
830    #[gpui::test]
831    fn clearing_field_focus_handles_mints_fresh_ones(cx: &mut TestAppContext) {
832        let id = ElementId::Name("clear_field_focus_handles_test".into());
833        let before = cx.update(|cx| field_focus_handle(&id, cx));
834        assert_eq!(
835            before,
836            cx.update(|cx| field_focus_handle(&id, cx)),
837            "the handle is stable until cleared"
838        );
839
840        clear_field_focus_handles();
841
842        let after = cx.update(|cx| field_focus_handle(&id, cx));
843        assert_ne!(
844            before, after,
845            "clearing evicts the cache, so the same id mints a new handle"
846        );
847    }
848}