mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
Wrap built-in TUI status lines within the footer row budget (#60682)
## Why Long built-in status lines were truncated to a single row even when the footer had room for more content. ## What changed - Wrap status text to the available width and row budget, preserving styles and hyperlink targets across rows. - Show an unlinked ellipsis when content exceeds the available space. - Refresh wrapped content when the status text, agent label, or width changes. - Keep reasoning-effort transitions on one row and restore wrapping after the final animation frame. ## Testing Add coverage for wrapping and hyperlink boundaries across footer layouts, content and agent-label updates, resizing, and wrapping restoration after an effort transition. Update transcript and startup snapshots for multiline status text. GitOrigin-RevId: 30e0795c473efd62fc4d0dca4deb6029b625a9e8
This commit is contained in:
@@ -503,6 +503,8 @@ async fn owned_drag_stops_when_focus_or_input_ownership_is_lost() -> Result<()>
|
||||
async fn owned_details_keep_the_composer_cursor_and_screen() -> Result<()> {
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
attach_thread(&mut app, ThreadId::new());
|
||||
app.chat_widget
|
||||
.set_status_line(Some("gpt-test default · /tmp/project".into()));
|
||||
app.transcript_cells = vec![user_cell("First prompt"), user_cell("Second prompt")];
|
||||
app.chat_widget
|
||||
.apply_external_edit("preserved draft".to_string());
|
||||
@@ -647,8 +649,7 @@ async fn owned_details_keep_the_composer_cursor_and_screen() -> Result<()> {
|
||||
app.transcript_view.link_at(column, row).as_deref(),
|
||||
Some("https://example.com/docs")
|
||||
);
|
||||
let copied_cursor = tui.terminal.last_known_cursor_pos;
|
||||
assert_eq!((copied_cursor.x, copied_cursor.y + 1), (caret.x, caret.y));
|
||||
assert_eq!(tui.terminal.last_known_cursor_pos, caret);
|
||||
let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal);
|
||||
for pos in Rect::from(outer)
|
||||
.positions()
|
||||
|
||||
+2
-2
@@ -33,7 +33,6 @@ transcript row 39
|
||||
tab to queue mes
|
||||
|
||||
6 columns, 18 rows
|
||||
ript
|
||||
row 38
|
||||
transc
|
||||
ript
|
||||
@@ -49,7 +48,8 @@ row 39
|
||||
her
|
||||
e
|
||||
|
||||
GPT…
|
||||
GPT-
|
||||
5.6…
|
||||
tab
|
||||
|
||||
80 columns, 6 rows
|
||||
|
||||
+2
-2
@@ -4,7 +4,6 @@ expression: normalize_snapshot_paths(screen)
|
||||
---
|
||||
····································
|
||||
····································
|
||||
····· ·····
|
||||
·····› Second prompt ·····
|
||||
····· ·····
|
||||
····· ·····
|
||||
@@ -17,6 +16,7 @@ expression: normalize_snapshot_paths(screen)
|
||||
····· ·····
|
||||
·····› preserved draft ·····
|
||||
····· ·····
|
||||
····· gpt-test default · /tmp…·····
|
||||
····· gpt-test default · /tmp/·····
|
||||
····· project ·····
|
||||
····································
|
||||
····································
|
||||
|
||||
+2
-2
@@ -40,8 +40,8 @@ expression: "normalize_snapshot_paths(normalize_agent_center_snapshot(snapshots.
|
||||
|
||||
|
||||
|
||||
|
||||
› Ask Codex to do anything
|
||||
|
||||
GPT-5.6-Sol default · /tmp/pr…
|
||||
GPT-5.6-Sol default · /tmp/
|
||||
project
|
||||
? for shortcuts
|
||||
|
||||
@@ -41,6 +41,8 @@ async fn owned_startup_hides_tip_in_transcript() -> Result<()> {
|
||||
];
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
tui.set_owned_screen(/*owned*/ true)?;
|
||||
app.chat_widget
|
||||
.set_status_line(Some("GPT-5.6-Sol default · /tmp/project".into()));
|
||||
let mut snapshots = Vec::new();
|
||||
for width in [80, 32] {
|
||||
let size = Size::new(width, /*height*/ 20);
|
||||
|
||||
@@ -248,6 +248,8 @@ async fn turn_tip_placements_and_completion_barrier() -> Result<()> {
|
||||
}
|
||||
}
|
||||
assert_eq!(saw_barrier, !working);
|
||||
app.chat_widget
|
||||
.set_status_line(Some("gpt-test default · /tmp/project".into()));
|
||||
let frames = tui.frame_requester();
|
||||
let mut painted = Default::default();
|
||||
tui.draw(height, |frame| {
|
||||
|
||||
@@ -414,6 +414,7 @@ mod paste_input;
|
||||
mod popup_state;
|
||||
mod reconnect;
|
||||
pub(crate) use reconnect::RestrictedInputMode;
|
||||
mod native_status;
|
||||
mod slash_input;
|
||||
mod sparkle;
|
||||
mod status_surface;
|
||||
@@ -796,6 +797,7 @@ impl ChatComposer {
|
||||
ide_context_active: false,
|
||||
status_line_value: None,
|
||||
measured_status_line: Default::default(),
|
||||
wrapped_native_status: Default::default(),
|
||||
status_line_enabled: false,
|
||||
side_conversation_context_label: None,
|
||||
active_agent_label: None,
|
||||
|
||||
@@ -413,6 +413,8 @@ pub(super) struct FooterState {
|
||||
pub(super) status_line_value: Option<FooterContent>,
|
||||
// Measurement bounds and occupied height, shared by layout and cursor placement.
|
||||
pub(super) measured_status_line: std::cell::Cell<Option<(ratatui::layout::Size, u16)>>,
|
||||
pub(super) wrapped_native_status:
|
||||
std::cell::RefCell<Option<super::native_status::WrappedNativeStatus>>,
|
||||
pub(super) status_line_enabled: bool,
|
||||
pub(super) side_conversation_context_label: Option<String>,
|
||||
pub(super) active_agent_label: Option<String>,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Wrap the built-in status line using its painted grapheme widths and native hyperlink ranges.
|
||||
//! Measurement and paint share the visible rows at the current width.
|
||||
|
||||
use super::ChatComposer;
|
||||
use super::FooterContent;
|
||||
use super::composer_layout::MAX_FOOTER_ROWS;
|
||||
use crate::render::line_utils::line_to_static;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::TerminalHyperlink;
|
||||
use crate::terminal_hyperlinks::project_hyperlink_columns;
|
||||
use crate::width::display_width;
|
||||
use crate::wrapping::adaptive_wrap_line_to_width;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use std::cell::Ref;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
pub(super) struct WrappedNativeStatus {
|
||||
width: u16,
|
||||
suffix: Vec<Span<'static>>,
|
||||
rows: Vec<HyperlinkLine>,
|
||||
}
|
||||
|
||||
impl WrappedNativeStatus {
|
||||
pub(super) fn height(&self) -> u16 {
|
||||
self.rows.len().max(1) as u16
|
||||
}
|
||||
|
||||
pub(super) fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let height = usize::from(area.height);
|
||||
for (index, line) in self.rows.iter().take(height).enumerate() {
|
||||
let row = Rect::new(area.x, area.y + index as u16, area.width, 1);
|
||||
if (index + 1 == height && self.rows.len() > height)
|
||||
|| line.width() > usize::from(area.width)
|
||||
{
|
||||
line.clone().render_with_ellipsis(row, buf);
|
||||
} else {
|
||||
line.render_fitted(row, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatComposer {
|
||||
pub(super) fn wrapped_native_status(
|
||||
&self,
|
||||
content: &FooterContent,
|
||||
width: u16,
|
||||
) -> Option<Ref<'_, WrappedNativeStatus>> {
|
||||
let (line, links) = content.native_parts()?;
|
||||
if width == 0 {
|
||||
return None;
|
||||
}
|
||||
// The setter invalidates configured spans. The styled agent suffix can change independently.
|
||||
let configured = self
|
||||
.footer
|
||||
.status_line_value
|
||||
.as_ref()
|
||||
.and_then(FooterContent::native_parts)
|
||||
.map_or(0, |(line, _)| line.spans.len());
|
||||
let suffix = &line.spans[configured..];
|
||||
let cache = &self.footer.wrapped_native_status;
|
||||
if cache
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.is_none_or(|entry| entry.width != width || entry.suffix != suffix)
|
||||
{
|
||||
if content.width() <= width {
|
||||
return None;
|
||||
}
|
||||
cache.replace(Some(WrappedNativeStatus {
|
||||
width,
|
||||
suffix: suffix.to_vec(),
|
||||
rows: wrap_rows(line, links, width),
|
||||
}));
|
||||
}
|
||||
Ref::filter_map(cache.borrow(), Option::as_ref).ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_rows(line: &Line<'_>, links: &[TerminalHyperlink], width: u16) -> Vec<HyperlinkLine> {
|
||||
// One row of lookahead settles the last visible break. Whitespace discarded by
|
||||
// wrapping cannot count toward that budget or it could hide a later word.
|
||||
let limit = usize::from(MAX_FOOTER_ROWS) + 1;
|
||||
let mut remaining = usize::from(width) * limit;
|
||||
let mut prefix = Line::default().style(line.style);
|
||||
for span in &line.spans {
|
||||
let mut end = 0;
|
||||
for grapheme in span.content.graphemes(true) {
|
||||
end += grapheme.len();
|
||||
if !grapheme.chars().all(char::is_whitespace) {
|
||||
remaining =
|
||||
remaining.saturating_sub(display_width(grapheme).min(usize::from(width)));
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
prefix.push_span(Span::styled(&span.content[..end], span.style));
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Native link columns are counted per grapheme within each painted span.
|
||||
let mut graphemes = prefix
|
||||
.spans
|
||||
.iter()
|
||||
.flat_map(|span| span.content.graphemes(true));
|
||||
let (mut byte, mut column) = (0, 0);
|
||||
let mut column_at = |target| {
|
||||
while byte < target
|
||||
&& let Some(grapheme) = graphemes.next()
|
||||
{
|
||||
byte += grapheme.len();
|
||||
column += display_width(grapheme);
|
||||
}
|
||||
column
|
||||
};
|
||||
adaptive_wrap_line_to_width(&prefix, usize::from(width).into())
|
||||
.into_iter()
|
||||
.take(limit)
|
||||
.map(|row| {
|
||||
let source = column_at(row.range.start)..column_at(row.range.end);
|
||||
HyperlinkLine {
|
||||
line: line_to_static(&row.line),
|
||||
hyperlinks: project_hyperlink_columns(links, source, /*prefix*/ 0),
|
||||
source: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer/status_surface_tests.rs
|
||||
expression: "frames.join(\"\\n---\\n\")"
|
||||
---
|
||||
لا PR #42 build
|
||||
green deploy
|
||||
ready all unit
|
||||
tests passed…
|
||||
---
|
||||
لا PR #42 build
|
||||
green deploy
|
||||
ready all unit…
|
||||
---
|
||||
لا PR #42 build
|
||||
green deploy…
|
||||
---
|
||||
لا PR #42 build…
|
||||
@@ -15,6 +15,7 @@ impl ChatComposer {
|
||||
self.effort_status_line_transition = None;
|
||||
}
|
||||
self.footer.measured_status_line.set(None);
|
||||
self.footer.wrapped_native_status.take();
|
||||
self.footer.status_line_value = status_line;
|
||||
true
|
||||
}
|
||||
@@ -66,6 +67,12 @@ impl ChatComposer {
|
||||
cache.set(Some((bounds, height)));
|
||||
height
|
||||
}
|
||||
Some(native) if bounds.height > 1 && self.active_status_transition().is_none() => self
|
||||
.wrapped_native_status(native, bounds.width)
|
||||
.map_or_else(
|
||||
|| u16::from(native.width() > 0),
|
||||
|rows| rows.height().min(bounds.height),
|
||||
),
|
||||
Some(line) => u16::from(line.width() > 0),
|
||||
None => 0,
|
||||
}
|
||||
@@ -98,6 +105,12 @@ impl ChatComposer {
|
||||
passive_footer_status_line(&props).map(Cow::Owned)
|
||||
}
|
||||
|
||||
fn active_status_transition(&self) -> Option<&EffortStatusLineTransition> {
|
||||
self.effort_status_line_transition
|
||||
.as_ref()
|
||||
.filter(|transition| !transition.is_finished())
|
||||
}
|
||||
|
||||
pub(super) fn render_status_surface(
|
||||
&self,
|
||||
area: Rect,
|
||||
@@ -119,11 +132,7 @@ impl ChatComposer {
|
||||
}
|
||||
let area = inset_footer_hint_area(area);
|
||||
let line = self.status_surface_content();
|
||||
if let Some(transition) = self
|
||||
.effort_status_line_transition
|
||||
.as_ref()
|
||||
.filter(|transition| !transition.is_finished())
|
||||
{
|
||||
if let Some(transition) = self.active_status_transition() {
|
||||
if let Some(frame) = transition.render_line(
|
||||
line.map(|line| line.into_owned().into_hyperlink_line()),
|
||||
area.width,
|
||||
@@ -134,7 +143,13 @@ impl ChatComposer {
|
||||
requester.schedule_frame_in(EFFORT_STATUS_LINE_FRAME_TICK);
|
||||
}
|
||||
} else if let Some(line) = line {
|
||||
line.into_owned().render(area, buf);
|
||||
if area.height > 1
|
||||
&& let Some(wrapped) = self.wrapped_native_status(&line, area.width)
|
||||
{
|
||||
wrapped.render(area, buf);
|
||||
} else {
|
||||
line.into_owned().render(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,6 +474,129 @@ fn extension_status_keeps_its_rows_when_effort_changes() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_status_wraps_links_within_the_host_budget() {
|
||||
let mut composer = composer();
|
||||
composer.set_collaboration_modes_enabled(/*enabled*/ true);
|
||||
let url = "https://example.com/pr/42";
|
||||
let mut status = HyperlinkLine::from("لا ");
|
||||
status.push_span(
|
||||
"PR #42 build green deploy ready all unit tests passed release queued".underlined(),
|
||||
Some(url),
|
||||
);
|
||||
composer.set_status_line(Some(status.into()));
|
||||
let mut frames = Vec::new();
|
||||
composer.footer.toggle_shortcuts_key = None;
|
||||
for (fullscreen, mode, status_height) in [
|
||||
(true, None, 4),
|
||||
(true, Some(CollaborationModeIndicator::Plan), 3),
|
||||
(false, None, 2),
|
||||
(false, Some(CollaborationModeIndicator::Plan), 1),
|
||||
] {
|
||||
composer.set_collaboration_mode_indicator(mode);
|
||||
let (_, rows) = render_status(
|
||||
&composer,
|
||||
Rect::new(0, 0, 18, 20),
|
||||
ComposerRenderOptions {
|
||||
separate_status_line: fullscreen,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(rows.len(), status_height);
|
||||
for row in &rows {
|
||||
let text = strip_osc8(row);
|
||||
let (prefix, rest) = text
|
||||
.strip_prefix("لا ")
|
||||
.map_or(("", text.as_str()), |rest| ("لا ", rest));
|
||||
let (link, end) = rest
|
||||
.strip_suffix('…')
|
||||
.map_or((rest, ""), |link| (link, "…"));
|
||||
let visible_link: String = link
|
||||
.chars()
|
||||
.map(|ch| osc8_hyperlink(url, &ch.to_string()))
|
||||
.collect();
|
||||
assert_eq!(*row, format!("{prefix}{visible_link}{end}"));
|
||||
}
|
||||
frames.push(
|
||||
rows.iter()
|
||||
.map(|row| strip_osc8(row))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
insta::assert_snapshot!(frames.join("\n---\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_wrap_tracks_agent_label_and_content_changes_at_the_same_width() {
|
||||
let mut composer = composer();
|
||||
composer.set_status_line(Some(Line::from("build ready").into()));
|
||||
let area = Rect::new(0, 0, 16, 14);
|
||||
composer.footer.toggle_shortcuts_key = None;
|
||||
for agent in ["frontend", "backend"] {
|
||||
composer.set_active_agent_label(Some(agent.into()));
|
||||
assert_eq!(
|
||||
render_status(&composer, area, Default::default()).1,
|
||||
["build ready ·", agent]
|
||||
);
|
||||
}
|
||||
composer.set_status_line(Some(Line::from("tests green").into()));
|
||||
assert_eq!(
|
||||
render_status(&composer, area, Default::default()).1,
|
||||
["tests green ·", "backend"]
|
||||
);
|
||||
let wide = Rect { width: 36, ..area };
|
||||
assert_eq!(
|
||||
render_status(&composer, wide, Default::default()).1,
|
||||
["tests green · backend"]
|
||||
);
|
||||
assert_eq!(
|
||||
render_status(&composer, area, Default::default()).1,
|
||||
["tests green ·", "backend"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn native_effort_transition_restores_wrapping_after_its_final_frame() {
|
||||
let mut composer = composer();
|
||||
composer.set_status_line(Some(
|
||||
Line::from("build ready tests passed deploy queued").into(),
|
||||
));
|
||||
composer.set_active_reasoning_effort_baseline(/*effort*/ None);
|
||||
composer.set_active_reasoning_effort(
|
||||
Some(&ReasoningEffort::Max),
|
||||
/*animations_enabled*/ true,
|
||||
);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 14, /*height*/ 20,
|
||||
);
|
||||
for separate_status_line in [false, true] {
|
||||
let options = ComposerRenderOptions {
|
||||
separate_status_line,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(render_status(&composer, area, options).1.len(), 1);
|
||||
}
|
||||
|
||||
let options = ComposerRenderOptions {
|
||||
separate_status_line: true,
|
||||
..Default::default()
|
||||
};
|
||||
let status = composer.layout_with_options(area, options).status;
|
||||
assert_eq!(status.height, 1);
|
||||
let (requester, mut frames) = FrameRequester::test_channel();
|
||||
composer.set_frame_requester(requester);
|
||||
tokio::time::advance(Duration::from_secs(/*secs*/ 10)).await;
|
||||
composer.render_status_surface(status, &mut Buffer::empty(area), options);
|
||||
frames.try_recv().expect("final paint requests a redraw");
|
||||
let wrapped = composer.layout_with_options(area, options).status;
|
||||
let mut buf = Buffer::empty(area);
|
||||
composer.render_status_surface(wrapped, &mut buf, options);
|
||||
assert!(wrapped.height > 1);
|
||||
assert!(frame(&buf).contains("tests"));
|
||||
assert!(frames.try_recv().is_err());
|
||||
}
|
||||
|
||||
fn render(
|
||||
composer: &ChatComposer,
|
||||
width: u16,
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use tokio::time::Instant;
|
||||
|
||||
use crate::color::blend;
|
||||
use crate::color::is_light;
|
||||
@@ -63,7 +63,14 @@ impl EffortTier {
|
||||
pub(crate) struct EffortStatusLineTransition {
|
||||
tier: EffortTier,
|
||||
previous: HyperlinkLine,
|
||||
started_at: Cell<Option<Instant>>,
|
||||
phase: Cell<TransitionPhase>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum TransitionPhase {
|
||||
Pending,
|
||||
Running(Instant),
|
||||
Finished,
|
||||
}
|
||||
|
||||
impl EffortStatusLineTransition {
|
||||
@@ -71,14 +78,12 @@ impl EffortStatusLineTransition {
|
||||
Self {
|
||||
tier,
|
||||
previous,
|
||||
started_at: Cell::new(None),
|
||||
phase: Cell::new(TransitionPhase::Pending),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_finished(&self) -> bool {
|
||||
self.started_at
|
||||
.get()
|
||||
.is_some_and(|started| started.elapsed() >= TOTAL)
|
||||
matches!(self.phase.get(), TransitionPhase::Finished)
|
||||
}
|
||||
|
||||
pub(crate) fn render_line(
|
||||
@@ -86,13 +91,18 @@ impl EffortStatusLineTransition {
|
||||
current: Option<HyperlinkLine>,
|
||||
width: u16,
|
||||
) -> Option<HyperlinkLine> {
|
||||
let elapsed = match self.started_at.get() {
|
||||
Some(started) => started.elapsed(),
|
||||
None => {
|
||||
self.started_at.set(Some(Instant::now()));
|
||||
let elapsed = match self.phase.get() {
|
||||
TransitionPhase::Pending => {
|
||||
self.phase.set(TransitionPhase::Running(Instant::now()));
|
||||
Duration::ZERO
|
||||
}
|
||||
TransitionPhase::Running(started) => started.elapsed(),
|
||||
TransitionPhase::Finished => return current,
|
||||
};
|
||||
// Paint the last single-row frame before the next layout restores wrapping.
|
||||
if elapsed >= TOTAL {
|
||||
self.phase.set(TransitionPhase::Finished);
|
||||
}
|
||||
transition_line_at(self.tier, &self.previous, current, elapsed, width)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,14 +19,17 @@ fn clock_waits_for_the_first_visible_frame() {
|
||||
let transition =
|
||||
EffortStatusLineTransition::new(EffortTier::Ultra, "gpt-5.4 high · main".into());
|
||||
assert!(!transition.is_finished());
|
||||
assert_eq!(transition.started_at.get(), None);
|
||||
assert!(matches!(transition.phase.get(), TransitionPhase::Pending));
|
||||
|
||||
let current = HyperlinkLine::from("gpt-5.4 ultra · main");
|
||||
let line = transition
|
||||
.render_line(Some(current), /*width*/ 40)
|
||||
.expect("first frame should render the outgoing line");
|
||||
assert_eq!(text(&line.line), "gpt-5.4 high · main");
|
||||
assert!(transition.started_at.get().is_some());
|
||||
assert!(matches!(
|
||||
transition.phase.get(),
|
||||
TransitionPhase::Running(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,9 +6,9 @@ use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::render::highlight::with_syntax_theme;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::TerminalHyperlink;
|
||||
use crate::ui_tree::PreparedUiTree;
|
||||
use crate::ui_tree::UiTree;
|
||||
|
||||
@@ -29,6 +29,14 @@ impl From<UiTree> for FooterContent {
|
||||
}
|
||||
|
||||
impl FooterContent {
|
||||
pub(crate) fn native_parts(&self) -> Option<(&Line<'static>, &[TerminalHyperlink])> {
|
||||
match self {
|
||||
Self::Line(line) => Some((line, &[])),
|
||||
Self::HyperlinkLine(line) => Some((&line.line, &line.hyperlinks)),
|
||||
Self::Extension { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Natural display width in terminal cells.
|
||||
pub(crate) fn width(&self) -> u16 {
|
||||
let width = match self {
|
||||
@@ -46,18 +54,17 @@ impl FooterContent {
|
||||
with_syntax_theme(|theme| content.render(area, theme, buf));
|
||||
}
|
||||
native => {
|
||||
let mut line = native.into_hyperlink_line();
|
||||
let line = native.into_hyperlink_line();
|
||||
let area = Rect {
|
||||
height: area.height.min(1),
|
||||
..area
|
||||
};
|
||||
let width = usize::from(area.width);
|
||||
if line.width() > width {
|
||||
line.line = truncate_line_with_ellipsis_if_overflow(line.line, width);
|
||||
// The ellipsis is never part of the link.
|
||||
line.clip_hyperlinks_to_width(line.width().saturating_sub(1));
|
||||
line.render_with_ellipsis(area, buf);
|
||||
} else {
|
||||
line.render_fitted(area, buf);
|
||||
}
|
||||
line.render_fitted(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,19 +482,8 @@ pub(crate) fn remap_source_wrapped_line(
|
||||
let end = start + display_width(&text[range.clone()]);
|
||||
source_byte = range.end;
|
||||
source_column = end;
|
||||
let hyperlinks = source
|
||||
.hyperlinks
|
||||
.iter()
|
||||
.filter_map(|link| {
|
||||
let first = link.columns.start.max(start);
|
||||
let last = link.columns.end.min(end);
|
||||
(first < last).then(|| {
|
||||
link.with_columns(
|
||||
prefix_columns + first - start..prefix_columns + last - start,
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let hyperlinks =
|
||||
project_hyperlink_columns(&source.hyperlinks, start..end, prefix_columns);
|
||||
HyperlinkLine {
|
||||
line,
|
||||
hyperlinks,
|
||||
@@ -504,6 +493,24 @@ pub(crate) fn remap_source_wrapped_line(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Translate links intersecting a source fragment into columns on the displayed row.
|
||||
pub(crate) fn project_hyperlink_columns(
|
||||
links: &[TerminalHyperlink],
|
||||
source: Range<usize>,
|
||||
prefix: usize,
|
||||
) -> Vec<TerminalHyperlink> {
|
||||
links
|
||||
.iter()
|
||||
.filter_map(|link| {
|
||||
let first = link.columns.start.max(source.start);
|
||||
let last = link.columns.end.min(source.end);
|
||||
(first < last).then(|| {
|
||||
link.with_columns(prefix + first - source.start..prefix + last - source.start)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Re-attach source hyperlink ranges after visible-text wrapping has split a line.
|
||||
///
|
||||
/// Link text is matched in display order so a URL split across table rows retains the complete
|
||||
|
||||
@@ -113,6 +113,17 @@ impl HyperlinkLine {
|
||||
self.mark_cells(area, buf);
|
||||
}
|
||||
|
||||
/// Reserve the last visible grapheme for an unlinked overflow marker.
|
||||
pub(crate) fn render_with_ellipsis(mut self, area: Rect, buf: &mut Buffer) {
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.line =
|
||||
crate::line_truncation::truncate_line_with_ellipsis(self.line, usize::from(area.width));
|
||||
self.clip_hyperlinks_to_width(self.width().saturating_sub(1));
|
||||
self.render_fitted(area, buf);
|
||||
}
|
||||
|
||||
fn mark_cells(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.is_empty() {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user