Phase 1: Sprach-Frontend (Lexer, AST, Parser, Semantik) + Entscheidungen

Frontend:
- Lexer komplett: Typ-Suffixe, Literal-Typisierung (Entscheidung: > 7
  signifikante Stellen -> DOUBLE), Hex/Oktal, Zeilenfortsetzung mit _,
  Strings mit ""-Escape, case-insensitive Keywords (Bibliotheksnamen
  bleiben Bezeichner)
- AST fuer Module/Prozeduren/Anweisungen/Ausdruecke
- Parser: fehlertolerant, zeilenorientiert; Kern-Anweisungssatz inkl.
  Bloecke, ON [LOCAL] ERROR, DEF FN (einzeilig); Datei-E/A als
  Phase-3-Platzhalter
- Semantik: Symboltabellen, implizite Deklaration, DEFtype, OPTION
  EXPLICIT, Arrays, Builtin-Signaturen, Labelpruefung; Hardware-Features
  (PEEK/POKE/...) werden zur Compile-Zeit abgewiesen
- Meilenstein: Testkorpus parst und wird typgeprueft (corpus.rs); 27 Tests

Entscheidungen eingearbeitet:
- Binaries heissen tb (IDE) und tbc (Compiler)
- Dynamische Terminalgroesse statt 80x25 (Minimum 80x25, btop-artiger
  Hinweis darunter); tb-ui::screen mit resize(), Spike angepasst
- Vollstaendigkeits-Leitplanke: 100% Sprache/Stdlib minus deklarierte
  Non-Features; Original-Doku als Guiding Principle; Inventar-Aufgabe
- CURRENCY als i64-Festkomma; ISAM wird implementiert; breite Zeichen
  belegen 2 Zellen; GET/PUT-Strings als UTF-32; Blink als hell simuliert
- LICENSE: MIT

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 09:16:01 +02:00
parent 8002ac2388
commit 333e794540
21 changed files with 3799 additions and 138 deletions

View File

@@ -43,9 +43,10 @@ fn testbild(s: &mut TextScreen) {
fn status(s: &mut TextScreen, text: &str) {
let (r, c) = (s.csrlin(), s.pos());
let (row, width) = (s.rows(), s.cols());
s.set_color(0, 7);
s.locate(25, 1).unwrap();
s.print(&format!("{text:<80.80}"));
s.locate(row, 1).unwrap();
s.print(&format!("{text:<width$.width$}"));
s.set_color(7, 0);
s.locate(r, c).unwrap();
}
@@ -54,7 +55,8 @@ fn main() -> anyhow::Result<()> {
let mut terminal = ratatui::init();
execute!(stdout(), EnableMouseCapture)?;
let mut screen = TextScreen::new();
let (tw, th) = crossterm::terminal::size()?;
let mut screen = TextScreen::with_size(tw as usize, th as usize);
testbild(&mut screen);
status(&mut screen, "Bereit. Tasten/Maus testen, Esc beendet.");
@@ -79,7 +81,10 @@ fn main() -> anyhow::Result<()> {
&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}")),
Event::Resize(w, h) => {
screen.resize(w as usize, h as usize);
status(&mut screen, &format!("Resize: {w}x{h}"));
}
_ => {}
}
}

View File

