From a0a7a630027860a204d8fc3eb0ce076a93741b3a Mon Sep 17 00:00:00 2001 From: Ian MacLeod Date: Thu, 1 Oct 2026 21:27:13 +0000 Subject: [PATCH] Centralize TUI loading glyphs and frame scheduling (#50112) ## What changed Move the voice connection spinner into shared helpers in `codex-rs/tui/src/motion.rs`, keeping its 100 ms animation cadence and static reduced-motion glyph. The helpers handle frame scheduling, and `loading_glyph_with_delay` supports hiding the animated spinner until a specified delay expires. GitOrigin-RevId: c4ebe5e6a33239999aab2f4432c9145432c8f4fd --- codex-rs/tui/src/bottom_pane/voice_strip.rs | 27 +++++----------- codex-rs/tui/src/motion.rs | 34 +++++++++++++++++++++ 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/voice_strip.rs b/codex-rs/tui/src/bottom_pane/voice_strip.rs index fd7437d62f..5e3aa75f54 100644 --- a/codex-rs/tui/src/bottom_pane/voice_strip.rs +++ b/codex-rs/tui/src/bottom_pane/voice_strip.rs @@ -4,6 +4,7 @@ use crate::key_hint::ShortcutHint; use crate::motion::MotionMode; +use crate::motion::loading_glyph; use crate::render::renderable::Renderable; use crate::tui::FrameRequester; use ratatui::buffer::Buffer; @@ -15,7 +16,6 @@ use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; -use std::time::Duration; use std::time::Instant; use unicode_width::UnicodeWidthStr; @@ -61,16 +61,6 @@ impl VoiceStrip { } } -pub(super) fn loading_glyph(started_at: Instant, mode: MotionMode) -> &'static str { - const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - if mode == MotionMode::Reduced { - "◌" - } else { - let frame = started_at.elapsed().as_millis() / 100; - FRAMES[usize::try_from(frame).unwrap_or_default() % FRAMES.len()] - } -} - impl Renderable for VoiceStrip { fn desired_height(&self, width: u16) -> u16 { if width == 0 { @@ -92,14 +82,13 @@ impl Renderable for VoiceStrip { let connecting = self.state.phase == VoiceStripPhase::Connecting; let mode = MotionMode::from_animations_enabled(self.state.animations && self.state.progress); - if connecting && mode == MotionMode::Animated { - self.frame_requester - .schedule_frame_in(Duration::from_millis(100)); - } - let marker = if connecting && self.state.microphone_live && !self.state.microphone_muted { - loading_glyph(self.started_at, mode).red().bold() - } else if connecting { - loading_glyph(self.started_at, mode).cyan() + let marker = if connecting { + let glyph = loading_glyph(self.started_at, mode, &self.frame_requester); + if self.state.microphone_live && !self.state.microphone_muted { + glyph.red().bold() + } else { + glyph.cyan() + } } else if self.state.microphone_live && !self.state.microphone_muted { "●".red().bold() } else { diff --git a/codex-rs/tui/src/motion.rs b/codex-rs/tui/src/motion.rs index 9a4332e5bf..db9de7504b 100644 --- a/codex-rs/tui/src/motion.rs +++ b/codex-rs/tui/src/motion.rs @@ -3,8 +3,10 @@ //! Callers choose an explicit reduced-motion fallback here instead of reaching //! directly for time-varying spinner or shimmer helpers. +use std::time::Duration; use std::time::Instant; +use crate::tui::FrameRequester; use ratatui::style::Stylize; use ratatui::text::Span; @@ -35,6 +37,38 @@ pub(crate) enum ReducedMotionIndicator { StaticBullet, } +/// Show the current loading glyph immediately and schedule the next frame. +pub(crate) fn loading_glyph( + started_at: Instant, + mode: MotionMode, + requester: &FrameRequester, +) -> &'static str { + loading_glyph_with_delay(started_at, Duration::ZERO, mode, requester) +} + +/// Show nothing during the animation delay, or a static glyph in reduced motion. +/// Schedule the frame that makes the spinner visible and each animation frame. +pub(crate) fn loading_glyph_with_delay( + started_at: Instant, + delay: Duration, + mode: MotionMode, + requester: &FrameRequester, +) -> &'static str { + const FRAME_DURATION: Duration = Duration::from_millis(100); + const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + if mode == MotionMode::Reduced { + return "◌"; + } + let elapsed = started_at.elapsed(); + if elapsed < delay { + requester.schedule_frame_in(delay - elapsed); + return ""; + } + requester.schedule_frame_in(FRAME_DURATION); + let frame = elapsed.as_millis() / FRAME_DURATION.as_millis(); + FRAMES[usize::try_from(frame).unwrap_or_default() % FRAMES.len()] +} + pub(crate) fn activity_indicator( start_time: Option, motion_mode: MotionMode,