返回 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 use crate::tui::work_surface::panels;
11
12 use super::{progress_shares_goal_row, top_goal_title, top_todo_progress};
13
14 const SIDE_RAIL_MIN_HOST_WIDTH: u16 = 72;
15 const SIDE_RAIL_MIN_CHAT_WIDTH: u16 = 40;
16
17 fn effective_placement(configured: WorkSurfacePlacement, host_width: u16) -> WorkSurfacePlacement {
18 if configured == WorkSurfacePlacement::Off {
19 return WorkSurfacePlacement::Off;
20 }
21 if host_width < SIDE_RAIL_MIN_HOST_WIDTH {
22 WorkSurfacePlacement::Top
23 } else {
24 configured
25 }
26 }
27
28 /// Responsive work-surface height.
29 ///
30 /// `rail_budget` is the caller's answer to "how many rows can the transcript
31 /// actually spare this frame" — terminal height minus fixed chrome minus the
32 /// transcript's own floor. See [`crate::tui::ui::rail_row_budget`]. The rail
33 /// takes spare rows; it never takes rows the transcript needs.
34 ///
35 /// Every Top panel auto-fits its content the same way: content rows + optional
36 /// goal title + the divider, capped by `top_height` and ambient room. A
37 /// two-item checklist is two rows; eight agents grow to show eight. The only
38 /// Top title is an active goal — never panel chrome ("Pinned"). Side rails
39 /// keep a muted panel name because a full-height column needs naming.
40 pub fn height(app: &mut App, width: u16, terminal_height: u16, rail_budget: u16) -> u16 {
41 app.work_surface.effective_placement = effective_placement(app.work_surface.placement, width);
42 // Off hides the rail outright: no strip, no side reservation, no stale
43 // interaction state.
44 if app.work_surface.effective_placement == WorkSurfacePlacement::Off {
45 collapse_strip(app);
46 return 0;
47 }
48 // The Context fact list on Top auto-fits like the row surface. Empty
49 // projections collapse to zero — an empty panel is not a panel. (Auto-fit
50 // governs HEIGHT only; membership is the model's business, and a settled
51 // to-do or finished sub-agent still occupies a row.) Side placements
52 // reserve via `split_chat` and take no top strip.
53 if app.work_surface.panel == RailPanel::Context {
54 if app.work_surface.effective_placement != WorkSurfacePlacement::Top {
55 return 0;
56 }
57 if !panels::panel_has_useful_content(app, app.work_surface.panel) {
58 collapse_strip(app);
59 return 0;
60 }
61 let cap = top_cap(app, terminal_height, rail_budget);
62 if cap < model::TOP_HEIGHT_MIN {
63 collapse_strip(app);
64 return 0;
65 }
66 let goal_rows = u16::from(top_goal_title(app).is_some());
67 let content_width = usize::from(width.saturating_sub(2).max(1));
68 // When the goal is the strip title, omit it from Pinned body rows so
69 // height and paint agree.
70 let content_rows = panels::panel_content_row_count(
71 app,
72 app.work_surface.panel,
73 content_width,
74 goal_rows > 0,
75 );
76 if content_rows == 0 && goal_rows == 0 {
77 collapse_strip(app);
78 return 0;
79 }
80 let desired = u16::try_from(content_rows)
81 .unwrap_or(u16::MAX)
82 .saturating_add(goal_rows)
83 .saturating_add(1); // divider
84 return desired.clamp(model::TOP_HEIGHT_MIN, cap);
85 }
86
87 let rows = visible_rows_for_panel(app);
88 let goal_rows = u16::from(
89 app.work_surface.effective_placement == WorkSurfacePlacement::Top
90 && top_goal_title(app).is_some(),
91 );
92 if rows.is_empty() {
93 // A live goal alone still deserves a strip: title + divider.
94 if goal_rows == 0 {
95 collapse_strip(app);
96 app.work_surface.latest_rows.clear();
97 app.work_surface.visible_rows = 0;
98 app.work_surface.total_rows = 0;
99 app.work_surface.scroll_offset = 0;
100 return 0;
101 }
102 if app.work_surface.effective_placement != WorkSurfacePlacement::Top {
103 return 0;
104 }
105 let cap = top_cap(app, terminal_height, rail_budget);
106 if cap < model::TOP_HEIGHT_MIN {
107 collapse_strip(app);
108 return 0;
109 }
110 return (goal_rows.saturating_add(1)).clamp(model::TOP_HEIGHT_MIN, cap);
111 }
112 if app.work_surface.effective_placement != WorkSurfacePlacement::Top {
113 return 0;
114 }
115 // The strip auto-fits its content: the literal selectable list plus the
116 // optional goal title, the pinned progress receipt, and the divider row,
117 // bounded by `top_cap`.
118 let cap = top_cap(app, terminal_height, rail_budget);
119 if cap < model::TOP_HEIGHT_MIN {
120 collapse_strip(app);
121 return 0;
122 }
123 // Count every painted row: selectable work + group headers (Subagents N).
124 // Progress receipt and goal title are layered above in render.
125 let list_rows = rows
126 .iter()
127 .filter(|row| row.selectable || row.id.0.starts_with("section:"))
128 .count();
129 let progress = u16::from(
130 top_todo_progress(app, &rows).is_some() && !progress_shares_goal_row(width, goal_rows > 0),
131 );
132 let desired = u16::try_from(list_rows)
133 .unwrap_or(u16::MAX)
134 .saturating_add(progress)
135 .saturating_add(goal_rows)
136 .saturating_add(1);
137 desired.clamp(model::TOP_HEIGHT_MIN, cap)
138 }
139
140 /// The ceilings the *terminal* imposes, independent of anything the user
141 /// asked for, smallest wins:
142 ///
143 /// - half the terminal: proportional restraint, so a tall rail on a short
144 /// terminal still reads as a strip over a transcript.
145 /// - `rail_budget`: the rows the transcript can actually spare. This is the
146 /// only one that knows the transcript has a floor, and it is the one that
147 /// lets decorative water outrank a panel nobody is watching.
148 ///
149 /// Kept separate from [`top_cap`] because the collapse cliff must be charged
150 /// against ambient room alone. Both are monotone non-decreasing in terminal
151 /// height, which is what keeps the strip from blinking across a resize.
152 fn ambient_cap(terminal_height: u16, rail_budget: u16) -> u16 {
153 terminal_height
154 .saturating_div(2)
155 .clamp(model::TOP_HEIGHT_MIN, model::TOP_HEIGHT_MAX)
156 .min(rail_budget)
157 }
158
159 /// [`ambient_cap`] plus `top_height` — what the user asked for via
160 /// drag-resize / settings. This is the ceiling on how *tall* a strip may
161 /// grow; it is deliberately not the quantity a collapse threshold is
162 /// compared against.
163 fn top_cap(app: &App, terminal_height: u16, rail_budget: u16) -> u16 {
164 app.work_surface
165 .top_height
166 .min(ambient_cap(terminal_height, rail_budget))
167 }
168
169 /// Drop the interaction state that only means anything while a strip is on
170 /// screen. Every path reporting "no strip this frame" must run this: hitboxes
171 /// outlive the rows they described, so a strip that yielded its rows would
172 /// still swallow clicks landing on the transcript that replaced it.
173 fn collapse_strip(app: &mut App) {
174 app.work_surface.last_area = None;
175 app.work_surface.hitboxes.clear();
176 app.work_surface.focused = false;
177 app.work_surface.selected = None;
178 app.work_surface.opened = None;
179 app.work_surface.hovered = None;
180 app.work_surface.resizing = false;
181 app.work_surface.divider_hovered = false;
182 }
183
184 /// Split the transcript slot for a side rail. Top placement consumes its own
185 /// vertical row before this point, so it returns the chat area unchanged.
186 ///
187 /// Placement and auto-fit are orthogonal but share one rule: **empty work is
188 /// not a rail**. Top expresses that as `height() == 0`. Left/Right express it
189 /// here — no column is reserved when the selected panel has nothing to say.
190 /// When there *is* content, the rail takes the full chat height at the
191 /// configured `side_width` (width is the ceiling, the way `top_height` is the
192 /// ceiling on Top). Narrow terminals that cannot fit the rail fall back to
193 /// Top, where height auto-fit takes over.
194 ///
195 /// `min_chat_width` is the column-axis twin of `height`'s `rail_budget`: the
196 /// columns the transcript must keep. When the idle ocean is on screen that is
197 /// the ambient floor, and a rail that cannot fit beside it hides rather than
198 /// squeezing the water into a strip too narrow to draw.
199 pub fn split_chat(app: &mut App, area: Rect, min_chat_width: u16) -> (Rect, Option<Rect>) {
200 let placement = effective_placement(app.work_surface.placement, area.width);
201 app.work_surface.effective_placement = placement;
202 if placement == WorkSurfacePlacement::Top || placement == WorkSurfacePlacement::Off {
203 return (area, None);
204 }
205 // Same empty-collapse rule as Top: a panel with nothing to show does not
206 // spend columns on a blank (or "No agents") column.
207 if !side_rail_has_content(app) {
208 return (area, None);
209 }
210
211 let min_chat_width = min_chat_width.max(SIDE_RAIL_MIN_CHAT_WIDTH);
212 let rail_width = app
213 .work_surface
214 .side_width
215 .clamp(model::SIDE_WIDTH_MIN, model::SIDE_WIDTH_MAX)
216 .min(area.width.saturating_sub(min_chat_width));
217 if rail_width < model::SIDE_WIDTH_MIN {
218 // Too narrow for a side column — fall back to Top. The caller will
219 // re-ask height() with effective_placement Top so content auto-fits
220 // as a strip instead of vanishing.
221 app.work_surface.effective_placement = WorkSurfacePlacement::Top;
222 return (area, None);
223 }
224
225 let chat_width = area.width.saturating_sub(rail_width);
226 match placement {
227 WorkSurfacePlacement::Left => (
228 Rect {
229 x: area.x.saturating_add(rail_width),
230 width: chat_width,
231 ..area
232 },
233 Some(Rect {
234 width: rail_width,
235 ..area
236 }),
237 ),
238 WorkSurfacePlacement::Right => (
239 Rect {
240 width: chat_width,
241 ..area
242 },
243 Some(Rect {
244 x: area.x.saturating_add(chat_width),
245 width: rail_width,
246 ..area
247 }),
248 ),
249 WorkSurfacePlacement::Top | WorkSurfacePlacement::Off => (area, None),
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 match app.work_surface.panel {
256 RailPanel::Context => panels::panel_has_useful_content(app, RailPanel::Context),
257 _ => !visible_rows_for_panel(app).is_empty(),
258 }
259 }
260
260 lines RUST