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