use crate::app::OriginSpeakApp; use crate::components::icon::{OriginSpeakIcon, icon}; use crate::state::OverlayState; use crate::theme::{DesignTokens, transparent}; use gpui::*; use std::f32::consts::TAU; use std::time::{Duration, Instant}; const STATUS_WIDTH: f32 = 88.0; const STATUS_HEIGHT: f32 = 32.0; const BOTTOM_MARGIN: f32 = 28.0; const CHIP_BORDER_WIDTH: f32 = 0.5; const MARKER_WIDTH: f32 = 72.0; const MARKER_HEIGHT: f32 = 20.0; const BAR_COUNT: usize = 9; const BAR_WIDTH: f32 = 4.0; const BAR_MIN_HEIGHT: f32 = 4.0; const BAR_MAX_HEIGHT: f32 = 18.0; const BAR_PROFILE: [f32; BAR_COUNT] = [0.42, 0.58, 0.76, 0.9, 1.0, 0.9, 0.76, 0.58, 0.42]; /// Per-second smoothing rates: bars jump up with the voice and settle back /// more slowly, like a VU meter. const BAR_ATTACK_RATE: f32 = 28.0; const BAR_RELEASE_RATE: f32 = 9.0; const SPINNER_SIZE: f32 = 16.0; const SPINNER_PERIOD_SECS: f32 = 0.9; const COMPLETION_ICON_SIZE: f32 = 18.0; const FADE_IN_SECS: f32 = 0.14; fn smoothstep(value: f32) -> f32 { let value = value.clamp(0.0, 1.0); value * value * (3.0 - 2.0 * value) } fn completion_pop(elapsed: f32) -> f32 { let settle = 1.0 - (-elapsed * 12.0).exp(); let bounce = (-elapsed * 7.0).exp() * (elapsed * 22.0).sin() * 0.16; (settle + bounce).clamp(0.72, 1.08) } fn bottom_center_bounds(display_bounds: Bounds, width: f32, height: f32) -> Bounds { Bounds { origin: point( display_bounds.center().x - px(width / 2.0), display_bounds.bottom() - px(height + BOTTOM_MARGIN), ), size: size(px(width), px(height)), } } /// Fixed-size box every state renders into, so switching states or animating /// inside one never moves anything else. fn marker_slot() -> Div { div() .w(px(MARKER_WIDTH)) .h(px(MARKER_HEIGHT)) .flex() .items_center() .justify_center() } pub(crate) fn open_status_overlay( app: Entity, cx: &mut App, ) -> Result, String> { let display = cx .primary_display() .ok_or_else(|| "No primary display is available for the status overlay".to_string())?; let bounds = bottom_center_bounds(display.bounds(), STATUS_WIDTH, STATUS_HEIGHT); let options = WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), titlebar: None, focus: false, show: true, kind: WindowKind::PopUp, is_movable: false, inactive_frame_interval: Some(Duration::from_millis(16)), is_resizable: false, is_minimizable: false, display_id: Some(display.id()), window_background: WindowBackgroundAppearance::Opaque, window_decorations: Some(WindowDecorations::Client), ..WindowOptions::default() }; cx.open_window(options, move |window, cx| { cx.new(|cx| StatusOverlayView::new(app, window, cx)) }) .map_err(|error| error.to_string()) } pub(crate) struct StatusOverlayView { app: Entity, tokens: DesignTokens, last_state: OverlayState, last_level_bucket: u8, state_started_at: Instant, last_frame_at: Instant, bar_levels: [f32; BAR_COUNT], _app_subscription: Subscription, } impl StatusOverlayView { fn new(app: Entity, window: &mut Window, cx: &mut Context) -> Self { let subscription = cx.observe_in(&app, window, |this, app, _window, cx| { let (state, level) = app.read(cx).overlay_snapshot(); let level_bucket = (level.clamp(0.0, 1.0) * 20.0).round() as u8; let state_changed = this.last_state != state; let level_changed = this.last_level_bucket != level_bucket; if state_changed { this.last_state = state; this.state_started_at = Instant::now(); if state == OverlayState::Listening { this.bar_levels = [0.0; BAR_COUNT]; } } if level_changed { this.last_level_bucket = level_bucket; } if state_changed || level_changed { cx.notify(); } }); let now = Instant::now(); Self { app, tokens: DesignTokens::default(), last_state: OverlayState::Listening, last_level_bucket: 0, state_started_at: now, last_frame_at: now, bar_levels: [0.0; BAR_COUNT], _app_subscription: subscription, } } fn update_bars(&mut self, level: f32, elapsed: f32, dt: f32) { let level = level.clamp(0.0, 1.0); for (index, bar) in self.bar_levels.iter_mut().enumerate() { let offset = index as f32; // The shimmer only scales the voice level, so silence stays flat. let shimmer = 0.72 + 0.14 * (elapsed * 7.3 + offset * 1.9).sin() + 0.14 * (elapsed * 11.1 - offset * 0.8).sin(); let target = level * BAR_PROFILE[index] * shimmer; let rate = if target > *bar { BAR_ATTACK_RATE } else { BAR_RELEASE_RATE }; *bar += (target - *bar) * (1.0 - (-rate * dt).exp()); } } fn listening_indicator(&self) -> Div { let mut bars = marker_slot().gap(px(2.0)); for bar in self.bar_levels { let bar = bar.clamp(0.0, 1.0); bars = bars.child( div() .w(px(BAR_WIDTH)) .h(px(BAR_MIN_HEIGHT + bar * (BAR_MAX_HEIGHT - BAR_MIN_HEIGHT))) .rounded_full() .bg(self.tokens.primary.alpha(0.5 + bar * 0.5)), ); } bars } fn processing_indicator(&self, elapsed: f32) -> Div { let fade = smoothstep(elapsed / FADE_IN_SECS); let angle = (elapsed / SPINNER_PERIOD_SECS).fract() * TAU; marker_slot().child( div() .relative() .size(px(SPINNER_SIZE)) .child( icon( OriginSpeakIcon::SpinnerTrack, SPINNER_SIZE, self.tokens.primary.alpha(0.22 * fade), ) .absolute() .top_0() .left_0(), ) .child( icon( OriginSpeakIcon::SpinnerArc, SPINNER_SIZE, self.tokens.primary.alpha(fade), ) .absolute() .top_0() .left_0() .with_transformation(Transformation::rotate(radians(angle))), ), ) } /// The pop is a paint-time transform, so the icon's layout box never changes. fn completion_indicator(&self, kind: OriginSpeakIcon, color: Hsla, elapsed: f32) -> Div { let pop = completion_pop(elapsed); let fade = smoothstep(elapsed / FADE_IN_SECS); marker_slot().child( icon(kind, COMPLETION_ICON_SIZE, color.alpha(fade)) .with_transformation(Transformation::scale(size(pop, pop))), ) } fn state_marker(&self, state: OverlayState, elapsed: f32) -> Div { match state { OverlayState::Listening => self.listening_indicator(), OverlayState::Processing => self.processing_indicator(elapsed), OverlayState::Success => { self.completion_indicator(OriginSpeakIcon::Success, self.tokens.positive, elapsed) } OverlayState::Error => { self.completion_indicator(OriginSpeakIcon::Alert, self.tokens.negative, elapsed) } OverlayState::Idle => marker_slot(), } } } impl Render for StatusOverlayView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let (state, level) = self.app.read(cx).overlay_snapshot(); let now = Instant::now(); let dt = now .duration_since(self.last_frame_at) .as_secs_f32() .min(0.1); self.last_frame_at = now; if state == OverlayState::Idle { return div().size_full().bg(transparent()); } window.request_animation_frame(); let elapsed = self.state_started_at.elapsed().as_secs_f32(); if state == OverlayState::Listening { self.update_bars(level, elapsed, dt); } div() .size_full() .border(px(CHIP_BORDER_WIDTH)) .border_color(self.tokens.chip_border) .bg(self.tokens.chip_surface) .flex() .items_center() .justify_center() .child(self.state_marker(state, elapsed)) } }