Skip to main content

rab/tui/
screen.rs

1use std::io::{self, Write};
2
3use crate::tui::focusable::CURSOR_MARKER;
4
5/// The diff renderer - maintains previous frame and emits minimal ANSI updates.
6pub struct Screen {
7    prev_lines: Vec<String>,
8    prev_width: u16,
9    prev_height: u16,
10    cursor_row: usize,
11    hardware_cursor_row: usize,
12    prev_viewport_top: usize,
13    max_lines_rendered: usize,
14    full_redraw_count: usize,
15    clear_on_shrink: bool,
16    /// Whether to use synchronized output markers (\x1b[?2026h / \x1b[?2026l).
17    /// Enabled by default (matching pi) to prevent flicker during differential renders.
18    use_sync_output: bool,
19}
20
21impl Screen {
22    pub fn new() -> Self {
23        Self {
24            prev_lines: Vec::new(),
25            prev_width: 0,
26            prev_height: 0,
27            cursor_row: 0,
28            hardware_cursor_row: 0,
29            prev_viewport_top: 0,
30            max_lines_rendered: 0,
31            full_redraw_count: 0,
32            clear_on_shrink: true,
33            use_sync_output: true,
34        }
35    }
36
37    /// Viewport top position (first visible line in terminal)
38    pub fn prev_viewport_top(&self) -> usize {
39        self.prev_viewport_top
40    }
41
42    /// The row where the hardware cursor physically sits after the last render.
43    /// Used by TUI to position the cursor to the marker position via relative
44    /// movements (matching pi's approach).
45    pub fn hardware_cursor_row(&self) -> usize {
46        self.hardware_cursor_row
47    }
48
49    /// Update the tracked hardware cursor row after TUI repositions the cursor
50    /// (matching pi's hardwareCursorRow = targetRow in positionHardwareCursor).
51    pub fn set_hardware_cursor_row(&mut self, row: usize) {
52        self.hardware_cursor_row = row;
53    }
54
55    /// Extract cursor marker from lines and return its (row, col) position.
56    /// Strips the marker from the line in-place.
57    /// Returns None if no marker is found.
58    pub(crate) fn extract_cursor_marker(
59        &self,
60        lines: &mut [String],
61        height: usize,
62    ) -> Option<(usize, usize)> {
63        let viewport_top = lines.len().saturating_sub(height);
64        for row in (viewport_top..lines.len()).rev() {
65            let line = &lines[row];
66            if let Some(marker_idx) = line.find(CURSOR_MARKER) {
67                use crate::tui::util::visible_width;
68                let col = visible_width(&line[..marker_idx]);
69                // Strip marker
70                let before = &line[..marker_idx];
71                let after = &line[marker_idx + CURSOR_MARKER.len()..];
72                lines[row] = format!("{}{}", before, after);
73                return Some((row, col));
74            }
75        }
76        None
77    }
78
79    pub fn prev_width(&self) -> usize {
80        self.prev_width as usize
81    }
82
83    pub fn prev_height(&self) -> usize {
84        self.prev_height as usize
85    }
86
87    pub fn full_redraw_count(&self) -> usize {
88        self.full_redraw_count
89    }
90
91    /// Total number of lines in the last rendered frame.
92    pub fn total_lines(&self) -> usize {
93        self.prev_lines.len()
94    }
95
96    /// Move cursor to one line past all rendered content (for clean program exit).
97    /// Writes the ANSI cursor-positioning sequences and `\r\n` so that subsequent
98    /// shell output appears on a fresh line after all TUI content.
99    pub fn finalize(&mut self, writer: &mut dyn Write) -> io::Result<()> {
100        if self.prev_lines.is_empty() {
101            return Ok(());
102        }
103        let target_row = self.prev_lines.len(); // one past the last content line
104        let line_diff = target_row as i64 - self.hardware_cursor_row as i64;
105        let mut buf = String::new();
106        if line_diff > 0 {
107            buf.push_str(&format!("\x1b[{}B", line_diff));
108        } else if line_diff < 0 {
109            buf.push_str(&format!("\x1b[{}A", -line_diff));
110        }
111        buf.push_str("\r\n");
112        write!(writer, "{}", buf)?;
113        writer.flush()?;
114        Ok(())
115    }
116
117    pub fn set_clear_on_shrink(&mut self, enabled: bool) {
118        self.clear_on_shrink = enabled;
119    }
120
121    /// Enable or disable synchronized output markers (\x1b[?2026h / \x1b[?2026l).
122    /// Enabled by default (matching pi's always-on approach).
123    pub fn set_use_sync_output(&mut self, enabled: bool) {
124        self.use_sync_output = enabled;
125    }
126
127    /// Emit synchronized output begin marker if enabled.
128    fn sync_begin(&self, buf: &mut String) {
129        if self.use_sync_output {
130            buf.push_str("\x1b[?2026h");
131        }
132    }
133
134    /// Emit synchronized output end marker if enabled.
135    fn sync_end(&self, buf: &mut String) {
136        if self.use_sync_output {
137            buf.push_str("\x1b[?2026l");
138        }
139    }
140
141    fn full_render(
142        &mut self,
143        lines: &[String],
144        w: &mut dyn Write,
145        clear: bool,
146        width: usize,
147        height: usize,
148    ) -> io::Result<()> {
149        self.full_redraw_count += 1;
150        let mut buf = String::new();
151
152        if clear {
153            buf.push_str("\x1b[2J\x1b[H\x1b[3J");
154        }
155
156        if lines.is_empty() {
157            self.sync_begin(&mut buf);
158            self.sync_end(&mut buf);
159            write!(w, "{}", buf)?;
160            w.flush()?;
161            self.cursor_row = 0;
162            self.hardware_cursor_row = 0;
163            self.max_lines_rendered = 0;
164            self.prev_viewport_top = 0;
165            self.prev_lines = lines.to_vec();
166            self.prev_width = width as u16;
167            self.prev_height = height as u16;
168            return Ok(());
169        }
170
171        self.sync_begin(&mut buf);
172
173        for (i, line) in lines.iter().enumerate() {
174            if i > 0 {
175                buf.push_str("\r\n");
176            }
177            buf.push_str(line);
178        }
179
180        self.sync_end(&mut buf);
181        write!(w, "{}", buf)?;
182        w.flush()?;
183
184        self.cursor_row = lines.len().saturating_sub(1);
185        self.hardware_cursor_row = self.cursor_row;
186        if clear {
187            self.max_lines_rendered = lines.len();
188        } else {
189            self.max_lines_rendered = self.max_lines_rendered.max(lines.len());
190        }
191        let buffer_len = height.max(lines.len());
192        self.prev_viewport_top = buffer_len.saturating_sub(height);
193        self.prev_lines = lines.to_vec();
194        self.prev_width = width as u16;
195        self.prev_height = height as u16;
196
197        Ok(())
198    }
199
200    /// Render new lines to the terminal using differential updates.
201    /// `writer` should be the terminal's stdout (in raw mode).
202    /// `width` and `height` are the current terminal dimensions.
203    ///
204    /// Lines may contain cursor markers (`CURSOR_MARKER`) which are extracted
205    /// and used for cursor tracking. Returns the cursor (row, col) position
206    /// if a marker was found, or None.
207    pub fn render(
208        &mut self,
209        mut new_lines: Vec<String>,
210        width: u16,
211        height: u16,
212        writer: &mut dyn Write,
213    ) -> io::Result<Option<(usize, usize)>> {
214        let width_usize = width as usize;
215        let height_usize = height as usize;
216
217        // Extract cursor marker from lines before any rendering
218        let cursor_pos = self.extract_cursor_marker(&mut new_lines, height_usize);
219
220        let width_changed = self.prev_width != 0 && self.prev_width as usize != width_usize;
221        let height_changed = self.prev_height != 0 && self.prev_height as usize != height_usize;
222        let prev_buffer_len = if self.prev_height > 0 {
223            self.prev_viewport_top + self.prev_height as usize
224        } else {
225            height_usize
226        };
227        let prev_viewport_top = if height_changed {
228            prev_buffer_len.saturating_sub(height_usize)
229        } else {
230            self.prev_viewport_top
231        };
232        let mut viewport_top = prev_viewport_top;
233
234        // First render - output everything without clearing (assumes clean screen)
235        if self.prev_lines.is_empty() && !width_changed && !height_changed {
236            self.full_render(&new_lines, writer, false, width_usize, height_usize)?;
237            return Ok(cursor_pos);
238        }
239
240        // Width/height changes need a full redraw
241        if width_changed || height_changed {
242            self.full_render(&new_lines, writer, true, width_usize, height_usize)?;
243            return Ok(cursor_pos);
244        }
245
246        // Content shrunk - full redraw to clear empty rows
247        if self.clear_on_shrink && new_lines.len() < self.max_lines_rendered {
248            self.full_render(&new_lines, writer, true, width_usize, height_usize)?;
249            return Ok(cursor_pos);
250        }
251
252        // Find changed range
253        let mut first_changed: i32 = -1;
254        let mut last_changed: i32 = -1;
255        let max_lines = new_lines.len().max(self.prev_lines.len());
256        for i in 0..max_lines {
257            let old = if i < self.prev_lines.len() {
258                &self.prev_lines[i]
259            } else {
260                ""
261            };
262            let new = if i < new_lines.len() {
263                &new_lines[i]
264            } else {
265                ""
266            };
267            if old != new {
268                if first_changed == -1 {
269                    first_changed = i as i32;
270                }
271                last_changed = i as i32;
272            }
273        }
274
275        let appended = new_lines.len() > self.prev_lines.len();
276        if appended && first_changed == -1 {
277            first_changed = self.prev_lines.len() as i32;
278            last_changed = new_lines.len() as i32 - 1;
279        }
280
281        // No changes
282        if first_changed == -1 {
283            self.prev_height = height_usize as u16;
284            self.prev_viewport_top = prev_viewport_top;
285            return Ok(cursor_pos);
286        }
287
288        // All changes are in deleted lines
289        let first = first_changed as usize;
290        let last = last_changed as usize;
291        if first >= new_lines.len() {
292            let mut buf = String::new();
293
294            // Move cursor to end of new content
295            let target_row = new_lines.len().saturating_sub(1);
296            let line_diff = if target_row >= prev_viewport_top {
297                (target_row - prev_viewport_top) as i32
298                    - (self.hardware_cursor_row.saturating_sub(prev_viewport_top)) as i32
299            } else {
300                // Target is above viewport - need full redraw
301                self.full_render(&new_lines, writer, true, width_usize, height_usize)?;
302                return Ok(cursor_pos);
303            };
304
305            self.sync_begin(&mut buf);
306
307            if line_diff > 0 {
308                buf.push_str(&format!("\x1b[{}B", line_diff));
309            } else if line_diff < 0 {
310                buf.push_str(&format!("\x1b[{}A", -line_diff));
311            }
312            buf.push('\r');
313
314            // Clear extra lines
315            let extra = self.prev_lines.len().saturating_sub(new_lines.len());
316            if extra > height_usize {
317                self.full_render(&new_lines, writer, true, width_usize, height_usize)?;
318                return Ok(cursor_pos);
319            }
320            if extra > 0 && !new_lines.is_empty() {
321                buf.push_str("\x1b[1B");
322            }
323            for i in 0..extra {
324                buf.push_str("\r\x1b[2K");
325                if i + 1 < extra {
326                    buf.push_str("\x1b[1B");
327                }
328            }
329            let move_back = extra.saturating_sub(1) + if new_lines.is_empty() { 0 } else { 1 };
330            if move_back > 0 {
331                buf.push_str(&format!("\x1b[{}A", move_back));
332            }
333
334            self.sync_end(&mut buf);
335            write!(writer, "{}", buf)?;
336            writer.flush()?;
337
338            self.cursor_row = target_row;
339            // Physical cursor is at end of remaining content after clearing
340            self.hardware_cursor_row = target_row;
341            self.prev_lines = new_lines;
342            self.prev_viewport_top = prev_viewport_top;
343            self.prev_height = height_usize as u16;
344            return Ok(cursor_pos);
345        }
346
347        // First changed line is above viewport - need full redraw
348        if first < prev_viewport_top {
349            self.full_render(&new_lines, writer, true, width_usize, height_usize)?;
350            return Ok(cursor_pos);
351        }
352
353        // Differential render: update changed lines in place
354        let mut buf = String::new();
355        self.sync_begin(&mut buf);
356
357        let move_target = if appended && first == self.prev_lines.len() && first > 0 {
358            first - 1
359        } else {
360            first
361        };
362
363        // Handle scrolling if needed
364        let prev_viewport_bottom = prev_viewport_top + height_usize - 1;
365        if move_target > prev_viewport_bottom {
366            let scroll = move_target - prev_viewport_bottom;
367            // Move to bottom of screen
368            let current_screen_row =
369                (self.hardware_cursor_row.saturating_sub(prev_viewport_top)).min(height_usize - 1);
370            let to_bottom = height_usize - 1 - current_screen_row;
371            if to_bottom > 0 {
372                buf.push_str(&format!("\x1b[{}B", to_bottom));
373            }
374            // Scroll
375            for _ in 0..scroll {
376                buf.push_str("\r\n");
377            }
378            self.hardware_cursor_row = move_target;
379            // Advance viewport_top to reflect the scroll (lines scrolled off top)
380            viewport_top += scroll;
381        }
382
383        // Move to first changed line
384        // Use viewport_top (potentially updated by scroll) for both calculations
385        // so they stay consistent even after content scrolled below viewport.
386        let current_screen_row = self.hardware_cursor_row.saturating_sub(viewport_top);
387        let target_screen_row = move_target.saturating_sub(viewport_top);
388        let line_diff = target_screen_row as i32 - current_screen_row as i32;
389
390        if line_diff > 0 {
391            buf.push_str(&format!("\x1b[{}B", line_diff));
392        } else if line_diff < 0 {
393            buf.push_str(&format!("\x1b[{}A", -line_diff));
394        }
395
396        if appended && first == self.prev_lines.len() {
397            buf.push_str("\r\n");
398        } else {
399            buf.push('\r');
400        }
401
402        // Write changed lines
403        let render_end = last.min(new_lines.len() - 1);
404        for (i, line) in new_lines
405            .iter()
406            .enumerate()
407            .skip(first)
408            .take(render_end + 1 - first)
409        {
410            if i > first {
411                buf.push_str("\r\n");
412            }
413
414            // Extract cursor marker if present
415            let line_without_marker = if line.contains(CURSOR_MARKER) {
416                line.replace(CURSOR_MARKER, "")
417            } else {
418                line.clone()
419            };
420
421            buf.push_str("\x1b[2K"); // clear line
422            buf.push_str(&line_without_marker);
423        }
424
425        // Clear any trailing old lines beyond the new content.
426        // This is needed when content shrinks (e.g. autocomplete list narrows)
427        // and clear_on_shrink is disabled (the app sets it to false to avoid
428        // full redraws during streaming).
429        if new_lines.len() < self.prev_lines.len() {
430            let extra = self.prev_lines.len() - new_lines.len();
431
432            if extra > height_usize {
433                // Too many extra lines - fall back to full redraw
434                self.sync_end(&mut buf);
435                write!(writer, "{}", buf)?;
436                writer.flush()?;
437                self.full_render(&new_lines, writer, true, width_usize, height_usize)?;
438                return Ok(cursor_pos);
439            }
440
441            // Move from render_end to the first extra line = new_lines.len()
442            let move_to_first_extra = new_lines.len() - render_end;
443            if move_to_first_extra > 0 {
444                buf.push_str(&format!("\x1b[{}B", move_to_first_extra));
445            }
446
447            // Clear each extra line
448            for i in 0..extra {
449                buf.push_str("\r\x1b[2K");
450                if i + 1 < extra {
451                    buf.push_str("\x1b[1B");
452                }
453            }
454
455            // Move cursor back to new_lines.len() - 1 (end of new content).
456            // After the last clear, cursor is at prev_lines.len() - 1.
457            if extra > 0 {
458                buf.push_str(&format!("\x1b[{}A", extra));
459            }
460        }
461
462        self.sync_end(&mut buf);
463        write!(writer, "{}", buf)?;
464        writer.flush()?;
465
466        // Track physical cursor position after the diff output:
467        // - If content shrunk: cursor was moved to end of new content
468        // - Otherwise: cursor is at the last written line (render_end)
469        let final_cursor_row = if new_lines.len() < self.prev_lines.len() {
470            new_lines.len().saturating_sub(1)
471        } else {
472            render_end
473        };
474        self.cursor_row = final_cursor_row;
475        self.hardware_cursor_row = final_cursor_row;
476        self.max_lines_rendered = self.max_lines_rendered.max(new_lines.len());
477        self.prev_lines = new_lines;
478        // Advance viewport_top if cursor ended up below the viewport
479        // (matching pi's Math.max(prevViewportTop, finalCursorRow - height + 1)).
480        let hw_row_for_viewport = final_cursor_row;
481        self.prev_viewport_top =
482            viewport_top.max(hw_row_for_viewport.saturating_sub(height_usize - 1));
483        self.prev_height = height_usize as u16;
484        self.prev_width = width_usize as u16;
485
486        Ok(cursor_pos)
487    }
488}
489
490impl Default for Screen {
491    fn default() -> Self {
492        Self::new()
493    }
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn test_new_screen() {
502        let screen = Screen::new();
503        assert_eq!(screen.full_redraw_count(), 0);
504    }
505
506    #[test]
507    fn test_clear_on_shrink_default() {
508        let screen = Screen::new();
509        assert!(screen.clear_on_shrink);
510    }
511
512    #[test]
513    fn test_first_render() {
514        let mut screen = Screen::new();
515        let lines = vec!["hello".to_string(), "world".to_string()];
516        let mut output = Vec::new();
517
518        screen.render(lines.clone(), 80, 24, &mut output).unwrap();
519
520        let output_str = String::from_utf8(output).unwrap();
521        assert!(output_str.contains("hello"));
522        assert!(output_str.contains("world"));
523    }
524
525    #[test]
526    fn test_differential_update() {
527        let mut screen = Screen::new();
528        let mut output = Vec::new();
529
530        // First render
531        let lines1 = vec!["hello".to_string(), "world".to_string()];
532        screen.render(lines1.clone(), 80, 24, &mut output).unwrap();
533        output.clear();
534
535        // Second render with same content - no output
536        screen.render(lines1.clone(), 80, 24, &mut output).unwrap();
537        assert!(output.is_empty());
538
539        // Third render with changed content
540        let lines2 = vec!["hello".to_string(), "rust".to_string()];
541        screen.render(lines2.clone(), 80, 24, &mut output).unwrap();
542        let output_str = String::from_utf8(output.clone()).unwrap();
543        assert!(output_str.contains("rust"));
544    }
545
546    #[test]
547    fn test_type_character_single_line_change() {
548        let mut screen = Screen::new();
549        let mut output = Vec::new();
550
551        // Simulate compose_ui: 12 lines, editor content at index 7
552        let mut initial: Vec<String> = Vec::new();
553        for i in 0..12 {
554            initial.push(format!("line {:02}", i));
555        }
556        screen.render(initial.clone(), 40, 24, &mut output).unwrap();
557        output.clear();
558
559        // Type "/" - only index 7 changes
560        let mut after = initial.clone();
561        after[7] = "line 07/".to_string();
562        screen.render(after, 40, 24, &mut output).unwrap();
563
564        let text = String::from_utf8_lossy(&output);
565        // Should contain the changed text
566        assert!(
567            text.contains("line 07/"),
568            "Missing changed text in: {}",
569            text
570        );
571        // Should NOT do a full clear
572        assert!(
573            !text.contains("\x1b[2J"),
574            "Should not full-clear on single line change"
575        );
576    }
577
578    #[test]
579    fn test_screen_append_no_duplicate_content() {
580        let mut screen = Screen::new();
581        let mut output = Vec::new();
582
583        // First frame: 4 lines
584        let frame1 = vec!["a", "b", "c", "d"]
585            .into_iter()
586            .map(String::from)
587            .collect::<Vec<_>>();
588        screen.render(frame1, 40, 24, &mut output).unwrap();
589        output.clear();
590
591        // Second frame: content appended at end (exactly prev_lines.len())
592        let frame2 = vec!["a", "b", "c", "d", "e"]
593            .into_iter()
594            .map(String::from)
595            .collect::<Vec<_>>();
596        screen.render(frame2, 40, 24, &mut output).unwrap();
597
598        let content = String::from_utf8_lossy(&output);
599        eprintln!("Append-only diff output: {:?}", content);
600
601        // The diff output should only contain the new line "e" plus ANSI codes
602        // It must not repeat any of the unchanged lines ("a", "b", "c", "d")
603        let counts = ["a", "b", "c", "d"];
604        for &ch in &counts {
605            let n = content.matches(ch).count();
606            assert!(
607                n <= 1,
608                "'{}' should appear at most once in diff, got {}: {:?}",
609                ch,
610                n,
611                content
612            );
613        }
614        // "e" must appear exactly once
615        let e_count = content.matches('e').count();
616        assert_eq!(
617            e_count, 1,
618            "'e' should appear exactly once, got {}",
619            e_count
620        );
621    }
622
623    #[test]
624    fn test_screen_insert_line_mid_content_no_duplicates() {
625        let mut screen = Screen::new();
626        let mut output = Vec::new();
627
628        // First frame: 3 lines
629        let frame1 = vec!["a", "c", "d"]
630            .into_iter()
631            .map(String::from)
632            .collect::<Vec<_>>();
633        screen.render(frame1, 40, 24, &mut output).unwrap();
634        output.clear();
635
636        // Second frame: "b" inserted between "a" and "c"
637        let frame2 = vec!["a", "b", "c", "d"]
638            .into_iter()
639            .map(String::from)
640            .collect::<Vec<_>>();
641        screen.render(frame2, 40, 24, &mut output).unwrap();
642
643        let content = String::from_utf8_lossy(&output);
644        eprintln!("Insert-mid diff output: {:?}", content);
645
646        // "a" should appear at most once (unchanged line shouldn't be re-written)
647        assert!(
648            content.matches('a').count() <= 1,
649            "'a' should appear at most once: {:?}",
650            content
651        );
652        // "b", "c", "d" should appear (changed/new lines)
653        assert!(content.contains('b'), "Should contain 'b'");
654        assert!(content.contains('c'), "Should contain 'c'");
655        assert!(content.contains('d'), "Should contain 'd'");
656    }
657
658    #[test]
659    fn test_screen_editor_appended_empty_line_no_duplicate() {
660        // Simulates pressing Ctrl+J on "hello" → "hello\n"
661        // Editor renders change from 3 lines to 4 lines:
662        //   [border, "hello", border]  →  [border, "hello", "", border]
663        let mut screen = Screen::new();
664        let mut output = Vec::new();
665
666        let frame1 = vec![
667            "header".to_string(),
668            "── editor border ──".to_string(),
669            "hello".to_string(),
670            "── editor border ──".to_string(),
671            "footer".to_string(),
672        ];
673        screen.render(frame1, 30, 24, &mut output).unwrap();
674        output.clear();
675
676        // After Ctrl+J: "hello" → "hello\n"
677        let frame2 = vec![
678            "header".to_string(),
679            "── editor border ──".to_string(),
680            "hello".to_string(),
681            "".to_string(), // new empty line
682            "── editor border ──".to_string(),
683            "footer".to_string(),
684        ];
685        screen.render(frame2, 30, 24, &mut output).unwrap();
686
687        let content = String::from_utf8_lossy(&output);
688        eprintln!("Editor append empty line diff: {:?}", content);
689
690        // "hello" should NOT be in the diff output (it didn't change)
691        let hello_count = content.matches("hello").count();
692        assert!(
693            hello_count <= 1,
694            "'hello' should appear at most once in diff, got {}: {:?}",
695            hello_count,
696            content
697        );
698        // "footer" should NOT be duplicated (it just shifted down, should appear once)
699        let footer_count = content.matches("footer").count();
700        assert!(
701            footer_count <= 1,
702            "'footer' should appear at most once in diff, got {}: {:?}",
703            footer_count,
704            content
705        );
706    }
707
708    #[test]
709    fn test_hardware_cursor_row_after_full_render() {
710        let mut screen = Screen::new();
711        let mut output = Vec::new();
712
713        let lines = vec!["a", "b", "c", "d"]
714            .into_iter()
715            .map(String::from)
716            .collect::<Vec<_>>();
717        screen.render(lines, 40, 24, &mut output).unwrap();
718
719        // After full render, hardware cursor should be at last content line
720        assert_eq!(screen.hardware_cursor_row(), 3);
721    }
722
723    #[test]
724    fn test_hardware_cursor_row_after_diff_single_line() {
725        let mut screen = Screen::new();
726        let mut output = Vec::new();
727
728        let initial: Vec<String> = (0..6).map(|i| format!("line {}", i)).collect();
729        screen.render(initial.clone(), 40, 24, &mut output).unwrap();
730        output.clear();
731
732        // Change line 2 only
733        let mut changed = initial.clone();
734        changed[2] = "line 2 modified".to_string();
735        screen.render(changed, 40, 24, &mut output).unwrap();
736
737        // After diff with single-line change, physical cursor is at render_end (= 2)
738        assert_eq!(screen.hardware_cursor_row(), 2);
739    }
740
741    #[test]
742    fn test_hardware_cursor_row_after_diff_content_shrunk() {
743        let mut screen = Screen::new();
744        let mut output = Vec::new();
745
746        let initial: Vec<String> = (0..6).map(|i| format!("line {}", i)).collect();
747        screen.render(initial, 40, 24, &mut output).unwrap();
748        output.clear();
749
750        // Content shrinks from 6 to 4 lines
751        let after: Vec<String> = (0..4).map(|i| format!("line {}", i)).collect();
752        screen.render(after, 40, 24, &mut output).unwrap();
753
754        // After diff with content shrink, physical cursor is at new_lines.len() - 1
755        assert_eq!(screen.hardware_cursor_row(), 3);
756    }
757
758    #[test]
759    fn test_set_hardware_cursor_row_syncs_tracking() {
760        let mut screen = Screen::new();
761        let mut output = Vec::new();
762
763        let lines = vec!["a", "b", "c"]
764            .into_iter()
765            .map(String::from)
766            .collect::<Vec<_>>();
767        screen.render(lines, 40, 24, &mut output).unwrap();
768        assert_eq!(screen.hardware_cursor_row(), 2);
769
770        // Simulate TUI repositioning cursor (like position_hard_cursor does)
771        screen.set_hardware_cursor_row(0);
772        assert_eq!(screen.hardware_cursor_row(), 0);
773
774        // Next diff should use the updated position
775        output.clear();
776        let new_lines = vec!["changed", "b", "c"]
777            .into_iter()
778            .map(String::from)
779            .collect::<Vec<_>>();
780        screen.render(new_lines, 40, 24, &mut output).unwrap();
781
782        // After repositioning to row 0, diff should start from there
783        let diff = String::from_utf8_lossy(&output);
784        // Should contain "changed" since line 0 was modified
785        assert!(diff.contains("changed"), "Diff should contain changed line");
786    }
787}