Skip to main content

gpui_ai/
button_label.rs

1//! Theme-aware text labels for composed gpui-component buttons.
2
3use gpui::{
4    App, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, SharedString,
5    Styled as _, Window, div, relative,
6};
7use gpui_component::{ActiveTheme as _, button::Button};
8
9/// Adds a text label with the theme's leading rather than a one-em line box.
10///
11/// This composes the upstream button's child slot: sizes, colors, focus,
12/// activation, icons, and disabled states remain owned by gpui-component.
13/// The label inherits the button's font size and the theme's body-text leading.
14pub trait ButtonLabelExt {
15    /// Sets the visible text and accessible name. Use instead of `Button::label`.
16    ///
17    /// A later `accessibility_label` call can supply a different spoken name.
18    /// A long label still ends in an ellipsis; descenders and accents keep the
19    /// theme's line-height clearance. Call once per button.
20    ///
21    /// ```
22    /// use gpui_ai::ButtonLabelExt as _;
23    /// use gpui_component::button::Button;
24    ///
25    /// let button = Button::new("apply").text_label("Apply changes");
26    /// ```
27    fn text_label(self, label: impl Into<SharedString>) -> Self;
28}
29
30impl ButtonLabelExt for Button {
31    fn text_label(self, label: impl Into<SharedString>) -> Self {
32        let label = label.into();
33        self.accessibility_label(label.clone())
34            .child(ButtonText { label })
35    }
36}
37
38#[derive(IntoElement)]
39struct ButtonText {
40    label: SharedString,
41}
42
43impl RenderOnce for ButtonText {
44    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
45        let body = cx.theme().typography_tokens().sm;
46        // A relative leading follows every upstream Button size and rem zoom.
47        // `Button::label` sets a one-em line box, which is the whole reason to
48        // compose this slot instead: at leading 1.0 there is no room under the
49        // baseline, and on DirectWrite the bottom of y/g/p and tall accented
50        // glyphs goes with it.
51        //
52        // No mask. `truncate()` is `overflow_hidden` plus nowrap plus ellipsis,
53        // and the hidden overflow was doing the same clipping this label exists
54        // to avoid - hidden only because a taller line box left the descenders
55        // room inside it. Upstream removed the same mask from its own label in
56        // #2921 for the same reason; the ellipsis needs the other two.
57        //
58        // And nothing here offsets the label optically. It is tempting: GPUI
59        // centres a line's ascent-to-descent box, so a word with no tall
60        // accents hangs low inside it, and half the ascent's excess over the
61        // cap height would centre what the eye actually sees. Measured, it is
62        // 2.29px at this size - and a 24px control has only 2.9px above that
63        // box, so spending it puts the ring of an Å on the border. The label
64        // cannot know the control's height, so it cannot know what is spare.
65        // Room is the control's to give: see `ControlMetricsExt`.
66        // Named after what it says, so a geometry test can find it. Dropped
67        // unread outside cfg(test), so the name costs nothing to carry.
68        let named = self.label.clone();
69        div()
70            .debug_selector(move || format!("button-label-{named}"))
71            .min_w_0()
72            .whitespace_nowrap()
73            .text_ellipsis()
74            .line_height(relative(f32::from(body.line_height) / f32::from(body.size)))
75            .child(self.label)
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use gpui::{
83        Context, Element as _, Render, Role, TestAppContext, accesskit, canvas,
84        prelude::FluentBuilder as _,
85    };
86    use gpui_component::Disableable as _;
87    use std::sync::{Arc, Mutex};
88
89    struct Probe {
90        nodes: Arc<Mutex<Vec<accesskit::Node>>>,
91    }
92
93    impl Render for Probe {
94        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
95            let nodes = self.nodes.clone();
96            canvas(
97                move |_, window, cx| {
98                    for disabled in [false, true] {
99                        let mut button = Button::new("label-probe")
100                            .text_label("Apply gyjpq ÅÉ fi fl")
101                            .disabled(disabled)
102                            .when(disabled, |button| button.accessibility_label("Unavailable"))
103                            .on_click(|_, _, _| {})
104                            .render(window, cx)
105                            .into_any_element();
106                        // The styled Button composes a base Button. Resolve its
107                        // view before inspecting the actual semantic div, not
108                        // the transparent RenderOnce wrapper (which has no role).
109                        let (_, mut rendered) = button
110                            .downcast_mut::<gpui::ViewElement<gpui_base::Button>>()
111                            .expect("upstream styled button composes base Button")
112                            .request_layout(None, None, window, cx);
113                        let button = rendered
114                            .as_mut()
115                            .expect("base button renders a child")
116                            .downcast_mut::<gpui::Stateful<gpui::Div>>()
117                            .expect("base button renders its semantic div");
118                        assert_eq!(button.a11y_role(), Some(Role::Button));
119                        let mut node = accesskit::Node::new(Role::Button);
120                        button.write_a11y_info(&mut node);
121                        nodes.lock().expect("capture lock").push(node);
122                    }
123                },
124                |_, _, _, _| {},
125            )
126        }
127    }
128
129    #[gpui::test]
130    fn composed_label_keeps_upstream_name_and_activation(cx: &mut TestAppContext) {
131        cx.update(crate::init);
132        let nodes = Arc::new(Mutex::new(Vec::new()));
133        let result = nodes.clone();
134        let (_, cx) = cx.add_window_view(|_, _| Probe { nodes });
135        cx.update(|window, cx| window.draw(cx).clear(cx));
136        let nodes = result.lock().expect("capture lock");
137        assert_eq!(nodes[0].label(), Some("Apply gyjpq ÅÉ fi fl"));
138        assert!(nodes[0].supports_action(accesskit::Action::Click));
139        assert!(!nodes[0].is_disabled());
140        assert_eq!(nodes[1].label(), Some("Unavailable"));
141        assert!(!nodes[1].supports_action(accesskit::Action::Click));
142    }
143}