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