返回 CodeWhale
composer_chrome.rs
根目录 / crates / tui / src / tui / composer_chrome.rs
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
255 lines RUST