Unify TUI status content and layout caching in StatusLine (#61200)

## What changed

- Replace `FooterContent` and the separate layout cache with a `StatusLine` that owns native or extension content and caches its layout by bounds.
- Move footer padding into the caller's layout and retain cached wrapping for unchanged agent-labeled status lines.
- Remove extension-to-single-line conversion and render prepared UI trees directly within their allotted rectangles.

## Testing

Extend composer coverage to verify that replacing measured extension content updates the displayed rows. Adapt status-line and UI-tree tests to exercise direct rendering, clipping, hyperlinks, and viewport bounds.

GitOrigin-RevId: 72273c87d689b8b59748ab243793a9e2a45f4884
This commit is contained in:
Felipe Coury
2026-11-12 22:44:19 +00:00
committed by copyberry
parent 94fc729602
commit 16e27626e6
23 changed files with 431 additions and 575 deletions
+11 -10
View File
@@ -8,8 +8,7 @@ use super::sidebar::PaneFocus;
use super::sidebar::SidebarContent;
use super::sidebar::SidebarLayout;
use crate::bottom_pane::FooterLayout;
use crate::bottom_pane::StatusLine;
use crate::bottom_pane::footer_content::StatusLineFocus;
use crate::bottom_pane::StatusLineFocus;
use crate::custom_terminal::Frame;
use crate::legacy_core::config::edit::ConfigEdit;
use crate::legacy_core::config::edit::ConfigEditsBuilder;
@@ -458,8 +457,9 @@ impl SidePaneLayout {
/*priority_hint_rows*/ hint_rows,
);
let status = companion.display.status_line.as_ref();
let status_line = StatusLine::default();
let wanted_status = status_line.height(status, area.width, footer.status_budget(available));
let wanted_status = status.map_or(0, |status| {
status.height(footer.status_bounds(area.width, available))
});
let footer = footer.allocate(available, wanted_status);
let above = available - footer.height();
let mut transcript = Rect {
@@ -512,12 +512,13 @@ impl SidePaneLayout {
&mut Default::default(),
);
}
status_line.render(
status,
footer.status_area(area),
frame.buffer,
StatusLineFocus::Unfocused,
);
if let Some(status) = status {
status.render(
footer.status_area(area),
frame.buffer,
StatusLineFocus::Unfocused,
);
}
}
}
+5 -5
View File
@@ -2218,7 +2218,6 @@ async fn replayed_interrupted_turn_restores_queued_input_to_composer() {
#[tokio::test]
async fn token_usage_update_refreshes_status_line_with_runtime_context_window() {
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::footer_content::FooterContent;
let mut app = make_test_app().await;
app.chat_widget.setup_status_line(
@@ -2249,10 +2248,11 @@ async fn token_usage_update_refreshes_status_line_with_runtime_context_window()
app.handle_thread_event_now(ThreadBufferedEvent::Notification(Box::new(
token_usage_notification(thread_id, "turn-1", Some(window)),
)));
assert!(matches!(
app.chat_widget.status_line_content(),
Some(FooterContent::HyperlinkLine(_))
));
assert!(
app.chat_widget
.status_line_content()
.is_some_and(|status| status.as_native().is_some())
);
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 60);
assert!(
rendered.lines().any(|row| row.trim() == expected),
+1 -1
View File
@@ -317,7 +317,7 @@ pub(crate) struct CapturedSideDisplay {
pub(crate) active: Option<Box<dyn HistoryCell>>,
pub(crate) auxiliary: Vec<crate::terminal_hyperlinks::HyperlinkLine>,
pub(crate) draft: Box<crate::bottom_pane::TextArea>,
pub(crate) status_line: Option<crate::bottom_pane::footer_content::FooterContent>,
pub(crate) status_line: Option<crate::bottom_pane::StatusLine>,
pub(crate) work: Option<CapturedSideWork>,
pub(crate) last_completed_agent_message: Option<(String, String)>,
pub(crate) cwd: AbsolutePathBuf,
+11 -11
View File
@@ -379,8 +379,8 @@ use super::skill_popup::SkillPopup;
use super::slash_commands::ServiceTierCommand;
use super::slash_commands::SlashCommandItem;
use super::voice_strip::VoiceStrip;
use crate::bottom_pane::footer_content::FooterContent;
use crate::bottom_pane::footer_content::StatusLineFocus;
use crate::bottom_pane::StatusLine;
use crate::bottom_pane::StatusLineFocus;
use crate::history_cell::sanitize_user_text;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::EditorKeymap;
@@ -795,7 +795,7 @@ impl ChatComposer {
goal_status_indicator: None,
ide_context_active: false,
status_line_value: None,
status_layout: Default::default(),
labeled_status_line: Default::default(),
status_line_enabled: false,
side_conversation_context_label: None,
active_agent_label: None,
@@ -908,13 +908,12 @@ impl ChatComposer {
let style = IgnitionStyle::random(self.effort_animation_style);
self.effort_ignition = Some(EffortIgnition::new(tier, style));
self.effort_animation_style = Some(style);
if let Some(previous) = self.status_surface_content(StatusLineFocus::Focused)
&& !matches!(previous.as_ref(), FooterContent::Extension { .. })
{
self.effort_status_line_transition = Some(EffortStatusLineTransition::new(
tier,
previous.into_owned().into_hyperlink_line(),
));
let previous = self.with_status_surface_content(StatusLineFocus::Focused, |status| {
status.and_then(StatusLine::as_native).cloned()
});
if let Some(previous) = previous {
self.effort_status_line_transition =
Some(EffortStatusLineTransition::new(tier, previous));
}
if let Some(frame_requester) = &self.frame_requester {
frame_requester.schedule_frame();
@@ -1891,7 +1890,7 @@ impl ChatComposer {
self.attachments.local_image_paths()
}
pub(crate) fn status_line_content(&self) -> Option<&FooterContent> {
pub(crate) fn status_line_content(&self) -> Option<&StatusLine> {
self.footer.status_line_value.as_ref()
}
@@ -4496,6 +4495,7 @@ impl ChatComposer {
return false;
}
self.footer.active_agent_label = active_agent_label;
*self.footer.labeled_status_line.get_mut() = None;
true
}
}
@@ -7,6 +7,7 @@ use crate::render::renderable::Renderable;
use crate::tui::FrameRequester;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::layout::Size;
struct ComposerRows {
input: u16,
@@ -40,7 +41,11 @@ impl FooterLayout {
}
}
pub(crate) fn status_budget(&self, available: u16) -> u16 {
pub(crate) fn status_bounds(&self, width: u16, available: u16) -> Size {
inset_footer_hint_area(Rect::new(0, 0, width, self.status_budget(available))).as_size()
}
fn status_budget(&self, available: u16) -> u16 {
self.cap.min(available).saturating_sub(self.reserved_hints)
}
@@ -61,11 +66,11 @@ impl FooterRows {
}
pub(crate) fn status_area(&self, area: Rect) -> Rect {
Rect {
inset_footer_hint_area(Rect {
y: area.bottom().saturating_sub(self.height()).max(area.y),
height: self.status,
..area
}
})
}
}
@@ -297,7 +302,7 @@ impl ChatComposer {
/*priority_hint_rows*/ if find_active { 2 } else { 1 },
);
let wanted_status =
self.status_surface_height(width, footer.status_budget(available), options);
self.status_surface_height(footer.status_bounds(width, available), options);
let basic = (footer.reserved_hints + wanted_status.min(1)).min(2);
let above_input = above_input.min(available.saturating_sub(basic));
available -= above_input;
@@ -18,9 +18,9 @@ use super::super::footer::footer_height;
use super::super::footer::reset_mode_after_activity;
use super::ActivePopup;
use super::warning_notice;
use crate::bottom_pane::StatusLine;
use crate::bottom_pane::footer::FooterKeyHints;
use crate::bottom_pane::footer::FooterProps;
use crate::bottom_pane::footer_content::FooterContent;
use crate::key_hint;
use crossterm::event::KeyCode;
use ratatui::buffer::Buffer;
@@ -383,8 +383,8 @@ pub(super) struct FooterState {
pub(super) collaboration_mode_indicator: Option<CollaborationModeIndicator>,
pub(super) goal_status_indicator: Option<GoalStatusIndicator>,
pub(super) ide_context_active: bool,
pub(super) status_line_value: Option<FooterContent>,
pub(super) status_layout: crate::bottom_pane::status_line::StatusLine,
pub(super) status_line_value: Option<StatusLine>,
pub(super) labeled_status_line: std::cell::RefCell<Option<StatusLine>>,
pub(super) status_line_enabled: bool,
pub(super) side_conversation_context_label: Option<String>,
pub(super) active_agent_label: Option<String>,
@@ -3,19 +3,20 @@
//! Shortcut help grows above the input, preserving status and close rows below it even when clipped.
use super::*;
use crate::bottom_pane::footer_content::StatusLineFocus;
use std::borrow::Cow;
use ratatui::layout::Size;
impl ChatComposer {
pub(crate) fn set_status_line(&mut self, status_line: Option<FooterContent>) -> bool {
pub(crate) fn set_status_line(&mut self, status_line: Option<StatusLine>) -> bool {
if self.footer.status_line_value == status_line {
return false;
}
if matches!(status_line, Some(FooterContent::Extension { .. })) {
if status_line
.as_ref()
.is_some_and(|status| status.as_native().is_none())
{
self.effort_status_line_transition = None;
}
self.footer.status_layout = Default::default();
*self.footer.labeled_status_line.get_mut() = None;
self.footer.status_line_value = status_line;
true
}
@@ -38,22 +39,16 @@ impl ChatComposer {
pub(super) fn status_surface_height(
&self,
width: u16,
available: u16,
mut bounds: Size,
options: ComposerRenderOptions<'_>,
) -> u16 {
let focus = options.status_line_focus();
let available =
if focus == StatusLineFocus::Focused && self.active_status_transition().is_some() {
available.min(1)
} else {
available
};
self.footer.status_layout.height(
self.status_surface_content(focus).as_deref(),
width,
available,
)
if focus == StatusLineFocus::Focused && self.active_status_transition().is_some() {
bounds.height = bounds.height.min(1);
}
self.with_status_surface_content(focus, |status| {
status.map_or(0, |status| status.height(bounds))
})
}
pub(super) fn hint_footer_props(&self) -> FooterProps {
@@ -66,25 +61,32 @@ impl ChatComposer {
props
}
pub(super) fn status_surface_content(
pub(super) fn with_status_surface_content<T>(
&self,
focus: StatusLineFocus,
) -> Option<Cow<'_, FooterContent>> {
use_status: impl FnOnce(Option<&StatusLine>) -> T,
) -> T {
if !self.footer.status_line_enabled {
return None;
return use_status(None);
}
let status = self.footer.status_line_value.as_ref();
if matches!(status, Some(FooterContent::Extension { .. }))
if status.is_some_and(|status| status.as_native().is_none())
|| self.footer.active_agent_label.is_none()
|| focus == StatusLineFocus::Unfocused
{
return status.map(Cow::Borrowed);
return use_status(status);
}
let mut props = self.footer_props();
// The viewed agent belongs to native status even during a transient composer hint.
props.mode = FooterMode::ComposerHasDraft;
props.is_task_running = false;
passive_footer_status_line(&props).map(Cow::Owned)
let next = passive_footer_status_line(&props);
let mut labeled = self.footer.labeled_status_line.borrow_mut();
// Preserve wrapped rows while the effective line, including the label style, stays equal.
if *labeled != next {
*labeled = next;
}
use_status(labeled.as_ref())
}
fn active_status_transition(&self) -> Option<&EffortStatusLineTransition> {
@@ -103,23 +105,21 @@ impl ChatComposer {
return;
}
let focus = options.status_line_focus();
let mut content = self.status_surface_content(focus);
if focus == StatusLineFocus::Focused
&& let Some(transition) = self.active_status_transition()
{
content = transition
.render_line(
content.map(|line| line.into_owned().into_hyperlink_line()),
inset_footer_hint_area(area).width,
)
.map(|frame| Cow::Owned(frame.into()));
if let Some(requester) = &self.frame_requester {
requester.schedule_frame_in(EFFORT_STATUS_LINE_FRAME_TICK);
self.with_status_surface_content(focus, |status| {
if focus == StatusLineFocus::Focused
&& let Some(transition) = self.active_status_transition()
{
let target = status.and_then(StatusLine::as_native).cloned();
if let Some(frame) = transition.render_line(target, area.width) {
StatusLine::from(frame).render(area, buf, focus);
}
if let Some(requester) = &self.frame_requester {
requester.schedule_frame_in(EFFORT_STATUS_LINE_FRAME_TICK);
}
} else if let Some(status) = status {
status.render(area, buf, focus);
}
}
self.footer
.status_layout
.render(content.as_deref(), area, buf, focus);
});
}
}
@@ -39,7 +39,7 @@ fn extension_status_line_clips_links_to_footer_layout() {
}"#
.parse()
.unwrap();
composer.set_status_line(Some(FooterContent::from(tree)));
composer.set_status_line(Some(StatusLine::from(tree)));
for separate_status_line in [false, true] {
for width in [12, 40] {
@@ -137,7 +137,7 @@ fn extension_uses_the_full_width_after_resize() {
{"type":"link","props":{"href":"https://example.com/pr/12345"},"children":["12345"]}]}]}"#
.parse()
.unwrap();
composer.set_status_line(Some(FooterContent::from(tree)));
composer.set_status_line(Some(StatusLine::from(tree)));
composer.set_collaboration_modes_enabled(/*enabled*/ true);
let linked_number: String = "12345"
.chars()
@@ -205,7 +205,7 @@ fn provider_status_and_hints_use_separate_rows() {
fn status_rows_respect_host_budget_and_yield_to_the_draft() {
let mut composer = composer();
let tree: crate::ui_tree::UiTree = r#""one\ntwo\nthree\nfour\nfive""#.parse().unwrap();
composer.set_status_line(Some(FooterContent::from(tree)));
composer.set_status_line(Some(StatusLine::from(tree)));
composer.set_text_content("a\nb\nc".into(), Vec::new(), Vec::new());
composer.move_cursor_to_end();
for separate_status_line in [false, true] {
@@ -252,8 +252,19 @@ fn status_rows_respect_host_budget_and_yield_to_the_draft() {
["one…"]
);
composer.set_collaboration_mode_indicator(/*indicator*/ None);
let options = ComposerRenderOptions {
separate_status_line: true,
max_height: Some(14),
..Default::default()
};
let area = Rect::new(0, 0, 7, 14);
assert_eq!(
render_status(&composer, area, options).1,
["one", "two", "three", "four…"]
);
let tree: crate::ui_tree::UiTree = r#""x\ny""#.parse().unwrap();
composer.set_status_line(Some(FooterContent::from(tree)));
composer.set_status_line(Some(StatusLine::from(tree)));
assert_eq!(render_status(&composer, area, options).1, ["x", "y"]);
assert_eq!(
composer.footer_hint_height(/*width*/ 7, ComposerRenderOptions::default()),
0
@@ -283,8 +294,7 @@ fn sidebar_focus_keeps_dim_extension_status_rows() {
if unfocused {
assert_eq!(composer.footer.warning_notice_area.get(), None);
assert!(!buffer.content.iter().any(|cell| cell.symbol() == "⚠"));
let status =
inset_footer_hint_area(composer.layout_with_options(buffer.area, options).status);
let status = composer.layout_with_options(buffer.area, options).status;
for y in status.y..status.bottom() {
assert!(
buffer[(status.x, y)]
@@ -424,7 +434,7 @@ fn extension_status_paints_with_the_current_theme_at_the_same_width() {
r#"{"type":"text","props":{"color":"primary"},"children":["λ"]}"#
.parse()
.unwrap();
composer.set_status_line(Some(FooterContent::from(content)));
composer.set_status_line(Some(StatusLine::from(content)));
let previous = current_syntax_theme();
let colors = [1, 2].map(|index| {
let mut theme = previous.clone();
@@ -460,7 +470,7 @@ fn extension_status_paints_with_the_current_theme_at_the_same_width() {
fn extension_status_keeps_its_rows_when_effort_changes() {
let mut composer = composer();
let tree: crate::ui_tree::UiTree = r#""one\ntwo\nthree""#.parse().unwrap();
composer.set_status_line(Some(FooterContent::from(tree)));
composer.set_status_line(Some(StatusLine::from(tree)));
composer.set_active_reasoning_effort_baseline(/*effort*/ None);
composer.set_active_reasoning_effort(
Some(&ReasoningEffort::Max),
+17 -10
View File
@@ -22,9 +22,8 @@
//! agent label, side-conversation state, or some combination of those.
//!
//! The composer fits ordinary navigation, modes, actions, and warnings in `chat_composer::passive_hints`.
pub(crate) mod footer_content;
use self::footer_content::FooterContent;
use super::StatusLine;
use super::shortcut_overlay;
use super::status_line_style::STATUS_LINE_SEPARATOR;
use crate::key_hint;
@@ -63,7 +62,7 @@ pub(crate) struct FooterProps {
///
/// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`.
pub(crate) quit_shortcut_key: KeyBinding,
pub(crate) status_line_value: Option<FooterContent>,
pub(crate) status_line_value: Option<StatusLine>,
pub(crate) status_line_enabled: bool,
pub(crate) key_hints: FooterKeyHints,
/// Active thread label shown when the footer is rendering contextual information instead of an
@@ -795,7 +794,7 @@ fn footer_from_props_lines(
/// The returned line may contain the configured status line, the currently viewed agent label, or
/// both combined. Active instructional states such as quit reminders, shortcut overlays, and queue
/// prompts deliberately return `None` so those call-to-action hints stay visible.
pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<FooterContent> {
pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<StatusLine> {
if !props.mode.shows_passive_line(props.is_task_running) {
return None;
}
@@ -804,10 +803,13 @@ pub(crate) fn passive_footer_status_line(props: &FooterProps) -> Option<FooterCo
.status_line_value
.as_ref()
.filter(|_| props.status_line_enabled);
let mut line = match status {
Some(FooterContent::HyperlinkLine(line)) => line.as_ref().clone(),
Some(content @ FooterContent::Extension { .. }) => return Some(content.clone()),
None => HyperlinkLine::default(),
let mut line = if let Some(status) = status {
let Some(line) = status.as_native() else {
return Some(status.clone());
};
line.clone()
} else {
HyperlinkLine::default()
};
let suffix = passive_footer_suffix(props, status.is_some());
if status.is_none() && suffix.spans.is_empty() {
@@ -1040,8 +1042,13 @@ mod tests {
let status_line_active = props.status_line_enabled
&& props.mode.shows_passive_line(props.is_task_running);
let passive_status_line = if status_line_active {
passive_footer_status_line(props)
.map(|content| content.into_hyperlink_line().line)
passive_footer_status_line(props).map(|status| {
status
.as_native()
.expect("native footer fixture")
.line
.clone()
})
} else {
None
};
@@ -1,135 +0,0 @@
//! Native and extension status content measured and painted inside the status rectangle.
use crate::line_truncation::truncate_line_with_ellipsis;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Line;
use std::borrow::Cow;
use std::sync::Arc;
use crate::render::highlight::with_syntax_theme;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::ui_tree::PreparedUiTree;
use crate::ui_tree::UiTree;
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum StatusLineFocus {
Focused,
Unfocused,
}
/// Owned status content displayed below the composer.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum FooterContent {
HyperlinkLine(Box<HyperlinkLine>),
Extension { content: Arc<PreparedUiTree> },
}
impl From<UiTree> for FooterContent {
fn from(tree: UiTree) -> Self {
Self::Extension {
content: Arc::new(tree.prepare()),
}
}
}
impl FooterContent {
/// Natural display width in terminal cells.
pub(crate) fn width(&self) -> u16 {
let width = match self {
Self::HyperlinkLine(line) => line.width(),
Self::Extension { content } => return content.measure_natural().width,
};
u16::try_from(width).unwrap_or(u16::MAX)
}
/// Paint a focused preview in its allotted rectangle.
pub(crate) fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_with_focus(area, buf, StatusLineFocus::Focused);
}
pub(in crate::bottom_pane) fn render_with_focus(
&self,
area: Rect,
buf: &mut Buffer,
focus: StatusLineFocus,
) {
match self {
Self::Extension { content } => {
let plain_style =
(focus == StatusLineFocus::Unfocused).then(|| Style::reset().dim());
with_syntax_theme(|theme| content.render(area, theme, buf, plain_style));
}
Self::HyperlinkLine(line) => render_status_row(
line,
Rect {
height: area.height.min(1),
..area
},
buf,
focus,
/*more_rows*/ false,
),
}
}
pub(crate) fn into_hyperlink_line(self) -> HyperlinkLine {
match self {
Self::HyperlinkLine(line) => *line,
Self::Extension { content } => {
with_syntax_theme(|theme| content.render_line(u16::MAX, theme))
}
}
}
}
pub(in crate::bottom_pane) fn render_status_row(
line: &HyperlinkLine,
area: Rect,
buf: &mut Buffer,
focus: StatusLineFocus,
more_rows: bool,
) {
if area.is_empty() {
return;
}
let mut line = Cow::Borrowed(line);
let width = usize::from(area.width);
if more_rows || line.width() > width {
let line = line.to_mut();
line.line = truncate_line_with_ellipsis(std::mem::take(&mut line.line), width);
line.clip_hyperlinks_to_width(line.width().saturating_sub(1));
}
if focus == StatusLineFocus::Unfocused {
let line = line.to_mut();
line.hyperlinks.clear();
line.line.style = Style::reset().dim();
for span in &mut line.line.spans {
span.style = Style::default();
}
}
line.render_fitted(area, buf);
}
impl From<HyperlinkLine> for FooterContent {
fn from(line: HyperlinkLine) -> Self {
Self::HyperlinkLine(Box::new(line))
}
}
impl From<Line<'static>> for FooterContent {
fn from(line: Line<'static>) -> Self {
Self::from(HyperlinkLine::from(line))
}
}
impl From<&'static str> for FooterContent {
fn from(text: &'static str) -> Self {
Self::from(Line::from(text))
}
}
#[cfg(test)]
#[path = "footer_content_tests.rs"]
mod tests;
+3 -3
View File
@@ -28,6 +28,7 @@ pub(crate) use composer_gap::ComposerGap;
pub(crate) use footer::footer_hint_items_line;
pub(crate) use footer::inset_footer_hint_area;
pub(crate) use status_line::StatusLine;
pub(crate) use status_line::StatusLineFocus;
use std::collections::VecDeque;
use std::path::PathBuf;
@@ -121,7 +122,6 @@ pub(crate) use approval_overlay::format_requested_permissions_rule;
pub(crate) use async_questions::AsyncQuestions;
pub(crate) use async_questions::QuestionState;
pub(crate) use async_questions::QuestionSubmission;
pub(crate) use footer::footer_content;
pub(crate) use mcp_server_elicitation::McpServerElicitationFormRequest;
pub(crate) use mcp_server_elicitation::McpServerElicitationOverlay;
pub(crate) use request_user_input::RequestUserInputOverlay;
@@ -1398,7 +1398,7 @@ impl BottomPane {
self.status_widget().is_some()
}
pub(crate) fn status_line_content(&self) -> Option<&footer_content::FooterContent> {
pub(crate) fn status_line_content(&self) -> Option<&StatusLine> {
self.composer.status_line_content()
}
@@ -2626,7 +2626,7 @@ impl BottomPane {
self.request_redraw();
}
pub(crate) fn set_status_line(&mut self, status_line: Option<footer_content::FooterContent>) {
pub(crate) fn set_status_line(&mut self, status_line: Option<StatusLine>) {
if self.composer.set_status_line(status_line) {
self.request_redraw();
}
@@ -58,7 +58,8 @@ use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
use super::footer_content::FooterContent;
use super::StatusLine;
use super::StatusLineFocus;
use super::picker_rows;
use super::picker_style;
use super::selection_popup_common::ColumnWidthConfig;
@@ -110,7 +111,7 @@ pub type CancelCallback = Box<dyn Fn(&AppEventSender) + Send + Sync>;
/// Callback to generate an optional preview line based on current item states.
/// Returns `None` to hide the preview area.
type PreviewCallback = Box<dyn Fn(&[MultiSelectItem]) -> Option<FooterContent> + Send + Sync>;
type PreviewCallback = Box<dyn Fn(&[MultiSelectItem]) -> Option<StatusLine> + Send + Sync>;
/// A single selectable item in the multi-select picker.
///
@@ -201,7 +202,7 @@ pub(crate) struct MultiSelectPicker {
preview_builder: Option<PreviewCallback>,
/// Cached preview line (updated on item changes).
preview_line: Option<FooterContent>,
preview_line: Option<StatusLine>,
/// Callback invoked when items change (toggle or reorder).
on_change: Option<ChangeCallBack>,
@@ -621,7 +622,7 @@ impl Renderable for MultiSelectPicker {
Layout::vertical([Constraint::Length(1), Constraint::Length(hint_height)])
.areas(footer_area);
let preview_area = preview_area.inset(Insets::vh(/*v*/ 0, /*h*/ 2));
preview_line.render(preview_area, buf);
preview_line.render(preview_area, buf, StatusLineFocus::Focused);
hint_area
} else {
footer_area
@@ -711,12 +712,12 @@ impl MultiSelectPickerBuilder {
where
F: Fn(&[MultiSelectItem]) -> Option<Line<'static>> + Send + Sync + 'static,
{
self.on_preview_content(move |items| callback(items).map(FooterContent::from))
self.on_preview_content(move |items| callback(items).map(StatusLine::from))
}
pub(crate) fn on_preview_content(
mut self,
callback: impl Fn(&[MultiSelectItem]) -> Option<FooterContent> + Send + Sync + 'static,
callback: impl Fn(&[MultiSelectItem]) -> Option<StatusLine> + Send + Sync + 'static,
) -> Self {
self.preview_builder = Some(Box::new(callback));
self
+176 -95
View File
@@ -1,132 +1,209 @@
//! Measure and paint status lines for the main conversation and side chats.
//! Measure and paint native and extension status lines within the caller's content rectangle.
use super::footer::inset_footer_hint_area;
use super::footer_content::FooterContent;
use super::footer_content::StatusLineFocus;
use super::footer_content::render_status_row;
use crate::line_truncation::truncate_line_with_ellipsis;
use crate::render::highlight::with_syntax_theme;
use crate::render::line_utils::line_to_static;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::project_hyperlink_columns;
use crate::ui_tree::PreparedUiTree;
use crate::ui_tree::UiTree;
use crate::width::display_width;
use crate::wrapping::adaptive_wrap_line_to_width;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::layout::Size;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use std::cell::Cell;
use std::borrow::Cow;
use std::cell::Ref;
use std::cell::RefCell;
use std::sync::Arc;
use unicode_segmentation::UnicodeSegmentation;
/// The owning composer resets this when its configured status changes.
#[derive(Default)]
pub(crate) struct StatusLine {
measured_extension: Cell<Option<(Size, u16)>>,
wrapped_native: RefCell<Option<WrappedNativeStatus>>,
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum StatusLineFocus {
Focused,
Unfocused,
}
struct WrappedNativeStatus {
bounds: Size,
content: HyperlinkLine,
rows: Vec<HyperlinkLine>,
/// A status line and the cached layout for its content.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct StatusLine(StatusContent);
#[derive(Clone, Debug, PartialEq)]
enum StatusContent {
Native(CachedLayout<Box<HyperlinkLine>, Option<Vec<HyperlinkLine>>>),
Extension(CachedLayout<Arc<PreparedUiTree>, u16>),
}
#[derive(Debug)]
struct CachedLayout<C, L> {
content: C,
layout: RefCell<Option<(Size, L)>>,
}
impl<C, L> CachedLayout<C, L> {
fn new(content: C) -> Self {
Self {
content,
layout: RefCell::default(),
}
}
fn get(&self, bounds: Size, compute: impl FnOnce(&C) -> L) -> Ref<'_, L> {
if self
.layout
.borrow()
.as_ref()
.is_none_or(|(previous, _)| *previous != bounds)
{
self.layout.replace(Some((bounds, compute(&self.content))));
}
Ref::map(self.layout.borrow(), |entry| {
let Some((_, layout)) = entry else {
unreachable!("layout cache was populated");
};
layout
})
}
}
// Cached geometry belongs to the current surface. Clones and comparisons use only the content.
impl<C: Clone, L> Clone for CachedLayout<C, L> {
fn clone(&self) -> Self {
Self::new(self.content.clone())
}
}
impl<C: PartialEq, L> PartialEq for CachedLayout<C, L> {
fn eq(&self, other: &Self) -> bool {
self.content == other.content
}
}
impl From<UiTree> for StatusLine {
fn from(tree: UiTree) -> Self {
Self(StatusContent::Extension(CachedLayout::new(Arc::new(
tree.prepare(),
))))
}
}
impl From<HyperlinkLine> for StatusLine {
fn from(line: HyperlinkLine) -> Self {
Self(StatusContent::Native(CachedLayout::new(Box::new(line))))
}
}
impl From<Line<'static>> for StatusLine {
fn from(line: Line<'static>) -> Self {
Self::from(HyperlinkLine::from(line))
}
}
impl From<&'static str> for StatusLine {
fn from(text: &'static str) -> Self {
Self::from(Line::from(text))
}
}
impl StatusLine {
pub(crate) fn height(
&self,
content: Option<&FooterContent>,
width: u16,
available: u16,
) -> u16 {
let bounds = inset_footer_hint_area(Rect::new(0, 0, width, available)).as_size();
pub(crate) fn as_native(&self) -> Option<&HyperlinkLine> {
match &self.0 {
StatusContent::Native(native) => Some(&native.content),
StatusContent::Extension(_) => None,
}
}
/// Measure within the space available for content after the caller applies padding.
pub(crate) fn height(&self, bounds: Size) -> u16 {
if bounds.width == 0 || bounds.height == 0 {
return 0;
}
match content {
Some(FooterContent::Extension { content }) => {
if let Some((previous, height)) = self.measured_extension.get()
&& previous == bounds
{
return height;
}
let height = content.measure(bounds).height;
self.measured_extension.set(Some((bounds, height)));
height
}
Some(native) => self.wrapped_rows(native, bounds).map_or_else(
|| u16::from(native.width() > 0),
match &self.0 {
StatusContent::Native(native) => wrapped_rows(native, bounds).map_or_else(
|| u16::from(native.content.width() > 0),
|rows| rows.len().max(1).min(usize::from(bounds.height)) as u16,
),
None => 0,
StatusContent::Extension(extension) => {
*extension.get(bounds, |tree| tree.measure(bounds).height)
}
}
}
pub(crate) fn render(
&self,
content: Option<&FooterContent>,
area: Rect,
buf: &mut Buffer,
focus: StatusLineFocus,
) {
let area = inset_footer_hint_area(area);
let Some(content) = content.filter(|_| !area.is_empty()) else {
/// Paint within the final content rectangle after the caller applies padding.
pub(crate) fn render(&self, area: Rect, buf: &mut Buffer, focus: StatusLineFocus) {
if area.is_empty() {
return;
};
if let Some(rows) = self.wrapped_rows(content, area.as_size()) {
let height = usize::from(area.height);
for (index, line) in rows.iter().take(height).enumerate() {
let row = Rect::new(area.x, area.y + index as u16, area.width, 1);
render_status_row(
line,
row,
buf,
focus,
index + 1 == height && rows.len() > height,
);
}
match &self.0 {
StatusContent::Native(native) => {
let wrapped = wrapped_rows(native, area.as_size());
let rows = wrapped
.as_deref()
.unwrap_or_else(|| std::slice::from_ref(native.content.as_ref()));
let more_rows = rows.len() > usize::from(area.height);
for (row, line) in area.rows().zip(rows) {
render_status_row(
line,
row,
buf,
focus,
more_rows && row.bottom() == area.bottom(),
);
}
}
StatusContent::Extension(extension) => {
let plain_style =
(focus == StatusLineFocus::Unfocused).then(|| Style::reset().dim());
with_syntax_theme(|theme| extension.content.render(area, theme, buf, plain_style));
}
} else {
content.render_with_focus(area, buf, focus);
}
}
}
fn wrapped_rows(
&self,
content: &FooterContent,
bounds: Size,
) -> Option<Ref<'_, [HyperlinkLine]>> {
let FooterContent::HyperlinkLine(line) = content else {
return None;
};
if bounds.height <= 1 {
return None;
}
// Reuse the same wrapped prefix across small height changes.
let bounds = Size {
height: bounds.height.max(4),
..bounds
};
let line = line.as_ref();
let cache = &self.wrapped_native;
if cache
.borrow()
.as_ref()
.is_none_or(|entry| entry.bounds != bounds || &entry.content != line)
{
if line.width() <= usize::from(bounds.width) {
return None;
}
cache.replace(Some(WrappedNativeStatus {
bounds,
content: line.clone(),
rows: wrap_rows(line, bounds),
}));
}
Ref::filter_map(cache.borrow(), |entry| {
entry.as_ref().map(|entry| entry.rows.as_slice())
})
.ok()
fn wrapped_rows(
native: &CachedLayout<Box<HyperlinkLine>, Option<Vec<HyperlinkLine>>>,
bounds: Size,
) -> Option<Ref<'_, [HyperlinkLine]>> {
if bounds.height <= 1 {
return None;
}
// Reuse the same wrapped prefix across small height changes.
let bounds = Size {
height: bounds.height.max(4),
..bounds
};
let cached = native.get(bounds, |line| {
(line.width() > usize::from(bounds.width)).then(|| wrap_rows(line, bounds))
});
Ref::filter_map(cached, Option::as_deref).ok()
}
fn render_status_row(
line: &HyperlinkLine,
area: Rect,
buf: &mut Buffer,
focus: StatusLineFocus,
more_rows: bool,
) {
let mut line = Cow::Borrowed(line);
let width = usize::from(area.width);
if more_rows || line.width() > width {
let line = line.to_mut();
line.line = truncate_line_with_ellipsis(std::mem::take(&mut line.line), width);
line.clip_hyperlinks_to_width(line.width().saturating_sub(1));
}
if focus == StatusLineFocus::Unfocused {
let line = line.to_mut();
line.hyperlinks.clear();
line.line.style = Style::reset().dim();
for span in &mut line.line.spans {
span.style = Style::default();
}
}
line.render_fitted(area, buf);
}
fn wrap_rows(status: &HyperlinkLine, bounds: Size) -> Vec<HyperlinkLine> {
@@ -185,3 +262,7 @@ fn wrap_rows(status: &HyperlinkLine, bounds: Size) -> Vec<HyperlinkLine> {
})
.collect()
}
#[cfg(test)]
#[path = "status_line_tests.rs"]
mod tests;
@@ -357,13 +357,15 @@ impl StatusLineSetupView {
let use_theme_colors = items
.iter()
.any(|item| item.id == STATUS_LINE_USE_THEME_COLORS_ITEM_ID && item.enabled);
preview_data.status_line_for_items(
items
.iter()
.filter(|item| item.enabled)
.filter_map(|item| item.id.parse::<StatusLineItem>().ok()),
use_theme_colors,
)
preview_data
.status_line_for_items(
items
.iter()
.filter(|item| item.enabled)
.filter_map(|item| item.id.parse::<StatusLineItem>().ok()),
use_theme_colors,
)
.map(Into::into)
})
.on_confirm(|ids, app_event| {
let use_theme_colors = ids
@@ -449,7 +451,7 @@ impl Renderable for StatusLineSetupView {
mod tests {
use super::*;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::footer_content::FooterContent;
use crate::terminal_hyperlinks::HyperlinkLine;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
@@ -840,7 +842,7 @@ mod tests {
.join("\n")
}
fn line_text(content: Option<FooterContent>) -> Option<String> {
content.map(|content| content.into_hyperlink_line().line.to_string())
fn line_text(content: Option<HyperlinkLine>) -> Option<String> {
content.map(|line| line.line.to_string())
}
}
@@ -1,6 +1,7 @@
#![cfg(test)]
use super::FooterContent;
use super::StatusLine;
use super::StatusLineFocus;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::osc8_hyperlink;
use pretty_assertions::assert_eq;
@@ -20,7 +21,7 @@ fn clipped_links_keep_their_cells_after_a_halfwidth_katakana_grapheme() {
/*x*/ 0, /*y*/ 0, /*width*/ 10, /*height*/ 1,
);
let mut buf = Buffer::empty(area);
FooterContent::from(line).render(area, &mut buf);
StatusLine::from(line).render(area, &mut buf, StatusLineFocus::Focused);
assert_eq!(buf[(0, 0)].symbol(), osc8_hyperlink(project_url, "ガ"));
for (x, ch) in (2..5).zip(" · ".chars()) {
assert_eq!(buf[(x, 0)].symbol(), ch.to_string());
@@ -40,12 +41,12 @@ fn arabic_prefix_keeps_link_on_the_painted_cells_at_exact_fit_and_overflow() {
let mut line = HyperlinkLine::from("لا · ");
line.push_span("PR #42".into(), Some(url));
assert_eq!(line.width(), 11);
let content = FooterContent::from(line);
let content = StatusLine::from(line);
for (width, visible_link) in [(11, "PR #42"), (10, "PR #")] {
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 1);
let mut buf = Buffer::empty(area);
content.render(area, &mut buf);
content.render(area, &mut buf, StatusLineFocus::Focused);
assert_eq!(buf[(4, 0)].symbol(), " ");
for (x, ch) in (5..width).zip(visible_link.chars()) {
assert_eq!(buf[(x, 0)].symbol(), osc8_hyperlink(url, &ch.to_string()));
@@ -57,7 +58,7 @@ fn arabic_prefix_keeps_link_on_the_painted_cells_at_exact_fit_and_overflow() {
}
#[test]
fn prepared_tree_uses_its_natural_width_and_paints_only_the_footer_viewport() {
fn prepared_tree_paints_only_the_footer_viewport() {
use crate::terminal_hyperlinks::strip_osc8;
use crate::ui_tree::UiTree;
use ratatui::buffer::Cell;
@@ -69,8 +70,7 @@ fn prepared_tree_uses_its_natural_width_and_paints_only_the_footer_viewport() {
.to_string()
.parse()
.unwrap();
let prepared = FooterContent::from(tree);
assert_eq!(prepared.width(), 11);
let prepared = StatusLine::from(tree);
let area = Rect::new(
/*x*/ 1, /*y*/ 1, /*width*/ 6, /*height*/ 4,
@@ -81,7 +81,7 @@ fn prepared_tree_uses_its_natural_width_and_paints_only_the_footer_viewport() {
),
Cell::new("."),
);
prepared.render(area, &mut buffer);
prepared.render(area, &mut buffer, StatusLineFocus::Focused);
let rows: Vec<String> = buffer
.content
.chunks(8)
@@ -1,6 +1,6 @@
use std::collections::BTreeMap;
use super::footer_content::FooterContent;
use crate::terminal_hyperlinks::HyperlinkLine;
use codex_protocol::ThreadId;
use super::status_line_from_segments;
@@ -248,7 +248,7 @@ impl StatusSurfacePreviewData {
&self,
items: I,
use_theme_colors: bool,
) -> Option<FooterContent>
) -> Option<HyperlinkLine>
where
I: IntoIterator<Item = StatusLineItem>,
{
@@ -263,7 +263,6 @@ impl StatusSurfacePreviewData {
self.live_value_for(StatusSurfacePreviewItem::PullRequestNumber)
.and(self.pull_request_url.as_deref()),
)
.map(FooterContent::from)
}
}
+8 -6
View File
@@ -2122,14 +2122,16 @@ impl ChatWidget {
#[cfg(test)]
pub(crate) fn status_line_text(&self) -> Option<String> {
self.status_line_content()
.cloned()
.map(|content| content.into_hyperlink_line().line.to_string())
self.status_line_content().map(|status| {
status
.as_native()
.expect("native status line")
.line
.to_string()
})
}
pub(crate) fn status_line_content(
&self,
) -> Option<&crate::bottom_pane::footer_content::FooterContent> {
pub(crate) fn status_line_content(&self) -> Option<&crate::bottom_pane::StatusLine> {
self.bottom_pane.status_line_content()
}
@@ -5,7 +5,7 @@
//! history-facing `/status` surface.
use super::*;
use crate::bottom_pane::footer_content::FooterContent;
use crate::bottom_pane::StatusLine;
use crate::status::thread_cost_micros;
impl ChatWidget {
@@ -84,7 +84,7 @@ impl ChatWidget {
}
/// Sets the currently rendered footer status-line value.
pub(crate) fn set_status_line(&mut self, status_line: Option<FooterContent>) {
pub(crate) fn set_status_line(&mut self, status_line: Option<StatusLine>) {
self.bottom_pane.set_status_line(status_line);
}
@@ -3,14 +3,6 @@ use crate::bottom_pane::preview_line_for_title_items;
use crate::chatwidget::ThreadUsageOutcome;
use codex_app_server_protocol::ThreadUsage;
use pretty_assertions::assert_eq;
use ratatui::text::Line;
fn line_text(line: Line<'static>) -> String {
line.spans
.into_iter()
.map(|span| span.content.into_owned())
.collect()
}
#[tokio::test]
async fn thread_color_preview_matches_footer() {
@@ -31,7 +23,6 @@ async fn thread_color_preview_matches_footer() {
let line = preview
.status_line_for_items(items, use_colors)
.unwrap()
.into_hyperlink_line()
.line;
let footer = crate::bottom_pane::status_line_from_segments(
items
@@ -54,7 +45,7 @@ fn status_preview_line_option(chat: &mut ChatWidget, items: &[StatusLineItem]) -
let preview_data = chat.status_surface_preview_data();
preview_data
.status_line_for_items(items.iter().copied(), /*use_theme_colors*/ true)
.map(|content| line_text(content.into_hyperlink_line().line))
.map(|line| line.line.to_string())
}
fn status_preview_line(chat: &mut ChatWidget, items: &[StatusLineItem]) -> String {
@@ -65,7 +56,7 @@ fn title_preview_line(chat: &mut ChatWidget, items: &[TerminalTitleItem]) -> Str
let preview_data = chat.terminal_title_preview_data();
let preview =
preview_line_for_title_items(items, &preview_data).expect("terminal title preview line");
line_text(preview)
preview.to_string()
}
fn combined_preview_snapshot(
-46
View File
@@ -14,16 +14,12 @@ use super::TextProps;
use super::TextWeight;
use super::UiTree;
use super::theme::RenderTheme;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::TerminalHyperlink;
use crate::terminal_hyperlinks::mark_hyperlink_cells;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::layout::Size;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Span;
use std::ops::Range;
use syntect::highlighting::Theme;
use vtparse::CollectingVTActor;
use vtparse::VTAction;
@@ -42,13 +38,6 @@ impl UiTree {
}
impl PreparedUiTree {
pub(crate) fn measure_natural(&self) -> Size {
self.0.as_ref().map_or_else(Size::default, |node| {
let width = u16::try_from(node.widths.preferred).unwrap_or(u16::MAX);
Size::new(width, node.natural_height)
})
}
pub(crate) fn measure(&self, bounds: Size) -> Size {
layout(self.0.as_ref(), bounds, /*draw*/ None)
}
@@ -83,29 +72,6 @@ impl PreparedUiTree {
}),
);
}
pub(crate) fn render_line(&self, width: u16, theme: &Theme) -> HyperlinkLine {
let natural = self.measure_natural();
if width == 1 && (natural.width > 1 || natural.height > 1) {
return ratatui::text::Line::from("…").into();
}
let mut line = HyperlinkLine::default();
let mut theme = RenderTheme::new(theme);
layout(
self.0.as_ref(),
Size::new(width, /*height*/ 1),
Some(&mut |_, col, text, cells, paint| {
line.line
.push_span(Span::styled(text.to_owned(), paint.style(&mut theme)));
if cells > 0
&& let Some(href) = &paint.href
{
push_link(&mut line, col..col + cells, href);
}
}),
);
line
}
}
#[derive(Clone, Copy)]
@@ -332,18 +298,6 @@ fn sanitize_leaf(mut raw: String) -> String {
.collect()
}
fn push_link(line: &mut HyperlinkLine, columns: Range<usize>, href: &str) {
if let Some(last) = line.hyperlinks.last_mut()
&& last.columns.end == columns.start
&& last.destination == href
{
last.columns.end = columns.end;
} else {
line.hyperlinks
.push(TerminalHyperlink::web(columns, href.to_owned()));
}
}
impl TextProps {
fn apply_to(&self, paint: &mut Paint) {
if let Some(color) = self.color {
@@ -10,7 +10,6 @@ use crate::ui_tree::UiNode;
pub(super) struct PreparedNode {
pub(super) kind: NodeKind,
pub(super) widths: Widths,
pub(super) natural_height: u16,
}
#[derive(Debug, PartialEq)]
@@ -62,7 +61,6 @@ impl PreparedNode {
widths.preferred = widths.preferred.max(width);
}
Some(Self {
natural_height: u16::try_from(text.lines.len()).unwrap_or(u16::MAX),
kind: NodeKind::Text(text),
widths,
})
@@ -97,11 +95,6 @@ impl PreparedNode {
};
Some(Self {
widths,
natural_height: children
.iter()
.map(|child| child.natural_height)
.max()
.unwrap_or_default(),
kind: NodeKind::Row {
children,
gap,
+94 -149
View File
@@ -34,12 +34,7 @@ fn elements_round_trip_and_require_explicit_row_spacing() {
}
for (gap, expected) in [(0, "AB"), (1, "A B"), (2, "A B"), (MAX_GAP, "A …")] {
let row = tree(json!({"type":"row","props":{"gap":gap},"children":["A","B"]}));
assert_eq!(
row.render_line(/*width*/ 4, &Theme::default())
.line
.to_string(),
expected
);
assert_eq!(painted_rows(&row, Size::new(4, 1)), [expected]);
}
}
@@ -104,8 +99,12 @@ fn links_only_annotate_their_own_cells() {
/*x*/ 0, /*y*/ 0, /*width*/ 16, /*height*/ 1,
);
let mut buffer = Buffer::empty(area);
tree.render_line(area.width, &Theme::default())
.render_fitted(area, &mut buffer);
tree.render(
area,
&Theme::default(),
&mut buffer,
/*plain_style*/ None,
);
assert_eq!(buffer[(0, 0)].symbol(), "界");
assert!(
buffer[(3, 0)]
@@ -113,86 +112,43 @@ fn links_only_annotate_their_own_cells() {
.contains("https://example.com/pr/42")
);
insta::assert_snapshot!(format!("{buffer:?}"));
assert_eq!(
tree.render_line(/*width*/ 1, &Theme::default())
.line
.to_string(),
"…"
);
let narrow = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 6, /*height*/ 2,
);
let mut buffer = Buffer::empty(narrow);
tree.render_line(narrow.width, &Theme::default())
.render_fitted(narrow, &mut buffer);
assert!(
buffer[(4, 0)]
.symbol()
.contains("https://example.com/pr/42")
);
assert_eq!(buffer[(5, 0)].symbol(), "…");
assert!((0..narrow.width).all(|x| buffer[(x, 1)].symbol() == " "));
}
#[test]
fn render_line_keeps_exactly_fitting_halfwidth_voiced_katakana() {
let text = tree(json!("ガ"));
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 2, /*height*/ 1,
);
let mut buffer = Buffer::empty(area);
text.render_line(area.width, &Theme::default())
.render_fitted(area, &mut buffer);
assert_eq!(buffer[(0, 0)].symbol(), "ガ");
fn exactly_fitting_halfwidth_voiced_katakana_is_visible() {
assert_eq!(painted_rows(&tree(json!("ガ")), Size::new(2, 1)), ["ガ"]);
}
#[test]
fn ellipsis_counts_toward_width_only_when_there_is_overflow() {
let row = tree(json!({"type":"row","props":{"gap":1},"children":["abcde", null]}));
for (width, expected) in [(0, ""), (1, "…"), (4, "abc…"), (5, "abcde")] {
let line = row.render_line(width, &Theme::default());
assert_eq!(line.line.to_string(), expected);
assert!(line.width() <= usize::from(width));
for (width, expected) in [(1, "…"), (4, "abc…"), (5, "abcde")] {
assert_eq!(painted_rows(&row, Size::new(width, 1)), [expected]);
}
let wide = tree(json!("a界界"));
assert_eq!(
wide.render_line(/*width*/ 4, &Theme::default())
.line
.to_string(),
"a界…"
);
let long = "x".repeat(513);
assert_eq!(
tree(json!(&long))
.render_line(/*width*/ 513, &Theme::default())
.line
.to_string(),
long
);
assert_eq!(painted_rows(&wide, Size::new(4, 1)), ["a界…"]);
}
#[test]
fn zero_width_fragments_do_not_style_the_ellipsis_or_add_row_gaps() {
let row = |text| json!({"type":"row","props":{"gap":0},"children":[text]});
let mark = json!({"type":"bold","children":["\u{301}"]});
for (mut root, width) in [
(json!({"type":"text"}), 2),
(json!({"type":"row","props":{"gap":1}}), 4),
let children = json!([row("x"), {"type":"bold","children":["\u{301}"]}, row("yz")]);
for (root, expected) in [
(json!({"type":"text","children":children}), "x…"),
(
json!({"type":"row","props":{"gap":1},"children":children}),
"x yz",
),
(
json!({"type":"text","children":["A", {"type":"bold","children":["\u{ad}BB"]}]}),
"A…",
),
(
json!({"type":"text","children":[{"type":"bold","children":["\u{ad}"]}, "AB"]}),
"…",
),
] {
root["children"] = json!([row("x"), mark, row("yz")]);
let node = tree(root.clone());
root["children"] = json!([row("x"), row("yz")]);
assert_eq!(
node.render_line(width, &Theme::default()),
tree(root).render_line(width, &Theme::default())
);
}
for (children, expected) in [
(json!(["A", {"type":"bold","children":["\u{ad}BB"]}]), "A…"),
(json!([{"type":"bold","children":["\u{ad}"]}, "AB"]), "…"),
] {
let node = tree(json!({"type":"text","children":children}));
let node = tree(root);
let expected = Buffer::with_lines([expected]);
let mut buf = Buffer::empty(expected.area);
node.render(
@@ -208,18 +164,12 @@ fn zero_width_fragments_do_not_style_the_ellipsis_or_add_row_gaps() {
#[test]
fn single_line_view_marks_vertical_overflow() {
let invisible = tree(json!({"type":"bold","children":["\u{ad}\na"]}));
let clipped = invisible.render_line(/*width*/ 1, &Theme::default());
assert_eq!(clipped.line.to_string(), "…");
assert_eq!(painted_rows(&invisible, Size::new(1, 1)), ["…"]);
let multiline = format!("\u{1b}[2Jok\n{}", "x".repeat(1024));
let tree = tree(json!({"type":"row","props":{"gap":0},"children":[
&multiline, "!"
]}));
assert_eq!(
tree.render_line(/*width*/ 4, &Theme::default())
.line
.to_string(),
"ok …"
);
assert_eq!(painted_rows(&tree, Size::new(4, 1)), ["ok …"]);
}
#[test]
@@ -227,10 +177,8 @@ fn directly_constructed_text_keeps_a_bounded_utf8_prefix() {
let prefix = "x".repeat(MAX_TEXT_BYTES - 1);
let text = UiTree(Some(UiNode::String(format!("{prefix}界tail")))).prepare();
assert_eq!(
text.render_line(u16::MAX, &Theme::default())
.line
.to_string(),
prefix
painted_rows(&text, Size::new(MAX_TEXT_BYTES as u16, 1)),
[prefix]
);
}
@@ -288,21 +236,28 @@ fn inline_modifiers_and_row_surfaces_follow_the_tree() {
{"type":"link","props":{"href":"https://example.com"},"children":["f"]}
]
}));
let clipped = node.render_line(/*width*/ 6, &Theme::default());
assert_eq!(
clipped.line.spans.last().unwrap(),
&ratatui::text::Span::styled(
"…",
ratatui::style::Style::default().bg(ratatui::style::Color::Blue),
)
);
let line = node.render_line(/*width*/ 12, &Theme::default());
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 12, /*height*/ 1,
);
let mut buffer = Buffer::empty(area);
line.render_fitted(area, &mut buffer);
node.render(
area,
&Theme::default(),
&mut buffer,
/*plain_style*/ None,
);
insta::assert_snapshot!(format!("{buffer:?}"));
let clipped = Rect { width: 6, ..area };
let mut buffer = Buffer::empty(clipped);
node.render(
clipped,
&Theme::default(),
&mut buffer,
/*plain_style*/ None,
);
assert_eq!(buffer[(5, 0)].symbol(), "…");
assert_eq!(buffer[(5, 0)].bg, ratatui::style::Color::Blue);
}
#[test]
@@ -318,18 +273,27 @@ fn hex_colors_use_the_terminal_palette_for_both_channels() {
bg: (0, 0, 0),
},
|| {
let line = tree.render_line(/*width*/ 3, &Theme::default());
let colors: Vec<_> = line
.line
.spans
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 3, /*height*/ 1,
);
let mut buffer = Buffer::empty(area);
tree.render(
area,
&Theme::default(),
&mut buffer,
/*plain_style*/ None,
);
let colors: Vec<_> = buffer
.content
.iter()
.map(|s| (s.style.fg, s.style.bg))
.map(|cell| (cell.fg, cell.bg))
.collect();
let surface = Some(crate::terminal_palette::rgb_color((16, 32, 240)));
let foreground = Some(crate::terminal_palette::rgb_color((171, 18, 240)));
let surface = crate::terminal_palette::rgb_color((16, 32, 240));
let foreground = crate::terminal_palette::rgb_color((171, 18, 240));
let reset = ratatui::style::Color::Reset;
assert_eq!(
colors,
[(foreground, surface), (None, surface), (None, surface)]
[(foreground, surface), (reset, surface), (reset, surface)]
);
},
);
@@ -439,22 +403,16 @@ fn terminal_commands_are_sanitized_within_each_string() {
"\u{1b}[31mre\rd\u{1b}[0m \u{1b}]8;;https://example.com\u{7}label\u{1b}]8;;\u{1b}\\\u{7}",
" \u{1b}[31", {"type":"link","props":{"href":url},"children":["Ready"]}
]}));
let line = node.render_line(/*width*/ 40, &Theme::default());
assert_eq!(
line.hyperlinks,
[crate::terminal_hyperlinks::TerminalHyperlink::web(
10..15,
url.to_owned()
)]
painted_rows(&node, Size::new(40, 1)),
[linked_columns("red label Ready", 10..15, url)]
);
insta::assert_snapshot!(line.line.to_string(), @"red label Ready");
}
#[test]
fn split_graphemes_use_the_first_style_and_require_unanimous_links() {
use crate::terminal_hyperlinks::TerminalHyperlink;
use ratatui::style::Stylize;
use ratatui::text::Span;
use crate::terminal_hyperlinks::osc8_hyperlink;
use ratatui::style::Modifier;
let url = "https://example.com/a";
let link = |href, text| json!({"type":"link","props":{"href":href},"children":[text]});
@@ -465,44 +423,42 @@ fn split_graphemes_use_the_first_style_and_require_unanimous_links() {
{"type":"bold","children":["\u{600}"]}, " x",
link(url, "तक्क"), "\u{301}"
]}));
let line = node.render_line(/*width*/ 20, &Theme::default());
assert_eq!(
line.line.spans,
[
Span::raw("R").bold(),
Span::raw("e\u{301}").bold(),
Span::raw("👩‍💻").underlined(),
Span::raw("👩‍💻").underlined(),
Span::raw("\u{600} ").bold(),
Span::raw("x"),
Span::raw("त").underlined(),
Span::raw("क्क\u{301}").underlined()
]
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 1,
);
assert_eq!(
line.hyperlinks,
[
TerminalHyperlink::web(2..4, url.to_owned()),
TerminalHyperlink::web(9..10, url.to_owned())
]
let mut buffer = Buffer::empty(area);
node.render(
area,
&Theme::default(),
&mut buffer,
/*plain_style*/ None,
);
for (x, symbol, modifier) in [
(0, "R".to_owned(), Modifier::BOLD),
(1, "e\u{301}".to_owned(), Modifier::BOLD),
(2, osc8_hyperlink(url, "👩‍💻"), Modifier::UNDERLINED),
(4, "👩‍💻".to_owned(), Modifier::UNDERLINED),
(6, "\u{600} ".to_owned(), Modifier::BOLD),
(8, "x".to_owned(), Modifier::empty()),
(9, osc8_hyperlink(url, "त"), Modifier::UNDERLINED),
(10, "क्क\u{301}".to_owned(), Modifier::UNDERLINED),
] {
let cell = &buffer[(x, 0)];
assert_eq!((cell.symbol(), cell.modifier), (symbol.as_str(), modifier));
}
}
#[test]
fn collapsed_space_keeps_the_first_whitespace_owners_link() {
use crate::terminal_hyperlinks::TerminalHyperlink;
let href = "https://example.com/build";
let node = tree(json!({"type":"text","children":[
"a", {"type":"link","props":{"href":href},"children":["\t"]},
"\u{1b}[31m", " b", {"type":"link","props":{"href":href},"children":[" "]},
{"type":"row","props":{"gap":0},"children":[" c "]}, " d"
]}));
let line = node.render_line(/*width*/ 10, &Theme::default());
assert_eq!(line.line.to_string(), "a bcd");
assert_eq!(
line.hyperlinks,
[TerminalHyperlink::web(1..2, href.to_owned())]
painted_rows(&node, Size::new(10, 1)),
[linked_columns("a bcd", 1..2, href)]
);
}
@@ -539,18 +495,13 @@ fn linked_columns(text: &str, columns: std::ops::Range<usize>, href: &str) -> St
}
#[test]
fn measures_natural_and_viewport_sizes_in_cells() {
fn measures_viewport_sizes_in_cells() {
let node =
tree(json!({"type":"row","props":{"gap":1,"wrap":"wrap"},"children":["ab\ncd","界界"]}));
assert_eq!(node.measure_natural(), Size::new(7, 2));
assert_eq!(node.measure(Size::new(4, 8)), Size::new(4, 3));
let wide = tree(json!("a界\nnext"));
assert_eq!(wide.measure(Size::new(3, 1)), Size::new(2, 1));
assert_eq!(painted_rows(&wide, Size::new(3, 1)), ["a…"]);
let huge =
tree(json!({"type":"row","props":{"gap":MAX_GAP,"wrap":"wrap"},"children":["x","y"]}));
assert_eq!(huge.measure_natural(), Size::new(u16::MAX, 1));
assert_eq!(tree(Value::Null).measure_natural(), Size::default());
}
#[test]
@@ -684,12 +635,6 @@ fn authored_line_breaks_and_collapsed_whitespace_preserve_styles_and_links() {
"{buffer:?}"
)));
assert_eq!(buffer[(6, 3)].symbol(), "…");
assert_eq!(
node.render_line(/*width*/ 20, &Theme::default())
.line
.to_string(),
"ready now…"
);
}
#[test]