diff --git a/crates/warpui_core/src/elements/tui/text.rs b/crates/warpui_core/src/elements/tui/text.rs index c3c0d19ba8..82bfa38d2c 100644 --- a/crates/warpui_core/src/elements/tui/text.rs +++ b/crates/warpui_core/src/elements/tui/text.rs @@ -47,6 +47,11 @@ enum TuiTextOverflow { Clip, Ellipsis, } +#[derive(Clone, Copy)] +struct TuiTextMeasurement { + available_width: u16, + natural_size: TuiSize, +} pub struct TuiText { /// Styled runs that concatenate into the full text. Runs may contain hard @@ -56,6 +61,7 @@ pub struct TuiText { style: TuiStyle, wrap: bool, overflow: TuiTextOverflow, + cached_measurement: Option, size: Option, origin: Option, } @@ -75,6 +81,7 @@ impl TuiText { style: TuiStyle::default(), wrap: true, overflow: TuiTextOverflow::default(), + cached_measurement: None, size: None, origin: None, } @@ -88,6 +95,7 @@ impl TuiText { /// Lays each hard line out as a single (clipped) row instead of wrapping. pub fn truncate(mut self) -> Self { self.wrap = false; + self.cached_measurement = None; self } /// Truncates each hard line at grapheme boundaries and appends `...` @@ -95,6 +103,7 @@ impl TuiText { pub fn truncate_with_ellipsis(mut self) -> Self { self.wrap = false; self.overflow = TuiTextOverflow::Ellipsis; + self.cached_measurement = None; self } @@ -262,18 +271,30 @@ impl TuiElement for TuiText { _ctx: &mut TuiLayoutContext, _app: &AppContext, ) -> TuiSize { - let size = if self.is_empty() { - constraint.clamp(TuiSize::ZERO) + let width = constraint.max.width; + let natural_size = if self.is_empty() { + TuiSize::ZERO + } else if let Some(measurement) = self + .cached_measurement + .filter(|measurement| measurement.available_width == width) + { + measurement.natural_size } else { let paragraph = self.paragraph(constraint.max.width); let height = u16::try_from(paragraph.line_count(constraint.max.width)).unwrap_or(u16::MAX); let content_width = u16::try_from(paragraph.line_width()).unwrap_or(u16::MAX); - TuiSize::new( - constraint.constrain_width(content_width), - constraint.constrain_height(height), - ) + let size = TuiSize::new(content_width, height); + self.cached_measurement = Some(TuiTextMeasurement { + available_width: width, + natural_size: size, + }); + size }; + let size = TuiSize::new( + constraint.constrain_width(natural_size.width), + constraint.constrain_height(natural_size.height), + ); self.size = Some(size); size } diff --git a/crates/warpui_core/src/elements/tui/text_tests.rs b/crates/warpui_core/src/elements/tui/text_tests.rs index 4af013ea3f..d0987da523 100644 --- a/crates/warpui_core/src/elements/tui/text_tests.rs +++ b/crates/warpui_core/src/elements/tui/text_tests.rs @@ -69,6 +69,34 @@ fn layout_reports_content_width_and_row_count() { }); } +#[test] +fn truncation_invalidates_a_cached_wrapped_measurement() { + App::test((), |app| async move { + app.read(|app_ctx| { + let constraint = TuiConstraint::loose(TuiSize::new(5, 10)); + for truncate in [ + TuiText::truncate as fn(TuiText) -> TuiText, + TuiText::truncate_with_ellipsis, + ] { + let mut text = TuiText::new("hello world"); + let mut rendered_views = EntityIdMap::default(); + let mut ctx = TuiLayoutContext { + rendered_views: &mut rendered_views, + }; + assert_eq!( + text.layout(constraint, &mut ctx, app_ctx), + TuiSize::new(5, 2) + ); + + text = truncate(text); + assert_eq!( + text.layout(constraint, &mut ctx, app_ctx), + TuiSize::new(5, 1) + ); + } + }); + }); +} #[test] fn word_wraps_at_the_width_boundary() { let text = TuiText::new("hello world foo"); diff --git a/crates/warpui_core/src/elements/tui/viewported_list.rs b/crates/warpui_core/src/elements/tui/viewported_list.rs index 24b906e73f..60b87c73ba 100644 --- a/crates/warpui_core/src/elements/tui/viewported_list.rs +++ b/crates/warpui_core/src/elements/tui/viewported_list.rs @@ -147,6 +147,12 @@ where let Some(resolved) = self.state.resolved_viewport() else { return; }; + let selection_is_valid = selection.validate_width(size.width); + let selection_range = selection_is_valid.then(|| selection.range()).flatten(); + if selection_range.is_none() && !self.trim_selection_line_ends { + self.selection_snapshot.borrow_mut().take(); + return; + } let visible_height = size.height.saturating_sub(resolved.screen_offset).min( resolved .content_height @@ -171,10 +177,7 @@ where .collect::>() }); *self.selection_snapshot.borrow_mut() = Some((resolved, snapshot)); - if !selection.validate_width(size.width) { - return; - } - let Some(range) = selection.range() else { + let Some(range) = selection_range else { return; }; let viewport_bottom = resolved.window.scroll_top.saturating_add(usize::from(