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

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