Phase 4: Steuerelemente implementieren

This commit is contained in:
2026-09-05 12:49:46 +02:00
parent 1cb6ae8fd9
commit 19804e0e2d
36 changed files with 6472 additions and 689 deletions

View File

@@ -1,15 +1,16 @@
//! `tbc` — Standalone-Compiler von Terminal Basic.
//!
//! Unterbefehle (Phase 2):
//! - `tbc run <datei.bas>` Kompilieren und sofort ausführen
//! - `tbc build <datei.bas>` Kompilieren zu `datei.tbc`
//! - `tbc check <datei.bas>` Nur Syntax-/Semantikprüfung
//! - `tbc run <datei.bas|datei.frm|projekt.mak>` Kompilieren und ausführen
//! - `tbc build <datei.bas|datei.frm|projekt.mak>` Zu `.tbc` kompilieren
//! - `tbc check <datei.bas|datei.frm|projekt.mak>` Syntax/Semantik prüfen
//! - `tbc convert-frm <quelle.frm> <ziel.frm>` Binärformular in Text wandeln
//!
//! Exit-Codes von `run` (Entscheidung D6, docs/tbvm-design.md):
//! 0 = END/SYSTEM/Programmende · 3 = STOP · 2 = Laufzeitfehler ·
//! 1 = Compile-Fehler/Bedienfehler.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use tb_runtime::host::{Ereignis, Host};
@@ -25,7 +26,7 @@ fn main() -> ExitCode {
Some("convert-frm") => cmd_convert_frm(&args[1..]),
_ => {
eprintln!(
"Aufruf: tbc run|build|check <datei.bas> | tbc convert-frm <quelle.frm> <ziel.frm>"
"Aufruf: tbc run|build|check <datei.bas|datei.frm|projekt.mak> | tbc convert-frm <quelle.frm> <ziel.frm>"
);
ExitCode::from(1)
}
@@ -81,23 +82,230 @@ fn module_name(path: &Path) -> String {
.unwrap_or_else(|| "MODUL".into())
}
fn relative_case_insensitive(base: &Path, relative: &str) -> std::io::Result<PathBuf> {
let direct = base.join(relative);
if direct.exists() {
return Ok(direct);
}
let wanted = Path::new(relative);
let mut current = base.to_path_buf();
for component in wanted.components() {
use std::path::Component;
match component {
Component::CurDir => {}
Component::ParentDir => {
current.pop();
}
Component::Normal(name) => {
let entry = std::fs::read_dir(&current)?.find_map(|entry| {
let entry = entry.ok()?;
entry
.file_name()
.to_string_lossy()
.eq_ignore_ascii_case(&name.to_string_lossy())
.then(|| entry.path())
});
current = entry.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("{} nicht gefunden", current.join(name).display()),
)
})?;
}
Component::RootDir | Component::Prefix(_) => current.push(component.as_os_str()),
}
}
Ok(current)
}
fn include_name(line: &str) -> Option<String> {
let trimmed = line.trim();
let upper = trimmed.to_ascii_uppercase();
let at = upper.find("$INCLUDE")?;
let rest = trimmed[at + "$INCLUDE".len()..].trim_start();
let rest = rest.strip_prefix(':')?.trim_start();
let rest = rest.strip_prefix('\'')?;
Some(rest.split('\'').next().unwrap_or_default().to_string())
}
fn expand_includes(path: &Path, source: &str, stack: &mut Vec<PathBuf>) -> Result<String, String> {
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if stack.contains(&canonical) {
return Err(format!("{}: zyklisches $INCLUDE", path.display()));
}
stack.push(canonical);
let mut out = String::new();
for line in source.split_inclusive('\n') {
if let Some(name) = include_name(line) {
if name.is_empty() {
return Err(format!("{}: leeres $INCLUDE", path.display()));
}
let included =
relative_case_insensitive(path.parent().unwrap_or(Path::new(".")), &name)
.map_err(|error| format!("{}: {error}", path.display()))?;
let text = std::fs::read_to_string(&included)
.map_err(|error| format!("{}: {error}", included.display()))?;
out.push_str(&expand_includes(&included, &text, stack)?);
if !out.ends_with('\n') {
out.push('\n');
}
} else {
out.push_str(line);
}
}
stack.pop();
Ok(out)
}
fn read_form(path: &Path) -> Result<tb_ui::frm::FormFile, String> {
let bytes = std::fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?;
if bytes.starts_with(b"VERSION ") || bytes.starts_with(b"Version ") {
let source = std::str::from_utf8(&bytes)
.map_err(|_| format!("{}: ungültige Textkodierung", path.display()))?;
let mut form = tb_ui::frm::read_text(&path.display().to_string(), source)
.map_err(|error| error.to_string())?;
form.code = expand_includes(path, &form.code, &mut Vec::new())?;
Ok(form)
} else {
tb_ui::frm::read_binary(&path.display().to_string(), &bytes)
.map(|read| read.form)
.map_err(|error| error.to_string())
}
}
#[derive(Default)]
struct ProjectSymbols {
constants: HashSet<String>,
types: HashSet<String>,
}
fn append_project_source(target: &mut String, source: &str, symbols: &mut ProjectSymbols) {
let mut in_proc = false;
let mut skip_type = false;
for line in source.split_inclusive('\n') {
let upper = line.trim().to_ascii_uppercase();
if skip_type {
if upper == "END TYPE" {
skip_type = false;
}
continue;
}
if !in_proc {
if let Some(name) = upper
.strip_prefix("TYPE ")
.and_then(|rest| rest.split_whitespace().next())
{
if !symbols.types.insert(name.to_string()) {
skip_type = true;
continue;
}
}
if let Some(rest) = upper.strip_prefix("CONST ") {
let names = rest
.split(',')
.filter_map(|part| part.split('=').next())
.map(str::trim)
.filter(|name| !name.is_empty())
.collect::<Vec<_>>();
if !names.is_empty() && names.iter().all(|name| symbols.constants.contains(*name)) {
continue;
}
symbols
.constants
.extend(names.into_iter().map(str::to_string));
}
}
target.push_str(line);
if upper.starts_with("SUB ")
|| upper.starts_with("FUNCTION ")
|| upper.starts_with("STATIC SUB ")
|| upper.starts_with("STATIC FUNCTION ")
{
in_proc = true;
} else if matches!(upper.as_str(), "END SUB" | "END FUNCTION") {
in_proc = false;
}
}
if !target.ends_with('\n') {
target.push('\n');
}
}
fn input_sources(path: &Path) -> Result<(String, Vec<tb_ui::frm::FormFile>), String> {
let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
let paths = if extension.eq_ignore_ascii_case("mak") {
let project = std::fs::read_to_string(path)
.map_err(|error| format!("{}: {error}", path.display()))?;
let base = path.parent().unwrap_or(Path::new("."));
project
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| {
relative_case_insensitive(base, line)
.map_err(|error| format!("{}: {error}", path.display()))
})
.collect::<Result<Vec<_>, _>>()?
} else {
vec![path.to_path_buf()]
};
let mut source = String::new();
let mut forms = Vec::new();
let mut symbols = ProjectSymbols::default();
for member in paths {
if member
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("frm"))
{
let form = read_form(&member)?;
append_project_source(&mut source, &form.code, &mut symbols);
forms.push(form);
} else {
let text = std::fs::read_to_string(&member)
.map_err(|error| format!("{}: {error}", member.display()))?;
let expanded = expand_includes(&member, &text, &mut Vec::new())?;
append_project_source(&mut source, &expanded, &mut symbols);
}
}
Ok((source, forms))
}
fn compile(
path_arg: Option<&String>,
) -> Result<(PathBuf, tb_vm::bytecode::CompiledModule), ExitCode> {
) -> Result<
(
PathBuf,
tb_vm::bytecode::CompiledModule,
Vec<tb_ui::frm::FormFile>,
),
ExitCode,
> {
let Some(path) = path_arg else {
eprintln!("Aufruf: tbc run|build|check <datei.bas>");
eprintln!("Aufruf: tbc run|build|check <datei.bas|datei.frm|projekt.mak>");
return Err(ExitCode::from(1));
};
let path = PathBuf::from(path);
let source = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
eprintln!("{}: {e}", path.display());
let (source, forms) = match input_sources(&path) {
Ok(input) => input,
Err(error) => {
eprintln!("{error}");
return Err(ExitCode::from(1));
}
};
match tb_vm::compile_source(&module_name(&path), &source) {
Ok(m) => Ok((path, m)),
let compiled = if forms.is_empty() {
tb_vm::compile_source(&module_name(&path), &source)
} else {
let mut catalog = tb_frontend::forms::FormCatalog::default();
for form in &forms {
for object in form.catalog().objects {
catalog.objects.push(object);
}
}
tb_vm::compile_source_with_forms(&forms[0].root.name, &source, &catalog)
};
match compiled {
Ok(m) => Ok((path, m, forms)),
Err(diags) => {
for d in &diags {
eprintln!("{}:{d}", path.display());
@@ -116,7 +324,7 @@ fn cmd_check(args: &[String]) -> ExitCode {
}
fn cmd_build(args: &[String]) -> ExitCode {
let (path, module) = match compile(args.first()) {
let (path, module, _) = match compile(args.first()) {
Ok(x) => x,
Err(code) => return code,
};
@@ -134,36 +342,20 @@ fn cmd_build(args: &[String]) -> ExitCode {
}
fn cmd_run(args: &[String]) -> ExitCode {
let (_path, module) = match compile(args.first()) {
Ok(x) => x,
Err(code) => return code,
};
let mut vm = Vm::new(module);
vm.rt.command = args[1..].join(" ");
// Im Rohmodus ist Strg+C kein Signal mehr — der Abbruch kommt als
// Ereignis und muss den Lauf beenden können.
vm.set_poll_interrupt(true);
// Der Rückfall auf UTC wird einmal beim Start gemeldet, nicht je
// Zeitabfrage — ein stiller Wechsel wäre ein Verstoß gegen den
// Guiding Principle.
if vm.rt.zeitzone == tb_runtime::datetime::Zeitzone::Unbekannt {
eprintln!("Zeitzone nicht ermittelbar — Zeitfunktionen rechnen in UTC.");
}
// Ohne Terminal (Pipe, Skript, CI) läuft das Programm im PipeHost:
// Eingabe zeilenweise von stdin, Ausgabe am Ende als Snapshot.
let ereignis = match TerminalHost::new() {
let result = match TerminalHost::new() {
Ok(mut host) => {
// Der Bildschirm folgt der Terminalgröße von Beginn an (80×25 ist
// stets nur die untere Schranke, nie eine feste Größe).
if let Ok((cols, rows)) = host.groesse() {
vm.rt.screen.resize(cols, rows);
}
let e = vm.run(&mut host);
let size = host.groesse().ok();
let result = run_chain(args, &mut host, size);
drop(host); // Alternativschirm verlassen, bevor gedruckt wird
e
result
}
Err(_) => vm.run(&mut PipeHost::new()),
Err(_) => run_chain(args, &mut PipeHost::new(), None),
};
let (ereignis, vm) = match result {
Ok(result) => result,
Err(code) => return code,
};
print!("{}", tb_runtime::snapshot::text(&vm.rt.screen));
// `LPRINT` sammelt im Druckerpuffer; am Programmende geht er in die
@@ -197,6 +389,90 @@ fn cmd_run(args: &[String]) -> ExitCode {
}
// Ohne Debugger-Flags treten diese Ereignisse nicht auf.
RunEvent::Breakpoint { .. } | RunEvent::Stepped { .. } => ExitCode::from(2),
RunEvent::Restart { .. } => unreachable!("RUN wird vom Runner aufgelöst"),
}
}
fn run_target(current: &Path, program: &str) -> Result<PathBuf, String> {
let base = current.parent().unwrap_or(Path::new("."));
if Path::new(program).extension().is_some() {
return relative_case_insensitive(base, program).map_err(|error| error.to_string());
}
for extension in ["bas", "frm", "mak"] {
let candidate = format!("{program}.{extension}");
if let Ok(path) = relative_case_insensitive(base, &candidate) {
return Ok(path);
}
}
Err(format!("{program}: Programm nicht gefunden"))
}
fn run_chain(
args: &[String],
host: &mut dyn Host,
size: Option<(usize, usize)>,
) -> Result<(RunEvent, Vm), ExitCode> {
let Some(first) = args.first() else {
eprintln!("Aufruf: tbc run <datei.bas|datei.frm|projekt.mak>");
return Err(ExitCode::from(1));
};
let mut current = PathBuf::from(first);
let mut start_line = None;
let command = args[1..].join(" ");
loop {
let current_arg = current.display().to_string();
let (path, module, forms) = compile(Some(&current_arg))?;
let mut vm = Vm::new(module);
if !forms.is_empty() {
let applied = forms.iter().try_for_each(|form| form.apply(&mut vm.forms));
let first = vm
.forms
.objects
.iter()
.position(|object| {
object
.description
.name
.eq_ignore_ascii_case(&forms[0].root.name)
})
.unwrap() as u16;
if let Err(error) = applied.and_then(|_| vm.forms.show(first, false).map(|_| ())) {
eprintln!("Runtime error {}: {}", error.0, error);
return Err(ExitCode::from(2));
}
}
if let Some(line) = start_line.take() {
if let Err(error) = vm.start_at_line(line) {
eprintln!("Runtime error {}: {}", error.0, error);
return Err(ExitCode::from(2));
}
}
if let Some((cols, rows)) = size {
vm.rt.screen.resize(cols, rows);
}
vm.rt.command.clone_from(&command);
vm.set_poll_interrupt(true);
if vm.rt.zeitzone == tb_runtime::datetime::Zeitzone::Unbekannt {
eprintln!("Zeitzone nicht ermittelbar — Zeitfunktionen rechnen in UTC.");
}
let event = vm.run(host);
let event = if event == RunEvent::Ended && vm.forms.has_visible_forms() {
vm.run_visible_forms(host)
} else {
event
};
match event {
RunEvent::Restart { program, line } => {
if let Some(program) = program {
current = run_target(&path, &program).map_err(|error| {
eprintln!("{error}");
ExitCode::from(1)
})?;
}
start_line = line;
}
event => return Ok((event, vm)),
}
}
}
@@ -211,11 +487,30 @@ struct PipeHost {
impl PipeHost {
fn new() -> Self {
PipeHost {
let mut host = PipeHost {
puffer: std::collections::VecDeque::new(),
eof: false,
start: std::time::Instant::now(),
};
use std::io::{IsTerminal, Read};
let mut stdin = std::io::stdin();
if !stdin.is_terminal() {
let mut input = String::new();
let _ = stdin.read_to_string(&mut input);
for line in input.split_inclusive('\n') {
for character in line.trim_end_matches(['\r', '\n']).chars() {
host.puffer
.push_back(Ereignis::Taste(character.to_string(), 0));
}
host.puffer.push_back(Ereignis::Taste(
tb_runtime::host::taste::ENTER.to_string(),
0,
));
}
host.puffer.push_back(Ereignis::Ende);
host.eof = true;
}
host
}
/// Eine Zeile von stdin in Tastendrücke zerlegen.

View File

@@ -13,9 +13,10 @@
//! nur die untere Schranke, nie eine feste Größe, und der Korpus muss das
//! nachweisen können.
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::process::Command;
use tb_runtime::host::CaptureHost;
use tb_runtime::host::{CaptureHost, Host};
use tb_vm::interp::{RunEvent, Vm};
fn compat_dir() -> PathBuf {
@@ -100,10 +101,14 @@ fn resize_direktive(name: &str, src: &str) -> Option<(usize, (usize, usize))> {
/// `<F1>`…`<F12>`, sonst je ein Zeichen; `+` davor bedeutet Umschalt,
/// `^` Strg, `%` Alt — gebraucht für die benutzerdefinierten Trap-Tasten.
fn tasten_direktive(name: &str, src: &str) -> Vec<tb_runtime::host::Ereignis> {
use tb_runtime::host::{taste, umschalt, Ereignis};
let Some(wert) = direktive(src, "tb-keys:") else {
return Vec::new();
};
tastenfolge(name, &wert)
}
fn tastenfolge(name: &str, wert: &str) -> Vec<tb_runtime::host::Ereignis> {
use tb_runtime::host::{taste, umschalt, Ereignis};
let mut out = Vec::new();
let zeichen: Vec<char> = wert.chars().collect();
let mut i = 0;
@@ -143,6 +148,21 @@ fn tasten_direktive(name: &str, src: &str) -> Vec<tb_runtime::host::Ereignis> {
"LEFT" => 0x4B,
"RIGHT" => 0x4D,
"DOWN" => 0x50,
"ENTER" => {
out.push(Ereignis::Taste(taste::ENTER.into(), shift));
i += ende + 1;
continue;
}
"ESC" => {
out.push(Ereignis::Taste(taste::ESC.into(), shift));
i += ende + 1;
continue;
}
"TAB" => {
out.push(Ereignis::Taste(taste::TAB.into(), shift));
i += ende + 1;
continue;
}
other => panic!("{name}: tb-keys kennt `<{other}>` nicht"),
};
out.push(Ereignis::Taste(taste::sonder(code), shift));
@@ -155,6 +175,98 @@ fn tasten_direktive(name: &str, src: &str) -> Vec<tb_runtime::host::Ereignis> {
out
}
enum Ereignisschritt {
Ereignis(tb_runtime::host::Ereignis),
Zeit(u64),
}
fn ereignis_direktive(name: &str, src: &str) -> Vec<Ereignisschritt> {
use tb_runtime::host::{Ereignis, MausArt, MausEreignis};
let Some(wert) = direktive(src, "tb-events:") else {
return Vec::new();
};
let mut out = Vec::new();
for teil in wert.split('|').map(str::trim) {
if let Some(keys) = teil.strip_prefix("key:") {
out.extend(
tastenfolge(name, keys)
.into_iter()
.map(Ereignisschritt::Ereignis),
);
} else if let Some(ms) = teil.strip_prefix("time:") {
let ms = ms.trim().strip_suffix("ms").unwrap_or(ms.trim());
out.push(Ereignisschritt::Zeit(
ms.parse()
.unwrap_or_else(|_| panic!("{name}: ungültige Zeit {ms:?}")),
));
} else if let Some(mouse) = teil.strip_prefix("mouse:") {
let fields: Vec<_> = mouse.split(',').map(str::trim).collect();
let [kind, button, shift, row, col] = fields.as_slice() else {
panic!("{name}: mouse erwartet art,taste,shift,zeile,spalte");
};
let art = match *kind {
"down" => MausArt::Druck,
"up" => MausArt::Loslassen,
"move" => MausArt::Bewegung,
_ => panic!("{name}: unbekannte Mausart {kind:?}"),
};
let parse = |value: &str| {
value
.parse::<usize>()
.unwrap_or_else(|_| panic!("{name}: ungültige Zahl {value:?}"))
};
out.push(Ereignisschritt::Ereignis(Ereignis::Maus(MausEreignis {
art,
taste: parse(button) as u8,
shift: parse(shift) as u8,
zeile: parse(row),
spalte: parse(col),
})));
} else {
panic!("{name}: unbekannter tb-events-Schritt {teil:?}");
}
}
out
}
struct EreignisHost {
inner: CaptureHost,
schritte: VecDeque<Ereignisschritt>,
pause: bool,
}
impl tb_runtime::host::Host for EreignisHost {
fn present(&mut self, screen: &tb_runtime::screen::TextScreen) {
self.inner.present(screen);
}
fn next_event(&mut self, blockierend: bool) -> Option<tb_runtime::host::Ereignis> {
if !blockierend && self.pause {
self.pause = false;
return None;
}
loop {
match self.schritte.pop_front() {
Some(Ereignisschritt::Zeit(ms)) => {
self.inner.uhr_vorruecken(ms);
if !blockierend {
return None;
}
}
Some(Ereignisschritt::Ereignis(event)) => {
self.pause = true;
return Some(event);
}
None => return self.inner.next_event(blockierend),
}
}
}
fn jetzt_ms(&mut self) -> u64 {
self.inner.jetzt_ms()
}
}
/// Virtuelle Uhr: `' tb-clock: <n>ms/Zustellpunkt`. Ohne die Direktive
/// steht die Uhr des Testhosts still — ein Programm mit Zeit-Traps liefe
/// dann endlos. Der Harness weist es deshalb ab, statt auf die Systemuhr
@@ -242,45 +354,77 @@ impl Drop for TempVerzeichnis {
/// Korpusdatei ausführen und den Bildschirm-Snapshot liefern.
fn run_corpus_file(path: &Path, groesse: Option<(usize, usize)>) -> String {
let src = std::fs::read_to_string(path).unwrap();
let raw = std::fs::read_to_string(path).unwrap();
let name = path.file_name().unwrap().to_string_lossy().to_string();
let modul_name = path.file_stem().unwrap().to_string_lossy().to_uppercase();
let (cols, rows) = groesse.unwrap_or_else(|| screen_groessen(&name, &src)[0]);
let module = tb_vm::compile_source(&modul_name, &src)
.unwrap_or_else(|d| panic!("{}: Compile-Fehler: {d:?}", path.display()));
let form = if path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("frm"))
{
Some(tb_ui::frm::read_text(&name, &raw).unwrap_or_else(|error| panic!("{error}")))
} else {
None
};
let src = form
.as_ref()
.map_or(raw.as_str(), |form| form.code.as_str());
let modul_name = form.as_ref().map_or_else(
|| path.file_stem().unwrap().to_string_lossy().to_uppercase(),
|form| form.root.name.to_uppercase(),
);
let (cols, rows) = groesse.unwrap_or_else(|| screen_groessen(&name, src)[0]);
let module = match &form {
Some(form) => tb_vm::compile_source_with_forms(&modul_name, src, &form.catalog()),
None => tb_vm::compile_source(&modul_name, src),
}
.unwrap_or_else(|d| panic!("{}: Compile-Fehler: {d:?}", path.display()));
let mut vm = Vm::new(module);
if let Some(form) = &form {
form.apply(&mut vm.forms).unwrap();
vm.forms.show(0, false).unwrap();
}
vm.rt.screen.resize(cols, rows);
if let Some(z) = tz_direktive(&name, &src) {
if let Some(z) = tz_direktive(&name, src) {
vm.rt.zeitzone = z;
}
// `' tb-tempdir` — das Programm arbeitet mit Dateien.
let _temp = direktive(&src, "tb-tempdir").map(|_| TempVerzeichnis::neu(&name));
let _temp = direktive(src, "tb-tempdir").map(|_| TempVerzeichnis::neu(&name));
let mut host = CaptureHost::default();
if let Some((nach, (c, r))) = resize_direktive(&name, &src) {
if let Some((nach, (c, r))) = resize_direktive(&name, src) {
host.ereignis_nach(
nach,
tb_runtime::host::Ereignis::Groesse { cols: c, rows: r },
);
}
for t in tasten_direktive(&name, &src) {
for t in tasten_direktive(&name, src) {
host.ereignis(t);
}
let schritt = clock_schritt(&name, &src);
let schritt = clock_schritt(&name, src);
let schritte = ereignis_direktive(&name, src);
if !schritte.is_empty() {
assert!(
schritt.is_none(),
"{name}: tb-events: time und tb-clock nicht mischen"
);
let mut host = EreignisHost {
inner: host,
schritte: schritte.into(),
pause: false,
};
return run_to_snapshot(path, &mut vm, &mut host);
}
if let Some(schritt_ms) = schritt {
let mut host = UhrHost {
inner: host,
schritt_ms,
};
return match vm.run(&mut host) {
RunEvent::Ended => tb_runtime::snapshot::snapshot(&vm.rt.screen),
other => panic!(
"{}: unerwartetes Laufzeitende {other:?}\nBildschirm bisher:\n{}",
path.display(),
tb_runtime::snapshot::snapshot(&vm.rt.screen)
),
};
return run_to_snapshot(path, &mut vm, &mut host);
}
match vm.run(&mut host) {
run_to_snapshot(path, &mut vm, &mut host)
}
fn run_to_snapshot(path: &Path, vm: &mut Vm, host: &mut dyn Host) -> String {
match vm.run(host) {
RunEvent::Ended => tb_runtime::snapshot::snapshot(&vm.rt.screen),
other => panic!(
"{}: unerwartetes Laufzeitende {other:?}\nBildschirm bisher:\n{}",
@@ -290,6 +434,21 @@ fn run_corpus_file(path: &Path, groesse: Option<(usize, usize)>) -> String {
}
}
fn source_for_directives(path: &Path) -> String {
let raw = std::fs::read_to_string(path).unwrap();
if path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("frm"))
{
tb_ui::frm::read_text(&path.display().to_string(), &raw)
.unwrap()
.code
} else {
raw
}
}
/// Snapshots vergleichen: erst das Textbild, dann die Attributebene.
/// Bei Abweichung im Text zählt die Zeile, bei Attributen Zeile und Spalte.
fn assert_output_matches(file: &str, want: &str, got: &str) {
@@ -365,12 +524,16 @@ fn korpus_laeuft_mit_korrekter_ausgabe() {
let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
.expect("tests/compat fehlt")
.map(|e| e.unwrap().path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("bas"))
.filter(|p| {
p.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| matches!(ext, "bas" | "frm"))
})
.collect();
entries.sort();
for path in entries {
let name = path.file_name().unwrap().to_string_lossy().to_string();
let src = std::fs::read_to_string(&path).unwrap();
let src = source_for_directives(&path);
let groessen = screen_groessen(&name, &src);
for (cols, rows) in &groessen {
// Bei mehreren Größen trägt jede ihre eigene Sollausgabe.
@@ -406,6 +569,25 @@ fn uhr_direktive_wird_gelesen() {
);
}
#[test]
fn ereignisfolge_liefert_taste_maus_und_zeit_in_reihenfolge() {
let steps = ereignis_direktive(
"form.frm",
"' tb-events: key:%o | mouse:down,1,0,5,7 | time:250ms | key:<ENTER>",
);
assert_eq!(steps.len(), 4);
assert!(
matches!(&steps[0], Ereignisschritt::Ereignis(tb_runtime::host::Ereignis::Taste(key, shift)) if key == "o" && *shift == 4)
);
assert!(
matches!(&steps[1], Ereignisschritt::Ereignis(tb_runtime::host::Ereignis::Maus(mouse)) if mouse.zeile == 5 && mouse.spalte == 7)
);
assert!(matches!(steps[2], Ereignisschritt::Zeit(250)));
assert!(
matches!(&steps[3], Ereignisschritt::Ereignis(tb_runtime::host::Ereignis::Taste(key, _)) if key == tb_runtime::host::taste::ENTER)
);
}
#[test]
#[should_panic(expected = "deklariert aber keinen Zeitverlauf")]
fn zeittrap_ohne_uhr_direktive_wird_abgewiesen() {
@@ -467,6 +649,40 @@ fn tbc_build_erzeugt_tbc() {
let _ = std::fs::remove_file(&tbc);
}
#[test]
fn tbc_loest_include_projekt_und_run_datei_auf() {
let exe = env!("CARGO_BIN_EXE_tbc");
let dir = std::env::temp_dir().join(format!("tb_project_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("werte.bi"), "CONST N = 7\n").unwrap();
std::fs::write(
dir.join("main.bas"),
"'$INCLUDE: 'WERTE.BI'\nDECLARE SUB Ausgabe\nAusgabe\nEND\n",
)
.unwrap();
std::fs::write(dir.join("lib.bas"), "SUB Ausgabe\nPRINT N\nEND SUB\n").unwrap();
std::fs::write(dir.join("app.mak"), "MAIN.BAS\nLIB.BAS\n").unwrap();
let project = Command::new(exe)
.args(["run"])
.arg(dir.join("app.mak"))
.output()
.unwrap();
assert!(project.status.success(), "{project:?}");
assert_eq!(String::from_utf8_lossy(&project.stdout), " 7 \n");
std::fs::write(dir.join("start.bas"), "RUN \"ZIEL\"\n").unwrap();
std::fs::write(dir.join("ziel.bas"), "PRINT \"weiter\"\nEND\n").unwrap();
let chained = Command::new(exe)
.args(["run"])
.arg(dir.join("start.bas"))
.output()
.unwrap();
assert!(chained.status.success(), "{chained:?}");
assert_eq!(String::from_utf8_lossy(&chained.stdout), "weiter\n");
let _ = std::fs::remove_dir_all(&dir);
}
// ---- Harness meldet Abweichungen benannt (Spec kompat-testkorpus) ----------
fn meldung(want: &str, got: &str) -> String {
@@ -537,12 +753,16 @@ fn erzeuge_sollausgaben() {
let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
.expect("tests/compat fehlt")
.map(|e| e.unwrap().path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("bas"))
.filter(|p| {
p.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| matches!(ext, "bas" | "frm"))
})
.collect();
entries.sort();
for path in entries {
let name = path.file_name().unwrap().to_string_lossy().to_string();
let src = std::fs::read_to_string(&path).unwrap();
let src = source_for_directives(&path);
let groessen = screen_groessen(&name, &src);
for (cols, rows) in &groessen {
let out_path = if groessen.len() == 1 {

View File

@@ -480,6 +480,26 @@ pub enum Stmt {
bottom: Option<Expr>,
pos: SourcePos,
},
GraphicsLine {
from: Option<(Expr, Expr)>,
to: (Expr, Expr),
relative: bool,
color: Option<Expr>,
fill: u8,
pos: SourcePos,
},
GraphicsPaint {
point: (Expr, Expr),
paint: Option<Expr>,
border: Option<Expr>,
pos: SourcePos,
},
GraphicsView {
rect: Option<(Expr, Expr, Expr, Expr)>,
fill: Option<Expr>,
border: Option<Expr>,
pos: SourcePos,
},
/// `ON TIMER(n&) GOSUB ziel`, `ON KEY(n%) GOSUB ziel`,
/// `ON UEVENT GOSUB ziel`, `ON SIGNAL(n%) GOSUB ziel` — die
/// klassischen Ereignis-Traps (Sprachreferenz §8). `GOSUB 0` schaltet

View File

@@ -21,10 +21,11 @@ pub enum ObjectClass {
Timer,
VScrollBar,
Screen,
Spin,
}
impl ObjectClass {
pub const ALL: [Self; 18] = [
pub const ALL: [Self; 19] = [
Self::Form,
Self::CheckBox,
Self::ComboBox,
@@ -43,6 +44,7 @@ impl ObjectClass {
Self::Timer,
Self::VScrollBar,
Self::Screen,
Self::Spin,
];
pub fn name(self) -> &'static str {
@@ -65,6 +67,7 @@ impl ObjectClass {
Self::Timer => "TIMER",
Self::VScrollBar => "VSCROLLBAR",
Self::Screen => "SCREEN",
Self::Spin => "SPIN",
}
}
@@ -88,6 +91,7 @@ impl ObjectClass {
Self::Timer => "Timer",
Self::VScrollBar => "VScrollBar",
Self::Screen => "Screen",
Self::Spin => "Spin",
}
}
@@ -295,7 +299,7 @@ pub fn properties(class: ObjectClass) -> Vec<PropertySpec> {
string("TEXT", ""),
]),
CheckBox => p.extend([string("CAPTION", ""), range("VALUE", 0, 0, 2)]),
OptionButton => p.extend([string("CAPTION", ""), range("VALUE", 0, -1, 0)]),
OptionButton => p.extend([string("CAPTION", ""), range("VALUE", 0, -1, 1)]),
Frame => p.push(string("CAPTION", "")),
Label => p.extend([
range("ALIGNMENT", 0, 0, 2),
@@ -311,6 +315,21 @@ pub fn properties(class: ObjectClass) -> Vec<PropertySpec> {
range("SMALLCHANGE", 1, 1, 32767),
range("VALUE", 0, -32768, 32767),
]),
Spin => {
p.extend([
ro("BORDERSTYLE", PropertyType::Integer),
range("INTERVAL", 250, 0, 65535),
range("MIN", 0, -32768, 32767),
range("MAX", 32767, -32768, 32767),
range("STYLE", 0, 0, 1),
range("VALUE", 0, -32768, 32767),
]);
for name in ["HEIGHT", "WIDTH"] {
if let Some(property) = p.iter_mut().find(|property| property.name == name) {
property.writable = false;
}
}
}
PictureBox => p.extend([
boolp("AUTOREDRAW", false),
range("BORDERSTYLE", 1, 0, 2),
@@ -337,10 +356,26 @@ pub fn properties(class: ObjectClass) -> Vec<PropertySpec> {
boolp("SEPARATOR", false),
string("TAG", ""),
boolp("VISIBLE", true),
string("SHORTCUT", ""),
]),
DirListBox => p.extend([
ro("LIST", PropertyType::String),
ro("LISTCOUNT", PropertyType::Integer),
int("LISTINDEX", -1),
string("PATH", ""),
string("TEXT", ""),
]),
DriveListBox => p.extend([
ro("LIST", PropertyType::String),
ro("LISTCOUNT", PropertyType::Integer),
int("LISTINDEX", -1),
string("DRIVE", ""),
string("TEXT", ""),
]),
DirListBox => p.extend([string("PATH", ""), string("TEXT", "")]),
DriveListBox => p.extend([string("DRIVE", ""), string("TEXT", "")]),
FileListBox => p.extend([
ro("LIST", PropertyType::String),
ro("LISTCOUNT", PropertyType::Integer),
int("LISTINDEX", -1),
string("FILENAME", ""),
string("PATH", ""),
string("PATTERN", "*.*"),
@@ -413,6 +448,7 @@ pub fn methods(class: ObjectClass) -> &'static [&'static str] {
&["DRAG", "MOVE", "REFRESH", "SETFOCUS"]
}
Frame | Label | HScrollBar | VScrollBar => &["DRAG", "MOVE", "REFRESH"],
Spin => &["DRAG", "REFRESH", "SETFOCUS"],
PictureBox => &[
"CLS",
"DRAG",
@@ -429,11 +465,7 @@ pub fn methods(class: ObjectClass) -> &'static [&'static str] {
}
pub fn method_is_implemented(class: ObjectClass, name: &str) -> bool {
matches!(
(class, name),
(ObjectClass::Form, "HIDE" | "LOAD" | "SHOW" | "UNLOAD")
| (ObjectClass::Screen, "HIDE" | "SHOW")
)
methods(class).contains(&name)
}
pub fn method_arity(class: ObjectClass, name: &str) -> Option<(usize, usize)> {
@@ -441,6 +473,22 @@ pub fn method_arity(class: ObjectClass, name: &str) -> Option<(usize, usize)> {
(ObjectClass::Form, "SHOW") => Some((0, 1)),
(ObjectClass::Form, "HIDE" | "LOAD" | "UNLOAD")
| (ObjectClass::Screen, "HIDE" | "SHOW") => Some((0, 0)),
(_, "REFRESH" | "SETFOCUS" | "CLS" | "PRINTFORM") => Some((0, 0)),
(_, "DRAG") => Some((1, 1)),
(_, "MOVE") => Some((2, 4)),
(ObjectClass::ListBox | ObjectClass::ComboBox, "ADDITEM") => Some((1, 2)),
(ObjectClass::ListBox | ObjectClass::ComboBox, "REMOVEITEM") => Some((1, 1)),
(ObjectClass::Form | ObjectClass::PictureBox, "PRINT") => Some((0, usize::MAX)),
(ObjectClass::Form | ObjectClass::PictureBox, "TEXTWIDTH" | "TEXTHEIGHT") => Some((1, 1)),
_ => None,
}
}
pub fn method_return_type(class: ObjectClass, name: &str) -> Option<PropertyType> {
match (class, name) {
(ObjectClass::Form | ObjectClass::PictureBox, "TEXTWIDTH" | "TEXTHEIGHT") => {
Some(PropertyType::Integer)
}
_ => None,
}
}
@@ -558,6 +606,16 @@ pub fn events(class: ObjectClass) -> &'static [&'static str] {
"KEYUP",
"LOSTFOCUS",
],
Spin => &[
"CUSTOM",
"DRAGDROP",
"DRAGOVER",
"GOTFOCUS",
"KEYDOWN",
"KEYPRESS",
"KEYUP",
"LOSTFOCUS",
],
PictureBox => &[
"CLICK",
"DBLCLICK",
@@ -608,6 +666,7 @@ pub fn event_params(event: &str) -> Option<&'static [(&'static str, EventParamTy
("STATE", Integer),
],
"UNLOAD" => &[("CANCEL", Integer)],
"CUSTOM" => &[("EVENTTYPE", Integer)],
"CLICK" | "DBLCLICK" | "CHANGE" | "DROPDOWN" | "GOTFOCUS" | "LOSTFOCUS" | "LOAD"
| "PAINT" | "RESIZE" | "TIMER" | "PATHCHANGE" | "PATTERNCHANGE" => &[],
_ => return None,
@@ -652,4 +711,18 @@ impl FormCatalog {
.find(|(_, o)| o.name.eq_ignore_ascii_case(name))
.map(|(i, o)| (i as u16, o))
}
pub fn belongs_to(&self, object: &FormObject, form: &str) -> bool {
let mut parent = object.parent_form.as_deref();
for _ in 0..self.objects.len() {
let Some(name) = parent else { return false };
if name.eq_ignore_ascii_case(form) {
return true;
}
parent = self
.find(name)
.and_then(|(_, object)| object.parent_form.as_deref());
}
false
}
}

View File

@@ -369,6 +369,13 @@ pub enum Builtin {
IsamSavepoint,
IsamSetmem,
IsamBof,
MsgBox,
InputBoxS,
ClipboardAdd,
ClipboardGet,
GraphicsLine,
GraphicsPaint,
GraphicsView,
}
#[derive(Debug, Clone)]
@@ -386,6 +393,20 @@ pub enum HExpr {
property: u16,
ty: HTy,
},
ObjectIndexedProperty {
object: u16,
object_index: Option<Box<HExpr>>,
property: u16,
index: Box<HExpr>,
ty: HTy,
},
ObjectMethodCall {
object: u16,
index: Option<Box<HExpr>>,
method: u16,
args: Vec<HExpr>,
ty: HTy,
},
DynamicObjectProperty {
object: Box<HExpr>,
property: String,
@@ -511,6 +532,13 @@ pub enum HStmtKind {
property: u16,
value: HExpr,
},
SetObjectIndexedProperty {
object: u16,
object_index: Option<HExpr>,
property: u16,
index: HExpr,
value: HExpr,
},
SetDynamicObjectProperty {
object: HExpr,
property: String,
@@ -518,6 +546,7 @@ pub enum HStmtKind {
},
ObjectMethod {
object: u16,
index: Option<HExpr>,
method: u16,
args: Vec<HExpr>,
},
@@ -592,6 +621,10 @@ pub enum HStmtKind {
targets: Vec<LabelId>,
},
ReturnGosub(Option<LabelId>),
Run {
target: Option<HExpr>,
string: bool,
},
/// Ereignis-Trap erklären (Sprachreferenz §8). `art` ist die Quelle
/// (0 KEY, 1 TIMER, 2 UEVENT, 3 SIGNAL), `index` ihre Kennung bzw. bei
/// `TIMER` das Intervall in Sekunden. `ziel = None` = `GOSUB 0`.

View File

@@ -750,8 +750,8 @@ mod tests {
assert_eq!(kinds("40000")[0], TokenKind::Num(NumValue::Long(40000)));
assert_eq!(kinds("1.5")[0], TokenKind::Num(NumValue::Single(1.5)));
assert_eq!(
kinds("3.14159265")[0],
TokenKind::Num(NumValue::Double(3.14159265))
kinds("1.23456789")[0],
TokenKind::Num(NumValue::Double(1.23456789))
);
assert_eq!(kinds("1E3")[0], TokenKind::Num(NumValue::Single(1000.0)));
assert_eq!(kinds("1D3")[0], TokenKind::Num(NumValue::Double(1000.0)));

View File

@@ -28,7 +28,18 @@ pub fn parse(module_name: &str, tokens: &[Token]) -> ParseOutput {
match p.k() {
TokenKind::Eof => break,
TokenKind::Kw(Kw::Sub) | TokenKind::Kw(Kw::Function) => {
if let Some(proc) = p.parse_proc() {
if let Some(proc) = p.parse_proc(false) {
procs.push(proc);
}
}
TokenKind::Kw(Kw::Static)
if matches!(
p.k_at(1),
TokenKind::Kw(Kw::Sub) | TokenKind::Kw(Kw::Function)
) =>
{
p.advance();
if let Some(proc) = p.parse_proc(true) {
procs.push(proc);
}
}
@@ -244,10 +255,7 @@ impl<'a> P<'a> {
self.advance();
self.parse_input(true, pos)
} else {
// Grafikform LINE (x1,y1)-(x2,y2): deklariertes Non-Feature.
self.err("Feature unavailable");
self.sync();
None
self.parse_graphics_line(pos)
}
}
TokenKind::Kw(Kw::If) => self.parse_if(pos),
@@ -350,12 +358,8 @@ impl<'a> P<'a> {
TokenKind::Kw(Kw::Erase) => {
self.advance();
let mut names = Vec::new();
loop {
if let Some(e) = self.parse_name_only() {
names.push(e);
} else {
break;
}
while let Some(e) = self.parse_name_only() {
names.push(e);
if !self.eat(&TokenKind::Comma) {
break;
}
@@ -548,12 +552,8 @@ impl<'a> P<'a> {
TokenKind::Kw(Kw::Read) => {
self.advance();
let mut vars = Vec::new();
loop {
if let Some(e) = self.parse_name_ref() {
vars.push(e);
} else {
break;
}
while let Some(e) = self.parse_name_ref() {
vars.push(e);
if !self.eat(&TokenKind::Comma) {
break;
}
@@ -733,6 +733,14 @@ impl<'a> P<'a> {
}
Some(Stmt::ViewPrint { top, bottom, pos })
}
TokenKind::Ident {
ref name,
suffix: None,
} if name == "VIEW" => self.parse_graphics_view(pos),
TokenKind::Ident {
ref name,
suffix: None,
} if name == "PAINT" => self.parse_graphics_paint(pos),
TokenKind::Ident {
ref name,
suffix: None,
@@ -832,6 +840,121 @@ impl<'a> P<'a> {
})
}
fn parse_graphics_point(&mut self) -> Option<(Expr, Expr)> {
if !self.eat(&TokenKind::LParen) {
self.err("Expected: (");
return None;
}
let x = self.parse_expr()?;
if !self.eat(&TokenKind::Comma) {
self.err("Expected: ,");
return None;
}
let y = self.parse_expr()?;
if !self.eat(&TokenKind::RParen) {
self.err("Expected: )");
return None;
}
Some((x, y))
}
fn parse_graphics_line(&mut self, pos: SourcePos) -> Option<Stmt> {
let from = if self.eat(&TokenKind::Minus) {
None
} else {
let point = self.parse_graphics_point()?;
if !self.eat(&TokenKind::Minus) {
self.err("Expected: -");
return None;
}
Some(point)
};
let relative = self.eat_kw(Kw::Step);
let to = self.parse_graphics_point()?;
let mut color = None;
let mut fill = 0;
if self.eat(&TokenKind::Comma) {
if !matches!(
self.k(),
TokenKind::Comma | TokenKind::Colon | TokenKind::Eol | TokenKind::Eof
) {
color = Some(self.parse_expr()?);
}
if self.eat(&TokenKind::Comma) {
if let TokenKind::Ident { name, .. } = self.k() {
fill = match name.as_str() {
"B" => 1,
"BF" => 2,
_ => {
self.err("Expected: B or BF");
return None;
}
};
self.advance();
}
}
}
Some(Stmt::GraphicsLine {
from,
to,
relative,
color,
fill,
pos,
})
}
fn parse_graphics_paint(&mut self, pos: SourcePos) -> Option<Stmt> {
self.advance();
let point = self.parse_graphics_point()?;
let paint = self
.eat(&TokenKind::Comma)
.then(|| self.parse_expr())
.flatten();
let border = self
.eat(&TokenKind::Comma)
.then(|| self.parse_expr())
.flatten();
Some(Stmt::GraphicsPaint {
point,
paint,
border,
pos,
})
}
fn parse_graphics_view(&mut self, pos: SourcePos) -> Option<Stmt> {
self.advance();
if self.at_stmt_end() {
return Some(Stmt::GraphicsView {
rect: None,
fill: None,
border: None,
pos,
});
}
let (x1, y1) = self.parse_graphics_point()?;
if !self.eat(&TokenKind::Minus) {
self.err("Expected: -");
return None;
}
let (x2, y2) = self.parse_graphics_point()?;
let fill = self
.eat(&TokenKind::Comma)
.then(|| self.parse_expr())
.flatten();
let border = self
.eat(&TokenKind::Comma)
.then(|| self.parse_expr())
.flatten();
Some(Stmt::GraphicsView {
rect: Some((x1, y1, x2, y2)),
fill,
border,
pos,
})
}
fn parse_print(&mut self, pos: SourcePos, printer: bool) -> Option<Stmt> {
self.advance(); // PRINT bzw. LPRINT
let file = if !printer && self.eat(&TokenKind::Hash) {
@@ -1056,12 +1179,8 @@ impl<'a> P<'a> {
}
}
let mut vars = Vec::new();
loop {
if let Some(e) = self.parse_name_ref() {
vars.push(e);
} else {
break;
}
while let Some(e) = self.parse_name_ref() {
vars.push(e);
if !self.eat(&TokenKind::Comma) {
break;
}
@@ -1384,11 +1503,8 @@ impl<'a> P<'a> {
return None;
};
let mut targets = Vec::new();
loop {
match self.parse_label_ref() {
Some(t) => targets.push(t),
None => break,
}
while let Some(t) = self.parse_label_ref() {
targets.push(t);
if !self.eat(&TokenKind::Comma) {
break;
}
@@ -1432,11 +1548,7 @@ impl<'a> P<'a> {
let dims = if self.eat(&TokenKind::LParen) {
let mut ds = Vec::new();
if !self.eat(&TokenKind::RParen) {
loop {
let a = match self.parse_expr() {
Some(e) => e,
None => break,
};
while let Some(a) = self.parse_expr() {
if self.eat_kw(Kw::To) {
match self.parse_expr() {
Some(b) => ds.push((Some(a), b)),
@@ -1602,44 +1714,42 @@ impl<'a> P<'a> {
}
};
let mut params = Vec::new();
if self.eat(&TokenKind::LParen) {
if !self.eat(&TokenKind::RParen) {
loop {
match self.k() {
TokenKind::Ident {
if self.eat(&TokenKind::LParen) && !self.eat(&TokenKind::RParen) {
loop {
match self.k() {
TokenKind::Ident {
name: pname,
suffix: psfx,
} => {
self.advance();
let array = if self.eat(&TokenKind::LParen) {
self.eat(&TokenKind::RParen);
true
} else {
false
};
let as_type = if self.eat_kw(Kw::As) {
self.parse_type_name()
} else {
None
};
params.push(Param {
name: pname,
suffix: psfx,
} => {
self.advance();
let array = if self.eat(&TokenKind::LParen) {
self.eat(&TokenKind::RParen);
true
} else {
false
};
let as_type = if self.eat_kw(Kw::As) {
self.parse_type_name()
} else {
None
};
params.push(Param {
name: pname,
suffix: psfx,
array,
as_type,
});
}
_ => {
self.err("Expected: identifier");
break;
}
array,
as_type,
});
}
if !self.eat(&TokenKind::Comma) {
_ => {
self.err("Expected: identifier");
break;
}
}
self.eat(&TokenKind::RParen);
if !self.eat(&TokenKind::Comma) {
break;
}
}
self.eat(&TokenKind::RParen);
}
Some(ProcSig {
kind,
@@ -1649,10 +1759,10 @@ impl<'a> P<'a> {
})
}
fn parse_proc(&mut self) -> Option<Proc> {
fn parse_proc(&mut self, leading_static: bool) -> Option<Proc> {
let pos = self.pos();
let sig = self.parse_proc_sig()?;
let is_static = self.eat_kw(Kw::Static);
let is_static = leading_static || self.eat_kw(Kw::Static);
let end_kw = match sig.kind {
ProcKind::Sub => Kw::Sub,
ProcKind::Function => Kw::Function,
@@ -1697,33 +1807,31 @@ impl<'a> P<'a> {
}
};
let mut params = Vec::new();
if self.eat(&TokenKind::LParen) {
if !self.eat(&TokenKind::RParen) {
loop {
match self.k() {
TokenKind::Ident {
if self.eat(&TokenKind::LParen) && !self.eat(&TokenKind::RParen) {
loop {
match self.k() {
TokenKind::Ident {
name: pname,
suffix: psfx,
} => {
self.advance();
params.push(Param {
name: pname,
suffix: psfx,
} => {
self.advance();
params.push(Param {
name: pname,
suffix: psfx,
array: false,
as_type: None,
});
}
_ => {
self.err("Expected: identifier");
break;
}
array: false,
as_type: None,
});
}
if !self.eat(&TokenKind::Comma) {
_ => {
self.err("Expected: identifier");
break;
}
}
self.eat(&TokenKind::RParen);
if !self.eat(&TokenKind::Comma) {
break;
}
}
self.eat(&TokenKind::RParen);
}
if self.eat(&TokenKind::Eq) {
let body = self.parse_expr()?;
@@ -1818,8 +1926,8 @@ impl<'a> P<'a> {
other => Expr::Paren(Box::new(other)),
})
.collect();
if !self.at_stmt_end() && call_args.is_empty() {
call_args = self.parse_arg_list_to_stmt_end();
if !self.at_stmt_end() && (call_args.is_empty() || name.contains('.')) {
call_args.extend(self.parse_arg_list_to_stmt_end());
}
Some(Stmt::Call {
name,
@@ -1842,7 +1950,7 @@ impl<'a> P<'a> {
match self.k() {
TokenKind::Ident { mut name, suffix } => {
self.advance();
let args = if self.eat(&TokenKind::LParen) {
let mut args = if self.eat(&TokenKind::LParen) {
let a = self.parse_arg_list(&TokenKind::RParen);
self.eat(&TokenKind::RParen);
Some(a)
@@ -1858,6 +1966,11 @@ impl<'a> P<'a> {
name.push('.');
name.push_str(&member);
self.advance();
if self.eat(&TokenKind::LParen) {
let member_args = self.parse_arg_list(&TokenKind::RParen);
self.eat(&TokenKind::RParen);
args.get_or_insert_with(Vec::new).extend(member_args);
}
}
_ => self.err("Expected: property"),
}
@@ -1935,6 +2048,7 @@ impl<'a> P<'a> {
args.push(Expr::Missing);
continue;
}
self.eat(&TokenKind::Hash);
match self.parse_expr() {
Some(e) => args.push(e),
None => break,
@@ -1985,11 +2099,7 @@ impl<'a> P<'a> {
fn parse_bin(&mut self, min_prec: u8) -> Option<Expr> {
let mut lhs = self.parse_prefix()?;
loop {
let (prec, op) = match self.infix_op() {
Some(x) => x,
None => break,
};
while let Some((prec, op)) = self.infix_op() {
if prec < min_prec {
break;
}

File diff suppressed because it is too large Load Diff

View File

@@ -131,12 +131,12 @@ fn stand() -> Stand {
let unsup_fn = abschnitt(
&q,
"// --- Spätere Phasen: dokumentiert",
"(HExpr::Unsupported(name)",
"(HExpr::Unsupported(\"Funktion\")",
);
let unsup_stmt = abschnitt(
&q,
"// Bildschirm-/Datei-/System-Anweisungen späterer Phasen.",
r#"_ => "Anweisung""#,
r#"HStmtKind::Unsupported("Anweisung")"#,
);
let mut signaturen = literale(fn_tab);
@@ -470,7 +470,6 @@ fn dokumentierte_elemente_werden_namentlich_abgewiesen() {
("Y = POINT(1, 2)", "POINT", "Feature unavailable"),
("CALLS Foo", "CALLS", "Feature unavailable"),
// Non-Feature über die Syntax (teilt das Schlüsselwort)
("LINE (1, 1)-(2, 2)", "LINE", "Feature unavailable"),
(
"GET (1, 1)-(2, 2), A",
"GET (Grafik)",

View File

@@ -63,6 +63,7 @@ pub struct RtState {
rng: u32,
rnd_last: f32,
pub command: String,
pub clipboard: String,
}
impl Default for RtState {
@@ -93,6 +94,7 @@ impl Default for RtState {
rng: 0x50000,
rnd_last: 0.0,
command: String::new(),
clipboard: String::new(),
}
}
}
@@ -438,7 +440,14 @@ pub mod ids {
pub const ISAM_BOF: u16 = 147;
/// `SetUEvent` — loest das benutzerdefinierte Ereignis aus (§8).
pub const SETUEVENT: u16 = 148;
pub const COUNT: u16 = 149;
pub const MSGBOX: u16 = 149;
pub const INPUTBOX_S: u16 = 150;
pub const CLIPBOARD_ADD: u16 = 151;
pub const CLIPBOARD_GET: u16 = 152;
pub const GRAPHICS_LINE: u16 = 153;
pub const GRAPHICS_PAINT: u16 = 154;
pub const GRAPHICS_VIEW: u16 = 155;
pub const COUNT: u16 = 156;
}
/// Dispatch-Tabelle in Index-Reihenfolge.
@@ -594,11 +603,35 @@ pub fn builtin_table() -> &'static [BuiltinFn] {
bi_isam_setmem,
bi_isam_bof,
bi_setuevent,
bi_msgbox,
bi_inputbox,
bi_clipboard_add,
bi_clipboard_get,
bi_graphics_line,
bi_graphics_paint,
bi_graphics_view,
];
debug_assert_eq!(TABLE.len(), ids::COUNT as usize);
TABLE
}
fn bi_clipboard_add(
st: &mut RtState,
_: &mut dyn Host,
a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
st.clipboard.push_str(&arg_str(a, 0)?);
Ok(None)
}
fn bi_clipboard_get(
st: &mut RtState,
_: &mut dyn Host,
_: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
Ok(Some(Value::Str(Rc::from(st.clipboard.as_str()))))
}
// ---- Argument-Hilfen --------------------------------------------------------
fn arg_str(args: &[Value], i: usize) -> Result<Rc<str>, RuntimeError> {
@@ -1881,19 +1914,92 @@ fn bi_view_print(
}
fn bi_screen_stmt(
_: &mut RtState,
st: &mut RtState,
_: &mut dyn Host,
a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
// `SCREEN modus[, farbschalter[, aktiv[, sichtbar]]]`. Nur der Textmodus 0
// existiert; Grafikmodi sind deklariertes Non-Feature. Die übrigen
// Argumente betreffen Grafikseiten und bleiben folgenlos.
// Die Terminaldarstellung bildet die historischen Pixelmodi auf Zellen ab;
// Seitenargumente haben im einzelnen Puffer keine zusätzliche Wirkung.
match opt(a, 0)? {
None | Some(0) => Ok(None),
Some(_) => Err(RuntimeError(73)), // Advanced feature unavailable
None | Some(0..=13) => {
st.screen.graphics_view(None).unwrap();
st.screen.cls();
Ok(None)
}
Some(_) => Err(RuntimeError::ILLEGAL_FUNCTION_CALL),
}
}
fn bi_graphics_line(
st: &mut RtState,
_: &mut dyn Host,
a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
let mut from = (arg_i32(a, 0)?, arg_i32(a, 1)?);
if from.0 == i32::MIN {
from = st.screen.graphics_position();
}
let mut to = (arg_i32(a, 2)?, arg_i32(a, 3)?);
if arg_i32(a, 5)? != 0 {
to.0 += from.0;
to.1 += from.1;
}
let color = match arg_i32(a, 4)? {
-1 => i32::from(st.screen.fg),
color @ 0..=15 => color,
_ => return Err(RuntimeError::ILLEGAL_FUNCTION_CALL),
};
st.screen
.graphics_line(from, to, color, arg_i32(a, 6)? as u8);
Ok(None)
}
fn bi_graphics_paint(
st: &mut RtState,
_: &mut dyn Host,
a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
let color = match a.get(2) {
Some(Value::Int(v)) => i32::from(*v),
Some(Value::Lng(v)) => *v,
Some(Value::Str(_)) | None => i32::from(st.screen.fg),
_ => return Err(RuntimeError::TYPE_MISMATCH),
};
if !(0..=15).contains(&color) {
return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
}
st.screen
.graphics_paint(arg_i32(a, 0)?, arg_i32(a, 1)?, color);
Ok(None)
}
fn bi_graphics_view(
st: &mut RtState,
_: &mut dyn Host,
a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
let x1 = arg_i32(a, 0)?;
if x1 < 0 {
st.screen.graphics_view(None).unwrap();
return Ok(None);
}
let view = (x1, arg_i32(a, 1)?, arg_i32(a, 2)?, arg_i32(a, 3)?);
st.screen
.graphics_view(Some(view))
.map_err(|_| RuntimeError::ILLEGAL_FUNCTION_CALL)?;
let fill = arg_i32(a, 4)?;
if fill >= 0 {
st.screen
.graphics_line((view.0, view.1), (view.2, view.3), fill, 2);
}
let border = arg_i32(a, 5)?;
if border >= 0 {
st.screen
.graphics_line((view.0, view.1), (view.2, view.3), border, 1);
}
Ok(None)
}
/// Nummer eines Funktionstasten-Makros → Index 011.
fn key_index(n: i32) -> Option<usize> {
match n {
@@ -2016,6 +2122,14 @@ fn bi_input_s(
if n < 0 {
return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
}
if a.len() > 1 {
let nummer = arg_i32(a, 1)?;
let datei = st.dateien.get(nummer)?;
let bytes = datei.bytes_lesen(datei.position, n as usize)?;
return Ok(Some(Value::Str(Rc::from(
String::from_utf8_lossy(&bytes).as_ref(),
))));
}
// Blockierend, ohne Echo auf dem Bildschirm.
let mut s = String::new();
for _ in 0..n {
@@ -2546,6 +2660,22 @@ fn bi_isam_bof(
Ok(Some(Value::Int(if st.isam.bof(n) { -1 } else { 0 })))
}
fn bi_msgbox(
_: &mut RtState,
_: &mut dyn Host,
_: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
Err(RuntimeError::FEATURE_UNAVAILABLE)
}
fn bi_inputbox(
_: &mut RtState,
_: &mut dyn Host,
_: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
Err(RuntimeError::FEATURE_UNAVAILABLE)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2635,7 +2765,7 @@ mod tests {
h.ereignis(Ereignis::Signal(1));
st.pump(&mut h, false);
assert_eq!(st.traps.naechstes(), Some((Quelle::Signal(1), 22)));
assert!(matches!(h.next_event(false), None));
assert!(h.next_event(false).is_none());
}
#[test]

View File

@@ -24,6 +24,9 @@ use unicode_width::UnicodeWidthChar;
pub const MIN_COLS: usize = 80;
pub const MIN_ROWS: usize = 25;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScreenError;
/// Eine Bildschirmzelle: Zeichen plus klassisches Farbattribut.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Cell {
@@ -78,6 +81,9 @@ pub struct TextScreen {
/// Seit der letzten Anzeige verändert? Der Host wird nur dann zum
/// Neuzeichnen aufgefordert.
veraendert: bool,
graphic_x: i32,
graphic_y: i32,
graphic_view: Option<(i32, i32, i32, i32)>,
}
impl Default for TextScreen {
@@ -110,6 +116,9 @@ impl TextScreen {
view_full: true,
belegt: vec![None; rows],
veraendert: true,
graphic_x: 0,
graphic_y: 0,
graphic_view: None,
}
}
@@ -146,8 +155,8 @@ impl TextScreen {
}
self.cells = cells;
let mut belegt = vec![None; rows];
for r in 0..self.rows.min(rows) {
belegt[r] = self.belegt[r].map(|c| c.min(cols - 1));
for (r, value) in belegt.iter_mut().enumerate().take(self.rows.min(rows)) {
*value = self.belegt[r].map(|c| c.min(cols - 1));
}
self.belegt = belegt;
self.cols = cols;
@@ -225,9 +234,9 @@ impl TextScreen {
}
/// `LOCATE zeile, spalte` (1-basiert); außerhalb → Err (Fehler 5).
pub fn locate(&mut self, row: usize, col: usize) -> Result<(), ()> {
pub fn locate(&mut self, row: usize, col: usize) -> Result<(), ScreenError> {
if row < 1 || row > self.rows || col < 1 || col > self.cols {
return Err(());
return Err(ScreenError);
}
self.cur_row = row - 1;
// Auf eine Fortsetzungszelle zu zeigen bedeutet den Zeichenanfang.
@@ -251,9 +260,9 @@ impl TextScreen {
}
/// `VIEW PRINT oben TO unten` (1-basiert).
pub fn view_print(&mut self, top: usize, bottom: usize) -> Result<(), ()> {
pub fn view_print(&mut self, top: usize, bottom: usize) -> Result<(), ScreenError> {
if top < 1 || bottom > self.rows || top > bottom {
return Err(());
return Err(ScreenError);
}
self.view_top = top - 1;
self.view_bottom = bottom - 1;
@@ -265,6 +274,122 @@ impl TextScreen {
self.cells[(row - 1) * self.cols + (col - 1)]
}
pub fn graphics_position(&self) -> (i32, i32) {
(self.graphic_x, self.graphic_y)
}
pub fn graphics_view(&mut self, view: Option<(i32, i32, i32, i32)>) -> Result<(), ScreenError> {
if view.is_some_and(|(x1, y1, x2, y2)| x1 < 0 || y1 < 0 || x1 > x2 || y1 > y2) {
return Err(ScreenError);
}
self.graphic_view = view;
Ok(())
}
fn graphics_cell(&mut self, x: i32, y: i32, color: i32) {
if x < 0
|| y < 0
|| self
.graphic_view
.is_some_and(|(x1, y1, x2, y2)| x < x1 || x > x2 || y < y1 || y > y2)
{
return;
}
let (col, row) = (x as usize / 8, y as usize / 8);
if col >= self.cols || row >= self.rows {
return;
}
self.cells[row * self.cols + col] = Cell {
ch: if color == 0 { ' ' } else { '█' },
fg: color.clamp(0, 15) as u8,
bg: self.bg,
fortsetzung: false,
};
self.belegt[row] = Some(self.belegt[row].map_or(col, |last| last.max(col)));
self.veraendert = true;
}
pub fn graphics_line(&mut self, from: (i32, i32), to: (i32, i32), color: i32, fill: u8) {
let (x1, y1) = from;
let (x2, y2) = to;
if fill == 2 {
for y in (y1.min(y2)..=y1.max(y2)).step_by(8) {
for x in (x1.min(x2)..=x1.max(x2)).step_by(8) {
self.graphics_cell(x, y, color);
}
}
} else if fill == 1 {
self.graphics_segment(x1, y1, x2, y1, color);
self.graphics_segment(x2, y1, x2, y2, color);
self.graphics_segment(x2, y2, x1, y2, color);
self.graphics_segment(x1, y2, x1, y1, color);
} else {
self.graphics_segment(x1, y1, x2, y2, color);
}
self.graphic_x = x2;
self.graphic_y = y2;
}
fn graphics_segment(&mut self, mut x: i32, mut y: i32, x2: i32, y2: i32, color: i32) {
let dx = (x2 - x).abs();
let sx = if x < x2 { 1 } else { -1 };
let dy = -(y2 - y).abs();
let sy = if y < y2 { 1 } else { -1 };
let mut error = dx + dy;
loop {
self.graphics_cell(x, y, color);
if x == x2 && y == y2 {
break;
}
let twice = error * 2;
if twice >= dy {
error += dy;
x += sx;
}
if twice <= dx {
error += dx;
y += sy;
}
}
}
pub fn graphics_paint(&mut self, x: i32, y: i32, color: i32) {
let (start_col, start_row) = (x.max(0) as usize / 8, y.max(0) as usize / 8);
if start_col >= self.cols || start_row >= self.rows {
return;
}
let target = self.cells[start_row * self.cols + start_col].ch;
let replacement = if color == 0 { ' ' } else { '█' };
if target == replacement {
return;
}
let mut pending = vec![(start_col, start_row)];
while let Some((col, row)) = pending.pop() {
let px = col as i32 * 8;
let py = row as i32 * 8;
if col >= self.cols
|| row >= self.rows
|| self.cells[row * self.cols + col].ch != target
|| self
.graphic_view
.is_some_and(|(x1, y1, x2, y2)| px < x1 || px > x2 || py < y1 || py > y2)
{
continue;
}
self.graphics_cell(px, py, color);
if col > 0 {
pending.push((col - 1, row));
}
if row > 0 {
pending.push((col, row - 1));
}
pending.push((col + 1, row));
pending.push((col, row + 1));
}
self.graphic_x = x;
self.graphic_y = y;
}
/// Text an der Cursorposition ausgeben: Umbruch am rechten Rand,
/// Scrollen am unteren Rand des Scrollbereichs. `\n` bricht um,
/// `\r` setzt an den Zeilenanfang.

View File

@@ -316,7 +316,7 @@ fn tausender_gruppieren(s: &str) -> String {
let z: Vec<char> = s.chars().collect();
let mut out = String::new();
for (i, c) in z.iter().enumerate() {
if i > 0 && (z.len() - i) % 3 == 0 {
if i > 0 && (z.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(*c);
@@ -433,7 +433,7 @@ mod tests {
#[test]
fn numerisches_feld_mit_nachkommastellen() {
assert_eq!(u("###.##", &[z(3.14159)]), " 3.14");
assert_eq!(u("###.##", &[z(3.126)]), " 3.13");
assert_eq!(u("##.##", &[z(-1.5)]), "-1.50");
}

View File

@@ -199,9 +199,7 @@ pub fn cur_to_i64(c: i64) -> i64 {
// r in 0..10000; runde halb-zu-gerade
if r > 5_000 {
q + 1
} else if r < 5_000 {
q
} else if q % 2 == 0 {
} else if r < 5_000 || q % 2 == 0 {
q
} else {
q + 1

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,11 @@
use std::collections::{BTreeMap, HashSet};
use std::fmt;
use tb_frontend::forms::{self, ObjectClass, PropertyDefault, PropertySpec, PropertyType};
use tb_frontend::forms::{
self, FormCatalog, ObjectClass, PropertyDefault, PropertySpec, PropertyType,
};
use crate::forms::PropertyValue;
use crate::forms::{FormsModel, PropertyValue};
#[derive(Debug, Clone, PartialEq)]
pub struct FormNode {
@@ -46,6 +48,67 @@ impl FormFile {
original: None,
}
}
pub fn catalog(&self) -> FormCatalog {
fn add(node: &FormNode, parent: Option<&str>, catalog: &mut FormCatalog) {
if let Some((id, _)) = catalog.find(&node.name) {
catalog.objects[id as usize].array = true;
} else {
let array = forms::property(node.class, "INDEX")
.and_then(|(property, _)| node.properties.get(&property))
.is_some_and(
|value| matches!(value, PropertyValue::Integer(index) if *index != 0),
);
catalog.add(&node.name, node.class, parent, array);
}
for child in &node.children {
add(child, Some(&node.name), catalog);
}
}
let mut catalog = FormCatalog::default();
add(&self.root, None, &mut catalog);
catalog
}
pub fn apply(&self, model: &mut FormsModel) -> Result<(), tb_runtime::errors::RuntimeError> {
fn apply_node(
node: &FormNode,
model: &mut FormsModel,
menu_depth: usize,
) -> Result<(), tb_runtime::errors::RuntimeError> {
let menu_depth = if node.class == ObjectClass::Menu {
menu_depth + 1
} else {
menu_depth
};
if menu_depth > 6 {
return Err(tb_runtime::errors::RuntimeError::ILLEGAL_FUNCTION_CALL);
}
let object = model
.objects
.iter()
.position(|candidate| candidate.description.name.eq_ignore_ascii_case(&node.name))
.ok_or(tb_runtime::errors::RuntimeError(420))? as u16;
let index = forms::property(node.class, "INDEX")
.and_then(|(property, _)| node.properties.get(&property))
.and_then(|value| match value {
PropertyValue::Integer(value) => Some(*value),
_ => None,
})
.unwrap_or(0);
if index != 0 && !model.is_loaded_at(object, Some(index)) {
model.load_design_array(object, index)?;
}
for (property, value) in &node.properties {
model.set_initial_at(object, Some(index), *property, value.clone())?;
}
for child in &node.children {
apply_node(child, model, menu_depth)?;
}
Ok(())
}
apply_node(&self.root, model, 0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -444,7 +507,7 @@ fn read_symbol_table(bytes: &[u8], mut at: usize) -> Result<Vec<BinarySymbol>, u
let reference = read_u16(bytes, at).ok_or(at)?;
let class_id = bytes[at + 2] & 0x7f;
let length = bytes[at + 3] as usize;
let class = ObjectClass::from_id(class_id).ok_or(at + 2)?;
let mut class = ObjectClass::from_id(class_id).ok_or(at + 2)?;
if length == 0 || length > 40 || at + 4 + length > bytes.len() {
return Err(at + 3);
}
@@ -459,12 +522,18 @@ fn read_symbol_table(bytes: &[u8], mut at: usize) -> Result<Vec<BinarySymbol>, u
if symbols.is_empty() && class != ObjectClass::Form {
return Err(at + 2);
}
let name = String::from_utf8_lossy(name).into_owned();
if class == ObjectClass::Screen
&& matches!(name.to_ascii_uppercase().as_str(), "VSPIN" | "HSPIN")
{
class = ObjectClass::Spin;
}
symbols.push(BinarySymbol {
offset: at,
class,
name: String::from_utf8_lossy(name).into_owned(),
name,
is_array: bytes[at + 2] & 0x80 != 0,
unsupported: class_id == ObjectClass::Screen.id(),
unsupported: class_id == ObjectClass::Screen.id() && class != ObjectClass::Spin,
});
at += 4 + length;
if reference == 0 {
@@ -501,7 +570,7 @@ fn record_len(class: ObjectClass) -> usize {
ObjectClass::PictureBox => 31,
ObjectClass::TextBox => 34,
ObjectClass::Menu | ObjectClass::Frame => 26,
ObjectClass::Screen => 80,
ObjectClass::Screen | ObjectClass::Spin => 80,
_ => 28,
}
}
@@ -522,7 +591,7 @@ fn record_lengths(class: ObjectClass) -> &'static [usize] {
ObjectClass::OptionButton => &[21, 28],
ObjectClass::PictureBox => &[31],
ObjectClass::HScrollBar | ObjectClass::VScrollBar => &[32],
ObjectClass::Screen => &[73, 80],
ObjectClass::Screen | ObjectClass::Spin => &[73, 80],
ObjectClass::Timer => &[21, 28],
}
}
@@ -627,7 +696,12 @@ fn walk_object_records(
let next_symbol = bytes[header] as usize;
if next_symbol >= symbols.len()
|| bytes[header + 2] != 0
|| bytes[header + 1] & 0x7f != symbols[next_symbol].class.id()
|| bytes[header + 1] & 0x7f
!= if symbols[next_symbol].class == ObjectClass::Spin {
ObjectClass::Screen.id()
} else {
symbols[next_symbol].class.id()
}
{
continue;
}
@@ -978,7 +1052,7 @@ fn assign_strings(
) -> Result<Vec<BinaryWarning>, FormError> {
let mut warnings = records
.iter()
.filter(|record| symbols[record.symbol].class == ObjectClass::Screen)
.filter(|record| symbols[record.symbol].unsupported)
.map(|record| BinaryWarning {
offset: 0x20 + record.start,
name: format!("{}.CustomControl", symbols[record.symbol].name),
@@ -1063,10 +1137,9 @@ fn assign_strings(
)?;
}
}
for record in records
.iter()
.filter(|record| symbols[record.symbol].unsupported)
{
for record in records.iter().filter(|record| {
symbols[record.symbol].unsupported || symbols[record.symbol].class == ObjectClass::Spin
}) {
for offset in [17, 28, 37, 39, 53, 55, 64, 69] {
let Some(pointer) =
record_u16(bytes, record.start, record.end, offset).filter(|pointer| *pointer != 0)
@@ -1319,6 +1392,35 @@ mod tests {
assert_eq!(write_text(&form), EXAMPLE);
}
#[test]
fn text_form_control_arrays_share_one_catalog_object_and_keep_indices() {
let form = read_text(
"array.frm",
"VERSION 1.00\nBegin Form Form1\n Begin CommandButton Command1\n Index = 0\n Caption = \"null\"\n End\n Begin CommandButton Command1\n Index = 1\n Caption = \"eins\"\n End\nEnd\n",
)
.unwrap();
let catalog = form.catalog();
assert_eq!(catalog.objects.len(), 2);
assert!(catalog.objects[1].array);
let mut model = FormsModel::new(catalog.objects, 80, 25);
form.apply(&mut model).unwrap();
let caption = forms::property(ObjectClass::CommandButton, "CAPTION")
.unwrap()
.0;
assert_eq!(
model.get_at(1, Some(0), caption).unwrap(),
PropertyValue::String("null".into())
);
assert_eq!(
model.get_at(1, Some(1), caption).unwrap(),
PropertyValue::String("eins".into())
);
model.show(0, false).unwrap();
model.events.clear();
model.object_method(1, "SETFOCUS", vec![]).unwrap();
assert_eq!(model.next_event().unwrap().array_index, Some(0));
}
#[test]
fn rejects_version_class_property_value_and_open_block_with_location() {
let cases = [
@@ -1502,6 +1604,18 @@ mod tests {
assert_eq!(failure.name, "New.HEIGHT");
}
#[test]
fn spin_symbol_wird_als_bedienbares_control_erkannt() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&[1, 0, ObjectClass::Form.id(), 5]);
bytes.extend_from_slice(b"Form1");
bytes.extend_from_slice(&[0, 0, ObjectClass::Screen.id(), 5]);
bytes.extend_from_slice(b"VSpin");
let symbols = read_symbol_table(&bytes, 0).unwrap();
assert_eq!(symbols[1].class, ObjectClass::Spin);
assert!(!symbols[1].unsupported);
}
#[test]
fn unsupported_custom_control_is_named_and_not_emitted_as_screen() {
let symbols = vec![
@@ -1515,7 +1629,7 @@ mod tests {
BinarySymbol {
offset: 0,
class: ObjectClass::Screen,
name: "VSpin".into(),
name: "Custom1".into(),
is_array: false,
unsupported: true,
},
@@ -1569,7 +1683,7 @@ mod tests {
let root = build_tree(decoded).unwrap();
assert_eq!(warnings[0].offset, 0x46);
assert_eq!(warnings[0].name, "VSpin.CustomControl");
assert_eq!(warnings[0].name, "Custom1.CustomControl");
assert!(root.children.is_empty());
let mut strings_with_orphan = (100..=108)

View File

@@ -1317,7 +1317,7 @@ fn token_def(code: u16) -> Option<TokenDef> {
}),
0x138 => Some(TokenDef {
len: 0,
rules: &["expr::=MKS$({0})"],
rules: &["expr::=MKS$({expr:0})", "{0}"],
}),
0x139 => Some(TokenDef {
len: 0,
@@ -1682,7 +1682,7 @@ fn token_def(code: u16) -> Option<TokenDef> {
}),
0x1b2 => Some(TokenDef {
len: 0,
rules: &["expr::=MSGBOX({2}, {1}, {0})"],
rules: &["expr::=STR$({expr:0})"],
}),
0x1b3 => Some(TokenDef {
len: 0,
@@ -2421,6 +2421,23 @@ impl<'a> Decoder<'a> {
.ok_or_else(|| self.fail(self.at, "P-Code", "Tokendaten abgeschnitten"))?
.to_owned();
self.at += (len + 1) & !1;
if matches!(self.pcode, 0x065 | 0x066) {
let operands = usize::from(self.pcode == 0x066);
let split = self.stack.len().saturating_sub(operands);
if split > 0
&& self.stack[split - 1].kind != "newline"
&& !self.stack[split - 1].text.ends_with(" THEN ")
&& !self.stack[split - 1].text.ends_with(" ELSE ")
{
self.stack.insert(
split,
Item {
kind: "newline".into(),
text: "\r\u{1}".into(),
},
);
}
}
if matches!(self.pcode, 0x1c5 | 0x1c6) {
let first = self
.stack
@@ -2634,6 +2651,26 @@ mod tests {
assert_eq!(decoder.array(&array, false).unwrap(), "Oldcontents()");
}
#[test]
fn next_trennt_eine_vorangehende_anweisung_auch_ohne_newline_token() {
let (sym, ids) = symbols(&["i"]);
let mut code = Vec::new();
code.extend_from_slice(&0u16.to_le_bytes());
code.extend_from_slice(&0x00au16.to_le_bytes());
code.extend_from_slice(&9u16.to_le_bytes());
code.extend_from_slice(&[0, 0]);
code.extend_from_slice(b"PRINT 1");
code.push(0);
code.extend_from_slice(&(0x0400u16 | 0x00b).to_le_bytes());
code.extend_from_slice(&ids[0].to_le_bytes());
code.extend_from_slice(&0x066u16.to_le_bytes());
code.extend_from_slice(&[0xff, 0xff, 1, 0]);
code.extend_from_slice(&0u16.to_le_bytes());
code.extend_from_slice(&8u16.to_le_bytes());
let (decoded, _) = Decoder::new("test.frm", &sym, &code, 0).run().unwrap();
assert!(decoded.contains("PRINT 1\nNEXT i%\n"), "{decoded:?}");
}
#[test]
fn decodes_defint_and_vbdos_isam_tokens() {
let mut decoder = Decoder::new("test.frm", &[], &[], 0);

View File

@@ -302,6 +302,7 @@ instrs! {
0xA4 RetGosub;
0xA5 RetGosubTo(a: u32);
0xA6 OnJump(a: u16, b: bool); // Sprungtabelle, gosub?
0xA7 Run(a: u8); // 0 = ohne Ziel, 1 = Zeile, 2 = Datei
// 0xB0 — Prozeduren und Builtins
0xB0 Call(a: u16, b: u8);
@@ -316,6 +317,9 @@ instrs! {
0xB9 ObjectLoad(a: u16, b: bool, c: bool); // unload?, Index liegt auf Stack?
0xBA LoadDynamicObjectProperty(a: u16);
0xBB StoreDynamicObjectProperty(a: u16);
0xBC LoadObjectIndexedProperty(a: u16, b: u16);
0xBD ObjectMethodFn(a: u16, b: u16, c: u8);
0xBE StoreObjectIndexedProperty(a: u16, b: u16);
// 0xE0 — Ereignis-Traps (Sprachreferenz §8); Kennung vom Stack
0xE0 TrapDefine(a: u8, b: u32); // Quellenart, Sprungziel

View File

@@ -20,6 +20,10 @@ pub fn compile(hir: &HirModule) -> CompiledModule {
string_ids: HashMap::new(),
jump_tables: Vec::new(),
modul_label_pc: Vec::new(),
initialize_main: hir
.objects
.iter()
.any(|object| object.class == tb_frontend::forms::ObjectClass::Form),
};
let mut procs = Vec::new();
for proc in &hir.procs {
@@ -29,7 +33,7 @@ pub fn compile(hir: &HirModule) -> CompiledModule {
name: hir.name.clone(),
option_base: hir.option_base,
strings: cg.strings,
globals_init: hir.globals.iter().map(|g| slot_init(g)).collect(),
globals_init: hir.globals.iter().map(slot_init).collect(),
global_names: hir.globals.iter().map(|g| g.name.clone()).collect(),
udts: hir
.udts
@@ -85,6 +89,9 @@ struct Codegen {
/// `ON ERROR GOTO` aus einer Prozedur zeigt dorthin; da Prozedur 0
/// zuerst übersetzt wird, stehen die Positionen rechtzeitig fest.
modul_label_pc: Vec<Option<u32>>,
/// Formulare müssen erst nach den globalen DIM-Anweisungen Ereignisse
/// zustellen; reine Textprogramme behalten ihre bisherigen Grenzen.
initialize_main: bool,
}
struct ProcCtx {
@@ -160,7 +167,30 @@ impl Codegen {
fixups: Vec::new(),
table_fixups: Vec::new(),
};
if proc.kind == hir::HProcKind::Main {
let declarations = proc
.body
.iter()
.filter(|stmt| matches!(stmt.kind, HStmtKind::Dim { redim: false, .. }))
.collect::<Vec<_>>();
for stmt in declarations {
self.stmt_inner(&mut ctx, proc, stmt, false);
}
if self.initialize_main
|| proc
.body
.iter()
.any(|stmt| matches!(stmt.kind, HStmtKind::Dim { redim: false, .. }))
{
ctx.emit(Instr::Stmt(0));
}
}
for stmt in &proc.body {
if proc.kind == hir::HProcKind::Main
&& matches!(stmt.kind, HStmtKind::Dim { redim: false, .. })
{
continue;
}
self.stmt(&mut ctx, proc, stmt);
}
// Rumpfende
@@ -234,12 +264,17 @@ impl Codegen {
// ---- Anweisungen -------------------------------------------------------
fn stmt(&mut self, ctx: &mut ProcCtx, proc: &hir::HProc, stmt: &HStmt) {
self.stmt_inner(ctx, proc, stmt, true);
}
fn stmt_inner(&mut self, ctx: &mut ProcCtx, proc: &hir::HProc, stmt: &HStmt, boundary: bool) {
match &stmt.kind {
HStmtKind::Label(l) => {
ctx.bind(*l);
return;
}
_ => ctx.emit(Instr::Stmt(stmt.line)),
_ if boundary => ctx.emit(Instr::Stmt(stmt.line)),
_ => {}
}
match &stmt.kind {
HStmtKind::Label(_) => unreachable!(),
@@ -263,6 +298,23 @@ impl Codegen {
index.is_some(),
));
}
HStmtKind::SetObjectIndexedProperty {
object,
object_index,
property,
index,
value,
} => {
if let Some(object_index) = object_index {
self.expr(ctx, object_index);
}
self.expr(ctx, index);
self.expr(ctx, value);
ctx.emit(Instr::StoreObjectIndexedProperty(
*object,
*property | if object_index.is_some() { 0x8000 } else { 0 },
));
}
HStmtKind::SetDynamicObjectProperty {
object,
property,
@@ -275,13 +327,21 @@ impl Codegen {
}
HStmtKind::ObjectMethod {
object,
index,
method,
args,
} => {
if let Some(index) = index {
self.expr(ctx, index);
}
for arg in args {
self.expr(ctx, arg);
}
ctx.emit(Instr::ObjectMethod(*object, *method, args.len() as u8));
ctx.emit(Instr::ObjectMethod(
*object,
*method,
args.len() as u8 | if index.is_some() { 0x80 } else { 0 },
));
}
HStmtKind::ObjectLoad {
object,
@@ -509,6 +569,18 @@ impl Codegen {
None => ctx.emit(Instr::RetGosub),
Some(l) => ctx.emit_jump(Instr::RetGosubTo(0), *l),
},
HStmtKind::Run { target, string } => {
if let Some(target) = target {
self.expr(ctx, target);
}
ctx.emit(Instr::Run(if target.is_none() {
0
} else if *string {
2
} else {
1
}));
}
HStmtKind::ExitProc => self.emit_proc_exit(ctx, proc),
HStmtKind::CallSub { proc: id, args } => {
for a in args {
@@ -533,6 +605,9 @@ impl Codegen {
}
let id = builtin_id(*b);
ctx.emit(Instr::CallBuiltin(id, args.len() as u8));
if matches!(b, Builtin::MsgBox) {
ctx.emit(Instr::Pop);
}
if builtin_returns_value(*b) {
ctx.emit(Instr::Pop);
}
@@ -725,6 +800,41 @@ impl Codegen {
index.is_some(),
));
}
HExpr::ObjectIndexedProperty {
object,
object_index,
property,
index,
..
} => {
if let Some(object_index) = object_index {
self.expr(ctx, object_index);
}
self.expr(ctx, index);
ctx.emit(Instr::LoadObjectIndexedProperty(
*object,
*property | if object_index.is_some() { 0x8000 } else { 0 },
));
}
HExpr::ObjectMethodCall {
object,
index,
method,
args,
..
} => {
if let Some(index) = index {
self.expr(ctx, index);
}
for arg in args {
self.expr(ctx, arg);
}
ctx.emit(Instr::ObjectMethodFn(
*object,
*method,
args.len() as u8 | if index.is_some() { 0x80 } else { 0 },
));
}
HExpr::DynamicObjectProperty { object, property } => {
self.expr(ctx, object);
let property = self.pool(property);
@@ -1173,6 +1283,9 @@ fn builtin_id(b: Builtin) -> u16 {
Builtin::Width => ids::WIDTH,
Builtin::ViewPrint => ids::VIEW_PRINT,
Builtin::ScreenStmt => ids::SCREEN_STMT,
Builtin::GraphicsLine => ids::GRAPHICS_LINE,
Builtin::GraphicsPaint => ids::GRAPHICS_PAINT,
Builtin::GraphicsView => ids::GRAPHICS_VIEW,
Builtin::KeyAssign => ids::KEY_ASSIGN,
Builtin::KeyList => ids::KEY_LIST,
Builtin::KeyDisplay => ids::KEY_DISPLAY,
@@ -1277,6 +1390,10 @@ fn builtin_id(b: Builtin) -> u16 {
Builtin::IsamSavepoint => ids::ISAM_SAVEPOINT,
Builtin::IsamSetmem => ids::ISAM_SETMEM,
Builtin::IsamBof => ids::ISAM_BOF,
Builtin::MsgBox => ids::MSGBOX,
Builtin::InputBoxS => ids::INPUTBOX_S,
Builtin::ClipboardAdd => ids::CLIPBOARD_ADD,
Builtin::ClipboardGet => ids::CLIPBOARD_GET,
}
}

View File

@@ -10,11 +10,11 @@
//! - `GOSUB`-Stack pro Frame; `RETURN` ohne GOSUB → Fehler 3.
use crate::bytecode::{CmpOp, CompiledModule, Instr};
use std::collections::HashSet;
use std::collections::{HashSet, VecDeque};
use std::rc::Rc;
use tb_runtime::builtins::{builtin_table, RtState};
use tb_runtime::builtins::{builtin_table, ids, RtState};
use tb_runtime::errors::RuntimeError;
use tb_runtime::host::Host;
use tb_runtime::host::{Ereignis, Host};
use tb_runtime::traps::Quelle;
use tb_runtime::value::{self, default_value, ArrayObj, RecordObj, TypeInit, Value, VarRef};
use tb_ui::forms::{FormEvent, FormsModel, PropertyValue, ShowResult};
@@ -37,6 +37,10 @@ pub enum RunEvent {
Interrupted {
line: u32,
},
Restart {
program: Option<String>,
line: Option<u32>,
},
/// Unbehandelter Laufzeitfehler.
Error {
code: u16,
@@ -106,6 +110,7 @@ pub struct Vm {
tick_zaehler: u32,
breakpoints: HashSet<u32>,
data_ptr: usize,
start_pc: Option<usize>,
pub forms: FormsModel,
}
@@ -166,6 +171,7 @@ impl Vm {
tick_zaehler: 0,
breakpoints: HashSet::new(),
data_ptr: 0,
start_pc: None,
forms,
module,
};
@@ -176,6 +182,24 @@ impl Vm {
vm
}
pub fn start_at_line(&mut self, line: u32) -> Result<(), RuntimeError> {
let target = self.module.procs[0]
.code
.iter()
.position(|instruction| matches!(instruction, Instr::Stmt(found) if *found == line));
let target = target.ok_or(RuntimeError(8))?;
if self.module.procs[0]
.code
.iter()
.any(|instruction| matches!(instruction, Instr::Stmt(0)))
{
self.start_pc = Some(target);
} else {
self.frames[0].pc = target;
}
Ok(())
}
fn push_frame(&mut self, proc: usize, argc: usize) {
// Argumente liegen zuoberst auf dem Stack (links → rechts).
let locals_base = self.locals.len();
@@ -204,6 +228,238 @@ impl Vm {
});
}
fn external_arg(&self, value: &Value) -> Result<Value, RuntimeError> {
match value {
Value::Ref(reference) => self.read_ref(reference),
value => Ok(value.clone()),
}
}
fn external_string(&self, value: &Value) -> Result<String, RuntimeError> {
match self.external_arg(value)? {
Value::Str(text) => Ok(text.to_string()),
_ => Err(RuntimeError::TYPE_MISMATCH),
}
}
fn external_set(
&mut self,
args: &[Value],
index: usize,
value: Value,
) -> Result<(), RuntimeError> {
match args.get(index) {
Some(Value::Ref(reference)) => self.write_ref(reference, value),
_ => Ok(()),
}
}
fn dialog_integer(&self, value: &Value) -> Result<i32, RuntimeError> {
match self.external_arg(value)? {
Value::Int(value) => Ok(value as i32),
Value::Lng(value) => Ok(value),
_ => Err(RuntimeError::TYPE_MISMATCH),
}
}
fn take_dialog_input(&mut self) -> VecDeque<Ereignis> {
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 restore_dialog_input(&mut self, events: VecDeque<Ereignis>) {
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 forms_dialog(
&mut self,
id: u16,
args: &[Value],
host: &mut dyn Host,
) -> Result<Option<Value>, RuntimeError> {
match id {
ids::MSGBOX => {
let text =
self.external_string(args.first().ok_or(RuntimeError::TYPE_MISMATCH)?)?;
let kind = args
.get(1)
.map_or(Ok(0), |value| self.dialog_integer(value))?;
let title = args
.get(2)
.map_or_else(|| Ok(String::new()), |value| self.external_string(value))?;
let mut queued = self.take_dialog_input();
let result = tb_ui::forms::msgbox_dialog(
&mut self.rt.screen,
host,
&mut queued,
&text,
kind,
&title,
);
self.restore_dialog_input(queued);
result.map(|value| Some(Value::Int(value)))
}
ids::INPUTBOX_S => {
let prompt =
self.external_string(args.first().ok_or(RuntimeError::TYPE_MISMATCH)?)?;
let title = args
.get(1)
.map_or_else(|| Ok(String::new()), |value| self.external_string(value))?;
let initial = args
.get(2)
.map_or_else(|| Ok(String::new()), |value| self.external_string(value))?;
let position = match (args.get(3), args.get(4)) {
(None, None) => None,
(Some(x), Some(y)) => Some((self.dialog_integer(x)?, self.dialog_integer(y)?)),
_ => return Err(RuntimeError::TYPE_MISMATCH),
};
let mut queued = self.take_dialog_input();
let result = tb_ui::forms::inputbox_dialog(
&mut self.rt.screen,
host,
&mut queued,
&prompt,
&title,
&initial,
position,
);
self.restore_dialog_input(queued);
result.map(|value| Some(Value::Str(Rc::from(value))))
}
_ => Err(RuntimeError::FEATURE_UNAVAILABLE),
}
}
fn external_dialog(
&mut self,
proc: usize,
argc: usize,
host: &mut dyn Host,
) -> Result<bool, RuntimeError> {
if !matches!(self.module.procs[proc].code.as_slice(), [Instr::RetProc]) {
return Ok(false);
}
let name = self.module.procs[proc].name.to_ascii_uppercase();
if !matches!(
name.as_str(),
"CMNDLGREGISTER"
| "CMNDLGCLOSE"
| "ABOUT"
| "FILEOPEN"
| "FILESAVE"
| "FILEPRINT"
| "FINDTEXT"
| "CHANGETEXT"
| "COLORPALETTE"
) {
return Ok(false);
}
let mut args = self.stack.split_off(self.stack.len().saturating_sub(argc));
match name.as_str() {
"CMNDLGREGISTER" => self.external_set(&args, 0, Value::Int(-1))?,
"CMNDLGCLOSE" => {}
"ABOUT" => {
let dialog = vec![
self.external_arg(&args[0])?,
Value::Lng(0),
Value::Str(Rc::from("About")),
];
self.forms_dialog(ids::MSGBOX, &dialog, host)?;
}
"FILEOPEN" | "FILESAVE" => {
let file = self.external_string(&args[0])?;
let path = self.external_string(&args[1])?;
let title = if name == "FILEOPEN" {
"Open file"
} else {
"Save file"
};
let default = if path.is_empty() {
file
} else {
format!("{path}{sep}{file}", sep = std::path::MAIN_SEPARATOR)
};
let dialog = vec![
Value::Str(Rc::from(title)),
Value::Str(Rc::from(title)),
Value::Str(Rc::from(default)),
];
let result = self
.forms_dialog(ids::INPUTBOX_S, &dialog, host)?
.unwrap_or(Value::Str(Rc::from("")));
let Value::Str(result) = result else {
return Err(RuntimeError::TYPE_MISMATCH);
};
let result = result.to_string();
if result.is_empty() {
self.external_set(&args, 7, Value::Int(-1))?;
} else {
let (path, file) = result
.rsplit_once(['/', '\\'])
.map_or(("", result.as_str()), |(path, file)| (path, file));
self.external_set(&args, 0, Value::Str(Rc::from(file)))?;
self.external_set(&args, 1, Value::Str(Rc::from(path)))?;
self.external_set(&args, 7, Value::Int(0))?;
}
}
"FILEPRINT" => {
self.external_set(&args, 0, Value::Int(1))?;
self.external_set(&args, 3, Value::Int(0))?;
}
"FINDTEXT" | "CHANGETEXT" => {
let dialog = vec![
Value::Str(Rc::from(if name == "FINDTEXT" {
"Find text"
} else {
"Replacement text"
})),
Value::Str(Rc::from("Find")),
self.external_arg(&args[usize::from(name == "CHANGETEXT")])?,
];
let result = self
.forms_dialog(ids::INPUTBOX_S, &dialog, host)?
.unwrap_or(Value::Str(Rc::from("")));
let target = usize::from(name == "CHANGETEXT");
self.external_set(&args, target, result.clone())?;
let empty = matches!(&result, Value::Str(text) if text.is_empty());
self.external_set(&args, args.len() - 1, Value::Int(-i16::from(empty)))?;
}
"COLORPALETTE" => {
let color = self.external_arg(&args[0])?;
self.external_set(&args, 0, color)?;
self.external_set(&args, 3, Value::Int(0))?;
}
_ => unreachable!(),
}
args.clear();
Ok(true)
}
pub fn queue_form_event(&mut self, event: FormEvent) {
self.forms.queue(event);
}
@@ -221,6 +477,17 @@ impl Vm {
}
}
fn form_arg(v: Value) -> Result<PropertyValue, RuntimeError> {
Ok(match v {
Value::Int(v) => PropertyValue::Integer(v as i32),
Value::Lng(v) => PropertyValue::Integer(v),
Value::Sng(v) => PropertyValue::Single(v),
Value::Str(v) => PropertyValue::String(v.to_string()),
Value::Obj(object, index) => PropertyValue::Object(Some((object, index))),
_ => return Err(RuntimeError::TYPE_MISMATCH),
})
}
fn property_value(
&self,
object: u16,
@@ -322,36 +589,37 @@ impl Vm {
Ok(())
}
fn forms_zustellen(&mut self) -> bool {
fn tick(&mut self, host: &mut dyn Host) {
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);
}
}
fn forms_zustellen(&mut self, host: &mut dyn Host) -> bool {
self.forms
.resize(self.rt.screen.cols(), self.rt.screen.rows());
while let Some(m) = self.rt.maus.pop_front() {
if let Some(object) = self.forms.active_form() {
let name = match m.art {
tb_runtime::host::MausArt::Druck => "MOUSEDOWN",
tb_runtime::host::MausArt::Loslassen => "MOUSEUP",
tb_runtime::host::MausArt::Bewegung => "MOUSEMOVE",
};
self.forms.queue(FormEvent {
object,
array_index: None,
name: name.into(),
args: vec![
PropertyValue::Integer(m.taste as i32),
PropertyValue::Integer(m.shift as i32),
PropertyValue::Single(m.spalte as f32),
PropertyValue::Single(m.zeile as f32),
],
});
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);
}
}
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.rt.traps.aktiv() {
if self.forms.menu_is_open() || !self.rt.traps.aktiv() {
return false;
}
let jetzt = host.jetzt_ms();
@@ -377,7 +645,7 @@ impl Vm {
// virtuelle nie vorrückt.
let real_start = std::time::Instant::now();
loop {
self.rt.tick(host);
self.tick(host);
if self.zustellen(host) {
return;
}
@@ -531,14 +799,41 @@ impl Vm {
// ---- Hauptschleife --------------------------------------------------------
/// Hält modellose Formulare nach dem Ende des Modulrumpfs bedienbar.
/// Ereignisprozeduren laufen weiter auf derselben VM und können das
/// Formular schließen, ein anderes Programm starten oder einen Fehler
/// auslösen.
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);
if dispatched {
match self.run(host) {
RunEvent::Ended => {}
event => return event,
}
}
if self.rt.ende {
return RunEvent::Ended;
}
if self.rt.abbruch {
return RunEvent::Interrupted {
line: self.current_line(),
};
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
RunEvent::Ended
}
pub fn run(&mut self, host: &mut dyn Host) -> RunEvent {
loop {
if let Some(form) = self.frames.last().and_then(|f| f.waiting_form) {
if !self.forms.is_visible(form) {
self.frames.last_mut().unwrap().waiting_form = None;
} else {
self.rt.tick(host);
self.forms_zustellen();
self.tick(host);
self.forms_zustellen(host);
if self.rt.ende {
return RunEvent::Ended;
}
@@ -818,6 +1113,11 @@ impl Vm {
use Instr as I;
match instr {
I::Stmt(line) => {
if line == 0 {
if let Some(target) = self.start_pc.take() {
self.frames[0].pc = target;
}
}
let f = self.frames.last_mut().unwrap();
f.line = line;
f.last_stmt_pc = pc;
@@ -826,11 +1126,12 @@ impl Vm {
// Debugger-Funktion — ohne sie sähe niemand die Ausgabe und
// Größenänderungen kämen nie an.
self.tick_zaehler = self.tick_zaehler.wrapping_add(1);
if self.rt.screen.ist_veraendert() || self.tick_zaehler % 1024 == 0 {
self.rt.tick(host);
self.forms.render(&mut self.rt.screen);
if self.rt.screen.ist_veraendert() || self.tick_zaehler.is_multiple_of(1024) {
self.tick(host);
self.rt.screen.veraenderung_quittieren();
}
if self.forms_zustellen() {
if self.forms_zustellen(host) {
return Ok(Flow::Normal);
}
// Ereigniszustellung (design.md, D1): nur wenn überhaupt
@@ -841,7 +1142,7 @@ impl Vm {
// Auflösung eines Zeit-Traps ist damit 64 Anweisungen;
// reicht das nicht, wird daraus ein eigener Zähler je
// Trap.
if self.tick_zaehler % 64 == 0 {
if self.tick_zaehler.is_multiple_of(64) {
let jetzt = host.jetzt_ms();
self.rt.traps.zeit_pruefen(jetzt);
}
@@ -901,8 +1202,8 @@ impl Vm {
// bekommt seinen eigenen Stapelabschnitt darüber und
// lässt den Wert unberührt.
self.stack.push(Value::Int(0));
self.rt.tick(host);
self.forms_zustellen();
self.tick(host);
self.forms_zustellen(host);
self.zustellen(host);
Ok(Flow::Normal)
}
@@ -1051,6 +1352,56 @@ impl Vm {
self.forms.set_at(object, index, property, value)?;
Ok(Flow::Normal)
}
I::LoadObjectIndexedProperty(object, property) => {
let index = self.pop_i32()?;
let object_index = if property & 0x8000 != 0 {
Some(self.pop_i32()?)
} else {
None
};
let value =
self.forms
.get_indexed_at(object, object_index, property & 0x7fff, index)?;
self.push(Self::form_value(value));
Ok(Flow::Normal)
}
I::StoreObjectIndexedProperty(object, property) => {
let value = self.pop_i32()?;
let index = self.pop_i32()?;
let object_index = if property & 0x8000 != 0 {
Some(self.pop_i32()?)
} else {
None
};
self.forms
.set_indexed_at(object, object_index, property & 0x7fff, index, value)?;
Ok(Flow::Normal)
}
I::ObjectMethodFn(object, method, argc) => {
let indexed = argc & 0x80 != 0;
let argc = argc & 0x7f;
let class = self
.module
.objects
.get(object as usize)
.ok_or(RuntimeError(420))?
.class;
let name = *tb_frontend::forms::methods(class)
.get(method as usize)
.ok_or(RuntimeError(421))?;
let mut args = Vec::with_capacity(argc as usize);
for _ in 0..argc {
args.push(Self::form_arg(self.pop()?)?);
}
args.reverse();
let index = if indexed { Some(self.pop_i32()?) } else { None };
let value = self
.forms
.object_method_at(object, index, name, args)?
.ok_or(RuntimeError(421))?;
self.push(Self::form_value(value));
Ok(Flow::Normal)
}
I::TypeOf(class) => {
let matches = match self.pop()? {
Value::Obj(object, _) => self
@@ -1064,6 +1415,8 @@ impl Vm {
Ok(Flow::Normal)
}
I::ObjectMethod(object, method, argc) => {
let indexed = argc & 0x80 != 0;
let argc = argc & 0x7f;
let class = self
.module
.objects
@@ -1093,6 +1446,7 @@ impl Vm {
args.push(self.pop()?);
}
args.reverse();
let index = if indexed { Some(self.pop_i32()?) } else { None };
if resumed_show && !self.forms.is_loaded(object) {
return Ok(Flow::Normal);
}
@@ -1118,13 +1472,26 @@ impl Vm {
(tb_frontend::forms::ObjectClass::Form, "UNLOAD") => {
self.request_unload(object)?
}
(tb_frontend::forms::ObjectClass::Form, "PRINTFORM") => {
self.forms.render(&mut self.rt.screen);
self.rt
.print
.drucker
.push_str(&tb_runtime::snapshot::text(&self.rt.screen));
}
(tb_frontend::forms::ObjectClass::Screen, "SHOW") => {
self.forms.screen_show(true)
}
(tb_frontend::forms::ObjectClass::Screen, "HIDE") => {
self.forms.screen_show(false)
}
_ => return Err(RuntimeError::FEATURE_UNAVAILABLE),
_ => {
let args = args
.into_iter()
.map(Self::form_arg)
.collect::<Result<Vec<_>, _>>()?;
self.forms.object_method_at(object, index, name, args)?;
}
}
self.dispatch_next_form_event();
Ok(Flow::Normal)
@@ -1318,7 +1685,7 @@ impl Vm {
s.chars().take(n).collect()
} else {
let mut t = s.to_string();
t.extend(std::iter::repeat(' ').take(n - len));
t.extend(std::iter::repeat_n(' ', n - len));
t
};
self.push(Value::Str(Rc::from(fixed.as_str())));
@@ -1714,9 +2081,27 @@ impl Vm {
}
Ok(Flow::Normal)
}
I::Run(kind) => {
let (program, line) = match kind {
0 => (None, None),
1 => {
let line = self.pop_i32()?;
if line < 0 {
return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
}
(None, Some(line as u32))
}
2 => (Some(self.pop_str()?.to_string()), None),
_ => return Err(RuntimeError::ILLEGAL_FUNCTION_CALL),
};
Ok(Flow::Event(RunEvent::Restart { program, line }))
}
// ---- Prozeduren ----
I::Call(proc, argc) => {
if self.external_dialog(proc as usize, argc as usize, host)? {
return Ok(Flow::Normal);
}
self.push_frame(proc as usize, argc as usize);
Ok(Flow::Normal)
}
@@ -1754,6 +2139,12 @@ impl Vm {
args.push(self.pop()?);
}
args.reverse();
if matches!(id, ids::MSGBOX | ids::INPUTBOX_S) {
if let Some(value) = self.forms_dialog(id, &args, host)? {
self.push(value);
}
return Ok(Flow::Normal);
}
let f = builtin_table()[id as usize];
match f(&mut self.rt, host, &mut args) {
Ok(Some(v)) => {
@@ -2228,9 +2619,7 @@ fn cur_mul(a: i64, b: i64) -> Result<i64, RuntimeError> {
let r = p.rem_euclid(10_000);
let rounded = if r > 5_000 {
q + 1
} else if r < 5_000 {
q
} else if q % 2 == 0 {
} else if r < 5_000 || q % 2 == 0 {
q
} else {
q + 1

View File

@@ -38,13 +38,11 @@ fn err_code(src: &str) -> u16 {
/// Verzeichnis, das beim Verlassen samt Inhalt verschwindet. Programme
/// hinterlassen dadurch nichts im Projektbaum.
struct TempVerzeichnis {
vorher: std::path::PathBuf,
dir: std::path::PathBuf,
}
impl TempVerzeichnis {
fn neu(name: &str) -> TempVerzeichnis {
let vorher = std::env::current_dir().unwrap();
let dir = std::env::temp_dir().join(format!(
"tb_vm_{name}_{}_{:?}",
std::process::id(),
@@ -52,14 +50,16 @@ impl TempVerzeichnis {
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::env::set_current_dir(&dir).unwrap();
TempVerzeichnis { vorher, dir }
TempVerzeichnis { dir }
}
fn pfad(&self, name: &str) -> String {
self.dir.join(name).to_string_lossy().replace('"', "\"\"")
}
}
impl Drop for TempVerzeichnis {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.vorher);
let _ = std::fs::remove_dir_all(&self.dir);
}
}
@@ -71,6 +71,48 @@ fn print_hallo_welt() {
assert_eq!(out("PRINT \"Hallo, Welt!\"\nEND"), "Hallo, Welt!\n");
}
#[test]
fn grafik_line_paint_und_view_zeichnen_in_den_zellenpuffer() {
assert_eq!(
out("SCREEN 2\nVIEW (0,0)-(31,15),0,1\nLINE (0,0)-(15,7),1,BF\nPAINT (24,8),1\nEND"),
"████\n████\n"
);
}
#[test]
fn run_gibt_datei_oder_zeile_an_den_runner_weiter() {
assert_eq!(
run("RUN \"next\"").0,
RunEvent::Restart {
program: Some("next".into()),
line: None
}
);
assert_eq!(
run("RUN 100").0,
RunEvent::Restart {
program: None,
line: Some(100)
}
);
}
#[test]
fn common_dialog_registrierung_setzt_byref_erfolg() {
assert_eq!(
out("DECLARE SUB CmnDlgRegister(ok AS INTEGER)\nCmnDlgRegister ok%\nPRINT ok%\nEND"),
"-1 \n"
);
}
#[test]
fn len_liefert_die_feste_satzbreite_eines_udt() {
assert_eq!(
out("TYPE Satz\nText AS STRING * 3\nZahl AS INTEGER\nEND TYPE\nDIM Wert AS Satz\nPRINT LEN(Wert)"),
" 14 \n"
);
}
#[test]
fn banker_rounding_cint() {
// Spec-Szenario: PRINT CINT(0.5); CINT(1.5); CINT(2.5) → " 0 2 2 "
@@ -486,31 +528,19 @@ fn print_zonen_und_tab() {
assert_eq!(out("PRINT \"a\"; SPC(3); \"b\""), "a b\n");
}
#[test]
fn unsupported_feature_fehler_73() {
// Dokumentiert, aber noch offen: `RUN` kommt mit Phase 5
// → Laufzeitfehler 73 mit dem Katalogtext des Vorbilds (VBDOS).
// (`SETUEVENT` stand hier bis zum Change `phase-4-ereignisschleife`.)
let (ev, _) = run("RUN");
match ev {
RunEvent::Error { code, message, .. } => {
assert_eq!(code, 73);
assert_eq!(message, "Feature unavailable");
}
other => panic!("{other:?}"),
}
}
/// Aufgabe 2.4 des Changes `phase-3-isam`: `OPEN … FOR ISAM` senkt nicht
/// mehr auf den „nicht verfügbar"-Marker ab, sondern arbeitet. Der Lauf
/// findet in einem temporären Verzeichnis statt und lässt nichts zurück.
#[test]
fn open_for_isam_endet_nicht_mehr_mit_fehler_73() {
let _dir = TempVerzeichnis::neu("open_isam");
let (ev, ausgabe) = run("TYPE T\n f AS INTEGER\nEND TYPE\n\
OPEN \"db.isam\" FOR ISAM T \"Tab\" AS #1\n\
let dir = TempVerzeichnis::neu("open_isam");
let (ev, ausgabe) = run(&format!(
"TYPE T\n f AS INTEGER\nEND TYPE\n\
OPEN \"{}\" FOR ISAM T \"Tab\" AS #1\n\
PRINT \"offen\"\n\
CLOSE #1");
CLOSE #1",
dir.pfad("db.isam")
));
assert_eq!(ev, RunEvent::Ended, "unerwartetes Ende: {ev:?}\n{ausgabe}");
assert!(ausgabe.contains("offen"), "{ausgabe}");
}
@@ -594,17 +624,10 @@ fn color_prueft_wertebereich() {
}
#[test]
fn screen_anweisung_nur_textmodus() {
// Literaler Grafikmodus schon zur Compile-Zeit.
let d = tb_vm::compile_source("T", "SCREEN 1").unwrap_err();
assert!(
d.iter().any(|x| x.message.contains("Feature unavailable")),
"{d:?}"
);
// Textmodus ist folgenlos zulässig.
fn screen_anweisung_bildet_grafikmodi_auf_den_zellenpuffer_ab() {
assert_eq!(out("SCREEN 0\nPRINT \"ok\""), "ok\n");
// Berechneter Modus erst zur Laufzeit.
assert_eq!(err_code("m% = 2\nSCREEN m%"), 73);
assert_eq!(out("SCREEN 13\nPRINT \"ok\""), "ok\n");
assert_eq!(err_code("m% = 14\nSCREEN m%"), 5);
}
#[test]
@@ -644,6 +667,32 @@ fn input_s_liest_genau_n_zeichen_ohne_echo() {
assert_eq!(tb_runtime::snapshot::text(&vm.rt.screen), "xyz\n");
}
#[test]
fn input_s_liest_aus_einer_binaerdatei() {
let tmp = TempVerzeichnis::neu("input_s_datei");
let pfad = tmp.pfad("daten.bin");
std::fs::write(&pfad, b"abcdef").unwrap();
assert_eq!(
out(&format!(
"OPEN \"{pfad}\" FOR BINARY AS #1\na$ = INPUT$(3, #1)\nPRINT a$\nCLOSE #1"
)),
"abc\n"
);
}
#[test]
fn clipboard_und_printer_objekte_haben_laufzeitwirkung() {
let module = tb_vm::compile_source(
"T",
"CLIPBOARD.ADDITEM \"abc\"\nPRINT CLIPBOARD.GETTEXT\nPRINTER.PRINT \"Seite\"\nPRINTER.NEWPAGE\nPRINTER.ENDDOC",
)
.unwrap();
let mut vm = Vm::new(module);
assert_eq!(vm.run(&mut CaptureHost::default()), RunEvent::Ended);
assert_eq!(tb_runtime::snapshot::text(&vm.rt.screen), "abc\n");
assert_eq!(vm.rt.print.drucker, "Seite\n\u{c}");
}
#[test]
fn cls_setzt_cursor_zurueck() {
assert_eq!(out("PRINT \"weg\"\nCLS\nPRINT \"neu\""), "neu\n");
@@ -1136,6 +1185,14 @@ fn objektzugriff_laeuft_ueber_objekt_und_eigenschaftsindex() {
assert_eq!(tb_runtime::snapshot::text(&vm.rt.screen).trim(), "hallo");
}
#[test]
fn screen_controlpanel_hat_einen_indexierten_laufzeitwert() {
assert_eq!(
out("SCREEN.CONTROLPANEL(5) = 3\nPRINT SCREEN.CONTROLPANEL(5)"),
" 3 \n"
);
}
#[test]
fn steuerarray_eigenschaften_und_objektparameter_funktionieren() {
let mut vm = form_vm(
@@ -1175,6 +1232,31 @@ fn modales_show_setzt_nach_unload_fort() {
assert!(host.presents >= 2);
}
#[test]
fn modelloses_formular_bleibt_nach_programmende_bedienbar() {
let mut vm = form_vm(
"DIM SHARED gesehen%\nEND\n\
SUB Form_MouseDown(Button AS INTEGER, Shift AS INTEGER, X AS SINGLE, Y AS SINGLE)\n\
SHARED gesehen%\ngesehen% = 1\nUNLOAD Form1\nEND SUB",
);
vm.forms.show(0, false).unwrap();
assert_eq!(vm.run(&mut CaptureHost::default()), RunEvent::Ended);
let mut host = CaptureHost::default();
host.ereignis_nach(
1,
tb_runtime::host::Ereignis::Maus(tb_runtime::host::MausEreignis {
art: tb_runtime::host::MausArt::Druck,
taste: 1,
shift: 0,
zeile: 1,
spalte: 1,
}),
);
assert_eq!(vm.run_visible_forms(&mut host), RunEvent::Ended);
assert!(matches!(vm.inspect("gesehen"), Some(Value::Int(1))));
assert!(!vm.forms.has_visible_forms());
}
#[test]
fn fehlende_ereignisprozedur_verfaellt_und_typeof_prueft_klasse() {
let mut vm = form_vm("Form1.Show\nIF TYPEOF Text1 IS TextBox THEN PRINT \"ja\"");
@@ -1278,3 +1360,153 @@ fn objektcode_erzeugt_keine_zusaetzlichen_zustellopcodes() {
.count();
assert_eq!(n, 1, "nur das explizite DOEVENTS ist ein Zustellopcode");
}
#[test]
fn listenmethoden_indexeigenschaft_und_picture_messung_laufen_in_der_vm() {
use tb_frontend::forms::ObjectClass;
let mut catalog = tb_frontend::forms::FormCatalog::default();
catalog.add("Form1", ObjectClass::Form, None, false);
catalog.add("List1", ObjectClass::ListBox, Some("Form1"), false);
catalog.add("Picture1", ObjectClass::PictureBox, Some("Form1"), false);
let module = tb_vm::compile_source_with_forms(
"FORM1",
"List1.Sorted = -1\nList1.ADDITEM \"b\"\nList1.ADDITEM \"a\"\nPRINT List1.List(0)\nPRINT List1.ListCount\nPRINT Picture1.TEXTWIDTH(\"abc\")",
&catalog,
)
.unwrap_or_else(|diagnostics| panic!("Compile-Fehler: {diagnostics:?}"));
let mut vm = Vm::new(module);
assert_eq!(vm.run(&mut CaptureHost::default()), RunEvent::Ended);
assert_eq!(
tb_runtime::snapshot::text(&vm.rt.screen).trim(),
"a\n 2 \n 3"
);
}
#[test]
fn listenmethoden_und_indexeigenschaft_laufen_auf_control_arrays() {
use tb_frontend::forms::ObjectClass;
let mut catalog = tb_frontend::forms::FormCatalog::default();
catalog.add("Form1", ObjectClass::Form, None, false);
catalog.add("List1", ObjectClass::ListBox, Some("Form1"), true);
let module = tb_vm::compile_source_with_forms(
"FORM1",
"LOAD List1(1)\nList1(1).ADDITEM \"x\"\nPRINT List1(1).List(0)",
&catalog,
)
.unwrap_or_else(|diagnostics| panic!("Compile-Fehler: {diagnostics:?}"));
let mut vm = Vm::new(module);
assert_eq!(vm.run(&mut CaptureHost::default()), RunEvent::Ended);
assert_eq!(tb_runtime::snapshot::text(&vm.rt.screen).trim(), "x");
}
#[test]
fn msgbox_und_inputbox_sind_keine_unsupported_opcodes_mehr() {
use tb_runtime::host::{taste, Ereignis};
let module = tb_vm::compile_source(
"DIALOG",
"r% = MSGBOX(\"Weiter?\", 4, \"Frage\")\ns$ = INPUTBOX$(\"Name\")\nPRINT r%; s$",
)
.unwrap_or_else(|diagnostics| panic!("Compile-Fehler: {diagnostics:?}"));
assert!(!module.procs[0]
.code
.iter()
.any(|instruction| matches!(instruction, tb_vm::bytecode::Instr::Unsupported(_))));
let mut vm = Vm::new(module);
let mut host = CaptureHost::default();
for key in [taste::ENTER, "A", taste::ENTER] {
host.ereignis(Ereignis::Taste(key.into(), 0));
}
assert_eq!(vm.run(&mut host), RunEvent::Ended);
assert_eq!(tb_runtime::snapshot::text(&vm.rt.screen).trim(), "6 A");
}
#[test]
fn default_button_hat_vorrang_und_beendet_modales_formular() {
use tb_frontend::forms::ObjectClass;
use tb_runtime::host::{taste, Ereignis};
let mut catalog = tb_frontend::forms::FormCatalog::default();
catalog.add("Form1", ObjectClass::Form, None, false);
catalog.add("Command1", ObjectClass::CommandButton, Some("Form1"), false);
let module = tb_vm::compile_source_with_forms(
"FORM1",
"DIM SHARED gesehen%\nForm1.Show 1\nPRINT gesehen%\nEND\nSUB Command1_Click()\nSHARED gesehen%\ngesehen% = 1\nUNLOAD Form1\nEND SUB",
&catalog,
)
.unwrap_or_else(|diagnostics| panic!("Compile-Fehler: {diagnostics:?}"));
let mut vm = Vm::new(module);
let default = tb_frontend::forms::property(ObjectClass::CommandButton, "DEFAULT")
.unwrap()
.0;
vm.forms
.set(1, default, tb_ui::forms::PropertyValue::Boolean(true))
.unwrap();
let mut host = CaptureHost::default();
host.ereignis(Ereignis::Taste(taste::ENTER.into(), 0));
assert_eq!(vm.run(&mut host), RunEvent::Ended);
assert!(matches!(vm.inspect("gesehen"), Some(Value::Int(1))));
}
#[test]
fn printform_schreibt_das_textformular_in_den_druckerkanal() {
use tb_frontend::forms::ObjectClass;
let mut catalog = tb_frontend::forms::FormCatalog::default();
catalog.add("Form1", ObjectClass::Form, None, false);
let module = tb_vm::compile_source_with_forms("FORM1", "Form1.Show\nForm1.PRINTFORM", &catalog)
.unwrap_or_else(|diagnostics| panic!("Compile-Fehler: {diagnostics:?}"));
let mut vm = Vm::new(module);
for (name, value) in [("WIDTH", 12), ("HEIGHT", 4)] {
let property = tb_frontend::forms::property(ObjectClass::Form, name)
.unwrap()
.0;
vm.forms
.set_initial(0, property, tb_ui::forms::PropertyValue::Integer(value))
.unwrap();
}
let caption = tb_frontend::forms::property(ObjectClass::Form, "CAPTION")
.unwrap()
.0;
vm.forms
.set_initial(
0,
caption,
tb_ui::forms::PropertyValue::String("Druck".into()),
)
.unwrap();
assert_eq!(vm.run(&mut CaptureHost::default()), RunEvent::Ended);
assert!(vm.rt.print.drucker.contains("Druck"));
}
#[test]
fn klassischer_timer_trap_ruht_solange_das_menu_offen_ist() {
use tb_frontend::forms::ObjectClass;
use tb_runtime::host::umschalt;
let mut catalog = tb_frontend::forms::FormCatalog::default();
catalog.add("Form1", ObjectClass::Form, None, false);
catalog.add("mnuDatei", ObjectClass::Menu, Some("Form1"), false);
let module = tb_vm::compile_source_with_forms(
"FORM1",
"DIM SHARED n%\nON TIMER(1) GOSUB Tick\nTIMER ON\nForm1.Show\nSTOP\nDOEVENTS\nSTOP\nDOEVENTS\nPRINT n%\nEND\nTick:\nn% = n% + 1\nRETURN",
&catalog,
)
.unwrap_or_else(|diagnostics| panic!("Compile-Fehler: {diagnostics:?}"));
let mut vm = Vm::new(module);
let caption = tb_frontend::forms::property(ObjectClass::Menu, "CAPTION")
.unwrap()
.0;
vm.forms
.set_initial(
1,
caption,
tb_ui::forms::PropertyValue::String("&Datei".into()),
)
.unwrap();
let mut host = CaptureHost::default();
assert!(matches!(vm.run(&mut host), RunEvent::Stopped { .. }));
assert!(vm.forms.handle_key("d", umschalt::ALT));
host.uhr_vorruecken(1_500);
assert!(matches!(vm.run(&mut host), RunEvent::Stopped { .. }));
assert!(matches!(vm.inspect("n"), Some(Value::Int(0))));
assert!(vm.forms.handle_key(tb_runtime::host::taste::ESC, 0));
assert_eq!(vm.run(&mut host), RunEvent::Ended);
assert!(matches!(vm.inspect("n"), Some(Value::Int(1))));
}