409 lines
13 KiB
Rust
409 lines
13 KiB
Rust
//! `Host`-Abstraktion für den Textbildschirm.
|
||
//!
|
||
//! Der Host ist bewusst dumm: er zeigt den Zellenpuffer an und liefert
|
||
//! Ereignisse. Alle Bildschirmwirkungen entstehen in der Laufzeitbibliothek
|
||
//! auf [`TextScreen`] — ein Zeichenstrom könnte `LOCATE`, `COLOR`, `CSRLIN`
|
||
//! oder das Zurücklesen per `SCREEN(z, s)` nicht transportieren
|
||
//! (Entscheidung 2026-09-03, D2).
|
||
//!
|
||
//! Der Host besitzt Zeit und Warten; die VM liefert nur an kooperativen
|
||
//! Zustellpunkten aus.
|
||
|
||
use std::collections::VecDeque;
|
||
|
||
use crate::screen::TextScreen;
|
||
|
||
/// Was vom Terminal hereinkommt.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum Ereignis {
|
||
/// Tastendruck in `INKEY$`-Form (ein Zeichen für Zeichentasten,
|
||
/// Nullzeichen + Kennung für Sondertasten) samt Umschaltzustand.
|
||
///
|
||
/// Der Umschaltzustand ist ein Bitfeld wie bei der Maus: 1 Umschalt,
|
||
/// 2 Strg, 4 Alt. Er wird für `ON KEY(15)`–`ON KEY(25)` gebraucht —
|
||
/// benutzerdefinierte Tasten sind über Umschaltzustand und Scancode
|
||
/// erklärt, nicht über das erzeugte Zeichen.
|
||
Taste(String, u8),
|
||
/// Die Darstellungsfläche hat ihre Größe geändert.
|
||
Groesse { cols: usize, rows: usize },
|
||
/// Mausereignis in Zellenkoordinaten, 1-basiert wie `LOCATE`.
|
||
Maus(MausEreignis),
|
||
/// Betriebssystemsignal: 1 = SIGINT, 2 = SIGTERM (Sprachreferenz,
|
||
/// Abweichung 8). Andere Signale werden nicht abgebildet.
|
||
Signal(u8),
|
||
/// Abbruchwunsch (Strg+Untbr).
|
||
Abbruch,
|
||
/// Eingabeende — es kommt nichts mehr.
|
||
Ende,
|
||
}
|
||
|
||
/// Was die Maus getan hat. Die Position ist 1-basiert und in Zellen des
|
||
/// Textbildschirms — dieselbe Zählweise wie `LOCATE`, `CSRLIN` und `POS`;
|
||
/// die Umrechnung aus der 0-basierten Zählung des Terminals macht die
|
||
/// Darstellungsschicht (design.md, D7).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct MausEreignis {
|
||
pub art: MausArt,
|
||
/// Gedrückte Taste: 1 links, 2 rechts, 4 Mitte; 0 bei reiner Bewegung.
|
||
pub taste: u8,
|
||
/// Umschaltzustand als Bitfeld: 1 Umschalt, 2 Strg, 4 Alt.
|
||
pub shift: u8,
|
||
pub zeile: usize,
|
||
pub spalte: usize,
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum MausArt {
|
||
Druck,
|
||
Loslassen,
|
||
Bewegung,
|
||
}
|
||
|
||
/// Umschaltzustand: Bitfeld 1 Umschalt, 2 Strg, 4 Alt.
|
||
pub mod umschalt {
|
||
pub const SHIFT: u8 = 1;
|
||
pub const CTRL: u8 = 2;
|
||
pub const ALT: u8 = 4;
|
||
}
|
||
|
||
/// Steuerzeichen in `INKEY$`-Form (Kodierung des Vorbilds).
|
||
pub mod taste {
|
||
pub const ENTER: &str = "\r";
|
||
pub const BACKSPACE: &str = "\u{8}";
|
||
pub const ESC: &str = "\u{1b}";
|
||
pub const TAB: &str = "\t";
|
||
|
||
/// Sondertaste: Nullzeichen + Kennung (F1 = 59, F2 = 60 …).
|
||
pub fn sonder(code: u8) -> String {
|
||
format!("\0{}", code as char)
|
||
}
|
||
|
||
/// Zeichen → Scancode der Tastatur (Tabelle aus `qb45advr.hlp`,
|
||
/// „Keyboard Scan Codes"). Gebraucht für die benutzerdefinierten
|
||
/// Trap-Tasten `KEY 15`–`KEY 25`, die über Scancode erklärt sind.
|
||
///
|
||
/// ponytail: nur die Tasten der Original-Tabelle, keine Ziffernblock-
|
||
/// Doppelbelegungen. Ceiling: ein hier nicht geführtes Zeichen kann
|
||
/// keinen benutzerdefinierten Trap auslösen; die Tabelle wächst, wenn
|
||
/// jemand eine solche Taste braucht.
|
||
pub fn scancode(c: char) -> Option<u8> {
|
||
let c = c.to_ascii_uppercase();
|
||
Some(match c {
|
||
'\u{1b}' => 0x01,
|
||
'1' | '!' => 0x02,
|
||
'2' | '@' => 0x03,
|
||
'3' | '#' => 0x04,
|
||
'4' | '$' => 0x05,
|
||
'5' | '%' => 0x06,
|
||
'6' | '^' => 0x07,
|
||
'7' | '&' => 0x08,
|
||
'8' | '*' => 0x09,
|
||
'9' | '(' => 0x0A,
|
||
'0' | ')' => 0x0B,
|
||
'-' | '_' => 0x0C,
|
||
'=' | '+' => 0x0D,
|
||
'\u{8}' => 0x0E,
|
||
'\t' => 0x0F,
|
||
'Q' => 0x10,
|
||
'W' => 0x11,
|
||
'E' => 0x12,
|
||
'R' => 0x13,
|
||
'T' => 0x14,
|
||
'Y' => 0x15,
|
||
'U' => 0x16,
|
||
'I' => 0x17,
|
||
'O' => 0x18,
|
||
'P' => 0x19,
|
||
'[' | '{' => 0x1A,
|
||
']' | '}' => 0x1B,
|
||
'\r' => 0x1C,
|
||
'A' => 0x1E,
|
||
'S' => 0x1F,
|
||
'D' => 0x20,
|
||
'F' => 0x21,
|
||
'G' => 0x22,
|
||
'H' => 0x23,
|
||
'J' => 0x24,
|
||
'K' => 0x25,
|
||
'L' => 0x26,
|
||
';' | ':' => 0x27,
|
||
'\'' | '"' => 0x28,
|
||
'`' | '~' => 0x29,
|
||
'\\' | '|' => 0x2B,
|
||
'Z' => 0x2C,
|
||
'X' => 0x2D,
|
||
'C' => 0x2E,
|
||
'V' => 0x2F,
|
||
'B' => 0x30,
|
||
'N' => 0x31,
|
||
'M' => 0x32,
|
||
',' | '<' => 0x33,
|
||
'.' | '>' => 0x34,
|
||
'/' | '?' => 0x35,
|
||
' ' => 0x39,
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// Scancode eines Tastendrucks in `INKEY$`-Form: bei Sondertasten die
|
||
/// Kennung hinter dem Nullzeichen, sonst aus der Zeichentabelle.
|
||
pub fn scancode_von(s: &str) -> Option<u8> {
|
||
let mut it = s.chars();
|
||
match (it.next(), it.next()) {
|
||
(Some('\0'), Some(k)) => Some(k as u8),
|
||
(Some(c), None) => scancode(c),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
pub trait Host {
|
||
/// None means the frontend has scheduled a terminal handoff; retry the
|
||
/// same request after it supplies the child result.
|
||
fn shell(&mut self, command: &str) -> Result<Option<i32>, crate::errors::RuntimeError> {
|
||
shell_command(command)
|
||
.status()
|
||
.map(|s| Some(s.code().unwrap_or(0)))
|
||
.map_err(|_| crate::errors::RuntimeError(53))
|
||
}
|
||
|
||
/// Aktuellen Bildschirmzustand anzeigen.
|
||
fn present(&mut self, screen: &TextScreen);
|
||
|
||
/// Nächstes Ereignis holen. Mit `blockierend = false` sofort `None`,
|
||
/// wenn nichts anliegt (Grundlage von `INKEY$`).
|
||
fn next_event(&mut self, blockierend: bool) -> Option<Ereignis>;
|
||
|
||
/// Wartet auf Eingabe oder eine absolute Deadline in `jetzt_ms`-Zeit.
|
||
/// `None` bedeutet: Deadline erreicht. Ohne Deadline wird bis zu einem
|
||
/// Ereignis (gegebenenfalls explizitem Eingabeende) gewartet. Virtuelle
|
||
/// Hosts steuern den Zeitfortschritt selbst; keine reale Rückfalluhr.
|
||
fn warten(&mut self, deadline_ms: Option<u64>) -> Option<Ereignis>;
|
||
|
||
/// Monotone Zeit in Millisekunden seit Programmstart.
|
||
///
|
||
/// Die Fälligkeit der Zeit-Traps rechnet die Laufzeit daraus aus; sie
|
||
/// greift dafür **nicht** selbst auf die Uhr zu (design.md, D3). Nur so
|
||
/// kann ein Host ohne Terminal die Zeit stellen und ein zeitgesteuertes
|
||
/// Programm ohne Wartezeit und reproduzierbar prüfen. Von der Ortszeit
|
||
/// hinter `TIME$`/`TIMER` ist das unabhängig — hier zählt Dauer, nicht
|
||
/// Uhrzeit.
|
||
fn jetzt_ms(&mut self) -> u64;
|
||
}
|
||
|
||
/// Host ohne Terminal: hält den Bildschirmzustand fest und speist
|
||
/// vorbereitete Ereignisse ein. Grundlage des Korpus-Harness.
|
||
#[derive(Default)]
|
||
pub struct CaptureHost {
|
||
/// Zuletzt angezeigter Bildschirm.
|
||
pub screen: Option<TextScreen>,
|
||
/// Wie oft angezeigt wurde (Nachweis der Zustellpunkte).
|
||
pub presents: usize,
|
||
ereignisse: VecDeque<Ereignis>,
|
||
/// Ereignisse, die erst nach der n-ten Anzeige eingereiht werden —
|
||
/// so lässt sich eine Größenänderung mitten im Lauf nachstellen.
|
||
verzoegert: Vec<(usize, Ereignis)>,
|
||
/// Virtuelle Uhr in Millisekunden. Sie läuft nur, wenn der Test sie
|
||
/// stellt — von allein vergeht hier keine Zeit.
|
||
uhr_ms: u64,
|
||
}
|
||
|
||
impl CaptureHost {
|
||
/// Host mit vorbereiteten Eingabezeilen: jede Zeile wird zu ihren
|
||
/// Zeichen plus Enter.
|
||
pub fn with_input(lines: &[&str]) -> Self {
|
||
let mut h = CaptureHost::default();
|
||
for l in lines {
|
||
h.tippe(l);
|
||
}
|
||
h
|
||
}
|
||
|
||
/// Text als Folge von Tastendrücken anhängen, abgeschlossen mit Enter.
|
||
pub fn tippe(&mut self, text: &str) {
|
||
for c in text.chars() {
|
||
self.ereignisse.push_back(Ereignis::Taste(c.to_string(), 0));
|
||
}
|
||
self.ereignisse
|
||
.push_back(Ereignis::Taste(taste::ENTER.to_string(), 0));
|
||
}
|
||
|
||
/// Tastendruck mit Umschaltzustand einreihen (Strg+A, Alt+F1 …).
|
||
pub fn taste_mit(&mut self, s: &str, shift: u8) {
|
||
self.ereignisse
|
||
.push_back(Ereignis::Taste(s.to_string(), shift));
|
||
}
|
||
|
||
/// Beliebiges Ereignis einreihen (Größenänderung, Abbruch, Sondertaste).
|
||
pub fn ereignis(&mut self, e: Ereignis) {
|
||
self.ereignisse.push_back(e);
|
||
}
|
||
|
||
/// Ereignis erst nach der `nach`-ten Anzeige einreihen.
|
||
pub fn ereignis_nach(&mut self, nach: usize, e: Ereignis) {
|
||
self.verzoegert.push((nach, e));
|
||
}
|
||
|
||
/// Virtuelle Uhr vorrücken.
|
||
pub fn uhr_vorruecken(&mut self, ms: u64) {
|
||
self.uhr_ms += ms;
|
||
}
|
||
|
||
/// Virtuelle Uhr auf einen Wert setzen.
|
||
pub fn uhr_setzen(&mut self, ms: u64) {
|
||
self.uhr_ms = ms;
|
||
}
|
||
}
|
||
|
||
impl Host for CaptureHost {
|
||
fn present(&mut self, screen: &TextScreen) {
|
||
self.screen = Some(screen.clone());
|
||
self.presents += 1;
|
||
let faellig = self.presents;
|
||
let mut i = 0;
|
||
while i < self.verzoegert.len() {
|
||
if self.verzoegert[i].0 <= faellig {
|
||
let (_, e) = self.verzoegert.remove(i);
|
||
self.ereignisse.push_back(e);
|
||
} else {
|
||
i += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn next_event(&mut self, _blockierend: bool) -> Option<Ereignis> {
|
||
self.ereignisse.pop_front()
|
||
}
|
||
|
||
fn warten(&mut self, deadline_ms: Option<u64>) -> Option<Ereignis> {
|
||
if let Some(event) = self.next_event(false) {
|
||
return Some(event);
|
||
}
|
||
match deadline_ms {
|
||
Some(deadline) => {
|
||
self.uhr_ms = self.uhr_ms.max(deadline);
|
||
None
|
||
}
|
||
None => Some(Ereignis::Ende),
|
||
}
|
||
}
|
||
|
||
fn jetzt_ms(&mut self) -> u64 {
|
||
self.uhr_ms
|
||
}
|
||
}
|
||
|
||
/// Shared child command, inheriting the foreground terminal and stdio.
|
||
pub fn shell_command(command: &str) -> std::process::Command {
|
||
let mut child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "sh" });
|
||
if !command.is_empty() {
|
||
child.args([if cfg!(windows) { "/C" } else { "-c" }, command]);
|
||
}
|
||
child
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn capture_host_haelt_zeichen_und_attribut() {
|
||
let mut h = CaptureHost::default();
|
||
let mut s = TextScreen::new();
|
||
s.locate(5, 10).unwrap();
|
||
s.set_color(14, 1);
|
||
s.print("x");
|
||
h.present(&s);
|
||
let gehalten = h.screen.as_ref().unwrap();
|
||
let z = gehalten.cell(5, 10);
|
||
assert_eq!(z.ch, 'x');
|
||
assert_eq!((z.fg, z.bg), (14, 1));
|
||
assert_eq!(h.presents, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn verzoegertes_ereignis_kommt_nach_der_anzeige() {
|
||
let mut h = CaptureHost::default();
|
||
h.ereignis_nach(
|
||
2,
|
||
Ereignis::Groesse {
|
||
cols: 120,
|
||
rows: 40,
|
||
},
|
||
);
|
||
let s = TextScreen::new();
|
||
h.present(&s);
|
||
assert_eq!(h.next_event(false), None);
|
||
h.present(&s);
|
||
assert_eq!(
|
||
h.next_event(false),
|
||
Some(Ereignis::Groesse {
|
||
cols: 120,
|
||
rows: 40
|
||
})
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn groessenaenderung_und_abbruch_als_ereignis() {
|
||
let mut h = CaptureHost::default();
|
||
h.ereignis(Ereignis::Groesse {
|
||
cols: 120,
|
||
rows: 40,
|
||
});
|
||
h.ereignis(Ereignis::Abbruch);
|
||
assert_eq!(
|
||
h.next_event(true),
|
||
Some(Ereignis::Groesse {
|
||
cols: 120,
|
||
rows: 40
|
||
})
|
||
);
|
||
assert_eq!(h.next_event(true), Some(Ereignis::Abbruch));
|
||
}
|
||
|
||
#[test]
|
||
fn maus_und_tasten_behalten_ihre_reihenfolge() {
|
||
let mut h = CaptureHost::default();
|
||
let klick = Ereignis::Maus(MausEreignis {
|
||
art: MausArt::Druck,
|
||
taste: 1,
|
||
shift: 0,
|
||
zeile: 3,
|
||
spalte: 7,
|
||
});
|
||
h.ereignis(Ereignis::Taste("a".into(), 0));
|
||
h.ereignis(klick.clone());
|
||
h.ereignis(Ereignis::Taste("b".into(), 0));
|
||
assert_eq!(h.next_event(true), Some(Ereignis::Taste("a".into(), 0)));
|
||
assert_eq!(h.next_event(true), Some(klick));
|
||
assert_eq!(h.next_event(true), Some(Ereignis::Taste("b".into(), 0)));
|
||
}
|
||
|
||
#[test]
|
||
fn virtuelle_uhr_laeuft_nur_wenn_gestellt() {
|
||
let mut h = CaptureHost::default();
|
||
assert_eq!(h.jetzt_ms(), 0);
|
||
h.uhr_vorruecken(250);
|
||
assert_eq!(h.jetzt_ms(), 250);
|
||
h.uhr_vorruecken(250);
|
||
assert_eq!(h.jetzt_ms(), 500);
|
||
h.uhr_setzen(42);
|
||
assert_eq!(h.jetzt_ms(), 42);
|
||
// Ohne Zutun vergeht keine Zeit — das traegt die Reproduzierbarkeit.
|
||
assert_eq!(h.jetzt_ms(), 42);
|
||
}
|
||
|
||
#[test]
|
||
fn vorbereitete_zeile_wird_zu_tasten() {
|
||
let mut h = CaptureHost::with_input(&["ab"]);
|
||
assert_eq!(h.next_event(true), Some(Ereignis::Taste("a".into(), 0)));
|
||
assert_eq!(h.next_event(true), Some(Ereignis::Taste("b".into(), 0)));
|
||
assert_eq!(
|
||
h.next_event(true),
|
||
Some(Ereignis::Taste(taste::ENTER.into(), 0))
|
||
);
|
||
assert_eq!(h.next_event(false), None);
|
||
}
|
||
}
|