Added feature switch to disable animations in TUI (#6870)

This PR adds support for a new feature flag `tui.animations`. By
default, the TUI uses animations in its welcome screen, "working"
spinners, and "shimmer" effects. This animations can interfere with
screen readers, so it's good to provide a way to disable them.

This change is inspired by [a
PR](https://github.com/openai/codex/pull/4014) contributed by @Orinks.
That PR has faltered a bit, but I think the core idea is sound. This
version incorporates feedback from @aibrahim-oai. In particular:
1. It uses a feature flag (`tui.animations`) rather than the unqualified
CLI key `no-animations`. Feature flags are the preferred way to expose
boolean switches. They are also exposed via CLI command switches.
2. It includes more complete documentation.
3. It disables a few animations that the other PR omitted.
This commit is contained in:
Eric Traut
2025-11-20 12:40:08 -06:00
committed by GitHub
parent 888c6dd9e7
commit d909048a85
15 changed files with 326 additions and 188 deletions

View File

@@ -155,6 +155,7 @@ pub(crate) struct AuthModeWidget {
pub auth_manager: Arc<AuthManager>,
pub forced_chatgpt_workspace_id: Option<String>,
pub forced_login_method: Option<ForcedLoginMethod>,
pub animations_enabled: bool,
}
impl AuthModeWidget {
@@ -260,10 +261,14 @@ impl AuthModeWidget {
fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) {
let mut spans = vec![" ".into()];
// 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"));
if self.animations_enabled {
// 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"));
} else {
spans.push("Finish signing in via your browser".into());
}
let mut lines = vec![spans.into(), "".into()];
let sign_in_state = self.sign_in_state.read().unwrap();
@@ -670,6 +675,7 @@ mod tests {
),
forced_chatgpt_workspace_id: None,
forced_login_method: Some(ForcedLoginMethod::Chatgpt),
animations_enabled: true,
};
(widget, codex_home)
}

View File

@@ -88,6 +88,7 @@ impl OnboardingScreen {
steps.push(Step::Welcome(WelcomeWidget::new(
!matches!(login_status, LoginStatus::NotAuthenticated),
tui.frame_requester(),
config.animations,
)));
if show_login_screen {
let highlighted_mode = match forced_login_method {
@@ -105,6 +106,7 @@ impl OnboardingScreen {
auth_manager,
forced_chatgpt_workspace_id,
forced_login_method,
animations_enabled: config.animations,
}))
}
let is_git_repo = get_git_repo_root(&cwd).is_some();

View File

@@ -25,10 +25,14 @@ const MIN_ANIMATION_WIDTH: u16 = 60;
pub(crate) struct WelcomeWidget {
pub is_logged_in: bool,
animation: AsciiAnimation,
animations_enabled: bool,
}
impl KeyboardHandler for WelcomeWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if !self.animations_enabled {
return;
}
if key_event.kind == KeyEventKind::Press
&& key_event.code == KeyCode::Char('.')
&& key_event.modifiers.contains(KeyModifiers::CONTROL)
@@ -40,10 +44,15 @@ impl KeyboardHandler for WelcomeWidget {
}
impl WelcomeWidget {
pub(crate) fn new(is_logged_in: bool, request_frame: FrameRequester) -> Self {
pub(crate) fn new(
is_logged_in: bool,
request_frame: FrameRequester,
animations_enabled: bool,
) -> Self {
Self {
is_logged_in,
animation: AsciiAnimation::new(request_frame),
animations_enabled,
}
}
}
@@ -51,17 +60,17 @@ impl WelcomeWidget {
impl WidgetRef for &WelcomeWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
self.animation.schedule_next_frame();
if self.animations_enabled {
self.animation.schedule_next_frame();
}
// Skip the animation entirely when the viewport is too small so we don't clip frames.
let show_animation =
area.height >= MIN_ANIMATION_HEIGHT && area.width >= MIN_ANIMATION_WIDTH;
let mut lines: Vec<Line> = Vec::new();
if show_animation {
if show_animation && self.animations_enabled {
let frame = self.animation.current_frame();
// let frame_line_count = frame.lines().count();
// lines.reserve(frame_line_count + 2);
lines.extend(frame.lines().map(Into::into));
lines.push("".into());
}
@@ -99,7 +108,7 @@ mod tests {
#[test]
fn welcome_renders_animation_on_first_draw() {
let widget = WelcomeWidget::new(false, FrameRequester::test_dummy());
let widget = WelcomeWidget::new(false, FrameRequester::test_dummy(), true);
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT);
let mut buf = Buffer::empty(area);
(&widget).render(area, &mut buf);
@@ -129,6 +138,7 @@ mod tests {
let mut widget = WelcomeWidget {
is_logged_in: false,
animation: AsciiAnimation::with_variants(FrameRequester::test_dummy(), &VARIANTS, 0),
animations_enabled: true,
};
let before = widget.animation.current_frame();