1use std::fmt;
2use std::io;
3use std::io::Write;
4
5use crate::tui;
6use ratatui::crossterm::Command;
7use ratatui::crossterm::cursor::MoveTo;
8use ratatui::crossterm::queue;
9use ratatui::crossterm::style::Attribute as CAttribute;
10use ratatui::crossterm::style::Color as CColor;
11use ratatui::crossterm::style::Colors;
12use ratatui::crossterm::style::Print;
13use ratatui::crossterm::style::SetAttribute;
14use ratatui::crossterm::style::SetBackgroundColor;
15use ratatui::crossterm::style::SetColors;
16use ratatui::crossterm::style::SetForegroundColor;
17use ratatui::layout::Size;
18use ratatui::style::Color;
19use ratatui::style::Modifier;
20use ratatui::text::Line;
21use ratatui::text::Span;
22use textwrap::Options as TwOptions;
23use textwrap::WordSplitter;
24
25pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
27 let mut out = std::io::stdout();
28 insert_history_lines_to_writer(terminal, &mut out, lines);
29}
30
31pub fn insert_history_lines_to_writer<B, W>(
34 terminal: &mut crate::custom_terminal::Terminal<B>,
35 writer: &mut W,
36 lines: Vec<Line>,
37) where
38 B: ratatui::backend::Backend,
39 W: Write,
40{
41 let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
42 let cursor_pos = terminal.get_cursor_position().ok();
43
44 let mut area = terminal.get_frame().area();
45
46 let wrapped = word_wrap_lines(&lines, area.width.max(1));
49 let wrapped_lines = wrapped.len() as u16;
50 let cursor_top = if area.bottom() < screen_size.height {
51 let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom());
54
55 let top_1based = area.top() + 1; queue!(writer, SetScrollRegion(top_1based..screen_size.height)).ok();
63 queue!(writer, MoveTo(0, area.top())).ok();
64 for _ in 0..scroll_amount {
65 queue!(writer, Print("\x1bM")).ok();
67 }
68 queue!(writer, ResetScrollRegion).ok();
69
70 let cursor_top = area.top().saturating_sub(1);
71 area.y += scroll_amount;
72 terminal.set_viewport_area(area);
73 cursor_top
74 } else {
75 area.top().saturating_sub(1)
76 };
77
78 queue!(writer, SetScrollRegion(1..area.top())).ok();
94
95 queue!(writer, MoveTo(0, cursor_top)).ok();
99
100 for line in wrapped {
101 queue!(writer, Print("\r\n")).ok();
102 write_spans(writer, line.iter()).ok();
103 }
104
105 queue!(writer, ResetScrollRegion).ok();
106
107 if let Some(cursor_pos) = cursor_pos {
109 queue!(writer, MoveTo(cursor_pos.x, cursor_pos.y)).ok();
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct SetScrollRegion(pub std::ops::Range<u16>);
115
116impl Command for SetScrollRegion {
117 fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
118 write!(f, "\x1b[{};{}r", self.0.start, self.0.end)
119 }
120
121 #[cfg(windows)]
122 fn execute_winapi(&self) -> std::io::Result<()> {
123 panic!("tried to execute SetScrollRegion command using WinAPI, use ANSI instead");
124 }
125
126 #[cfg(windows)]
127 fn is_ansi_code_supported(&self) -> bool {
128 true
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct ResetScrollRegion;
135
136impl Command for ResetScrollRegion {
137 fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
138 write!(f, "\x1b[r")
139 }
140
141 #[cfg(windows)]
142 fn execute_winapi(&self) -> std::io::Result<()> {
143 panic!("tried to execute ResetScrollRegion command using WinAPI, use ANSI instead");
144 }
145
146 #[cfg(windows)]
147 fn is_ansi_code_supported(&self) -> bool {
148 true
150 }
151}
152
153struct ModifierDiff {
154 pub from: Modifier,
155 pub to: Modifier,
156}
157
158impl ModifierDiff {
159 fn queue<W>(self, mut w: W) -> io::Result<()>
160 where
161 W: io::Write,
162 {
163 use ratatui::crossterm::style::Attribute as CAttribute;
164 let removed = self.from - self.to;
165 if removed.contains(Modifier::REVERSED) {
166 queue!(w, SetAttribute(CAttribute::NoReverse))?;
167 }
168 if removed.contains(Modifier::BOLD) {
169 queue!(w, SetAttribute(CAttribute::NormalIntensity))?;
170 if self.to.contains(Modifier::DIM) {
171 queue!(w, SetAttribute(CAttribute::Dim))?;
172 }
173 }
174 if removed.contains(Modifier::ITALIC) {
175 queue!(w, SetAttribute(CAttribute::NoItalic))?;
176 }
177 if removed.contains(Modifier::UNDERLINED) {
178 queue!(w, SetAttribute(CAttribute::NoUnderline))?;
179 }
180 if removed.contains(Modifier::DIM) {
181 queue!(w, SetAttribute(CAttribute::NormalIntensity))?;
182 }
183 if removed.contains(Modifier::CROSSED_OUT) {
184 queue!(w, SetAttribute(CAttribute::NotCrossedOut))?;
185 }
186 if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) {
187 queue!(w, SetAttribute(CAttribute::NoBlink))?;
188 }
189
190 let added = self.to - self.from;
191 if added.contains(Modifier::REVERSED) {
192 queue!(w, SetAttribute(CAttribute::Reverse))?;
193 }
194 if added.contains(Modifier::BOLD) {
195 queue!(w, SetAttribute(CAttribute::Bold))?;
196 }
197 if added.contains(Modifier::ITALIC) {
198 queue!(w, SetAttribute(CAttribute::Italic))?;
199 }
200 if added.contains(Modifier::UNDERLINED) {
201 queue!(w, SetAttribute(CAttribute::Underlined))?;
202 }
203 if added.contains(Modifier::DIM) {
204 queue!(w, SetAttribute(CAttribute::Dim))?;
205 }
206 if added.contains(Modifier::CROSSED_OUT) {
207 queue!(w, SetAttribute(CAttribute::CrossedOut))?;
208 }
209 if added.contains(Modifier::SLOW_BLINK) {
210 queue!(w, SetAttribute(CAttribute::SlowBlink))?;
211 }
212 if added.contains(Modifier::RAPID_BLINK) {
213 queue!(w, SetAttribute(CAttribute::RapidBlink))?;
214 }
215
216 Ok(())
217 }
218}
219
220fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()>
221where
222 I: Iterator<Item = &'a Span<'a>>,
223{
224 let mut fg = Color::Reset;
225 let mut bg = Color::Reset;
226 let mut last_modifier = Modifier::empty();
227 for span in content {
228 let mut modifier = Modifier::empty();
229 modifier.insert(span.style.add_modifier);
230 modifier.remove(span.style.sub_modifier);
231 if modifier != last_modifier {
232 let diff = ModifierDiff {
233 from: last_modifier,
234 to: modifier,
235 };
236 diff.queue(&mut writer)?;
237 last_modifier = modifier;
238 }
239 let next_fg = span.style.fg.unwrap_or(Color::Reset);
240 let next_bg = span.style.bg.unwrap_or(Color::Reset);
241 if next_fg != fg || next_bg != bg {
242 queue!(
243 writer,
244 SetColors(Colors::new(next_fg.into(), next_bg.into()))
245 )?;
246 fg = next_fg;
247 bg = next_bg;
248 }
249
250 queue!(writer, Print(span.content.clone()))?;
251 }
252
253 queue!(
254 writer,
255 SetForegroundColor(CColor::Reset),
256 SetBackgroundColor(CColor::Reset),
257 SetAttribute(CAttribute::Reset),
258 )
259}
260
261pub(crate) fn word_wrap_lines(lines: &[Line], width: u16) -> Vec<Line<'static>> {
263 let mut out = Vec::new();
264 let w = width.max(1) as usize;
265 for line in lines {
266 out.extend(word_wrap_line(line, w));
267 }
268 out
269}
270
271fn word_wrap_line(line: &Line, width: usize) -> Vec<Line<'static>> {
272 if width == 0 {
273 return vec![to_owned_line(line)];
274 }
275 let mut flat = String::new();
277 let mut span_bounds = Vec::new(); let mut cursor = 0usize;
279 for s in &line.spans {
280 let text = s.content.as_ref();
281 let start = cursor;
282 flat.push_str(text);
283 cursor += text.len();
284 span_bounds.push((start, cursor, s.style));
285 }
286
287 let opts = TwOptions::new(width)
289 .break_words(false)
290 .word_splitter(WordSplitter::NoHyphenation);
291 let wrapped = textwrap::wrap(&flat, &opts);
292
293 if wrapped.len() <= 1 {
294 return vec![to_owned_line(line)];
295 }
296
297 let mut start_cursor = 0usize;
299 let mut out: Vec<Line<'static>> = Vec::with_capacity(wrapped.len());
300 for piece in wrapped {
301 let piece_str: &str = &piece;
302 if piece_str.is_empty() {
303 out.push(Line {
304 style: line.style,
305 alignment: line.alignment,
306 spans: Vec::new(),
307 });
308 continue;
309 }
310 if let Some(rel) = flat[start_cursor..].find(piece_str) {
313 let s = start_cursor + rel;
314 let e = s + piece_str.len();
315 out.push(slice_line_spans(line, &span_bounds, s, e));
316 start_cursor = e;
317 } else {
318 let s = start_cursor;
320 let e = (start_cursor + piece_str.len()).min(flat.len());
321 out.push(slice_line_spans(line, &span_bounds, s, e));
322 start_cursor = e;
323 }
324 }
325
326 out
327}
328
329fn to_owned_line(l: &Line<'_>) -> Line<'static> {
330 Line {
331 style: l.style,
332 alignment: l.alignment,
333 spans: l
334 .spans
335 .iter()
336 .map(|s| Span {
337 style: s.style,
338 content: std::borrow::Cow::Owned(s.content.to_string()),
339 })
340 .collect(),
341 }
342}
343
344fn slice_line_spans(
345 original: &Line<'_>,
346 span_bounds: &[(usize, usize, ratatui::style::Style)],
347 start_byte: usize,
348 end_byte: usize,
349) -> Line<'static> {
350 let mut acc: Vec<Span<'static>> = Vec::new();
351 for (i, (s, e, style)) in span_bounds.iter().enumerate() {
352 if *e <= start_byte {
353 continue;
354 }
355 if *s >= end_byte {
356 break;
357 }
358 let seg_start = start_byte.max(*s);
359 let seg_end = end_byte.min(*e);
360 if seg_end > seg_start {
361 let local_start = seg_start - *s;
362 let local_end = seg_end - *s;
363 let content = original.spans[i].content.as_ref();
364 let slice = &content[local_start..local_end];
365 acc.push(Span {
366 style: *style,
367 content: std::borrow::Cow::Owned(slice.to_string()),
368 });
369 }
370 if *e >= end_byte {
371 break;
372 }
373 }
374 Line {
375 style: original.style,
376 alignment: original.alignment,
377 spans: acc,
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use ratatui::crossterm::style::Attribute as CAttribute;
385
386 #[test]
387 fn writes_bold_then_regular_spans() {
388 use ratatui::style::Stylize;
389
390 let spans = ["A".bold(), "B".into()];
391
392 let mut actual: Vec<u8> = Vec::new();
393 write_spans(&mut actual, spans.iter()).unwrap();
394
395 let mut expected: Vec<u8> = Vec::new();
396 queue!(
397 expected,
398 SetAttribute(CAttribute::Bold),
399 Print("A"),
400 SetAttribute(CAttribute::NormalIntensity),
401 Print("B"),
402 SetForegroundColor(CColor::Reset),
403 SetBackgroundColor(CColor::Reset),
404 SetAttribute(CAttribute::Reset),
405 )
406 .unwrap();
407
408 assert_eq!(
409 String::from_utf8(actual).unwrap(),
410 String::from_utf8(expected).unwrap()
411 );
412 }
413
414 #[test]
415 fn line_height_counts_double_width_emoji() {
416 let line = Line::from("😀😀😀"); assert_eq!(word_wrap_line(&line, 4).len(), 2);
418 assert_eq!(word_wrap_line(&line, 2).len(), 3);
419 assert_eq!(word_wrap_line(&line, 6).len(), 1);
420 }
421
422 #[test]
423 fn word_wrap_does_not_split_words_simple_english() {
424 let sample = "Years passed, and Willowmere thrived in peace and friendship. Mira’s herb garden flourished with both ordinary and enchanted plants, and travelers spoke of the kindness of the woman who tended them.";
425 let line = Line::from(sample);
426 let wrapped = word_wrap_lines(&[line], 40);
428 let joined: String = wrapped
429 .iter()
430 .map(|l| {
431 l.spans
432 .iter()
433 .map(|s| s.content.clone())
434 .collect::<String>()
435 })
436 .collect::<Vec<_>>()
437 .join("\n");
438 assert!(
439 !joined.contains("bo\nth"),
440 "word 'both' should not be split across lines:\n{joined}"
441 );
442 assert!(
443 !joined.contains("Willowm\nere"),
444 "should not split inside words:\n{joined}"
445 );
446 }
447}