diff --git a/codex-rs/tui/src/bottom_pane/status_line_content.rs b/codex-rs/tui/src/bottom_pane/status_line_content.rs index faf4929d32..5095708551 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_content.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_content.rs @@ -54,7 +54,7 @@ impl StatusLineRenderer { children.push(Some(node)); } let tree = UiNode::Row { - props: RowProps { gap: 0 }, + props: RowProps::default(), children, }; Some(FooterContent::Extension { diff --git a/codex-rs/tui/src/line_truncation.rs b/codex-rs/tui/src/line_truncation.rs index b62ab97fd2..42248714d9 100644 --- a/codex-rs/tui/src/line_truncation.rs +++ b/codex-rs/tui/src/line_truncation.rs @@ -88,6 +88,12 @@ pub(crate) fn truncate_line_with_ellipsis_if_overflow( return line; } + truncate_line_with_ellipsis(line, max_width) +} + +/// Fit an already overflowing line into a nonempty area, including the ellipsis. +pub(crate) fn truncate_line_with_ellipsis(line: Line<'static>, max_width: usize) -> Line<'static> { + debug_assert!(max_width > 0); let truncated = truncate_line_to_width(line, max_width.saturating_sub(1)); let Line { style, diff --git a/codex-rs/tui/src/snapshots/codex_tui__ui_tree__tests__inline_modifiers_and_row_surfaces_follow_the_tree.snap b/codex-rs/tui/src/snapshots/codex_tui__ui_tree__tests__inline_modifiers_and_row_surfaces_follow_the_tree.snap new file mode 100644 index 0000000000..9c8b18a1f4 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__ui_tree__tests__inline_modifiers_and_row_surfaces_follow_the_tree.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/ui_tree_tests.rs +expression: "format!(\"{buffer:?}\")" +--- +Buffer { + area: Rect { x: 0, y: 0, width: 12, height: 1 }, + content: [ + "abc d e ]8;;https://example.comf]8;; ", + ], + styles: [ + x: 0, y: 0, fg: Reset, bg: Gray, underline: Reset, modifier: BOLD | ITALIC | UNDERLINED, + x: 1, y: 0, fg: Cyan, bg: Gray, underline: Reset, modifier: NONE, + x: 2, y: 0, fg: Reset, bg: Gray, underline: Reset, modifier: BOLD | ITALIC, + x: 3, y: 0, fg: Reset, bg: Gray, underline: Reset, modifier: NONE, + x: 4, y: 0, fg: Reset, bg: Blue, underline: Reset, modifier: NONE, + x: 7, y: 0, fg: Reset, bg: Gray, underline: Reset, modifier: NONE, + x: 8, y: 0, fg: Reset, bg: Gray, underline: Reset, modifier: UNDERLINED, + x: 9, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + ] +} diff --git a/codex-rs/tui/src/ui_tree.rs b/codex-rs/tui/src/ui_tree.rs index 45de0fc8ba..ead40c6883 100644 --- a/codex-rs/tui/src/ui_tree.rs +++ b/codex-rs/tui/src/ui_tree.rs @@ -2,6 +2,10 @@ mod decode; mod render; +mod theme; + +use theme::Color; +use theme::TextWeight; #[cfg(test)] #[path = "ui_tree_tests.rs"] @@ -21,7 +25,7 @@ pub(crate) struct UiTree(pub(crate) Option); #[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)] pub(crate) enum UiNode { Text { - #[serde(default, skip_serializing)] + #[serde(default, skip_serializing_if = "is_default")] props: TextProps, children: Vec>, }, @@ -33,13 +37,37 @@ pub(crate) enum UiNode { props: RowProps, children: Vec>, }, + Bold { + children: Vec>, + }, + Italic { + children: Vec>, + }, + Underline { + children: Vec>, + }, #[serde(untagged)] String(String), } -#[derive(Clone, Debug, Default, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct TextProps {} +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct TextProps { + #[serde( + deserialize_with = "decode::optional_token", + skip_serializing_if = "Option::is_none" + )] + color: Option, + #[serde( + deserialize_with = "decode::optional_token", + skip_serializing_if = "Option::is_none" + )] + weight: Option, + #[serde(skip_serializing_if = "Option::is_none")] + italic: Option, + #[serde(skip_serializing_if = "Option::is_none")] + underline: Option, +} #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -79,4 +107,14 @@ pub(crate) struct RowProps { /// Horizontal spacing in terminal columns, expressed as a non-negative JavaScript-safe integer. #[serde(deserialize_with = "decode::gap")] pub(crate) gap: u64, + #[serde( + default, + deserialize_with = "decode::optional_token", + skip_serializing_if = "Option::is_none" + )] + background: Option, +} + +fn is_default(value: &T) -> bool { + *value == T::default() } diff --git a/codex-rs/tui/src/ui_tree/decode.rs b/codex-rs/tui/src/ui_tree/decode.rs index 0377563bcf..01052cd1a3 100644 --- a/codex-rs/tui/src/ui_tree/decode.rs +++ b/codex-rs/tui/src/ui_tree/decode.rs @@ -6,7 +6,9 @@ use super::UiNode; use super::UiTree; use serde::Deserialize; use serde::Deserializer; +use serde::de::DeserializeOwned; use serde::de::Error; +use serde::de::IntoDeserializer; impl std::str::FromStr for UiTree { type Err = serde_json::Error; @@ -35,6 +37,9 @@ impl std::str::FromStr for UiTree { } } UiNode::Text { children, .. } + | UiNode::Bold { children } + | UiNode::Italic { children } + | UiNode::Underline { children } | UiNode::Link { children, .. } | UiNode::Row { children, .. } => { pending.extend(children.iter().flatten().map(|child| (child, depth + 1))); @@ -45,6 +50,14 @@ impl std::str::FromStr for UiTree { } } +pub(super) fn optional_token<'de, D: Deserializer<'de>, T: DeserializeOwned>( + d: D, +) -> Result, D::Error> { + Option::::deserialize(d)? + .map(|s| T::deserialize(s.into_deserializer())) + .transpose() +} + pub(super) fn gap<'de, D: Deserializer<'de>>(de: D) -> Result { let gap = u64::deserialize(de)?; if gap > MAX_GAP { diff --git a/codex-rs/tui/src/ui_tree/render.rs b/codex-rs/tui/src/ui_tree/render.rs index a2553d035a..2704a76420 100644 --- a/codex-rs/tui/src/ui_tree/render.rs +++ b/codex-rs/tui/src/ui_tree/render.rs @@ -1,13 +1,17 @@ //! Render the visible prefix of a horizontal display tree into one terminal line. use super::MAX_TEXT_BYTES; +use super::TextProps; +use super::TextWeight; use super::UiNode; use super::UiTree; use crate::history_cell::sanitize_user_text; -use crate::line_truncation::truncate_line_to_width; +use crate::line_truncation::truncate_line_with_ellipsis; use crate::terminal_hyperlinks::HyperlinkLine; use crate::terminal_hyperlinks::TerminalHyperlink; use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::text::Span; impl UiTree { /// Render one row. On overflow, fit an unlinked ellipsis inside the supplied width. @@ -16,47 +20,52 @@ impl UiTree { let Some(root) = self.0.as_ref().filter(|_| width > 0) else { return HyperlinkLine::default(); }; - let mut line = node_line(root, width); + let mut line = node_line(root, width, Style::default()); if line.width() > width { - line.line = truncate_line_to_width(line.line, width - 1); - line.clip_hyperlinks_to_width(line.width()); - line.line.push_span("…"); + line.line = truncate_line_with_ellipsis(line.line, width); + line.clip_hyperlinks_to_width(line.width() - 1); } line } } -fn node_line(node: &UiNode, width: usize) -> HyperlinkLine { - match node { +fn node_line(node: &UiNode, width: usize, style: Style) -> HyperlinkLine { + let (children, gap, style) = match node { UiNode::String(raw) => { // Built-in values are constructed directly, bypassing the wire decoder. let raw = &raw[..raw.floor_char_boundary(MAX_TEXT_BYTES)]; let first = raw.split('\n').next().unwrap_or_default(); - sanitize_user_text(first.into()) - .replace('\t', " ") - .into() + let visible = sanitize_user_text(first.into()).replace('\t', " "); + return HyperlinkLine::new(Span::styled(visible, style).into()); } UiNode::Link { props, children } => { - let mut line = row_line(children, /*gap*/ 0, width); - for span in &mut line.line.spans { - span.style = span.style.add_modifier(Modifier::UNDERLINED); - } + let mut line = row_line(children, /*gap*/ 0, width, style.underlined()); let width = line.width(); if width > 0 { line.hyperlinks = vec![TerminalHyperlink::web(0..width, props.href.0.clone())]; } - line + return line; } - UiNode::Row { props, children } => row_line(children, props.gap, width), - UiNode::Text { children, .. } => row_line(children, /*gap*/ 0, width), - } + UiNode::Row { props, children } => { + let style = props + .background + .as_ref() + .map_or(style, |color| style.bg(color.resolve())); + (children, props.gap, style) + } + UiNode::Text { props, children } => (children, 0, props.style(style)), + UiNode::Bold { children } => (children, 0, style.bold()), + UiNode::Italic { children } => (children, 0, style.italic()), + UiNode::Underline { children } => (children, 0, style.underlined()), + }; + row_line(children, gap, width, style) } -fn row_line(children: &[Option], gap: u64, width: usize) -> HyperlinkLine { +fn row_line(children: &[Option], gap: u64, width: usize, style: Style) -> HyperlinkLine { let mut result = HyperlinkLine::default(); let mut used = 0; for child in children.iter().flatten() { - let child = node_line(child, width); + let child = node_line(child, width, style); let child_width = child.width(); if child_width == 0 { continue; @@ -64,7 +73,9 @@ fn row_line(children: &[Option], gap: u64, width: usize) -> HyperlinkLin if used > 0 && gap > 0 { // One column past the viewport is sufficient to detect overflow. let spacing = gap.min((width + 1 - used) as u64) as usize; - result.line.push_span(" ".repeat(spacing)); + result + .line + .push_span(Span::styled(" ".repeat(spacing), style)); used += spacing; } let offset = used; @@ -82,3 +93,26 @@ fn row_line(children: &[Option], gap: u64, width: usize) -> HyperlinkLin } result } + +impl TextProps { + fn style(&self, mut style: Style) -> Style { + if let Some(color) = &self.color { + style = style.fg(color.resolve()); + } + for (enabled, modifier) in [ + ( + self.weight.map(|weight| weight == TextWeight::Bold), + Modifier::BOLD, + ), + (self.italic, Modifier::ITALIC), + (self.underline, Modifier::UNDERLINED), + ] { + match enabled { + Some(true) => style = style.add_modifier(modifier), + Some(false) => style = style.remove_modifier(modifier), + None => {} + } + } + style + } +} diff --git a/codex-rs/tui/src/ui_tree/theme.rs b/codex-rs/tui/src/ui_tree/theme.rs new file mode 100644 index 0000000000..1efa59949d --- /dev/null +++ b/codex-rs/tui/src/ui_tree/theme.rs @@ -0,0 +1,70 @@ +//! Literal provider colors use the terminal's palette at paint time. + +use crate::terminal_palette; +use ratatui::style::Color as TerminalColor; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(super) enum Color { + Black, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + White, + #[serde(untagged)] + Rgb(HexRgb), +} + +impl Color { + pub(super) fn resolve(&self) -> TerminalColor { + match self { + Self::Rgb(rgb) => terminal_palette::best_color(rgb.0), + Self::Black => TerminalColor::Black, + Self::Red => TerminalColor::Red, + Self::Green => TerminalColor::Green, + Self::Yellow => TerminalColor::Yellow, + Self::Blue => TerminalColor::Blue, + Self::Magenta => TerminalColor::Magenta, + Self::Cyan => TerminalColor::Cyan, + // Ratatui maps Gray to ANSI slot 7 and White to slot 15. + Self::White => TerminalColor::Gray, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub(super) struct HexRgb((u8, u8, u8)); + +impl TryFrom for HexRgb { + type Error = &'static str; + + fn try_from(source: String) -> Result { + const INVALID: &str = "expected #RRGGBB"; + if source.len() != 7 { + return Err(INVALID); + } + let color = source + .parse::() + .map_err(|_| INVALID)?; + Ok(Self((color.r, color.g, color.b))) + } +} + +impl From for String { + fn from(HexRgb((r, g, b)): HexRgb) -> Self { + format!("#{r:02x}{g:02x}{b:02x}") + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(super) enum TextWeight { + Normal, + Bold, +} diff --git a/codex-rs/tui/src/ui_tree_tests.rs b/codex-rs/tui/src/ui_tree_tests.rs index 9aa2574033..28ed4227ed 100644 --- a/codex-rs/tui/src/ui_tree_tests.rs +++ b/codex-rs/tui/src/ui_tree_tests.rs @@ -56,7 +56,14 @@ fn rejects_invalid_gaps_and_elements() { fn deserialization_checks_limits_across_the_whole_tree() { let parent = |children: Vec| json!({"type":"text","children":children}); assert!(parse(parent(vec![Value::Null; 257])).is_ok()); - let nested = (0..16).fold(json!("X"), |child, _| parent(vec![child])); + let nested = ["text", "bold", "italic", "underline"] + .into_iter() + .cycle() + .take(16) + .fold( + json!("X"), + |child, kind| json!({"type":kind,"children":[child]}), + ); assert!(parse(nested.clone()).is_ok()); assert!(parse(parent(vec![nested])).is_err()); let mut leaves = vec![json!(""); 255]; @@ -159,3 +166,93 @@ fn directly_constructed_text_keeps_a_bounded_utf8_prefix() { let text = UiTree(Some(UiNode::String(format!("{prefix}界tail")))); assert_eq!(text.render_line(u16::MAX).line.to_string(), prefix); } + +#[test] +fn text_and_row_share_the_literal_color_contract() { + for color in ["red", "white", "#ab12f0"] { + let value = json!({"type":"row","props":{"gap":0,"background":color},"children":[ + {"type":"text","props":{"color":color,"weight":"bold","italic":true,"underline":false},"children":["ok"]} + ]}); + assert_eq!(serde_json::to_value(tree(value.clone())).unwrap(), value); + } + for props in [ + json!({"weight":"semibold"}), + json!({"weight":{"bold":null}}), + json!({"color":{"red":null}}), + json!({"color":"#abcdef00"}), + json!({"color":"#+12345"}), + json!({"color":"#123"}), + json!({"color":"teal"}), + json!({"underline":"dotted"}), + ] { + assert!( + serde_json::from_value::(json!({"type":"text","props":props,"children":[]})) + .is_err(), + "{props}" + ); + } + assert!( + serde_json::from_value::(json!({"type":"italic","props":{},"children":[]})) + .is_err() + ); +} + +#[test] +fn inline_modifiers_and_row_surfaces_follow_the_tree() { + let node = tree(json!({ + "type":"row", "props":{"gap":1,"background":"white"}, "children":[ + {"type":"bold","children":[{"type":"italic","children":[ + {"type":"underline","children":["a"]}, + {"type":"text","props":{"weight":"normal","italic":false,"color":"cyan"},"children":["b"]}, + "c" + ]}]}, + {"type":"row","props":{"gap":1,"background":"blue"},"children":["d","e"]}, + {"type":"link","props":{"href":"https://example.com"},"children":["f"]} + ] + })); + let clipped = node.render_line(/*width*/ 6); + 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); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 12, /*height*/ 1, + ); + let mut buffer = Buffer::empty(area); + line.render_fitted(area, &mut buffer); + insta::assert_snapshot!(format!("{buffer:?}")); +} + +#[test] +fn hex_colors_use_the_terminal_palette_for_both_channels() { + let tree = tree(json!({ + "type":"row", "props":{"gap":1,"background":"#1020F0"}, "children":[ + {"type":"text","props":{"color":"#AB12f0"},"children":["a"]}, "b" + ] + })); + crate::terminal_palette::with_test_default_colors( + crate::terminal_probe::DefaultColors { + fg: (255, 255, 255), + bg: (0, 0, 0), + }, + || { + let line = tree.render_line(/*width*/ 3); + let colors: Vec<_> = line + .line + .spans + .iter() + .map(|s| (s.style.fg, s.style.bg)) + .collect(); + let surface = Some(crate::terminal_palette::rgb_color((16, 32, 240))); + let foreground = Some(crate::terminal_palette::rgb_color((171, 18, 240))); + assert_eq!( + colors, + [(foreground, surface), (None, surface), (None, surface)] + ); + }, + ); +}