返回 CodeWhale
layout.rs
根目录 / crates / tui / src / tui / work_surface / render / layout.rs
1 //! Where the surface goes and how tall it is — the arithmetic [`height`] and
2 //! [`super::render`] must agree on before a single cell is painted.
3
4 use ratatui::layout::Rect;
5
6 use crate::tui::app::App;
7 use crate::tui::work_surface::model::{
8 self, RailPanel, WorkSurfacePlacement, visible_rows_for_panel,
9 };
10
11 use super::{progress_shares_goal_row, top_goal_title, top_todo_progress};
12
13 const SIDE_RAIL_MIN_HOST_WIDTH: u16 = 72;
14 const SIDE_RAIL_MIN_CHAT_WIDTH: u16 = 40;
15
16 fn effective_placement(configured: WorkSurfacePlacement, host_width: u16) -> WorkSurfacePlacement {
17 if configured == WorkSurfacePlacement::Off {
18 return WorkSurfacePlacement::Off;
19 }
20 // Only the side rails need width; strip placements work at any host
21 // width, and a narrow host keeps the user's Bottom default.
22 if matches!(
23 configured,
24 WorkSurfacePlacement::Left | WorkSurfacePlacement::Right
25 ) && host_width < SIDE_RAIL_MIN_HOST_WIDTH
26 {
27 WorkSurfacePlacement::Top
28 } else {
29 configured
30 }
31 }
32
33 /// Responsive work-surface height.
34 ///
35 /// `rail_budget` is the caller's answer to "how many rows can the transcript
36 /// actually spare this frame" — terminal height minus fixed chrome minus the
37 /// transcript's own floor. See [`crate::tui::ui::rail_row_budget`]. The rail
38 /// takes spare rows; it never takes rows the transcript needs.
39 ///
40 /// Every view auto-fits its content the same way: content rows + optional
41 /// goal title + the divider, capped by `top_height` and ambient room. A
42 /// two-item checklist is two rows; eight agents grow to show eight. The only
43 /// Top title is an active goal — never panel chrome. Which view is on screen
44 /// is decided first by [`model::resolve_view`]: the user's explicit pick, or
45 /// the first work view with content.
46 pub fn height(app: &mut App, width: u16, terminal_height: u16, rail_budget: u16) -> u16 {
47 app.work_surface.effective_placement = effective_placement(app.work_surface.placement, width);
48 // Off hides the rail outright: no strip, no side reservation, no stale
49 // interaction state.
50 if app.work_surface.effective_placement == WorkSurfacePlacement::Off {
51 collapse_strip(app);
52 return 0;
53 }
54 model::resolve_view(app);
55 if app.work_surface.dismissed {
56 // New work anywhere in the auto views re-opens the dock — a worker
57 // spawning while the to-do list is on top counts, even though the
58 // resolved view stays TODO.
59 let current_rows = model::auto_work_rows(app);
60 let new_work = app.work_surface.panel != app.work_surface.dismissed_view
61 || current_rows > app.work_surface.dismissed_at_rows;
62 if new_work {
63 app.work_surface.dismissed = false;
64 } else {
65 collapse_strip(app);
66 return 0;
67 }
68 }
69
70 // The strip is something to SHOW, not a fixture: without an explicit
71 // pick it opens only for work — live agents, the to-do list, background
72 // jobs. The persisted `rail_panel` preference (files, context, git,
73 // price…) always has rows to paint, and letting it auto-open made the
74 // dock a permanent band under the composer. It still opens on demand
75 // (cycle, tab click, `/workbar <view>`) and then sticks until Esc.
76 let auto_open =
77 app.work_surface.explicit_view || RailPanel::AUTO_ORDER.contains(&app.work_surface.panel);
78 let rows = if auto_open {
79 visible_rows_for_panel(app)
80 } else {
81 Vec::new()
82 };
83 let strip = app.work_surface.effective_placement.is_strip();
84 let goal_rows = u16::from(strip && top_goal_title(app).is_some());
85 let explicit = app.work_surface.explicit_view;
86 if rows.is_empty() && !explicit {
87 // A live goal alone still deserves a strip: title + divider.
88 if goal_rows == 0 {
89 collapse_strip(app);
90 app.work_surface.latest_rows.clear();
91 app.work_surface.visible_rows = 0;
92 app.work_surface.total_rows = 0;
93 app.work_surface.scroll_offset = 0;
94 return 0;
95 }
96 if !strip {
97 return 0;
98 }
99 let cap = top_cap(app, terminal_height, rail_budget);
100 if cap < model::TOP_HEIGHT_MIN {
101 collapse_strip(app);
102 return 0;
103 }
104 return (goal_rows.saturating_add(2)).clamp(model::TOP_HEIGHT_MIN, cap);
105 }
106 if !strip {
107 return 0;
108 }
109 // The strip auto-fits its content: the view's rows plus the optional
110 // goal title, the pinned progress receipt, and the divider row, bounded
111 // by `top_cap`. An explicitly opened empty view keeps one row for its
112 // "nothing here yet" line so cycling never lands on a blank band.
113 let cap = top_cap(app, terminal_height, rail_budget);
114 // On compact terminals an explicit choice can fit in three rows:
115 // divider, tabs, and one usable content row. The usual five-row
116 // preference must not turn an accepted open command into a no-op.
117 if cap < model::TOP_HEIGHT_MIN && (!explicit || cap < 3) {
118 collapse_strip(app);
119 return 0;
120 }
121 let list_rows = rows.len().max(usize::from(explicit));
122 let progress = u16::from(
123 top_todo_progress(app, &rows).is_some() && !progress_shares_goal_row(width, goal_rows > 0),
124 );
125 let desired = u16::try_from(list_rows)
126 .unwrap_or(u16::MAX)
127 .saturating_add(progress)
128 .saturating_add(goal_rows)
129 .saturating_add(2);
130 desired.clamp(model::TOP_HEIGHT_MIN.min(cap), cap)
131 }
132
133 /// The ceilings the *terminal* imposes, independent of anything the user
134 /// asked for, smallest wins:
135 ///
136 /// - half the terminal: proportional restraint, so a tall rail on a short
137 /// terminal still reads as a strip over a transcript.
138 /// - `rail_budget`: the rows the transcript can actually spare. This is the
139 /// only one that knows the transcript has a floor, and it is the one that
140 /// lets decorative water outrank a panel nobody is watching.
141 ///
142 /// Kept separate from [`top_cap`] because the collapse cliff must be charged
143 /// against ambient room alone. Both are monotone non-decreasing in terminal
144 /// height, which is what keeps the strip from blinking across a resize.
145 fn ambient_cap(terminal_height: u16, rail_budget: u16) -> u16 {
146 terminal_height
147 .saturating_div(2)
148 .clamp(model::TOP_HEIGHT_MIN, model::TOP_HEIGHT_MAX)
149 .min(rail_budget)
150 }
151
152 /// [`ambient_cap`] plus `top_height` — what the user asked for via
153 /// drag-resize / settings. This is the ceiling on how *tall* a strip may
154 /// grow; it is deliberately not the quantity a collapse threshold is
155 /// compared against.
156 fn top_cap(app: &App, terminal_height: u16, rail_budget: u16) -> u16 {
157 app.work_surface
158 .top_height
159 .min(ambient_cap(terminal_height, rail_budget))
160 }
161
162 /// Drop the interaction state that only means anything while a strip is on
163 /// screen. Every path reporting "no strip this frame" must run this: hitboxes
164 /// outlive the rows they described, so a strip that yielded its rows would
165 /// still swallow clicks landing on the transcript that replaced it.
166 pub(crate) fn collapse_strip(app: &mut App) {
167 app.work_surface.last_area = None;
168 app.work_surface.hitboxes.clear();
169 app.work_surface.focused = false;
170 app.work_surface.selected = None;
171 app.work_surface.opened = None;
172 app.work_surface.hovered = None;
173 app.work_surface.resizing = false;
174 app.work_surface.divider_hovered = false;
175 app.work_surface.dock_tabs.clear();
176 app.work_surface.pressed_tab = None;
177 app.work_surface.hovered_tab = None;
178 }
179
180 /// Split the transcript slot for a side rail. Top placement consumes its own
181 /// vertical row before this point, so it returns the chat area unchanged.
182 ///
183 /// Placement and auto-fit are orthogonal but share one rule: **empty work is
184 /// not a rail**. Top expresses that as `height() == 0`. Left/Right express it
185 /// here — no column is reserved when the selected panel has nothing to say.
186 /// When there *is* content, the rail takes the full chat height at the
187 /// configured `side_width` (width is the ceiling, the way `top_height` is the
188 /// ceiling on Top). Narrow terminals that cannot fit the rail fall back to
189 /// Top, where height auto-fit takes over.
190 ///
191 /// `min_chat_width` is the column-axis twin of `height`'s `rail_budget`: the
192 /// columns the transcript must keep. When the idle ocean is on screen that is
193 /// the ambient floor, and a rail that cannot fit beside it hides rather than
194 /// squeezing the water into a strip too narrow to draw.
195 pub fn split_chat(app: &mut App, area: Rect, min_chat_width: u16) -> (Rect, Option<Rect>) {
196 let placement = effective_placement(app.work_surface.placement, area.width);
197 app.work_surface.effective_placement = placement;
198 if placement.is_strip() || placement == WorkSurfacePlacement::Off {
199 return (area, None);
200 }
201 // Same empty-collapse rule as Top: a panel with nothing to show does not
202 // spend columns on a blank (or "No agents") column.
203 if !side_rail_has_content(app) {
204 collapse_strip(app);
205 return (area, None);
206 }
207
208 let min_chat_width = min_chat_width.max(SIDE_RAIL_MIN_CHAT_WIDTH);
209 let rail_width = app
210 .work_surface
211 .side_width
212 .clamp(model::SIDE_WIDTH_MIN, model::SIDE_WIDTH_MAX)
213 .min(area.width.saturating_sub(min_chat_width));
214 if rail_width < model::SIDE_WIDTH_MIN {
215 // Too narrow for a side column — fall back to Top. The caller will
216 // re-ask height() with effective_placement Top so content auto-fits
217 // as a strip instead of vanishing.
218 app.work_surface.effective_placement = WorkSurfacePlacement::Top;
219 collapse_strip(app);
220 return (area, None);
221 }
222
223 let chat_width = area.width.saturating_sub(rail_width);
224 match placement {
225 WorkSurfacePlacement::Left => (
226 Rect {
227 x: area.x.saturating_add(rail_width),
228 width: chat_width,
229 ..area
230 },
231 Some(Rect {
232 width: rail_width,
233 ..area
234 }),
235 ),
236 WorkSurfacePlacement::Right => (
237 Rect {
238 width: chat_width,
239 ..area
240 },
241 Some(Rect {
242 x: area.x.saturating_add(chat_width),
243 width: rail_width,
244 ..area
245 }),
246 ),
247 WorkSurfacePlacement::Top | WorkSurfacePlacement::Bottom | WorkSurfacePlacement::Off => {
248 (area, None)
249 }
250 }
251 }
252
253 /// Whether a Left/Right rail should reserve columns this frame.
254 fn side_rail_has_content(app: &mut App) -> bool {
255 if app.work_surface.dismissed {
256 return false;
257 }
258 model::resolve_view(app);
259 app.work_surface.explicit_view || !visible_rows_for_panel(app).is_empty()
260 }
261
261 lines RUST