Upgrade Ratatui to 0.30.2 (#35959)

## What changed

- Upgrade `ratatui`, `ratatui-macros`, `crossterm`, and `ansi-to-tui`, and adapt the TUI to their updated rendering, backend, color-conversion, and cell APIs.
- Use Ratatui's cell-width and continuation-cell metadata when mapping OSC 8 hyperlinks so links remain aligned across wrapped wide glyphs and halfwidth characters with dakuten.
- Preserve explicit hyperlinks in the sign-in success screen and empty MCP state without embedding escape sequences in the text passed to Ratatui.

## Testing

- Add regression coverage for hyperlink placement across wide glyphs and halfwidth dakuten, hyperlink preservation in onboarding and MCP output, and display-width handling.

GitOrigin-RevId: 8460873e5a266923acd0417bc46264845eab0134
This commit is contained in:
Charlie Marsh
2026-07-29 13:53:15 +00:00
committed by copyberry
parent e1895710ad
commit 00cb5c465b
43 changed files with 1097 additions and 303 deletions
+60 -11
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,7 +3,7 @@
ignore = [
"RUSTSEC-2024-0388", # derivative 2.2.0 via starlark/starlark_syntax; upstream crate is unmaintained
"RUSTSEC-2025-0057", # fxhash 0.2.1 via starlark_map/bm25; upstream crate is unmaintained
"RUSTSEC-2024-0436", # paste 1.0.15 via starlark/ratatui; upstream crate is unmaintained
"RUSTSEC-2024-0436", # paste 1.0.15 via starlark/v8; upstream crate is unmaintained
"RUSTSEC-2023-0089", # atomic-polyfill via postcard/heapless/pagable; upstream crate is unmaintained
"RUSTSEC-2024-0320", # yaml-rust via syntect; remove when syntect drops or updates it
"RUSTSEC-2025-0141", # bincode via syntect; remove when syntect drops or updates it
+652 -123
View File
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -269,7 +269,7 @@ mcp_test_support = { path = "mcp-server/tests/common" }
# External
age = "0.11.1"
ansi-to-tui = "7.0.0"
ansi-to-tui = "8.0.1"
anyhow = "1"
arboard = { version = "3", features = ["wayland-data-control"] }
arc-swap = "1.9.0"
@@ -302,7 +302,7 @@ color-eyre = "0.6.3"
constant_time_eq = "0.3.1"
crossbeam-channel = "0.5.15"
crypto_box = { version = "0.9.1", features = ["seal"] }
crossterm = "0.28.1"
crossterm = "0.29.0"
ctor = "0.6.3"
deno_core_icudata = "0.77.0"
derive_more = "2"
@@ -368,8 +368,12 @@ pretty_assertions = "1.4.1"
pulldown-cmark = { version = "0.10", default-features = false }
quick-xml = "0.41.0"
rand = "0.9"
ratatui = "0.29.0"
ratatui-macros = "0.6.0"
ratatui = { version = "0.30.2", default-features = false, features = [
"crossterm",
"layout-cache",
"underline-color",
] }
ratatui-macros = "0.7.2"
rcgen = { version = "0.14.7", default-features = false, features = [
"aws_lc_rs",
"pem",
@@ -564,8 +568,7 @@ opt-level = 0
[patch.crates-io]
# Uncomment to debug local changes.
# ratatui = { path = "../../ratatui" }
crossterm = { git = "https://github.com/openai-oss-forks/crossterm", rev = "87db8bfa6dc99427fd3b071681b07fc31c6ce995" }
ratatui = { git = "https://github.com/nornagon/ratatui", rev = "9b2ad1298408c45918ee9f8241a6f95498cdbed2" }
crossterm = { git = "https://github.com/openai-oss-forks/crossterm", rev = "f69a4a0499f2fdc7d5d222df32373ffffe9ba3f5" }
tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" }
tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" }
+1 -4
View File
@@ -15,8 +15,5 @@ workspace = true
[dependencies]
ansi-to-tui = { workspace = true }
ratatui = { workspace = true, features = [
"unstable-rendered-line-info",
"unstable-widget-ref",
] }
ratatui = { workspace = true }
tracing = { workspace = true, features = ["log"] }
+1 -2
View File
@@ -38,8 +38,7 @@ rustPlatform.buildRustPackage (_: {
];
cargoLock.outputHashes = {
"ratatui-0.29.0" = "sha256-HBvT5c8GsiCxMffNjJGLmHnvG77A6cqEL+1ARurBXho=";
"crossterm-0.28.1" = "sha256-6qCtfSMuXACKFb9ATID39XyFDIEMFDmbx6SSmNe+728=";
"crossterm-0.29.0" = "sha256-ewiWWQPEU1lSUHzmZTiO5yes5luIaQ9TrvCNnTWhxpE=";
"nucleo-0.5.0" = "sha256-Hm4SxtTSBrcWpXrtSqeO0TACbUxq3gizg1zD/6Yw/sI=";
"nucleo-matcher-0.3.1" = "sha256-Hm4SxtTSBrcWpXrtSqeO0TACbUxq3gizg1zD/6Yw/sI=";
"runfiles-0.1.0" = "sha256-uJpVLcQh8wWZA3GPv9D8Nt43EOirajfDJ7eq/FB+tek=";
+1 -2
View File
@@ -74,7 +74,7 @@ ignore = [
# Each exception must identify the dependency path and removal condition.
{ id = "RUSTSEC-2024-0388", reason = "derivative is unmaintained; pulled in via starlark/starlark_syntax v0.14.2 used by execpolicy/cli/core; no fixed starlark release yet" },
{ id = "RUSTSEC-2025-0057", reason = "fxhash is unmaintained; pulled in via starlark_map under starlark v0.14.2 used by execpolicy/cli/core and bm25 used by core; remove when upstream dependencies drop fxhash" },
{ id = "RUSTSEC-2024-0436", reason = "paste is unmaintained; pulled in via ratatui/rmcp/starlark used by tui/execpolicy; no fixed release yet" },
{ id = "RUSTSEC-2024-0436", reason = "paste is unmaintained; pulled in via starlark/v8 used by execpolicy/code-mode; no fixed release yet" },
{ id = "RUSTSEC-2023-0089", reason = "atomic-polyfill is unmaintained; pulled in via postcard/heapless/pagable under starlark v0.14.2 used by execpolicy/cli/core; no fixed starlark release yet" },
# TODO(fcoury): remove this exception when syntect drops yaml-rust and bincode, or updates to versions that have fixed the vulnerabilities.
{ id = "RUSTSEC-2024-0320", reason = "yaml-rust is unmaintained; pulled in via syntect v5.3.0 used by codex-tui for syntax highlighting; no fixed release yet" },
@@ -314,7 +314,6 @@ allow-git = [
"https://github.com/dzbarsky/rules_rust",
"https://github.com/helix-editor/nucleo.git",
"https://github.com/juberti-oai/rust-sdks.git",
"https://github.com/nornagon/ratatui",
"https://github.com/openai-oss-forks/crossterm",
"https://github.com/openai-oss-forks/tokio-tungstenite",
"https://github.com/openai-oss-forks/tungstenite-rs",
+3 -1
View File
@@ -4,6 +4,8 @@
//! resuming/forking saved sessions, replacing ChatWidget instances, and maintaining the agent picker
//! cache used for multi-agent navigation.
use std::io;
use super::agent_picker::AGENT_PICKER_VIEW_ID;
use super::*;
use crate::app_server_session::source_agent_path;
@@ -569,7 +571,7 @@ impl App {
terminal: &mut crate::custom_terminal::Terminal<B>,
) -> Result<()>
where
B: Backend + Write,
B: Backend<Error = io::Error> + Write,
{
terminal.clear_scrollback_and_visible_screen_ansi()?;
let mut area = terminal.viewport_area;
@@ -192,6 +192,7 @@ use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Paragraph;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use codex_protocol::openai_models::ReasoningEffort;
@@ -4665,7 +4666,7 @@ impl ChatComposer {
}
} else if self.footer.flash_visible() {
if let Some(flash) = self.footer.flash.as_ref() {
flash.line.render(inset_footer_hint_area(hint_rect), buf);
Widget::render(&flash.line, inset_footer_hint_area(hint_rect), buf);
}
} else if let Some(items) = active_footer_hint_override {
render_footer_hint_items(hint_rect, buf, items);
@@ -4703,11 +4704,11 @@ impl ChatComposer {
}
}
let style = user_message_style();
Block::default().style(style).render_ref(composer_rect, buf);
Block::default().style(style).render(composer_rect, buf);
if !remote_images_rect.is_empty() {
Paragraph::new(self.attachments.remote_image_lines())
.style(style)
.render_ref(remote_images_rect, buf);
.render(remote_images_rect, buf);
}
if !textarea_rect.is_empty() {
let prompt = if self.draft.input_enabled {
@@ -4780,8 +4781,7 @@ impl ChatComposer {
};
if !textarea_rect.is_empty() {
let placeholder = Span::from(text).dim();
Line::from(vec![placeholder])
.render_ref(textarea_rect.inner(Margin::new(0, 0)), buf);
Line::from(vec![placeholder]).render(textarea_rect.inner(Margin::new(0, 0)), buf);
}
}
if matches!(self.popups.active, ActivePopup::None)
@@ -2,7 +2,6 @@ use std::path::PathBuf;
use ratatui::style::Style;
use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::text::Span;
const TAG_WIDTH: usize = "Plugin".len();
@@ -54,7 +54,6 @@ use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use unicode_width::UnicodeWidthStr;
@@ -309,7 +308,7 @@ impl Renderable for DelayedLoadingHeader {
lines.push(Line::from(note.as_str().dim()));
}
Paragraph::new(lines).render_ref(area, buf);
Paragraph::new(lines).render(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
@@ -281,7 +281,6 @@ async fn unified_exec_begin_restores_working_status_snapshot() {
let height = chat.desired_height(width);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height))
.expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, width, height));
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw chatwidget");
@@ -1220,7 +1219,6 @@ async fn approval_modal_exec_without_reason_snapshot() -> anyhow::Result<()> {
let height = chat.desired_height(width);
let mut terminal =
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, width, height));
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw approval modal (no reason)");
@@ -1265,7 +1263,6 @@ async fn approval_modal_exec_multiline_prefix_hides_execpolicy_option_snapshot()
let height = chat.desired_height(width);
let mut terminal =
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, width, height));
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw approval modal (multiline prefix)");
@@ -1309,7 +1306,6 @@ async fn approval_modal_patch_snapshot() -> anyhow::Result<()> {
let height = chat.desired_height(/*width*/ 80);
let mut terminal =
ratatui::Terminal::new(VT100Backend::new(/*width*/ 80, height)).expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, 80, height));
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw patch approval modal");
@@ -635,7 +635,6 @@ async fn approvals_popup_shows_disabled_presets() {
let height = chat.desired_height(width);
let mut terminal =
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, width, height));
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("render approvals popup");
@@ -2542,7 +2542,6 @@ async fn status_widget_and_approval_modal_snapshot() {
let height = chat.desired_height(width);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height))
.expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, width, height));
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw status + approval modal");
@@ -3941,7 +3940,6 @@ async fn reasoning_delta_restores_recreated_status_indicator_header() {
let height = chat.desired_height(width);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height))
.expect("create terminal");
terminal.set_viewport_area(Rect::new(/*x*/ 0, /*y*/ 0, width, height));
terminal
.draw(|frame| chat.render(frame.area(), frame.buffer_mut()))
.expect("draw restored reasoning status");
@@ -5,7 +5,6 @@
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Stylize;
use super::TokenActivityView;
use crate::color::blend;
+29 -10
View File
@@ -37,27 +37,29 @@ use crossterm::terminal::Clear;
use derive_more::IsVariant;
use ratatui::backend::Backend;
use ratatui::backend::ClearType;
use ratatui::backend::IntoCrossterm;
use ratatui::buffer::Buffer;
use ratatui::buffer::CellDiffOption;
use ratatui::buffer::CellWidth;
use ratatui::layout::Position;
use ratatui::layout::Rect;
use ratatui::layout::Size;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
/// Returns the display width of a cell symbol, ignoring OSC escape sequences.
///
/// OSC sequences (e.g. OSC 8 hyperlinks: `\x1B]8;;URL\x07`) are terminal
/// control sequences that don't consume display columns. The standard
/// `UnicodeWidthStr::width()` method incorrectly counts the printable
/// `CellWidth::cell_width()` method incorrectly counts the printable
/// characters inside OSC payloads (like `]`, `8`, `;`, and URL characters).
/// This function strips them first so that only visible characters contribute
/// to the width.
fn display_width(s: &str) -> usize {
// Fast path: no escape sequences present.
if !s.contains('\x1B') {
return s.width();
return usize::from(s.cell_width());
}
// Strip OSC sequences: ESC ] ... BEL
@@ -76,7 +78,7 @@ fn display_width(s: &str) -> usize {
}
visible.push(ch);
}
visible.width()
usize::from(visible.as_str().cell_width())
}
fn osc8_hyperlink_parts(symbol: &str) -> Option<(&str, &str)> {
@@ -156,7 +158,7 @@ impl Frame<'_> {
#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
pub struct Terminal<B>
where
B: Backend + Write,
B: Backend<Error = io::Error> + Write,
{
/// The backend used to interface with the terminal
backend: B,
@@ -182,7 +184,7 @@ where
impl<B> Drop for Terminal<B>
where
B: Backend,
B: Backend<Error = io::Error>,
B: Write,
{
#[allow(clippy::print_stderr)]
@@ -202,7 +204,7 @@ where
impl<B> Terminal<B>
where
B: Backend,
B: Backend<Error = io::Error>,
B: Write,
{
/// Creates a new [`Terminal`] with the given [`Backend`] and [`TerminalOptions`].
@@ -626,7 +628,10 @@ fn diff_buffers(a: &Buffer, b: &Buffer) -> Vec<DrawCommand> {
// their place (the skipped cells should be blank anyway), or due to per-cell-skipping:
let mut to_skip: usize = 0;
for (i, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() {
if !current.skip && (current != previous || invalidated > 0) && to_skip == 0 {
if current.diff_option != CellDiffOption::Skip
&& (current != previous || invalidated > 0)
&& to_skip == 0
{
let (x, y) = a.pos_of(i);
let row = i / a.area.width as usize;
if x <= last_nonblank_columns[row] {
@@ -690,7 +695,10 @@ where
if cell.fg != fg || cell.bg != bg {
queue!(
writer,
SetColors(Colors::new(cell.fg.into(), cell.bg.into()))
SetColors(Colors::new(
cell.fg.into_crossterm(),
cell.bg.into_crossterm()
))
)?;
fg = cell.fg;
bg = cell.bg;
@@ -705,7 +713,7 @@ where
DrawCommand::ClearToEnd { bg: clear_bg, .. } => {
queue!(writer, SetAttribute(crossterm::style::Attribute::Reset))?;
modifier = Modifier::empty();
queue!(writer, SetBackgroundColor((*clear_bg).into()))?;
queue!(writer, SetBackgroundColor((*clear_bg).into_crossterm()))?;
bg = *clear_bg;
queue!(writer, Clear(crossterm::terminal::ClearType::UntilNewLine))?;
}
@@ -840,6 +848,8 @@ mod tests {
}
impl Backend for CaptureBackend {
type Error = io::Error;
fn draw<'a, I>(&mut self, _content: I) -> io::Result<()>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
@@ -989,6 +999,15 @@ mod tests {
assert!(output.find(close).expect("hyperlink close") < footer);
}
#[test]
fn display_width_handles_halfwidth_dakuten() {
assert_eq!(display_width("ガ"), 2);
assert_eq!(
display_width("\x1b]8;;https://example.com\x07ガ\x1b]8;;\x07"),
2
);
}
#[test]
fn terminal_draw_applies_requested_cursor_style() {
let mut output = Vec::new();
+1
View File
@@ -353,6 +353,7 @@ mod tests {
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
use ratatui::widgets::FrameExt;
use tempfile::TempDir;
fn new_prompt() -> CwdPromptScreen {
+1 -2
View File
@@ -1324,7 +1324,6 @@ mod tests {
use ratatui::backend::TestBackend;
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
#[test]
@@ -1377,7 +1376,7 @@ mod tests {
.draw(|f| {
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.render_ref(f.area(), f.buffer_mut())
.render(f.area(), f.buffer_mut())
})
.expect("draw");
assert!(
@@ -4,6 +4,7 @@ use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
use ratatui::widgets::FrameExt;
fn new_screen() -> ExternalAgentConfigSourceScreen {
ExternalAgentConfigSourceScreen::new(
+10 -4
View File
@@ -25,22 +25,28 @@ impl HistoryCell for PlainHistoryCell {
#[derive(Debug)]
pub(crate) struct WebHyperlinkHistoryCell {
lines: Vec<Line<'static>>,
lines: Vec<HyperlinkLine>,
}
impl WebHyperlinkHistoryCell {
pub(crate) fn new(lines: Vec<Line<'static>>) -> Self {
Self {
lines: crate::terminal_hyperlinks::annotate_web_urls(lines),
}
}
pub(crate) fn new_hyperlink_lines(lines: Vec<HyperlinkLine>) -> Self {
Self { lines }
}
}
impl HistoryCell for WebHyperlinkHistoryCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
self.lines.iter().map(|line| line.line.clone()).collect()
}
fn display_hyperlink_lines(&self, _width: u16) -> Vec<HyperlinkLine> {
crate::terminal_hyperlinks::annotate_web_urls(self.lines.clone())
self.lines.clone()
}
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
@@ -48,7 +54,7 @@ impl HistoryCell for WebHyperlinkHistoryCell {
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.lines.clone())
plain_lines(self.lines.iter().map(|line| line.line.clone()))
}
}
#[derive(Debug)]
+16 -18
View File
@@ -317,26 +317,24 @@ fn decode_mcp_image(block: &serde_json::Value) -> Option<DynamicImage> {
.ok()
}
/// Render a summary of configured MCP servers from the current `Config`.
pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
let lines: Vec<Line<'static>> = vec![
"/mcp".magenta().into(),
"".into(),
vec!["🔌 ".into(), "MCP Tools".bold()].into(),
"".into(),
" • No MCP servers configured.".italic().into(),
Line::from(vec![
" See the ".into(),
crate::terminal_hyperlinks::osc8_hyperlink(
"https://developers.openai.com/codex/mcp",
"MCP docs",
)
.underlined(),
" to configure them.".into(),
])
.style(Style::default().add_modifier(Modifier::DIM)),
pub(crate) fn empty_mcp_output() -> WebHyperlinkHistoryCell {
let mut docs_line = HyperlinkLine::new(Line::from(" See the "));
docs_line.push_span(
"MCP docs".underlined(),
Some("https://developers.openai.com/codex/mcp"),
);
docs_line.push_span(" to configure them.".into(), /*destination*/ None);
let lines = vec![
HyperlinkLine::new("/mcp".magenta().into()),
HyperlinkLine::from(""),
HyperlinkLine::new(vec!["🔌 ".into(), "MCP Tools".bold()].into()),
HyperlinkLine::from(""),
HyperlinkLine::new(" • No MCP servers configured.".italic().into()),
docs_line.style(Style::default().add_modifier(Modifier::DIM)),
];
PlainHistoryCell::new(lines)
WebHyperlinkHistoryCell::new_hyperlink_lines(lines)
}
#[cfg(test)]
+1 -14
View File
@@ -257,22 +257,9 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
/// Returns the number of viewport rows for the transcript overlay.
///
/// Uses the same `Paragraph::line_count` measurement as
/// `desired_height`. Contains a workaround for a ratatui bug where
/// a single whitespace-only line reports 2 rows instead of 1.
/// Uses the same `Paragraph::line_count` measurement as `desired_height`.
fn desired_transcript_height(&self, width: u16) -> u16 {
let lines = visible_lines(self.transcript_hyperlink_lines(width));
// Workaround: ratatui's line_count returns 2 for a single
// whitespace-only line. Clamp to 1 in that case.
if let [line] = &lines[..]
&& line
.spans
.iter()
.all(|s| s.content.chars().all(char::is_whitespace))
{
return 1;
}
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.line_count(width)
+1 -1
View File
@@ -6,7 +6,7 @@ fn finalized_plan_reuses_lines_primed_by_transcript_height() {
let cell = new_proposed_plan("1. Inspect **markdown**".to_string(), Path::new("/tmp"));
let width = 48;
assert_eq!(cell.desired_transcript_height(width), 8);
assert_eq!(cell.desired_transcript_height(width), 5);
cell.rendered_lines
.cached
.lock()
+51
View File
@@ -352,6 +352,57 @@ fn composite_cell_preserves_child_web_links() {
);
}
#[test]
fn empty_mcp_output_preserves_docs_hyperlink() {
let destination = "https://developers.openai.com/codex/mcp";
let cell: Box<dyn HistoryCell> = Box::new(empty_mcp_output());
insta::assert_snapshot!(render_lines(&cell.display_lines(/*width*/ 80)).join("\n"), @r"
/mcp
🔌 MCP Tools
• No MCP servers configured.
See the MCP docs to configure them.
");
let expected_link = vec![crate::terminal_hyperlinks::TerminalHyperlink::web(
/*columns*/ 12..20,
destination.to_string(),
)];
assert_eq!(
cell.display_hyperlink_lines(/*width*/ 80)[5].hyperlinks,
expected_link
);
assert_eq!(
cell.transcript_hyperlink_lines(/*width*/ 80)[5].hyperlinks,
expected_link
);
let area = Rect::new(0, 0, 80, 6);
let mut buf = Buffer::empty(area);
cell.render(area, &mut buf);
assert_eq!(
(0..39).map(|x| buf[(x, 5)].modifier).collect::<Vec<_>>(),
[
vec![Modifier::DIM; 12],
vec![Modifier::DIM | Modifier::UNDERLINED; 8],
vec![Modifier::DIM; 19],
]
.concat()
);
let linked_text = area
.positions()
.filter_map(|position| {
let symbol = buf[position].symbol();
symbol
.contains(&format!("\x1b]8;;{destination}\x07"))
.then(|| crate::terminal_hyperlinks::strip_osc8(symbol))
})
.collect::<String>();
assert_eq!(linked_text, "MCP docs");
}
#[test]
fn proposed_plan_cell_unwraps_markdown_fenced_table() {
let plan = new_proposed_plan(
@@ -9,7 +9,6 @@ use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::style::Stylize;
use std::sync::Arc;
use tempfile::TempDir;
+11 -7
View File
@@ -32,6 +32,7 @@ use crossterm::style::SetColors;
use crossterm::style::SetForegroundColor;
use crossterm::terminal::Clear;
use crossterm::terminal::ClearType;
use ratatui::backend::IntoCrossterm;
use ratatui::layout::Size;
use ratatui::prelude::Backend;
use ratatui::style::Color;
@@ -63,7 +64,7 @@ pub fn insert_history_lines<B>(
lines: Vec<Line>,
) -> io::Result<()>
where
B: Backend + Write,
B: Backend<Error = io::Error> + Write,
{
insert_history_lines_with_wrap_policy(terminal, lines, HistoryLineWrapPolicy::PreWrap)
}
@@ -74,7 +75,7 @@ pub fn insert_history_lines_with_wrap_policy<B>(
wrap_policy: HistoryLineWrapPolicy,
) -> io::Result<()>
where
B: Backend + Write,
B: Backend<Error = io::Error> + Write,
{
insert_history_lines_with_mode_and_wrap_policy(
terminal,
@@ -91,7 +92,7 @@ pub(crate) fn insert_history_lines_with_mode_and_wrap_policy<B>(
wrap_policy: HistoryLineWrapPolicy,
) -> io::Result<()>
where
B: Backend + Write,
B: Backend<Error = io::Error> + Write,
{
insert_history_hyperlink_lines_with_mode_and_wrap_policy(
terminal,
@@ -108,7 +109,7 @@ pub(crate) fn insert_history_hyperlink_lines_with_mode_and_wrap_policy<B>(
wrap_policy: HistoryLineWrapPolicy,
) -> io::Result<()>
where
B: Backend + Write,
B: Backend<Error = io::Error> + Write,
{
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
@@ -299,12 +300,12 @@ fn write_history_line<W: Write>(
line.line
.style
.fg
.map(std::convert::Into::into)
.map(IntoCrossterm::into_crossterm)
.unwrap_or(CColor::Reset),
line.line
.style
.bg
.map(std::convert::Into::into)
.map(IntoCrossterm::into_crossterm)
.unwrap_or(CColor::Reset)
))
)?;
@@ -459,7 +460,10 @@ where
if next_fg != fg || next_bg != bg {
queue!(
writer,
SetColors(Colors::new(next_fg.into(), next_bg.into()))
SetColors(Colors::new(
next_fg.into_crossterm(),
next_bg.into_crossterm()
))
)?;
fg = next_fg;
bg = next_bg;
-1
View File
@@ -19,7 +19,6 @@ use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Span;
#[cfg(test)]
-1
View File
@@ -64,7 +64,6 @@ use pulldown_cmark::Parser;
use pulldown_cmark::Tag;
use pulldown_cmark::TagEnd;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::text::Text;
+94 -27
View File
@@ -52,6 +52,9 @@ use crate::motion::shimmer_text;
use crate::onboarding::keys;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::mark_buffer_hyperlinks;
use crate::terminal_hyperlinks::visible_lines;
use crate::tui::FrameRequester;
/// Marks buffer cells that have cyan+underlined style as an OSC 8 hyperlink.
@@ -544,50 +547,51 @@ impl AuthModeWidget {
}
fn render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) {
let mut docs_line = HyperlinkLine::new(Line::from(" For more details see the ").dim());
docs_line.push_span(
"Codex docs".underlined(),
Some("https://developers.openai.com/codex/security"),
);
let mut preferences_line =
HyperlinkLine::new(Line::from(" Uses your plan's rate limits and ").dim());
preferences_line.push_span(
"training data preferences".underlined(),
Some("https://chatgpt.com/#settings"),
);
let lines = vec![
"✓ Signed in with your ChatGPT account"
.fg(Color::Green)
.into(),
HyperlinkLine::new(
"✓ Signed in with your ChatGPT account"
.fg(Color::Green)
.into(),
),
"".into(),
" Before you start:".into(),
"".into(),
" Decide how much autonomy you want to grant Codex".into(),
Line::from(vec![
" For more details see the ".into(),
crate::terminal_hyperlinks::osc8_hyperlink(
"https://developers.openai.com/codex/security",
"Codex docs",
)
.underlined(),
])
.dim(),
docs_line,
"".into(),
" Codex can make mistakes".into(),
" Review the code it writes and commands it runs"
.dim()
.into(),
HyperlinkLine::new(
" Review the code it writes and commands it runs"
.dim()
.into(),
),
"".into(),
" Powered by your ChatGPT account".into(),
Line::from(vec![
" Uses your plan's rate limits and ".into(),
crate::terminal_hyperlinks::osc8_hyperlink(
"https://chatgpt.com/#settings",
"training data preferences",
)
.underlined(),
])
.dim(),
preferences_line,
"".into(),
Line::from(vec![
HyperlinkLine::new(Line::from(vec![
" Press ".fg(Color::Cyan),
self.confirm_binding().into(),
" to continue".fg(Color::Cyan),
]),
])),
];
Paragraph::new(lines)
Paragraph::new(visible_lines(lines.clone()))
.wrap(Wrap { trim: false })
.render(area, buf);
mark_buffer_hyperlinks(buf, area, &lines, /*scroll_rows*/ 0);
}
fn render_chatgpt_success(&self, area: Rect, buf: &mut Buffer) {
@@ -1251,6 +1255,69 @@ mod tests {
assert!(contents.contains("Press esc to cancel"));
}
#[test]
fn chatgpt_success_message_renders_osc8_hyperlinks() {
let runtime = tokio::runtime::Runtime::new().unwrap();
let (widget, _tmp) = runtime.block_on(widget_forced_chatgpt());
let area = Rect::new(0, 0, 80, 14);
let mut buf = Buffer::empty(area);
widget.render_chatgpt_success_message(area, &mut buf);
assert_eq!(
collect_osc8_chars(&buf, area, "https://developers.openai.com/codex/security"),
"Codex docs"
);
assert_eq!(
collect_osc8_chars(&buf, area, "https://chatgpt.com/#settings"),
"training data preferences"
);
assert_eq!(
(0..37).map(|x| buf[(x, 5)].modifier).collect::<Vec<_>>(),
[
vec![Modifier::DIM; 27],
vec![Modifier::DIM | Modifier::UNDERLINED; 10],
]
.concat()
);
assert_eq!(
(0..60).map(|x| buf[(x, 11)].modifier).collect::<Vec<_>>(),
[
vec![Modifier::DIM; 35],
vec![Modifier::DIM | Modifier::UNDERLINED; 25],
]
.concat()
);
let visible = (area.top()..area.bottom())
.map(|y| {
let row = (area.left()..area.right())
.map(|x| buf[(x, y)].symbol())
.collect::<String>();
crate::terminal_hyperlinks::strip_osc8(&row)
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(visible, @r###"
✓ Signed in with your ChatGPT account
Before you start:
Decide how much autonomy you want to grant Codex
For more details see the Codex docs
Codex can make mistakes
Review the code it writes and commands it runs
Powered by your ChatGPT account
Uses your plan's rate limits and training data preferences
Press enter to continue
"###);
}
#[test]
fn auth_widget_suppresses_animations_when_device_code_is_visible() {
let runtime = tokio::runtime::Runtime::new().unwrap();
+2 -2
View File
@@ -146,7 +146,7 @@ mod tests {
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT);
let mut buf = Buffer::empty(area);
let frame_lines = widget.animation.current_frame().lines().count() as u16;
(&widget).render(area, &mut buf);
(&widget).render_ref(area, &mut buf);
let welcome_row = row_containing(&buf, "Welcome");
assert_eq!(welcome_row, Some(frame_lines + 1));
@@ -161,7 +161,7 @@ mod tests {
);
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT - 1);
let mut buf = Buffer::empty(area);
(&widget).render(area, &mut buf);
(&widget).render_ref(area, &mut buf);
let welcome_row = row_containing(&buf, "Welcome");
assert_eq!(welcome_row, Some(0));
+5 -2
View File
@@ -24,9 +24,9 @@ use crossterm::terminal::enable_raw_mode;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::buffer::Buffer;
use ratatui::layout::Alignment;
use ratatui::layout::Constraint;
use ratatui::layout::Direction;
use ratatui::layout::HorizontalAlignment;
use ratatui::layout::Layout;
use ratatui::layout::Margin;
use ratatui::layout::Rect;
@@ -270,7 +270,10 @@ impl WidgetRef for &OssSelectionWidget<'_> {
} else {
Style::new().bg(Color::DarkGray)
};
opt.label.clone().alignment(Alignment::Center).style(style)
opt.label
.clone()
.alignment(HorizontalAlignment::Center)
.style(style)
})
.collect();
+5 -6
View File
@@ -47,7 +47,6 @@ use ratatui::text::Text;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
pub(crate) enum Overlay {
@@ -113,7 +112,7 @@ fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec<KeyBinding>, &st
spans.push(Span::from(desc.to_string()));
first = false;
}
Paragraph::new(vec![Line::from(spans).dim()]).render_ref(area, buf);
Paragraph::new(vec![Line::from(spans).dim()]).render(area, buf);
}
/// Generic widget for rendering a pager view.
@@ -177,9 +176,9 @@ impl PagerView {
fn render_header(&self, area: Rect, buf: &mut Buffer) {
Span::from("/ ".repeat(area.width as usize / 2))
.dim()
.render_ref(area, buf);
.render(area, buf);
let header = format!("/ {}", self.title);
header.dim().render_ref(area, buf);
header.dim().render(area, buf);
}
fn render_content(&self, area: Rect, buf: &mut Buffer) {
@@ -230,7 +229,7 @@ impl PagerView {
Span::from("─".repeat(sep_rect.width as usize))
.dim()
.render_ref(sep_rect, buf);
.render(sep_rect, buf);
let percent = if total_len == 0 {
100
} else {
@@ -247,7 +246,7 @@ impl PagerView {
let pct_x = sep_rect.x + sep_rect.width - pct_w - 1;
Span::from(pct_text)
.dim()
.render_ref(Rect::new(pct_x, sep_rect.y, pct_w, 1), buf);
.render(Rect::new(pct_x, sep_rect.y, pct_w, 1), buf);
}
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> {
+2 -2
View File
@@ -10,7 +10,7 @@ use std::sync::Arc;
use std::sync::Mutex;
use ratatui::buffer::Buffer;
use ratatui::layout::Alignment;
use ratatui::layout::HorizontalAlignment;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
@@ -128,7 +128,7 @@ impl Renderable for PetPickerPreviewRenderable {
lines.push(Line::from(body.dim()));
}
Paragraph::new(lines)
.alignment(Alignment::Center)
.alignment(HorizontalAlignment::Center)
.render(text_area, buf);
}
+2 -1
View File
@@ -7,6 +7,7 @@ use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use crate::render::Insets;
@@ -109,7 +110,7 @@ impl<'a> Renderable for Span<'a> {
impl<'a> Renderable for Line<'a> {
fn render(&self, area: Rect, buf: &mut Buffer) {
WidgetRef::render_ref(self, area, buf);
Widget::render(self, area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
1
+13 -13
View File
@@ -1902,11 +1902,11 @@ fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
state.action.title().bold().cyan()
};
let header_line: Line = vec![header_title].into();
frame.render_widget_ref(header_line, chrome(header));
frame.render_widget_ref(&header_line, chrome(header));
// Search line
let search = chrome(search);
frame.render_widget_ref(search_line(state, search.width), search);
frame.render_widget_ref(&search_line(state, search.width), search);
let list = Rect::new(
list.x.saturating_add(2),
@@ -2076,7 +2076,7 @@ fn render_picker_footer(
if y >= area.bottom() {
break;
}
frame.render_widget_ref(line, Rect::new(area.x, y, area.width, 1));
frame.render_widget_ref(&line, Rect::new(area.x, y, area.width, 1));
}
}
@@ -2090,7 +2090,7 @@ fn render_picker_footer_separator(
}
let separator = "─".repeat(area.width as usize);
frame.render_widget_ref(Line::from(separator.dim()), area);
frame.render_widget_ref(&Line::from(separator.dim()), area);
let progress_width = UnicodeWidthStr::width(progress_label.as_str()) as u16;
if progress_width < area.width {
@@ -2100,7 +2100,7 @@ fn render_picker_footer_separator(
progress_width,
1,
);
frame.render_widget_ref(Line::from(progress_label.dim()), percent_area);
frame.render_widget_ref(&Line::from(progress_label.dim()), percent_area);
}
}
@@ -2339,7 +2339,7 @@ fn render_transcript_loading_overlay(frame: &mut crate::custom_terminal::Frame,
message_width.min(overlay.width),
1,
);
frame.render_widget_ref(Line::from(message.bold()), line);
frame.render_widget_ref(&Line::from(message.bold()), line);
}
fn transcript_loading_overlay_style() -> Style {
@@ -2456,7 +2456,7 @@ fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &Pi
let rows = &state.filtered_rows;
if rows.is_empty() {
let message = render_empty_state_line(state);
frame.render_widget_ref(message, area);
frame.render_widget_ref(&message, area);
return;
}
@@ -2472,7 +2472,7 @@ fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &Pi
);
if show_more_above {
frame.render_widget_ref(
more_line("↑ more"),
&more_line("↑ more"),
Rect::new(area.x, area.y, area.width, 1),
);
}
@@ -2493,7 +2493,7 @@ fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &Pi
if y >= content_area.y.saturating_add(content_area.height) {
break;
}
frame.render_widget_ref(line, Rect::new(area.x, y, area.width, 1));
frame.render_widget_ref(&line, Rect::new(area.x, y, area.width, 1));
y = y.saturating_add(1);
}
if state.density == SessionListDensity::Comfortable
@@ -2509,7 +2509,7 @@ fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &Pi
{
let loading_line: Line = vec![" ".into(), "Loading older sessions…".italic().dim()].into();
let rect = Rect::new(area.x, y, area.width, 1);
frame.render_widget_ref(loading_line, rect);
frame.render_widget_ref(&loading_line, rect);
}
if show_more_below {
let label = if state.pagination.loading.is_pending() {
@@ -2518,7 +2518,7 @@ fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &Pi
"↓ more"
};
frame.render_widget_ref(
more_line(label),
&more_line(label),
Rect::new(
area.x,
area.y.saturating_add(area.height.saturating_sub(1)),
@@ -3764,7 +3764,7 @@ mod tests {
{
let mut frame = terminal.get_frame();
let line = search_line(&state, frame.area().width);
frame.render_widget_ref(line, frame.area());
frame.render_widget_ref(&line, frame.area());
}
terminal.flush().expect("flush");
@@ -4663,7 +4663,7 @@ session_picker_view = "dense"
{
let mut frame = terminal.get_frame();
let line = search_line(&state, frame.area().width);
frame.render_widget_ref(line, frame.area());
frame.render_widget_ref(&line, frame.area());
}
terminal.flush().expect("flush");
-1
View File
@@ -2,7 +2,6 @@ use crate::render::renderable::Renderable;
use crate::render::renderable::RowRenderable;
use ratatui::style::Style;
use ratatui::style::Styled as _;
use ratatui::style::Stylize as _;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use unicode_width::UnicodeWidthStr;
+1 -1
View File
@@ -258,7 +258,7 @@ fn selection_item(name: &str, is_disabled: bool) -> SelectionItem {
fn draw_view(tui: &mut Tui, view: &ListSelectionView) -> Result<()> {
tui.draw(u16::MAX, |frame| {
let area = frame.area();
frame.render_widget_ref(Clear, area);
frame.render_widget_ref(&Clear, area);
let view_area = Rect::new(
area.x,
area.y,
+2 -2
View File
@@ -15,7 +15,7 @@ use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Widget;
use unicode_width::UnicodeWidthStr;
use crate::app_event_sender::AppEventSender;
@@ -293,7 +293,7 @@ impl Renderable for StatusIndicatorWidget {
lines.extend(details.into_iter().take(max_details));
}
Paragraph::new(Text::from(lines)).render_ref(area, buf);
Paragraph::new(Text::from(lines)).render(area, buf);
}
}
-1
View File
@@ -8,7 +8,6 @@ use crate::terminal_palette::rgb_color;
use crate::terminal_palette::stdout_color_level;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Stylize;
const LIGHT_BG_ACCENT_RGB: (u8, u8, u8) = (0, 95, 135);
// Decorative table rules should remain visible without competing with cell content.
+110 -19
View File
@@ -6,6 +6,8 @@
use std::ops::Range;
use ratatui::buffer::Buffer;
use ratatui::buffer::CellDiffOption;
use ratatui::buffer::CellWidth;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
@@ -15,8 +17,6 @@ use ratatui::text::Text;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
use url::Url;
use crate::render::line_utils::line_to_borrowed;
@@ -85,12 +85,16 @@ impl HyperlinkLine {
}
pub(crate) fn width(&self) -> usize {
self.line.width()
self.line
.spans
.iter()
.map(|span| usize::from(span.content.as_ref().cell_width()))
.sum()
}
pub(crate) fn push_span(&mut self, span: Span<'static>, destination: Option<&str>) {
let start = self.width();
let end = start + span.content.width();
let end = start + usize::from(span.content.as_ref().cell_width());
self.line.push_span(span);
if end > start
&& let Some(destination) = destination.and_then(web_destination)
@@ -153,7 +157,7 @@ pub(crate) fn prefix_hyperlink_lines(
} else {
subsequent_prefix.clone()
};
let shift = prefix.content.width();
let shift = usize::from(prefix.content.as_ref().cell_width());
let mut spans = Vec::with_capacity(line.line.spans.len() + 1);
spans.push(prefix);
spans.extend(line.line.spans);
@@ -227,7 +231,10 @@ pub(crate) fn remap_wrapped_line(
if index > 0 {
let trimmed = source_text[source_byte..].trim_start_matches(char::is_whitespace);
let skipped = source_text[source_byte..].len() - trimmed.len();
source_column += source_text[source_byte..source_byte + skipped].width();
source_column += source_text[source_byte..source_byte + skipped]
.chars()
.map(char_cell_width)
.sum::<usize>();
source_byte += skipped;
}
@@ -237,9 +244,9 @@ pub(crate) fn remap_wrapped_line(
continue;
};
let mapped = &rendered[rendered_start..];
let mut output_column = rendered[..rendered_start].width();
let mut output_column = usize::from(rendered[..rendered_start].cell_width());
for ch in mapped.chars() {
let width = ch.width().unwrap_or(/*default*/ 0);
let width = char_cell_width(ch);
while source
.hyperlinks
.get(link_index)
@@ -312,8 +319,8 @@ pub(crate) fn web_links_in_text(text: &str) -> Vec<TerminalHyperlink> {
let Some(destination) = web_destination(candidate) else {
continue;
};
let start = text[..raw_start + trimmed_start].width();
let end = start + candidate.width();
let start = usize::from(text[..raw_start + trimmed_start].cell_width());
let end = start + usize::from(candidate.cell_width());
links.push(TerminalHyperlink::web(start..end, destination));
}
links
@@ -427,7 +434,7 @@ pub(crate) fn decorate_spans(line: &HyperlinkLine) -> Vec<Span<'static>> {
let mut active_destination: Option<String> = None;
for span in &line.line.spans {
for ch in span.content.chars() {
let width = ch.width().unwrap_or(/*default*/ 0);
let width = char_cell_width(ch);
while line
.hyperlinks
.get(link_index)
@@ -480,6 +487,14 @@ fn append_to_last_span(out: &mut [Span<'static>], content: &str) {
}
}
fn char_cell_width(ch: char) -> usize {
if ch.is_control() {
return 0;
}
let mut encoded = [0; 4];
usize::from(ch.encode_utf8(&mut encoded).cell_width())
}
pub(crate) fn mark_buffer_hyperlinks(
buf: &mut Buffer,
area: Rect,
@@ -509,10 +524,19 @@ pub(crate) fn mark_buffer_hyperlinks(
paragraph.render(layout_area, &mut layout);
let rendered_lines = (0..layout_area.height)
.map(|row| {
let mut trailing_columns = 0usize;
let text = (0..layout_area.width)
.filter_map(|column| {
if trailing_columns > 0 {
trailing_columns -= 1;
return None;
}
let cell = &layout[(column, row)];
(!cell.skip).then(|| cell.symbol())
if cell.diff_option == CellDiffOption::Skip {
return None;
}
trailing_columns = usize::from(cell.cell_width()).saturating_sub(1);
Some(cell.symbol())
})
.collect::<String>();
Line::from(text.trim_end().to_string())
@@ -520,7 +544,12 @@ pub(crate) fn mark_buffer_hyperlinks(
.collect();
for (row, rendered) in remap_wrapped_line(line, rendered_lines).iter().enumerate() {
for link in &rendered.hyperlinks {
let mut trailing_columns = 0usize;
for column in link.columns.clone() {
if trailing_columns > 0 {
trailing_columns -= 1;
continue;
}
let row = logical_row + row;
if row < scroll_rows || row - scroll_rows >= usize::from(area.height) {
continue;
@@ -528,9 +557,10 @@ pub(crate) fn mark_buffer_hyperlinks(
let x = area.x + column as u16;
let y = area.y + (row - scroll_rows) as u16;
let cell = &mut buf[(x, y)];
if cell.skip || cell.symbol().trim().is_empty() {
if cell.diff_option == CellDiffOption::Skip {
continue;
}
trailing_columns = usize::from(cell.cell_width()).saturating_sub(1);
let symbol = link.terminal_destination().map_or_else(
|| cell.symbol().to_string(),
|destination| {
@@ -568,7 +598,10 @@ fn mark_matching_cells(
}
for position in area.positions() {
let cell = &mut buf[position];
if !cell.skip && !cell.symbol().trim().is_empty() && matches(cell) {
if cell.diff_option != CellDiffOption::Skip
&& !cell.symbol().trim().is_empty()
&& matches(cell)
{
let symbol = osc8_hyperlink(destination, cell.symbol());
cell.set_symbol(&symbol);
}
@@ -611,7 +644,7 @@ mod tests {
assert_eq!(
web_links_in_text(&format!("See ({destination}).")),
vec![TerminalHyperlink::web(
/*columns*/ 5..5 + destination.width(),
/*columns*/ 5..5 + usize::from(destination.cell_width()),
destination.to_string(),
)]
);
@@ -623,7 +656,7 @@ mod tests {
let line = HyperlinkLine {
line: Line::from(destination),
hyperlinks: vec![TerminalHyperlink::web(
/*columns*/ 0..destination.width(),
/*columns*/ 0..usize::from(destination.cell_width()),
destination.to_string(),
)],
};
@@ -662,13 +695,15 @@ mod tests {
let text = "alpha 😀here middle there end";
let first_start = text.find("here").expect("first link");
let second_start = text.find("there").expect("second link");
let first_column = usize::from(text[..first_start].cell_width());
let second_column = usize::from(text[..second_start].cell_width());
let mut source = HyperlinkLine::new(Line::from(text));
source.hyperlinks.push(TerminalHyperlink::web(
text[..first_start].width()..text[..first_start].width() + "here".width(),
first_column..first_column + usize::from("here".cell_width()),
"https://example.com/first".to_string(),
));
source.hyperlinks.push(TerminalHyperlink::web(
text[..second_start].width()..text[..second_start].width() + "there".width(),
second_column..second_column + usize::from("there".cell_width()),
"https://example.com/second".to_string(),
));
@@ -706,7 +741,7 @@ mod tests {
let destination = "https://example.com/path";
let mut line = HyperlinkLine::new(Line::from(format!("See {destination} now")));
line.hyperlinks.push(TerminalHyperlink::web(
/*columns*/ 4..4 + destination.width(),
/*columns*/ 4..4 + usize::from(destination.cell_width()),
destination.to_string(),
));
let area = Rect::new(
@@ -731,6 +766,62 @@ mod tests {
assert_eq!(linked_text, destination);
}
#[test]
fn buffer_hyperlinks_follow_wrapped_wide_glyphs() {
let destination = "https://example.com/wide";
let mut line = HyperlinkLine::new(Line::from("前文 "));
line.push_span("漢字漢字".into(), Some(destination));
line.push_span(" 後文".into(), /*destination*/ None);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 6, /*height*/ 4,
);
let mut buf = Buffer::empty(area);
Paragraph::new(Text::from(line.line.clone()))
.wrap(Wrap { trim: false })
.render(area, &mut buf);
mark_buffer_hyperlinks(&mut buf, area, &[line], /*scroll_rows*/ 0);
let linked_text = area
.positions()
.filter_map(|position| {
let symbol = buf[position].symbol();
symbol
.contains(&format!("\x1b]8;;{destination}\x07"))
.then(|| strip_osc8(symbol))
})
.collect::<String>();
assert_eq!(linked_text, "漢字漢字");
}
#[test]
fn buffer_hyperlinks_follow_wrapped_halfwidth_dakuten() {
let destination = "https://example.com/dakuten";
let mut line = HyperlinkLine::new(Line::from("ガ "));
line.push_span("パlink".into(), Some(destination));
line.push_span(" tail".into(), /*destination*/ None);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 5, /*height*/ 4,
);
let mut buf = Buffer::empty(area);
Paragraph::new(Text::from(line.line.clone()))
.wrap(Wrap { trim: false })
.render(area, &mut buf);
mark_buffer_hyperlinks(&mut buf, area, &[line], /*scroll_rows*/ 0);
let linked_text = area
.positions()
.filter_map(|position| {
let symbol = buf[position].symbol();
symbol
.contains(&format!("\x1b]8;;{destination}\x07"))
.then(|| strip_osc8(symbol))
})
.collect::<String>();
assert_eq!(linked_text, "パlink");
}
#[test]
fn trusted_file_destination_receives_osc8_without_enabling_plain_file_links() {
let temp_dir = tempfile::tempdir().expect("temp directory");
+2
View File
@@ -53,6 +53,8 @@ impl fmt::Display for VT100Backend {
}
impl Backend for VT100Backend {
type Error = io::Error;
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
+2 -1
View File
@@ -1,5 +1,6 @@
use std::fmt;
use std::future::Future;
use std::io;
use std::io::IsTerminal;
use std::io::Result;
use std::io::Stdout;
@@ -589,7 +590,7 @@ struct PendingHistoryLines {
fn clear_for_viewport_change<B>(terminal: &mut CustomTerminal<B>, new_area: Rect) -> Result<()>
where
B: Backend + Write,
B: Backend<Error = io::Error> + Write,
{
let clear_position = if terminal.viewport_area.is_empty() {
new_area.as_position()
+1
View File
@@ -248,6 +248,7 @@ mod tests {
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::Terminal;
use ratatui::widgets::FrameExt;
fn new_prompt() -> UpdatePromptScreen {
UpdatePromptScreen::new(