Skip to main content

gpui_ai/
form.rs

1//! The controls a person answers with: one choice from a set, and one
2//! switch between two states.
3//!
4//! Written in the library's own grammar rather than wrapped from upstream.
5//! Upstream's checkbox and radio are `Styled` only on their outer frame —
6//! the box, its radius, its fill, and its size are fixed inside `render` —
7//! so a wrapped one arrives with a look that no amount of styling can bring
8//! into line with the rows, chips, and cards around it. Composing them here
9//! costs a `Role` and an `aria_*` per control, and buys the same selected
10//! surface, the same press ramp, the same focus ring, and the same size
11//! policy everything else in this crate already answers to.
12//!
13//! Both are controlled: the application owns which option is chosen and
14//! whether a switch is on, and these report a typed event asking for the
15//! change. Nothing here keeps durable state.
16
17use crate::control::{PressReleaseExt as _, QuietSurfaceExt as _, composed_button};
18use crate::handlers::SharedHandler;
19use crate::motion::disclosure_progress;
20use crate::surface::{hint, selection_surface};
21use crate::theme::SemanticStyledExt as _;
22use gpui::{
23    AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement,
24    ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
25    StyleRefinement, Styled, Window, accesskit, div, prelude::FluentBuilder as _,
26};
27use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _, v_flex};
28
29/// One option in a single-choice set.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct ChoiceOption {
32    id: SharedString,
33    label: SharedString,
34    description: Option<SharedString>,
35    disabled: bool,
36}
37
38impl ChoiceOption {
39    /// Creates an option with a stable identifier and its visible label.
40    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
41        Self {
42            id: id.into(),
43            label: label.into(),
44            description: None,
45            disabled: false,
46        }
47    }
48
49    /// A second line under the label, for what the option means.
50    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
51        self.description = Some(description.into());
52        self
53    }
54
55    /// Marks the option unavailable. It stays readable and stays announced.
56    pub fn disabled(mut self, disabled: bool) -> Self {
57        self.disabled = disabled;
58        self
59    }
60}
61
62/// What a choice group reports.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum ChoiceEvent {
65    /// A person picked an option.
66    Chosen {
67        /// The group's identifier.
68        group: SharedString,
69        /// The chosen option's identifier.
70        option: SharedString,
71    },
72}
73
74/// A set of options where exactly one is chosen.
75///
76/// # Example
77///
78/// ```
79/// use gpui_ai::prelude::{ChoiceGroup, ChoiceOption};
80///
81/// let flavours = ChoiceGroup::new("flavours", "How many flavours?")
82///     .options([
83///         ChoiceOption::new("three", "Three").description("The core line"),
84///         ChoiceOption::new("five", "Five"),
85///     ])
86///     .selected("three");
87/// ```
88#[derive(IntoElement)]
89pub struct ChoiceGroup {
90    id: SharedString,
91    label: Option<SharedString>,
92    options: Vec<ChoiceOption>,
93    selected: Option<SharedString>,
94    on_event: Option<SharedHandler<ChoiceEvent>>,
95    style: StyleRefinement,
96}
97
98impl ChoiceGroup {
99    /// Creates a group with a stable identifier and the question it asks.
100    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
101        Self {
102            id: id.into(),
103            label: Some(label.into()),
104            options: Vec::new(),
105            selected: None,
106            on_event: None,
107            style: StyleRefinement::default(),
108        }
109    }
110
111    /// Creates a group with no visible question of its own, for a caller
112    /// that has already asked one.
113    pub fn unlabelled(id: impl Into<SharedString>) -> Self {
114        Self {
115            id: id.into(),
116            label: None,
117            options: Vec::new(),
118            selected: None,
119            on_event: None,
120            style: StyleRefinement::default(),
121        }
122    }
123
124    /// Sets the options, in the order they are offered.
125    pub fn options(mut self, options: impl IntoIterator<Item = ChoiceOption>) -> Self {
126        self.options = options.into_iter().collect();
127        self
128    }
129
130    /// Selects an option by identifier. Nothing is selected by default.
131    pub fn selected(mut self, option: impl Into<SharedString>) -> Self {
132        self.selected = Some(option.into());
133        self
134    }
135
136    /// Selects an option, or none.
137    pub fn selection(mut self, option: Option<SharedString>) -> Self {
138        self.selected = option;
139        self
140    }
141
142    /// Handles the typed choice.
143    pub fn on_event(
144        mut self,
145        handler: impl Fn(&ChoiceEvent, &mut Window, &mut App) + 'static,
146    ) -> Self {
147        self.on_event = Some(std::rc::Rc::new(handler));
148        self
149    }
150}
151
152impl Styled for ChoiceGroup {
153    fn style(&mut self) -> &mut StyleRefinement {
154        &mut self.style
155    }
156}
157
158impl RenderOnce for ChoiceGroup {
159    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
160        let tokens = cx.theme().semantic_tokens();
161        let root_id = ElementId::from(self.id.clone());
162        let group = self.id.clone();
163        let handler = self.on_event;
164        let selected = self.selected;
165        let label = self.label.clone();
166        let rows: Vec<AnyElement> = self
167            .options
168            .into_iter()
169            .enumerate()
170            .map(|(position, option)| {
171                let chosen = selected.as_ref() == Some(&option.id);
172                choice_row(
173                    &root_id,
174                    &group,
175                    option,
176                    chosen,
177                    position,
178                    handler.clone(),
179                    window,
180                    cx,
181                )
182            })
183            .collect();
184        let set_size = rows.len();
185
186        v_flex()
187            .id(root_id)
188            .gap(tokens.spacing.xs)
189            .role(Role::RadioGroup)
190            .when_some(label.clone(), |group, label| group.aria_label(label))
191            .aria_size_of_set(set_size)
192            .refine_style(&self.style)
193            .when_some(label, |group, label| {
194                group.child(
195                    div()
196                        .text_token(tokens.typography.sm)
197                        .text_color(cx.theme().foreground)
198                        .child(label),
199                )
200            })
201            .children(rows)
202    }
203}
204
205/// The diameter of a choice's indicator ring, and the side of a check box.
206///
207/// A glyph slot rather than a fresh number, and deliberately not the seat it
208/// sits in: the control is sixteen pixels, the seat is the label's own line,
209/// and conflating the two put every indicator in this file two pixels above
210/// the text it belongs to.
211fn indicator_size(cx: &App) -> Pixels {
212    crate::sizing::slot_sm(cx)
213}
214
215/// The seat every control in this file sits in.
216///
217/// As tall as the label's first line, so the control is centred on the words
218/// rather than on the whole option - an option with a description is two lines
219/// tall, and a control centred on both floats between them. As wide as the
220/// widest control the file draws, which is the switch's track, so a column
221/// mixing switches and boxes keeps one left edge and one text inset.
222fn control_seat(control: impl IntoElement, cx: &App) -> gpui::Div {
223    let tokens = cx.theme().semantic_tokens();
224    crate::surface::leading_control_slot(
225        tokens.typography.sm.line_height,
226        switch_track_width(indicator_size(cx)),
227        control,
228    )
229}
230
231/// How wide a switch's track is, for the seat and for the switch itself.
232fn switch_track_width(slot: Pixels) -> Pixels {
233    slot * 1.6
234}
235
236/// One option: an indicator, its label, and whatever it says about itself.
237#[allow(clippy::too_many_arguments)]
238fn choice_row(
239    root_id: &ElementId,
240    group: &SharedString,
241    option: ChoiceOption,
242    chosen: bool,
243    position: usize,
244    handler: Option<SharedHandler<ChoiceEvent>>,
245    window: &mut Window,
246    cx: &mut App,
247) -> AnyElement {
248    let tokens = cx.theme().semantic_tokens();
249    let row_id = ElementId::from((root_id.clone(), option.id.clone()));
250    let press_id = ElementId::from((row_id.clone(), "press"));
251    // Built inside the selector, never for it: `debug_selector` drops its
252    // closure unread in release, and a `SharedString` clone is a refcount bump.
253    let debug_group = group.clone();
254    let debug_option = option.id.clone();
255    let event = ChoiceEvent::Chosen {
256        group: group.clone(),
257        option: option.id.clone(),
258    };
259    // The dot grows into the ring rather than appearing in it. Same channel
260    // the disclosures use, so a reduced preference lands it in one frame.
261    let fill = disclosure_progress(
262        gpui_base::motion::TransitionId::from(ElementId::from((row_id.clone(), "chosen"))),
263        chosen,
264        window,
265        cx,
266    );
267    let ring = indicator_size(cx);
268    let accessibility_label = option.label.clone();
269
270    let row = composed_button(row_id.clone(), accessibility_label)
271        .w_full()
272        .justify_start()
273        .items_start()
274        .gap(tokens.spacing.sm)
275        .px(tokens.spacing.sm)
276        .py(tokens.spacing.xs)
277        .role(Role::RadioButton)
278        .aria_toggled(if chosen {
279            accesskit::Toggled::True
280        } else {
281            accesskit::Toggled::False
282        })
283        .aria_position_in_set(position + 1)
284        .debug_selector(move || format!("choice-{debug_group}-{debug_option}"))
285        .child(
286            control_seat(
287                div()
288                    .flex()
289                    .items_center()
290                    .justify_center()
291                    .size(ring)
292                    .rounded_full()
293                    .border_1()
294                    .border_color(if chosen {
295                        cx.theme().primary
296                    } else {
297                        cx.theme().border
298                    })
299                    .child(
300                        div()
301                            .size(ring * 0.5 * fill)
302                            .rounded_full()
303                            .bg(cx.theme().primary),
304                    ),
305                cx,
306            )
307            .debug_selector({
308                let (group, option) = (group.clone(), option.id.clone());
309                move || format!("choice-seat-{group}-{option}")
310            }),
311        )
312        .child(
313            v_flex()
314                .min_w_0()
315                .gap(tokens.spacing.xxs)
316                .child(
317                    div()
318                        .debug_selector({
319                            let (group, option) = (group.clone(), option.id.clone());
320                            move || format!("choice-label-{group}-{option}")
321                        })
322                        .text_token(tokens.typography.sm)
323                        .text_color(cx.theme().foreground)
324                        .child(option.label.clone()),
325                )
326                .when_some(option.description.clone(), |body, description| {
327                    body.child(hint(description, cx))
328                }),
329        );
330
331    // The selected surface owns hover — GPUI accepts `.hover` once, so the
332    // ramp added here is the press decay alone, not `quiet_press_surface`.
333    let row = selection_surface(row, chosen, tokens.radius.lg, tokens.spacing.xs, None, cx)
334        .border_1()
335        .border_color(cx.theme().transparent)
336        .focus_visible(|style| style.border_color(cx.theme().ring))
337        .press_release(press_id, tokens.radius.md, window, cx);
338
339    if option.disabled {
340        return row.disabled(true).into_any_element();
341    }
342
343    match handler {
344        Some(handler) => row
345            .on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
346            .into_any_element(),
347        None => row.into_any_element(),
348    }
349}
350
351/// Whether a toggle reads as a box that is ticked or a switch that is thrown.
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
353pub enum ToggleShape {
354    /// A box: one of several things a person is choosing to include.
355    #[default]
356    Check,
357    /// A switch: a setting that takes effect the moment it moves.
358    Switch,
359}
360
361/// What a toggle reports.
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub enum ToggleEvent {
364    /// A person asked for the toggle to change.
365    Toggled {
366        /// The toggle's identifier.
367        id: SharedString,
368        /// The state being asked for.
369        on: bool,
370    },
371}
372
373/// A labelled control that is either on or off.
374///
375/// # Example
376///
377/// ```
378/// use gpui_ai::prelude::{Toggle, ToggleShape};
379///
380/// let stream = Toggle::new("stream", "Stream the answer")
381///     .shape(ToggleShape::Switch)
382///     .on(true);
383/// ```
384#[derive(IntoElement)]
385pub struct Toggle {
386    id: SharedString,
387    label: SharedString,
388    description: Option<SharedString>,
389    shape: ToggleShape,
390    on: bool,
391    disabled: bool,
392    on_event: Option<SharedHandler<ToggleEvent>>,
393    style: StyleRefinement,
394}
395
396impl Toggle {
397    /// Creates a toggle with a stable identifier and its label.
398    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
399        Self {
400            id: id.into(),
401            label: label.into(),
402            description: None,
403            shape: ToggleShape::default(),
404            on: false,
405            disabled: false,
406            on_event: None,
407            style: StyleRefinement::default(),
408        }
409    }
410
411    /// A second line under the label.
412    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
413        self.description = Some(description.into());
414        self
415    }
416
417    /// Chooses the box or the switch. The role follows the shape.
418    pub fn shape(mut self, shape: ToggleShape) -> Self {
419        self.shape = shape;
420        self
421    }
422
423    /// Sets the state. The application owns it.
424    pub fn on(mut self, on: bool) -> Self {
425        self.on = on;
426        self
427    }
428
429    /// Marks the toggle unavailable.
430    pub fn disabled(mut self, disabled: bool) -> Self {
431        self.disabled = disabled;
432        self
433    }
434
435    /// Handles the typed change request.
436    pub fn on_event(
437        mut self,
438        handler: impl Fn(&ToggleEvent, &mut Window, &mut App) + 'static,
439    ) -> Self {
440        self.on_event = Some(std::rc::Rc::new(handler));
441        self
442    }
443}
444
445impl Styled for Toggle {
446    fn style(&mut self) -> &mut StyleRefinement {
447        &mut self.style
448    }
449}
450
451impl RenderOnce for Toggle {
452    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
453        toggle_button(self, window, cx)
454    }
455}
456
457/// The button a [`Toggle`] is, before it becomes an element.
458///
459/// Split out so its role and toggled state can be read directly — a
460/// `RenderOnce` hands a caller an opaque `impl IntoElement`, and the role
461/// belongs to the button underneath it.
462fn toggle_button(toggle: Toggle, window: &mut Window, cx: &mut App) -> gpui_base::Button {
463    let tokens = cx.theme().semantic_tokens();
464    let row_id = ElementId::from(toggle.id.clone());
465    let press_id = ElementId::from((row_id.clone(), "press"));
466    let debug_id = toggle.id.clone();
467    let event = ToggleEvent::Toggled {
468        id: toggle.id.clone(),
469        on: !toggle.on,
470    };
471    let travel = disclosure_progress(
472        gpui_base::motion::TransitionId::from(ElementId::from((row_id.clone(), "on"))),
473        toggle.on,
474        window,
475        cx,
476    );
477    let slot = indicator_size(cx);
478    let indicator = match toggle.shape {
479        ToggleShape::Check => check_indicator(slot, travel, cx),
480        ToggleShape::Switch => switch_indicator(slot, travel, cx),
481    };
482    let role = match toggle.shape {
483        ToggleShape::Check => Role::CheckBox,
484        ToggleShape::Switch => Role::Switch,
485    };
486
487    let row = composed_button(row_id.clone(), toggle.label.clone())
488        .w_full()
489        .justify_start()
490        .items_start()
491        .gap(tokens.spacing.sm)
492        .px(tokens.spacing.sm)
493        .py(tokens.spacing.xs)
494        .role(role)
495        .aria_toggled(if toggle.on {
496            accesskit::Toggled::True
497        } else {
498            accesskit::Toggled::False
499        })
500        .debug_selector(move || format!("toggle-{debug_id}"))
501        .refine_style(&toggle.style)
502        .child(control_seat(indicator, cx))
503        .child(
504            v_flex()
505                .min_w_0()
506                .gap(tokens.spacing.xxs)
507                .child(
508                    div()
509                        .text_token(tokens.typography.sm)
510                        .text_color(cx.theme().foreground)
511                        .child(toggle.label.clone()),
512                )
513                .when_some(toggle.description.clone(), |body, description| {
514                    body.child(hint(description, cx))
515                }),
516        )
517        .quiet_press_surface(press_id, tokens.radius.md, window, cx);
518
519    let handler = toggle.on_event.filter(|_| !toggle.disabled);
520    row.when(toggle.disabled, |row| row.disabled(true))
521        .when_some(handler, |row, handler| {
522            row.on_click(move |_: &ClickEvent, window, cx| handler(&event, window, cx))
523        })
524}
525
526/// A box that fills and shows its tick as the state arrives.
527fn check_indicator(slot: Pixels, travel: f32, cx: &App) -> AnyElement {
528    let tokens = cx.theme().semantic_tokens();
529    div()
530        .flex()
531        .items_center()
532        .justify_center()
533        .size(slot)
534        .rounded(tokens.radius.sm)
535        .border_1()
536        .border_color(if travel > 0.5 {
537            cx.theme().primary
538        } else {
539            cx.theme().border
540        })
541        .bg(cx.theme().primary.opacity(travel))
542        .child(
543            Icon::new(IconName::Check)
544                .xsmall()
545                .text_color(cx.theme().primary_foreground.opacity(travel)),
546        )
547        .into_any_element()
548}
549
550/// A track the thumb travels along, sized from the same slot as the box so
551/// a column of mixed toggles keeps one text inset.
552fn switch_indicator(slot: Pixels, travel: f32, cx: &App) -> AnyElement {
553    let tokens = cx.theme().semantic_tokens();
554    // Every number here is the slot or a spacing token: a switch that grows
555    // with the size policy rather than one drawn at a fixed size.
556    let track_width = switch_track_width(slot);
557    let track_height = slot * 0.75;
558    let inset = tokens.spacing.xxs;
559    let thumb = track_height - inset * 2.0;
560    div()
561        .relative()
562        .flex()
563        .items_center()
564        .w(track_width)
565        .h(track_height)
566        .rounded_full()
567        // The track carries the state: the border colour at rest, the
568        // primary once thrown, crossed through as the thumb travels.
569        .bg(cx.theme().border.opacity(1.0 - travel))
570        .child(
571            div()
572                .absolute()
573                .inset_0()
574                .rounded_full()
575                .bg(cx.theme().primary.opacity(travel)),
576        )
577        .child(
578            div()
579                .absolute()
580                .ml(inset + (track_width - thumb - inset * 2.0) * travel)
581                .size(thumb)
582                .rounded_full()
583                .bg(cx.theme().background),
584        )
585        .into_any_element()
586}