Phase 0: Referenzdokumente, Fehlerkatalog, Testkorpus, Ratatui-Spike
Entscheidungen festgehalten: TBVM bestaetigt und eingebettet in die Executables; durchgaengig UTF-8 statt CP437 (dokumentierte Abweichung). - docs/: Sprachreferenz, Forms-Referenz, Dateiformate, TBVM-Design - tb-runtime::errors: klassischer Laufzeitfehler-Katalog (implementiert) - tb-ui::screen: 80x25-Unicode-Zellenpuffer mit 16-Farben-Abbildung, Scrollbereich (VIEW PRINT), Letterboxing; Ratatui-Widget + Tests - Spike: cargo run -p tb-ui --example spike (Farben, Unicode, Tasten, Maus) - tests/compat/: erste Referenzprogramme mit byte-genauer Sollausgabe Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,161 @@
|
||||
//! Laufzeitfehler: Codes und Meldungstexte kompatibel zum Vorbild
|
||||
//! (z. B. 6 = Overflow, 9 = Subscript out of range, 53 = File not found).
|
||||
//! Laufzeitfehler: Codes und Meldungstexte kompatibel zum Vorbild.
|
||||
//!
|
||||
//! Die Codes und (englischen) Meldungstexte entsprechen dem klassischen
|
||||
//! DOS-BASIC-Katalog; Programme prüfen `ERR` gegen diese Nummern, daher
|
||||
//! sind sie Teil des Kompatibilitätsvertrags. Nicht belegte Nummern
|
||||
//! liefern "Unprintable error" (wie im Vorbild bei `ERROR n`).
|
||||
|
||||
// Platzhalter — wird in Phase 3 ausgearbeitet (siehe PLAN.md)
|
||||
/// Ein Laufzeitfehler des Dialekts, identifiziert durch seinen klassischen Code.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RuntimeError(pub u8);
|
||||
|
||||
impl RuntimeError {
|
||||
pub const NEXT_WITHOUT_FOR: Self = Self(1);
|
||||
pub const SYNTAX_ERROR: Self = Self(2);
|
||||
pub const RETURN_WITHOUT_GOSUB: Self = Self(3);
|
||||
pub const OUT_OF_DATA: Self = Self(4);
|
||||
pub const ILLEGAL_FUNCTION_CALL: Self = Self(5);
|
||||
pub const OVERFLOW: Self = Self(6);
|
||||
pub const OUT_OF_MEMORY: Self = Self(7);
|
||||
pub const LABEL_NOT_DEFINED: Self = Self(8);
|
||||
pub const SUBSCRIPT_OUT_OF_RANGE: Self = Self(9);
|
||||
pub const DUPLICATE_DEFINITION: Self = Self(10);
|
||||
pub const DIVISION_BY_ZERO: Self = Self(11);
|
||||
pub const ILLEGAL_IN_DIRECT_MODE: Self = Self(12);
|
||||
pub const TYPE_MISMATCH: Self = Self(13);
|
||||
pub const OUT_OF_STRING_SPACE: Self = Self(14);
|
||||
pub const STRING_TOO_COMPLEX: Self = Self(16);
|
||||
pub const CANNOT_CONTINUE: Self = Self(17);
|
||||
pub const FUNCTION_NOT_DEFINED: Self = Self(18);
|
||||
pub const NO_RESUME: Self = Self(19);
|
||||
pub const RESUME_WITHOUT_ERROR: Self = Self(20);
|
||||
pub const DEVICE_TIMEOUT: Self = Self(24);
|
||||
pub const DEVICE_FAULT: Self = Self(25);
|
||||
pub const FOR_WITHOUT_NEXT: Self = Self(26);
|
||||
pub const OUT_OF_PAPER: Self = Self(27);
|
||||
pub const WHILE_WITHOUT_WEND: Self = Self(29);
|
||||
pub const WEND_WITHOUT_WHILE: Self = Self(30);
|
||||
pub const DUPLICATE_LABEL: Self = Self(33);
|
||||
pub const SUBPROGRAM_NOT_DEFINED: Self = Self(35);
|
||||
pub const ARGUMENT_COUNT_MISMATCH: Self = Self(37);
|
||||
pub const ARRAY_NOT_DEFINED: Self = Self(38);
|
||||
pub const VARIABLE_REQUIRED: Self = Self(40);
|
||||
pub const FIELD_OVERFLOW: Self = Self(50);
|
||||
pub const INTERNAL_ERROR: Self = Self(51);
|
||||
pub const BAD_FILE_NAME_OR_NUMBER: Self = Self(52);
|
||||
pub const FILE_NOT_FOUND: Self = Self(53);
|
||||
pub const BAD_FILE_MODE: Self = Self(54);
|
||||
pub const FILE_ALREADY_OPEN: Self = Self(55);
|
||||
pub const FIELD_STATEMENT_ACTIVE: Self = Self(56);
|
||||
pub const DEVICE_IO_ERROR: Self = Self(57);
|
||||
pub const FILE_ALREADY_EXISTS: Self = Self(58);
|
||||
pub const BAD_RECORD_LENGTH: Self = Self(59);
|
||||
pub const DISK_FULL: Self = Self(61);
|
||||
pub const INPUT_PAST_END_OF_FILE: Self = Self(62);
|
||||
pub const BAD_RECORD_NUMBER: Self = Self(63);
|
||||
pub const BAD_FILE_NAME: Self = Self(64);
|
||||
pub const TOO_MANY_FILES: Self = Self(67);
|
||||
pub const DEVICE_UNAVAILABLE: Self = Self(68);
|
||||
pub const COMM_BUFFER_OVERFLOW: Self = Self(69);
|
||||
pub const PERMISSION_DENIED: Self = Self(70);
|
||||
pub const DISK_NOT_READY: Self = Self(71);
|
||||
pub const DISK_MEDIA_ERROR: Self = Self(72);
|
||||
pub const FEATURE_UNAVAILABLE: Self = Self(73);
|
||||
pub const RENAME_ACROSS_DISKS: Self = Self(74);
|
||||
pub const PATH_FILE_ACCESS_ERROR: Self = Self(75);
|
||||
pub const PATH_NOT_FOUND: Self = Self(76);
|
||||
|
||||
/// Klassischer Fehlercode für `ERR`.
|
||||
pub fn code(self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Meldungstext des Vorbilds; unbelegte Codes: "Unprintable error".
|
||||
pub fn message(self) -> &'static str {
|
||||
match self.0 {
|
||||
1 => "NEXT without FOR",
|
||||
2 => "Syntax error",
|
||||
3 => "RETURN without GOSUB",
|
||||
4 => "Out of DATA",
|
||||
5 => "Illegal function call",
|
||||
6 => "Overflow",
|
||||
7 => "Out of memory",
|
||||
8 => "Label not defined",
|
||||
9 => "Subscript out of range",
|
||||
10 => "Duplicate definition",
|
||||
11 => "Division by zero",
|
||||
12 => "Illegal in direct mode",
|
||||
13 => "Type mismatch",
|
||||
14 => "Out of string space",
|
||||
16 => "String formula too complex",
|
||||
17 => "Cannot continue",
|
||||
18 => "Function not defined",
|
||||
19 => "No RESUME",
|
||||
20 => "RESUME without error",
|
||||
24 => "Device timeout",
|
||||
25 => "Device fault",
|
||||
26 => "FOR without NEXT",
|
||||
27 => "Out of paper",
|
||||
29 => "WHILE without WEND",
|
||||
30 => "WEND without WHILE",
|
||||
33 => "Duplicate label",
|
||||
35 => "Subprogram not defined",
|
||||
37 => "Argument-count mismatch",
|
||||
38 => "Array not defined",
|
||||
40 => "Variable required",
|
||||
50 => "FIELD overflow",
|
||||
51 => "Internal error",
|
||||
52 => "Bad file name or number",
|
||||
53 => "File not found",
|
||||
54 => "Bad file mode",
|
||||
55 => "File already open",
|
||||
56 => "FIELD statement active",
|
||||
57 => "Device I/O error",
|
||||
58 => "File already exists",
|
||||
59 => "Bad record length",
|
||||
61 => "Disk full",
|
||||
62 => "Input past end of file",
|
||||
63 => "Bad record number",
|
||||
64 => "Bad file name",
|
||||
67 => "Too many files",
|
||||
68 => "Device unavailable",
|
||||
69 => "Communication-buffer overflow",
|
||||
70 => "Permission denied",
|
||||
71 => "Disk not ready",
|
||||
72 => "Disk-media error",
|
||||
73 => "Feature unavailable",
|
||||
74 => "Rename across disks",
|
||||
75 => "Path/File access error",
|
||||
76 => "Path not found",
|
||||
_ => "Unprintable error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RuntimeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.message())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RuntimeError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bekannte_codes() {
|
||||
assert_eq!(RuntimeError::OVERFLOW.code(), 6);
|
||||
assert_eq!(RuntimeError::OVERFLOW.message(), "Overflow");
|
||||
assert_eq!(RuntimeError::SUBSCRIPT_OUT_OF_RANGE.code(), 9);
|
||||
assert_eq!(RuntimeError::FILE_NOT_FOUND.message(), "File not found");
|
||||
assert_eq!(RuntimeError::FEATURE_UNAVAILABLE.code(), 73);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbelegte_codes_sind_unprintable() {
|
||||
assert_eq!(RuntimeError(15).message(), "Unprintable error");
|
||||
assert_eq!(RuntimeError(200).message(), "Unprintable error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,3 +11,6 @@ ratatui.workspace = true
|
||||
crossterm.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow.workspace = true
|
||||
|
||||
90
crates/tb-ui/examples/spike.rs
Normal file
90
crates/tb-ui/examples/spike.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
//! Phase-0-Spike: 80×25-Bildschirmpuffer, 16-Farben-Palette, Unicode,
|
||||
//! Tastatur- und Mausereignisse.
|
||||
//!
|
||||
//! Start: `cargo run -p tb-ui --example spike` — Beenden mit Esc.
|
||||
//! Getippte Zeichen erscheinen im Eingabebereich; Sondertasten und
|
||||
//! Mausereignisse werden in der Statuszeile angezeigt.
|
||||
|
||||
use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind};
|
||||
use crossterm::execute;
|
||||
use std::io::stdout;
|
||||
use std::time::Duration;
|
||||
use tb_ui::screen::TextScreen;
|
||||
|
||||
fn testbild(s: &mut TextScreen) {
|
||||
s.set_color(15, 1);
|
||||
s.print(&format!("{:^80}", "Terminal Basic — Phase-0-Spike (Esc beendet)"));
|
||||
|
||||
// Farbraster: alle Vordergrundfarben auf allen Hintergründen
|
||||
for bg in 0u8..8 {
|
||||
s.set_color(15, 0);
|
||||
s.locate(3 + bg as usize, 3).unwrap();
|
||||
s.print(&format!("HG {bg} "));
|
||||
for fg in 0u8..16 {
|
||||
s.set_color(fg, bg);
|
||||
s.print(&format!(" {fg:2}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Unicode statt CP437: Rahmen, Umlaute, Symbole
|
||||
s.set_color(14, 0);
|
||||
s.locate(12, 3).unwrap();
|
||||
s.print("┌─ Unicode ────────────────────────────┐");
|
||||
s.locate(13, 3).unwrap();
|
||||
s.print("│ Äpfel, Öl, Übermaß, ß — ☃ ♥ ♦ ♣ ♠ π λ │");
|
||||
s.locate(14, 3).unwrap();
|
||||
s.print("└──────────────────────────────────────┘");
|
||||
|
||||
s.set_color(7, 0);
|
||||
s.locate(16, 3).unwrap();
|
||||
s.print("Eingabe (Zeichen erscheinen hier):");
|
||||
s.locate(17, 3).unwrap();
|
||||
}
|
||||
|
||||
fn status(s: &mut TextScreen, text: &str) {
|
||||
let (r, c) = (s.csrlin(), s.pos());
|
||||
s.set_color(0, 7);
|
||||
s.locate(25, 1).unwrap();
|
||||
s.print(&format!("{text:<80.80}"));
|
||||
s.set_color(7, 0);
|
||||
s.locate(r, c).unwrap();
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let mut terminal = ratatui::init();
|
||||
execute!(stdout(), EnableMouseCapture)?;
|
||||
|
||||
let mut screen = TextScreen::new();
|
||||
testbild(&mut screen);
|
||||
status(&mut screen, "Bereit. Tasten/Maus testen, Esc beendet.");
|
||||
|
||||
loop {
|
||||
terminal.draw(|f| f.render_widget(&screen, f.area()))?;
|
||||
if !event::poll(Duration::from_millis(100))? {
|
||||
continue;
|
||||
}
|
||||
match event::read()? {
|
||||
Event::Key(k) if k.kind != KeyEventKind::Release => match k.code {
|
||||
KeyCode::Esc => break,
|
||||
KeyCode::Char(c) => {
|
||||
screen.print(&c.to_string());
|
||||
status(&mut screen, &format!("Taste: {:?} Modifier: {:?}", k.code, k.modifiers));
|
||||
}
|
||||
KeyCode::Enter => screen.print("\n"),
|
||||
other => status(&mut screen, &format!("Sondertaste: {other:?} Modifier: {:?}", k.modifiers)),
|
||||
},
|
||||
Event::Mouse(m) => {
|
||||
status(
|
||||
&mut screen,
|
||||
&format!("Maus: {:?} bei Spalte {}, Zeile {}", m.kind, m.column + 1, m.row + 1),
|
||||
);
|
||||
}
|
||||
Event::Resize(w, h) => status(&mut screen, &format!("Resize: {w}x{h}")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
execute!(stdout(), DisableMouseCapture)?;
|
||||
ratatui::restore();
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Bildschirm-Runtime und Forms-Engine von Terminal Basic, aufbauend auf Ratatui.
|
||||
//!
|
||||
//! Zwei Schichten:
|
||||
//! 1. `screen`: Emulation des 80×25-Textbildschirms (Codepage 437, 16 Farben,
|
||||
//! 1. `screen`: Emulation des 80×25-Textbildschirms (Unicode/UTF-8, 16 Farben,
|
||||
//! Cursor) als Zeichenpuffer, gerendert über Ratatui. Darauf setzen die
|
||||
//! klassischen Anweisungen `PRINT`, `LOCATE`, `COLOR`, `CLS`, `INPUT` auf.
|
||||
//! 2. `forms`: ereignisgesteuerte Forms-Engine — Fenster, Steuerelemente
|
||||
|
||||
@@ -1,4 +1,284 @@
|
||||
//! Textbildschirm-Emulation: Zellenpuffer (Zeichen + Attribut), Cursor,
|
||||
//! Codepage-437-Abbildung auf Unicode, Rendering über Ratatui.
|
||||
//! Textbildschirm-Emulation: 80×25-Zellenpuffer (Unicode-Zeichen + Farb-
|
||||
//! attribut), Cursor, Scrollen — gerendert als Ratatui-Widget.
|
||||
//!
|
||||
//! Der Puffer ist die Grundlage für `PRINT`, `LOCATE`, `COLOR`, `CLS` usw.
|
||||
//! Entscheidung (siehe PLAN.md): durchgängig Unicode, keine CP437-Emulation.
|
||||
//! Das Zellenmodell ist strikt 1 Zeichen = 1 Zelle; Zeichen mit
|
||||
//! Darstellungsbreite ≠ 1 sind eine offene Frage (PLAN.md).
|
||||
|
||||
// Platzhalter — wird in Phase 0/3 ausgearbeitet (siehe PLAN.md)
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
pub const COLS: usize = 80;
|
||||
pub const ROWS: usize = 25;
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
impl Default for Cell {
|
||||
fn default() -> Self {
|
||||
Cell { ch: ' ', fg: 7, bg: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Abbildung der klassischen 16-Farben-Palette auf ANSI-Indexfarben.
|
||||
/// (Klassisch: 1 = Blau, 4 = Rot — ANSI: 1 = Rot, 4 = Blau usw.)
|
||||
pub fn basic_color(n: u8) -> Color {
|
||||
const MAP: [u8; 16] = [0, 4, 2, 6, 1, 5, 3, 7, 8, 12, 10, 14, 9, 13, 11, 15];
|
||||
Color::Indexed(MAP[(n & 0x0F) as usize])
|
||||
}
|
||||
|
||||
/// Der emulierte 80×25-Textbildschirm.
|
||||
///
|
||||
/// Koordinaten in der öffentlichen API sind 1-basiert (Zeile 1–25,
|
||||
/// Spalte 1–80), wie bei `LOCATE`/`CSRLIN`/`POS` des Dialekts.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextScreen {
|
||||
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,
|
||||
}
|
||||
|
||||
impl Default for TextScreen {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TextScreen {
|
||||
pub fn new() -> Self {
|
||||
TextScreen {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// `CLS`: Scrollbereich mit aktueller Hintergrundfarbe löschen,
|
||||
/// Cursor an den Anfang des Bereichs.
|
||||
pub fn cls(&mut self) {
|
||||
let blank = Cell { ch: ' ', fg: self.fg, bg: self.bg };
|
||||
for row in self.view_top..=self.view_bottom {
|
||||
self.cells[row * COLS..(row + 1) * COLS].fill(blank);
|
||||
}
|
||||
self.cur_row = self.view_top;
|
||||
self.cur_col = 0;
|
||||
}
|
||||
|
||||
/// `COLOR vg, hg` — Werte werden wie im Vorbild maskiert
|
||||
/// (vg 0–31, wir ignorieren Blink → 0–15; hg 0–7).
|
||||
pub fn set_color(&mut self, fg: u8, bg: u8) {
|
||||
self.fg = fg & 0x0F;
|
||||
self.bg = bg & 0x07;
|
||||
}
|
||||
|
||||
/// `LOCATE zeile, spalte` (1-basiert). Außerhalb des Bildschirms:
|
||||
/// Fehler 5 beim Aufrufer — hier wird geklemmt geprüft.
|
||||
pub fn locate(&mut self, row: usize, col: usize) -> Result<(), ()> {
|
||||
if row < 1 || row > ROWS || col < 1 || col > COLS {
|
||||
return Err(());
|
||||
}
|
||||
self.cur_row = row - 1;
|
||||
self.cur_col = col - 1;
|
||||
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<(), ()> {
|
||||
if top < 1 || bottom > ROWS || top > bottom {
|
||||
return Err(());
|
||||
}
|
||||
self.view_top = top - 1;
|
||||
self.view_bottom = bottom - 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cell(&self, row: usize, col: usize) -> Cell {
|
||||
self.cells[(row - 1) * COLS + (col - 1)]
|
||||
}
|
||||
|
||||
/// Text an der Cursorposition ausgeben: Umbruch am rechten Rand,
|
||||
/// Scrollen am unteren Rand des Scrollbereichs. `\n` bricht um,
|
||||
/// `\r` setzt an den Zeilenanfang; andere Steuerzeichen werden
|
||||
/// (noch) als normale Zeichen behandelt.
|
||||
pub fn print(&mut self, text: &str) {
|
||||
for ch in text.chars() {
|
||||
match ch {
|
||||
'\n' => self.newline(),
|
||||
'\r' => self.cur_col = 0,
|
||||
_ => {
|
||||
self.cells[self.cur_row * COLS + self.cur_col] =
|
||||
Cell { ch, fg: self.fg, bg: self.bg };
|
||||
self.cur_col += 1;
|
||||
if self.cur_col >= COLS {
|
||||
self.newline();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Scrollbereich um eine Zeile nach oben schieben; unterste Zeile leeren.
|
||||
pub fn scroll_up(&mut self) {
|
||||
let blank = Cell { ch: ' ', fg: self.fg, bg: self.bg };
|
||||
for row in self.view_top..self.view_bottom {
|
||||
let (a, b) = self.cells.split_at_mut((row + 1) * COLS);
|
||||
a[row * COLS..].copy_from_slice(&b[..COLS]);
|
||||
}
|
||||
self.cells[self.view_bottom * COLS..(self.view_bottom + 1) * COLS].fill(blank);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rendert den Bildschirm zentriert (Letterboxing) in die verfügbare Fläche.
|
||||
/// Ist das Terminal kleiner als 80×25, wird ein Hinweis angezeigt.
|
||||
impl Widget for &TextScreen {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
if (area.width as usize) < COLS || (area.height as usize) < ROWS {
|
||||
let msg = format!(
|
||||
"Terminal zu klein: {}x{} — benötigt {}x{}",
|
||||
area.width, area.height, COLS, ROWS
|
||||
);
|
||||
if let Some(cell) = buf.cell_mut((area.x, area.y)) {
|
||||
cell.set_symbol(" ");
|
||||
}
|
||||
buf.set_string(area.x, area.y, msg, Style::default().fg(Color::Red));
|
||||
return;
|
||||
}
|
||||
let x0 = area.x + (area.width - COLS as u16) / 2;
|
||||
let y0 = area.y + (area.height - ROWS as u16) / 2;
|
||||
for row in 0..ROWS {
|
||||
for col in 0..COLS {
|
||||
let c = self.cells[row * COLS + col];
|
||||
if let Some(cell) = buf.cell_mut((x0 + col as u16, y0 + row as u16)) {
|
||||
let mut s = String::new();
|
||||
s.push(c.ch);
|
||||
cell.set_symbol(&s);
|
||||
cell.set_style(
|
||||
Style::default().fg(basic_color(c.fg)).bg(basic_color(c.bg)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cursor als invertierte Zelle darstellen (Terminal-Cursor wird in
|
||||
// der Forms-/Runtime-Schicht später gezielt gesteuert).
|
||||
if self.cursor_visible {
|
||||
let (cx, cy) = (x0 + self.cur_col as u16, y0 + self.cur_row as u16);
|
||||
if let Some(cell) = buf.cell_mut((cx, cy)) {
|
||||
cell.set_style(cell.style().add_modifier(ratatui::style::Modifier::REVERSED));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 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 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'); // Zeile 25 ist nach 24 gerutscht
|
||||
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'); // Kopfzeile unberührt
|
||||
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 farbabbildung() {
|
||||
assert_eq!(basic_color(1), Color::Indexed(4)); // klassisch Blau
|
||||
assert_eq!(basic_color(4), Color::Indexed(1)); // klassisch Rot
|
||||
assert_eq!(basic_color(14), Color::Indexed(11)); // Gelb
|
||||
assert_eq!(basic_color(15), Color::Indexed(15)); // Weiß
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user