| 1 | //! Ocean composer chrome policy. |
| 2 | //! |
| 3 | //! The composer auto-fits its content: one input row when empty or |
| 4 | //! single-line, growing with typed content up to the density cap. Comfortable |
| 5 | //! and spacious densities reserve quiet rows around short input when room is |
| 6 | //! available. Compact panes always give that space back to the transcript. |
| 7 | |
| 8 | use crate::tui::app::ComposerDensity; |
| 9 | |
| 10 | /// Top/bottom chrome rows for the quiet rule (TOP border only) or the |
| 11 | /// enclosed panel (TOP + BOTTOM), plus the total-row growth cap. |
| 12 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 13 | pub struct ComposerChrome { |
| 14 | pub border_rows: u16, |
| 15 | pub max_total_rows: u16, |
| 16 | } |
| 17 | |
| 18 | impl ComposerChrome { |
| 19 | /// Baseline for the given density. Panel shape gets both borders; |
| 20 | /// quiet shape keeps a single top rule so the prompt still has a |
| 21 | /// clear ledge without reading as a card. Density picks the growth |
| 22 | /// cap; desired_height adds the density's bounded input padding. |
| 23 | #[must_use] |
| 24 | pub fn for_density(density: ComposerDensity, enclosed_panel: bool) -> Self { |
| 25 | let border_rows = if enclosed_panel { 2 } else { 1 }; |
| 26 | let max_total_rows = match density { |
| 27 | ComposerDensity::Compact => 7, |
| 28 | ComposerDensity::Comfortable => 9, |
| 29 | ComposerDensity::Spacious => 12, |
| 30 | }; |
| 31 | Self { |
| 32 | border_rows, |
| 33 | max_total_rows, |
| 34 | } |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | /// Decide how many rows the composer should occupy. |
| 39 | /// |
| 40 | /// The height follows the content: one input row when the composer is |
| 41 | /// empty or holds a single line, growing one row per content line up to |
| 42 | /// the density cap (`max_total_rows`) or the available height, whichever |
| 43 | /// is smaller. Comfortable/spacious density keeps a stable two/three-row |
| 44 | /// input floor when space permits. Menu rows and border chrome add on top. Compact |
| 45 | /// terminals shed the border before they shed typed content. |
| 46 | #[must_use] |
| 47 | pub fn desired_height( |
| 48 | content_lines: usize, |
| 49 | extra_menu_lines: usize, |
| 50 | available_height: u16, |
| 51 | density: ComposerDensity, |
| 52 | enclosed_panel: bool, |
| 53 | ) -> u16 { |
| 54 | let chrome = ComposerChrome::for_density(density, enclosed_panel); |
| 55 | let available = available_height.max(1); |
| 56 | let input_floor = match density { |
| 57 | ComposerDensity::Compact => 1, |
| 58 | ComposerDensity::Comfortable => 2, |
| 59 | ComposerDensity::Spacious => 3, |
| 60 | }; |
| 61 | let content = content_lines.max(input_floor); |
| 62 | let wants_panel = enclosed_panel && available >= 3; |
| 63 | |
| 64 | let border = if wants_panel { |
| 65 | usize::from(chrome.border_rows) |
| 66 | } else if available >= 2 { |
| 67 | 1 |
| 68 | } else { |
| 69 | 0 |
| 70 | }; |
| 71 | |
| 72 | let total = content |
| 73 | .saturating_add(extra_menu_lines) |
| 74 | .saturating_add(border); |
| 75 | let max_height = usize::from(available.min(chrome.max_total_rows).max(1)); |
| 76 | total.clamp(1, max_height).try_into().unwrap_or(1) |
| 77 | } |
| 78 | |
| 79 | /// Top padding inside the content budget. Keep at least one quiet row below a |
| 80 | /// short prompt when the budget has room, instead of bottom-pinning |
| 81 | /// the caret directly against the phase footer. Compact heights naturally |
| 82 | /// report zero padding once the budget collapses. A single spare row stays |
| 83 | /// below the caret; do not spend it all above the input against the footer. |
| 84 | #[must_use] |
| 85 | pub fn top_padding(content_lines: usize, rows_budget: usize) -> usize { |
| 86 | let content = content_lines.max(1).min(rows_budget.max(1)); |
| 87 | let spare = rows_budget.saturating_sub(content); |
| 88 | spare / 2 |
| 89 | } |
| 90 | |
| 91 | #[cfg(test)] |
| 92 | mod tests { |
| 93 | use super::*; |
| 94 | |
| 95 | #[test] |
| 96 | fn short_composer_respects_density_and_keeps_padding_below_the_caret() { |
| 97 | for (density, height) in [ |
| 98 | (ComposerDensity::Compact, 2), |
| 99 | (ComposerDensity::Comfortable, 3), |
| 100 | (ComposerDensity::Spacious, 4), |
| 101 | ] { |
| 102 | assert_eq!(desired_height(1, 0, 8, density, false), height); |
| 103 | } |
| 104 | assert_eq!( |
| 105 | top_padding(1, 2), |
| 106 | 0, |
| 107 | "one spare row belongs below the input" |
| 108 | ); |
| 109 | assert_eq!(top_padding(1, 3), 1); |
| 110 | } |
| 111 | |
| 112 | #[test] |
| 113 | fn compact_height_sheds_border_before_content() { |
| 114 | // Only two rows available: keep a border + one content row. |
| 115 | let height = desired_height(1, 0, 2, ComposerDensity::Comfortable, false); |
| 116 | assert_eq!(height, 2); |
| 117 | } |
| 118 | |
| 119 | #[test] |
| 120 | fn content_growth_expands_up_to_the_density_cap() { |
| 121 | // Six content rows + border fits under the Comfortable cap of 9. |
| 122 | let height = desired_height(6, 0, 12, ComposerDensity::Comfortable, false); |
| 123 | assert_eq!(height, 7, "typed content must grow the composer: {height}"); |
| 124 | |
| 125 | // Past the cap the density setting wins, not the content. |
| 126 | let capped = desired_height(20, 0, 30, ComposerDensity::Comfortable, false); |
| 127 | assert_eq!(capped, 9, "Comfortable caps total rows at 9"); |
| 128 | let spacious = desired_height(20, 0, 30, ComposerDensity::Spacious, false); |
| 129 | assert_eq!(spacious, 12, "Spacious caps total rows at 12"); |
| 130 | } |
| 131 | |
| 132 | #[test] |
| 133 | fn spacious_panel_reserves_input_padding_and_both_borders() { |
| 134 | let height = desired_height(1, 0, 12, ComposerDensity::Spacious, true); |
| 135 | assert_eq!(height, 5, "panel = 2 borders + 3 input rows, got {height}"); |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | // --------------------------------------------------------------------------- |
| 140 | // Tideline composer restyle (spec §2 composer decision, §5a "Composer"): |
| 141 | // rounded border + `[↵]` send hitbox. Translation scaffolding in |
| 142 | // the topbar mold — a pure, deterministic widget over injected state; the |
| 143 | // composer authority logic (composer_ui.rs) is untouched, and wiring into |
| 144 | // `ui/frame.rs` is the landing slice after #5698 settles. |
| 145 | // |
| 146 | // Cell rules (spec §2): no bezier strokes — `╭─╮│╰╯` border dim at rest and |
| 147 | // Info on focus; the send `↵` is a 3-cell `[↵]` hitbox right-aligned inside |
| 148 | // the border. The hand-drawn three-cell crown fluke this cap used to carry |
| 149 | // was deleted by the 2026-08-29 founder decree (terminal marks must be |
| 150 | // generated from the brand master path, never hand-drawn); the corner is a |
| 151 | // plain `╮` again. The hull taper silhouette is deliberately dropped |
| 152 | // (sub-cell vector work). |
| 153 | |
| 154 | use ratatui::{buffer::Buffer, layout::Rect, style::Style}; |
| 155 | use unicode_width::UnicodeWidthStr; |
| 156 | |
| 157 | use codewhale_palette::{ChromeInk, UiTheme, chrome_style}; |
| 158 | |
| 159 | /// Fixed width of the painted `[↵]` submit control. |
| 160 | pub const TIDELINE_COMPOSER_SUBMIT_WIDTH: u16 = 3; |
| 161 | |
| 162 | /// Blank cell between input content and the painted submit control. |
| 163 | pub const TIDELINE_COMPOSER_SUBMIT_BREATHING_WIDTH: u16 = 1; |
| 164 | |
| 165 | fn chrome(theme: &UiTheme, ink: ChromeInk) -> Style { |
| 166 | chrome_style(theme, ink) |
| 167 | } |
| 168 | |
| 169 | fn put(buf: &mut Buffer, x: u16, y: u16, text: &str, style: Style) { |
| 170 | let width = text.width(); |
| 171 | buf.set_stringn(x, y, text, width, style); |
| 172 | } |
| 173 | |
| 174 | /// Shared geometry for the rounded Tideline composer shell. |
| 175 | /// |
| 176 | /// Rendering, launch hit-testing, and the live composer must derive their |
| 177 | /// interior and submit rect from this one cell map. Otherwise a visible |
| 178 | /// `[↵]` can drift away from the mouse target at a terminal width boundary. |
| 179 | #[derive(Debug, Clone, Copy)] |
| 180 | pub struct TidelineComposerGeometry { |
| 181 | /// Interior input rows, excluding the one-cell rails, the submit control, |
| 182 | /// and its one-cell breathing space. |
| 183 | pub content: Rect, |
| 184 | /// The visible three-cell `[↵]` submit affordance. |
| 185 | pub submit: Rect, |
| 186 | } |
| 187 | |
| 188 | /// Derive the fixed shell geometry. The caller must only paint the rounded |
| 189 | /// shell when the area is at least three rows tall. |
| 190 | #[must_use] |
| 191 | pub fn tideline_composer_geometry(area: Rect) -> TidelineComposerGeometry { |
| 192 | let rail_width = 1; |
| 193 | let interior_breathing_width = 1; |
| 194 | let submit = Rect { |
| 195 | x: area.x.saturating_add(area.width.saturating_sub( |
| 196 | rail_width + interior_breathing_width + TIDELINE_COMPOSER_SUBMIT_WIDTH, |
| 197 | )), |
| 198 | y: area.y.saturating_add(area.height.saturating_sub(2)), |
| 199 | width: TIDELINE_COMPOSER_SUBMIT_WIDTH.min(area.width), |
| 200 | height: 1.min(area.height), |
| 201 | }; |
| 202 | let content_x = area.x.saturating_add(rail_width + interior_breathing_width); |
| 203 | let content_right = submit |
| 204 | .x |
| 205 | .saturating_sub(TIDELINE_COMPOSER_SUBMIT_BREATHING_WIDTH); |
| 206 | let content = Rect { |
| 207 | x: content_x, |
| 208 | y: area.y.saturating_add(1), |
| 209 | width: content_right.saturating_sub(content_x), |
| 210 | height: area.height.saturating_sub(2), |
| 211 | }; |
| 212 | TidelineComposerGeometry { content, submit } |
| 213 | } |
| 214 | |
| 215 | /// Paint or restore the visible `[↵]` affordance above caller-owned content. |
| 216 | /// |
| 217 | /// The multiline work composer paints this after its input or queued crumb, so that |
| 218 | /// content can never overwrite the one cell target the user is meant to click. |
| 219 | /// Ink follows the submission predicate used by the pointer target. |
| 220 | pub fn render_tideline_composer_submit( |
| 221 | area: Rect, |
| 222 | buf: &mut Buffer, |
| 223 | theme: &UiTheme, |
| 224 | can_submit: bool, |
| 225 | ascii_safe: bool, |
| 226 | ) { |
| 227 | if area.width < 6 || area.height < 3 { |
| 228 | return; |
| 229 | } |
| 230 | let geometry = tideline_composer_geometry(area); |
| 231 | let send = if can_submit { |
| 232 | if ascii_safe { "[>]" } else { "[↵]" } |
| 233 | } else if ascii_safe { |
| 234 | "[.]" |
| 235 | } else { |
| 236 | "[·]" |
| 237 | }; |
| 238 | let send_ink = if can_submit { |
| 239 | ChromeInk::Info |
| 240 | } else { |
| 241 | ChromeInk::MetadataDim |
| 242 | }; |
| 243 | put( |
| 244 | buf, |
| 245 | geometry.submit.x, |
| 246 | geometry.submit.y, |
| 247 | send, |
| 248 | if can_submit { |
| 249 | chrome(theme, send_ink).bold() |
| 250 | } else { |
| 251 | chrome(theme, send_ink) |
| 252 | }, |
| 253 | ); |
| 254 | } |
| 255 |