775 lines
25 KiB
Rust
775 lines
25 KiB
Rust
//! 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<Cell>,
|
||
/// 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<Option<usize>>,
|
||
/// 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::<String>(),
|
||
"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');
|
||
}
|
||
}
|