1use urushi_terminal::{
4 HyperlinkParameter, TerminalHyperlink, TerminalStyle, backend::ansi::AnsiWriter,
5};
6
7use super::TerminalTextStyle;
8use crate::{Hyperlink, RenderSettings, ResolvedView, StyledGrapheme, StyledText, TextStyle};
9
10pub 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
22pub 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: ¶meters,
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: ¶meters,
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}