返回 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 workbar panel from a keyboard shortcut and say what happened.
9 /// When the workbar 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 crate::tui::work_surface::select_dock_panel(app, panel);
13 app.needs_redraw = true;
14 let mut message = format!("Workbar panel: {}", panel.as_setting());
15 if app.work_surface.placement == crate::tui::work_surface::WorkSurfacePlacement::Off {
16 message.push_str(" (workbar is off — /workbar 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 // An explicit work-bar choice takes priority over decorative empty-state
114 // space, just as real transcript content does. Otherwise Ctrl+] can be
115 // accepted while the ambient floor immediately hides its result.
116 let ambient_mark_can_draw = idle_empty
117 && !app.work_surface.explicit_view
118 && terminal_width >= crate::tui::underwater::AMBIENT_MIN_CHAT_WIDTH;
119 let chat_floor = if ambient_mark_can_draw {
120 crate::tui::underwater::AMBIENT_MIN_CHAT_HEIGHT
121 } else {
122 MIN_CHAT_HEIGHT
123 };
124 let composer_floor = crate::tui::composer_chrome::desired_height(
125 1,
126 0,
127 terminal_height,
128 app.composer_density,
129 crate::tui::widgets::composer_enclosure_enabled(app),
130 );
131 terminal_height
132 .saturating_sub(info_row_height_for(terminal_height))
133 // The merged Tideline footer is one row (spec §3: slots 6+8
134 // collapsed into it) — the shell no longer brackets the composer
135 // with two standing bands.
136 .saturating_sub(crate::tui::phase_strip::height())
137 .saturating_sub(composer_floor)
138 .saturating_sub(chat_floor)
139 }
140
141 /// The info line is one row under the posture row (spec §5b:
142 /// `Constraint::Length(1)`), at every terminal height. Shared so the rail
143 /// budget charges the same chrome the layout actually reserves.
144 pub(crate) fn info_row_height_for(_terminal_height: u16) -> u16 {
145 1
146 }
147
148 /// Column-axis twin of [`rail_row_budget`]: the columns a side rail must
149 /// leave the transcript.
150 pub(crate) fn rail_min_chat_width(idle_empty: bool) -> u16 {
151 if idle_empty {
152 crate::tui::underwater::AMBIENT_MIN_CHAT_WIDTH
153 } else {
154 0
155 }
156 }
157
158 pub(crate) fn status_animation_interval_ms(app: &App) -> u64 {
159 if app.effective_low_motion_for_status() {
160 crate::tui::display_refresh::adaptive_animation_interval_ms(true)
161 } else {
162 // Keep the braille marker on its fixed 5 Hz table for width stability;
163 // only atmosphere uses the measured display cadence.
164 UI_STATUS_ANIMATION_MS
165 }
166 }
167
168 pub(crate) fn underwater_animation_interval_ms(app: &App) -> u64 {
169 if app.effective_low_motion_for_status() || app.low_motion {
170 crate::tui::display_refresh::adaptive_animation_interval_ms(true)
171 } else if app.constrained_frame_rate {
172 UI_CONSTRAINED_UNDERWATER_ANIMATION_MS
173 } else if crate::tui::display_refresh::terminal_is_ghostty() {
174 UI_GHOSTTY_UNDERWATER_ANIMATION_MS
175 } else {
176 // Measured display Hz can raise atmosphere cadence on high-Hz
177 // panels; missing probe falls back to the ~8 fps floor.
178 crate::tui::display_refresh::adaptive_animation_interval_ms(false)
179 .min(UI_UNDERWATER_ANIMATION_MS)
180 }
181 }
182
183 /// Whether any underwater motion owner is actually visible in the transcript
184 /// host. This keeps the scheduler honest: only the explicit underwater
185 /// treatment earns a viewport budget, and obscured surfaces never request
186 /// frames.
187 #[must_use]
188 pub(crate) fn underwater_motion_surface_visible(
189 area: Option<Rect>,
190 underwater_atmosphere_enabled: bool,
191 deepsea_field_breathes: bool,
192 empty_water_visible: bool,
193 obscured: bool,
194 ) -> bool {
195 if obscured || !underwater_atmosphere_enabled {
196 return false;
197 }
198 area.is_some_and(|area| {
199 area.width > 0
200 && area.height > 0
201 && (deepsea_field_breathes
202 || (area.width >= crate::tui::ocean::AMBIENT_MIN_WIDTH
203 && area.height >= crate::tui::ocean::AMBIENT_MIN_HEIGHT)
204 || (empty_water_visible && crate::tui::underwater::empty_state_mark_visible(area)))
205 })
206 }
207
208 pub(crate) fn animation_interval_ms(
209 app: &App,
210 status_motion: bool,
211 underwater_motion: bool,
212 ) -> u64 {
213 let underwater = underwater_animation_interval_ms(app);
214 match (status_motion, underwater_motion) {
215 (true, true) => status_animation_interval_ms(app).min(underwater),
216 (true, false) => status_animation_interval_ms(app),
217 (false, true) => underwater,
218 (false, false) => underwater,
219 }
220 }
221
222 pub(crate) fn should_tick_status_animation(
223 app: &App,
224 has_running_agents: bool,
225 history_has_live_motion: bool,
226 active_cell_has_live_motion: bool,
227 translation_placeholder_has_live_motion: bool,
228 ) -> bool {
229 !matches!(app.motion_policy().mode(), MotionMode::Still)
230 && (app.is_loading
231 || has_running_agents
232 || app.is_compacting
233 || app.is_purging
234 || history_has_live_motion
235 || active_cell_has_live_motion
236 || translation_placeholder_has_live_motion
237 || visible_background_task_has_live_motion(app))
238 }
239
240 pub(crate) fn visible_background_task_has_live_motion(app: &App) -> bool {
241 app.work_surface.panel == crate::tui::work_surface::RailPanel::Tasks
242 && app.work_surface.last_area.is_some()
243 && app.task_panel.iter().any(|task| task.status == "running")
244 }
245
246 pub(crate) fn active_cell_has_live_motion(app: &App) -> bool {
247 app.active_cell
248 .as_ref()
249 .is_some_and(|active| active.entries().iter().any(HistoryCell::has_live_motion))
250 }
251
252 pub(crate) fn history_has_live_motion(history: &[HistoryCell]) -> bool {
253 history.iter().any(HistoryCell::has_live_motion)
254 }
255
255 lines RUST