From b7015f17a49501e99642009ebadabcbefc0fbad0 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 30 Jun 2026 15:55:13 +0300 Subject: [PATCH] fix: render ansi undercurl styles refs #895 --- src/ghostty/mod.rs | 9 +++++ src/pane/terminal.rs | 65 +++++++++++++++++++++++++++++++++++-- src/pane/xtgettcap.rs | 20 ++++++++++-- src/protocol/render_ansi.rs | 22 +++++++++++-- src/protocol/wire.rs | 19 +++++++++-- 5 files changed, 125 insertions(+), 10 deletions(-) diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 3800ab49..8a909b8c 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -353,6 +353,7 @@ pub struct CellStyle { pub invisible: bool, pub strikethrough: bool, pub overline: bool, + pub underline: u8, pub underlined: bool, } @@ -370,11 +371,19 @@ impl From for CellStyle { invisible: value.invisible, strikethrough: value.strikethrough, overline: value.overline, + underline: normalize_underline_style(value.underline), underlined: value.underline != 0, } } } +fn normalize_underline_style(value: std::os::raw::c_int) -> u8 { + match value { + 0..=5 => value as u8, + _ => 1, + } +} + fn cell_color_from_style_color(color: ffi::GhosttyStyleColor) -> Option { match color.tag { ffi::GhosttyStyleColorTag_GHOSTTY_STYLE_COLOR_PALETTE => { diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index b36ae0ee..708a7d28 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -2010,6 +2010,7 @@ fn ghostty_cell_style( if basic.style.strikethrough { modifiers |= Modifier::CROSSED_OUT; } + modifiers = crate::protocol::modifier_with_underline_style(modifiers, basic.style.underline); style.add_modifier(modifiers) } @@ -3783,8 +3784,7 @@ mod tests { let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); let pane_id = PaneId::from_raw(1); - let result = - pane.process_pty_bytes(pane_id, 0, b"\x1bP+q6E6F7065;536D756C78;4D7\x1b\\", &tx); + let result = pane.process_pty_bytes(pane_id, 0, b"\x1bP+q6E6F7065;4D7\x1b\\", &tx); assert!(result.terminal_responses.is_empty()); assert!(rx.try_recv().is_err()); @@ -3797,12 +3797,18 @@ mod tests { let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); let pane_id = PaneId::from_raw(1); - let result = pane.process_pty_bytes(pane_id, 0, b"\x1bP+q5375;536574756C63\x1b\\", &tx); + let result = pane.process_pty_bytes( + pane_id, + 0, + b"\x1bP+q5375;536D756C78;536574756C63\x1b\\", + &tx, + ); assert_eq!( result.terminal_responses, vec![ expected_xtgettcap_response("5375", None), + expected_xtgettcap_response("536D756C78", Some(b"\\E[4:%p1%dm")), expected_xtgettcap_response( "536574756C63", Some(b"\\E[58:2::%p1%{65536}%/%d:%p1%{256}%/%{255}%&%d:%p1%{255}%&%d%;m") @@ -3833,6 +3839,59 @@ mod tests { assert_eq!(style.underline_color, Some(Color::Rgb(17, 34, 51))); } + #[test] + fn dirty_patch_preserves_curly_underline_style() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + let backend = ratatui::backend::TestBackend::new(20, 5); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false)) + .unwrap(); + { + let mut core = pane.core.lock().unwrap(); + core.terminal.write(b"\x1b[4:3mU"); + } + + let patch = match pane.collect_dirty_patch(20, 5) { + TerminalDirtyPatchOutcome::Patch(patch) => patch, + other => panic!("expected dirty patch, got {other:?}"), + }; + + let cell = &patch.rows[0].1[0]; + assert_eq!(cell.symbol, "U"); + assert_eq!( + crate::protocol::underline_style_from_modifier(cell.modifier), + 3 + ); + } + + #[test] + fn full_frame_preserves_curly_underline_style() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + { + let mut core = pane.core.lock().unwrap(); + core.terminal.write(b"\x1b[4:3mU"); + } + + let backend = ratatui::backend::TestBackend::new(20, 5); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false)) + .unwrap(); + + let frame = + crate::protocol::FrameData::from_ratatui_buffer(terminal.backend().buffer(), None); + assert_eq!(frame.cells[0].symbol, "U"); + assert_eq!( + crate::protocol::underline_style_from_modifier(frame.cells[0].modifier), + 3 + ); + } + #[test] fn process_pty_bytes_orders_default_color_reply_before_following_device_attribute_reply() { let (tx, mut rx) = mpsc::channel(4); diff --git a/src/pane/xtgettcap.rs b/src/pane/xtgettcap.rs index f0a91df5..7d2dfc5d 100644 --- a/src/pane/xtgettcap.rs +++ b/src/pane/xtgettcap.rs @@ -174,8 +174,7 @@ fn xtgettcap_response(cap_hex: &[u8]) -> Option { } fn xtgettcap_value(cap_hex: &[u8]) -> Option> { - // Mirror only the Ghostty terminfo capabilities that this pane path can - // stand behind. Smulx is intentionally absent until underline shapes render. + // Mirror only the Ghostty terminfo capabilities that this pane path can stand behind. match cap_hex { b"5463" => Some(None), b"524742" => Some(Some(b"8")), @@ -183,6 +182,7 @@ fn xtgettcap_value(cap_hex: &[u8]) -> Option> { b"73657472676262" => Some(Some(b"\\E[48:2:%p1%d:%p2%d:%p3%dm")), b"4D73" => Some(Some(b"\\E]52;%p1%s;%p2%s\\007")), b"5375" => Some(None), + b"536D756C78" => Some(Some(b"\\E[4:%p1%dm")), b"536574756C63" => Some(Some( b"\\E[58:2::%p1%{65536}%/%d:%p1%{256}%/%{255}%&%d:%p1%{255}%&%d%;m", )), @@ -255,11 +255,25 @@ mod tests { fn tracker_ignores_unsupported_capabilities() { let mut tracker = XtgettcapQueryTracker::default(); - tracker.observe(b"\x1bP+q536D756C78;6E6F7065\x1b\\"); + tracker.observe(b"\x1bP+q6E6F7065\x1b\\"); assert!(response_bytes(tracker.drain_pending()).is_empty()); } + #[test] + fn tracker_returns_underline_style_capability() { + let mut tracker = XtgettcapQueryTracker::default(); + + tracker.observe(b"\x1bP+q536D756C78\x1b\\"); + + assert_eq!( + response_bytes(tracker.drain_pending()), + vec![Bytes::from_static( + b"\x1bP1+r536D756C78=5C455B343A25703125646D\x1b\\" + )] + ); + } + #[test] fn tracker_keeps_split_query_until_string_terminator() { let mut tracker = XtgettcapQueryTracker::default(); diff --git a/src/protocol/render_ansi.rs b/src/protocol/render_ansi.rs index 12606bff..3346bb9c 100644 --- a/src/protocol/render_ansi.rs +++ b/src/protocol/render_ansi.rs @@ -31,7 +31,7 @@ use std::io::Write; use unicode_width::UnicodeWidthStr; -use crate::protocol::{CellData, FrameData}; +use crate::protocol::{underline_style_from_modifier, CellData, FrameData}; /// Bytes produced by a [`BlitEncoder`] for one terminal frame. pub(crate) struct EncodedBlit { @@ -294,7 +294,13 @@ fn modifier_to_sgr_parts(val: u16) -> Vec<&'static str> { parts.push("3"); } if val & UNDERLINED != 0 { - parts.push("4"); + parts.push(match underline_style_from_modifier(val) { + 2 => "4:2", + 3 => "4:3", + 4 => "4:4", + 5 => "4:5", + _ => "4", + }); } if val & SLOW_BLINK != 0 { parts.push("5"); @@ -842,6 +848,18 @@ mod tests { assert_eq!(build_sgr(0x00_00_00_00, 0x00_00_00_00, 0), "\x1b[0;39;49m"); } + #[test] + fn build_sgr_preserves_curly_underline_style() { + let modifier = crate::protocol::modifier_to_u16( + crate::protocol::modifier_with_underline_style(ratatui::style::Modifier::UNDERLINED, 3), + ); + + assert_eq!( + build_sgr(0x00_00_00_00, 0x00_00_00_00, modifier), + "\x1b[0;4:3;39;49m" + ); + } + #[test] fn cells_equal_identical() { let a = make_cell("A", 2, 1, 0); diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 5f44691b..8225cb8c 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -426,7 +426,7 @@ pub struct CellData { pub fg: u32, /// Background color as a packed u32. pub bg: u32, - /// Bitmask of style modifiers (bold, italic, etc.). + /// Bitmask of style modifiers (bold, italic, etc.) plus Herdr extension bits. pub modifier: u16, /// Whether this cell should be skipped during diff-based rendering. pub skip: bool, @@ -729,15 +729,30 @@ fn u32_to_color(val: u32) -> ratatui::style::Color { } } +const UNDERLINE_STYLE_SHIFT: u16 = 12; +const UNDERLINE_STYLE_MASK: u16 = 0xF000; + /// Converts a ratatui `Modifier` bitmask to a u16 for wire transport. pub(crate) fn modifier_to_u16(modifier: ratatui::style::Modifier) -> u16 { modifier.bits() } +pub(crate) fn underline_style_from_modifier(modifier: u16) -> u8 { + ((modifier & UNDERLINE_STYLE_MASK) >> UNDERLINE_STYLE_SHIFT) as u8 +} + +pub(crate) fn modifier_with_underline_style( + modifier: ratatui::style::Modifier, + underline_style: u8, +) -> ratatui::style::Modifier { + let bits = modifier.bits() | ((u16::from(underline_style) & 0x0F) << UNDERLINE_STYLE_SHIFT); + ratatui::style::Modifier::from_bits_retain(bits) +} + /// Converts a u16 back to a ratatui `Modifier`. #[cfg(test)] fn u16_to_modifier(val: u16) -> ratatui::style::Modifier { - ratatui::style::Modifier::from_bits_truncate(val) + ratatui::style::Modifier::from_bits_truncate(val & !UNDERLINE_STYLE_MASK) } // ---------------------------------------------------------------------------