From 227bee0445c58121771c52bb0dd4ec7a91b43bae Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 1 May 2026 09:07:56 -0700 Subject: [PATCH] Enforce `animations = false` for screen readers (#20564) ## Why Issue #20489 calls out that animated TUI affordances can be noisy for screen-reader users. Codex already has `tui.animations = false` as a reduced-motion setting, but some live activity rows render spinner-style prefixes in that mode. These were relatively recent regressions. We have also regressed this pattern more than once by adding new spinner/shimmer callsites that do not think through the reduced-motion path, so this PR adds a small guardrail while fixing the current surfaces. ## What changed - Omit the live status-row spinner when animations are disabled, so the row starts with stable text like `Working (...)`. - Render running hook headers without the spinner prefix when animations are disabled, while preserving shimmer/spinner behavior when animations are enabled. - Centralize TUI activity indicators in `tui/src/motion.rs`, with explicit reduced-motion choices for hidden prefixes, static bullets, and plain shimmer-text fallbacks. - Route existing spinner/shimmer callsites through the central motion helper, including exec rows, MCP/web-search/loading rows, hook rows, plugin loading, and onboarding loading text. - Add a source-scan regression test that rejects direct `spinner(...)` or `shimmer_spans(...)` usage outside the central module and primitive definition. - Add focused coverage that reduced-motion active exec rows are stable, status rows start without a spinner, running hooks omit the spinner, and MCP inventory loading stays stable. - Update the one affected status-indicator snapshot; the existing detail tree prefix remains unchanged. ## Verification - `cargo test -p codex-tui` --- codex-rs/tui/src/chatwidget/plugins.rs | 8 +- codex-rs/tui/src/exec_cell/mod.rs | 1 - codex-rs/tui/src/exec_cell/render.rs | 58 ++++-- codex-rs/tui/src/history_cell.rs | 35 +++- codex-rs/tui/src/history_cell/hook_cell.rs | 48 ++++- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/motion.rs | 184 ++++++++++++++++++ codex-rs/tui/src/onboarding/auth.rs | 8 +- .../onboarding/auth/headless_chatgpt_login.rs | 5 +- ...ders_wrapped_details_panama_two_lines.snap | 2 +- codex-rs/tui/src/status_indicator_widget.rs | 49 ++++- 11 files changed, 354 insertions(+), 45 deletions(-) create mode 100644 codex-rs/tui/src/motion.rs diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 70d4407a2..6c5fe6c15 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -16,10 +16,11 @@ use crate::bottom_pane::custom_prompt_view::CustomPromptView; use crate::history_cell; use crate::key_hint; use crate::legacy_core::config::Config; +use crate::motion::MotionMode; +use crate::motion::shimmer_text; use crate::onboarding::mark_url_hyperlink; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; -use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; use codex_app_server_protocol::MarketplaceAddResponse; use codex_app_server_protocol::MarketplaceRemoveResponse; @@ -100,7 +101,10 @@ impl Renderable for DelayedLoadingHeader { } else if self.animations_enabled { self.frame_requester .schedule_frame_in(LOADING_ANIMATION_INTERVAL); - lines.push(Line::from(shimmer_spans(self.loading_text.as_str()))); + lines.push(Line::from(shimmer_text( + self.loading_text.as_str(), + MotionMode::Animated, + ))); } else { lines.push(Line::from(self.loading_text.as_str().dim())); } diff --git a/codex-rs/tui/src/exec_cell/mod.rs b/codex-rs/tui/src/exec_cell/mod.rs index 906091113..58976e12a 100644 --- a/codex-rs/tui/src/exec_cell/mod.rs +++ b/codex-rs/tui/src/exec_cell/mod.rs @@ -9,4 +9,3 @@ pub(crate) use render::OutputLinesParams; pub(crate) use render::TOOL_CALL_MAX_LINES; pub(crate) use render::new_active_exec_command; pub(crate) use render::output_lines; -pub(crate) use render::spinner; diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 7c1b533ac..f780e3d3e 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -5,10 +5,12 @@ use super::model::ExecCall; use super::model::ExecCell; use crate::exec_command::strip_bash_lc_and_escape; use crate::history_cell::HistoryCell; +use crate::motion::MotionMode; +use crate::motion::ReducedMotionIndicator; +use crate::motion::activity_indicator; use crate::render::highlight::highlight_bash_to_lines; use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; -use crate::shimmer::shimmer_spans; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::adaptive_wrap_lines; @@ -180,20 +182,13 @@ pub(crate) fn output_lines( } } -pub(crate) fn spinner(start_time: Option, animations_enabled: bool) -> Span<'static> { - if !animations_enabled { - return "•".dim(); - } - let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default(); - if supports_color::on_cached(supports_color::Stream::Stdout) - .map(|level| level.has_16m) - .unwrap_or(false) - { - shimmer_spans("•")[0].clone() - } else { - let blink_on = (elapsed.as_millis() / 600).is_multiple_of(2); - if blink_on { "•".into() } else { "◦".dim() } - } +fn activity_marker(start_time: Option, animations_enabled: bool) -> Span<'static> { + activity_indicator( + start_time, + MotionMode::from_animations_enabled(animations_enabled), + ReducedMotionIndicator::StaticBullet, + ) + .unwrap_or_else(|| "•".dim()) } impl HistoryCell for ExecCell { @@ -263,7 +258,7 @@ impl ExecCell { let mut out: Vec> = Vec::new(); out.push(Line::from(vec![ if self.is_active() { - spinner(self.active_start_time(), self.animations_enabled()) + activity_marker(self.active_start_time(), self.animations_enabled()) } else { "•".dim() }, @@ -371,7 +366,7 @@ impl ExecCell { let bullet = match success { Some(true) => "•".green().bold(), Some(false) => "•".red().bold(), - None => spinner(call.start_time, self.animations_enabled()), + None => activity_marker(call.start_time, self.animations_enabled()), }; let is_interaction = call.is_unified_exec_interaction(); let title = if is_interaction { @@ -957,6 +952,35 @@ mod tests { ); } + #[test] + fn active_command_without_animations_is_stable() { + let call = ExecCall { + call_id: "call-id".to_string(), + command: vec!["bash".into(), "-lc".into(), "echo done".into()], + parsed: Vec::new(), + output: None, + source: ExecCommandSource::Agent, + start_time: Some(Instant::now()), + duration: None, + interaction_input: None, + }; + + let cell = ExecCell::new(call, /*animations_enabled*/ false); + let first: Vec = cell + .command_display_lines(/*width*/ 80) + .iter() + .map(render_line_text) + .collect(); + let second: Vec = cell + .command_display_lines(/*width*/ 80) + .iter() + .map(render_line_text) + .collect(); + + assert_eq!(first, second); + assert_eq!(first, vec!["• Running echo done".to_string()]); + } + #[test] fn exploring_display_does_not_split_long_url_like_search_query() { let url_like = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path"; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index dd85348fa..5ed2cee9e 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -17,12 +17,14 @@ use crate::exec_cell::CommandOutput; use crate::exec_cell::OutputLinesParams; use crate::exec_cell::TOOL_CALL_MAX_LINES; use crate::exec_cell::output_lines; -use crate::exec_cell::spinner; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; use crate::legacy_core::config::Config; use crate::live_wrap::take_prefix_by_width; use crate::markdown::append_markdown; +use crate::motion::MotionMode; +use crate::motion::ReducedMotionIndicator; +use crate::motion::activity_indicator; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; @@ -1668,7 +1670,12 @@ impl HistoryCell for McpToolCallCell { let bullet = match status { Some(true) => "•".green().bold(), Some(false) => "•".red().bold(), - None => spinner(Some(self.start_time), self.animations_enabled), + None => activity_indicator( + Some(self.start_time), + MotionMode::from_animations_enabled(self.animations_enabled), + ReducedMotionIndicator::StaticBullet, + ) + .unwrap_or_else(|| "•".dim()), }; let header_text = if status.is_some() { "Called" @@ -1858,7 +1865,12 @@ impl HistoryCell for WebSearchCell { let bullet = if self.completed { "•".dim() } else { - spinner(Some(self.start_time), self.animations_enabled) + activity_indicator( + Some(self.start_time), + MotionMode::from_animations_enabled(self.animations_enabled), + ReducedMotionIndicator::StaticBullet, + ) + .unwrap_or_else(|| "•".dim()) }; let header = web_search_header(self.completed); let detail = web_search_detail(self.action.as_ref(), &self.query); @@ -2468,7 +2480,12 @@ impl HistoryCell for McpInventoryLoadingCell { fn display_lines(&self, _width: u16) -> Vec> { vec![ vec![ - spinner(Some(self.start_time), self.animations_enabled), + activity_indicator( + Some(self.start_time), + MotionMode::from_animations_enabled(self.animations_enabled), + ReducedMotionIndicator::StaticBullet, + ) + .unwrap_or_else(|| "•".dim()), " ".into(), "Loading MCP inventory".bold(), "…".dim(), @@ -3966,6 +3983,16 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn mcp_inventory_loading_without_animations_is_stable() { + let cell = new_mcp_inventory_loading(/*animations_enabled*/ false); + let first = render_lines(&cell.display_lines(/*width*/ 80)); + let second = render_lines(&cell.display_lines(/*width*/ 80)); + + assert_eq!(first, second); + assert_eq!(first, vec!["• Loading MCP inventory…".to_string()]); + } + #[test] fn completed_mcp_tool_call_success_snapshot() { let invocation = McpInvocation { diff --git a/codex-rs/tui/src/history_cell/hook_cell.rs b/codex-rs/tui/src/history_cell/hook_cell.rs index c44d353c4..ec5f1ca18 100644 --- a/codex-rs/tui/src/history_cell/hook_cell.rs +++ b/codex-rs/tui/src/history_cell/hook_cell.rs @@ -11,9 +11,11 @@ //! first drawn. //! 4. Completed runs only persist when they have output or a non-success status. use super::HistoryCell; -use crate::exec_cell::spinner; +use crate::motion::MotionMode; +use crate::motion::ReducedMotionIndicator; +use crate::motion::activity_indicator; +use crate::motion::shimmer_text; use crate::render::renderable::Renderable; -use crate::shimmer::shimmer_spans; use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::HookOutputEntry; use codex_app_server_protocol::HookOutputEntryKind; @@ -626,11 +628,17 @@ fn push_running_hook_header( status_message: Option<&str>, animations_enabled: bool, ) { - let mut header = vec![spinner(start_time, animations_enabled), " ".into()]; - if animations_enabled { - header.extend(shimmer_spans(hook_text)); - } else { - header.push(hook_text.to_string().bold()); + let mut header = Vec::new(); + let motion_mode = MotionMode::from_animations_enabled(animations_enabled); + if let Some(indicator) = + activity_indicator(start_time, motion_mode, ReducedMotionIndicator::Hidden) + { + header.push(indicator); + header.push(" ".into()); + } + header.extend(shimmer_text(hook_text, motion_mode)); + if !animations_enabled && let Some(span) = header.last_mut() { + span.style = span.style.patch(Style::default().bold()); } if let Some(status_message) = status_message && !status_message.is_empty() @@ -761,6 +769,32 @@ mod tests { assert_eq!(cell.transcript_animation_tick(), None); } + #[test] + fn visible_hook_without_animations_omits_spinner() { + let mut cell = HookCell::new_active( + hook_run_summary("hook-1"), + /*animations_enabled*/ false, + ); + cell.reveal_running_runs_now_for_test(); + cell.advance_time(Instant::now()); + + let rendered: Vec = cell + .display_lines(/*width*/ 80) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect(); + + assert_eq!( + rendered, + vec!["Running PostToolUse hook: checking output policy".to_string()] + ); + } + fn hook_run_summary(id: &str) -> HookRunSummary { HookRunSummary { id: id.to_string(), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 334e412c0..d3d9aa4af 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -140,6 +140,7 @@ mod markdown_stream; mod mention_codec; mod model_catalog; mod model_migration; +mod motion; mod multi_agents; mod notifications; #[cfg(any(not(debug_assertions), test))] diff --git a/codex-rs/tui/src/motion.rs b/codex-rs/tui/src/motion.rs new file mode 100644 index 000000000..bb137ca65 --- /dev/null +++ b/codex-rs/tui/src/motion.rs @@ -0,0 +1,184 @@ +//! Centralized motion primitives for the TUI. +//! +//! Callers choose an explicit reduced-motion fallback here instead of reaching +//! directly for time-varying spinner or shimmer helpers. + +use std::time::Instant; + +use ratatui::style::Stylize; +use ratatui::text::Span; + +use crate::shimmer::shimmer_spans; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum MotionMode { + Animated, + Reduced, +} + +impl MotionMode { + pub(crate) fn from_animations_enabled(animations_enabled: bool) -> Self { + if animations_enabled { + Self::Animated + } else { + Self::Reduced + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ReducedMotionIndicator { + Hidden, + StaticBullet, +} + +pub(crate) fn activity_indicator( + start_time: Option, + motion_mode: MotionMode, + reduced_motion_indicator: ReducedMotionIndicator, +) -> Option> { + match motion_mode { + MotionMode::Animated => Some(animated_activity_indicator(start_time)), + MotionMode::Reduced => match reduced_motion_indicator { + ReducedMotionIndicator::Hidden => None, + ReducedMotionIndicator::StaticBullet => Some("•".dim()), + }, + } +} + +pub(crate) fn shimmer_text(text: &str, motion_mode: MotionMode) -> Vec> { + match motion_mode { + MotionMode::Animated => shimmer_spans(text), + MotionMode::Reduced => { + if text.is_empty() { + Vec::new() + } else { + vec![text.to_string().into()] + } + } + } +} + +fn animated_activity_indicator(start_time: Option) -> Span<'static> { + let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default(); + if supports_color::on_cached(supports_color::Stream::Stdout) + .map(|level| level.has_16m) + .unwrap_or(false) + { + shimmer_spans("•") + .into_iter() + .next() + .unwrap_or_else(|| "•".into()) + } else { + let blink_on = (elapsed.as_millis() / 600).is_multiple_of(2); + if blink_on { "•".into() } else { "◦".dim() } + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + use std::path::PathBuf; + + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn reduced_motion_activity_indicator_uses_explicit_fallback() { + assert_eq!( + activity_indicator( + /*start_time*/ None, + MotionMode::Reduced, + ReducedMotionIndicator::Hidden, + ), + None + ); + assert_eq!( + activity_indicator( + /*start_time*/ None, + MotionMode::Reduced, + ReducedMotionIndicator::StaticBullet, + ), + Some("•".dim()) + ); + } + + #[test] + fn reduced_motion_shimmer_text_is_plain_text() { + assert_eq!( + shimmer_text("Loading", MotionMode::Reduced), + vec!["Loading".into()] + ); + assert_eq!( + shimmer_text("", MotionMode::Reduced), + Vec::>::new() + ); + } + + #[test] + fn animation_primitives_are_only_used_by_motion_module() { + let direct_spinner = regex_lite::Regex::new(r"(^|[^A-Za-z0-9_])spinner\s*\(").unwrap(); + let direct_shimmer = + regex_lite::Regex::new(r"(^|[^A-Za-z0-9_])shimmer_spans\s*\(").unwrap(); + let lib_rs = codex_utils_cargo_bin::find_resource!("src/lib.rs") + .expect("failed to locate TUI source"); + let src_dir = lib_rs.parent().expect("lib.rs should have a parent"); + + let mut source_files = Vec::new(); + collect_rust_files(src_dir, &mut source_files).expect("failed to collect TUI source files"); + + let mut violations = Vec::new(); + for path in source_files { + let relative_path = path + .strip_prefix(src_dir) + .expect("source file should be under src") + .to_string_lossy() + .replace('\\', "/"); + if animation_primitive_allowlisted_path(&relative_path) { + continue; + } + + let contents = fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("failed to read {relative_path}: {err}")); + for (line_number, line) in contents.lines().enumerate() { + let code = line.split_once("//").map_or(line, |(code, _)| code); + if direct_spinner.is_match(code) { + violations.push(format!( + "{relative_path}:{} contains a direct `spinner(...)` call; use crate::motion instead", + line_number + 1 + )); + } + if direct_shimmer.is_match(code) { + violations.push(format!( + "{relative_path}:{} contains a direct `shimmer_spans(...)` call; use crate::motion instead", + line_number + 1 + )); + } + } + } + + assert!( + violations.is_empty(), + "direct animation primitive usage found:\n{}", + violations.join("\n") + ); + } + + fn collect_rust_files(dir: &Path, files: &mut Vec) -> std::io::Result<()> { + for entry in fs::read_dir(dir)? { + let path = entry?.path(); + if path.is_dir() { + collect_rust_files(&path, files)?; + } else if path.extension().is_some_and(|ext| ext == "rs") { + files.push(path); + } + } + Ok(()) + } + + fn animation_primitive_allowlisted_path(relative_path: &str) -> bool { + matches!(relative_path, "motion.rs" | "shimmer.rs") + } +} diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index a5fd4cea4..9ceef56db 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -46,10 +46,11 @@ use uuid::Uuid; use crate::LoginStatus; use crate::key_hint::KeyBinding; use crate::key_hint::KeyBindingListExt; +use crate::motion::MotionMode; +use crate::motion::shimmer_text; use crate::onboarding::keys; use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; -use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; /// Marks buffer cells that have cyan+underlined style as an OSC 8 hyperlink. @@ -511,7 +512,10 @@ impl AuthModeWidget { // Schedule a follow-up frame to keep the shimmer animation going. self.request_frame .schedule_frame_in(std::time::Duration::from_millis(100)); - spans.extend(shimmer_spans("Finish signing in via your browser")); + spans.extend(shimmer_text( + "Finish signing in via your browser", + MotionMode::Animated, + )); } else { spans.push("Finish signing in via your browser".into()); } diff --git a/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs b/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs index 2282649fd..bdaac75ec 100644 --- a/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs +++ b/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs @@ -10,7 +10,8 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; use uuid::Uuid; -use crate::shimmer::shimmer_spans; +use crate::motion::MotionMode; +use crate::motion::shimmer_text; use super::AuthModeWidget; use super::ContinueWithDeviceCodeState; @@ -98,7 +99,7 @@ pub(super) fn render_device_code_login( widget .request_frame .schedule_frame_in(std::time::Duration::from_millis(100)); - spans.extend(shimmer_spans(banner)); + spans.extend(shimmer_text(banner, MotionMode::Animated)); } else { spans.push(banner.into()); } diff --git a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_wrapped_details_panama_two_lines.snap b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_wrapped_details_panama_two_lines.snap index 565d5451f..c1f6112fe 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_wrapped_details_panama_two_lines.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__status_indicator_widget__tests__renders_wrapped_details_panama_two_lines.snap @@ -2,6 +2,6 @@ source: tui/src/status_indicator_widget.rs expression: terminal.backend() --- -"• Working (0s) " +"Working (0s) " " └ A man a plan a canal " " panama " diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index 94aec5d7f..dabe00535 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -19,11 +19,13 @@ use ratatui::widgets::WidgetRef; use unicode_width::UnicodeWidthStr; use crate::app_event_sender::AppEventSender; -use crate::exec_cell::spinner; use crate::key_hint; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::motion::MotionMode; +use crate::motion::ReducedMotionIndicator; +use crate::motion::activity_indicator; +use crate::motion::shimmer_text; use crate::render::renderable::Renderable; -use crate::shimmer::shimmer_spans; use crate::text_formatting::capitalize_first; use crate::tui::FrameRequester; use crate::wrapping::RtOptions; @@ -240,16 +242,21 @@ impl Renderable for StatusIndicatorWidget { let now = Instant::now(); let elapsed_duration = self.elapsed_duration_at(now); let pretty_elapsed = fmt_elapsed_compact(elapsed_duration.as_secs()); + let motion_mode = MotionMode::from_animations_enabled(self.animations_enabled); let mut spans = Vec::with_capacity(5); - spans.push(spinner(Some(self.last_resume_at), self.animations_enabled)); - spans.push(" ".into()); - if self.animations_enabled { - spans.extend(shimmer_spans(&self.header)); - } else if !self.header.is_empty() { - spans.push(self.header.clone().into()); + if let Some(indicator) = activity_indicator( + Some(self.last_resume_at), + motion_mode, + ReducedMotionIndicator::Hidden, + ) { + spans.push(indicator); + spans.push(" ".into()); + } + spans.extend(shimmer_text(&self.header, motion_mode)); + if !spans.is_empty() { + spans.push(" ".into()); } - spans.push(" ".into()); if self.show_interrupt_hint { spans.extend(vec![ format!("({pretty_elapsed} • ").dim(), @@ -374,6 +381,30 @@ mod tests { insta::assert_snapshot!(terminal.backend()); } + #[test] + fn renders_without_spinner_when_animations_disabled() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut w = StatusIndicatorWidget::new( + tx, + crate::tui::FrameRequester::test_dummy(), + /*animations_enabled*/ false, + ); + w.is_paused = true; + w.elapsed_running = Duration::ZERO; + + let mut terminal = Terminal::new(TestBackend::new(80, 1)).expect("terminal"); + terminal + .draw(|f| w.render(f.area(), f.buffer_mut())) + .expect("draw"); + let line = terminal.backend().buffer().content()[..80] + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::(); + + assert!(line.starts_with("Working (0s • esc to interrupt)")); + } + #[test] fn timer_pauses_when_requested() { let (tx_raw, _rx) = unbounded_channel::();