Skip to main content

urushi/render/
ansi.rs

1//! Serialization of a resolved rectangle.
2
3use urushi_terminal::{
4    HyperlinkParameter, TerminalHyperlink, TerminalStyle, backend::ansi::AnsiWriter,
5};
6
7use super::TerminalTextStyle;
8use crate::{Hyperlink, RenderSettings, ResolvedView, StyledGrapheme, StyledText, TextStyle};
9
10/// Serializes `view` using the selected output features.
11///
12/// Rendering does not perform layout, inspect a terminal, or write bytes. The
13/// caller must resolve the view first and explicitly choose the settings.
14pub fn render(view: &ResolvedView, settings: &RenderSettings) -> String {
15    view.rows()
16        .iter()
17        .map(|row| serialize_row(row, settings))
18        .collect::<Vec<_>>()
19        .join("\n")
20}
21
22/// Serializes styled source text without resolving terminal-cell layout.
23///
24/// Tabs and line boundaries are written as authored. Callers choosing this
25/// path accept the destination terminal's tab-stop behavior; use a `View` and
26/// [`render`] when width, wrapping, alignment, or backend geometry matters.
27pub fn render_text(text: &StyledText, settings: &RenderSettings) -> String {
28    let mut output = String::new();
29    for (segment, style) in text.spans() {
30        output.push_str(&serialize_run(segment, &settings.resolve_text_style(style)));
31    }
32    output
33}
34
35fn serialize_row(row: &[StyledGrapheme], settings: &RenderSettings) -> String {
36    let mut output = String::new();
37    let mut index = 0;
38    while index < row.len() {
39        let style = settings.resolve_text_style(row[index].style());
40        let mut run = String::new();
41        while index < row.len() && settings.resolve_text_style(row[index].style()) == style {
42            run.push_str(row[index].symbol());
43            index += 1;
44        }
45        output.push_str(&serialize_run(&run, &style));
46    }
47    output
48}
49
50fn serialize_run(text: &str, style: &TextStyle) -> String {
51    if text.is_empty() {
52        return String::new();
53    }
54    let terminal = TerminalTextStyle::from(style);
55    let terminal_style = terminal.style();
56    let Some(hyperlink) = terminal.hyperlink() else {
57        if terminal_style == TerminalStyle::default() {
58            return text.to_owned();
59        }
60        return paint_line(text, terminal_style, None);
61    };
62    let parameters = hyperlink_parameters(hyperlink);
63    let hyperlink = TerminalHyperlink {
64        uri: hyperlink.uri(),
65        parameters: &parameters,
66    };
67    if !text.contains('\n') {
68        return paint_line(text, terminal_style, Some(hyperlink));
69    }
70
71    let mut output = String::with_capacity(text.len());
72    for segment in text.split_inclusive('\n') {
73        let line = segment.strip_suffix('\n').unwrap_or(segment);
74        output.push_str(&paint_line(line, terminal_style, Some(hyperlink)));
75        if segment.ends_with('\n') {
76            output.push('\n');
77        }
78    }
79    output
80}
81
82fn hyperlink_parameters(hyperlink: &Hyperlink) -> Vec<HyperlinkParameter<'_>> {
83    hyperlink
84        .parameters()
85        .iter()
86        .map(|(key, value)| HyperlinkParameter { key, value })
87        .collect()
88}
89
90fn paint_line(
91    text: &str,
92    style: TerminalStyle,
93    hyperlink: Option<TerminalHyperlink<'_>>,
94) -> String {
95    if text.is_empty() {
96        return String::new();
97    }
98    let mut encoder = AnsiWriter::new(Vec::with_capacity(text.len()));
99    if let Some(hyperlink) = hyperlink {
100        encoder
101            .write_hyperlink_start(hyperlink)
102            .expect("writing to a Vec cannot fail");
103    }
104    encoder
105        .write_style_prefix(style)
106        .expect("writing to a Vec cannot fail");
107    encoder.writer_mut().extend_from_slice(text.as_bytes());
108    if style != TerminalStyle::default() {
109        encoder
110            .write_style_reset()
111            .expect("writing to a Vec cannot fail");
112    }
113    if hyperlink.is_some() {
114        encoder
115            .write_hyperlink_end()
116            .expect("writing to a Vec cannot fail");
117    }
118    String::from_utf8(encoder.into_inner()).expect("ANSI encoding preserves UTF-8")
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use urushi_terminal::{
125        Command, CommandWriter, TerminalText, TextAttributes, Underline, UnderlineStyle,
126        backend::ansi::AnsiWriter,
127    };
128
129    use crate::{Available, Color, Hyperlink, TextStyle, View, resolve};
130
131    #[test]
132    fn rendering_preserves_logical_styles_in_the_resolved_view() {
133        let style = TextStyle::new().foreground(Color::Rgb(10, 20, 30)).bold();
134        let resolved = resolve(&View::text("result", style.clone()), Available::NONE).unwrap();
135
136        assert_eq!(render(&resolved, &RenderSettings::default()), "result");
137        assert_eq!(resolved.rows()[0][0].style(), &style);
138    }
139
140    #[test]
141    fn hyperlinks_are_selected_independently_from_sgr_features() {
142        let style = TextStyle::new()
143            .hyperlink(Hyperlink::new("https://example.com").parameter("id", "docs"));
144        let resolved = resolve(&View::text("link", style), Available::NONE).unwrap();
145
146        assert_eq!(render(&resolved, &RenderSettings::default()), "link");
147        assert_eq!(
148            render(&resolved, &RenderSettings::default().hyperlinks(true)),
149            "\x1b]8;id=docs;https://example.com\x1b\\link\x1b]8;;\x1b\\"
150        );
151    }
152
153    #[test]
154    fn direct_text_rendering_preserves_tabs_and_style_spans() {
155        let text = StyledText::try_from_spans([
156            crate::TextSpan::new("name\t", TextStyle::new().bold()),
157            crate::TextSpan::new("値", TextStyle::new().foreground(Color::RED)),
158        ])
159        .unwrap();
160        let settings = RenderSettings::default()
161            .color_level(crate::ColorLevel::Ansi16)
162            .attributes(crate::TextAttribute::Bold.into());
163
164        assert_eq!(
165            render_text(&text, &settings),
166            "\x1b[1mname\t\x1b[0m\x1b[31m値\x1b[0m"
167        );
168    }
169
170    #[test]
171    fn static_and_command_paths_share_every_style_parameter_mapping() {
172        for underline_style in [
173            UnderlineStyle::Single,
174            UnderlineStyle::Double,
175            UnderlineStyle::Curly,
176            UnderlineStyle::Dotted,
177            UnderlineStyle::Dashed,
178        ] {
179            let style = TextStyle::new()
180                .foreground(Color::Ansi(3))
181                .background(Color::Ansi256(212))
182                .add_attributes(TextAttributes::all())
183                .underline(Underline::new(underline_style).color(Color::Rgb(1, 2, 3)));
184            let static_output =
185                render_text(&StyledText::new("x", style.clone()), &RenderSettings::all());
186            let terminal_style = TerminalTextStyle::from(&style).style();
187            let mut command_output = AnsiWriter::new(Vec::new());
188            command_output
189                .write_command(Command::SetStyle(terminal_style))
190                .expect("command style encodes");
191            command_output
192                .write_command(Command::Print(
193                    TerminalText::try_from("x").expect("text is printable"),
194                ))
195                .expect("command text encodes");
196            command_output
197                .write_command(Command::ResetStyle)
198                .expect("command reset encodes");
199            let command_output =
200                String::from_utf8(command_output.into_inner()).expect("ANSI is UTF-8");
201
202            let static_parameters = static_output
203                .strip_prefix("\x1b[")
204                .and_then(|output| output.split_once('m'))
205                .map(|(parameters, _)| parameters)
206                .expect("static output starts with SGR");
207            let command_parameters = command_output
208                .strip_prefix("\x1b[0;")
209                .and_then(|output| output.split_once('m'))
210                .map(|(parameters, _)| parameters)
211                .expect("command output starts with reset plus SGR");
212
213            assert_eq!(static_parameters, command_parameters);
214            assert!(static_output.ends_with("mx\x1b[0m"));
215            assert!(command_output.ends_with("mx\x1b[0m"));
216        }
217    }
218
219    #[test]
220    fn static_and_command_paths_share_hyperlink_escaping_and_framing() {
221        let hyperlink = Hyperlink::new("https://example.test/a\u{1b}\u{9c}")
222            .parameter("i:d=;\u{9d}", "v:a=l;ue\n\u{9b}");
223        let static_output = render_text(
224            &StyledText::new("link", TextStyle::new().hyperlink(hyperlink.clone())),
225            &RenderSettings::all(),
226        );
227        let parameters = hyperlink_parameters(&hyperlink);
228        let mut command_output = AnsiWriter::new(Vec::new());
229        command_output
230            .write_command(Command::SetHyperlink(Some(TerminalHyperlink {
231                uri: hyperlink.uri(),
232                parameters: &parameters,
233            })))
234            .expect("command hyperlink encodes");
235        command_output
236            .write_command(Command::Print(
237                TerminalText::try_from("link").expect("text is printable"),
238            ))
239            .expect("command text encodes");
240        command_output
241            .write_command(Command::SetHyperlink(None))
242            .expect("command hyperlink closes");
243
244        assert_eq!(static_output.as_bytes(), command_output.into_inner());
245    }
246}