//! Textbildschirm-Emulation: Zellenpuffer (Unicode-Zeichen + Farbattribut), //! Cursor, Scrollen. //! //! Der Puffer liegt in der Laufzeitbibliothek und ist frei von jeder //! Terminal-Abhängigkeit: `PRINT`, `LOCATE`, `CLS`, `COLOR` und `SCREEN()` //! sind Bibliothekselemente und müssen ihn erreichen, und jedes //! Bildschirmverhalten bleibt ohne Terminal prüfbar. Die Darstellung liegt in //! `tb-ui` (`ScreenWidget`). //! //! Entscheidungen (siehe PLAN.md): //! - durchgängig Unicode, keine CP437-Emulation //! - **dynamische Größe**: der Bildschirm folgt der Terminalgröße; unterhalb //! der Mindestgröße (80×25) wird nur ein Hinweis gerendert (btop-artig), //! größere Terminals werden voll genutzt. //! //! Zeichen belegen so viele Zellen, wie ihre Darstellungsbreite verlangt //! (Entscheidung 2026-09-02): schmale Zeichen eine, breite (Emoji, CJK) //! zwei. Die zweite Zelle ist als Fortsetzung markiert und wird nie //! eigenständig beschrieben. use unicode_width::UnicodeWidthChar; /// Mindestgröße; darunter wird nur ein Hinweis angezeigt. pub const MIN_COLS: usize = 80; pub const MIN_ROWS: usize = 25; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ScreenError; /// Eine Bildschirmzelle: Zeichen plus klassisches Farbattribut. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Cell { pub ch: char, /// Vordergrund 0–15 (klassische Palette). pub fg: u8, /// Hintergrund 0–7. pub bg: u8, /// Zweite Hälfte eines breiten Zeichens. Solche Zellen tragen kein /// eigenes Zeichen und werden bei der Darstellung übersprungen. pub fortsetzung: bool, } impl Default for Cell { fn default() -> Self { Cell { ch: ' ', fg: 7, bg: 0, fortsetzung: false, } } } /// Der emulierte Textbildschirm mit dynamischer Größe. /// /// Koordinaten in der öffentlichen API sind 1-basiert (Zeile, Spalte), /// wie bei `LOCATE`/`CSRLIN`/`POS` des Dialekts. #[derive(Debug, Clone)] pub struct TextScreen { cols: usize, rows: usize, cells: Vec, /// Cursorposition, 0-basiert intern. cur_row: usize, cur_col: usize, pub cursor_visible: bool, /// Aktuelle Ausgabefarben (`COLOR`). pub fg: u8, pub bg: u8, /// Scrollbereich (`VIEW PRINT`), 0-basiert inklusiv. view_top: usize, view_bottom: usize, /// true, solange kein eigenes `VIEW PRINT` gesetzt ist — der /// Scrollbereich folgt dann der Bildschirmgröße. view_full: bool, /// Je Zeile die größte beschriebene Spalte (0-basiert), `None` für /// unberührte Zeilen. Grundlage des getrimmten Snapshots: nachgestellte /// Leerzeichen, die `PRINT` selbst ausgibt, zählen als belegt, ein nie /// beschriebener Zeilenrest nicht. belegt: Vec>, /// Seit der letzten Anzeige verändert? Der Host wird nur dann zum /// Neuzeichnen aufgefordert. veraendert: bool, graphic_x: i32, graphic_y: i32, graphic_view: Option<(i32, i32, i32, i32)>, } impl Default for TextScreen { fn default() -> Self { Self::new() } } impl TextScreen { /// Bildschirm in Mindestgröße (80×25). pub fn new() -> Self { Self::with_size(MIN_COLS, MIN_ROWS) } /// Bildschirm in gegebener Größe (wird auf die Mindestgröße angehoben). pub fn with_size(cols: usize, rows: usize) -> Self { let cols = cols.max(MIN_COLS); let rows = rows.max(MIN_ROWS); TextScreen { cols, rows, cells: vec![Cell::default(); cols * rows], cur_row: 0, cur_col: 0, cursor_visible: true, fg: 7, bg: 0, view_top: 0, view_bottom: rows - 1, view_full: true, belegt: vec![None; rows], veraendert: true, graphic_x: 0, graphic_y: 0, graphic_view: None, } } pub fn cols(&self) -> usize { self.cols } pub fn rows(&self) -> usize { self.rows } /// An neue Terminalgröße anpassen: Inhalt bleibt oben links erhalten, /// neue Zellen sind leer. Unterhalb der Mindestgröße bleibt der Puffer /// bei 80×25 (das Widget zeigt dann den Zu-klein-Hinweis). pub fn resize(&mut self, cols: usize, rows: usize) { self.veraendert = true; let cols = cols.max(MIN_COLS); let rows = rows.max(MIN_ROWS); if cols == self.cols && rows == self.rows { return; } let mut cells = vec![ Cell { ch: ' ', fg: self.fg, bg: self.bg, fortsetzung: false }; cols * rows ]; for row in 0..self.rows.min(rows) { for col in 0..self.cols.min(cols) { cells[row * cols + col] = self.cells[row * self.cols + col]; } } self.cells = cells; let mut belegt = vec![None; rows]; for (r, value) in belegt.iter_mut().enumerate().take(self.rows.min(rows)) { *value = self.belegt[r].map(|c| c.min(cols - 1)); } self.belegt = belegt; self.cols = cols; self.rows = rows; self.cur_row = self.cur_row.min(rows - 1); self.cur_col = self.cur_col.min(cols - 1); if self.view_full || self.view_top > rows - 1 { // Ohne eigenes VIEW PRINT folgt der Bereich dem Bildschirm; ein // Bereich, der ganz vom Schirm gefallen ist, wäre leer und fällt // auf den vollen Bildschirm zurück. self.view_top = 0; self.view_bottom = rows - 1; self.view_full = true; } else { self.view_bottom = self.view_bottom.min(rows - 1); } } /// `CLS`: Scrollbereich mit aktueller Hintergrundfarbe löschen, /// Cursor an den Anfang des Bereichs. pub fn cls(&mut self) { self.veraendert = true; let blank = Cell { ch: ' ', fg: self.fg, bg: self.bg, fortsetzung: false, }; for row in self.view_top..=self.view_bottom { self.cells[row * self.cols..(row + 1) * self.cols].fill(blank); } self.cur_row = self.view_top; self.cur_col = 0; for r in self.view_top..=self.view_bottom { self.belegt[r] = None; } } /// Hat sich seit der letzten Quittung etwas geändert? pub fn ist_veraendert(&self) -> bool { self.veraendert } /// Änderung quittieren (nach dem Anzeigen). pub fn veraenderung_quittieren(&mut self) { self.veraendert = false; } /// Anzahl belegter Zeilen (1-basiert bis zur letzten beschriebenen), /// 0 wenn nichts ausgegeben wurde. pub fn belegte_zeilen(&self) -> usize { self.belegt .iter() .rposition(|b| b.is_some()) .map_or(0, |i| i + 1) } /// Breite der belegten Zeile `z` (1-basiert); 0 für unberührte Zeilen. pub fn belegte_breite(&self, z: usize) -> usize { self.belegt .get(z - 1) .copied() .flatten() .map_or(0, |c| c + 1) } /// `COLOR vg, hg` (vg 0–31, hg 0–7). Blinkende Vordergrundfarben /// (16–31) werden als „hell" simuliert (Entscheidung 2026-09-02): /// echtes Terminal-Blinken ist nicht überall verfügbar. pub fn set_color(&mut self, fg: u8, bg: u8) { self.veraendert = true; let fg = if fg >= 16 { (fg & 0x0F) | 8 } else { fg }; self.fg = fg & 0x0F; self.bg = bg & 0x07; } /// `LOCATE zeile, spalte` (1-basiert); außerhalb → Err (Fehler 5). pub fn locate(&mut self, row: usize, col: usize) -> Result<(), ScreenError> { if row < 1 || row > self.rows || col < 1 || col > self.cols { return Err(ScreenError); } self.cur_row = row - 1; // Auf eine Fortsetzungszelle zu zeigen bedeutet den Zeichenanfang. let mut c = col - 1; if c > 0 && self.cells[self.cur_row * self.cols + c].fortsetzung { c -= 1; } self.cur_col = c; self.veraendert = true; Ok(()) } /// `CSRLIN` (1-basiert). pub fn csrlin(&self) -> usize { self.cur_row + 1 } /// `POS(0)` (1-basiert). pub fn pos(&self) -> usize { self.cur_col + 1 } /// `VIEW PRINT oben TO unten` (1-basiert). pub fn view_print(&mut self, top: usize, bottom: usize) -> Result<(), ScreenError> { if top < 1 || bottom > self.rows || top > bottom { return Err(ScreenError); } self.view_top = top - 1; self.view_bottom = bottom - 1; self.view_full = top == 1 && bottom == self.rows; Ok(()) } pub fn cell(&self, row: usize, col: usize) -> Cell { self.cells[(row - 1) * self.cols + (col - 1)] } pub fn graphics_position(&self) -> (i32, i32) { (self.graphic_x, self.graphic_y) } pub fn graphics_view(&mut self, view: Option<(i32, i32, i32, i32)>) -> Result<(), ScreenError> { if view.is_some_and(|(x1, y1, x2, y2)| x1 < 0 || y1 < 0 || x1 > x2 || y1 > y2) { return Err(ScreenError); } self.graphic_view = view; Ok(()) } fn graphics_cell(&mut self, x: i32, y: i32, color: i32) { if x < 0 || y < 0 || self .graphic_view .is_some_and(|(x1, y1, x2, y2)| x < x1 || x > x2 || y < y1 || y > y2) { return; } let (col, row) = (x as usize / 8, y as usize / 8); if col >= self.cols || row >= self.rows { return; } self.cells[row * self.cols + col] = Cell { ch: if color == 0 { ' ' } else { '█' }, fg: color.clamp(0, 15) as u8, bg: self.bg, fortsetzung: false, }; self.belegt[row] = Some(self.belegt[row].map_or(col, |last| last.max(col))); self.veraendert = true; } pub fn graphics_line(&mut self, from: (i32, i32), to: (i32, i32), color: i32, fill: u8) { let (x1, y1) = from; let (x2, y2) = to; if fill == 2 { for y in (y1.min(y2)..=y1.max(y2)).step_by(8) { for x in (x1.min(x2)..=x1.max(x2)).step_by(8) { self.graphics_cell(x, y, color); } } } else if fill == 1 { self.graphics_segment(x1, y1, x2, y1, color); self.graphics_segment(x2, y1, x2, y2, color); self.graphics_segment(x2, y2, x1, y2, color); self.graphics_segment(x1, y2, x1, y1, color); } else { self.graphics_segment(x1, y1, x2, y2, color); } self.graphic_x = x2; self.graphic_y = y2; } fn graphics_segment(&mut self, mut x: i32, mut y: i32, x2: i32, y2: i32, color: i32) { let dx = (x2 - x).abs(); let sx = if x < x2 { 1 } else { -1 }; let dy = -(y2 - y).abs(); let sy = if y < y2 { 1 } else { -1 }; let mut error = dx + dy; loop { self.graphics_cell(x, y, color); if x == x2 && y == y2 { break; } let twice = error * 2; if twice >= dy { error += dy; x += sx; } if twice <= dx { error += dx; y += sy; } } } pub fn graphics_paint(&mut self, x: i32, y: i32, color: i32) { let (start_col, start_row) = (x.max(0) as usize / 8, y.max(0) as usize / 8); if start_col >= self.cols || start_row >= self.rows { return; } let target = self.cells[start_row * self.cols + start_col].ch; let replacement = if color == 0 { ' ' } else { '█' }; if target == replacement { return; } let mut pending = vec![(start_col, start_row)]; while let Some((col, row)) = pending.pop() { let px = col as i32 * 8; let py = row as i32 * 8; if col >= self.cols || row >= self.rows || self.cells[row * self.cols + col].ch != target || self .graphic_view .is_some_and(|(x1, y1, x2, y2)| px < x1 || px > x2 || py < y1 || py > y2) { continue; } self.graphics_cell(px, py, color); if col > 0 { pending.push((col - 1, row)); } if row > 0 { pending.push((col, row - 1)); } pending.push((col + 1, row)); pending.push((col, row + 1)); } self.graphic_x = x; self.graphic_y = y; } /// Text an der Cursorposition ausgeben: Umbruch am rechten Rand, /// Scrollen am unteren Rand des Scrollbereichs. `\n` bricht um, /// `\r` setzt an den Zeilenanfang. pub fn print(&mut self, text: &str) { self.veraendert = true; for ch in text.chars() { match ch { '\n' => self.newline(), '\r' => self.cur_col = 0, _ => self.zeichen_setzen(ch, true), } } } /// Eine UI-Zeile ohne Umbruch oder Scrollen bis zum Bildschirmrand zeichnen. /// Steuerzeichen werden als Leerzeichen dargestellt; breite Zeichen passen /// nur vollständig. Der Cursor bleibt spätestens in der letzten Spalte. pub fn print_line(&mut self, text: &str) { self.veraendert = true; for ch in text.chars() { let ch = if ch.is_control() { ' ' } else { ch }; if self.cur_col + ch.width().unwrap_or(1).max(1) > self.cols { break; } self.zeichen_setzen(ch, false); } self.cur_col = self.cur_col.min(self.cols - 1); } /// Ein Zeichen an der Cursorposition setzen und den Cursor um seine /// Darstellungsbreite vorrücken. Ein breites Zeichen, das nicht mehr /// vollständig in die Zeile passt, wandert als Ganzes in die nächste; /// die letzte Spalte bleibt dann leer. fn zeichen_setzen(&mut self, ch: char, wrap: bool) { // ponytail: Breite 0 (kombinierende Zeichen) wird wie 1 behandelt — // sie bekommen eine eigene Zelle statt sich an die vorige zu hängen. // Ceiling: echte Kombination erst, wenn ein Korpusprogramm sie braucht. let breite = ch.width().unwrap_or(1).max(1); if self.cur_col + breite > self.cols { self.newline(); } let attr = (self.fg, self.bg); let start = self.cur_row * self.cols + self.cur_col; self.haelften_freimachen(self.cur_col, breite); self.cells[start] = Cell { ch, fg: attr.0, bg: attr.1, fortsetzung: false, }; for i in 1..breite { self.cells[start + i] = Cell { ch: ' ', fg: attr.0, bg: attr.1, fortsetzung: true, }; } let letzte = self.cur_col + breite - 1; let b = &mut self.belegt[self.cur_row]; *b = Some(b.map_or(letzte, |c| c.max(letzte))); self.cur_col += breite; if wrap && self.cur_col >= self.cols { self.newline(); } } /// Vor dem Schreiben angeschnittene breite Zeichen auflösen: wird auf /// eine Fortsetzung geschrieben, verliert die Hälfte davor ihr Zeichen; /// überschreibt der neue Text den Anfang eines breiten Zeichens, wird /// dessen Fortsetzung geleert. Sonst blieben Geisterzeichen stehen. fn haelften_freimachen(&mut self, col: usize, breite: usize) { let zeilenanfang = self.cur_row * self.cols; let leer = Cell { ch: ' ', fg: self.fg, bg: self.bg, fortsetzung: false, }; if col > 0 && self.cells[zeilenanfang + col].fortsetzung { self.cells[zeilenanfang + col - 1] = leer; } let hinter = col + breite; if hinter < self.cols && self.cells[zeilenanfang + hinter].fortsetzung { self.cells[zeilenanfang + hinter] = leer; } } /// Zeilenumbruch inkl. Scrollen im Scrollbereich. fn newline(&mut self) { self.cur_col = 0; if self.cur_row >= self.view_bottom { self.scroll_up(); self.cur_row = self.view_bottom; } else { self.cur_row += 1; } } /// Rückschritt: Cursor eine Spalte zurück und die Zelle leeren. /// Am Zeilenanfang passiert nichts (Vorbild). pub fn rueckschritt(&mut self) { if self.cur_col == 0 { return; } self.cur_col -= 1; // War das gelöschte eine Fortsetzung, gehört die Hälfte davor dazu. if self.cells[self.cur_row * self.cols + self.cur_col].fortsetzung && self.cur_col > 0 { self.cells[self.cur_row * self.cols + self.cur_col] = Cell { ch: ' ', fg: self.fg, bg: self.bg, fortsetzung: false, }; self.cur_col -= 1; } self.cells[self.cur_row * self.cols + self.cur_col] = Cell { ch: ' ', fg: self.fg, bg: self.bg, fortsetzung: false, }; // Die gelöschte Zelle gilt nicht mehr als belegt. let b = &mut self.belegt[self.cur_row]; *b = match (self.cur_col, *b) { (0, _) => None, (c, Some(alt)) => Some(alt.min(c - 1)), (c, None) => Some(c - 1), }; self.veraendert = true; } /// Scrollbereich um eine Zeile nach oben schieben; unterste Zeile leeren. pub fn scroll_up(&mut self) { self.veraendert = true; let blank = Cell { ch: ' ', fg: self.fg, bg: self.bg, fortsetzung: false, }; for row in self.view_top..self.view_bottom { let (a, b) = self.cells.split_at_mut((row + 1) * self.cols); a[row * self.cols..].copy_from_slice(&b[..self.cols]); } self.cells[self.view_bottom * self.cols..(self.view_bottom + 1) * self.cols].fill(blank); for row in self.view_top..self.view_bottom { self.belegt[row] = self.belegt[row + 1]; } self.belegt[self.view_bottom] = None; } } #[cfg(test)] mod tests { use super::*; #[test] fn print_schreibt_und_bewegt_cursor() { let mut s = TextScreen::new(); s.print("AB"); assert_eq!(s.cell(1, 1).ch, 'A'); assert_eq!(s.cell(1, 2).ch, 'B'); assert_eq!((s.csrlin(), s.pos()), (1, 3)); } #[test] fn ui_zeile_bleibt_am_rand_ohne_umbruch_oder_scrollen() { let mut s = TextScreen::new(); s.print("oben"); s.view_print(3, 10).unwrap(); s.set_color(2, 4); for row in [10, 25] { s.locate(row, 78).unwrap(); s.print_line("中xREST"); assert_eq!(s.cell(row, 78).ch, '中'); assert!(s.cell(row, 79).fortsetzung); assert_eq!(s.cell(row, 80).ch, 'x'); assert_eq!((s.csrlin(), s.pos()), (row, 80)); assert_eq!((s.cell(row, 80).fg, s.cell(row, 80).bg), (2, 4)); } assert_eq!(s.cell(1, 1).ch, 'o'); assert_eq!(s.cell(9, 78).ch, ' '); assert_eq!(s.cell(24, 78).ch, ' '); s.locate(2, 1).unwrap(); s.print_line("a\nb\rc"); assert_eq!( (1..=5).map(|col| s.cell(2, col).ch).collect::(), "a b c" ); s.locate(2, 80).unwrap(); s.print_line("中"); assert_eq!(s.cell(2, 80).ch, ' '); assert_eq!(s.cell(3, 1).ch, ' '); assert_eq!((s.csrlin(), s.pos()), (2, 80)); } #[test] fn unicode_zeichen_belegen_eine_zelle() { let mut s = TextScreen::new(); s.print("Ä☃"); assert_eq!(s.cell(1, 1).ch, 'Ä'); assert_eq!(s.cell(1, 2).ch, '☃'); } #[test] fn breites_zeichen_belegt_zwei_zellen() { let mut s = TextScreen::new(); s.print("中a"); assert_eq!(s.cell(1, 1).ch, '中'); assert!(s.cell(1, 2).fortsetzung); assert_eq!(s.cell(1, 3).ch, 'a'); assert_eq!(s.pos(), 4); } #[test] fn breites_zeichen_am_rechten_rand_bricht_ganz_um() { let mut s = TextScreen::new(); s.print(&"x".repeat(79)); // Cursor auf Spalte 80 assert_eq!(s.pos(), 80); s.print("中"); // Letzte Spalte bleibt leer, das Zeichen steht in Zeile 2. assert_eq!(s.cell(1, 80).ch, ' '); assert_eq!(s.cell(2, 1).ch, '中'); assert!(s.cell(2, 2).fortsetzung); } #[test] fn ueberschreiben_einer_haelfte_leert_die_andere() { let mut s = TextScreen::new(); s.print("中"); // Auf den Anfang schreiben → Fortsetzung muss weg. s.locate(1, 1).unwrap(); s.print("a"); assert_eq!(s.cell(1, 1).ch, 'a'); assert!(!s.cell(1, 2).fortsetzung); assert_eq!(s.cell(1, 2).ch, ' '); // Auf die Fortsetzung schreiben → Anfang muss weg. let mut s2 = TextScreen::new(); s2.print("中"); s2.locate(1, 2).unwrap(); // klemmt auf den Zeichenanfang assert_eq!(s2.pos(), 1); s2.locate(1, 3).unwrap(); s2.print("b"); assert_eq!(s2.cell(1, 3).ch, 'b'); } #[test] fn locate_auf_fortsetzung_trifft_den_zeichenanfang() { let mut s = TextScreen::new(); s.print("中"); s.locate(1, 2).unwrap(); assert_eq!(s.pos(), 1); } #[test] fn rueckschritt_loescht_ein_breites_zeichen_ganz() { let mut s = TextScreen::new(); s.print("a中"); assert_eq!(s.pos(), 4); s.rueckschritt(); assert_eq!(s.pos(), 2); assert_eq!(s.cell(1, 2).ch, ' '); assert!(!s.cell(1, 2).fortsetzung); assert_eq!(s.cell(1, 3).ch, ' '); } #[test] fn umbruch_am_rechten_rand() { let mut s = TextScreen::new(); s.print(&"x".repeat(81)); assert_eq!(s.cell(1, 80).ch, 'x'); assert_eq!(s.cell(2, 1).ch, 'x'); assert_eq!((s.csrlin(), s.pos()), (2, 2)); } #[test] fn scrollen_am_unteren_rand() { let mut s = TextScreen::new(); s.locate(25, 1).unwrap(); s.print("unten\n"); // erzwingt Scroll assert_eq!(s.cell(24, 1).ch, 'u'); assert_eq!(s.cell(25, 1).ch, ' '); assert_eq!(s.csrlin(), 25); } #[test] fn view_print_begrenzt_scrollen() { let mut s = TextScreen::new(); s.locate(1, 1).unwrap(); s.print("kopf"); s.view_print(3, 5).unwrap(); s.locate(5, 1).unwrap(); s.print("a\nb"); // scrollt nur Zeilen 3–5 assert_eq!(s.cell(1, 1).ch, 'k'); assert_eq!(s.cell(4, 1).ch, 'a'); assert_eq!(s.cell(5, 1).ch, 'b'); } #[test] fn locate_prueft_grenzen() { let mut s = TextScreen::new(); assert!(s.locate(0, 1).is_err()); assert!(s.locate(26, 1).is_err()); assert!(s.locate(25, 80).is_ok()); } #[test] fn dynamische_groesse_und_resize() { let mut s = TextScreen::with_size(120, 40); assert_eq!((s.cols(), s.rows()), (120, 40)); s.locate(40, 1).unwrap(); s.print("!"); assert_eq!(s.cell(40, 1).ch, '!'); // Verkleinern: Inhalt oben links bleibt, Cursor wird geklemmt s.locate(1, 1).unwrap(); s.print("K"); s.resize(100, 30); assert_eq!((s.cols(), s.rows()), (100, 30)); assert_eq!(s.cell(1, 1).ch, 'K'); // Unter Minimum wird auf 80×25 geklemmt s.resize(10, 5); assert_eq!((s.cols(), s.rows()), (80, 25)); } #[test] fn blink_wird_als_hell_simuliert() { let mut s = TextScreen::new(); s.set_color(17, 0); // blinkend Blau → helles Blau assert_eq!(s.fg, 9); s.set_color(31, 0); // blinkend Hellweiß → Hellweiß assert_eq!(s.fg, 15); s.set_color(7, 0); assert_eq!(s.fg, 7); } #[test] fn resize_klemmt_cursor_und_scrollbereich() { let mut s = TextScreen::with_size(120, 40); s.locate(1, 1).unwrap(); s.print("K"); s.view_print(30, 38).unwrap(); s.locate(38, 5).unwrap(); s.resize(80, 25); // Inhalt oben links bleibt. assert_eq!(s.cell(1, 1).ch, 'K'); // Cursor liegt in den neuen Grenzen. assert!(s.csrlin() <= s.rows() && s.pos() <= s.cols()); // Der Bereich lag ganz außerhalb → voller Bildschirm. s.locate(25, 1).unwrap(); s.print("u\n"); assert_eq!(s.cell(24, 1).ch, 'u'); } #[test] fn resize_klemmt_nur_den_unteren_rand_wenn_der_bereich_bleibt() { let mut s = TextScreen::with_size(100, 40); s.view_print(5, 38).unwrap(); s.resize(100, 30); // Oberer Rand bleibt bei 5, unterer klemmt auf 30. s.locate(30, 1).unwrap(); s.print("a\nb"); assert_eq!(s.cell(29, 1).ch, 'a'); assert_eq!(s.cell(30, 1).ch, 'b'); // Zeile 4 liegt außerhalb und bleibt unberührt. assert_eq!(s.cell(4, 1).ch, ' '); } #[test] fn resize_folgt_vollem_scrollbereich() { let mut s = TextScreen::new(); s.resize(90, 40); s.locate(40, 1).unwrap(); s.print("a\nb"); // Scroll am neuen unteren Rand assert_eq!(s.cell(39, 1).ch, 'a'); assert_eq!(s.cell(40, 1).ch, 'b'); } }