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