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:
2026-09-01 16:36:45 +02:00
parent 5821ace0cf
commit 7e6fa6ea88
24 changed files with 1148 additions and 25 deletions

View File

@@ -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");
}
}

View File

@@ -11,3 +11,6 @@ ratatui.workspace = true
crossterm.workspace = true
thiserror.workspace = true
log.workspace = true
[dev-dependencies]
anyhow.workspace = true

View 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(())
}

View File

@@ -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

View File

@@ -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 015 (klassische Palette).
pub fg: u8,
/// Hintergrund 07.
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 125,
/// Spalte 180), 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 031, wir ignorieren Blink → 015; hg 07).
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 35
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ß
}
}