1use crate::{
10 control::{outlined_control_with_icon, outlined_control_with_label},
11 handlers::SharedHandler,
12 motion::{MotionTokens, VisibleAnimationExt as _},
13 surface::icon_button,
14 theme::SemanticStyledExt as _,
15};
16use gpui::{
17 AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement,
18 ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
19 StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _, rems,
20};
21use gpui_component::{
22 ActiveTheme as _, IconName, Sizable as _, StyledExt as _, h_flex, spinner::Spinner, v_flex,
23};
24use std::rc::Rc;
25
26#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum VoiceState {
29 Idle,
31 Listening {
33 level: f32,
35 },
36 Transcribing,
38 Speaking,
40}
41
42impl VoiceState {
43 pub fn label(self) -> &'static str {
45 match self {
46 Self::Idle => "Idle",
47 Self::Listening { .. } => "Listening",
48 Self::Transcribing => "Transcribing",
49 Self::Speaking => "Speaking",
50 }
51 }
52
53 pub fn is_listening(self) -> bool {
55 matches!(self, Self::Listening { .. })
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum VoiceEvent {
62 DictationStarted,
64 DictationStopped,
66 DictationCancelled,
68 SpeakRequested,
70 SpeakStopped,
72}
73
74#[derive(IntoElement)]
86pub struct VoiceControls {
87 id: SharedString,
88 style: StyleRefinement,
89 state: VoiceState,
90 transcript: Option<SharedString>,
91 speakable: bool,
92 on_event: Option<SharedHandler<VoiceEvent>>,
93}
94
95impl VoiceControls {
96 pub fn new(id: impl Into<SharedString>, state: VoiceState) -> Self {
98 Self {
99 id: id.into(),
100 style: StyleRefinement::default(),
101 state,
102 transcript: None,
103 speakable: false,
104 on_event: None,
105 }
106 }
107
108 pub fn transcript(mut self, transcript: impl Into<SharedString>) -> Self {
110 let transcript = transcript.into();
111 self.transcript = (!transcript.is_empty()).then_some(transcript);
112 self
113 }
114
115 pub fn speakable(mut self, speakable: bool) -> Self {
117 self.speakable = speakable;
118 self
119 }
120
121 pub fn on_event(
123 mut self,
124 handler: impl Fn(&VoiceEvent, &mut Window, &mut App) + 'static,
125 ) -> Self {
126 self.on_event = Some(Rc::new(handler));
127 self
128 }
129}
130
131impl Styled for VoiceControls {
132 fn style(&mut self) -> &mut StyleRefinement {
133 &mut self.style
134 }
135}
136
137impl RenderOnce for VoiceControls {
138 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
139 let tokens = cx.theme().semantic_tokens();
140 let root_id = ElementId::from(self.id.clone());
141 let debug_id = self.id.to_string();
142 let handler = self.on_event;
143 let state = self.state;
144
145 let (dictate_name, dictate_label, dictate_event): (&str, &str, Option<VoiceEvent>) =
146 match state {
147 VoiceState::Idle | VoiceState::Speaking => (
148 "Start dictation",
149 "Dictate",
150 Some(VoiceEvent::DictationStarted),
151 ),
152 VoiceState::Listening { .. } => (
153 "Stop dictation",
154 "Listening",
155 Some(VoiceEvent::DictationStopped),
156 ),
157 VoiceState::Transcribing => ("Transcribing", "Transcribing", None),
158 };
159 let indicator: AnyElement = match state {
160 VoiceState::Listening { level } => {
161 let field_debug = debug_id.clone();
162 div()
163 .debug_selector(move || format!("voice-signal-{field_debug}"))
164 .child(signal_field(
165 ElementId::from((root_id.clone(), "signal-field")),
166 level,
167 cx,
168 ))
169 .into_any_element()
170 }
171 VoiceState::Transcribing => Spinner::new().xsmall().into_any_element(),
172 VoiceState::Idle | VoiceState::Speaking => div()
173 .flex_none()
174 .size_2()
175 .rounded(tokens.radius.full)
176 .bg(cx.theme().muted_foreground.opacity(0.6))
177 .into_any_element(),
178 };
179 let dictate_debug = debug_id.clone();
180 let dictate = {
181 let handler = handler.clone();
182 let event = dictate_event.clone();
183 outlined_control_with_label(
184 (root_id.clone(), "dictate"),
185 dictate_name,
186 dictate_label,
187 window,
188 cx,
189 )
190 .debug_selector(move || format!("voice-dictate-{dictate_debug}"))
191 .disabled(dictate_event.is_none() || handler.is_none())
192 .when(state.is_listening(), |this| {
193 this.border_color(cx.theme().primary)
194 .text_color(cx.theme().primary)
195 })
196 .gap(tokens.spacing.xs)
197 .child(indicator)
198 .on_click(move |_: &ClickEvent, window, cx| {
199 if let (Some(handler), Some(event)) = (&handler, &event) {
200 handler(event, window, cx)
201 }
202 })
203 };
204 let cancel = (state.is_listening() && handler.is_some()).then(|| {
207 let handler = handler.clone();
208 let cancel_debug = debug_id.clone();
209 icon_button(
210 (root_id.clone(), "cancel"),
211 IconName::Close,
212 "Cancel dictation",
213 window,
214 cx,
215 )
216 .debug_selector(move || format!("voice-cancel-{cancel_debug}"))
217 .on_click(move |_: &ClickEvent, window, cx| {
218 if let Some(handler) = &handler {
219 handler(&VoiceEvent::DictationCancelled, window, cx)
220 }
221 })
222 });
223 let speak = self.speakable.then(|| {
224 let handler = handler.clone();
225 let speak_debug = debug_id.clone();
226 let speaking = state == VoiceState::Speaking;
227 let (name, icon, event) = if speaking {
228 ("Stop speaking", IconName::Pause, VoiceEvent::SpeakStopped)
229 } else {
230 (
231 "Read the latest response aloud",
232 IconName::Play,
233 VoiceEvent::SpeakRequested,
234 )
235 };
236 outlined_control_with_icon(
242 (root_id.clone(), "speak"),
243 name,
244 icon,
245 if speaking { "Stop" } else { "Speak" },
246 window,
247 cx,
248 )
249 .debug_selector(move || format!("voice-speak-{speak_debug}"))
250 .when(speaking, |button| {
251 button
252 .border_color(cx.theme().primary)
253 .text_color(cx.theme().primary)
254 })
255 .disabled(handler.is_none())
256 .on_click(move |_: &ClickEvent, window, cx| {
257 if let Some(handler) = &handler {
258 handler(&event, window, cx)
259 }
260 })
261 });
262 let transcript_debug = debug_id.clone();
263 let transcript = self.transcript.map(|text| {
264 div()
265 .id((root_id.clone(), "transcript"))
266 .role(Role::Status)
267 .aria_label(format!("Heard: {text}"))
268 .debug_selector(move || format!("voice-transcript-{transcript_debug}"))
269 .min_w_0()
270 .truncate()
271 .text_token(tokens.typography.sm)
272 .italic()
273 .text_color(cx.theme().muted_foreground)
274 .child(text)
275 });
276
277 v_flex()
278 .id(self.id)
279 .role(Role::Group)
280 .aria_label(format!(
281 "Voice controls, {}",
282 state.label().to_ascii_lowercase()
283 ))
284 .debug_selector(move || format!("voice-{debug_id}"))
285 .min_w_0()
286 .gap(tokens.spacing.xs)
287 .child(
288 h_flex()
289 .items_center()
290 .gap(tokens.spacing.xs)
291 .child(dictate)
292 .children(cancel)
293 .children(speak),
294 )
295 .children(transcript)
296 .refine_style(&self.style)
297 }
298}
299
300fn signal_field(id: ElementId, level: f32, cx: &App) -> impl IntoElement {
314 let tokens = cx.theme().semantic_tokens();
315 let color = cx.theme().primary;
316 let level = level.clamp(0.0, 1.0);
317 let gap = tokens.spacing.xxs;
318 let radius = tokens.radius.full;
319 let full = crate::motion::motion_is_full(cx);
320 const WEIGHTS: [f32; 6] = [0.35, 0.65, 1.0, 0.85, 0.55, 0.3];
321 h_flex()
322 .flex_none()
323 .items_end()
324 .h(rems(0.9))
325 .gap(gap)
326 .with_visible_animation(
327 id,
328 MotionTokens::read(cx).breathing().looping_synced(),
333 move |field, delta| {
334 let delta = if full { delta } else { 0.0 };
335 field.children(WEIGHTS.iter().enumerate().map(|(index, weight)| {
336 let phase = index as f32 / WEIGHTS.len() as f32;
337 let wave = ((delta - phase).rem_euclid(1.0) * 2.0 - 1.0).abs();
338 div()
339 .w(rems(0.14))
340 .h(rems(0.25 + 0.65 * level * weight))
341 .rounded(radius)
342 .bg(color)
343 .opacity(0.35 + 0.65 * wave)
344 }))
345 },
346 )
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use gpui::{Context, Render, TestAppContext, VisualTestContext, px};
353
354 struct VoiceProbe {
355 state: VoiceState,
356 }
357
358 impl Render for VoiceProbe {
359 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
360 div()
361 .w(px(420.))
362 .h(px(160.))
363 .child(VoiceControls::new("probe-voice", self.state).speakable(true))
364 }
365 }
366
367 fn draw(cx: &mut VisualTestContext) {
368 cx.update(|window, cx| window.draw(cx).clear(cx));
369 }
370
371 #[gpui::test]
372 fn the_signal_field_exists_exactly_while_listening(cx: &mut TestAppContext) {
373 cx.update(crate::init);
374 let (probe, cx) = cx.add_window_view(|_, _| VoiceProbe {
375 state: VoiceState::Listening { level: 0.6 },
376 });
377 let cx: &mut VisualTestContext = cx;
378 draw(cx);
379 assert!(
380 cx.debug_bounds("voice-signal-probe-voice").is_some(),
381 "listening must show the signal field"
382 );
383
384 probe.update(cx, |probe, cx| {
385 probe.state = VoiceState::Idle;
386 cx.notify();
387 });
388 draw(cx);
389 assert!(
390 cx.debug_bounds("voice-signal-probe-voice").is_none(),
391 "leaving Listening must unmount the field, and its clock with it"
392 );
393 }
394
395 #[gpui::test]
396 fn reduced_motion_keeps_a_static_field(cx: &mut TestAppContext) {
397 cx.update(crate::init);
398 cx.update(|cx| cx.set_reduce_motion(true));
399 let (_, cx) = cx.add_window_view(|_, _| VoiceProbe {
400 state: VoiceState::Listening { level: 0.6 },
401 });
402 let cx: &mut VisualTestContext = cx;
403 draw(cx);
404 assert!(
405 cx.debug_bounds("voice-signal-probe-voice").is_some(),
406 "reduced motion keeps the static envelope as the meaningful mark"
407 );
408 }
409
410 #[test]
411 fn states_describe_themselves() {
412 assert_eq!(VoiceState::Listening { level: 0.2 }.label(), "Listening");
413 assert!(VoiceState::Listening { level: 0.2 }.is_listening());
414 assert!(!VoiceState::Transcribing.is_listening());
415 }
416}