mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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`
This commit is contained in:
committed by
GitHub
Unverified
parent
f476338f93
commit
227bee0445
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Instant>, 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<Instant>, 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<Line<'static>> = 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<String> = cell
|
||||
.command_display_lines(/*width*/ 80)
|
||||
.iter()
|
||||
.map(render_line_text)
|
||||
.collect();
|
||||
let second: Vec<String> = 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";
|
||||
|
||||
@@ -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<Line<'static>> {
|
||||
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 {
|
||||
|
||||
@@ -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<String> = cell
|
||||
.display_lines(/*width*/ 80)
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.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(),
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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<Instant>,
|
||||
motion_mode: MotionMode,
|
||||
reduced_motion_indicator: ReducedMotionIndicator,
|
||||
) -> Option<Span<'static>> {
|
||||
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<Span<'static>> {
|
||||
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<Instant>) -> 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::<Span<'static>>::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<PathBuf>) -> 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")
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 "
|
||||
|
||||
@@ -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::<AppEvent>();
|
||||
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::<String>();
|
||||
|
||||
assert!(line.starts_with("Working (0s • esc to interrupt)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_pauses_when_requested() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
|
||||
Reference in New Issue
Block a user