Skip to content

Commit 84a988d

Browse files
author
CodeWhale Bot
committed
feat(tui): wire rounded startup composer shell
1 parent ecb5acd commit 84a988d

4 files changed

Lines changed: 393 additions & 141 deletions

File tree

crates/tui/src/tui/composer_chrome.rs

Lines changed: 134 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,6 @@ use unicode_width::UnicodeWidthStr;
147147
use crate::palette::{ChromeInk, UiTheme, chrome_style};
148148

149149
/// The composer's fixed docked height in the work-screen shell (spec §5b).
150-
#[allow(dead_code)] // translation scaffolding: wired by the landing slice
151150
pub const TIDELINE_COMPOSER_HEIGHT: u16 = 4;
152151

153152
/// What the caller owes the composer chrome. Draft, queued-crumb, and
@@ -227,33 +226,95 @@ fn put(buf: &mut Buffer, x: u16, y: u16, text: &str, style: Style) {
227226
buf.set_stringn(x, y, text, width, style);
228227
}
229228

230-
/// Paint the composer chrome. Deterministic: the caller owns the caret clock
231-
/// (a `low_motion` caller passes the still `_`); this render shows the draft
232-
/// and a terminal caret block.
233-
#[allow(dead_code)] // translation scaffolding: wired by the landing slice
234-
pub fn render_tideline_composer(area: Rect, buf: &mut Buffer, composer: &TidelineComposer<'_>) {
229+
fn symbol(glyph: &str, ascii_safe: bool) -> String {
230+
if !ascii_safe {
231+
return glyph.to_string();
232+
}
233+
if let Some(fallback) = crate::tui::glyphs::ascii_fallback(glyph) {
234+
return fallback.to_string();
235+
}
236+
glyph
237+
.chars()
238+
.map(|ch| {
239+
crate::tui::glyphs::ascii_fallback(&ch.to_string())
240+
.map(str::to_string)
241+
.unwrap_or_else(|| ch.to_string())
242+
})
243+
.collect()
244+
}
245+
246+
/// Shared geometry for the rounded Tideline composer shell.
247+
///
248+
/// Rendering, launch hit-testing, and the live composer must derive their
249+
/// interior and submit rect from this one cell map. Otherwise a visible
250+
/// `[↑]` can drift away from the mouse target at a terminal width boundary.
251+
#[derive(Debug, Clone, Copy)]
252+
pub struct TidelineComposerGeometry {
253+
/// Interior rows, excluding the one-cell rails and the breathing space
254+
/// immediately inside each rail.
255+
pub content: Rect,
256+
/// The visible three-cell `[↑]` submit affordance.
257+
pub submit: Rect,
258+
/// The full rounded shell; clicking it focuses the composer.
259+
pub focus: Rect,
260+
}
261+
262+
/// Derive the fixed shell geometry. The caller must only paint the rounded
263+
/// shell when the area is at least three rows tall.
264+
#[must_use]
265+
pub fn tideline_composer_geometry(area: Rect) -> TidelineComposerGeometry {
266+
let rail_width = 1;
267+
let send_width = 3;
268+
let content = Rect {
269+
x: area.x.saturating_add(2),
270+
y: area.y.saturating_add(1),
271+
width: area.width.saturating_sub(2 + rail_width * 2),
272+
height: area.height.saturating_sub(2),
273+
};
274+
let submit = Rect {
275+
x: area
276+
.x
277+
.saturating_add(area.width.saturating_sub(rail_width + 1 + send_width)),
278+
y: area.y.saturating_add(area.height.saturating_sub(2)),
279+
width: send_width.min(area.width),
280+
height: 1.min(area.height),
281+
};
282+
TidelineComposerGeometry {
283+
content,
284+
submit,
285+
focus: area,
286+
}
287+
}
288+
289+
/// Paint only the shared rounded shell and its visible `[↑]` submit target.
290+
///
291+
/// Content remains caller-owned: the launch surface supplies its localized
292+
/// placeholder/caret/hint projection, while the live composer supplies its
293+
/// multiline editor. Sharing this shell keeps the visual component and exact
294+
/// submit geometry coherent without creating a second input authority.
295+
pub fn render_tideline_composer_shell(
296+
area: Rect,
297+
buf: &mut Buffer,
298+
theme: &UiTheme,
299+
focused: bool,
300+
ascii_safe: bool,
301+
) {
235302
if area.width < 6 || area.height < 3 {
236303
return;
237304
}
238-
let theme = composer.theme;
239-
let border_ink = if composer.focused {
305+
let border_ink = if focused {
240306
ChromeInk::Info
241307
} else {
242308
ChromeInk::MetadataDim
243309
};
244310
let border = chrome(theme, border_ink);
245-
246-
// Rounded border. Top row: `╭──…──╮` — the hand-drawn crown fluke that
247-
// used to replace the top-right corner was deleted by the founder
248-
// decree; the corner is a plain `╮` again, waking with the border only.
249311
let top_fill = usize::from(area.width.saturating_sub(2).max(1));
250312
let top: String = std::iter::once('╭')
251313
.chain(std::iter::repeat_n('─', top_fill))
252314
.chain(std::iter::once('╮'))
253315
.collect();
254-
put(buf, area.x, area.y, &composer.sym(&top), border);
316+
put(buf, area.x, area.y, &symbol(&top, ascii_safe), border);
255317

256-
// Bottom row: `╰──…──╯`.
257318
let bottom_fill = usize::from(area.width.saturating_sub(2));
258319
let bottom: String = std::iter::once('╰')
259320
.chain(std::iter::repeat_n('─', bottom_fill))
@@ -263,23 +324,67 @@ pub fn render_tideline_composer(area: Rect, buf: &mut Buffer, composer: &Tidelin
263324
buf,
264325
area.x,
265326
area.y + area.height - 1,
266-
&composer.sym(&bottom),
327+
&symbol(&bottom, ascii_safe),
267328
border,
268329
);
269330

270-
// Side rails.
271-
let rail = composer.sym("│");
272-
let rail_w = rail.width() as u16;
331+
let rail = symbol("│", ascii_safe);
332+
let rail_width = rail.width() as u16;
273333
for y in (area.y + 1)..(area.y + area.height - 1) {
274334
put(buf, area.x, y, &rail, border);
275-
put(buf, area.x + area.width - rail_w, y, &rail, border);
335+
put(buf, area.x + area.width - rail_width, y, &rail, border);
276336
}
277337

278-
let inner_x = area.x + 2;
279-
let inner_w = area.width.saturating_sub(2 + rail_w * 2).max(1);
280-
let content_top = area.y + 1;
338+
render_tideline_composer_submit(area, buf, theme, focused, ascii_safe);
339+
}
340+
341+
/// Paint or restore the visible `[↑]` affordance above caller-owned content.
342+
///
343+
/// The standalone shell paints it immediately. The multiline work composer
344+
/// calls this again after it has painted a long input or queued crumb, so that
345+
/// content can never overwrite the one cell target the user is meant to click.
346+
pub fn render_tideline_composer_submit(
347+
area: Rect,
348+
buf: &mut Buffer,
349+
theme: &UiTheme,
350+
focused: bool,
351+
ascii_safe: bool,
352+
) {
353+
if area.width < 6 || area.height < 3 {
354+
return;
355+
}
356+
let geometry = tideline_composer_geometry(area);
357+
let send = symbol("[↑]", ascii_safe);
358+
let send_ink = if focused {
359+
ChromeInk::Active
360+
} else {
361+
ChromeInk::MetadataDim
362+
};
363+
put(
364+
buf,
365+
geometry.submit.x,
366+
geometry.submit.y,
367+
&send,
368+
chrome(theme, send_ink),
369+
);
370+
}
371+
372+
/// Paint the composer chrome. Deterministic: the caller owns the caret clock
373+
/// (a `low_motion` caller passes the still `_`); this render shows the draft
374+
/// and a terminal caret block.
375+
pub fn render_tideline_composer(area: Rect, buf: &mut Buffer, composer: &TidelineComposer<'_>) {
376+
if area.width < 6 || area.height < 3 {
377+
return;
378+
}
379+
let theme = composer.theme;
380+
render_tideline_composer_shell(area, buf, theme, composer.focused, composer.ascii_safe);
381+
382+
let geometry = tideline_composer_geometry(area);
383+
let inner_x = geometry.content.x;
384+
let inner_w = geometry.content.width.max(1);
385+
let content_top = geometry.content.y;
281386
// Last row *inside* the border (the bottom border owns the final row).
282-
let content_bottom = area.y + area.height - 2;
387+
let content_bottom = geometry.content.bottom().saturating_sub(1);
283388

284389
// Content rows: the crumb (if any) sits one row above the input line
285390
// (spec §3 slot-3 merge); without a crumb the input takes the first
@@ -327,24 +432,9 @@ pub fn render_tideline_composer(area: Rect, buf: &mut Buffer, composer: &Tidelin
327432
put(buf, inner_x, input_y, &line, chrome(theme, ink));
328433
}
329434

330-
// Send hitbox `[↑]`, right-aligned inside the border on the last
331-
// content row — its own quiet row unless height collapsed to one.
332-
let send = composer.sym("[↑]");
333-
let send_w = send.width() as u16;
334-
let send_x = area.x + area.width - rail_w - 1 - send_w;
335-
let send_y = if input_y == content_bottom {
336-
input_y
337-
} else {
338-
content_bottom
339-
};
340-
if send_x > inner_x {
341-
let send_ink = if composer.focused {
342-
ChromeInk::Active
343-
} else {
344-
ChromeInk::MetadataDim
345-
};
346-
put(buf, send_x, send_y, &send, chrome(theme, send_ink));
347-
}
435+
// `render_tideline_composer_shell` paints the action for standalone
436+
// callers; restore it after content so a long draft cannot erase it.
437+
render_tideline_composer_submit(area, buf, theme, composer.focused, composer.ascii_safe);
348438
}
349439

350440
/// Truncate a rendered string to `width` cells on a char boundary (never
@@ -366,7 +456,6 @@ fn truncate_cells(text: &str, width: usize) -> String {
366456
/// Recorded hitboxes for one rendered composer (spec §6): the `[↑]` submit
367457
/// rect and the top-border ring (click = focus the composer).
368458
#[derive(Debug, Clone, Copy)]
369-
#[allow(dead_code)] // translation scaffolding: wired by the landing slice
370459
pub struct TidelineComposerHitboxes {
371460
pub submit: Rect,
372461
pub border: Rect,
@@ -375,24 +464,11 @@ pub struct TidelineComposerHitboxes {
375464
/// Compute the composer hitboxes for one render area; same inputs as
376465
/// [`render_tideline_composer`] so the submit rect matches painted cells.
377466
#[must_use]
378-
#[allow(dead_code)] // translation scaffolding: wired by the landing slice
379467
pub fn tideline_composer_hitboxes(area: Rect) -> TidelineComposerHitboxes {
380-
let rail_w = 1;
381-
let send_w = 3;
382-
let send_x = area.x + area.width.saturating_sub(rail_w + 1 + send_w);
468+
let geometry = tideline_composer_geometry(area);
383469
TidelineComposerHitboxes {
384-
submit: Rect {
385-
x: send_x,
386-
y: area.y + area.height.saturating_sub(2),
387-
width: send_w,
388-
height: 1,
389-
},
390-
border: Rect {
391-
x: area.x,
392-
y: area.y,
393-
width: area.width,
394-
height: 1,
395-
},
470+
submit: geometry.submit,
471+
border: geometry.focus,
396472
}
397473
}
398474

crates/tui/src/tui/composer_chrome/tideline_tests.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,21 @@ fn composer_border_is_rounded_with_send_hitbox_and_no_crown() {
5858
assert!(send_row.contains("[↑]"), "send hitbox: {send_row:?}");
5959
}
6060

61+
#[test]
62+
fn composer_send_hitbox_survives_a_long_draft() {
63+
let draft = "x".repeat(240);
64+
let text = draw_docked(
65+
80,
66+
24,
67+
&TidelineComposer::new(&UI_THEME, &draft).focused(true),
68+
);
69+
let send_row = text.lines().nth(22).unwrap_or_default();
70+
assert!(
71+
send_row.contains("[↑]"),
72+
"caller-owned text must not overwrite the submit target: {send_row:?}"
73+
);
74+
}
75+
6176
#[test]
6277
fn composer_focus_states_change_ink_not_cells() {
6378
let draft = "same draft";

crates/tui/src/tui/ui/frame.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,13 +1001,17 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
10011001
let topbar_interactions = render_topbar_row(f, app, topbar_area);
10021002
register_topbar_interaction_targets(app, topbar_interactions);
10031003
let startup = crate::tui::underwater::tideline_startup_from_app(app);
1004-
let hitboxes = crate::tui::underwater::tideline_startup_hitboxes(stage_area);
1004+
let hitboxes = if startup.composer.enclosed {
1005+
crate::tui::underwater::tideline_startup_hitboxes(stage_area)
1006+
} else {
1007+
crate::tui::underwater::tideline_startup_hitboxes_with_composer(stage_area, false)
1008+
};
10051009
crate::tui::underwater::render_tideline_startup(stage_area, f.buffer_mut(), &startup);
10061010
// The completion popup paints above the docked composer's input row,
10071011
// over the stage rows it needs — the same caller-computed entries
10081012
// the session popup rides.
10091013
if let Some(input_row) = hitboxes
1010-
.composer
1014+
.input
10111015
.map(|area| area.y.saturating_sub(stage_area.y))
10121016
{
10131017
crate::tui::underwater::render_launch_completion_popup(

0 commit comments

Comments
 (0)