@@ -147,7 +147,6 @@ use unicode_width::UnicodeWidthStr;
147147use 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
151150pub 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
370459pub 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
379467pub 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
0 commit comments