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}