From 08f916e33b6bd30d3b015713dcb925a2105abf57 Mon Sep 17 00:00:00 2001 From: Edwin Date: Tue, 18 Aug 2026 19:26:14 -0700 Subject: [PATCH 1/3] feat(tui): animate focused pane borders --- crates/cli/src/app.rs | 140 ++++++++++++++++++++++++++ crates/cli/src/ui.rs | 144 ++++++++++++++++++++++++++- specs/0203-tui-focus-border-sweep.md | 31 ++++++ 3 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 specs/0203-tui-focus-border-sweep.md diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index dcb338ad..1bbb218c 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -696,6 +696,118 @@ pub enum PaneFocus { View, } +/// Stable identity of the bordered TUI surface that currently owns keyboard +/// focus. `PaneFocus` alone cannot distinguish sibling split windows or the +/// lineage section nested inside the list pane, both of which need their own +/// focus-acquisition affordance. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FocusBorderTarget { + SessionList, + Lineage, + MainWindow(u64), +} + +/// Transient state for the focused-pane border sweep. The first observed +/// target establishes the baseline without animating; only a real focus +/// transition starts a sweep. +#[derive(Debug, Default)] +pub(crate) struct FocusBorderSweep { + target: Option, + started_at: Option, +} + +impl FocusBorderSweep { + pub(crate) fn observe( + &mut self, + target: FocusBorderTarget, + now: Instant, + ) -> Option { + match self.target { + None => { + self.target = Some(target); + return None; + } + Some(previous) if previous != target => { + self.target = Some(target); + self.started_at = Some(now); + } + Some(_) => {} + } + + let started_at = self.started_at?; + let elapsed = now.saturating_duration_since(started_at); + if elapsed >= Duration::from_millis(FOCUS_BORDER_SWEEP_MS) { + self.started_at = None; + return None; + } + Some(elapsed.as_secs_f32() / Duration::from_millis(FOCUS_BORDER_SWEEP_MS).as_secs_f32()) + } + + /// Keep target tracking current when its surface has no border (the + /// edge-to-edge zoomed view), without scheduling an invisible animation. + pub(crate) fn sync(&mut self, target: FocusBorderTarget) { + self.target = Some(target); + self.started_at = None; + } + + pub(crate) fn is_animating(&self, now: Instant) -> bool { + self.started_at.is_some_and(|started_at| { + now.saturating_duration_since(started_at) + < Duration::from_millis(FOCUS_BORDER_SWEEP_MS) + }) + } +} + +#[cfg(test)] +mod focus_border_sweep_tests { + use super::*; + + #[test] + fn first_focus_is_baseline_then_changes_sweep_for_two_hundred_ms() { + let mut sweep = FocusBorderSweep::default(); + let t0 = Instant::now(); + assert_eq!( + sweep.observe(FocusBorderTarget::MainWindow(1), t0), + None, + "startup focus should not flash" + ); + + assert_eq!(sweep.observe(FocusBorderTarget::SessionList, t0), Some(0.0)); + assert!(sweep.is_animating(t0 + Duration::from_millis(199))); + let halfway = sweep + .observe( + FocusBorderTarget::SessionList, + t0 + Duration::from_millis(100), + ) + .expect("sweep is active"); + assert!((halfway - 0.5).abs() < f32::EPSILON); + assert_eq!( + sweep.observe( + FocusBorderTarget::SessionList, + t0 + Duration::from_millis(200), + ), + None + ); + assert!(!sweep.is_animating(t0 + Duration::from_millis(200))); + } + + #[test] + fn changing_split_windows_restarts_an_in_flight_sweep() { + let mut sweep = FocusBorderSweep::default(); + let t0 = Instant::now(); + sweep.sync(FocusBorderTarget::SessionList); + assert_eq!(sweep.observe(FocusBorderTarget::MainWindow(1), t0), Some(0.0)); + assert_eq!( + sweep.observe( + FocusBorderTarget::MainWindow(2), + t0 + Duration::from_millis(150), + ), + Some(0.0), + "the newly focused sibling starts at its own top-left corner" + ); + } +} + /// A spatial direction for moving keyboard focus between split panes /// (emacs `windmove`). Used by the `Shift+Arrow` bindings. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -2034,6 +2146,9 @@ pub struct App { /// frame. The 120 ms timer still drives animation and maintenance, but a /// static frame can leave its tick unpainted instead of rebuilding the TUI. tick_redraw_requested: Cell, + /// Brief directional highlight that identifies the pane which just took + /// keyboard focus. + pub(crate) focus_border_sweep: FocusBorderSweep, /// Set by `on_notification` to report whether the just-handled /// notification changed something currently *visible* (a focused / /// split pane, the minibuffer panel, or any structural / @@ -5158,6 +5273,11 @@ pub const PTY_QUIESCENCE: Duration = Duration::from_millis(600); /// Spinner frame cadence — fast enough to feel alive, slow enough to keep /// the TUI tick loop cheap. pub const SPINNER_FRAME_MS: u128 = 120; +/// Duration and dedicated frame cadence for the pane focus-border sweep. The +/// ordinary spinner tick stays at 120 ms; this faster timer is polled only +/// while a sweep is actually visible. +pub const FOCUS_BORDER_SWEEP_MS: u64 = 200; +const FOCUS_BORDER_SWEEP_FRAME_MS: u64 = 40; /// Pulsing-star spinner: a 4-glyph sparkle whose size "breathes" via a /// palindromic frame schedule (small → big → small). Single cell wide so /// it slots into the same column as the static state glyph. @@ -5538,6 +5658,7 @@ async fn run_with_socket_initial_selection( operator_view_scroll: 0, skip_redraw_after_event: false, tick_redraw_requested: Cell::new(false), + focus_border_sweep: FocusBorderSweep::default(), notification_dirtied_view: true, hydrating_sessions: HashSet::new(), minibuffer_scrollback: 0, @@ -5921,6 +6042,12 @@ async fn run_loop( // Tick at the spinner frame boundary. Visible animations request paints; // static frames use the same wakeup only for maintenance. let mut tick = tokio::time::interval(Duration::from_millis(SPINNER_FRAME_MS as u64)); + // A focus sweep needs more than the spinner tick's one intermediate frame + // to read as directional. This timer is gated off outside the 200 ms + // animation window, so idle/static TUIs retain the existing wake cadence. + let mut focus_border_tick = + tokio::time::interval(Duration::from_millis(FOCUS_BORDER_SWEEP_FRAME_MS)); + focus_border_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); // Lineage preview, keyboard-focused mode (spec 0080; supersedes the old // `C-x q` / `q` popup, spec 0139): its per-node elapsed-time/cost stats // are recomputed at render time from `SessionSummary` fields that are @@ -6398,6 +6525,10 @@ async fn run_loop( } } } + _ = focus_border_tick.tick(), if app.focus_border_sweep.is_animating(Instant::now()) => { + // Waking the loop is sufficient: the next iteration paints + // the sweep at its current monotonic-clock position. + } // Gate on `reconnect.is_none()`: once the daemon drops, the // old `notifications` channel is closed, so `recv()` is // *immediately* ready with `None` on every poll. Left @@ -6816,6 +6947,14 @@ fn op_xy_slot_state_masks(sessions: &[SessionSummary], slots: &[Option]) } impl App { + pub(crate) fn focused_border_target(&self) -> FocusBorderTarget { + match self.focus { + PaneFocus::List if self.lineage_focused => FocusBorderTarget::Lineage, + PaneFocus::List => FocusBorderTarget::SessionList, + PaneFocus::View => FocusBorderTarget::MainWindow(self.active_window_id), + } + } + async fn reconnect( &mut self, socket: &std::path::Path, @@ -17538,6 +17677,7 @@ mod tests { operator_view_scroll: 0, skip_redraw_after_event: false, tick_redraw_requested: Cell::new(false), + focus_border_sweep: FocusBorderSweep::default(), notification_dirtied_view: true, hydrating_sessions: HashSet::new(), minibuffer_scrollback: 0, diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index 75d79690..8208f049 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -2,8 +2,8 @@ use crate::app::{ feature_guidance, harness_guidance, harness_picker_entries, smith_method_guidance, App, - ConfigureTab, HarnessHit, HintZone, ListItem as AppListItem, MainWindowTree, Prompt, - PromptChoiceAction, PromptChoiceHit, PromptIntent, PaneFocus, RemoteControlHit, + ConfigureTab, FocusBorderTarget, HarnessHit, HintZone, ListItem as AppListItem, MainWindowTree, + PaneFocus, Prompt, PromptChoiceAction, PromptChoiceHit, PromptIntent, RemoteControlHit, RemoteControlHitAction, ScreenPoint, Selection, OperatorTitleMenuAction, SessionTitleMenuAction, TextSelectionRange, TurnRowHit, ViewMode, WindowDividerHit, WindowPaneHit, WindowSplitDirection, ZoomMode, CONFIGURE_TABS, PLAYBOOK_AGENT_COLLAB_CURSOR_TTL_MS, @@ -457,6 +457,7 @@ pub fn render(f: &mut Frame, app: &mut App) { // them after Playbooks and transitions so a rolled-down document cannot // cover the badge at its owning pane's top-left corner. paint_main_window_ordinal_badges(f, app); + render_focus_border_sweep(f, app, Instant::now()); // The block is complete: slide it, translate everything it recorded into // screen coordinates, and hand the pointer back. Everything below paints @@ -2387,6 +2388,9 @@ fn render_zoomed_view(f: &mut Frame, area: Rect, app: &mut App) { ViewMode::Chat => render_chat(f, main_area, app), } } + // Zoomed views are deliberately borderless. Sync the focus identity so + // returning to a bordered layout cannot replay a stale transition. + render_focus_border_sweep(f, app, Instant::now()); apply_main_block_slide(f, app, &slide); app.mouse_pos = screen_mouse; render_prompt(f, prompt_area, app); @@ -2417,6 +2421,7 @@ fn render_zoomed_list(f: &mut Frame, area: Rect, app: &mut App) { app.layout.list_scroll_offset = 0; render_sessions(f, main_area, app); + render_focus_border_sweep(f, app, Instant::now()); apply_main_block_slide(f, app, &slide); app.mouse_pos = screen_mouse; render_prompt(f, prompt_area, app); @@ -15103,6 +15108,141 @@ fn pane_border_style(theme: &Theme, focused: bool) -> Style { } } +/// Paint the moving bright band for a newly focused pane after that pane and +/// its title chrome are fully composited. A full frame uses `(x + y) / 2` as +/// its phase, so the band intersects the top/left edges first and the +/// bottom/right edges last: a diagonal top-left → bottom-right sweep without +/// changing any border glyphs or pane geometry. +fn render_focus_border_sweep(f: &mut Frame, app: &mut App, now: Instant) { + let target = app.focused_border_target(); + let rect = match target { + FocusBorderTarget::SessionList => app.layout.list_area, + FocusBorderTarget::Lineage => app.layout.lineage_area, + FocusBorderTarget::MainWindow(id) => app + .layout + .main_window_areas + .iter() + .find(|pane| pane.id == id) + .map(|pane| pane.area), + }; + let Some(rect) = rect.filter(|rect| rect.width > 0 && rect.height > 0) else { + app.focus_border_sweep.sync(target); + return; + }; + let Some(progress) = app.focus_border_sweep.observe(target, now) else { + return; + }; + app.request_tick_redraw(); + + // Lineage owns a header rule rather than a four-sided frame. Pane side + // borders are also user-hideable (and hidden by default); in both cases + // keep the sweep on the visible top chrome instead of resurrecting glyphs + // the layout intentionally omitted. + let top_only = target == FocusBorderTarget::Lineage || app.hide_pane_side_borders; + paint_focus_border_sweep( + f, + rect, + progress, + top_only, + Style::default() + .fg(app.theme.accent) + // Reverse makes the tracer a filled, theme-colored highlight + // instead of a barely different green line in the Matrix theme; + // it also remains legible on low-color terminals. + .add_modifier(Modifier::BOLD | Modifier::REVERSED) + .remove_modifier(Modifier::DIM), + ); +} + +fn paint_focus_border_sweep( + f: &mut Frame, + rect: Rect, + progress: f32, + top_only: bool, + highlight: Style, +) { + if rect.width == 0 || rect.height == 0 { + return; + } + const BAND_HALF_WIDTH: f32 = 0.14; + let x_denom = rect.width.saturating_sub(1).max(1) as f32; + let y_denom = rect.height.saturating_sub(1).max(1) as f32; + for y in rect.top()..rect.bottom() { + for x in rect.left()..rect.right() { + let on_edge = y == rect.top() + || (!top_only + && (y == rect.bottom().saturating_sub(1) + || x == rect.left() + || x == rect.right().saturating_sub(1))); + if !on_edge { + continue; + } + let nx = x.saturating_sub(rect.left()) as f32 / x_denom; + let phase = if top_only { + nx + } else { + let ny = y.saturating_sub(rect.top()) as f32 / y_denom; + (nx + ny) * 0.5 + }; + if (phase - progress).abs() > BAND_HALF_WIDTH { + continue; + } + if let Some(cell) = f.buffer_mut().cell_mut(Position { x, y }) { + cell.set_style(cell.style().patch(highlight)); + } + } + } +} + +#[cfg(test)] +mod focus_border_paint_tests { + use super::*; + use ratatui::backend::TestBackend; + + #[test] + fn full_border_band_crosses_top_right_and_bottom_left_halfway() { + let backend = TestBackend::new(12, 12); + let mut terminal = ratatui::Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| { + paint_focus_border_sweep( + f, + Rect::new(0, 0, 11, 11), + 0.5, + false, + Style::default().fg(Color::Red), + ); + }) + .expect("draw"); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(10, 0)].fg, Color::Red); + assert_eq!(buffer[(0, 10)].fg, Color::Red); + assert_ne!(buffer[(0, 0)].fg, Color::Red); + assert_ne!(buffer[(10, 10)].fg, Color::Red); + } + + #[test] + fn top_only_band_traverses_visible_chrome_without_painting_hidden_sides() { + let backend = TestBackend::new(12, 12); + let mut terminal = ratatui::Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| { + paint_focus_border_sweep( + f, + Rect::new(0, 0, 11, 11), + 0.5, + true, + Style::default().fg(Color::Blue), + ); + }) + .expect("draw"); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(5, 0)].fg, Color::Blue); + assert_ne!(buffer[(0, 5)].fg, Color::Blue); + assert_ne!(buffer[(5, 10)].fg, Color::Blue); + } +} + /// Style for the title text of a session pane. The last-focused pane keeps /// the focused border hue even when focus sits on the session list — the /// border dims, the name doesn't — so the pane `C-x o` returns to stays diff --git a/specs/0203-tui-focus-border-sweep.md b/specs/0203-tui-focus-border-sweep.md new file mode 100644 index 00000000..f48b8d73 --- /dev/null +++ b/specs/0203-tui-focus-border-sweep.md @@ -0,0 +1,31 @@ +# 0203-tui-focus-border-sweep + +Status: accepted +Date: 2026-08-18 +Area: tui +Scope: Keyboard focus acquisition is acknowledged by a brief directional highlight on the newly focused pane's visible border chrome. + +## Decision + +When keyboard focus moves to a different TUI pane or focusable sidebar section, that surface plays a roughly 200 ms border highlight sweeping from its top-left toward its bottom-right, then settles into the ordinary focused-border appearance. + +Focus identity includes the session-list rows, the lineage section, and each split window independently. Moving between sibling split windows therefore retriggers the animation even though both use the same general view-focus route. The initial pane at TUI startup does not animate. + +The effect changes style only. It must not change border glyphs, pane geometry, content layout, terminal size, or input routing. When side and bottom borders are hidden, or a section exposes only a header rule, the sweep follows that visible top chrome and must not resurrect hidden edges. Edge-to-edge borderless zoom views do not animate. + +## Reason + +Static border-color changes are easy to miss when focus jumps among similarly shaped panes, especially in a split layout. A short directional motion gives the eye a clear acquisition cue without becoming a persistent distraction or delaying interaction. + +## Consequences + +- Every keyboard, mouse, or external-controller path that changes the existing focus state receives the same affordance because rendering derives it from focus identity rather than from individual input handlers. +- The animation uses monotonic time and requests frames only while visible; an otherwise idle TUI keeps its normal redraw cadence. +- Focus changes during an active sweep restart the effect on the newest target. +- Pane-specific border hues and steady focused/unfocused semantics remain authoritative after the sweep ends. + +## Non-Goals + +- Animating selection changes within a focused pane. +- Adding borders to borderless or intentionally hidden-edge layouts. +- Changing focus order, keybindings, or mouse behavior. From 5a32a31da3f4fea4749e8f2be161b585438ab05d Mon Sep 17 00:00:00 2001 From: Edwin Date: Tue, 18 Aug 2026 20:03:35 -0700 Subject: [PATCH 2/3] fix(tui): sweep full border foreground --- crates/cli/src/app.rs | 20 ++++- crates/cli/src/ui.rs | 116 ++++++++++++++++++--------- specs/0203-tui-focus-border-sweep.md | 8 +- 3 files changed, 100 insertions(+), 44 deletions(-) diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 1bbb218c..498e13ab 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -740,7 +740,13 @@ impl FocusBorderSweep { self.started_at = None; return None; } - Some(elapsed.as_secs_f32() / Duration::from_millis(FOCUS_BORDER_SWEEP_MS).as_secs_f32()) + // Reach the bottom-right endpoint on the final scheduled visible + // frame. The following cadence tick clears the overlay at 200 ms. + Some( + (elapsed.as_secs_f32() + / Duration::from_millis(FOCUS_BORDER_SWEEP_TRAVEL_MS).as_secs_f32()) + .min(1.0), + ) } /// Keep target tracking current when its surface has no border (the @@ -777,10 +783,18 @@ mod focus_border_sweep_tests { let halfway = sweep .observe( FocusBorderTarget::SessionList, - t0 + Duration::from_millis(100), + t0 + Duration::from_millis(80), ) .expect("sweep is active"); assert!((halfway - 0.5).abs() < f32::EPSILON); + assert_eq!( + sweep.observe( + FocusBorderTarget::SessionList, + t0 + Duration::from_millis(160), + ), + Some(1.0), + "the last visible cadence frame reaches the bottom-right corner" + ); assert_eq!( sweep.observe( FocusBorderTarget::SessionList, @@ -5278,6 +5292,8 @@ pub const SPINNER_FRAME_MS: u128 = 120; /// while a sweep is actually visible. pub const FOCUS_BORDER_SWEEP_MS: u64 = 200; const FOCUS_BORDER_SWEEP_FRAME_MS: u64 = 40; +const FOCUS_BORDER_SWEEP_TRAVEL_MS: u64 = + FOCUS_BORDER_SWEEP_MS - FOCUS_BORDER_SWEEP_FRAME_MS; /// Pulsing-star spinner: a 4-glyph sparkle whose size "breathes" via a /// palindromic frame schedule (small → big → small). Single cell wide so /// it slots into the same column as the static state glyph. diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index 8208f049..ac2eab41 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -15109,10 +15109,10 @@ fn pane_border_style(theme: &Theme, focused: bool) -> Style { } /// Paint the moving bright band for a newly focused pane after that pane and -/// its title chrome are fully composited. A full frame uses `(x + y) / 2` as -/// its phase, so the band intersects the top/left edges first and the -/// bottom/right edges last: a diagonal top-left → bottom-right sweep without -/// changing any border glyphs or pane geometry. +/// its title chrome are fully composited. The overlay temporarily draws all +/// four edges even when the steady pane chrome hides some of them. `(x + y) / +/// 2` supplies the phase, so the band intersects the top/left edges first and +/// the bottom/right edges last: a diagonal top-left → bottom-right sweep. fn render_focus_border_sweep(f: &mut Frame, app: &mut App, now: Instant) { let target = app.focused_border_target(); let rect = match target { @@ -15134,33 +15134,18 @@ fn render_focus_border_sweep(f: &mut Frame, app: &mut App, now: Instant) { }; app.request_tick_redraw(); - // Lineage owns a header rule rather than a four-sided frame. Pane side - // borders are also user-hideable (and hidden by default); in both cases - // keep the sweep on the visible top chrome instead of resurrecting glyphs - // the layout intentionally omitted. - let top_only = target == FocusBorderTarget::Lineage || app.hide_pane_side_borders; paint_focus_border_sweep( f, rect, progress, - top_only, Style::default() .fg(app.theme.accent) - // Reverse makes the tracer a filled, theme-colored highlight - // instead of a barely different green line in the Matrix theme; - // it also remains legible on low-color terminals. - .add_modifier(Modifier::BOLD | Modifier::REVERSED) - .remove_modifier(Modifier::DIM), + .add_modifier(Modifier::BOLD) + .remove_modifier(Modifier::DIM | Modifier::REVERSED), ); } -fn paint_focus_border_sweep( - f: &mut Frame, - rect: Rect, - progress: f32, - top_only: bool, - highlight: Style, -) { +fn paint_focus_border_sweep(f: &mut Frame, rect: Rect, progress: f32, highlight: Style) { if rect.width == 0 || rect.height == 0 { return; } @@ -15170,25 +15155,38 @@ fn paint_focus_border_sweep( for y in rect.top()..rect.bottom() { for x in rect.left()..rect.right() { let on_edge = y == rect.top() - || (!top_only - && (y == rect.bottom().saturating_sub(1) - || x == rect.left() - || x == rect.right().saturating_sub(1))); + || y == rect.bottom().saturating_sub(1) + || x == rect.left() + || x == rect.right().saturating_sub(1); if !on_edge { continue; } let nx = x.saturating_sub(rect.left()) as f32 / x_denom; - let phase = if top_only { - nx - } else { - let ny = y.saturating_sub(rect.top()) as f32 / y_denom; - (nx + ny) * 0.5 - }; + let ny = y.saturating_sub(rect.top()) as f32 / y_denom; + let phase = (nx + ny) * 0.5; if (phase - progress).abs() > BAND_HALF_WIDTH { continue; } if let Some(cell) = f.buffer_mut().cell_mut(Position { x, y }) { - cell.set_style(cell.style().patch(highlight)); + let symbol = match ( + x == rect.left(), + x == rect.right().saturating_sub(1), + y == rect.top(), + y == rect.bottom().saturating_sub(1), + ) { + (true, _, true, _) => "┌", + (_, true, true, _) => "┐", + (true, _, _, true) => "└", + (_, true, _, true) => "┘", + (_, _, true, _) | (_, _, _, true) => "─", + _ => "│", + }; + cell.set_symbol(symbol); + cell.set_style(highlight); + // `Cell::set_style` patches the existing cell and therefore + // cannot clear a modifier inherited from the pane beneath it. + // The focus cue is the glyph foreground, never reverse video. + cell.modifier.remove(Modifier::DIM | Modifier::REVERSED); } } } @@ -15209,7 +15207,6 @@ mod focus_border_paint_tests { f, Rect::new(0, 0, 11, 11), 0.5, - false, Style::default().fg(Color::Red), ); }) @@ -15222,24 +15219,65 @@ mod focus_border_paint_tests { } #[test] - fn top_only_band_traverses_visible_chrome_without_painting_hidden_sides() { + fn sweep_draws_full_foreground_border_over_hidden_edges() { let backend = TestBackend::new(12, 12); let mut terminal = ratatui::Terminal::new(backend).expect("terminal"); terminal .draw(|f| { + f.buffer_mut() + .cell_mut(Position { x: 0, y: 5 }) + .expect("left edge") + .set_style( + Style::default() + .fg(Color::Green) + .bg(Color::Yellow) + .add_modifier(Modifier::REVERSED), + ); paint_focus_border_sweep( f, Rect::new(0, 0, 11, 11), - 0.5, - true, + 0.25, Style::default().fg(Color::Blue), ); }) .expect("draw"); let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(5, 0)].symbol(), "─"); assert_eq!(buffer[(5, 0)].fg, Color::Blue); - assert_ne!(buffer[(0, 5)].fg, Color::Blue); - assert_ne!(buffer[(5, 10)].fg, Color::Blue); + assert_eq!(buffer[(0, 5)].symbol(), "│"); + assert_eq!(buffer[(0, 5)].fg, Color::Blue); + assert_eq!(buffer[(0, 5)].bg, Color::Yellow); + assert!(!buffer[(0, 5)].modifier.contains(Modifier::REVERSED)); + assert_eq!(buffer[(0, 0)].symbol(), " "); + } + + #[test] + fn sweep_draws_corner_glyphs_at_both_diagonal_endpoints() { + let backend = TestBackend::new(12, 12); + let mut terminal = ratatui::Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| { + paint_focus_border_sweep( + f, + Rect::new(0, 0, 11, 11), + 0.0, + Style::default().fg(Color::Cyan), + ); + }) + .expect("draw first endpoint"); + assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), "┌"); + + terminal + .draw(|f| { + paint_focus_border_sweep( + f, + Rect::new(0, 0, 11, 11), + 1.0, + Style::default().fg(Color::Cyan), + ); + }) + .expect("draw second endpoint"); + assert_eq!(terminal.backend().buffer()[(10, 10)].symbol(), "┘"); } } diff --git a/specs/0203-tui-focus-border-sweep.md b/specs/0203-tui-focus-border-sweep.md index f48b8d73..bd5691da 100644 --- a/specs/0203-tui-focus-border-sweep.md +++ b/specs/0203-tui-focus-border-sweep.md @@ -3,7 +3,7 @@ Status: accepted Date: 2026-08-18 Area: tui -Scope: Keyboard focus acquisition is acknowledged by a brief directional highlight on the newly focused pane's visible border chrome. +Scope: Keyboard focus acquisition is acknowledged by a brief directional highlight around the newly focused pane's full perimeter. ## Decision @@ -11,7 +11,9 @@ When keyboard focus moves to a different TUI pane or focusable sidebar section, Focus identity includes the session-list rows, the lineage section, and each split window independently. Moving between sibling split windows therefore retriggers the animation even though both use the same general view-focus route. The initial pane at TUI startup does not animate. -The effect changes style only. It must not change border glyphs, pane geometry, content layout, terminal size, or input routing. When side and bottom borders are hidden, or a section exposes only a header rule, the sweep follows that visible top chrome and must not resurrect hidden edges. Edge-to-edge borderless zoom views do not animate. +The sweep temporarily draws all four border edges, including their corners, even when the pane's steady chrome hides its side and bottom borders or exposes only a header rule. The highlight changes the border line's foreground color and weight; it does not fill or reverse the cells' backgrounds. Once the sweep passes, the pane immediately returns to its configured steady border visibility. + +The effect must not change pane geometry, content layout, terminal size, or input routing. Edge-to-edge borderless zoom views do not animate. ## Reason @@ -27,5 +29,5 @@ Static border-color changes are easy to miss when focus jumps among similarly sh ## Non-Goals - Animating selection changes within a focused pane. -- Adding borders to borderless or intentionally hidden-edge layouts. +- Persistently adding borders to borderless or intentionally hidden-edge layouts. - Changing focus order, keybindings, or mouse behavior. From b676fdacfe86015905926861cb24e5ce99dd7a47 Mon Sep 17 00:00:00 2001 From: Edwin Date: Tue, 18 Aug 2026 20:26:22 -0700 Subject: [PATCH 3/3] fix(tui): preserve titles during focus sweep --- crates/cli/src/ui.rs | 39 ++++++++++++++++++++++++++++ specs/0203-tui-focus-border-sweep.md | 2 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index ac2eab41..6013e3a0 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -15168,6 +15168,15 @@ fn paint_focus_border_sweep(f: &mut Frame, rect: Rect, progress: f32, highlight: continue; } if let Some(cell) = f.buffer_mut().cell_mut(Position { x, y }) { + let is_corner = (x == rect.left() || x == rect.right().saturating_sub(1)) + && (y == rect.top() || y == rect.bottom().saturating_sub(1)); + // Pane titles and their live edit controls occupy the top + // border row. Highlight only actual horizontal-rule cells + // there; hidden side/bottom edges are blank and deliberately + // receive transient glyphs below. Corners always participate. + if y == rect.top() && !is_corner && cell.symbol() != "─" { + continue; + } let symbol = match ( x == rect.left(), x == rect.right().saturating_sub(1), @@ -15224,6 +15233,10 @@ mod focus_border_paint_tests { let mut terminal = ratatui::Terminal::new(backend).expect("terminal"); terminal .draw(|f| { + f.buffer_mut() + .cell_mut(Position { x: 5, y: 0 }) + .expect("top rule") + .set_symbol("─"); f.buffer_mut() .cell_mut(Position { x: 0, y: 5 }) .expect("left edge") @@ -15279,6 +15292,32 @@ mod focus_border_paint_tests { .expect("draw second endpoint"); assert_eq!(terminal.backend().buffer()[(10, 10)].symbol(), "┘"); } + + #[test] + fn sweep_does_not_overwrite_title_text_in_top_border() { + let backend = TestBackend::new(22, 12); + let mut terminal = ratatui::Terminal::new(backend).expect("terminal"); + terminal + .draw(|f| { + f.buffer_mut().set_string( + 1, + 0, + " rename me ", + Style::default().fg(Color::Yellow), + ); + paint_focus_border_sweep( + f, + Rect::new(0, 0, 21, 11), + 0.25, + Style::default().fg(Color::Cyan), + ); + }) + .expect("draw"); + let title = (1..12) + .map(|x| terminal.backend().buffer()[(x, 0)].symbol()) + .collect::(); + assert_eq!(title, " rename me "); + } } /// Style for the title text of a session pane. The last-focused pane keeps diff --git a/specs/0203-tui-focus-border-sweep.md b/specs/0203-tui-focus-border-sweep.md index bd5691da..5719baac 100644 --- a/specs/0203-tui-focus-border-sweep.md +++ b/specs/0203-tui-focus-border-sweep.md @@ -11,7 +11,7 @@ When keyboard focus moves to a different TUI pane or focusable sidebar section, Focus identity includes the session-list rows, the lineage section, and each split window independently. Moving between sibling split windows therefore retriggers the animation even though both use the same general view-focus route. The initial pane at TUI startup does not animate. -The sweep temporarily draws all four border edges, including their corners, even when the pane's steady chrome hides its side and bottom borders or exposes only a header rule. The highlight changes the border line's foreground color and weight; it does not fill or reverse the cells' backgrounds. Once the sweep passes, the pane immediately returns to its configured steady border visibility. +The sweep temporarily draws all four border edges, including their corners, even when the pane's steady chrome hides its side and bottom borders or exposes only a header rule. The highlight changes the border line's foreground color and weight; it does not fill or reverse the cells' backgrounds. Embedded titles and live title-edit text remain readable and are not replaced by the sweep. Once the sweep passes, the pane immediately returns to its configured steady border visibility. The effect must not change pane geometry, content layout, terminal size, or input routing. Edge-to-edge borderless zoom views do not animate.