diff --git a/crates/warp_tui/src/agent_block_sections.rs b/crates/warp_tui/src/agent_block_sections.rs index b2204d1cd1c..df0a0570e58 100644 --- a/crates/warp_tui/src/agent_block_sections.rs +++ b/crates/warp_tui/src/agent_block_sections.rs @@ -9,6 +9,7 @@ use std::time::Duration; use warp::tui_export::{format_elapsed_seconds, MessageId}; use warpui_core::elements::tui::{TuiContainer, TuiElement, TuiFlex, TuiText}; +use warpui_core::elements::CrossAxisAlignment; use warpui_core::AppContext; use crate::agent_block::ThinkingBlockStates; @@ -24,7 +25,9 @@ pub(crate) fn render_input_section(text: &str, app: &AppContext) -> Box, + /// Where children land along the cross axis (see + /// [`with_cross_axis_alignment`](Self::with_cross_axis_alignment)). + cross_axis_alignment: CrossAxisAlignment, /// Sizes returned by each child's `layout()` call; populated during layout /// so `render`, `cursor_position`, and `dispatch_event` have consistent slot /// information. @@ -59,6 +69,7 @@ impl TuiFlex { Self { axis, children: Vec::new(), + cross_axis_alignment: CrossAxisAlignment::Start, child_sizes: Vec::new(), } } @@ -94,6 +105,15 @@ impl TuiFlex { self } + /// Sets where children land along the cross axis, mirroring the GUI + /// `Flex`'s method of the same name. `Stretch` additionally makes the flex + /// (and its children) fill the offered cross extent instead of sizing to + /// content. + pub fn with_cross_axis_alignment(mut self, alignment: CrossAxisAlignment) -> Self { + self.cross_axis_alignment = alignment; + self + } + /// The main-axis component of `size`. fn main_extent(axis: Axis, size: TuiSize) -> u16 { match axis { @@ -102,6 +122,14 @@ impl TuiFlex { } } + /// The cross-axis component of `size`. + fn cross_extent(axis: Axis, size: TuiSize) -> u16 { + match axis { + Axis::Vertical => size.width, + Axis::Horizontal => size.height, + } + } + /// A size from main- and cross-axis extents. fn size_of(axis: Axis, main: u16, cross: u16) -> TuiSize { match axis { @@ -126,6 +154,78 @@ impl TuiFlex { Axis::Horizontal => constraint.constrain_width(extent), } } + + /// Clamps a cross-axis extent into the constraint's cross-axis bounds. + fn constrain_cross(axis: Axis, constraint: TuiConstraint, extent: u16) -> u16 { + match axis { + Axis::Vertical => constraint.constrain_width(extent), + Axis::Horizontal => constraint.constrain_height(extent), + } + } + + /// The minimum cross extent handed to children: `Stretch` tightens the + /// cross constraint so children fill it; other alignments leave it loose. + fn child_cross_min(&self, cross: u16) -> u16 { + match self.cross_axis_alignment { + CrossAxisAlignment::Stretch => cross, + CrossAxisAlignment::Start | CrossAxisAlignment::Center | CrossAxisAlignment::End => 0, + } + } + + /// The cross extent the flex reports for itself: `Stretch` fills the + /// offered extent; otherwise the largest child's, clamped to the + /// constraint. + fn reported_cross(&self, constraint: TuiConstraint, cross: u16, cross_max: u16) -> u16 { + match self.cross_axis_alignment { + CrossAxisAlignment::Stretch => cross, + CrossAxisAlignment::Start | CrossAxisAlignment::Center | CrossAxisAlignment::End => { + Self::constrain_cross(self.axis, constraint, cross_max) + } + } + } + + /// The rect a child occupies within its main-axis `slot`, positioned along + /// the cross axis per the alignment. `Start` and `Stretch` keep the full + /// slot (children paint only their content, and hit areas span the slot); + /// `Center` / `End` place the child's measured cross extent within it. + /// Associated (not `&self`) so `dispatch_event` can call it while + /// `children` is mutably borrowed. + fn child_rect_for( + axis: Axis, + alignment: CrossAxisAlignment, + slot: TuiRect, + child_size: TuiSize, + ) -> TuiRect { + // The cross axis is horizontal for a column and vertical for a row. + let (slot_cross, child_cross) = match axis { + Axis::Vertical => (slot.width, child_size.width.min(slot.width)), + Axis::Horizontal => (slot.height, child_size.height.min(slot.height)), + }; + let offset = match alignment { + CrossAxisAlignment::Start | CrossAxisAlignment::Stretch => return slot, + CrossAxisAlignment::Center => slot_cross.saturating_sub(child_cross) / 2, + CrossAxisAlignment::End => slot_cross.saturating_sub(child_cross), + }; + match axis { + Axis::Vertical => TuiRect::new( + slot.x.saturating_add(offset), + slot.y, + child_cross, + slot.height, + ), + Axis::Horizontal => TuiRect::new( + slot.x, + slot.y.saturating_add(offset), + slot.width, + child_cross, + ), + } + } + + /// [`Self::child_rect_for`] with this flex's axis and alignment. + fn child_rect(&self, slot: TuiRect, child_size: TuiSize) -> TuiRect { + Self::child_rect_for(self.axis, self.cross_axis_alignment, slot, child_size) + } } /// Allows [`TuiParentElement`](super::TuiParentElement) (`with_child`, @@ -148,14 +248,18 @@ impl TuiElement for TuiFlex { app: &AppContext, ) -> TuiSize { let axis = self.axis; - // The flex fills the cross-axis extent it is offered. + // Children lay out against the full offered cross-axis extent; the + // flex itself reports its largest child's cross extent, clamped to the + // constraint (or the full extent under `Stretch`). let cross = match axis { Axis::Vertical => constraint.constrain_width(constraint.max.width), Axis::Horizontal => constraint.constrain_height(constraint.max.height), }; + let cross_min = self.child_cross_min(cross); let offered_main = Self::main_extent(axis, constraint.max); let has_flex = self.children.iter().any(|c| c.flex); self.child_sizes.clear(); + let mut cross_max: u16 = 0; if !has_flex { // No flex children: give each child the remaining main-axis extent @@ -163,15 +267,19 @@ impl TuiElement for TuiFlex { let mut total_main: u16 = 0; for child in &mut self.children { let remaining = offered_main.saturating_sub(total_main); - let child_constraint = TuiConstraint::loose(Self::size_of(axis, remaining, cross)); + let child_constraint = TuiConstraint::new( + Self::size_of(axis, 0, cross_min), + Self::size_of(axis, remaining, cross), + ); let size = child.element.layout(child_constraint, ctx, app); total_main = total_main.saturating_add(Self::main_extent(axis, size)); + cross_max = cross_max.max(Self::cross_extent(axis, size)); self.child_sizes.push(size); } return Self::size_of( axis, Self::constrain_main(axis, constraint, total_main), - cross, + self.reported_cross(constraint, cross, cross_max), ); } @@ -184,9 +292,13 @@ impl TuiElement for TuiFlex { fixed_sizes.push(None); } else { let remaining = offered_main.saturating_sub(total_fixed); - let child_constraint = TuiConstraint::loose(Self::size_of(axis, remaining, cross)); + let child_constraint = TuiConstraint::new( + Self::size_of(axis, 0, cross_min), + Self::size_of(axis, remaining, cross), + ); let size = child.element.layout(child_constraint, ctx, app); total_fixed = total_fixed.saturating_add(Self::main_extent(axis, size)); + cross_max = cross_max.max(Self::cross_extent(axis, size)); fixed_sizes.push(Some(size)); } } @@ -201,18 +313,26 @@ impl TuiElement for TuiFlex { let size = if child.flex { let slot = base + u16::from(flex_rank < remainder); flex_rank += 1; - let slot_size = Self::size_of(axis, slot, cross); - // Lay out with a tight extent so the child fills its slot. - child - .element - .layout(TuiConstraint::tight(slot_size), ctx, app); - slot_size + // Tight along the main axis so the child fills its slot; the + // cross axis stays as for fixed children (loose, or tight + // under `Stretch`). + let child_constraint = TuiConstraint::new( + Self::size_of(axis, slot, cross_min), + Self::size_of(axis, slot, cross), + ); + let child_size = child.element.layout(child_constraint, ctx, app); + cross_max = cross_max.max(Self::cross_extent(axis, child_size)); + Self::size_of(axis, slot, Self::cross_extent(axis, child_size)) } else { maybe_size.expect("fixed child was measured in pass 1") }; self.child_sizes.push(size); } - Self::size_of(axis, offered_main, cross) + Self::size_of( + axis, + offered_main, + self.reported_cross(constraint, cross, cross_max), + ) } fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, ctx: &mut TuiLayoutContext) { @@ -223,7 +343,9 @@ impl TuiElement for TuiFlex { } let (slot, rest) = Self::split_main(self.axis, remaining, Self::main_extent(self.axis, *size)); - child.element.render(slot, buffer, ctx); + child + .element + .render(self.child_rect(slot, *size), buffer, ctx); remaining = rest; } } @@ -236,11 +358,12 @@ impl TuiElement for TuiFlex { } let (slot, rest) = Self::split_main(self.axis, remaining, Self::main_extent(self.axis, *size)); - if let Some((cx, cy)) = child.element.cursor_position(slot, ctx) { - // Offset is relative to the slot, not the full area. + let rect = self.child_rect(slot, *size); + if let Some((cx, cy)) = child.element.cursor_position(rect, ctx) { + // Offset is relative to the child's rect, not the full area. return Some(( - slot.x.saturating_sub(area.x) + cx, - slot.y.saturating_sub(area.y) + cy, + rect.x.saturating_sub(area.x) + cx, + rect.y.saturating_sub(area.y) + cy, )); } remaining = rest; @@ -262,19 +385,21 @@ impl TuiElement for TuiFlex { ctx: &mut TuiLayoutContext, app: &AppContext, ) -> bool { - // Offer the event to each child in its rendered slot (mirrors render's - // packing); the first child to handle it consumes it. Children clipped - // past the available extent see no events. + // Offer the event to each child in its rendered rect (mirrors render's + // packing and alignment); the first child to handle it consumes it. + // Children clipped past the available extent see no events. let axis = self.axis; + let alignment = self.cross_axis_alignment; let mut remaining = area; for (child, size) in self.children.iter_mut().zip(&self.child_sizes) { if remaining.is_empty() { break; } let (slot, rest) = Self::split_main(axis, remaining, Self::main_extent(axis, *size)); + let rect = Self::child_rect_for(axis, alignment, slot, *size); if child .element - .dispatch_event(event, slot, event_ctx, ctx, app) + .dispatch_event(event, rect, event_ctx, ctx, app) { return true; } diff --git a/crates/warpui_core/src/elements/tui/flex_tests.rs b/crates/warpui_core/src/elements/tui/flex_tests.rs index 1f37ebcab5d..44f7c8d4129 100644 --- a/crates/warpui_core/src/elements/tui/flex_tests.rs +++ b/crates/warpui_core/src/elements/tui/flex_tests.rs @@ -9,6 +9,7 @@ use crate::elements::tui::{ TuiEventHandler, TuiLayoutContext, TuiParentElement, TuiPresentationContext, TuiRect, TuiSize, TuiText, }; +use crate::elements::CrossAxisAlignment; use crate::event::KeyEventDetails; use crate::keymap::Keystroke; use crate::{App, EntityId, EntityIdMap}; @@ -195,15 +196,96 @@ fn row_clips_children_past_the_available_width() { } #[test] -fn row_fills_the_offered_cross_axis_height() { +fn row_sizes_cross_axis_to_its_tallest_child() { App::test((), |app| async move { app.read(|app_ctx| { - // Like a column spans the offered width, a row spans the offered - // height; cap it with a TuiConstrainedBox where a thinner bar is - // needed. + // The cross axis is content-sized (like the GUI Flex): a row of + // one-row children is one row tall, not the offered three rows. let mut row = TuiFlex::row().child(TuiText::new("A").truncate().finish()); let size = layout_at(&mut row, TuiSize::new(4, 3), app_ctx); - assert_eq!(size, TuiSize::new(1, 3)); + assert_eq!(size, TuiSize::new(1, 1)); + }); + }); +} + +#[test] +fn tight_cross_axis_constraint_forces_fill() { + App::test((), |app| async move { + app.read(|app_ctx| { + // A tight constraint's min clamps the content-sized cross axis + // (and main axis) up to the offered extent. + let mut row = TuiFlex::row().child(TuiText::new("A").truncate().finish()); + let mut rendered_views = EntityIdMap::default(); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + let size = row.layout(TuiConstraint::tight(TuiSize::new(4, 3)), &mut ctx, app_ctx); + assert_eq!(size, TuiSize::new(4, 3)); + }); + }); +} + +// -- cross-axis alignment -- + +#[test] +fn stretch_fills_offered_cross_extent_and_tightens_children() { + App::test((), |app| async move { + app.read(|app_ctx| { + // A stretched column fills the offered width, and its children get + // a tight cross constraint: the nested (unstretched) column is + // forced to width 4 even though its content is one column wide. + let mut column = TuiFlex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .child( + TuiFlex::column() + .child(TuiText::new("A").truncate().finish()) + .finish(), + ); + let size = layout_at(&mut column, TuiSize::new(4, 3), app_ctx); + assert_eq!(size, TuiSize::new(4, 1)); + }); + }); +} + +#[test] +fn center_positions_child_along_cross_axis() { + App::test((), |app| async move { + app.read(|app_ctx| { + // A one-row child centered in a three-row row lands on the middle + // row. + let mut row = TuiFlex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .child(TuiText::new("A").truncate().finish()); + let mut rendered_views = EntityIdMap::default(); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + let size = row.layout(TuiConstraint::tight(TuiSize::new(5, 3)), &mut ctx, app_ctx); + assert_eq!(size, TuiSize::new(5, 3)); + assert_eq!( + render_to_lines(&row, TuiSize::new(5, 3)), + vec![" ", "A ", " "], + ); + }); + }); +} + +#[test] +fn end_positions_child_along_cross_axis() { + App::test((), |app| async move { + app.read(|app_ctx| { + // End-aligned column: the one-column child lands at the right edge + // of the five-column cross extent. + let mut column = TuiFlex::column() + .with_cross_axis_alignment(CrossAxisAlignment::End) + .child(TuiText::new("A").truncate().finish()); + let mut rendered_views = EntityIdMap::default(); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + let size = column.layout(TuiConstraint::tight(TuiSize::new(5, 1)), &mut ctx, app_ctx); + assert_eq!(size, TuiSize::new(5, 1)); + assert_eq!(render_to_lines(&column, TuiSize::new(5, 1)), vec![" A"]); }); }); } diff --git a/specs/tui-flex-alignment/TECH.md b/specs/tui-flex-alignment/TECH.md new file mode 100644 index 00000000000..14b11f357e2 --- /dev/null +++ b/specs/tui-flex-alignment/TECH.md @@ -0,0 +1,42 @@ +# TuiFlex cross-axis sizing + `CrossAxisAlignment` — Tech Spec + +Branch: `harry/tui-flex-alignment`. Consumed by the stacked shell-command-execution work ([`specs/CODE-1805/TECH.md`](../CODE-1805/TECH.md)). + +## Motivation + +`TuiFlex` previously reported its cross-axis size as the full extent it was offered: a row offered 80×20 claimed 20 rows tall regardless of content. Callers that needed a thinner flex capped the *offer* with a `TuiConstrainedBox` (e.g. the one-row footer in `crates/warp_tui/src/terminal_session_view.rs`), which only works when the height is known up front. + +The shell-mode input breaks that assumption. It composes as `row[ "!" gutter, editor ]`, where the editor is 1–6 rows tall depending on how its text wraps — a height only known *during* layout, so there is nothing to pre-cap the row at. With fill-the-offer sizing, the row claims the whole offered height and destroys the bottom-docked input layout. The row must size to its tallest child. + +This is also exactly the GUI `Flex`'s policy (Flutter's): cross-axis size is the max of the children's cross extents (`cross_axis_max` in `crates/warpui_core/src/elements/gui/flex/mod.rs (229-345)`), with filling as an opt-in alignment. The TUI flex was the divergence. + +## Changes + +All in `crates/warpui_core/src/elements/tui/flex.rs` plus two call-site consequence fixes. + +### Content-sized cross axis (new default) + +`TuiFlex::layout` now reports its cross axis as the largest child's cross extent, clamped to the constraint — so a tight cross constraint still forces the flex to fill it, matching how the GUI flex respects `constraint.min`. Children are still *offered* the full cross extent (loose); flex children are tight only along the main axis. Rendering is unchanged for `Start`: each child keeps its full-cross-extent slot, so paint regions and hit areas don't shift. + +### `with_cross_axis_alignment(CrossAxisAlignment)` + +Reuses the GUI's `CrossAxisAlignment` enum directly (`crates/warpui_core/src/elements/gui/flex/mod.rs:733`; already re-exported at `crate::elements::` alongside `Axis`, so no hoist was needed) with the same builder name as the GUI `Flex`: + +- `Start` (default): children anchored at the cross start with full-slot rects — the pre-change behavior minus the greedy self-sizing. +- `Center` / `End`: each child's measured cross extent is positioned within its slot. One helper (`child_rect_for`, `flex.rs:193`) computes the rect and is shared by `render`, `cursor_position`, and `dispatch_event`, so painting, cursor placement, and hit-testing always agree. +- `Stretch`: children get a tight cross constraint (`child_cross_min`, `flex.rs:168`) and the flex reports the full offered cross extent (`reported_cross`, `flex.rs:178`) — the GUI `Stretch` semantics, used where a child must span the flex (e.g. a full-width background). + +### Call-site consequence fixes + +Two places relied on the old fill behavior: + +- The transcript input banner (`crates/warp_tui/src/agent_block_sections.rs:30`) opts into `Stretch` so its highlighted background spans the full row, not just the text width. +- `TuiInputElement::layout` (`crates/warp_tui/src/input/view.rs (1302-1306)`) reports its full wrap width rather than its inner column's content width, since the cursor/selection geometry assumes the element spans the width it wrapped at. + +## GUI parity summary + +Default cross-axis sizing (content, clamped to constraint), the `CrossAxisAlignment` enum and builder method, and `Stretch`'s tighten-children behavior now all match the GUI `Flex`. Remaining intentional difference: under `Start` the TUI gives children full-cross-extent slots for paint/hit-testing (cells are cheap and this preserves existing row-wide click targets), whereas the GUI paints children at their own size. + +## Testing + +`crates/warpui_core/src/elements/tui/flex_tests.rs`: content-sized rows (`row_sizes_cross_axis_to_its_tallest_child`), tight-constraint fill (`tight_cross_axis_constraint_forces_fill`), `Stretch` reporting + child tightening (`stretch_fills_offered_cross_extent_and_tightens_children`), and `Center`/`End` positioning via rendered output (`center_positions_child_along_cross_axis`, `end_positions_child_along_cross_axis`). Existing flex, input-view, and agent-block suites pin the no-regression behavior at the call sites.