| 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. Density |
| 5 | //! no longer forces a multi-row baseline — it only bounds how tall the |
| 6 | //! composer may grow. Content-driven growth still wins once the user |
| 7 | //! types past one row, and submit/clear collapses the composer back to |
| 8 | //! a single input row. |
| 9 | |
| 10 | use crate::tui::app::ComposerDensity; |
| 11 | |
| 12 | /// Top/bottom chrome rows for the quiet rule (TOP border only) or the |
| 13 | /// enclosed panel (TOP + BOTTOM), plus the total-row growth cap. |
| 14 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 15 | pub struct ComposerChrome { |
| 16 | pub border_rows: u16, |
| 17 | pub max_total_rows: u16, |
| 18 | } |
| 19 | |
| 20 | impl ComposerChrome { |
| 21 | /// Baseline for the given density. Panel shape gets both borders; |
| 22 | /// quiet shape keeps a single top rule so the prompt still has a |
| 23 | /// clear ledge without reading as a card. Density picks the growth |
| 24 | /// cap only — the composer starts at one content row regardless. |
| 25 | #[must_use] |
| 26 | pub fn for_density(density: ComposerDensity, enclosed_panel: bool) -> Self { |
| 27 | let border_rows = if enclosed_panel { 2 } else { 1 }; |
| 28 | let max_total_rows = match density { |
| 29 | ComposerDensity::Compact => 7, |
| 30 | ComposerDensity::Comfortable => 9, |
| 31 | ComposerDensity::Spacious => 12, |
| 32 | }; |
| 33 | Self { |
| 34 | border_rows, |
| 35 | max_total_rows, |
| 36 | } |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | /// Decide how many rows the composer should occupy. |
| 41 | /// |
| 42 | /// The height follows the content: one input row when the composer is |
| 43 | /// empty or holds a single line, growing one row per content line up to |
| 44 | /// the density cap (`max_total_rows`) or the available height, whichever |
| 45 | /// is smaller. Menu rows and the border chrome add on top. Compact |
| 46 | /// terminals shed the border before they shed typed content. |
| 47 | #[must_use] |
| 48 | pub fn desired_height( |
| 49 | content_lines: usize, |
| 50 | extra_menu_lines: usize, |
| 51 | available_height: u16, |
| 52 | density: ComposerDensity, |
| 53 | enclosed_panel: bool, |
| 54 | ) -> u16 { |
| 55 | let chrome = ComposerChrome::for_density(density, enclosed_panel); |
| 56 | let available = available_height.max(1); |
| 57 | let content = content_lines.max(1); |
| 58 | let wants_panel = enclosed_panel && available >= 3; |
| 59 | |
| 60 | let border = if wants_panel { |
| 61 | usize::from(chrome.border_rows) |
| 62 | } else if available >= 2 { |
| 63 | 1 |
| 64 | } else { |
| 65 | 0 |
| 66 | }; |
| 67 | |
| 68 | let total = content |
| 69 | .saturating_add(extra_menu_lines) |
| 70 | .saturating_add(border); |
| 71 | let max_height = usize::from(available.min(chrome.max_total_rows).max(1)); |
| 72 | total.clamp(1, max_height).try_into().unwrap_or(1) |
| 73 | } |
| 74 | |
| 75 | /// Top padding inside the content budget. Keep at least one quiet row below a |
| 76 | /// short prompt when the budget has room, instead of bottom-pinning |
| 77 | /// the caret directly against the phase footer. Compact heights naturally |
| 78 | /// report zero padding once the budget collapses. |
| 79 | #[must_use] |
| 80 | pub fn top_padding(content_lines: usize, rows_budget: usize) -> usize { |
| 81 | let content = content_lines.max(1).min(rows_budget.max(1)); |
| 82 | let spare = rows_budget.saturating_sub(content); |
| 83 | spare.saturating_add(1) / 2 |
| 84 | } |
| 85 | |
| 86 | #[cfg(test)] |
| 87 | mod tests { |
| 88 | use super::*; |
| 89 | |
| 90 | #[test] |
| 91 | fn empty_composer_fits_one_input_row_plus_chrome() { |
| 92 | // Auto-fit: an empty/single-line composer takes exactly one input |
| 93 | // row plus the quiet-rule border, regardless of density. |
| 94 | let height = desired_height(1, 0, 8, ComposerDensity::Comfortable, false); |
| 95 | assert_eq!(height, 2, "1 content row + 1 border row"); |
| 96 | } |
| 97 | |
| 98 | #[test] |
| 99 | fn compact_height_sheds_border_before_content() { |
| 100 | // Only two rows available: keep a border + one content row. |
| 101 | let height = desired_height(1, 0, 2, ComposerDensity::Comfortable, false); |
| 102 | assert_eq!(height, 2); |
| 103 | } |
| 104 | |
| 105 | #[test] |
| 106 | fn content_growth_expands_up_to_the_density_cap() { |
| 107 | // Six content rows + border fits under the Comfortable cap of 9. |
| 108 | let height = desired_height(6, 0, 12, ComposerDensity::Comfortable, false); |
| 109 | assert_eq!(height, 7, "typed content must grow the composer: {height}"); |
| 110 | |
| 111 | // Past the cap the density setting wins, not the content. |
| 112 | let capped = desired_height(20, 0, 30, ComposerDensity::Comfortable, false); |
| 113 | assert_eq!(capped, 9, "Comfortable caps total rows at 9"); |
| 114 | let spacious = desired_height(20, 0, 30, ComposerDensity::Spacious, false); |
| 115 | assert_eq!(spacious, 12, "Spacious caps total rows at 12"); |
| 116 | } |
| 117 | |
| 118 | #[test] |
| 119 | fn single_line_panel_is_one_input_row_plus_both_borders() { |
| 120 | let height = desired_height(1, 0, 12, ComposerDensity::Spacious, true); |
| 121 | assert_eq!(height, 3, "panel = 2 borders + 1 content row, got {height}"); |
| 122 | } |
| 123 | } |
| 124 |