@@ -1,8 +1,12 @@
//! Textbildschirm-Emulation: 80×25-Zellenpuffer (Unicode-Zeichen + Farb-
//! attribut), Cursor, Scrollen — gerendert als Ratatui-Widget.
//! Textbildschirm-Emulation: Zellenpuffer (Unicode-Zeichen + Farbattribut),
//! Cursor, Scrollen — gerendert als Ratatui-Widget.
//!
//! 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.
//!
//! 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).
@@ -11,8 +15,9 @@ use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::widgets::Widget;
pub const COLS: usize = 80;
pub const ROWS: usize = 25;
/// Mindestgröße; darunter wird nur ein Hinweis angezeigt.
pub const MIN_COLS: usize = 80;
pub const MIN_ROWS: usize = 25;
/// Eine Bildschirmzelle: Zeichen plus klassisches Farbattribut.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -37,12 +42,14 @@ pub fn basic_color(n: u8) -> Color {
Color::Indexed(MAP[(n & 0x0F) as usize])
}
/// Der emulierte 80×25-Textbildschirm.
/// Der emulierte Textbildschirm mit dynamischer Größe.
///
/// Koordinaten in der öffentlichen API sind 1-basiert (Zeile 125,
/// Spalte 180), wie bei `LOCATE`/`CSRLIN`/`POS` des Dialekts.
/// 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,
@@ -54,6 +61,9 @@ pub struct TextScreen {
/// 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,
}
impl Default for TextScreen {
@@ -63,16 +73,63 @@ impl Default for TextScreen {
}
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 {
cells: vec![Cell::default(); COLS * ROWS],
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_bottom: rows - 1,
view_full: true,
}
}
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) {
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 }; 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;
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 = 0;
self.view_bottom = rows - 1;
} else {
self.view_top = self.view_top.min(rows - 1);
self.view_bottom = self.view_bottom.min(rows - 1);
}
}
@@ -81,23 +138,24 @@ impl TextScreen {
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.cells[row * self.cols..(row + 1) * self.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).
/// `COLOR vg, hg` (vg 031, hg 07). Blinkende Vordergrundfarben
/// (1631) 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) {
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 des Bildschirms:
/// Fehler 5 beim Aufrufer — hier wird geklemmt geprüft.
/// `LOCATE zeile, spalte` (1-basiert); außerhalb → Err (Fehler 5).
pub fn locate(&mut self, row: usize, col: usize) -> Result<(), ()> {
if row < 1 || row > ROWS || col < 1 || col > COLS {
if row < 1 || row > self.rows || col < 1 || col > self.cols {
return Err(());
}
self.cur_row = row - 1;
@@ -117,32 +175,32 @@ impl TextScreen {
/// `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 {
if top < 1 || bottom > self.rows || top > bottom {
return Err(());
}
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) * COLS + (col - 1)]
self.cells[(row - 1) * self.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.
/// `\r` setzt an den Zeilenanfang.
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] =
self.cells[self.cur_row * self.cols + self.cur_col] =
Cell { ch, fg: self.fg, bg: self.bg };
self.cur_col += 1;
if self.cur_col >= COLS {
if self.cur_col >= self.cols {
self.newline();
}
}
@@ -165,34 +223,34 @@ impl TextScreen {
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]);
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 * COLS..(self.view_bottom + 1) * COLS].fill(blank);
self.cells[self.view_bottom * self.cols..(self.view_bottom + 1) * self.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.
/// Rendert den Bildschirm oben links in die verfügbare Fläche. Ist das
/// Terminal kleiner als die Mindestgröße, erscheint nur ein Hinweis
/// (btop-artig); die Anwendung ruft bei Resize `TextScreen::resize` auf,
/// damit Puffer und Terminal deckungsgleich bleiben.
impl Widget for &TextScreen {
fn render(self, area: Rect, buf: &mut Buffer) {
if (area.width as usize) < COLS || (area.height as usize) < ROWS {
if (area.width as usize) < MIN_COLS || (area.height as usize) < MIN_ROWS {
let msg = format!(
"Terminal zu klein: {}x{}benötigt {}x{}",
area.width, area.height, COLS, ROWS
"Terminal zu klein: {}x{}Minimum {}x{}",
area.width, area.height, MIN_COLS, MIN_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 cols = self.cols.min(area.width as usize);
let rows = self.rows.min(area.height as usize);
for row in 0..rows {
for col in 0..cols {
let c = self.cells[row * self.cols + col];
if let Some(cell) = buf.cell_mut((area.x + col as u16, area.y + row as u16)) {
let mut s = String::new();
s.push(c.ch);
cell.set_symbol(&s);
@@ -204,10 +262,12 @@ impl Widget for &TextScreen {
}
// 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 self.cursor_visible && self.cur_row < rows && self.cur_col < cols {
let (cx, cy) = (area.x + self.cur_col as u16, area.y + 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));
cell.set_style(
cell.style().add_modifier(ratatui::style::Modifier::REVERSED),
);
}
}
}
@@ -248,7 +308,7 @@ mod tests {
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(24, 1).ch, 'u');
assert_eq!(s.cell(25, 1).ch, ' ');
assert_eq!(s.csrlin(), 25);
}
@@ -261,7 +321,7 @@ mod tests {
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(1, 1).ch, 'k');
assert_eq!(s.cell(4, 1).ch, 'a');
assert_eq!(s.cell(5, 1).ch, 'b');
}
@@ -274,6 +334,47 @@ mod tests {
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_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');
}
#[test]
fn farbabbildung() {
assert_eq!(basic_color(1), Color::Indexed(4)); // klassisch Blau