Skip to main content

gpui_component/speech/
button.rs

1use std::sync::Arc;
2
3use gpui::{
4    App, ElementId, Entity, IntoElement, ParentElement as _, RenderOnce, StyleRefinement, Styled,
5    Window, div, prelude::FluentBuilder as _, px,
6};
7use rust_i18n::t;
8
9use crate::{
10    Disableable, IconName, Selectable as _, Sizable, Size, StyledExt as _,
11    button::{Button, ButtonVariants as _},
12};
13
14use super::{SpeechIndicator, SpeechState, SpeechStatus};
15
16/// The button that starts and stops a [`SpeechState`]'s session.
17///
18/// Shows a microphone at rest and, pressed, a [`SpeechIndicator`] while
19/// capturing: bars that move with the voice, turning into a stop glyph under
20/// the pointer or while the button has keyboard focus. A click toggles the
21/// session. While the final result is pending it shows a spinner and ignores
22/// clicks.
23///
24/// Renders nothing when the state has no recognizer on this platform, unless
25/// [`Self::show_when_unsupported`] is set, and renders disabled while the
26/// recognizer reports itself unavailable.
27#[derive(IntoElement)]
28pub struct SpeechButton {
29    id: ElementId,
30    state: Entity<SpeechState>,
31    size: Size,
32    disabled: bool,
33    show_when_unsupported: bool,
34    style: StyleRefinement,
35}
36
37impl SpeechButton {
38    /// A button for `state`.
39    pub fn new(state: &Entity<SpeechState>) -> Self {
40        Self {
41            id: ("speech-button", state.entity_id()).into(),
42            state: state.clone(),
43            size: Size::default(),
44            disabled: false,
45            show_when_unsupported: false,
46            style: StyleRefinement::default(),
47        }
48    }
49
50    /// Render a disabled button, instead of nothing, when the state has no
51    /// recognizer on this platform. Default `false`.
52    pub fn show_when_unsupported(mut self, show: bool) -> Self {
53        self.show_when_unsupported = show;
54        self
55    }
56}
57
58impl Sizable for SpeechButton {
59    fn with_size(mut self, size: impl Into<Size>) -> Self {
60        self.size = size.into();
61        self
62    }
63}
64
65impl Styled for SpeechButton {
66    /// Refines the button, e.g. its size or corner radius, to match the
67    /// controls around it.
68    fn style(&mut self) -> &mut StyleRefinement {
69        &mut self.style
70    }
71}
72
73impl Disableable for SpeechButton {
74    fn disabled(mut self, disabled: bool) -> Self {
75        self.disabled = disabled;
76        self
77    }
78}
79
80impl RenderOnce for SpeechButton {
81    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
82        // Kept here rather than in the Button so that keyboard focus can show
83        // the stop glyph, which the indicator otherwise reveals only under
84        // the pointer.
85        let focus_handle = window
86            .use_keyed_state(
87                ElementId::NamedChild(Arc::new(self.id.clone()), "focus".into()),
88                cx,
89                |_, cx| cx.focus_handle(),
90            )
91            .read(cx)
92            .clone();
93        let keyboard_focused = focus_handle.is_focused(window) && window.last_input_was_keyboard();
94
95        let state = self.state.read(cx);
96        let status = state.status();
97        let supported = state.has_recognizer();
98        if !supported && !self.show_when_unsupported {
99            return div().into_any_element();
100        }
101        // A running session stays stoppable even if the recognizer turns
102        // unavailable mid-way.
103        let available = status.is_active() || state.is_available(cx);
104        let capturing = status.is_capturing();
105        let label = if !available {
106            t!("Speech.Unavailable")
107        } else if capturing {
108            t!("Speech.Stop")
109        } else {
110            t!("Speech.Start")
111        };
112
113        let stopping = status == SpeechStatus::Stopping;
114
115        Button::new(self.id)
116            .ghost()
117            .with_size(self.size)
118            .track_focus(&focus_handle)
119            .map(|this| {
120                if capturing && !keyboard_focused {
121                    // With a child instead of an icon the button sizes to its
122                    // content, so give it the icon button's square and let the
123                    // indicator fill it.
124                    let (side, icon) = match self.size {
125                        Size::Size(side) => (side, Size::Size(side * 0.75)),
126                        Size::XSmall => (px(20.), Size::XSmall),
127                        Size::Small => (px(24.), Size::Small),
128                        size => (px(32.), size),
129                    };
130                    this.size(side)
131                        .p_0()
132                        .child(SpeechIndicator::new(&self.state).with_size(icon))
133                } else {
134                    this.icon(if capturing || stopping {
135                        IconName::Square
136                    } else {
137                        IconName::Mic
138                    })
139                }
140            })
141            .selected(capturing)
142            .loading(stopping)
143            .disabled(self.disabled || !available)
144            .tooltip(label.clone())
145            .accessibility_label(label)
146            .refine_style(&self.style)
147            .on_click({
148                let state = self.state.clone();
149                move |_, _, cx| state.update(cx, |state, cx| state.toggle(cx))
150            })
151            .into_any_element()
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use gpui::{AppContext as _, TestAppContext};
158
159    use super::*;
160
161    #[gpui::test]
162    fn test_speech_button_builder(cx: &mut TestAppContext) {
163        let state = cx.update(|cx| cx.new(|cx| SpeechState::new(cx).system_fallback(false)));
164        let button = SpeechButton::new(&state)
165            .small()
166            .disabled(true)
167            .show_when_unsupported(true);
168
169        assert_eq!(button.size, Size::Small);
170        assert!(button.disabled);
171        assert!(button.show_when_unsupported);
172        assert_eq!(
173            button.id,
174            ElementId::from(("speech-button", state.entity_id()))
175        );
176    }
177}