diff --git a/crates/tb-cli/Cargo.toml b/crates/tb-cli/Cargo.toml index c7dcc10..e6a3c40 100644 --- a/crates/tb-cli/Cargo.toml +++ b/crates/tb-cli/Cargo.toml @@ -14,5 +14,5 @@ path = "src/main.rs" tb-frontend.workspace = true tb-vm.workspace = true tb-runtime.workspace = true -tb-ui.workspace = true +tb-ui = { workspace = true, features = ["terminal"] } anyhow.workspace = true diff --git a/crates/tb-cli/src/main.rs b/crates/tb-cli/src/main.rs index 62f8677..6d99d12 100644 --- a/crates/tb-cli/src/main.rs +++ b/crates/tb-cli/src/main.rs @@ -551,6 +551,20 @@ impl Host for PipeHost { self.puffer.pop_front() } + fn warten(&mut self, deadline_ms: Option) -> Option { + if let Some(event) = self.next_event(false) { + return Some(event); + } + if let Some(deadline) = deadline_ms { + std::thread::sleep(std::time::Duration::from_millis( + deadline.saturating_sub(self.jetzt_ms()), + )); + None + } else { + self.next_event(true).or(Some(Ereignis::Ende)) + } + } + fn jetzt_ms(&mut self) -> u64 { self.start.elapsed().as_millis() as u64 } diff --git a/crates/tb-cli/tests/compat.rs b/crates/tb-cli/tests/compat.rs index 1a3c234..e4ecf21 100644 --- a/crates/tb-cli/tests/compat.rs +++ b/crates/tb-cli/tests/compat.rs @@ -265,6 +265,12 @@ impl tb_runtime::host::Host for EreignisHost { } } + fn warten(&mut self, deadline_ms: Option) -> Option { + if let Some(event) = self.next_event(false) { + return Some(event); + } + self.inner.warten(deadline_ms) + } fn jetzt_ms(&mut self) -> u64 { self.inner.jetzt_ms() } @@ -318,6 +324,12 @@ impl tb_runtime::host::Host for UhrHost { fn next_event(&mut self, blockierend: bool) -> Option { self.inner.next_event(blockierend) } + fn warten(&mut self, deadline_ms: Option) -> Option { + if let Some(event) = self.next_event(false) { + return Some(event); + } + self.inner.warten(deadline_ms) + } fn jetzt_ms(&mut self) -> u64 { let jetzt = self.inner.jetzt_ms(); self.inner.uhr_vorruecken(self.schritt_ms); diff --git a/crates/tb-frontend/src/parser.rs b/crates/tb-frontend/src/parser.rs index 54c2459..f44b526 100644 --- a/crates/tb-frontend/src/parser.rs +++ b/crates/tb-frontend/src/parser.rs @@ -1882,7 +1882,7 @@ impl<'a> P<'a> { { if matches!( name.as_str(), - "TIMER" | "KEY" | "COM" | "PEN" | "PLAY" | "STRIG" | "UEVENT" | "EVENT" + "TIMER" | "KEY" | "COM" | "PEN" | "PLAY" | "STRIG" | "UEVENT" | "SIGNAL" | "EVENT" ) { let action = match self.k() { TokenKind::Kw(Kw::On) => Some(EventAction::On), diff --git a/crates/tb-runtime/src/builtins.rs b/crates/tb-runtime/src/builtins.rs index bcf5cf6..86186cf 100644 --- a/crates/tb-runtime/src/builtins.rs +++ b/crates/tb-runtime/src/builtins.rs @@ -28,14 +28,8 @@ pub struct RtState { /// ISAM-Zustand: Tabellenbindungen, Cursor, laufende Transaktion. pub isam: crate::isam::Isam, pub print: PrintState, - /// Noch nicht abgeholte Tastendrücke in `INKEY$`-Form, je mit - /// Umschaltzustand (Bitfeld 1 Umschalt, 2 Strg, 4 Alt). Der Zustand - /// wird nur für die benutzerdefinierten Trap-Tasten gebraucht; - /// `INKEY$` und `INPUT` sehen ihn nicht. - pub tasten: VecDeque<(String, u8)>, - /// Mausereignisse für den Forms-Dispatch; sie werden erst an den - /// bestehenden Zustellpunkten von der VM entnommen. - pub maus: VecDeque, + /// Hostreihenfolge bis zur Annahme durch Trap, Formular oder Konsole. + pub eingaben: VecDeque, /// Abbruchwunsch (Strg+Untbr) wurde gemeldet. pub abbruch: bool, /// Eingabeende wurde gemeldet. @@ -66,6 +60,23 @@ pub struct RtState { pub clipboard: String, } +/// Eine noch nicht verbrauchte Eingabe. Unter KEY STOP wird der Trap +/// einmal gemerkt; die Taste bleibt danach unabhängig vom Trap lesbar. +#[derive(Debug)] +pub struct Eingabe { + pub ereignis: Ereignis, + trap_gemerkt: bool, +} + +impl From for Eingabe { + fn from(ereignis: Ereignis) -> Self { + Self { + ereignis, + trap_gemerkt: false, + } + } +} + impl Default for RtState { fn default() -> Self { RtState { @@ -73,8 +84,7 @@ impl Default for RtState { dateien: Default::default(), isam: Default::default(), print: PrintState::default(), - tasten: VecDeque::new(), - maus: VecDeque::new(), + eingaben: VecDeque::new(), abbruch: false, ende: false, // Das Vorbild belegt die Makros im Interpreter vor; ein @@ -111,24 +121,28 @@ impl RtState { let Some(e) = host.next_event(blockierend) else { return; }; - match e { - Ereignis::Taste(t, shift) => self.tasten.push_back((t, shift)), - Ereignis::Groesse { cols, rows } => self.screen.resize(cols, rows), - Ereignis::Abbruch => self.abbruch = true, - Ereignis::Ende => self.ende = true, - Ereignis::Signal(n) => { - if n == 1 && !self.traps.melden(crate::traps::Quelle::Signal(n)) { - self.abbruch = true; - } - } - Ereignis::Maus(m) => self.maus.push_back(m), - } + self.ereignis(e); if blockierend { return; } } } + pub fn ereignis(&mut self, e: Ereignis) { + match e { + Ereignis::Taste(..) | Ereignis::Maus(_) => self.eingaben.push_back(e.into()), + Ereignis::Groesse { cols, rows } => self.screen.resize(cols, rows), + Ereignis::Abbruch => self.abbruch = true, + Ereignis::Ende => self.ende = true, + Ereignis::Signal(n) => { + let gemeldet = self.traps.melden(crate::traps::Quelle::Signal(n)); + if n == 1 && !gemeldet { + self.abbruch = true; + } + } + } + } + /// Bildschirm anzeigen und anstehende Ereignisse einsammeln. /// Zustellpunkt an Tick-Grenzen und vor blockierender Eingabe. pub fn tick(&mut self, host: &mut dyn Host) { @@ -147,45 +161,80 @@ impl RtState { if !self.traps.aktiv() { return; } - let jetzt = host.jetzt_ms(); - self.traps.zeit_pruefen(jetzt); + if self.traps.naechste_frist().is_some() { + self.traps.zeit_pruefen(host.jetzt_ms()); + } self.tasten_traps_pruefen(); } /// Getrappte Tasten aus der Schlange nehmen. Ohne Host, damit die VM /// das an jeder Anweisungsgrenze tun kann, ohne die Uhr zu lesen. pub fn tasten_traps_pruefen(&mut self) { - // Nur der Kopf der Schlange: die Reihenfolge der Eingabe bleibt - // erhalten, und eine getrappte Taste hinter einer ungetrappten - // wartet, bis diese gelesen ist. - while let Some((s, shift)) = self.tasten.front() { - let Some(code) = crate::host::taste::scancode_von(s) else { - break; - }; - let Some(n) = self.traps.taste_faengt(code, *shift) else { - break; - }; - self.tasten.pop_front(); - self.traps.melden(crate::traps::Quelle::Key(n)); + while self.taste_trappen(0) {} + } + + /// Prüft eine wartende Taste genau bis zu ihrer Annahme. ON verbraucht + /// sie, STOP merkt den Trap einmal und belässt sie im Eingabestrom. + pub fn taste_trappen(&mut self, index: usize) -> bool { + if !self.traps.aktiv() { + return false; + } + let Some(eingabe) = self.eingaben.get_mut(index) else { + return false; + }; + let Ereignis::Taste(s, shift) = &eingabe.ereignis else { + return false; + }; + if eingabe.trap_gemerkt { + return false; + } + let Some(code) = crate::host::taste::scancode_von(s) else { + return false; + }; + let Some(n) = self.traps.taste_faengt(code, *shift) else { + return false; + }; + self.traps.melden(crate::traps::Quelle::Key(n)); + if self.traps.ist_an(crate::traps::Quelle::Key(n)) { + self.eingaben.remove(index); + true + } else { + eingabe.trap_gemerkt = true; + false } } - /// Nächster Tastendruck in `INKEY$`-Form; blockiert bei `blockierend`. - pub fn naechste_taste(&mut self, host: &mut dyn Host, blockierend: bool) -> Option { - loop { - if let Some((t, _)) = self.tasten.pop_front() { + /// Nur bereits gepufferte Tasten annehmen, ohne weitere Hostereignisse + /// abzuholen. Die VM kann zwischen Abholen und Blockieren zustellen. + pub fn gepufferte_taste(&mut self) -> Option { + while let Some(i) = self + .eingaben + .iter() + .position(|e| matches!(e.ereignis, Ereignis::Taste(..))) + { + if self.taste_trappen(i) { + continue; + } + if let Ereignis::Taste(t, _) = self.eingaben.remove(i).unwrap().ereignis { return Some(t); } - if self.ende { + } + None + } + + /// Nächster Tastendruck; auch während blockierender Eingabe gelten + /// ON/OFF/STOP. Mausereignisse bleiben dem VM-Zustellpunkt vorbehalten. + pub fn naechste_taste(&mut self, host: &mut dyn Host, blockierend: bool) -> Option { + let mut abgeholt = false; + loop { + if let Some(t) = self.gepufferte_taste() { + return Some(t); + } + if self.ende || self.abbruch || (!blockierend && abgeholt) { return None; } self.pump(host, blockierend); - if !blockierend && self.tasten.is_empty() { - return None; - } - if blockierend && self.tasten.is_empty() && self.ende { - return None; - } + abgeholt = true; } } @@ -2367,12 +2416,24 @@ fn bi_sleep( a: &mut [Value], ) -> Result, RuntimeError> { let secs = if a.is_empty() { 0.0 } else { arg_f64(a, 0)? }; - // SLEEP n wartet n Sekunden; SLEEP ohne Argument bis zum Tastendruck. + // Der Bytecode nutzt Instr::Sleep für Handlerzustellung. Auch direkte + // Bibliotheksaufrufe dürfen keine eigene reale Uhr verwenden. + let deadline = (secs > 0.0).then(|| host.jetzt_ms().saturating_add((secs * 1000.0) as u64)); st.tick(host); - if secs > 0.0 { - std::thread::sleep(std::time::Duration::from_secs_f64(secs)); - } else { - st.naechste_taste(host, true); + loop { + if st.ende || st.abbruch { + break; + } + if let Some(deadline) = deadline { + if host.jetzt_ms() >= deadline { + break; + } + } else if st.naechste_taste(host, false).is_some() { + break; + } + if let Some(event) = host.warten(deadline) { + st.ereignis(event); + } } Ok(None) } @@ -2777,6 +2838,60 @@ mod tests { assert!(h.next_event(false).is_none()); } + #[test] + fn signal_stop_merkt_beide_quellen_und_off_verwirft() { + use crate::host::{CaptureHost, Ereignis}; + use crate::traps::{Quelle, Zustand}; + for n in [1, 2] { + let mut st = RtState::default(); + let mut host = CaptureHost::default(); + st.traps.definieren(Quelle::Signal(n), Some(22)); + st.traps.setzen(Quelle::Signal(n), Zustand::Gestoppt, 0); + for _ in 0..3 { + host.ereignis(Ereignis::Signal(n)); + } + st.pump(&mut host, false); + assert!(!st.abbruch); + assert_eq!(st.traps.naechstes(), None); + st.traps.setzen(Quelle::Signal(n), Zustand::An, 0); + assert_eq!(st.traps.naechstes(), Some((Quelle::Signal(n), 22))); + st.traps.handler_beendet(Quelle::Signal(n)); + assert_eq!(st.traps.naechstes(), None); + st.traps.setzen(Quelle::Signal(n), Zustand::Gestoppt, 0); + host.ereignis(Ereignis::Signal(n)); + st.pump(&mut host, false); + st.traps.setzen(Quelle::Signal(n), Zustand::Aus, 0); + st.traps.setzen(Quelle::Signal(n), Zustand::An, 0); + assert_eq!(st.traps.naechstes(), None); + } + } + + #[test] + fn key_stop_merkt_einmal_und_laesst_alle_tasten_auch_nach_on_lesbar() { + use crate::host::{CaptureHost, Ereignis}; + use crate::traps::{Quelle, Zustand}; + let mut st = RtState::default(); + let mut host = CaptureHost::default(); + st.traps.definieren(Quelle::Key(15), Some(22)); + st.traps.benutzertaste(15, 0, 30); + st.traps.setzen(Quelle::Key(15), Zustand::Gestoppt, 0); + for _ in 0..3 { + host.ereignis(Ereignis::Taste("a".into(), 0)); + } + st.pump(&mut host, false); + for i in 0..3 { + assert!(!st.taste_trappen(i)); + } + assert_eq!(st.naechste_taste(&mut host, false).as_deref(), Some("a")); + st.traps.setzen(Quelle::Key(15), Zustand::An, 0); + assert_eq!(st.traps.naechstes(), Some((Quelle::Key(15), 22))); + st.traps.handler_beendet(Quelle::Key(15)); + for _ in 0..2 { + assert_eq!(st.naechste_taste(&mut host, false).as_deref(), Some("a")); + } + assert_eq!(st.traps.naechstes(), None); + } + #[test] fn sigint_ohne_aktiven_trap_bleibt_abbruch() { use crate::host::Ereignis; @@ -2802,7 +2917,10 @@ mod tests { })); } st.pump(&mut h, false); - assert!(st.tasten.is_empty(), "Maus landet nicht im Tastenpuffer"); + assert!(st + .eingaben + .iter() + .all(|e| matches!(e.ereignis, Ereignis::Maus(_)))); } #[test] diff --git a/crates/tb-runtime/src/host.rs b/crates/tb-runtime/src/host.rs index 6cc0c85..1d722c1 100644 --- a/crates/tb-runtime/src/host.rs +++ b/crates/tb-runtime/src/host.rs @@ -6,8 +6,8 @@ //! oder das Zurücklesen per `SCREEN(z, s)` nicht transportieren //! (Entscheidung 2026-09-03, D2). //! -//! Host-Aufrufe dürfen blockieren; kooperative Zustellpunkte bringt erst die -//! Ereignisschleife der Forms-Engine (Phase 4). +//! Der Host besitzt Zeit und Warten; die VM liefert nur an kooperativen +//! Zustellpunkten aus. use std::collections::VecDeque; @@ -165,6 +165,12 @@ pub trait Host { /// wenn nichts anliegt (Grundlage von `INKEY$`). fn next_event(&mut self, blockierend: bool) -> Option; + /// 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) -> Option; + /// Monotone Zeit in Millisekunden seit Programmstart. /// /// Die Fälligkeit der Zeit-Traps rechnet die Laufzeit daraus aus; sie @@ -260,6 +266,19 @@ impl Host for CaptureHost { self.ereignisse.pop_front() } + fn warten(&mut self, deadline_ms: Option) -> Option { + 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 } diff --git a/crates/tb-runtime/src/traps.rs b/crates/tb-runtime/src/traps.rs index 3bdeb83..8380b01 100644 --- a/crates/tb-runtime/src/traps.rs +++ b/crates/tb-runtime/src/traps.rs @@ -57,6 +57,8 @@ pub struct Traps { /// `EVENT OFF` klammert einen Abschnitt ohne Ereignisprüfung. Kein /// dritter Zustand — den kennt das Vorbild nicht. event_an: bool, + /// Menüsperre ohne Änderung der expliziten ON/OFF/STOP-Zustände. + suspendiert: bool, /// Fälligkeit des Zeit-Traps: Intervall und der Zeitpunkt, an dem er /// das nächste Mal fällig wird (beides in Millisekunden). timer_intervall_ms: u64, @@ -178,6 +180,27 @@ impl Traps { self.aktiv } + pub fn suspendieren(&mut self, gesperrt: bool) { + self.suspendiert = gesperrt; + } + + pub fn ist_an(&self, q: Quelle) -> bool { + self.traps + .iter() + .any(|t| t.quelle == q && t.zustand == Zustand::An) + } + + /// Nächste Hostfrist, sofern der Zeit-Trap überhaupt Zeit benötigt. + pub fn naechste_frist(&self) -> Option { + (self.event_an + && !self.suspendiert + && self.timer_intervall_ms > 0 + && self.traps.iter().any(|t| { + t.quelle == Quelle::Timer && t.ziel.is_some() && t.zustand != Zustand::Aus + })) + .then_some(self.timer_faellig_ms) + } + /// Benutzerdefinierte Taste erklären (`KEY n, CHR$(flag)+CHR$(scan)`, /// n = 15–25). `flag` ist das Tastaturflagbyte des Vorbilds. pub fn benutzertaste(&mut self, n: u8, flag: u8, scancode: u8) { @@ -193,7 +216,7 @@ impl Traps { /// Liefert die Trap-Nummer, nicht nur „ja/nein": der Aufrufer muss /// wissen, welche Quelle er melden soll. pub fn taste_faengt(&self, scancode: u8, shift: u8) -> Option { - if !self.event_an { + if !self.event_an || self.suspendiert { return None; } let treffer = |k: u8| -> bool { @@ -218,7 +241,7 @@ impl Traps { /// Nächstes zuzustellendes Ereignis: Quelle und Sprungziel. Liefert /// höchstens eines je Aufruf; die übrigen bleiben anstehend. pub fn naechstes(&mut self) -> Option<(Quelle, u32)> { - if !self.event_an { + if !self.event_an || self.suspendiert { return None; } let mut kandidaten: Vec = (0..self.traps.len()) diff --git a/crates/tb-ui/Cargo.toml b/crates/tb-ui/Cargo.toml index 230c505..48bbaee 100644 --- a/crates/tb-ui/Cargo.toml +++ b/crates/tb-ui/Cargo.toml @@ -1,17 +1,21 @@ [package] name = "tb-ui" -description = "Terminal Basic: Bildschirm-Runtime und Forms-Engine auf Ratatui" +description = "Terminal Basic: Forms-Engine mit optionalem Terminal-Backend" version.workspace = true edition.workspace = true license.workspace = true authors.workspace = true +[features] +default = [] +terminal = ["dep:ratatui", "dep:crossterm", "dep:signal-hook"] + [dependencies] tb-runtime.workspace = true tb-frontend.workspace = true -ratatui.workspace = true -crossterm.workspace = true -signal-hook.workspace = true +ratatui = { workspace = true, optional = true } +crossterm = { workspace = true, optional = true } +signal-hook = { workspace = true, optional = true } thiserror.workspace = true log.workspace = true diff --git a/crates/tb-ui/src/forms.rs b/crates/tb-ui/src/forms.rs index 9864602..242f74a 100644 --- a/crates/tb-ui/src/forms.rs +++ b/crates/tb-ui/src/forms.rs @@ -1777,6 +1777,49 @@ impl FormsModel { } } + fn timer_enabled(&self, key: ObjectKey) -> bool { + self.instance(key) + .is_ok_and(|obj| obj.description.class == ObjectClass::Timer) + && self.boolean(key, "ENABLED") + && self.integer(key, "INTERVAL").unwrap_or(0) > 0 + && self + .root_form(key) + .is_some_and(|form| self.is_visible(form)) + } + + /// Zeitbedarf für Timer und gedrückt gehaltene Spin-Schaltflächen. + pub fn next_deadline(&self) -> Option { + if self.menu_is_open() || (!self.has_visible_forms() && self.spin_repeat.is_none()) { + return None; + } + let timers = self + .keys() + .into_iter() + .filter(|key| self.timer_enabled(*key)) + .map(|key| { + self.timer_last + .get(&key) + .copied() + .unwrap_or(0) + .saturating_add(self.integer(key, "INTERVAL").unwrap() as u64) + }); + let spin = self.spin_repeat.and_then(|(key, _)| { + let interval = self.integer(key, "INTERVAL").unwrap_or(250).max(0) as u64; + (interval > 0).then(|| { + self.spin_last + .map_or(0, |last| last.saturating_add(interval)) + }) + }); + timers.chain(spin).min() + } + + pub fn mouse_needs_time(&self, event: MausEreignis) -> bool { + self.active_form.is_some() + && !self.menu_is_open() + && event.art == MausArt::Loslassen + && self.pressed.is_some() + } + pub fn timers(&mut self, now_ms: u64) { if !self.menu_path.is_empty() { return; @@ -1784,15 +1827,7 @@ impl FormsModel { let mut due: Vec<_> = self .keys() .into_iter() - .filter(|key| { - self.instance(*key) - .is_ok_and(|obj| obj.description.class == ObjectClass::Timer) - && self.boolean(*key, "ENABLED") - && self.integer(*key, "INTERVAL").unwrap_or(0) > 0 - && self - .root_form(*key) - .is_some_and(|form| self.is_visible(form)) - }) + .filter(|key| self.timer_enabled(*key)) .collect(); due.sort_by_key(|key| { self.instance(*key) @@ -2395,13 +2430,16 @@ fn dialog_event( model: &mut FormsModel, screen: &mut TextScreen, host: &mut dyn Host, - queued: &mut VecDeque, + queued: &mut VecDeque, plain_access_key: bool, ) -> Option { loop { model.render(screen); host.present(screen); - let event = queued.pop_front().or_else(|| host.next_event(true))?; + let event = queued + .pop_front() + .map(|e| e.ereignis) + .or_else(|| host.next_event(true))?; match event { Ereignis::Taste(key, shift) => { let shift = if plain_access_key && key.chars().count() == 1 { @@ -2412,14 +2450,19 @@ fn dialog_event( model.handle_key(&key, shift); } Ereignis::Maus(event) => { - model.handle_mouse_at(event, host.jetzt_ms()); + let now = if model.mouse_needs_time(event) { + host.jetzt_ms() + } else { + 0 + }; + model.handle_mouse_at(event, now); } Ereignis::Groesse { cols, rows } => { screen.resize(cols, rows); model.resize(cols, rows); } event @ (Ereignis::Abbruch | Ereignis::Ende | Ereignis::Signal(_)) => { - queued.push_front(event); + queued.push_front(event.into()); return None; } } @@ -2435,7 +2478,7 @@ fn dialog_event( pub fn msgbox_dialog( screen: &mut TextScreen, host: &mut dyn Host, - queued: &mut VecDeque, + queued: &mut VecDeque, text: &str, kind: i32, title: &str, @@ -2549,7 +2592,7 @@ pub fn msgbox_dialog( pub fn inputbox_dialog( screen: &mut TextScreen, host: &mut dyn Host, - queued: &mut VecDeque, + queued: &mut VecDeque, prompt: &str, title: &str, initial: &str, diff --git a/crates/tb-ui/src/host.rs b/crates/tb-ui/src/host.rs index 39a4912..7f1f6f8 100644 --- a/crates/tb-ui/src/host.rs +++ b/crates/tb-ui/src/host.rs @@ -86,7 +86,33 @@ impl Host for TerminalHost { self.start.elapsed().as_millis() as u64 } + fn warten(&mut self, deadline_ms: Option) -> Option { + loop { + if let Some(e) = self.next_event(false) { + return Some(e); + } + let timeout = match deadline_ms { + Some(deadline) => { + let rest = deadline.saturating_sub(self.jetzt_ms()); + if rest == 0 { + return None; + } + Duration::from_millis(rest.min(10)) + } + None => Duration::from_millis(10), + }; + // Begrenzt warten, damit auch SIGINT/SIGTERM ohne Terminaltaste + // abgeholt werden. Die VM kennt dieses Backend-Intervall nicht. + if event::poll(timeout).is_err() { + return Some(Ereignis::Ende); + } + } + } + fn next_event(&mut self, blockierend: bool) -> Option { + if blockierend { + return self.warten(None); + } loop { // Signale vor dem Terminal: sie liegen schon an, wenn wir hier // ankommen, und ein blockierendes `read` wuerde sie liegenlassen. @@ -181,7 +207,7 @@ fn taste_zu_ereignis(k: KeyEvent) -> Option { KeyCode::Enter => taste::ENTER.to_string(), KeyCode::Backspace => taste::BACKSPACE.to_string(), KeyCode::Esc => taste::ESC.to_string(), - KeyCode::Tab => taste::TAB.to_string(), + KeyCode::Tab | KeyCode::BackTab => taste::TAB.to_string(), // Sondertasten in der Kodierung des Vorbilds: Nullzeichen + Kennung. KeyCode::F(n @ 1..=10) => taste::sonder(58 + n), KeyCode::Home => taste::sonder(71), @@ -197,7 +223,7 @@ fn taste_zu_ereignis(k: KeyEvent) -> Option { _ => return None, }; let mut shift = 0u8; - if k.modifiers.contains(KeyModifiers::SHIFT) { + if k.modifiers.contains(KeyModifiers::SHIFT) || k.code == KeyCode::BackTab { shift |= umschalt::SHIFT; } if k.modifiers.contains(KeyModifiers::CONTROL) { @@ -213,6 +239,16 @@ fn taste_zu_ereignis(k: KeyEvent) -> Option { mod tests { use super::*; + #[test] + fn backtab_ist_tab_mit_shift_auch_ohne_modifier() { + for modifiers in [KeyModifiers::NONE, KeyModifiers::SHIFT, KeyModifiers::ALT] { + assert_eq!( + taste_zu_ereignis(KeyEvent::new(KeyCode::BackTab, modifiers)), + taste_zu_ereignis(KeyEvent::new(KeyCode::Tab, modifiers | KeyModifiers::SHIFT)) + ); + } + } + #[test] fn zeichentaste_wird_zu_inkey_form() { let k = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE); diff --git a/crates/tb-ui/src/lib.rs b/crates/tb-ui/src/lib.rs index 8e26c37..ac98a64 100644 --- a/crates/tb-ui/src/lib.rs +++ b/crates/tb-ui/src/lib.rs @@ -1,17 +1,16 @@ -//! Bildschirm-Runtime und Forms-Engine von Terminal Basic, aufbauend auf Ratatui. +//! Terminalunabhängiges Forms-Modell, FRM-Reader und Dialoge. //! -//! Zwei Schichten: -//! 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 -//! (Schaltflächen, Textfelder, Listen …), Menüs, Fokusreihenfolge, -//! Tastatur- und Mausereignisse. +//! Die Ausführung verwendet `tb_runtime::host::Host` und dessen Zellenpuffer. +//! Das optionale Feature `terminal` ergänzt Anzeige über Ratatui, +//! crossterm-Eingaben und Betriebssystemsignale. pub mod events; pub mod forms; pub mod frm; mod frm_pcode; +#[cfg(feature = "terminal")] pub mod host; // Terminal-Host: Anzeige + Tastatur-/Größenereignisse +#[cfg(feature = "terminal")] pub mod screen; +#[cfg(feature = "terminal")] pub mod signale; // Betriebssystemsignale als Ereignisquelle (SIGNAL) diff --git a/crates/tb-vm/src/interp.rs b/crates/tb-vm/src/interp.rs index b13ac7b..8bde32a 100644 --- a/crates/tb-vm/src/interp.rs +++ b/crates/tb-vm/src/interp.rs @@ -78,11 +78,27 @@ struct Frame { waiting_form: Option, pending_show: Option, form_event: Option, + handler_start: bool, + eingabe: Option, +} + +struct KonsolenEingabe { + text: String, + /// None = Zeile bis Enter; Some = noch erwartete Tasten für INPUT$. + rest: Option, +} + +#[derive(Clone, Copy, PartialEq)] +enum Zustellpunkt { + Anweisung, + Kooperativ, + Konsole, } #[derive(Clone, Copy)] enum FormEventReturn { Normal, + Load, Unload(u16), } @@ -225,6 +241,8 @@ impl Vm { waiting_form: None, pending_show: None, form_event: None, + handler_start: false, + eingabe: None, }); } @@ -262,36 +280,15 @@ impl Vm { } } - fn take_dialog_input(&mut self) -> VecDeque { - let mut events = VecDeque::new(); - events.extend( - self.rt - .tasten - .drain(..) - .map(|(key, shift)| Ereignis::Taste(key, shift)), - ); - events.extend(self.rt.maus.drain(..).map(Ereignis::Maus)); - events + fn take_dialog_input(&mut self) -> VecDeque { + std::mem::take(&mut self.rt.eingaben) } - fn restore_dialog_input(&mut self, events: VecDeque) { - for event in events { - match event { - Ereignis::Taste(key, shift) => self.rt.tasten.push_back((key, shift)), - Ereignis::Maus(event) => self.rt.maus.push_back(event), - Ereignis::Abbruch => self.rt.abbruch = true, - Ereignis::Ende => self.rt.ende = true, - Ereignis::Signal(number) => { - if number == 1 - && !self - .rt - .traps - .melden(tb_runtime::traps::Quelle::Signal(number)) - { - self.rt.abbruch = true; - } - } - Ereignis::Groesse { cols, rows } => self.rt.screen.resize(cols, rows), + fn restore_dialog_input(&mut self, events: VecDeque) { + for input in events { + match input.ereignis { + Ereignis::Taste(..) | Ereignis::Maus(_) => self.rt.eingaben.push_back(input), + event => self.rt.ereignis(event), } } } @@ -563,13 +560,47 @@ impl Vm { ); if let Some(frame) = self.frames.last_mut() { frame.form_event = Some(on_return); + frame.handler_start = true; } true } - fn dispatch_next_form_event(&mut self) -> bool { - while let Some(event) = self.forms.next_event() { - if self.dispatch_form_event(event, FormEventReturn::Normal) { + fn dispatch_next_form_event(&mut self, load_only: bool) -> bool { + let mut i = 0; + while let Some(event) = self.forms.events.get(i) { + if load_only && !event.name.eq_ignore_ascii_case("LOAD") { + i += 1; + continue; + } + let laeuft = self + .module + .event_procs + .iter() + .find(|binding| { + binding.object == event.object + && binding.event.eq_ignore_ascii_case(&event.name) + }) + .is_some_and(|binding| { + self.frames.iter().any(|frame| { + frame.form_event.is_some() && frame.proc == binding.proc as usize + }) + }); + // Gesperrte Timer und laufende Prozeduren bleiben anstehend. + // Menübedienung und explizite modale Kooperation müssen trotzdem + // andere Handler erreichen können. + if laeuft || (self.forms.menu_is_open() && event.name.eq_ignore_ascii_case("TIMER")) { + i += 1; + continue; + } + let event = self.forms.events.remove(i).unwrap(); + if self.dispatch_form_event( + event, + if load_only { + FormEventReturn::Load + } else { + FormEventReturn::Normal + }, + ) { return true; } } @@ -577,6 +608,12 @@ impl Vm { } fn request_unload(&mut self, object: u16) -> Result<(), RuntimeError> { + if self.frames.iter().any(|frame| { + matches!(frame.form_event, + Some(FormEventReturn::Unload(running)) if running == object) + }) { + return Ok(()); + } let event = FormEvent { object, array_index: None, @@ -590,80 +627,136 @@ impl Vm { } fn tick(&mut self, host: &mut dyn Host) { + self.rt.traps.suspendieren(self.forms.menu_is_open()); self.forms.render(&mut self.rt.screen); - if self.forms.menu_is_open() { - host.present(&self.rt.screen); - self.rt.pump(host, false); - } else { - self.rt.tick(host); + host.present(&self.rt.screen); + self.rt.pump(host, false); + if !self.forms.menu_is_open() && self.rt.traps.naechste_frist().is_some() { + self.rt.traps.zeit_pruefen(host.jetzt_ms()); } } - fn forms_zustellen(&mut self, host: &mut dyn Host) -> bool { - self.forms - .resize(self.rt.screen.cols(), self.rt.screen.rows()); - self.forms.timers(host.jetzt_ms()); - if self.forms.active_form().is_some() { - while let Some((key, shift)) = self.rt.tasten.pop_front() { - self.forms.handle_key(&key, shift); + /// Ein gemeinsamer Zustellpfad für Statements, DOEVENTS, SLEEP und + /// blockierende Eingabe. Nur explizite Kooperation verschachtelt Forms. + fn zustellen(&mut self, host: &mut dyn Host, punkt: Zustellpunkt) -> bool { + let kooperativ = punkt != Zustellpunkt::Anweisung; + 'zustellung: loop { + self.rt.traps.suspendieren(self.forms.menu_is_open()); + self.forms + .resize(self.rt.screen.cols(), self.rt.screen.rows()); + if self.forms.next_deadline().is_some() { + self.forms.timers(host.jetzt_ms()); } - } - let now_ms = host.jetzt_ms(); - while let Some(m) = self.rt.maus.pop_front() { - self.forms.handle_mouse_at(m, now_ms); - } - self.forms.render(&mut self.rt.screen); - self.dispatch_next_form_event() - } - - /// Anstehendes Ereignis zustellen, falls eines fällig ist. Liefert - /// `true`, wenn ein Handler aufgesetzt wurde. - fn zustellen(&mut self, host: &mut dyn Host) -> bool { - if self.forms.menu_is_open() || !self.rt.traps.aktiv() { + let forms_frei = kooperativ || !self.frames.iter().any(|f| f.form_event.is_some()); + if forms_frei && self.dispatch_next_form_event(false) { + return true; + } + if self.rt.traps.aktiv() { + self.rt.tasten_traps_pruefen(); + if (kooperativ || self.tick_zaehler.is_multiple_of(64)) + && self.rt.traps.naechste_frist().is_some() + { + self.rt.traps.zeit_pruefen(host.jetzt_ms()); + } + if let Some((q, ziel)) = self.rt.traps.naechstes() { + self.trap_frame_aufsetzen(q, ziel); + return true; + } + } + // Unverbrauchte Konsolentasten bleiben liegen. Maus ohne Formular + // wird verworfen; mit Formular wird jede Eingabe samt ihrer Handler + // abgearbeitet, bevor die nächste Eingabe angenommen wird. + let mut i = 0; + while i < self.rt.eingaben.len() { + if !self.forms.menu_is_open() && self.rt.taste_trappen(i) { + if let Some((q, ziel)) = self.rt.traps.naechstes() { + self.trap_frame_aufsetzen(q, ziel); + return true; + } + continue; + } + if punkt == Zustellpunkt::Konsole + && matches!(self.rt.eingaben[i].ereignis, Ereignis::Taste(..)) + { + i += 1; + continue; + } + if self.forms.active_form().is_some() { + if !forms_frei { + break; + } + match self.rt.eingaben.remove(i).unwrap().ereignis { + Ereignis::Taste(key, shift) => { + let menu_war_offen = self.forms.menu_is_open(); + self.forms.handle_key(&key, shift); + self.rt.traps.suspendieren(self.forms.menu_is_open()); + if menu_war_offen && !self.forms.menu_is_open() { + continue 'zustellung; + } + } + Ereignis::Maus(m) => { + if m.zeile > 0 + && m.zeile <= self.rt.screen.rows() + && m.spalte > 0 + && m.spalte <= self.rt.screen.cols() + { + let now = if self.forms.mouse_needs_time(m) { + host.jetzt_ms() + } else { + 0 + }; + self.forms.handle_mouse_at(m, now); + } + } + _ => unreachable!(), + } + if self.dispatch_next_form_event(false) { + return true; + } + } else if matches!(self.rt.eingaben[i].ereignis, Ereignis::Maus(_)) { + self.rt.eingaben.remove(i); + } else { + i += 1; + } + } + self.forms.render(&mut self.rt.screen); return false; } - let jetzt = host.jetzt_ms(); - self.rt.traps.zeit_pruefen(jetzt); - self.rt.tasten_traps_pruefen(); - if let Some((q, ziel)) = self.rt.traps.naechstes() { - self.trap_frame_aufsetzen(q, ziel); - return true; - } - false } - /// `SLEEP [n]`: wartet `n` Sekunden, ohne Argument bis zum nächsten - /// Tastendruck. Ein zugestelltes Ereignis beendet das Warten - /// (Sprachreferenz §8) — der Handler läuft, und danach geht es hinter - /// dem `SLEEP` weiter. + fn warten(&mut self, host: &mut dyn Host, deadline: Option) { + let deadline = deadline + .into_iter() + .chain(self.forms.next_deadline()) + .chain(if self.forms.menu_is_open() { + None + } else { + self.rt.traps.naechste_frist() + }) + .min(); + if let Some(event) = host.warten(deadline) { + self.rt.ereignis(event); + } + } + + /// Ein zugestelltes Ereignis beendet SLEEP; Fristen und Warten gehören + /// ausschließlich dem Host, einschließlich einer virtuellen Uhr. fn schlafen(&mut self, host: &mut dyn Host, sekunden: f64) { - let start = host.jetzt_ms(); - let dauer_ms = (sekunden.max(0.0) * 1000.0) as u64; - // ponytail: zusätzlich eine echte Zeitschranke, damit ein Host mit - // stehender Uhr (Testhost) nicht ewig wartet. Ceiling: bei einer - // virtuellen Uhr endet `SLEEP` nach der realen Dauer, wenn die - // virtuelle nie vorrückt. - let real_start = std::time::Instant::now(); + let deadline = + (sekunden > 0.0).then(|| host.jetzt_ms().saturating_add((sekunden * 1000.0) as u64)); loop { self.tick(host); - if self.zustellen(host) { + if self.zustellen(host, Zustellpunkt::Kooperativ) || self.rt.ende || self.rt.abbruch { return; } - if self.rt.ende || self.rt.abbruch { + if let Some(deadline) = deadline { + if host.jetzt_ms() >= deadline { + return; + } + } else if self.rt.naechste_taste(host, false).is_some() { return; } - if sekunden <= 0.0 { - // Ohne Argument: bis zum Tastendruck. - if self.rt.naechste_taste(host, false).is_some() { - return; - } - } else { - let vergangen = host.jetzt_ms().saturating_sub(start); - if vergangen >= dauer_ms || real_start.elapsed().as_millis() as u64 >= dauer_ms { - return; - } - } - std::thread::sleep(std::time::Duration::from_millis(5)); + self.warten(host, deadline); } } @@ -686,6 +779,8 @@ impl Vm { waiting_form: None, pending_show: None, form_event: None, + handler_start: true, + eingabe: None, }); } @@ -699,7 +794,11 @@ impl Vm { fn pop_frame(&mut self) { let f = self.frames.pop().expect("Frame-Unterlauf"); - self.locals.truncate(f.locals_base); + if let Some(q) = f.trap { + self.rt.traps.handler_beendet(q); + } else { + self.locals.truncate(f.locals_base); + } self.stack.truncate(f.stack_base); } @@ -806,14 +905,14 @@ impl Vm { pub fn run_visible_forms(&mut self, host: &mut dyn Host) -> RunEvent { while self.forms.has_visible_forms() { self.tick(host); - let dispatched = self.forms_zustellen(host); + let dispatched = self.zustellen(host, Zustellpunkt::Kooperativ); if dispatched { match self.run(host) { RunEvent::Ended => {} event => return event, } } - if self.rt.ende { + if !dispatched && self.rt.ende { return RunEvent::Ended; } if self.rt.abbruch { @@ -821,7 +920,9 @@ impl Vm { line: self.current_line(), }; } - std::thread::sleep(std::time::Duration::from_millis(1)); + if !dispatched && self.forms.has_visible_forms() { + self.warten(host, None); + } } RunEvent::Ended } @@ -833,11 +934,18 @@ impl Vm { self.frames.last_mut().unwrap().waiting_form = None; } else { self.tick(host); - self.forms_zustellen(host); - if self.rt.ende { + let dispatched = self.zustellen(host, Zustellpunkt::Kooperativ); + if !dispatched && self.rt.ende { return RunEvent::Ended; } - std::thread::yield_now(); + if self.rt.abbruch { + return RunEvent::Interrupted { + line: self.current_line(), + }; + } + if !dispatched { + self.warten(host, None); + } continue; } } @@ -898,6 +1006,7 @@ impl Vm { (f.stack_base, f.last_stmt_pc, f.proc) }; self.stack.truncate(stack_base); + self.frames.last_mut().unwrap().eingabe = None; self.err = code; match handler { Handler::Goto(pc) => { @@ -1131,27 +1240,11 @@ impl Vm { self.tick(host); self.rt.screen.veraenderung_quittieren(); } - if self.forms_zustellen(host) { + let erster_eintritt = + std::mem::take(&mut self.frames.last_mut().unwrap().handler_start); + if !erster_eintritt && self.zustellen(host, Zustellpunkt::Anweisung) { return Ok(Flow::Normal); } - // Ereigniszustellung (design.md, D1): nur wenn überhaupt - // ein Trap definiert ist — sonst kostet die Grenze nichts. - if self.rt.traps.aktiv() { - // ponytail: die Uhr nur alle 64 Anweisungen lesen, die - // Tastenschlange dagegen jedes Mal. Ceiling: die - // Auflösung eines Zeit-Traps ist damit 64 Anweisungen; - // reicht das nicht, wird daraus ein eigener Zähler je - // Trap. - if self.tick_zaehler.is_multiple_of(64) { - let jetzt = host.jetzt_ms(); - self.rt.traps.zeit_pruefen(jetzt); - } - self.rt.tasten_traps_pruefen(); - if let Some((q, ziel)) = self.rt.traps.naechstes() { - self.trap_frame_aufsetzen(q, ziel); - return Ok(Flow::Normal); - } - } if self.flags != 0 { if self.flags & F_STEP != 0 { return Ok(Flow::Event(RunEvent::Stepped { line })); @@ -1193,7 +1286,7 @@ impl Vm { 1 => tb_runtime::traps::Zustand::Aus, _ => tb_runtime::traps::Zustand::Gestoppt, }; - let jetzt = host.jetzt_ms(); + let jetzt = if art == ART_TIMER { host.jetzt_ms() } else { 0 }; self.rt.traps.setzen(q, z, jetzt); Ok(Flow::Normal) } @@ -1203,8 +1296,7 @@ impl Vm { // lässt den Wert unberührt. self.stack.push(Value::Int(0)); self.tick(host); - self.forms_zustellen(host); - self.zustellen(host); + self.zustellen(host, Zustellpunkt::Kooperativ); Ok(Flow::Normal) } I::Sleep(mit_arg) => { @@ -1258,7 +1350,7 @@ impl Vm { }; if !self.forms.is_loaded_at(object, index) { self.forms.ensure_loaded_at(object, index)?; - if index.is_none() && self.dispatch_next_form_event() { + if index.is_none() && self.dispatch_next_form_event(true) { let caller = self.frames.len() - 2; self.frames[caller].pc = self.frames[caller].pc.saturating_sub(1); return Ok(Flow::Normal); @@ -1280,7 +1372,7 @@ impl Vm { if index.is_none() { self.push(value.clone()); } - if index.is_none() && self.dispatch_next_form_event() { + if index.is_none() && self.dispatch_next_form_event(true) { let caller = self.frames.len() - 2; self.frames[caller].pc = self.frames[caller].pc.saturating_sub(1); return Ok(Flow::Normal); @@ -1304,7 +1396,7 @@ impl Vm { }; self.forms.ensure_loaded_at(object, index)?; self.push(Value::Obj(object, index)); - self.dispatch_next_form_event(); + self.dispatch_next_form_event(true); Ok(Flow::Normal) } I::LoadDynamicObjectProperty(property) => { @@ -1434,7 +1526,7 @@ impl Vm { && !self.forms.is_loaded(object) { self.forms.ensure_loaded(object)?; - if self.dispatch_next_form_event() { + if self.dispatch_next_form_event(true) { let caller = self.frames.len() - 2; self.frames[caller].pending_show = Some(object); self.frames[caller].pc = self.frames[caller].pc.saturating_sub(1); @@ -1493,7 +1585,7 @@ impl Vm { self.forms.object_method_at(object, index, name, args)?; } } - self.dispatch_next_form_event(); + self.dispatch_next_form_event(true); Ok(Flow::Normal) } I::ObjectLoad(object, unload, has_index) => { @@ -1508,7 +1600,7 @@ impl Vm { self.request_unload(object)?; } else { self.forms.ensure_loaded(object)?; - self.dispatch_next_form_event(); + self.dispatch_next_form_event(true); } Ok(Flow::Normal) } @@ -2058,6 +2150,8 @@ impl Vm { self.pop_frame(); } let f = self.frames.last_mut().unwrap(); + f.eingabe = None; + self.stack.truncate(f.stack_base); f.pc = t as usize; Ok(Flow::Normal) } @@ -2122,8 +2216,10 @@ impl Vm { if let Some(FormEventReturn::Unload(object)) = event { self.forms.unload_with(object, |c| *c = cancel)?; } - if event.is_some() { - self.dispatch_next_form_event(); + if matches!(event, Some(FormEventReturn::Normal)) + && !self.frames.iter().any(|f| f.form_event.is_some()) + { + self.dispatch_next_form_event(false); } Ok(Flow::Normal) } @@ -2134,6 +2230,38 @@ impl Vm { Ok(Flow::Normal) } I::CallBuiltin(id, argc) => { + if id == ids::RANDOMIZE && argc == 0 { + if self.frames.last().unwrap().eingabe.is_none() { + self.rt + .screen + .print("Random Number Seed (-32768 to 32767)? "); + } + let caller = self.frames.len() - 1; + if let Some(text) = self.konsoleneingabe(host, None)? { + self.rt.saat_setzen(tb_runtime::format::val(&text)); + } else { + self.frames[caller].pc = pc; + } + return Ok(Flow::Normal); + } + if id == ids::INPUT_S && argc == 1 { + let n = match self.stack.last() { + Some(Value::Int(n)) => i32::from(*n), + Some(Value::Lng(n)) => *n, + _ => return Err(RuntimeError::TYPE_MISMATCH), + }; + if n < 0 { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + let caller = self.frames.len() - 1; + if let Some(text) = self.konsoleneingabe(host, Some(n as usize))? { + self.pop()?; + self.push(Value::Str(Rc::from(text))); + } else { + self.frames[caller].pc = pc; + } + return Ok(Flow::Normal); + } let mut args = Vec::with_capacity(argc as usize); for _ in 0..argc { args.push(self.pop()?); @@ -2223,7 +2351,10 @@ impl Vm { Ok(Flow::Normal) } I::Input(argc, line_mode, prompt, question) => { - self.do_input(argc, line_mode, prompt, question, host)?; + let caller = self.frames.len() - 1; + if !self.do_input(argc, line_mode, prompt, question, host)? { + self.frames[caller].pc = pc; + } Ok(Flow::Normal) } I::InputFile(argc, line_mode) => { @@ -2272,6 +2403,64 @@ impl Vm { Ok(Flow::Normal) } + /// Fortsetzbarer Leser: Text und Restlänge gehören zum unterbrochenen + /// Frame; Referenzen/Argumente bleiben bis zum Abschluss auf dessen Stack. + fn konsoleneingabe( + &mut self, + host: &mut dyn Host, + rest: Option, + ) -> Result, RuntimeError> { + let caller = self.frames.len() - 1; + self.frames[caller] + .eingabe + .get_or_insert_with(|| KonsolenEingabe { + text: String::new(), + rest, + }); + loop { + if self.frames[caller].eingabe.as_ref().unwrap().rest == Some(0) { + return Ok(Some(self.frames[caller].eingabe.take().unwrap().text)); + } + self.tick(host); + if self.zustellen(host, Zustellpunkt::Konsole) { + return Ok(None); + } + if let Some(taste) = self.rt.gepufferte_taste() { + let eingabe = self.frames[caller].eingabe.as_mut().unwrap(); + if let Some(rest) = &mut eingabe.rest { + eingabe.text.push_str(&taste); + *rest -= 1; + } else { + match taste.as_str() { + tb_runtime::host::taste::ENTER => { + self.rt.screen.print("\n"); + return Ok(Some(self.frames[caller].eingabe.take().unwrap().text)); + } + tb_runtime::host::taste::BACKSPACE => { + if eingabe.text.pop().is_some() { + self.rt.screen.rueckschritt(); + } + } + _ => { + if taste.chars().count() == 1 + && taste.chars().next().is_some_and(|c| c as u32 >= 0x20) + { + eingabe.text.push_str(&taste); + self.rt.screen.print(&taste); + } + } + } + } + continue; + } + if self.rt.ende || self.rt.abbruch { + self.frames[caller].eingabe = None; + return Err(RuntimeError(62)); + } + self.warten(host, None); + } + } + fn do_input( &mut self, argc: u8, @@ -2279,38 +2468,41 @@ impl Vm { prompt: u16, question: bool, host: &mut dyn Host, - ) -> Result<(), RuntimeError> { - // Referenzen in Reihenfolge einsammeln. - let mut refs = Vec::with_capacity(argc as usize); - for _ in 0..argc { - match self.pop()? { - Value::Ref(r) => refs.push(r), - _ => return Err(RuntimeError::TYPE_MISMATCH), - } - } - refs.reverse(); - + ) -> Result { + let start = self + .stack + .len() + .checked_sub(argc as usize) + .ok_or(RuntimeError(51))?; + let refs = self.stack[start..] + .iter() + .map(|v| match v { + Value::Ref(r) => Ok(r.clone()), + _ => Err(RuntimeError::TYPE_MISMATCH), + }) + .collect::, _>>()?; loop { - if prompt != 0xFFFF { - let p = self.module.strings[prompt as usize].clone(); - self.rt.screen.print(&p); + if self.frames.last().unwrap().eingabe.is_none() { + if prompt != 0xFFFF { + self.rt.screen.print(&self.module.strings[prompt as usize]); + } + if question { + self.rt.screen.print("? "); + } } - if question { - self.rt.screen.print("? "); - } - let Some(mut line) = self.rt.read_line(host) else { - return Err(RuntimeError(62)); // Input past end of file + let Some(line) = self.konsoleneingabe(host, None)? else { + return Ok(false); }; if line_mode { let r = refs.first().ok_or(RuntimeError(51))?; - self.write_ref(r, Value::Str(Rc::from(line.as_str())))?; - return Ok(()); + self.write_ref(r, Value::Str(Rc::from(line)))?; + self.stack.truncate(start); + return Ok(true); } // Felder trennen (Quotes respektieren). let fields = split_input_fields(&line); if fields.len() != refs.len() { self.rt.screen.print("Redo from start\n"); - line.clear(); continue; } // Konvertieren nach aktuellem Zieltyp; Fehler → Redo. @@ -2333,7 +2525,8 @@ impl Vm { for (r, v) in refs.iter().zip(vals) { self.write_ref(r, v)?; } - return Ok(()); + self.stack.truncate(self.stack.len() - argc as usize); + return Ok(true); } } diff --git a/crates/tb-vm/tests/events.rs b/crates/tb-vm/tests/events.rs new file mode 100644 index 0000000..ebf6bb3 --- /dev/null +++ b/crates/tb-vm/tests/events.rs @@ -0,0 +1,584 @@ +//! Regressionen zum Change ereigniszustellung-und-hostgrenzen. +use tb_frontend::forms::{FormCatalog, ObjectClass}; +use tb_runtime::host::{CaptureHost, Ereignis, Host, MausArt, MausEreignis}; +use tb_runtime::screen::TextScreen; +use tb_runtime::value::Value; +use tb_vm::interp::{RunEvent, Vm}; + +#[path = "../../../tests/support/prozessfrist.rs"] +mod prozessfrist; + +fn vm(src: &str) -> Vm { + let mut catalog = FormCatalog::default(); + catalog.add("Form1", ObjectClass::Form, None, false); + catalog.add("Text1", ObjectClass::TextBox, Some("Form1"), false); + catalog.add("Text2", ObjectClass::TextBox, Some("Form1"), false); + catalog.add("Timer1", ObjectClass::Timer, Some("Form1"), false); + catalog.add("Menu1", ObjectClass::Menu, Some("Form1"), false); + catalog.add("Item1", ObjectClass::Menu, Some("Menu1"), false); + Vm::new( + tb_vm::compile_source_with_forms("FORM1", src, &catalog) + .unwrap_or_else(|d| panic!("{d:?}")), + ) +} + +fn stop(vm: &mut Vm, host: &mut dyn Host) { + assert!(matches!(vm.run(host), RunEvent::Stopped { .. })); +} + +fn string(vm: &Vm, name: &str) -> String { + match vm.inspect(name) { + Some(Value::Str(s)) => s.to_string(), + other => panic!("{other:?}"), + } +} + +fn integer(vm: &Vm, name: &str) -> i16 { + match vm.inspect(name) { + Some(Value::Int(n)) => n, + other => panic!("{other:?}"), + } +} + +fn key(s: &str) -> Ereignis { + Ereignis::Taste(s.into(), 0) +} +fn mouse() -> Ereignis { + Ereignis::Maus(MausEreignis { + art: MausArt::Druck, + taste: 1, + shift: 0, + zeile: 1, + spalte: 1, + }) +} + +#[test] +fn beide_signale_werden_zugestellt_und_beginnen_in_quellreihenfolge() { + let mut vm = vm("ON SIGNAL(1) GOSUB IntHandler\nON SIGNAL(2) GOSUB TermHandler\nSIGNAL(1) ON\nSIGNAL(2) ON\nSTOP\nDOEVENTS\nEND\nIntHandler:\ns$ = s$ + \"1\"\nRETURN\nTermHandler:\ns$ = s$ + \"2\"\nRETURN"); + let mut host = CaptureHost::default(); + stop(&mut vm, &mut host); + host.ereignis(Ereignis::Signal(2)); + host.ereignis(Ereignis::Signal(1)); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(string(&vm, "s"), "12"); + for n in [0, 3] { + assert!( + tb_vm::compile_source("TEST", &format!("ON SIGNAL({n}) GOSUB H\nEND\nH:\nRETURN")) + .is_err() + ); + let mut bad = vm_for_source(&format!("n% = {n}\nON SIGNAL(n%) GOSUB H\nEND\nH:\nRETURN")); + assert!(matches!( + bad.run(&mut host), + RunEvent::Error { code: 5, .. } + )); + } +} + +fn vm_for_source(src: &str) -> Vm { + Vm::new(tb_vm::compile_source("TEST", src).unwrap()) +} + +#[test] +fn key_on_off_stop_gelten_fuer_alle_konsoleneingaben() { + for state in ["ON", "OFF", "STOP"] { + for input in ["s$ = INKEY$", "INPUT s$", "LINE INPUT s$", "s$ = INPUT$(1)"] { + let mut vm = vm_for_source(&format!("ON KEY(15) GOSUB H\nKEY 15, CHR$(0) + CHR$(30)\nKEY(15) {state}\nSTOP\n{input}\nKEY(15) ON\nDOEVENTS\nEND\nH:\nn% = n% + 1\nRETURN")); + let mut host = CaptureHost::default(); + stop(&mut vm, &mut host); + host.ereignis(key("a")); + if state == "ON" { + host.ereignis(key("b")); + } + host.ereignis(key("\r")); + assert_eq!(vm.run(&mut host), RunEvent::Ended, "{state}: {input}"); + assert_eq!( + string(&vm, "s"), + if state == "ON" { "b" } else { "a" }, + "{state}: {input}" + ); + assert_eq!( + integer(&vm, "n"), + if state == "OFF" { 0 } else { 1 }, + "{state}: {input}" + ); + } + } +} + +#[test] +fn taste_maus_und_trap_behalten_die_annahmereihenfolge() { + for (events, expected) in [ + (vec![key("a"), mouse()], "aM"), + (vec![mouse(), key("a")], "Ma"), + ( + vec![key("a"), mouse(), key("b"), key("t"), mouse()], + "aMbTM", + ), + ] { + let mut vm = vm("DIM SHARED s$\nON KEY(15) GOSUB H\nKEY 15, CHR$(0) + CHR$(20)\nKEY(15) ON\nForm1.Show\nText1.SetFocus\nSTOP\nn% = DOEVENTS + LEN(s$)\nDOEVENTS\nDOEVENTS\nDOEVENTS\nDOEVENTS\nEND\nH:\ns$ = s$ + \"T\"\nRETURN\nSUB Text1_KeyPress(KeyAscii AS INTEGER)\nSHARED s$\ns$ = s$ + CHR$(KeyAscii)\nEND SUB\nSUB Form_MouseDown(Button AS INTEGER, Shift AS INTEGER, X AS SINGLE, Y AS SINGLE)\nSHARED s$\ns$ = s$ + \"M\"\nEND SUB"); + let mut host = CaptureHost::default(); + stop(&mut vm, &mut host); + for event in events { + host.ereignis(event); + } + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(string(&vm, "s"), expected); + assert!( + matches!(vm.inspect("n"), Some(Value::Int(n)) if n > 0), + "DOEVENTS muss vor der Auswertung von LEN zustellen" + ); + } +} + +#[test] +fn fokus_handler_laufen_geordnet_bis_zur_rueckkehr() { + let mut vm = vm("DIM SHARED s$\nText1.TabIndex = 0\nText2.TabIndex = 1\nForm1.Show\nText1.SetFocus\nSTOP\nDOEVENTS\nEND\nSUB Text1_LostFocus()\nSHARED s$\ns$ = s$ + \"L\"\ns$ = s$ + \"l\"\nEND SUB\nSUB Text2_GotFocus()\nSHARED s$\ns$ = s$ + \"G\"\ns$ = s$ + \"g\"\nEND SUB"); + let mut host = CaptureHost::default(); + stop(&mut vm, &mut host); + host.ereignis(key("\t")); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(string(&vm, "s"), "LlGg"); +} + +#[test] +fn doevents_verhindert_rekursion_derselben_forms_prozedur() { + let mut vm = vm("DIM SHARED s$\nForm1.Show\nText1.SetFocus\nSTOP\nDOEVENTS\nEND\nSUB Text1_KeyPress(KeyAscii AS INTEGER)\nSHARED s$\ns$ = s$ + CHR$(KeyAscii)\nDOEVENTS\ns$ = s$ + \"r\"\nEND SUB"); + let mut host = CaptureHost::default(); + stop(&mut vm, &mut host); + host.ereignis(key("a")); + host.ereignis(key("b")); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(string(&vm, "s"), "arbr"); +} + +#[test] +fn offenes_menu_sperrt_traps_und_wartende_forms_timer_an_allen_zustellpunkten() { + let mut vm = vm("DIM SHARED n%\nON UEVENT GOSUB H\nUEVENT ON\nMenu1.Caption = \"&Datei\"\nItem1.Caption = \"&Ende\"\nForm1.Show\nSTOP\nCALL SetUEvent\nn% = n% + 1\nDOEVENTS\nSLEEP .001\nLINE INPUT s$\nSTOP\nDOEVENTS\nEND\nH:\nn% = n% + 10\nRETURN\nSUB Timer1_Timer()\nSHARED n%\nn% = n% + 100\nEND SUB"); + let mut host = CaptureHost::default(); + stop(&mut vm, &mut host); + vm.forms.handle_key("d", 4); + assert!(vm.forms.menu_is_open()); + vm.queue_form_event(tb_ui::forms::FormEvent { + object: 3, + array_index: None, + name: "TIMER".into(), + args: vec![], + }); + // Erst die blockierende Konsole holt Enter ab, damit das Menü offen bleibt. + struct InputHost(CaptureHost); + impl Host for InputHost { + fn present(&mut self, s: &TextScreen) { + self.0.present(s); + } + fn next_event(&mut self, block: bool) -> Option { + block.then(|| key("\r")) + } + fn jetzt_ms(&mut self) -> u64 { + self.0.jetzt_ms() + } + fn warten(&mut self, deadline: Option) -> Option { + if deadline.is_none() { + self.next_event(true) + } else { + self.0.warten(deadline) + } + } + } + stop(&mut vm, &mut InputHost(CaptureHost::default())); + assert_eq!(integer(&vm, "n"), 1); + assert!(vm.forms.menu_is_open()); + host.ereignis(key("\u{1b}")); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(integer(&vm, "n"), 111); +} + +#[test] +fn ohne_zeitbedarf_keine_uhrabfrage_und_keine_mausansammlung() { + struct OhneUhr(CaptureHost); + impl Host for OhneUhr { + fn present(&mut self, s: &TextScreen) { + self.0.present(s); + } + fn next_event(&mut self, b: bool) -> Option { + self.0.next_event(b) + } + fn jetzt_ms(&mut self) -> u64 { + panic!("unerwartete Uhrabfrage") + } + fn warten(&mut self, _: Option) -> Option { + panic!("unerwartetes Warten") + } + } + for src in ["a% = 1\na% = 2\nEND", "Form1.Show\na% = 1\nDOEVENTS\nEND"] { + let mut vm = vm(src); + let mut host = OhneUhr(CaptureHost::default()); + for _ in 0..500 { + host.0.ereignis(mouse()); + } + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert!(vm.rt.eingaben.is_empty()); + } +} + +#[test] +fn sleep_folgt_nur_der_hostfrist_und_forms_timer_beendet_es() { + let _frist = prozessfrist::Prozessfrist::neu("Host-SLEEP", std::time::Duration::from_secs(5)); + struct WarteHost { + now: u64, + deadlines: Vec>, + timer: bool, + } + impl Host for WarteHost { + fn present(&mut self, _: &TextScreen) {} + fn next_event(&mut self, _: bool) -> Option { + None + } + fn jetzt_ms(&mut self) -> u64 { + self.now + } + fn warten(&mut self, deadline: Option) -> Option { + self.deadlines.push(deadline); + if self.deadlines.len() == 1 && !self.timer { + // Reale Zeit verstreicht bei eingefrorener Hostuhr. Erst die + // folgende Host-Wartephase darf die Deadline erreichen. + std::thread::sleep(std::time::Duration::from_millis(10)); + return Some(Ereignis::Groesse { cols: 80, rows: 25 }); + } + self.now = deadline.expect("Deadline fehlt"); + None + } + } + let mut host = WarteHost { + now: 0, + deadlines: vec![], + timer: false, + }; + let mut plain = vm_for_source("SLEEP .001\nEND"); + assert_eq!(plain.run(&mut host), RunEvent::Ended); + assert_eq!(host.deadlines, [Some(1), Some(1)]); + let mut vm = vm("DIM SHARED n%\nForm1.Show\nTimer1.Interval = 100\nTimer1.Enabled = -1\nSLEEP 60\nEND\nSUB Timer1_Timer()\nSHARED n%\nn% = n% + 1\nEND SUB"); + let mut host = WarteHost { + now: 0, + deadlines: vec![], + timer: true, + }; + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(host.deadlines, [Some(100)]); + assert_eq!(integer(&vm, "n"), 1); +} + +#[test] +fn dialog_erhaelt_stop_markierung_unverbrauchter_tasten() { + let mut vm = vm_for_source("ON KEY(15) GOSUB H\nKEY 15, CHR$(0) + CHR$(30)\nKEY(15) STOP\nSTOP\nx% = MSGBOX(\"Weiter\")\nKEY(15) ON\nDOEVENTS\ns$ = INKEY$\nEND\nH:\nn% = n% + 1\nRETURN"); + let mut host = CaptureHost::default(); + stop(&mut vm, &mut host); + host.ereignis(key("\r")); + host.ereignis(key("a")); + vm.rt.pump(&mut host, false); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(string(&vm, "s"), "a"); + assert_eq!(integer(&vm, "n"), 1); +} + +#[test] +fn unload_aus_unload_handler_ruft_dieselbe_prozedur_nicht_rekursiv_auf() { + let mut vm = vm("DIM SHARED s$\nForm1.Show\nUNLOAD Form1\nEND\nSUB Form_Unload(Cancel AS INTEGER)\nSHARED s$\ns$ = s$ + \"u\"\nUNLOAD Form1\ns$ = s$ + \"r\"\nEND SUB"); + assert_eq!(vm.run(&mut CaptureHost::default()), RunEvent::Ended); + assert_eq!(string(&vm, "s"), "ur"); + assert!(!vm.forms.is_loaded(0)); +} + +// Nachverifikation V1–V5: unveränderte Sollwerte aus den Review-Proben. +#[test] +fn review_signal_waehrend_blockierender_eingabe() { + struct Input { + reads: usize, + } + impl Host for Input { + fn present(&mut self, _: &TextScreen) {} + fn next_event(&mut self, block: bool) -> Option { + if !block { + return None; + } + self.reads += 1; + Some(if self.reads == 1 { + Ereignis::Signal(2) + } else { + Ereignis::Ende + }) + } + fn jetzt_ms(&mut self) -> u64 { + 0 + } + fn warten(&mut self, _: Option) -> Option { + self.next_event(true) + } + } + let mut results = vec![]; + for statement in ["INPUT s$", "LINE INPUT s$", "s$ = INPUT$(1)"] { + let src = + format!("ON SIGNAL(2) GOSUB H\nSIGNAL(2) ON\n{statement}\nEND\nH:\nPRINT \"H\"\nEND"); + let mut vm = Vm::new(tb_vm::compile_source("TEST", &src).unwrap()); + let result = vm.run(&mut Input { reads: 0 }); + println!( + "{statement}: {result:?}; Ausgabe={:?}", + tb_runtime::snapshot::text(&vm.rt.screen) + ); + results.push(result); + } + assert!(results.iter().all(|result| *result == RunEvent::Ended)); +} + +#[test] +fn review_objektargument_darf_gotfocus_nicht_vorziehen() { + for action in [ + "CALL Mark(Text1)", + "n% = Text1.TabIndex\nCALL Mark(Text1)", + "Text1.Text = \"neu\"\nCALL Mark(Text1)", + "Text1.Refresh\nCALL Mark(Text1)", + "LOAD Form1\nCALL Mark(Text1)", + ] { + let mut vm = vm(&format!("DIM SHARED s$\nText1.TabIndex = 0\nText2.TabIndex = 1\nForm1.Show\nText1.SetFocus\nSTOP\nDOEVENTS\nEND\nSUB Text1_LostFocus()\nSHARED s$\n{action}\ns$ = s$ + \"l\"\nEND SUB\nSUB Mark(c AS CONTROL)\nSHARED s$\ns$ = s$ + \"L\"\nEND SUB\nSUB Text2_GotFocus()\nSHARED s$\ns$ = s$ + \"G\"\ns$ = s$ + \"g\"\nEND SUB")); + let mut host = CaptureHost::default(); + stop(&mut vm, &mut host); + host.ereignis(key("\t")); + assert_eq!(vm.run(&mut host), RunEvent::Ended, "{action}"); + assert_eq!(string(&vm, "s"), "LlGg", "{action}"); + } +} + +#[test] +fn review_gleichzeitig_key_und_uevent_behalten_prioritaet() { + let mut vm = vm("ON KEY(15) GOSUB K\nKEY 15, CHR$(0) + CHR$(20)\nKEY(15) ON\nON UEVENT GOSUB U\nUEVENT ON\nSTOP\nDOEVENTS\nEND\nK:\ns$ = s$ + \"K\"\nRETURN\nU:\ns$ = s$ + \"U\"\nRETURN"); + let mut host = CaptureHost::default(); + stop(&mut vm, &mut host); + host.ereignis(key("t")); + vm.rt.pump(&mut host, false); + vm.rt.traps.melden(tb_runtime::traps::Quelle::UEvent); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(string(&vm, "s"), "KU"); +} + +#[test] +fn review_modale_eingabe_vor_eof_wird_zugestellt() { + let mut vm = vm("DIM SHARED s$\nForm1.Show 1\nEND\nSUB Form_MouseDown(Button AS INTEGER, Shift AS INTEGER, X AS SINGLE, Y AS SINGLE)\nSHARED s$\ns$ = \"M\"\nUNLOAD Form1\nEND SUB"); + let mut host = CaptureHost::default(); + host.ereignis_nach(2, mouse()); + host.ereignis_nach(2, Ereignis::Ende); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(string(&vm, "s"), "M"); +} + +#[test] +fn eof_leert_angenommene_eingaben_bei_modalen_und_modellosen_forms() { + for modal in [false, true] { + let show = if modal { "Form1.Show 1" } else { "Form1.Show" }; + let mut vm = vm(&format!("DIM SHARED s$\n{show}\nEND\nSUB Form_MouseDown(Button AS INTEGER, Shift AS INTEGER, X AS SINGLE, Y AS SINGLE)\nSHARED s$\ns$ = s$ + \"M\"\nEND SUB")); + let mut host = CaptureHost::default(); + if !modal { + assert_eq!(vm.run(&mut host), RunEvent::Ended); + } + host.ereignis_nach(2, mouse()); + host.ereignis_nach(2, mouse()); + host.ereignis_nach(2, Ereignis::Ende); + let result = if modal { + vm.run(&mut host) + } else { + vm.run_visible_forms(&mut host) + }; + assert_eq!(result, RunEvent::Ended, "modal={modal}"); + assert_eq!(string(&vm, "s"), "MM", "modal={modal}"); + } +} + +#[test] +fn review_menu_schliessen_stellt_pending_trap_vor_erneutem_warten_zu() { + struct MenuHost { + time: u64, + waits: usize, + } + impl Host for MenuHost { + fn present(&mut self, _: &TextScreen) {} + fn next_event(&mut self, _: bool) -> Option { + None + } + fn jetzt_ms(&mut self) -> u64 { + self.time + } + fn warten(&mut self, deadline: Option) -> Option { + self.waits += 1; + if self.waits == 1 { + return Some(key("\u{1b}")); + } + self.time = deadline.unwrap(); + None + } + } + let mut vm = vm("ON UEVENT GOSUB H\nUEVENT ON\nMenu1.Caption = \"&Datei\"\nItem1.Caption = \"&Ende\"\nForm1.Show\nSTOP\nSLEEP 60\nEND\nH:\ns$ = \"U\"\nRETURN"); + let mut host = CaptureHost::default(); + stop(&mut vm, &mut host); + vm.forms.handle_key("d", 4); + vm.rt.traps.melden(tb_runtime::traps::Quelle::UEvent); + let mut host = MenuHost { time: 0, waits: 0 }; + assert_eq!(vm.run(&mut host), RunEvent::Ended); + println!( + "Menü/SLEEP: Zeit={}, Warteaufrufe={}, Handler={}", + host.time, + host.waits, + string(&vm, "s") + ); + assert_eq!(host.waits, 1); +} + +#[test] +fn review_maus_vor_getrappter_taste() { + let mut vm = vm("DIM SHARED s$\nON KEY(15) GOSUB H\nKEY 15, CHR$(0) + CHR$(20)\nKEY(15) ON\nForm1.Show\nSTOP\nDOEVENTS\nDOEVENTS\nEND\nH:\ns$ = s$ + \"K\"\nRETURN\nSUB Form_MouseDown(Button AS INTEGER, Shift AS INTEGER, X AS SINGLE, Y AS SINGLE)\nSHARED s$\ns$ = s$ + \"M\"\nEND SUB"); + let mut host = CaptureHost::default(); + stop(&mut vm, &mut host); + host.ereignis(mouse()); + host.ereignis(key("t")); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(string(&vm, "s"), "MK"); +} + +/// Ereignisse treffen erst beim Warten ein, auch mitten in einer Eingabe. +struct EingabeHost { + events: std::collections::VecDeque, + now: u64, + deadlines: Vec>, +} +impl Host for EingabeHost { + fn present(&mut self, _: &TextScreen) {} + fn next_event(&mut self, _: bool) -> Option { + None + } + fn jetzt_ms(&mut self) -> u64 { + self.now + } + fn warten(&mut self, deadline: Option) -> Option { + self.deadlines.push(deadline); + if let Some(deadline) = deadline { + self.now = deadline; + None + } else { + Some(self.events.pop_front().unwrap_or(Ereignis::Ende)) + } + } +} +fn eingabe_host(events: Vec) -> EingabeHost { + EingabeHost { + events: events.into(), + now: 0, + deadlines: vec![], + } +} + +#[test] +fn input_fortsetzung_erhaelt_teiltext_ausdruck_und_byref_ziel() { + for (statement, expected, enter) in [ + ("INPUT s$", "ab", true), + ("LINE INPUT s$", "ab", true), + ("s$ = \"x\" + INPUT$(2) + \"y\"", "xaby", false), + ] { + let src = format!("ON SIGNAL(2) GOSUB H\nSIGNAL(2) ON\nCALL Lesen(s$)\nEND\nH:\nn% = n% + 1\nRETURN\nSUB Lesen(s$)\n{statement}\nEND SUB"); + let mut vm = vm_for_source(&src); + let mut events = vec![key("a"), Ereignis::Signal(2), key("b")]; + if enter { + events.push(key("\r")); + } + let mut host = eingabe_host(events); + assert_eq!(vm.run(&mut host), RunEvent::Ended, "{statement}"); + assert_eq!(string(&vm, "s"), expected); + assert_eq!(integer(&vm, "n"), 1); + assert_eq!(host.deadlines.len(), if enter { 4 } else { 3 }); + } +} + +#[test] +fn handler_eingabe_und_stop_bewahren_die_aeussere_eingabe() { + let mut vm = vm_for_source( + "ON SIGNAL(2) GOSUB H\nSIGNAL(2) ON\nINPUT s$\nEND\nH:\nSTOP\nINPUT t$\nRETURN", + ); + let mut host = eingabe_host(vec![ + key("a"), + Ereignis::Signal(2), + key("h"), + key("\r"), + key("b"), + key("\r"), + ]); + stop(&mut vm, &mut host); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(string(&vm, "s"), "ab"); + assert_eq!(string(&vm, "t"), "h"); +} + +#[test] +fn timer_waehrend_input_wird_vor_erneutem_warten_zugestellt() { + let mut vm = vm_for_source( + "ON TIMER(1) GOSUB H\nTIMER ON\nINPUT s$\nEND\nH:\nn% = n% + 1\nTIMER OFF\nRETURN", + ); + let mut host = eingabe_host(vec![key("a"), key("\r")]); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(string(&vm, "s"), "a"); + assert_eq!(integer(&vm, "n"), 1); + assert_eq!(host.deadlines, [Some(1000), None, None]); +} + +#[test] +fn input_redo_verliert_weder_referenzen_noch_teiltext_beim_trap() { + let mut vm = + vm_for_source("ON SIGNAL(2) GOSUB H\nSIGNAL(2) ON\nINPUT \"Zahl:\"; n%\nEND\nH:\nRETURN"); + let mut host = eingabe_host(vec![ + key("x"), + key("\r"), + key("4"), + Ereignis::Signal(2), + key("2"), + key("\r"), + ]); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(integer(&vm, "n"), 42); + assert_eq!( + tb_runtime::snapshot::text(&vm.rt.screen) + .matches("Zahl:") + .count(), + 2 + ); +} + +#[test] +fn lokaler_error_handler_nach_trap_verwirft_abgebrochene_teileingabe() { + let mut vm = vm_for_source("ON SIGNAL(2) GOSUB H\nSIGNAL(2) ON\nCALL Lesen(s$)\nEND\nH:\nERROR 5\nRETURN\nSUB Lesen(s$)\nON LOCAL ERROR GOTO Fehler\nINPUT s$\nEXIT SUB\nFehler:\nINPUT s$\nRESUME NEXT\nEND SUB"); + let mut host = eingabe_host(vec![key("a"), Ereignis::Signal(2), key("b"), key("\r")]); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(string(&vm, "s"), "b"); +} + +#[test] +fn randomize_eingabe_teilt_den_fortsetzbaren_konsolenpfad() { + let mut vm = vm_for_source( + "ON SIGNAL(2) GOSUB H\nSIGNAL(2) ON\nRANDOMIZE\nEND\nH:\nn% = n% + 1\nRETURN", + ); + let mut host = eingabe_host(vec![key("4"), Ereignis::Signal(2), key("2"), key("\r")]); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(integer(&vm, "n"), 1); + assert_eq!( + tb_runtime::snapshot::text(&vm.rt.screen) + .matches("Random Number Seed") + .count(), + 1 + ); +} + +#[test] +fn return_label_verwirft_abgebrochene_eingabe_und_ausdruck() { + for statement in ["INPUT s$", "s$ = \"alt\" + INPUT$(2)"] { + let mut vm = vm_for_source(&format!("ON SIGNAL(2) GOSUB H\nSIGNAL(2) ON\n{statement}\nEND\nWeiter:\nINPUT t$\nEND\nH:\nRETURN Weiter")); + let mut host = eingabe_host(vec![key("a"), Ereignis::Signal(2), key("b"), key("\r")]); + assert_eq!(vm.run(&mut host), RunEvent::Ended, "{statement}"); + assert_eq!(string(&vm, "s"), ""); + assert_eq!(string(&vm, "t"), "b"); + } +} diff --git a/crates/tb-vm/tests/vm.rs b/crates/tb-vm/tests/vm.rs index ef3be80..36636d8 100644 --- a/crates/tb-vm/tests/vm.rs +++ b/crates/tb-vm/tests/vm.rs @@ -859,6 +859,12 @@ fn run_mit_uhr(src: &str, schritt_ms: u64) -> (RunEvent, String) { fn next_event(&mut self, b: bool) -> Option { self.inner.next_event(b) } + fn warten(&mut self, deadline_ms: Option) -> Option { + if let Some(event) = self.next_event(false) { + return Some(event); + } + self.inner.warten(deadline_ms) + } fn jetzt_ms(&mut self) -> u64 { let j = self.inner.jetzt_ms(); self.inner.uhr_vorruecken(self.schritt); diff --git a/docs/reviews/2026-09-05-openspec-code-review.md b/docs/reviews/2026-09-05-openspec-code-review.md index 52b8363..3b99070 100644 --- a/docs/reviews/2026-09-05-openspec-code-review.md +++ b/docs/reviews/2026-09-05-openspec-code-review.md @@ -4,6 +4,18 @@ Geprüfter Code: `19804e0e2de9eb5a8b0ef256b5be76c741560e07` in TerminalBasic. De Die Implementierung deckt breite Grundpfade ab, erfüllt den gesamten Vertrag aber nicht. **25 Befunde**: 10 mit hoher, 12 mit mittlerer Priorität, 2 Dokumentationsabweichungen und 1 Vertragsklärung. Die Priorität bewertet die Auswirkung, nicht den Aufwand. Befunde können mehrere Requirements betreffen. +## Umsetzungsstand: Ereigniszustellung und Hostgrenzen + +F04, F05, F06, F07, F08, F18 und F20 sind im archivierten Change +[ereigniszustellung-und-hostgrenzen](../../openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/proposal.md) +implementiert. Die fünf Restfehler der anschließenden +[Verifikation](../../openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/review.md) +sind behoben: 419 Workspace-Tests und alle sechs ursprünglichen Review-Proben +bestehen. Die konkreten Implementierungs- und aktuellen Abnahmenachweise stehen in +[verification.md](../../openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/verification.md). +Die Befundtexte und die Matrix unten dokumentieren weiterhin den ursprünglichen +Reviewstand; die übrigen Changes bleiben eigenständig. + ## Prüfmethode und Grenzen - Alle Requirements/Szenarien gelesen und gegen Einstiegspunkte, Implementierung und vorhandene Tests abgeglichen. Die Matrix unten enthält jeden Requirement-Titel einmal. @@ -46,7 +58,7 @@ Ein eingespeistes Signal(2) bei definiertem und eingeschaltetem Trap ergibt kein Beleg: [crates/tb-runtime/src/builtins.rs:121](../../crates/tb-runtime/src/builtins.rs#L121). Vertrag: [openspec/specs/ereignissteuerung/spec.md:186](../../openspec/specs/ereignissteuerung/spec.md#L186). -Umsetzung: [ereigniszustellung-und-hostgrenzen](../../openspec/changes/ereigniszustellung-und-hostgrenzen/proposal.md). +Umsetzung: [ereigniszustellung-und-hostgrenzen](../../openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/proposal.md). ### F05 — KEY STOP entfernt die Taste aus dem Eingabestrom (hoch) @@ -54,7 +66,7 @@ Bei KEY(1) STOP wird F1 zwar für das spätere ON gemerkt, aber aus dem normalen Beleg: [crates/tb-runtime/src/traps.rs:215](../../crates/tb-runtime/src/traps.rs#L215). Vertrag: [openspec/specs/ereignissteuerung/spec.md:81](../../openspec/specs/ereignissteuerung/spec.md#L81), [openspec/specs/ereignissteuerung/spec.md:143](../../openspec/specs/ereignissteuerung/spec.md#L143). -Umsetzung: [ereigniszustellung-und-hostgrenzen](../../openspec/changes/ereigniszustellung-und-hostgrenzen/proposal.md). +Umsetzung: [ereigniszustellung-und-hostgrenzen](../../openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/proposal.md). ### F06 — Forms-Handler und Eingaben verlieren ihre Reihenfolge (hoch) @@ -62,7 +74,7 @@ Der Fokuswechsel Text1 nach Text2 ergibt in der VM GL statt LG: GotFocus läuft Beleg: [crates/tb-vm/src/interp.rs:570](../../crates/tb-vm/src/interp.rs#L570). Vertrag: [openspec/specs/ereignissteuerung/spec.md:32](../../openspec/specs/ereignissteuerung/spec.md#L32), [openspec/specs/forms-steuerelemente/spec.md:30](../../openspec/specs/forms-steuerelemente/spec.md#L30), [openspec/specs/forms-objektmodell/spec.md:106](../../openspec/specs/forms-objektmodell/spec.md#L106). -Umsetzung: [ereigniszustellung-und-hostgrenzen](../../openspec/changes/ereigniszustellung-und-hostgrenzen/proposal.md). +Umsetzung: [ereigniszustellung-und-hostgrenzen](../../openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/proposal.md). ### F07 — Offenes Menü verhindert Traps nicht an allen Zustellpunkten (hoch) @@ -70,7 +82,7 @@ Ein UEVENT-Trap wird während eines geöffneten Menüs an einer gewöhnlichen An Beleg: [crates/tb-vm/src/interp.rs:1139](../../crates/tb-vm/src/interp.rs#L1139). Vertrag: [openspec/specs/forms-steuerelemente/spec.md:102](../../openspec/specs/forms-steuerelemente/spec.md#L102), [openspec/specs/ereignissteuerung/spec.md:59](../../openspec/specs/ereignissteuerung/spec.md#L59). -Umsetzung: [ereigniszustellung-und-hostgrenzen](../../openspec/changes/ereigniszustellung-und-hostgrenzen/proposal.md). +Umsetzung: [ereigniszustellung-und-hostgrenzen](../../openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/proposal.md). ### F08 — Zeit- und Wartepfade verletzen die Hostgrenze (mittel) @@ -78,7 +90,7 @@ Drei einfache Anweisungen ohne Trap/Form-Timer lesen die Hostzeit sechsmal. form Beleg: [crates/tb-vm/src/interp.rs:602](../../crates/tb-vm/src/interp.rs#L602). Vertrag: [openspec/specs/vm-ausfuehrung/spec.md:118](../../openspec/specs/vm-ausfuehrung/spec.md#L118), [openspec/specs/ereignissteuerung/spec.md:15](../../openspec/specs/ereignissteuerung/spec.md#L15), [openspec/specs/ereignissteuerung/spec.md:59](../../openspec/specs/ereignissteuerung/spec.md#L59), [openspec/specs/forms-objektmodell/spec.md:106](../../openspec/specs/forms-objektmodell/spec.md#L106). -Umsetzung: [ereigniszustellung-und-hostgrenzen](../../openspec/changes/ereigniszustellung-und-hostgrenzen/proposal.md). +Umsetzung: [ereigniszustellung-und-hostgrenzen](../../openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/proposal.md). ### F09 — Control-Namen sind faktisch global statt formularlokal (hoch) @@ -158,7 +170,7 @@ Statisch belegt: Der Terminal-Mapper behandelt KeyCode::Tab, aber nicht KeyCode: Beleg: [crates/tb-ui/src/host.rs:179](../../crates/tb-ui/src/host.rs#L179). Vertrag: [openspec/specs/forms-steuerelemente/spec.md:30](../../openspec/specs/forms-steuerelemente/spec.md#L30). -Umsetzung: [ereigniszustellung-und-hostgrenzen](../../openspec/changes/ereigniszustellung-und-hostgrenzen/proposal.md). +Umsetzung: [ereigniszustellung-und-hostgrenzen](../../openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/proposal.md). ### F19 — Der Zonenzustand ist für BASIC nicht abfragbar (mittel) @@ -174,7 +186,7 @@ tb-vm hängt unmittelbar von tb-ui ab; dort sind ratatui, crossterm und signal-h Beleg: [crates/tb-vm/Cargo.toml:15](../../crates/tb-vm/Cargo.toml#L15). Vertrag: [openspec/specs/textbildschirm/spec.md:92](../../openspec/specs/textbildschirm/spec.md#L92). -Umsetzung: [ereigniszustellung-und-hostgrenzen](../../openspec/changes/ereigniszustellung-und-hostgrenzen/proposal.md). +Umsetzung: [ereigniszustellung-und-hostgrenzen](../../openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/proposal.md). ### F21 — Inventarstatus beweist kein vorhandenes Laufzeitverhalten (mittel) @@ -221,7 +233,7 @@ Umsetzung: [spezifikationsabgleich-und-regressionsnachweise](../../openspec/chan | Reihenfolge | Change | Befunde | Abhängigkeit | |---|---|---|---| | 1 | [isam-transaktionen-und-dateinummern](../../openspec/changes/archive/2026-09-05-isam-transaktionen-und-dateinummern/proposal.md) | F01, F02, F03 | Eigenständig; bei gemeinsamen Dateien sequenziell integrieren | -| 2 | [ereigniszustellung-und-hostgrenzen](../../openspec/changes/ereigniszustellung-und-hostgrenzen/proposal.md) | F04, F05, F06, F07, F08, F18, F20 | Eigenständig; bei gemeinsamen Dateien sequenziell integrieren | +| 2 | [ereigniszustellung-und-hostgrenzen](../../openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/proposal.md) | F04, F05, F06, F07, F08, F18, F20 | Eigenständig; bei gemeinsamen Dateien sequenziell integrieren | | 3 | [forms-zustand-und-bedienung](../../openspec/changes/forms-zustand-und-bedienung/proposal.md) | F12, F13 | Nach Ereigniszustellung | | 4 | [projektmodule-und-kompilat](../../openspec/changes/projektmodule-und-kompilat/proposal.md) | F09, F10, F11, F24 | Eigenständig; bei gemeinsamen Dateien sequenziell integrieren | | 5 | [laufzeit-eingabe-und-systemstatus](../../openspec/changes/laufzeit-eingabe-und-systemstatus/proposal.md) | F14, F15, F16, F17, F19 | Eigenständig; bei gemeinsamen Dateien sequenziell integrieren | diff --git a/docs/tbvm-design.md b/docs/tbvm-design.md index 36377a8..6a0fcbb 100644 --- a/docs/tbvm-design.md +++ b/docs/tbvm-design.md @@ -55,15 +55,76 @@ enum Value { ## Ausführungsmodell / Unterbrechbarkeit -- Die Interpreterschleife läuft in **Ticks**: nach jeder Anweisung (Grenze - aus der Zeilentabelle) prüft sie ein Flag-Set: Breakpoint? Einzelschritt? - Strg+Untbr? Ereignis-Queue nicht leer und Zustellung erlaubt? -- Ereigniszustellung (Forms, Timer) erfolgt kooperativ: nur an - Zustellpunkten (`DOEVENTS`, `SLEEP`, blockierende Eingabe, Ende einer - Ereignisprozedur) — wie im Vorbild. -- Die VM ist eine gewöhnliche zustandsbehaftete Struktur, `step()`-basiert; - die einbettende Schleife (IDE-Debugger oder Runner) treibt sie. Kein - eigener Thread nötig; Terminal-Events werden zwischen Ticks gepollt. +Die VM läuft auf dem aufrufenden Thread. `Vm::run` liefert bei STOP, +Breakpoint, Einzelschritt, Programmende oder Fehler ein `RunEvent` an den +Runner zurück. `Stmt` markiert Anweisungsgrenzen; es gibt keine Prüfung +nach jedem Opcode. + +Die gemeinsame Zustellentscheidung gilt an Anweisungsgrenzen, bei +`DOEVENTS`, während `SLEEP` und während blockierender Konsoleneingabe. Eine +zugestellte Ereignisprozedur beginnt ihre erste Anweisung, bevor ein +weiterer Handler sie unterbrechen darf. Forms-Handler laufen normalerweise +bis zur Rückkehr; explizites DOEVENTS und blockierendes Warten können andere +Handler verschachteln, aber nie dieselbe laufende Forms-Ereignisprozedur. +Die Rückkehr aus dem äußersten normalen Forms-Handler arbeitet wartende +Forms-Ereignisse geordnet ab. Objekt-Opcodes stellen nur erforderliche +synchrone LOAD-Handler zu; deren Rückkehr zieht keine unabhängigen Ereignisse +mitten in eine Anweisung vor. Ein offenes +Menü sperrt klassische Traps und Forms-Timer, ohne ON/OFF/STOP zu verändern. +Beim Menüschließen wird die Zustellbarkeit vor erneutem Warten geprüft. + +INPUT, LINE INPUT, INPUT$ und RANDOMIZE ohne Argument teilen einen +fortsetzbaren Konsolenleser. Teiltext und Restlänge liegen im aufrufenden +Frame; Referenzen und Ausdrucksoperanden bleiben bis zum Abschluss auf dem +Stack. Nach jedem Hostereignis kann die VM einen Handler ausführen und +danach am Eingabe-Opcode fortsetzen, auch über STOP/CONT und verschachtelte +Eingaben hinweg. Fehler-Unwinding und RETURN label verwerfen die abgebrochene +Eingabe. Während dieses Lesens gehören ungetrappte Tasten der Konsole. + +`RtState::eingaben` hält Tasten und Maus in Hostreihenfolge, auch beim +Übergeben an Dialoge. Die VM nimmt eine Forms-Eingabe samt ihren Handlern +vor der nächsten Eingabe an. Konsolentasten bleiben lesbar; Maus ohne +Formular oder außerhalb der Bildschirmfläche wird verworfen. KEY ON +entnimmt getrappte Tasten. KEY STOP merkt höchstens einen Trap, markiert die +betroffenen Eingaben als bereits gemerkt und lässt sie lesbar, auch nach ON. +Vor der klassischen Quellenauswahl wird der getrappte Tastenanfang der Queue +gemeldet: KEY behält seine Priorität vor TIMER/UEVENT/SIGNAL, ohne eine davor +wartende Mauseingabe zu überholen. EOF beendet die Quelle; modale und +modellose Formularschleifen arbeiten schon angenommene Ereignisse noch ab. + +`Host::jetzt_ms` ist die einzige Uhr für Ereignisfristen. Die VM fragt sie +nur für aktive Zeitquellen, SLEEP-Fristen und zeitabhängige Mausauswertung +ab. Der klassische TIMER wird weiterhin bei Anzeigen, expliziter +Kooperation und ansonsten alle 64 Anweisungsgrenzen geprüft. +`Host::warten(Option)` wartet auf ein Ereignis oder die absolute +Deadline derselben Uhr; `None` als Ergebnis meldet eine erreichte Deadline. +Ohne Deadline liefert der Host erst ein Ereignis oder explizites +`Ereignis::Ende`. Die VM verwendet weder Instant noch Sleep/Yield als +Rückfallebene. Auch modale und nach Programmende sichtbare Forms verwenden +diesen Wartepfad. CaptureHost liefert vorbereitete Ereignisse zuerst und +rückt beim Warten andernfalls bis zur Deadline vor; ohne Deadline meldet +er Eingabeende. Seine Uhr bleibt außerhalb dieser ausdrücklichen +Warteoperation oder manueller Zeitänderungen stehen. + +`tb-ui` hat standardmäßig keine Terminalabhängigkeiten. Das Feature +`terminal` schaltet die Module `host`, `screen`, `signale` und die optionalen +Dependencies crossterm, ratatui und signal-hook ein. `tb-cli` aktiviert es +explizit; VM, Forms-Modell, FRM-Reader und Dialoge brauchen es nicht. Einbetter +müssen die neue Host-Wartemethode implementieren. Dialog-Eingabequeues tragen +`Eingabe` statt nackter `Ereignis`-Werte, damit KEY-STOP-Markierungen erhalten +bleiben; Ereignisse lassen sich mit `.into()` einreihen. + +Der separate Workspace `tests/terminalfrei` prüft Einbettung ohne +Feature-Unifikation mit CLI oder IDE: + +```sh +cargo run --manifest-path tests/terminalfrei/Cargo.toml --offline +cargo tree --manifest-path tests/terminalfrei/Cargo.toml --edges normal +``` + +Der erste Befehl lädt ein FRM-Korpusprogramm und prüft dessen `KM`-Ausgabe +mit CaptureHost. Der zweite Baum darf crossterm, ratatui und signal-hook +nicht enthalten. ## `.tbc`-Container (Stand Phase 2, Formatversion 1) diff --git a/openspec/changes/ereigniszustellung-und-hostgrenzen/.openspec.yaml b/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/.openspec.yaml similarity index 100% rename from openspec/changes/ereigniszustellung-und-hostgrenzen/.openspec.yaml rename to openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/.openspec.yaml diff --git a/openspec/changes/ereigniszustellung-und-hostgrenzen/design.md b/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/design.md similarity index 100% rename from openspec/changes/ereigniszustellung-und-hostgrenzen/design.md rename to openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/design.md diff --git a/openspec/changes/ereigniszustellung-und-hostgrenzen/proposal.md b/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/proposal.md similarity index 93% rename from openspec/changes/ereigniszustellung-und-hostgrenzen/proposal.md rename to openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/proposal.md index 3c3de50..d23318c 100644 --- a/openspec/changes/ereigniszustellung-und-hostgrenzen/proposal.md +++ b/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/proposal.md @@ -1,6 +1,6 @@ ## Why -Ereignisse gehen verloren, laufen in falscher Reihenfolge oder umgehen Menüsperren. F04–F08, F18 und F20 zeigen außerdem Zeitabfragen ohne Bedarf und eine unerwünschte Terminalabhängigkeit der VM. Siehe [Review vom 05.09.2026](../../../docs/reviews/2026-09-05-openspec-code-review.md). +Ereignisse gehen verloren, laufen in falscher Reihenfolge oder umgehen Menüsperren. F04–F08, F18 und F20 zeigen außerdem Zeitabfragen ohne Bedarf und eine unerwünschte Terminalabhängigkeit der VM. Siehe [Review vom 05.09.2026](../../../../docs/reviews/2026-09-05-openspec-code-review.md). ## What Changes diff --git a/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/review-probes.rs b/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/review-probes.rs new file mode 100644 index 0000000..a345327 --- /dev/null +++ b/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/review-probes.rs @@ -0,0 +1,167 @@ +//! Ursprüngliche Reproduktionen: fünf behobene Befunde und eine Kontrollprobe. +//! Separater Cargo-Treiber; siehe review.md. Alle sechs Prüfungen müssen bestehen. +use tb_frontend::forms::{FormCatalog, ObjectClass}; +use tb_runtime::host::{CaptureHost, Ereignis, Host, MausArt, MausEreignis}; +use tb_runtime::screen::TextScreen; +use tb_runtime::value::Value; +use tb_vm::interp::{RunEvent, Vm}; + +fn vm(src: &str) -> Vm { + let mut catalog = FormCatalog::default(); + catalog.add("Form1", ObjectClass::Form, None, false); + catalog.add("Text1", ObjectClass::TextBox, Some("Form1"), false); + catalog.add("Text2", ObjectClass::TextBox, Some("Form1"), false); + catalog.add("Timer1", ObjectClass::Timer, Some("Form1"), false); + catalog.add("Menu1", ObjectClass::Menu, Some("Form1"), false); + catalog.add("Item1", ObjectClass::Menu, Some("Menu1"), false); + Vm::new( + tb_vm::compile_source_with_forms("FORM1", src, &catalog) + .unwrap_or_else(|d| panic!("{d:?}")), + ) +} + +fn stop(vm: &mut Vm, host: &mut dyn Host) { + assert!(matches!(vm.run(host), RunEvent::Stopped { .. })); +} + +fn string(vm: &Vm, name: &str) -> String { + match vm.inspect(name) { + Some(Value::Str(s)) => s.to_string(), + other => panic!("{other:?}"), + } +} + +fn key(s: &str) -> Ereignis { + Ereignis::Taste(s.into(), 0) +} +fn mouse() -> Ereignis { + Ereignis::Maus(MausEreignis { + art: MausArt::Druck, + taste: 1, + shift: 0, + zeile: 1, + spalte: 1, + }) +} + +#[test] +fn review_signal_waehrend_blockierender_eingabe() { + struct Input { + reads: usize, + } + impl Host for Input { + fn present(&mut self, _: &TextScreen) {} + fn next_event(&mut self, block: bool) -> Option { + if !block { + return None; + } + self.reads += 1; + Some(if self.reads == 1 { + Ereignis::Signal(2) + } else { + Ereignis::Ende + }) + } + fn jetzt_ms(&mut self) -> u64 { + 0 + } + fn warten(&mut self, _: Option) -> Option { + self.next_event(true) + } + } + let mut results = vec![]; + for statement in ["INPUT s$", "LINE INPUT s$", "s$ = INPUT$(1)"] { + let src = + format!("ON SIGNAL(2) GOSUB H\nSIGNAL(2) ON\n{statement}\nEND\nH:\nPRINT \"H\"\nEND"); + let mut vm = Vm::new(tb_vm::compile_source("TEST", &src).unwrap()); + let result = vm.run(&mut Input { reads: 0 }); + println!( + "{statement}: {result:?}; Ausgabe={:?}", + tb_runtime::snapshot::text(&vm.rt.screen) + ); + results.push(result); + } + assert!(results.iter().all(|result| *result == RunEvent::Ended)); +} + +#[test] +fn review_objektargument_darf_gotfocus_nicht_vorziehen() { + let mut vm = vm("DIM SHARED s$\nText1.TabIndex = 0\nText2.TabIndex = 1\nForm1.Show\nText1.SetFocus\nSTOP\nDOEVENTS\nEND\nSUB Text1_LostFocus()\nSHARED s$\nCALL Mark(Text1)\ns$ = s$ + \"l\"\nEND SUB\nSUB Mark(c AS CONTROL)\nSHARED s$\ns$ = s$ + \"L\"\nEND SUB\nSUB Text2_GotFocus()\nSHARED s$\ns$ = s$ + \"G\"\ns$ = s$ + \"g\"\nEND SUB"); + let mut host = CaptureHost::default(); + stop(&mut vm, &mut host); + host.ereignis(key("\t")); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(string(&vm, "s"), "LlGg"); +} + +#[test] +fn review_gleichzeitig_key_und_uevent_behalten_prioritaet() { + let mut vm = vm("ON KEY(15) GOSUB K\nKEY 15, CHR$(0) + CHR$(20)\nKEY(15) ON\nON UEVENT GOSUB U\nUEVENT ON\nSTOP\nDOEVENTS\nEND\nK:\ns$ = s$ + \"K\"\nRETURN\nU:\ns$ = s$ + \"U\"\nRETURN"); + let mut host = CaptureHost::default(); + stop(&mut vm, &mut host); + host.ereignis(key("t")); + vm.rt.pump(&mut host, false); + vm.rt.traps.melden(tb_runtime::traps::Quelle::UEvent); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(string(&vm, "s"), "KU"); +} + +#[test] +fn review_modale_eingabe_vor_eof_wird_zugestellt() { + let mut vm = vm("DIM SHARED s$\nForm1.Show 1\nEND\nSUB Form_MouseDown(Button AS INTEGER, Shift AS INTEGER, X AS SINGLE, Y AS SINGLE)\nSHARED s$\ns$ = \"M\"\nUNLOAD Form1\nEND SUB"); + let mut host = CaptureHost::default(); + host.ereignis_nach(2, mouse()); + host.ereignis_nach(2, Ereignis::Ende); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(string(&vm, "s"), "M"); +} + +#[test] +fn review_menu_schliessen_stellt_pending_trap_vor_erneutem_warten_zu() { + struct MenuHost { + time: u64, + waits: usize, + } + impl Host for MenuHost { + fn present(&mut self, _: &TextScreen) {} + fn next_event(&mut self, _: bool) -> Option { + None + } + fn jetzt_ms(&mut self) -> u64 { + self.time + } + fn warten(&mut self, deadline: Option) -> Option { + self.waits += 1; + if self.waits == 1 { + return Some(key("\u{1b}")); + } + self.time = deadline.unwrap(); + None + } + } + let mut vm = vm("ON UEVENT GOSUB H\nUEVENT ON\nMenu1.Caption = \"&Datei\"\nItem1.Caption = \"&Ende\"\nForm1.Show\nSTOP\nSLEEP 60\nEND\nH:\ns$ = \"U\"\nRETURN"); + let mut host = CaptureHost::default(); + stop(&mut vm, &mut host); + vm.forms.handle_key("d", 4); + vm.rt.traps.melden(tb_runtime::traps::Quelle::UEvent); + let mut host = MenuHost { time: 0, waits: 0 }; + assert_eq!(vm.run(&mut host), RunEvent::Ended); + println!( + "Menü/SLEEP: Zeit={}, Warteaufrufe={}, Handler={}", + host.time, + host.waits, + string(&vm, "s") + ); + assert_eq!(host.waits, 1); +} + +#[test] +fn review_maus_vor_getrappter_taste() { + let mut vm = vm("DIM SHARED s$\nON KEY(15) GOSUB H\nKEY 15, CHR$(0) + CHR$(20)\nKEY(15) ON\nForm1.Show\nSTOP\nDOEVENTS\nDOEVENTS\nEND\nH:\ns$ = s$ + \"K\"\nRETURN\nSUB Form_MouseDown(Button AS INTEGER, Shift AS INTEGER, X AS SINGLE, Y AS SINGLE)\nSHARED s$\ns$ = s$ + \"M\"\nEND SUB"); + let mut host = CaptureHost::default(); + stop(&mut vm, &mut host); + host.ereignis(mouse()); + host.ereignis(key("t")); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(string(&vm, "s"), "MK"); +} diff --git a/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/review.md b/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/review.md new file mode 100644 index 0000000..2d6cff6 --- /dev/null +++ b/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/review.md @@ -0,0 +1,191 @@ +# Verifikation: ereigniszustellung-und-hostgrenzen + +Stand nach Behebung: 05.09.2026, aktueller Arbeitsbaum auf `57d6386`. +**Alle fünf Verifikationsbefunde sind behoben.** Die ursprünglichen sechs +Review-Proben bestehen unverändert in ihren Erwartungen; zusätzlich wurden +sie in die Workspace-Regressionen übernommen und um verwandte Pfade ergänzt. + +| Dimension | Ergebnis | +| --- | --- | +| Vollständigkeit | 12/12 Tasks erledigt; ursprüngliche Zuordnung der 9 Delta-Requirements und 33 Szenarien um Korrektur-Nachweise ergänzt. | +| Korrektheit | 419 Workspace-Tests und alle sechs separaten Review-Proben bestanden; keine offenen V1–V5-Befunde. | +| Kohärenz | Gemeinsame Zustellung auch während Eingabe und nach Menüschluss; synchrone LOAD-Wirkungen getrennt, klassische Quellpriorität erhalten. | + +## Korrekturen und Regressionen + +| Befund | Behebung | Nachweis in `crates/tb-vm/tests/events.rs` | +| --- | --- | --- | +| V1 | Fortsetzbarer Konsolenleser für INPUT, LINE INPUT, INPUT$ und RANDOMIZE ohne Argument. Frame hält Teiltext/Restlänge, Stack hält Operanden und Referenzen. Trap-Unwinding erhält gemeinsam genutzte Locals; Fehler und RETURN label verwerfen abgebrochene Eingaben. | Ursprüngliche Signalprobe; Teiltext/BYREF/Ausdruck, verschachtelte Eingaben mit STOP/CONT, Timer, Redo, lokaler Fehlerhandler, RANDOMIZE und RETURN label. | +| V2 | Implizite Objektaufrufe stellen ausschließlich notwendige LOAD-Handler zu. Deren Rückkehr zieht keine unabhängigen Forms-Ereignisse vor. | LlGg mit Control-Argument, Property-Lesen/-Schreiben, Refresh und LOAD; bestehende Load-vor-Show- und Unload-Tests. | +| V3 | Nach dem Schließen des Menüs wird die gemeinsame Auswahl vor jedem erneuten Warten wiederholt. | Esc bei Zeit 0, UEVENT zugestellt nach genau einem Warteaufruf; bestehende Menüsperrtests einschließlich Forms-Timer. | +| V4 | Modale und modellose Schleifen beenden bei EOF erst, wenn kein Handler mehr zugestellt wurde. | Ursprüngliche MouseDown/Unload-Probe; jeweils zwei Mausereignisse vor EOF ergeben MM in beiden Schleifen. | +| V5 | Getrappte Tasten am Queue-Anfang werden vor der klassischen Quellenauswahl gemeldet. | Gleichzeitiges KEY/UEVENT ergibt KU; Maus vor getrappter Taste bleibt MK. | + +## Ursprüngliche Befunde — historischer Stand vor der Behebung + +Die folgenden Ist-Beobachtungen und Ursachen dokumentieren den fehlgeschlagenen +Review vor diesen Korrekturen. Alle fünf sind durch die Nachweise oben geschlossen. + +### V1 — Während blockierender Eingabe werden eintreffende Traps nicht zugestellt (hoch) + +**Reproduktion:** `ON SIGNAL(2) GOSUB H`, `SIGNAL(2) ON`, danach INPUT. +Der Host liefert erst im blockierenden `next_event(true)` Signal(2), beim +nächsten Aufruf Eingabeende. Der Handler H würde `PRINT "H"` und END ausführen. + +**Ist:** INPUT, LINE INPUT und INPUT$(1) enden jeweils mit Fehler 62, H läuft +nicht. Ohne nachfolgende Eingabe kann die Laufzeit stattdessen erneut +blockieren, obwohl der Signal-Trap schon ansteht. + +**Soll:** Vor dem erneuten Blockieren muss der erlaubte Zustellpunkt genutzt +werden; H beendet das Programm, Ausgabe H, kein Fehler 62. + +**Ursache:** Der einmalige VM-Check vor der Eingabeinstruktion genügt nicht. +[RtState::naechste_taste](../../../../crates/tb-runtime/src/builtins.rs#L209) +pumpt innerhalb seiner Schleife weitere Ereignisse und blockiert erneut, +ohne zur VM-Zustellung zurückzukehren. Dasselbe betrifft die drei genannten +Eingabefunktionen über ihren gemeinsamen Leser. + +**Vertrag:** `ereignissteuerung` / Abschließende Liste der Zustellpunkte, +Signal-Trap auf Betriebssystemsignalen; Design D2; Task 2.1. +**Korrektur:** Blockierende Eingabe so fortsetzbar machen, dass nach einem +Nicht-Tasten-Ereignis und vor dem nächsten Host-Warten die gemeinsame +VM-Zustellung laufen kann. Eingabepuffer, Referenzen und Operanden erhalten. + +### V2 — Control-Argumente stellen wartende Forms-Handler mitten in einer Anweisung zu (hoch) + +**Reproduktion:** Fokuswechsel Text1 → Text2. Text1_LostFocus ruft als erste +wirksame Anweisung `CALL Mark(Text1)` auf; Mark hängt L an eine gemeinsame +Variable, LostFocus danach l. Text2_GotFocus hängt G und g an. + +**Ist:** `GgLl`. **Soll:** `LlGg`. + +**Ursache:** [Instr::PushObject](../../../../crates/tb-vm/src/interp.rs#L1344) +ruft nach dem Ablegen eines bereits geladenen Control-Objekts bedingungslos +`dispatch_next_form_event` auf. Dadurch läuft GotFocus noch vor dem eigentlichen +CALL, obwohl weder DOEVENTS noch modales Warten vorliegt. Der Schutz am ersten +Stmt-Eintritt und `forms_frei` werden damit umgangen. + +**Vertrag:** Fokus, Tabreihenfolge und Access-Keys / Handlerwirkungen in +Fokusreihenfolge; Abschließende Liste der Zustellpunkte; Design D1/D2; +Tasks 1.2 und 2.1. +**Korrektur:** Unabhängig wartende Ereignisse aus Objekt-Opcodes heraus nicht +zustellen. Notwendige synchrone Load-/Unload-Wirkungen gezielt behandeln; +alle Aufrufer von `dispatch_next_form_event` gegen dieselben Zustellregeln prüfen. + +### V3 — Menüschließen während SLEEP führt zu erneutem Warten trotz bereitem Trap (hoch) + +**Reproduktion:** Menü offen, UEVENT anstehend, `SLEEP 60`. Der erste +Host-Warteaufruf liefert Esc bei unveränderter virtueller Zeit 0. + +**Ist:** Esc schließt das Menü; die VM wartet ein zweites Mal bis Hostzeit +60.000 ms. Erst dann läuft der UEVENT-Handler. +**Soll:** Nach Esc läuft der freigegebene Handler ohne zweiten Warteaufruf +und beendet SLEEP sofort. Bei einer Wartephase ohne eigene Deadline droht +entsprechend Warten auf ein weiteres externes Ereignis. + +**Ursache:** Die klassische Trap-Auswahl in +[Vm::zustellen](../../../../crates/tb-vm/src/interp.rs#L625) erfolgt vor der +Eingabeverarbeitung. Nachdem Esc die Menüsperre aufgehoben hat, kehrt die +Methode ohne erneute Auswahl zurück; +[SLEEP](../../../../crates/tb-vm/src/interp.rs#L707) ruft unmittelbar wieder +`warten` auf. + +**Vertrag:** Menüsystem / Traps ruhen im geöffneten Menü und werden danach +fortgesetzt; Abschließende Liste der Zustellpunkte / SLEEP endet durch +Ereignis; Design D2/D3; Tasks 2.1/2.2. +**Korrektur:** Nach einer Eingabe, die die Zustellbarkeit verändert, bereite +Ereignisse vor jedem weiteren blockierenden Warten erneut auswählen. + +### V4 — Modale Schleife verliert bereits angenommenes Ereignis unmittelbar vor EOF (hoch) + +**Reproduktion:** `Form1.Show 1`; nach der zweiten Anzeige liefert der Host +MouseDown und unmittelbar danach Eingabeende. Der MouseDown-Handler hängt M +an und entlädt das Formular. + +**Ist:** VM meldet Ended, die gemeinsame Variable bleibt leer; der Handler +wurde nicht ausgeführt. **Soll:** Das vor EOF eingetroffene Mausereignis wird +noch wirksam, Ergebnis M, Formular entladen. + +**Ursache:** In der [modalen Run-Schleife](../../../../crates/tb-vm/src/interp.rs#L890) +setzt `zustellen` einen Handlerframe auf. Direkt danach wird ungeachtet von +`dispatched` wegen `rt.ende` zurückgekehrt. Das Ereignis ist bereits aus der +Queue entfernt und wird stillschweigend verloren. + +**Vertrag:** Mausereignisse in Zellenkoordinaten / Reihenfolge bis zur +beobachtbaren Handlerausführung; Design D1/D2; Tasks 1.2/2.1. +**Korrektur:** EOF als Ende der Eingabequelle behandeln und bereits angenommene +Ereignisse/Handler geordnet abarbeiten, bevor die modale Ausführung endet. + +## Ursprüngliche weitere Designabweichung + +### V5 — Gleichzeitig wartende klassische Quellen erhalten eine andere Priorität (mittel) + +**Reproduktion:** KEY(15) ON und UEVENT ON. Vor demselben Zustellpunkt liegen +eine passende Taste in der Eingabequeue und ein gemeldeter UEVENT vor. +Die Handler hängen K bzw. U an. + +**Ist:** `UK`. **Soll:** `KU` gemäß der bestehenden klassischen Quellpriorität. + +**Ursache:** [Vm::zustellen](../../../../crates/tb-vm/src/interp.rs#L631) wählt +bereits gemeldete Traps aus, bevor die wartende Taste durch `taste_trappen` +gemeldet wird. Die Prioritätsauswahl in Traps sieht die Taste deshalb noch +nicht. Früher erfolgte die Tastenprüfung vor der gemeinsamen Trap-Auswahl. + +**Vertrag:** Design / Non-Goals: keine Änderung der festgelegten Priorität +gleichzeitig fälliger klassischer Quellen; D1/D2; Task 2.1. +**Korrektur:** Fälligkeitserfassung und Trap-Auswahl so koordinieren, dass die +bestehende Quellpriorität erhalten bleibt, ohne die Reihenfolge zwischen +Maus- und Tasteneingaben wieder nach Quellarten umzusortieren. + +## Abdeckung und positive Nachweise + +| Delta-Requirement | Nachweis / Einschränkung | +| --- | --- | +| Ereignisquellen mit Zeit vom Host | Host-Warteschnittstelle, eingefrorene virtuelle Zeit, klassischer Timer und Forms-Timer-SLEEP getestet. Keine reale Rückfalluhr in der VM; Zustellung nach Menüschluss durch V3-Regression abgesichert. | +| Mausereignisse in Zellenkoordinaten | Mapper-/Bounds-Tests, leere Verbraucher, KM/MK-Korpus und gemischte Eingaben. V4-Regressionen sichern EOF in modalen und modellosen Schleifen ab. | +| Abschließende Liste der Zustellpunkte | DOEVENTS-Wert, SLEEP, vorbereitete und erst beim Warten eintreffende INPUT-Ereignisse getestet; V1–V3 geschlossen. | +| Getrappte Tasten verlassen den Eingabestrom | ON/OFF/STOP × INKEY$/INPUT/LINE INPUT/INPUT$, mehrfaches STOP-Merken und Dialogrestpuffer getestet. | +| Signal-Trap auf Betriebssystemsignalen | Beide Kennungen, Maskierung und ungültige Werte getestet. V1-Regression sichert Signalzustellung während blockierender Eingabe ab. | +| Fokus, Tabreihenfolge und Access-Keys | Tab/Shift-Tab, BackTab-Mapper, Access-Keys und einfacher LostFocus→GotFocus-Lauf belegt. V2-Regressionen sichern Control-Argumente und verwandte Objektaufrufe ab. | +| Menüsystem | Menüeigenschaften/-auswahl und Sperre an normalen Grenzen getestet. V3-Regression sichert Freigabe innerhalb von SLEEP ab. | +| Zellenpuffer ohne Terminalabhängigkeit | Separater Workspace kompiliert FRM/VM und liefert KM; 16 Pakete im normalen Dependency-Graph, keines davon crossterm/ratatui/signal-hook. CLI schaltet das Feature ausdrücklich ein. | +| Ereigniszustellung an Anweisungsgrenzen | Bestehende SUB-/GOSUB-/BYREF-/RESUME-Tests und Host ohne erlaubte Uhrabfragen grün. V2/V5-Regressionen sichern zulässige Handlerbeginn-Reihenfolge und klassische Priorität ab. | + +Erneut ausgeführt: + +- `cargo test --workspace`: **419 bestanden, 0 fehlgeschlagen, 1 ignoriert**. +- `openspec validate ereigniszustellung-und-hostgrenzen --strict`: gültig. +- Isolierter Einbetter mit CaptureHost: **KM**, Dependency-Prüfung erfolgreich. +- Separate [Review-Proben](review-probes.rs): **6 bestanden, 0 fehlgeschlagen**. + Die Kontrollprobe prüft Maus unmittelbar vor getrappter Taste. +- Workspace-Ereignistests: **25 bestanden**, einschließlich Frist-Watchdog. +- Keine bestehenden Sollausgaben neu aufgezeichnet oder geändert. + +Warnings-denied Clippy, Formatprüfungen und Compile-/VM-Benchmarks wurden +nach der Behebung erneut erfolgreich ausgeführt; Zahlen siehe +[verification.md](verification.md). Kein interaktiver Terminaltest und keine +Windows-/Linux-Matrix. Die Proben speisen Ereignisse +gezielt über Host ein und benötigen weder echte Signale noch reale Wartefristen. + +## Review-Proben reproduzieren + +Im Repository-Wurzelverzeichnis ausführen. Das erzeugt ausschließlich einen +separaten temporären Cargo-Treiber und referenziert den aktuellen Produktcode: + +```sh +python3 - <<'PY' +from pathlib import Path +import json, subprocess, tempfile +root = Path.cwd() +p = Path(tempfile.mkdtemp(prefix='tb-review-')) +manifest = '[package]\nname="tb-review"\nversion="0.0.0"\nedition="2021"\n[workspace]\n' +manifest += '[[test]]\nname="befunde"\npath=' + json.dumps(str(root / 'openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/review-probes.rs')) + '\n[dependencies]\n' +for name in ['tb-vm', 'tb-runtime', 'tb-ui', 'tb-frontend']: + manifest += name + ' = { path = ' + json.dumps(str(root / 'crates' / name)) + ' }\n' +(p / 'Cargo.toml').write_text(manifest) +subprocess.run(['cargo', 'test', '--manifest-path', str(p / 'Cargo.toml'), '--test', 'befunde', '--', '--nocapture'], check=True, timeout=120) +PY +``` + +Alle sechs Proben müssen auf dem korrigierten Stand bestehen. Ihre Erwartungen +wurden auch in den regulären Workspace-Testlauf übernommen. diff --git a/openspec/changes/ereigniszustellung-und-hostgrenzen/specs/ereignissteuerung/spec.md b/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/specs/ereignissteuerung/spec.md similarity index 100% rename from openspec/changes/ereigniszustellung-und-hostgrenzen/specs/ereignissteuerung/spec.md rename to openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/specs/ereignissteuerung/spec.md diff --git a/openspec/changes/ereigniszustellung-und-hostgrenzen/specs/forms-steuerelemente/spec.md b/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/specs/forms-steuerelemente/spec.md similarity index 100% rename from openspec/changes/ereigniszustellung-und-hostgrenzen/specs/forms-steuerelemente/spec.md rename to openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/specs/forms-steuerelemente/spec.md diff --git a/openspec/changes/ereigniszustellung-und-hostgrenzen/specs/textbildschirm/spec.md b/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/specs/textbildschirm/spec.md similarity index 100% rename from openspec/changes/ereigniszustellung-und-hostgrenzen/specs/textbildschirm/spec.md rename to openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/specs/textbildschirm/spec.md diff --git a/openspec/changes/ereigniszustellung-und-hostgrenzen/specs/vm-ausfuehrung/spec.md b/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/specs/vm-ausfuehrung/spec.md similarity index 100% rename from openspec/changes/ereigniszustellung-und-hostgrenzen/specs/vm-ausfuehrung/spec.md rename to openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/specs/vm-ausfuehrung/spec.md diff --git a/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/tasks.md b/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/tasks.md new file mode 100644 index 0000000..1287d46 --- /dev/null +++ b/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/tasks.md @@ -0,0 +1,25 @@ +## 1. Hosteingabe und Maskierung + +- [x] 1.1 SIGNAL(2), KEY STOP und BackTab am gemeinsamen Eingang berichtigen; verifizieren durch Tests für beide Signale, alle drei KEY-Zustände mit INKEY$/INPUT und den Terminal-Key-Mapper. +- [x] 1.2 Geordnete Tasten-/Mausannahme implementieren; verifizieren mit K→M, M→K und gemischten Folgen einschließlich ungetrappter und getrappter Tasten. + +## 2. Dispatch und Warten + +- [x] 2.1 Handlerbeginn/Rückkehr und Menüsperre an allen Zustellpunkten vereinheitlichen; verifizieren mit LostFocus→GotFocus, gleichzeitig wartenden Handlern, UEVENT bei offenem Menü, DOEVENTS und den bestehenden Fehler-/Modalitätstests. +- [x] 2.2 Hostgesteuertes Warten und bedarfsgerechte Zeitabfrage implementieren; verifizieren mit einem Host, der unerwartete Uhrabfragen zählt/abweist, eingefrorener und vorrückender Zeit sowie einem Forms-Timer, der SLEEP beendet. +- [x] 2.3 Terminal-Feature in tb-ui abgrenzen und CLI ausdrücklich anschließen; verifizieren durch isolierten terminalfreien VM-Einbetter, Dependency-Graph ohne crossterm/ratatui/signal-hook und normalen CLI-Build. + +## 3. Abnahme + +- [x] 3.1 Gezielte geordnete Ereignisprogramme zum Korpus ergänzen; verifizieren mit cargo test --workspace und Vergleich der bestehenden Snapshots ohne automatisches Neuaufzeichnen. +- [x] 3.2 Zeit-/Zustellverträge und Backend-Feature dokumentieren; verifizieren mit Compile-/VM-Benchmarks und openspec validate ereigniszustellung-und-hostgrenzen --strict. + +Abnahmenachweise: [verification.md](verification.md). + +## 4. Verifikationsbefunde + +- [x] 4.1 V1: Blockierende Konsoleneingabe fortsetzbar machen; Signale, Timer, Teiltexte und Handler-Rückkehr absichern. +- [x] 4.2 V2: Synchrone Load-Wirkungen von wartenden Forms-Ereignissen trennen; Control-Argumente und Geschwisterpfade prüfen. +- [x] 4.3 V3: Nach Menüschluss vor erneutem Warten bereit gewordene Ereignisse zustellen. +- [x] 4.4 V4: Vor EOF angenommene Ereignisse im modalen Lauf vollständig abarbeiten. +- [x] 4.5 V5: Klassische Quellpriorität bei gleichzeitig wartenden Tasten und UEVENT erhalten. diff --git a/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/verification.md b/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/verification.md new file mode 100644 index 0000000..aa64ea7 --- /dev/null +++ b/openspec/changes/archive/2026-09-05-ereigniszustellung-und-hostgrenzen/verification.md @@ -0,0 +1,82 @@ +# Implementierungsnachweis — 05.09.2026 + +Change: `ereigniszustellung-und-hostgrenzen`, Ausgangscommit `57d6386`. +Alle zwölf Implementierungs-/Abnahmetasks einschließlich der fünf +[Verifikationsbefunde](review.md) sind abgeschlossen. Die vier betroffenen +Hauptspezifikationen sind synchronisiert; der Change wurde am 05.09.2026 archiviert. + +## Befunde und Umsetzung + +| Befund / Tasks | Umsetzung | Ausführbarer Nachweis | +| --- | --- | --- | +| F04 / 1.1 | SIGNAL(1) und SIGNAL(2) werden im gemeinsamen RtState-Eingang gemeldet; nur ungetrapptes SIGINT wird Abbruch. Zusätzlich erkennt der Parser SIGNAL(n) ON/OFF/STOP. | VM-Test `beide_signale_werden_zugestellt_und_beginnen_in_quellreihenfolge` einschließlich ungültiger konstanter/dynamischer Kennungen; Runtime-Tests für STOP/OFF beider Quellen und SIGINT ohne Trap. | +| F05 / 1.1 | ON verbraucht die Taste; STOP merkt den Trap einmal und belässt die Eingabe auch nach erneutem ON lesbar. Die Markierung bleibt beim Dialogaufruf erhalten. | VM-Test `key_on_off_stop_gelten_fuer_alle_konsoleneingaben`: drei Zustände × INKEY$, INPUT, LINE INPUT, INPUT$. Runtime-Test mit drei STOP-Tasten; VM-Regression für verbleibende Dialogeingaben. | +| F06 / 1.2, 2.1 | Eine Eingabequeue für Tasten/Maus bis zur Annahme. Forms-Eingaben und ihre Handler werden geordnet verarbeitet; erster Handler-Statement-Eintritt geschützt, gleiche Forms-Prozedur nicht rekursiv zugestellt. | VM-Tests für aM, Ma, aMbTM einschließlich KEY-Trap; Fokusfolge LlGg; explizites DOEVENTS ergibt arbr; UNLOAD im eigenen Handler ergibt ur. | +| F07 / 2.1 | Gemeinsame Zustellung; separate Menüsperre in Traps ohne Änderung der ON/OFF/STOP-Zustände. Bereits wartende Forms-Timer bleiben gesperrt, Menübedienung kann fortfahren. | VM-Menütest über gewöhnliche Zuweisung, DOEVENTS, SLEEP und LINE INPUT: Zähler bleibt 1, nach Esc werden UEVENT und Forms-Timer nachgeholt, Ergebnis 111. | +| F08 / 2.2 | Host besitzt Zeit und Deadline-Warten; keine Instant-/Sleep-/Yield-Rückfallebene in der VM. Auch Forms-SLEEP, modales Warten und sichtbare Forms nach Programmende nutzen den Host. Zeitabfragen nur bei tatsächlichem Zeitbedarf. | Host, der jede unerwartete Uhrabfrage/Warteoperation ablehnt; eingefrorene Uhr über reale 10 ms mit zwei identischen Hostdeadlines; Forms-Timer beendet SLEEP 60 bei Hostzeit 100 ms. Bestehender klassischer SLEEP-/TIMER-Test. | +| F18 / 1.1 | BackTab wird als TAB mit SHIFT normalisiert, auch wenn crossterm kein separates SHIFT-Flag liefert. | Terminal-Mapper-Test `backtab_ist_tab_mit_shift_auch_ohne_modifier`; bestehende Forms-Tests für Rückwärtstab, übersprungene Controls und Access-Keys. | +| F20 / 2.3 | tb-ui ist standardmäßig terminalfrei; `terminal` aktiviert Backendmodule und optionale Dependencies. CLI aktiviert dieses Feature ausdrücklich. | Separater Workspace `tests/terminalfrei`: FRM laden/kompilieren, Forms ausführen, CaptureHost-Ausgabe KM prüfen. Normaler Dependency-Graph: 16 Pakete, keines davon crossterm, ratatui oder signal-hook. CLI-Kompilation und CLI-Prozesstests erfolgreich. | +| 3.1 | Zwei kleine geordnete Ereignisprogramme mit ausdrücklich festgelegten Ausgaben ergänzt. | `eingabe-taste-maus.frm` → KM; `eingabe-maus-taste.frm` → MK. Beide laufen zweimal im bestehenden Korpusharness. Keine bestehende .out-Datei verändert. | +| 3.2 | Zeit-, Zustell-, Eingabequeue- und Backend-Verträge einschließlich Einbetter-Migration dokumentiert. | [VM-Design](../../../../docs/tbvm-design.md#ausführungsmodell--unterbrechbarkeit), Checks und Benchmarks unten. | + +Die neuen VM-Regressionen liegen in +[crates/tb-vm/tests/events.rs](../../../../crates/tb-vm/tests/events.rs). +Bestehende Trap-Rückkehr-, GOSUB-, Fehler-/RESUME-, BYREF- und Modalitätstests +wurden im vollständigen Workspace-Testlauf mit ausgeführt. + +## Behebung der Verifikationsbefunde + +V1–V5 sind geschlossen. Der [aktualisierte Report](review.md) ordnet jede +Korrektur den ursprünglichen Beobachtungen und ihren Regressionen zu. +Die 25 Tests in `events.rs` decken nun auch fortsetzbare Eingabe samt Teiltext, +Ausdrucksoperanden, BYREF, verschachtelten Handlern, STOP/CONT, Timer, +Fehler-Unwinding und RETURN label ab. Objektaufrufe bewahren die Fokusfolge, +Menüschluss gibt Traps sofort frei, beide Formularschleifen arbeiten Eingaben +vor EOF ab und die klassische KEY-/UEVENT-Priorität bleibt erhalten. + +## Ausgeführte Prüfungen + +- `cargo test --workspace`: **419 bestanden, 0 fehlgeschlagen, 1 ignoriert**. + Ignoriert bleibt ausschließlich `erzeuge_sollausgaben`; keine automatische + Neuaufzeichnung von Golden Files. +- Separater Treiber der ursprünglichen `review-probes.rs`: **6 bestanden, 0 fehlgeschlagen**. +- `cargo clippy -p tb-runtime -p tb-ui -p tb-vm -p tb-cli --all-targets --all-features -- -D warnings`: erfolgreich, ohne Warnungen. +- `cargo fmt --all -- --check` und Formatprüfung des separaten Einbetters: erfolgreich. +- `cargo run --manifest-path tests/terminalfrei/Cargo.toml --offline`: erfolgreich, + `Terminalfreier FRM-/VM-Lauf: KM`. +- `cargo tree --manifest-path tests/terminalfrei/Cargo.toml --offline --edges normal --prefix none --format '{p}'`: + mit expliziter Prüfung auf ausgeschlossene Terminalpakete, erfolgreich. +- `openspec validate ereigniszustellung-und-hostgrenzen --strict`: erfolgreich. +- `git diff --check`: erfolgreich. + +## Release-Benchmarks + +`cargo bench -p tb-vm --bench compile --bench vm`, auf diesem Rechner: + +| Last | Ergebnis | +| --- | --- | +| Compile: 508 Zeilen | 0,83 ms, Budget 50 ms eingehalten | +| Compile: 49.760 Zeilen in 20 Modulen | 58 ms, Budget 1.000 ms eingehalten | +| INTEGER: 10 Mio. Iterationen | 1.048 ms, 9,5 Mio. Iterationen/s | +| DOUBLE: 5 Mio. Iterationen | 504 ms, 9,9 Mio. Iterationen/s | +| SUB/BYREF: 1 Mio. Aufrufe | 123 ms, 8,2 Mio. Aufrufe/s | +| String-Funktionen: 200.000 Runden | 99 ms, 2,0 Mio. Runden/s | + +Der Compile-Projektbenchmark übersetzt 20 Module einzeln; er prüft keinen +Mehrmodul-Linker. Die VM-Zahlen sind lokale Durchsatzmessungen, keine +zugesicherte Performance auf anderer Hardware. + +## Synchronisation und Archivierung + +Neun bestehende Requirements in vier Hauptspezifikationen wurden um elf +Szenarien ergänzt; alle bisherigen Szenarien bleiben erhalten. Vollständiger +Abgleich aller Delta-Requirements erfolgreich; `openspec validate --specs +--strict`: 15 bestanden, 0 fehlgeschlagen. + +## Grenzen + +Terminaleingaben wurden über den Mapper und vorbereitete Hostereignisse +geprüft. Kein interaktiver Terminal-/Fremdprogrammvergleich und keine +Windows-/Linux-Ausführung. Der neue Vertrag verlangt von Einbettern eine +Implementierung von Host::warten; CaptureHost modelliert eine endliche +Ereignisquelle und explizites virtuelles Fortschreiten bis zur Deadline. diff --git a/openspec/changes/ereigniszustellung-und-hostgrenzen/tasks.md b/openspec/changes/ereigniszustellung-und-hostgrenzen/tasks.md deleted file mode 100644 index 054ffda..0000000 --- a/openspec/changes/ereigniszustellung-und-hostgrenzen/tasks.md +++ /dev/null @@ -1,15 +0,0 @@ -## 1. Hosteingabe und Maskierung - -- [ ] 1.1 SIGNAL(2), KEY STOP und BackTab am gemeinsamen Eingang berichtigen; verifizieren durch Tests für beide Signale, alle drei KEY-Zustände mit INKEY$/INPUT und den Terminal-Key-Mapper. -- [ ] 1.2 Geordnete Tasten-/Mausannahme implementieren; verifizieren mit K→M, M→K und gemischten Folgen einschließlich ungetrappter und getrappter Tasten. - -## 2. Dispatch und Warten - -- [ ] 2.1 Handlerbeginn/Rückkehr und Menüsperre an allen Zustellpunkten vereinheitlichen; verifizieren mit LostFocus→GotFocus, gleichzeitig wartenden Handlern, UEVENT bei offenem Menü, DOEVENTS und den bestehenden Fehler-/Modalitätstests. -- [ ] 2.2 Hostgesteuertes Warten und bedarfsgerechte Zeitabfrage implementieren; verifizieren mit einem Host, der unerwartete Uhrabfragen zählt/abweist, eingefrorener und vorrückender Zeit sowie einem Forms-Timer, der SLEEP beendet. -- [ ] 2.3 Terminal-Feature in tb-ui abgrenzen und CLI ausdrücklich anschließen; verifizieren durch isolierten terminalfreien VM-Einbetter, Dependency-Graph ohne crossterm/ratatui/signal-hook und normalen CLI-Build. - -## 3. Abnahme - -- [ ] 3.1 Gezielte geordnete Ereignisprogramme zum Korpus ergänzen; verifizieren mit cargo test --workspace und Vergleich der bestehenden Snapshots ohne automatisches Neuaufzeichnen. -- [ ] 3.2 Zeit-/Zustellverträge und Backend-Feature dokumentieren; verifizieren mit Compile-/VM-Benchmarks und openspec validate ereigniszustellung-und-hostgrenzen --strict. diff --git a/openspec/specs/ereignissteuerung/spec.md b/openspec/specs/ereignissteuerung/spec.md index 13f412d..3caf8e6 100644 --- a/openspec/specs/ereignissteuerung/spec.md +++ b/openspec/specs/ereignissteuerung/spec.md @@ -21,6 +21,8 @@ Systemuhr zugreifen. Ein Host ohne Terminal MUST die Zeit frei stellen können, sodass zeitgesteuerte Programme ohne Wartezeit und mit reproduzierbarem Ergebnis prüfbar sind. +Auch Wartefristen der VM SHALL ausschließlich von der Hostzeit abhängen. Ein Host mit stehender virtueller Zeit MUST NOT durch reale verstrichene Zeit eine Frist scheinbar erreichen. + #### Scenario: Zeitgesteuerter Trap ohne echte Wartezeit - **WHEN** ein Testhost die Zeit um 5 Sekunden vorstellt und ein Programm `ON TIMER(5) GOSUB Marke` mit `TIMER ON` aktiv hat - **THEN** wird der Trap zugestellt, ohne dass der Test tatsächlich wartet @@ -29,6 +31,10 @@ reproduzierbarem Ergebnis prüfbar sind. - **WHEN** dasselbe zeitgesteuerte Korpusprogramm zweimal mit demselben Zeitverlauf ausgeführt wird - **THEN** ist die Ausgabe beide Male identisch +#### Scenario: Stehende virtuelle Zeit +- **WHEN** ein Host während SLEEP seine Zeit unverändert hält +- **THEN** endet SLEEP nicht wegen realer Zeit; eine vom Host gemeldete Frist oder ein zugestelltes Ereignis beendet das Warten + ### Requirement: Mausereignisse in Zellenkoordinaten Die Darstellungsschicht SHALL Mausereignisse der Ausführungsumgebung an die Laufzeit weitergeben: Drücken, Loslassen und Bewegung, jeweils mit @@ -40,6 +46,8 @@ SHALL der Reihenfolge ihres Eintreffens entsprechen. Solange kein Verbraucher ein Mausereignis annimmt, MUST es am Zustellpunkt verworfen werden, damit die Warteschlange nicht unbegrenzt wächst. +Die Reihenfolge SHALL bis zur beobachtbaren Ausführung der zugehörigen Handler erhalten bleiben; getrennte Quellarten MUST NOT nachträglich nach ihrer Art umgeordnet werden. + #### Scenario: Position als Zelle - **WHEN** in der linken oberen Ecke der Darstellungsfläche die linke Maustaste gedrückt wird - **THEN** trägt das Ereignis Zeile 1 und Spalte 1 @@ -56,6 +64,14 @@ werden, damit die Warteschlange nicht unbegrenzt wächst. - **WHEN** ein Programm ohne Verbraucher für Mausereignisse läuft und die Maus dauerhaft bewegt wird - **THEN** wächst die Warteschlange nicht über ihre Grenze und das Programm läuft unverändert weiter +#### Scenario: Taste vor Maus bis zum Handler +- **WHEN** eine Taste vor einem Mausklick eintrifft und beide Handler eine Kennung anhängen +- **THEN** steht die Tastenkennung vor der Mauskennung + +#### Scenario: Maus vor Taste bis zum Handler +- **WHEN** ein Mausklick vor einer Taste eintrifft +- **THEN** wird zuerst der Maushandler wirksam + ### Requirement: Abschließende Liste der Zustellpunkte Ereignisse SHALL ausschließlich an folgenden Punkten zugestellt werden — das Vorbild prüft die Ereignismarke „before executing the next @@ -66,6 +82,8 @@ Trap-Handler beginnen; insbesondere MUST NOT eine Anweisung in ihrer Mitte unterbrochen werden. Ein zugestelltes Ereignis SHALL ein laufendes `SLEEP` beenden. +Forms-Ereignisse SHALL auch während SLEEP an dessen bestehenden Zustellpunkten verarbeitet werden. Eine für das Menü geltende Zustellsperre SHALL an jedem Zustellpunkt gleichermaßen gelten. + #### Scenario: Keine Zustellung innerhalb einer Anweisung - **WHEN** ein Ereignis fällig wird, während eine mehrteilige Anweisung ausgewertet wird - **THEN** läuft die Anweisung zu Ende und der Handler beginnt erst an der folgenden Anweisungsgrenze @@ -78,6 +96,10 @@ Mitte unterbrochen werden. Ein zugestelltes Ereignis SHALL ein laufendes - **WHEN** ein anstehendes Ereignis vorliegt und `DOEVENTS` ausgewertet wird - **THEN** läuft der zugehörige Handler, bevor `DOEVENTS` einen Wert liefert +#### Scenario: Forms-Timer beendet SLEEP +- **WHEN** während SLEEP ein aktiver Forms-Timer fällig wird +- **THEN** läuft dessen Handler und SLEEP kehrt danach zurück + ### Requirement: Maskierung je Quelle mit drei Zuständen Jede Ereignisquelle SHALL drei Zustände kennen. `ON` stellt Ereignisse zu. `OFF` verwirft sie; ein während `OFF` eingetretenes Ereignis MUST @@ -146,6 +168,8 @@ zugestellt werden und MUST NOT zusätzlich über `INKEY$`, `INPUT`, `LINE INPUT` oder `INPUT$` sichtbar werden. Bei `OFF` oder `STOP` der Quelle SHALL die Taste dem normalen Eingabestrom erhalten bleiben. +Unter STOP SHALL die Taste normal lesbar bleiben und zugleich höchstens ein Ereignis für das spätere ON gemerkt werden. + #### Scenario: Getrappte Taste erscheint nicht bei INKEY$ - **WHEN** `ON KEY(1) GOSUB Marke` mit `KEY(1) ON` gilt und F1 gedrückt wird - **THEN** läuft der Handler und ein anschließendes `INKEY$` liefert den leeren String @@ -154,6 +178,10 @@ Quelle SHALL die Taste dem normalen Eingabestrom erhalten bleiben. - **WHEN** derselbe Trap mit `KEY(1) OFF` gilt und F1 gedrückt wird - **THEN** liefert `INKEY$` die Sondertastenfolge für F1 +#### Scenario: STOP merkt und erhält Eingabe +- **WHEN** unter KEY(1) STOP F1 gedrückt, per INKEY$ gelesen und danach KEY(1) ON ausgeführt wird +- **THEN** liefert INKEY$ die Sondertastenfolge und der Handler läuft nach ON genau einmal + ### Requirement: Benutzerdefiniertes Ereignis `SetUEvent` SHALL ein benutzerdefiniertes Ereignis auslösen, das über `ON UEVENT GOSUB` und die Steueranweisung `UEVENT` denselben Regeln für @@ -208,3 +236,7 @@ ausgewiesen sein. #### Scenario: Signal ohne Trap bleibt Abbruch - **WHEN** ein `SIGINT` eintrifft, ohne dass ein `SIGNAL(1)`-Trap aktiv ist - **THEN** bleibt es beim bisherigen Abbruchverhalten + +#### Scenario: SIGTERM wird zugestellt +- **WHEN** SIGNAL(2) ON gilt und ein SIGTERM über den Host eintrifft +- **THEN** wird das Ziel von ON SIGNAL(2) GOSUB am nächsten erlaubten Zustellpunkt ausgeführt diff --git a/openspec/specs/forms-steuerelemente/spec.md b/openspec/specs/forms-steuerelemente/spec.md index 9baa3f6..24b2223 100644 --- a/openspec/specs/forms-steuerelemente/spec.md +++ b/openspec/specs/forms-steuerelemente/spec.md @@ -36,6 +36,8 @@ auslöst oder ihm den Fokus gibt. Enter SHALL die `Default`-Schaltfläche auslösen, Esc die `Cancel`-Schaltfläche. Fokuswechsel MUST `LostFocus` am alten und `GotFocus` am neuen Element auslösen, in dieser Reihenfolge. +Die über das Terminal gelieferte Rückwärtstab-Taste SHALL denselben Fokuswechsel wie Tab mit Umschalt auslösen. Die Reihenfolge LostFocus vor GotFocus SHALL auch an den Wirkungen der BASIC-Handler sichtbar bleiben. + #### Scenario: Tab überspringt - **WHEN** das mittlere von drei Elementen `TabStop = 0` trägt und Tab gedrückt wird - **THEN** erhält das dritte Element den Fokus @@ -48,6 +50,14 @@ am alten und `GotFocus` am neuen Element auslösen, in dieser Reihenfolge. - **WHEN** der Fokus von `Text1` auf `Text2` wechselt - **THEN** läuft erst `Text1_LostFocus`, danach `Text2_GotFocus` +#### Scenario: Handlerwirkungen in Fokusreihenfolge +- **WHEN** LostFocus den Text L und GotFocus den Text G an dieselbe Variable anhängen +- **THEN** lautet das Ergebnis LG + +#### Scenario: Rückwärtstab vom Terminal +- **WHEN** das Terminal eine Rückwärtstab-Taste liefert +- **THEN** wechselt der Fokus zum vorherigen zulässigen TabIndex + ### Requirement: Maussteuerung mit Trefferprüfung Ein Mausereignis SHALL dem obersten Steuerelement an seiner Position zugestellt werden; liegt dort keines, dem Formular. Klick, Doppelklick @@ -116,6 +126,10 @@ fortgesetzt werden. - **WHEN** ein Menü geöffnet ist und ein Zeit-Trap fällig wird - **THEN** läuft sein Handler erst, nachdem das Menü geschlossen wurde +#### Scenario: UEVENT an gewöhnlicher Anweisungsgrenze +- **WHEN** ein Menü offen ist und UEVENT vor einer Zuweisung ansteht +- **THEN** läuft dessen Handler erst nach dem Schließen des Menüs + ### Requirement: Vordefinierte Dialoge `MSGBOX text$ [, typ% [, titel$]]` SHALL als Anweisung und als Funktion verfügbar sein; die Funktion SHALL die gedrückte Schaltfläche als diff --git a/openspec/specs/textbildschirm/spec.md b/openspec/specs/textbildschirm/spec.md index 8596b20..f3e210f 100644 --- a/openspec/specs/textbildschirm/spec.md +++ b/openspec/specs/textbildschirm/spec.md @@ -98,10 +98,16 @@ Darstellungsschicht liegen. Damit MUST jedes Bildschirmverhalten in automatischen Tests ohne Terminal prüfbar sein, und die Ausführungsschicht MUST NOT von einer Terminal-Bibliothek abhängen. +Ein separater Einbetter SHALL die VM einschließlich Forms ohne Terminalbackend bauen und ausführen können. Der CLI-Runner SHALL das Backend ausdrücklich zuschalten. + #### Scenario: Bildschirmverhalten im Test ohne Terminal - **WHEN** ein Testprogramm in einer Umgebung ohne Terminal `LOCATE 5, 10 : PRINT "x"` ausführt - **THEN** trägt die Zelle (5, 10) das Zeichen `x` und der Test benötigt kein Terminal +#### Scenario: Terminalfreier Einbetter +- **WHEN** ein separates Programm ausschließlich die VM mit einem Capture-Host einbindet +- **THEN** enthält sein aufgelöster Abhängigkeitsbaum keine Terminalbibliothek und die Forms-Tests können darin laufen + ### Requirement: Bildschirmanweisungen des Dialekts `CLS`, `COLOR`, `LOCATE`, `WIDTH`, `VIEW PRINT` und die Anweisungsform von `SCREEN` SHALL auf dem Zellenpuffer wirken. `CLS` SHALL den Scrollbereich diff --git a/openspec/specs/vm-ausfuehrung/spec.md b/openspec/specs/vm-ausfuehrung/spec.md index 70d3efe..d23e669 100644 --- a/openspec/specs/vm-ausfuehrung/spec.md +++ b/openspec/specs/vm-ausfuehrung/spec.md @@ -124,6 +124,8 @@ ausführen. Der unterbrochene Zustand — Aufrufkette, Schleifen, Locals, Die Prüfung MUST NOT pro Opcode erfolgen; solange kein Trap definiert ist, MUST sie auf einen einzelnen Test hinauslaufen und MUST NOT die Uhr lesen. +Ohne klassische Traps, aktive Forms-Timer oder zeitabhängige Mausauswertung SHALL auch die Forms-Integration keine Hostzeit abfragen. + #### Scenario: Trap aus einer Prozedur heraus - **WHEN** ein Ereignis zugestellt wird, während eine `SUB` läuft, und das Trap-Ziel im Modulrumpf liegt - **THEN** läuft der Handler und nach `RETURN` setzt die `SUB` an der unterbrochenen Anweisung fort @@ -132,6 +134,10 @@ MUST sie auf einen einzelnen Test hinauslaufen und MUST NOT die Uhr lesen. - **WHEN** ein Programm ohne jede `ON …`-Trap-Definition ausgeführt wird - **THEN** fragt die Ausführung an keiner Anweisungsgrenze die Zeit ab, und der Durchsatz bleibt im Rahmen der Messgenauigkeit unverändert +#### Scenario: Einfaches Programm liest keine Ereigniszeit +- **WHEN** ein Programm nur Zuweisungen und END ausführt und ein Testhost Zeitabfragen zählt +- **THEN** ist der Zähler am Ende 0 + ### Requirement: Jede Schleife trägt eine Anweisungsgrenze im Kreis Der zurückspringende Zweig jeder Schleife (`FOR`/`NEXT`, `WHILE`/`WEND`, `DO`/`LOOP` in allen Bedingungsformen) MUST über mindestens eine diff --git a/tests/compat/eingabe-maus-taste.frm b/tests/compat/eingabe-maus-taste.frm new file mode 100644 index 0000000..8b4313f --- /dev/null +++ b/tests/compat/eingabe-maus-taste.frm @@ -0,0 +1,32 @@ +VERSION 1.00 +Begin Form Form1 + Width = 40 + Height = 12 + Begin TextBox Text1 + Left = 5 + Top = 5 + Width = 10 + Height = 1 + End +End + +' tb-screen: 80x25 +' tb-events: time:0ms | mouse:down,1,0,2,2 | key:K +' Erwartete Handlerwirkungen in Hostreihenfolge; keine Snapshot-Neuaufnahme. +Text1.SetFocus +DIM SHARED s$ +DOEVENTS +DOEVENTS +DOEVENTS +Form1.Hide +CLS +PRINT s$ +END +SUB Text1_KeyPress(KeyAscii AS INTEGER) +SHARED s$ +s$ = s$ + CHR$(KeyAscii) +END SUB +SUB Form_MouseDown(Button AS INTEGER, Shift AS INTEGER, X AS SINGLE, Y AS SINGLE) +SHARED s$ +s$ = s$ + "M" +END SUB diff --git a/tests/compat/eingabe-maus-taste.out b/tests/compat/eingabe-maus-taste.out new file mode 100644 index 0000000..a3f21f3 --- /dev/null +++ b/tests/compat/eingabe-maus-taste.out @@ -0,0 +1 @@ +MK diff --git a/tests/compat/eingabe-taste-maus.frm b/tests/compat/eingabe-taste-maus.frm new file mode 100644 index 0000000..b6c1013 --- /dev/null +++ b/tests/compat/eingabe-taste-maus.frm @@ -0,0 +1,32 @@ +VERSION 1.00 +Begin Form Form1 + Width = 40 + Height = 12 + Begin TextBox Text1 + Left = 5 + Top = 5 + Width = 10 + Height = 1 + End +End + +' tb-screen: 80x25 +' tb-events: time:0ms | key:K | mouse:down,1,0,2,2 +' Erwartete Handlerwirkungen in Hostreihenfolge; keine Snapshot-Neuaufnahme. +Text1.SetFocus +DIM SHARED s$ +DOEVENTS +DOEVENTS +DOEVENTS +Form1.Hide +CLS +PRINT s$ +END +SUB Text1_KeyPress(KeyAscii AS INTEGER) +SHARED s$ +s$ = s$ + CHR$(KeyAscii) +END SUB +SUB Form_MouseDown(Button AS INTEGER, Shift AS INTEGER, X AS SINGLE, Y AS SINGLE) +SHARED s$ +s$ = s$ + "M" +END SUB diff --git a/tests/compat/eingabe-taste-maus.out b/tests/compat/eingabe-taste-maus.out new file mode 100644 index 0000000..11f8a92 --- /dev/null +++ b/tests/compat/eingabe-taste-maus.out @@ -0,0 +1 @@ +KM diff --git a/tests/terminalfrei/.gitignore b/tests/terminalfrei/.gitignore new file mode 100644 index 0000000..4fffb2f --- /dev/null +++ b/tests/terminalfrei/.gitignore @@ -0,0 +1,2 @@ +/target +/Cargo.lock diff --git a/tests/terminalfrei/Cargo.toml b/tests/terminalfrei/Cargo.toml new file mode 100644 index 0000000..59e98e9 --- /dev/null +++ b/tests/terminalfrei/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "tb-terminalfrei-check" +version = "0.0.0" +edition = "2021" +publish = false + +# Eigener Resolver: keine Feature-Unifikation mit CLI/IDE im Hauptworkspace. +[workspace] + +[dependencies] +tb-vm = { path = "../../crates/tb-vm" } +tb-runtime = { path = "../../crates/tb-runtime" } +tb-ui = { path = "../../crates/tb-ui" } diff --git a/tests/terminalfrei/src/main.rs b/tests/terminalfrei/src/main.rs new file mode 100644 index 0000000..96f684f --- /dev/null +++ b/tests/terminalfrei/src/main.rs @@ -0,0 +1,29 @@ +use tb_runtime::host::{CaptureHost, Ereignis, MausArt, MausEreignis}; +use tb_vm::interp::{RunEvent, Vm}; + +fn main() { + let form = tb_ui::frm::read_text( + "eingabe-taste-maus.frm", + include_str!("../../compat/eingabe-taste-maus.frm"), + ) + .unwrap(); + let module = tb_vm::compile_source_with_forms("FORM1", &form.code, &form.catalog()).unwrap(); + let mut vm = Vm::new(module); + form.apply(&mut vm.forms).unwrap(); + vm.forms.show(0, false).unwrap(); + let mut host = CaptureHost::default(); + host.ereignis_nach(2, Ereignis::Taste("K".into(), 0)); + host.ereignis_nach( + 2, + Ereignis::Maus(MausEreignis { + art: MausArt::Druck, + taste: 1, + shift: 0, + zeile: 2, + spalte: 2, + }), + ); + assert_eq!(vm.run(&mut host), RunEvent::Ended); + assert_eq!(tb_runtime::snapshot::text(&vm.rt.screen), "KM\n"); + println!("Terminalfreier FRM-/VM-Lauf: KM"); +}