返回 CodeWhale
motion.rs
根目录 / crates / tui / src / tui / ui / motion.rs
1 //! Redraw pacing: animation intervals, live-motion predicates, and the rail's
2 //! size budgets.
3 //!
4 //! Moved verbatim out of `ui.rs`.
5
6 use super::*;
7
8 /// Select a rail panel from a keyboard shortcut and say what happened.
9 /// When the rail is off the panel change is real but invisible, so the
10 /// status names that instead of implying something rendered.
11 pub(crate) fn rail_panel_shortcut(app: &mut App, panel: crate::tui::work_surface::RailPanel) {
12 app.work_surface.panel = panel;
13 app.needs_redraw = true;
14 let mut message = format!("Rail panel: {}", panel.as_setting());
15 if app.work_surface.placement == crate::tui::work_surface::WorkSurfacePlacement::Off {
16 message.push_str(" (rail is off — /rail top to show)");
17 }
18 app.status_message = Some(message);
19 }
20
21 /// #3033: gate progress-driven repaints to at most one per 100ms.
22 ///
23 /// Returns whether the current `AgentProgress` event may request a redraw,
24 /// updating the last-redraw timestamp when it may. Data updates are never
25 /// throttled — only the repaint request is.
26 pub(crate) fn agent_progress_redraw_permitted(
27 last_redraw: &mut Option<Instant>,
28 now: Instant,
29 ) -> bool {
30 match *last_redraw {
31 Some(last) if now.duration_since(last) < Duration::from_millis(100) => false,
32 _ => {
33 *last_redraw = Some(now);
34 true
35 }
36 }
37 }
38
39 /// #4095 residual: pace workflow budget-only repaints under fan-out.
40 ///
41 /// Same 100ms floor as AgentProgress. High-signal workflow lifecycle events
42 /// bypass this gate and always paint.
43 pub(crate) fn workflow_budget_redraw_permitted(
44 last_redraw: &mut Option<Instant>,
45 now: Instant,
46 ) -> bool {
47 agent_progress_redraw_permitted(last_redraw, now)
48 }
49
50 pub(crate) fn agent_progress_redraw_permitted_for_drain(
51 last_redraw: &mut Option<Instant>,
52 seen_agents: &mut HashSet<String>,
53 agent_id: &str,
54 now: Instant,
55 ) -> bool {
56 if !seen_agents.insert(agent_id.to_string()) {
57 return false;
58 }
59 agent_progress_redraw_permitted(last_redraw, now)
60 }
61
62 /// Rows the transcript can spare for the work rail this frame.
63 ///
64 /// Everything above the transcript is decoration relative to the transcript
65 /// itself, so the rail is paid out of what is *left over* after the fixed
66 /// chrome and the transcript's own floor — not out of a fraction of the
67 /// terminal, which at 24 rows would hand the rail half the screen.
68 ///
69 /// That floor moves. While the shell is fully idle the transcript is showing
70 /// the ocean, and the ocean does not draw at all below
71 /// [`AMBIENT_MIN_CHAT_HEIGHT`](crate::tui::underwater::AMBIENT_MIN_CHAT_HEIGHT)
72 /// rows — so on a 24-row terminal an always-on panel strip does not shrink
73 /// the water, it deletes it. Once there is real work on screen the floor
74 /// drops back to [`MIN_CHAT_HEIGHT`] and the rail gets its rows. Decorative
75 /// water yields to work; work never yields to decoration.
76 ///
77 /// `idle_empty` alone is not enough to charge that floor. It is an
78 /// app-state predicate — it knows the session is quiet, not that the terminal
79 /// can draw. [`empty_state_mark_visible`](crate::tui::underwater::empty_state_mark_visible)
80 /// also demands
81 /// [`AMBIENT_MIN_CHAT_WIDTH`](crate::tui::underwater::AMBIENT_MIN_CHAT_WIDTH)
82 /// columns, so on a narrow terminal charging the ambient floor would reserve
83 /// 16 rows for a mark that cannot render at any height and make the strip
84 /// yield for nothing.
85 ///
86 /// The row half of that gate is deliberately *not* mirrored here. It would be
87 /// a step down in terminal *height* — below the floor the rail would take the
88 /// rows, at the floor it would hand them back — and a strip that vanishes as
89 /// the terminal grows taller is the resize flicker this budget exists to
90 /// avoid. The swept axis must stay monotone.
91 ///
92 /// The column gate is a real trade, not a free one, and an earlier version of
93 /// this comment wrongly claimed otherwise. Widening past
94 /// `AMBIENT_MIN_CHAT_WIDTH` on a short-but-tall terminal can swap a strip for
95 /// the ocean in one column step. That is accepted deliberately: a horizontal
96 /// resize past 60 columns is a deliberate act with a visible payoff (the
97 /// water appears), whereas the height version fires while dragging the axis
98 /// the strip is measured in. Both cannot be monotone at once — charging the
99 /// floor is what buys the whale its rows, and something has to give.
100 /// `rail_strip_and_whale_swap_at_the_ambient_width` pins the swap so it stays
101 /// a decision rather than drifting into an accident.
102 ///
103 /// The composer is charged at a fixed floor rather than its measured height:
104 /// the real `composer_height` is itself computed from the strip height, and
105 /// feeding it back in here would close a loop that oscillates across a
106 /// resize instead of settling.
107 pub(crate) fn rail_row_budget(
108 app: &App,
109 terminal_width: u16,
110 terminal_height: u16,
111 idle_empty: bool,
112 ) -> u16 {
113 let ambient_mark_can_draw =
114 idle_empty && terminal_width >= crate::tui::underwater::AMBIENT_MIN_CHAT_WIDTH;
115 let chat_floor = if ambient_mark_can_draw {
116 crate::tui::underwater::AMBIENT_MIN_CHAT_HEIGHT
117 } else {
118 MIN_CHAT_HEIGHT
119 };
120 let composer_floor = MIN_COMPOSER_HEIGHT.saturating_add(u16::from(app.composer_border));
121 terminal_height
122 .saturating_sub(header_height_for(terminal_height))
123 .saturating_sub(crate::tui::phase_strip::height())
124 .saturating_sub(composer_floor)
125 .saturating_sub(chat_floor)
126 }
127
128 /// The header collapses to a single row on short terminals. Shared so the
129 /// rail budget charges the same chrome the layout actually reserves.
130 pub(crate) fn header_height_for(terminal_height: u16) -> u16 {
131 if terminal_height < 16 { 1 } else { 2 }
132 }
133
134 /// Column-axis twin of [`rail_row_budget`]: the columns a side rail must
135 /// leave the transcript.
136 pub(crate) fn rail_min_chat_width(idle_empty: bool) -> u16 {
137 if idle_empty {
138 crate::tui::underwater::AMBIENT_MIN_CHAT_WIDTH
139 } else {
140 0
141 }
142 }
143
144 pub(crate) fn status_color(level: StatusToastLevel) -> ratatui::style::Color {
145 match level {
146 StatusToastLevel::Info => palette::WHALE_INFO,
147 StatusToastLevel::Success => palette::STATUS_SUCCESS,
148 StatusToastLevel::Warning => palette::STATUS_WARNING,
149 StatusToastLevel::Error => palette::STATUS_ERROR,
150 }
151 }
152
153 pub(crate) fn status_animation_interval_ms(app: &App) -> u64 {
154 if app.effective_low_motion_for_status() {
155 crate::tui::display_refresh::adaptive_animation_interval_ms(true)
156 } else {
157 // Keep the braille marker on its fixed 5 Hz table for width stability;
158 // only atmosphere uses the measured display cadence.
159 UI_STATUS_ANIMATION_MS
160 }
161 }
162
163 pub(crate) fn underwater_animation_interval_ms(app: &App) -> u64 {
164 if app.effective_low_motion_for_status() || app.low_motion {
165 crate::tui::display_refresh::adaptive_animation_interval_ms(true)
166 } else {
167 // Measured display Hz can raise atmosphere cadence on high-Hz
168 // panels; missing probe falls back to the ~8 fps floor.
169 crate::tui::display_refresh::adaptive_animation_interval_ms(false)
170 .min(UI_UNDERWATER_ANIMATION_MS)
171 }
172 }
173
174 /// Whether any underwater motion owner is actually visible in the transcript
175 /// host. This keeps the scheduler honest: ombre needs a non-empty viewport,
176 /// fish need their collision-safe water budget, and the smaller idle whale may
177 /// independently earn its caustic. Obscured surfaces never request frames.
178 #[must_use]
179 pub(crate) fn underwater_motion_surface_visible(
180 area: Option<Rect>,
181 ombre_field_breathes: bool,
182 empty_water_visible: bool,
183 obscured: bool,
184 ) -> bool {
185 if obscured {
186 return false;
187 }
188 area.is_some_and(|area| {
189 area.width > 0
190 && area.height > 0
191 && (ombre_field_breathes
192 || (area.width >= crate::tui::ocean::AMBIENT_MIN_WIDTH
193 && area.height >= crate::tui::ocean::AMBIENT_MIN_HEIGHT)
194 || (empty_water_visible && crate::tui::underwater::empty_state_mark_visible(area)))
195 })
196 }
197
198 pub(crate) fn animation_interval_ms(
199 app: &App,
200 status_motion: bool,
201 underwater_motion: bool,
202 ) -> u64 {
203 let underwater = underwater_animation_interval_ms(app);
204 match (status_motion, underwater_motion) {
205 (true, true) => status_animation_interval_ms(app).min(underwater),
206 (true, false) => status_animation_interval_ms(app),
207 (false, true) => underwater,
208 (false, false) => underwater,
209 }
210 }
211
212 pub(crate) fn should_tick_status_animation(
213 app: &App,
214 has_running_agents: bool,
215 history_has_live_motion: bool,
216 active_cell_has_live_motion: bool,
217 translation_placeholder_has_live_motion: bool,
218 ) -> bool {
219 !matches!(app.motion_policy().mode(), MotionMode::Still)
220 && (app.is_loading
221 || has_running_agents
222 || app.is_compacting
223 || app.is_purging
224 || history_has_live_motion
225 || active_cell_has_live_motion
226 || translation_placeholder_has_live_motion
227 || visible_background_task_has_live_motion(app))
228 }
229
230 pub(crate) fn visible_background_task_has_live_motion(app: &App) -> bool {
231 app.work_surface.panel == crate::tui::work_surface::RailPanel::Tasks
232 && app.work_surface.last_area.is_some()
233 && app.task_panel.iter().any(|task| task.status == "running")
234 }
235
236 pub(crate) fn active_cell_has_live_motion(app: &App) -> bool {
237 app.active_cell
238 .as_ref()
239 .is_some_and(|active| active.entries().iter().any(HistoryCell::has_live_motion))
240 }
241
242 pub(crate) fn history_has_live_motion(history: &[HistoryCell]) -> bool {
243 history.iter().any(HistoryCell::has_live_motion)
244 }
245
245 lines RUST