| 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 |