From 19804e0e2de9eb5a8b0ef256b5be76c741560e07 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sat, 5 Sep 2026 12:49:46 +0200 Subject: [PATCH] Phase 4: Steuerelemente implementieren --- PLAN.md | 31 +- crates/tb-cli/src/main.rs | 373 +- crates/tb-cli/tests/compat.rs | 270 +- crates/tb-frontend/src/ast.rs | 20 + crates/tb-frontend/src/forms.rs | 91 +- crates/tb-frontend/src/hir.rs | 33 + crates/tb-frontend/src/lexer.rs | 4 +- crates/tb-frontend/src/parser.rs | 298 +- crates/tb-frontend/src/sema.rs | 883 +++-- crates/tb-frontend/tests/inventar.rs | 5 +- crates/tb-runtime/src/builtins.rs | 146 +- crates/tb-runtime/src/screen.rs | 137 +- crates/tb-runtime/src/using.rs | 4 +- crates/tb-runtime/src/value.rs | 4 +- crates/tb-ui/src/forms.rs | 3071 ++++++++++++++++- crates/tb-ui/src/frm.rs | 144 +- crates/tb-ui/src/frm_pcode.rs | 41 +- crates/tb-vm/src/bytecode.rs | 4 + crates/tb-vm/src/codegen.rs | 123 +- crates/tb-vm/src/interp.rs | 463 ++- crates/tb-vm/tests/vm.rs | 300 +- docs/dateiformate.md | 5 +- docs/forms-referenz.md | 68 +- docs/inventar.md | 171 +- docs/sprachreferenz.md | 32 +- .../.openspec.yaml | 0 .../design.md | 15 +- .../proposal.md | 17 +- .../specs/forms-steuerelemente/spec.md | 0 .../specs/kompat-testkorpus/spec.md | 9 +- .../tasks.md | 58 +- .../vbdos-befunde.md | 44 + openspec/specs/forms-steuerelemente/spec.md | 140 + openspec/specs/kompat-testkorpus/spec.md | 27 + tests/compat/formular.frm | 97 + tests/compat/formular.out | 33 + 36 files changed, 6472 insertions(+), 689 deletions(-) rename openspec/changes/{phase-4-steuerelemente => archive/2026-09-05-phase-4-steuerelemente}/.openspec.yaml (100%) rename openspec/changes/{phase-4-steuerelemente => archive/2026-09-05-phase-4-steuerelemente}/design.md (91%) rename openspec/changes/{phase-4-steuerelemente => archive/2026-09-05-phase-4-steuerelemente}/proposal.md (84%) rename openspec/changes/{phase-4-steuerelemente => archive/2026-09-05-phase-4-steuerelemente}/specs/forms-steuerelemente/spec.md (100%) rename openspec/changes/{phase-4-steuerelemente => archive/2026-09-05-phase-4-steuerelemente}/specs/kompat-testkorpus/spec.md (79%) rename openspec/changes/{phase-4-steuerelemente => archive/2026-09-05-phase-4-steuerelemente}/tasks.md (63%) create mode 100644 openspec/changes/archive/2026-09-05-phase-4-steuerelemente/vbdos-befunde.md create mode 100644 openspec/specs/forms-steuerelemente/spec.md create mode 100644 tests/compat/formular.frm create mode 100644 tests/compat/formular.out diff --git a/PLAN.md b/PLAN.md index 7356f9d..5a7e14c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -393,6 +393,10 @@ Maßgeblich ist seit 2026-09-03 das Inventar, nicht diese Liste. Transaktion verfällt. ## Phase 4 — Forms-Engine (`tb-ui::forms`) + +**Status: abgeschlossen (2026-09-05).** Alle sieben Programme des +öffentlichen VBDOS-Bestands kompilieren ohne Diagnose und starten im PipeHost. + - [x] Eingangsaufgabe (aus Phase 0 übernommen): Ereignisschleifen- Architektur — Terminal-Events → Event-Queue → VM-Ticks, kooperative Zustellpunkte (`DOEVENTS`, `SLEEP`, blockierende Eingabe, Ende einer @@ -401,11 +405,12 @@ Maßgeblich ist seit 2026-09-03 das Inventar, nicht diese Liste. Forms-Ereignisprozeduren verwenden später denselben Rückrufweg. - [x] Formular-Modell: Eigenschaften, Lade-/Entladezyklus, `SHOW`/`HIDE` (modal/nicht-modal) -- [ ] Steuerelemente: CommandButton, TextBox, ListBox, ComboBox, CheckBox, +- [x] Steuerelemente: CommandButton, TextBox, ListBox, ComboBox, CheckBox, OptionButton, Frame, Label, HScrollBar/VScrollBar, PictureBox (Text), - Timer — mit allen Eigenschaften/Methoden/Ereignissen des Vorbilds -- [ ] Menüsystem (Menüleiste, Shortcuts, Access Keys) -- [ ] Fokus-/Tab-Reihenfolge, Access-Keys, Maussteuerung + Timer und VSpin/HSpin — mit ihren Eigenschaften, Methoden und + Ereignissen +- [x] Menüsystem (Menüleiste, Shortcuts, Access Keys) +- [x] Fokus-/Tab-Reihenfolge, Access-Keys, Maussteuerung - [x] `.FRM`-Textformat: Serialisierung **definieren** (kein Original- Beispiel verfügbar — Windows-1.0-Schema, siehe dateiformate.md), dokumentieren, dann lesen/schreiben implementieren @@ -413,9 +418,14 @@ Maßgeblich ist seit 2026-09-03 das Inventar, nicht diese Liste. (Gegenstück zu FT.EXE des Vorbilds; Magic `FC 08 01 00`): als `tbc convert-frm`. Format per Reverse Engineering aus den Beispieldateien des Originalpakets und des cout/vbdos-Repos -- [ ] Meilenstein (erster Kompatibilitätstest): die Programme aus +- [x] Meilenstein (erster Kompatibilitätstest): die Programme aus https://github.com/cout/vbdos ohne Non-Features kompilieren und sind nutzbar (Konsolenprogramme bereits ab Phase 3) + Befund: Alle sieben Programme wurden gegen Commit `1cdd2b3` als + `.MAK` bzw. `.FRM` ohne Diagnosen geprüft und im PipeHost gestartet. + Dafür wurden Projekt-/Include-Auflösung, P-Code-Dekodierung, + Zellgrafik und VSpin/HSpin vollständig geschlossen. Einzelheiten: + `openspec/changes/phase-4-steuerelemente/vbdos-befunde.md`. - [x] Ereignisdispatch: Event-Queue ↔ VM (Ereignisprozeduren `Name_Ereignis`) - [x] **Klassische Ereignis-Traps** (ohne Forms, Sprachreferenz §8) — `ON TIMER(n)`, `ON KEY(n)`, `ON UEVENT` und `ON SIGNAL(n)` je mit @@ -426,11 +436,14 @@ Maßgeblich ist seit 2026-09-03 das Inventar, nicht diese Liste. (Fehler 73). **10 Inventareinträge** Befund: `EVENT` besitzt nur `ON`/`OFF`; `SIGNAL(1/2)` bildet plattformübergreifend `SIGINT`/`SIGTERM` ab. -- [ ] **Vordefinierte Dialoge**: `MSGBOX` als Anweisung **und** als +- [x] **Vordefinierte Dialoge**: `MSGBOX` als Anweisung **und** als Funktion (liefert die gedrückte Schaltfläche) sowie `INPUTBOX$`. Sie hängen am Formularmodell, nicht an der Steuerelementliste, und fallen deshalb sonst zwischen die Aufgaben. **3 Inventareinträge** -- [ ] Meilenstein: Beispiel-Formularprogramme aus dem Testkorpus laufen +- [x] Meilenstein: Beispiel-Formularprogramme aus dem Testkorpus laufen + Befund: `formular.frm` speist Fokus, Tab, Access-Key, Mausklick, + Menüwahl, Liste, Timer und modalen Dialog über eine deklarierte, + virtuelle Ereignisfolge reproduzierbar ein. ## Phase 5 — IDE (`tb-ide`) - [ ] IDE-Rahmen: Menüleiste, MDI-artige Fensterverwaltung, Statuszeile, @@ -441,11 +454,11 @@ Maßgeblich ist seit 2026-09-03 das Inventar, nicht diese Liste. - [ ] Formular-Designer: Steuerelemente platzieren/verschieben/skalieren, Eigenschaftenfenster - [ ] Ausführen aus der IDE: Start/Unterbrechen/Fortsetzen/Neustart -- [ ] **`$INCLUDE`** im Compile-Treiber auflösen — der Lexer liefert den +- [x] **`$INCLUDE`** im Compile-Treiber auflösen — der Lexer liefert den Metabefehl bereits als Token. Datei einlesen, Pfade relativ zur einschließenden Datei, Zyklen erkennen und namentlich abweisen. **1 Inventareintrag** -- [ ] **`RUN`** — Programm neu starten bzw. ein anderes laden und starten. +- [x] **`RUN`** — Programm neu starten bzw. ein anderes laden und starten. Nutzt denselben Zurücksetzpfad der VM wie „Neustart" oben. **1 Inventareintrag** - [ ] Debugger: Breakpoints, Einzelschritt/Prozedurschritt, Direktfenster, diff --git a/crates/tb-cli/src/main.rs b/crates/tb-cli/src/main.rs index 93e8f15..62f8677 100644 --- a/crates/tb-cli/src/main.rs +++ b/crates/tb-cli/src/main.rs @@ -1,15 +1,16 @@ //! `tbc` — Standalone-Compiler von Terminal Basic. //! //! Unterbefehle (Phase 2): -//! - `tbc run ` Kompilieren und sofort ausführen -//! - `tbc build ` Kompilieren zu `datei.tbc` -//! - `tbc check ` Nur Syntax-/Semantikprüfung +//! - `tbc run ` Kompilieren und ausführen +//! - `tbc build ` Zu `.tbc` kompilieren +//! - `tbc check ` Syntax/Semantik prüfen //! - `tbc convert-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 | tbc convert-frm " + "Aufruf: tbc run|build|check | tbc convert-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 { + 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(¤t)?.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 { + 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) -> Result { + 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 { + 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, + types: HashSet, +} + +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::>(); + 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), 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::, _>>()? + } 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, + ), + ExitCode, +> { let Some(path) = path_arg else { - eprintln!("Aufruf: tbc run|build|check "); + eprintln!("Aufruf: tbc run|build|check "); 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 { + 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 "); + 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(¤t_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. diff --git a/crates/tb-cli/tests/compat.rs b/crates/tb-cli/tests/compat.rs index 5c97607..e01dbd8 100644 --- a/crates/tb-cli/tests/compat.rs +++ b/crates/tb-cli/tests/compat.rs @@ -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))> { /// ``…``, sonst je ein Zeichen; `+` davor bedeutet Umschalt, /// `^` Strg, `%` Alt — gebraucht für die benutzerdefinierten Trap-Tasten. fn tasten_direktive(name: &str, src: &str) -> Vec { - 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 { + use tb_runtime::host::{taste, umschalt, Ereignis}; let mut out = Vec::new(); let zeichen: Vec = wert.chars().collect(); let mut i = 0; @@ -143,6 +148,21 @@ fn tasten_direktive(name: &str, src: &str) -> Vec { "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 { out } +enum Ereignisschritt { + Ereignis(tb_runtime::host::Ereignis), + Zeit(u64), +} + +fn ereignis_direktive(name: &str, src: &str) -> Vec { + 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::() + .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, + 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 { + 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: 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 = 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:", + ); + 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 = 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 { diff --git a/crates/tb-frontend/src/ast.rs b/crates/tb-frontend/src/ast.rs index 8bf421c..59e6e71 100644 --- a/crates/tb-frontend/src/ast.rs +++ b/crates/tb-frontend/src/ast.rs @@ -480,6 +480,26 @@ pub enum Stmt { bottom: Option, pos: SourcePos, }, + GraphicsLine { + from: Option<(Expr, Expr)>, + to: (Expr, Expr), + relative: bool, + color: Option, + fill: u8, + pos: SourcePos, + }, + GraphicsPaint { + point: (Expr, Expr), + paint: Option, + border: Option, + pos: SourcePos, + }, + GraphicsView { + rect: Option<(Expr, Expr, Expr, Expr)>, + fill: Option, + border: Option, + 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 diff --git a/crates/tb-frontend/src/forms.rs b/crates/tb-frontend/src/forms.rs index 1a5d264..e2bb8b9 100644 --- a/crates/tb-frontend/src/forms.rs +++ b/crates/tb-frontend/src/forms.rs @@ -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 { 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 { 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 { 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 { + 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 + } } diff --git a/crates/tb-frontend/src/hir.rs b/crates/tb-frontend/src/hir.rs index d5fa5b3..cb4117d 100644 --- a/crates/tb-frontend/src/hir.rs +++ b/crates/tb-frontend/src/hir.rs @@ -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>, + property: u16, + index: Box, + ty: HTy, + }, + ObjectMethodCall { + object: u16, + index: Option>, + method: u16, + args: Vec, + ty: HTy, + }, DynamicObjectProperty { object: Box, property: String, @@ -511,6 +532,13 @@ pub enum HStmtKind { property: u16, value: HExpr, }, + SetObjectIndexedProperty { + object: u16, + object_index: Option, + property: u16, + index: HExpr, + value: HExpr, + }, SetDynamicObjectProperty { object: HExpr, property: String, @@ -518,6 +546,7 @@ pub enum HStmtKind { }, ObjectMethod { object: u16, + index: Option, method: u16, args: Vec, }, @@ -592,6 +621,10 @@ pub enum HStmtKind { targets: Vec, }, ReturnGosub(Option), + Run { + target: Option, + 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`. diff --git a/crates/tb-frontend/src/lexer.rs b/crates/tb-frontend/src/lexer.rs index 20ea751..376c652 100644 --- a/crates/tb-frontend/src/lexer.rs +++ b/crates/tb-frontend/src/lexer.rs @@ -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))); diff --git a/crates/tb-frontend/src/parser.rs b/crates/tb-frontend/src/parser.rs index 752c57c..54c2459 100644 --- a/crates/tb-frontend/src/parser.rs +++ b/crates/tb-frontend/src/parser.rs @@ -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 { + 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 { + 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 { + 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 { 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 { + fn parse_proc(&mut self, leading_static: bool) -> Option { 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 { 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; } diff --git a/crates/tb-frontend/src/sema.rs b/crates/tb-frontend/src/sema.rs index 770d745..d0f535d 100644 --- a/crates/tb-frontend/src/sema.rs +++ b/crates/tb-frontend/src/sema.rs @@ -17,7 +17,7 @@ use crate::hir::{ }; use crate::lexer::Suffix; use crate::{Diagnostic, SourcePos}; -use std::collections::HashMap; +use std::collections::{hash_map::Entry, HashMap, HashSet}; #[derive(Debug, Clone, PartialEq)] pub enum Ty { @@ -307,8 +307,8 @@ fn banned_feature(name: &str) -> bool { // Overlay-Mechanismus | "CHAIN" // Grafik - | "PSET" | "PRESET" | "CIRCLE" | "PAINT" | "DRAW" | "PALETTE" - | "PCOPY" | "PMAP" | "WINDOW" | "POINT" | "VIEW" + | "PSET" | "PRESET" | "CIRCLE" | "DRAW" | "PALETTE" + | "PCOPY" | "PMAP" | "WINDOW" | "POINT" // Event-Geräte (auch als Funktion in `ON COM(1) GOSUB …`) | "COM" | "PEN" | "STRIG" | "STICK" // Klang (außer BEEP) @@ -318,16 +318,11 @@ fn banned_feature(name: &str) -> bool { /// Non-Features, die nicht über ihren Namen abgewiesen werden können, weil /// sie ihr Schlüsselwort mit einer unterstützten Form teilen. Sie werden an -/// der Syntax erkannt: `GET`/`PUT` mit `(` (Grafik) und `LINE` ohne `INPUT` -/// im Parser, `OPEN` mit einem `COMn:`-Gerätenamen hier in der Semantik. +/// der Syntax erkannt: `GET`/`PUT` mit `(` (Grafik) im Parser und `OPEN` mit +/// einem `COMn:`-Gerätenamen hier in der Semantik. /// Der Inventar-Abgleich liest diese Liste. -pub const SYNTAKTISCH_ABGEWIESEN: &[&str] = &[ - "GET (Grafik)", - "PUT (Grafik)", - "LINE", - "OPEN COM", - "DEF SEG", -]; +pub const SYNTAKTISCH_ABGEWIESEN: &[&str] = + &["GET (Grafik)", "PUT (Grafik)", "OPEN COM", "DEF SEG"]; /// Ist `s` ein serieller Gerätename (`COM1:` …)? Nur für Stringliterale. fn ist_com_geraet(s: &str) -> bool { @@ -428,6 +423,7 @@ pub fn lower_with_forms( udt_defs: Vec::new(), consts: HashMap::new(), module_vars: HashMap::new(), + shared_vars: HashSet::new(), module_labels: HashMap::new(), module_line_labels: HashMap::new(), globals: Vec::new(), @@ -463,6 +459,8 @@ struct Sema { consts: HashMap)>, /// Modulvariablen (Schlüssel wie `var_key`). module_vars: HashMap, + /// Mit `DIM SHARED`/`COMMON SHARED` für Prozeduren sichtbare Modulvariablen. + shared_vars: HashSet, /// Sprungziele des Modulrumpfs — ein `ON ERROR GOTO` ohne `LOCAL` in /// einer Prozedur verweist auf sie (Scoping-Regel des Vorbilds). module_labels: HashMap, @@ -555,21 +553,60 @@ impl Sema { self.lower_proc(proc); } - // Zusammensetzen: fehlende Rümpfe (nur DECLARE) als leere Stubs. - let procs: Vec = self - .hir_procs - .drain(..) + // Zusammensetzen: fehlende Rümpfe (nur DECLARE) behalten ihre + // Signatur, damit externe Bibliotheksaufrufe dieselben BYREF-Slots + // und Funktionsrückgaben wie lokale Implementierungen besitzen. + let mut declared = vec![None; self.next_proc_id as usize]; + for (name, info) in self.procs.iter() { + declared[info.id as usize] = Some((name.clone(), info.clone())); + } + let procs: Vec = std::mem::take(&mut self.hir_procs) + .into_iter() .enumerate() .map(|(i, p)| { - p.unwrap_or_else(|| hir::HProc { - name: format!(""), - kind: hir::HProcKind::Sub, - params: Vec::new(), - locals: Vec::new(), - ret_slot: None, - ret_ty: None, - body: Vec::new(), - label_count: 0, + p.unwrap_or_else(|| { + let (name, info) = declared[i].clone().unwrap(); + let params = info + .params + .iter() + .enumerate() + .map(|(index, (ty, array))| hir::HParam { + name: format!("ARG{index}"), + ty: self.h_ty(ty), + array: *array, + by_ref: !array && !matches!(ty, Ty::Udt(_)), + }) + .collect::>(); + let mut locals = params + .iter() + .map(|param| hir::HVar { + name: param.name.clone(), + ty: param.ty.clone(), + array: param.array, + }) + .collect::>(); + let (kind, ret_slot, ret_ty) = if info.kind == ProcKind::Function { + let ty = self.h_ty(&info.ret); + let slot = VarSlot::Local(locals.len() as u16); + locals.push(hir::HVar { + name: name.clone(), + ty: ty.clone(), + array: false, + }); + (hir::HProcKind::Function, Some(slot), Some(ty)) + } else { + (hir::HProcKind::Sub, None, None) + }; + hir::HProc { + name, + kind, + params, + locals, + ret_slot, + ret_ty, + body: Vec::new(), + label_count: 0, + } }) }) .collect(); @@ -782,6 +819,25 @@ impl Sema { } } + fn fixed_width(&self, ty: &HTy) -> Option { + let bytes = match ty { + HTy::Num(NumTy::Int) => 2, + HTy::Num(NumTy::Lng | NumTy::Sng) => 4, + HTy::Num(NumTy::Cur | NumTy::Dbl) => 8, + HTy::FixedStr(length) => usize::try_from(*length).ok()?.checked_mul(4)?, + HTy::Udt(id) => self + .udt_defs + .get(*id as usize)? + .fields + .iter() + .try_fold(0usize, |sum, (_, field)| { + sum.checked_add(self.fixed_width(field)? as usize) + })?, + _ => return None, + }; + i16::try_from(bytes).ok() + } + fn register_event_proc(&mut self, proc: &Proc) { let Some((object, class, event)) = self.event_binding(&proc.sig.name) else { return; @@ -872,6 +928,24 @@ impl Sema { None } + fn implicit_form_property( + &self, + scope: &Scope, + name: &str, + ) -> Option<(u16, u16, forms::PropertySpec)> { + if self.declared_var(scope, name).is_some() || self.consts.contains_key(name) { + return None; + } + let (object, form) = self + .forms + .objects + .iter() + .enumerate() + .find(|(_, object)| object.class == ObjectClass::Form)?; + let (property, spec) = forms::property(form.class, name)?; + Some((object as u16, property, spec)) + } + fn object_property( &mut self, path: &str, @@ -886,10 +960,7 @@ impl Sema { }; let (id, object) = self.object_by_name(object_name, pos)?; if let Some(parent) = parent { - let parent_ok = object - .parent_form - .as_deref() - .is_some_and(|p| p.eq_ignore_ascii_case(parent)); + let parent_ok = self.forms.belongs_to(&object, parent); if !parent_ok { self.err( pos, @@ -922,11 +993,7 @@ impl Sema { }; let (id, object) = self.object_by_name(object_name, pos)?; if let Some(parent) = parent { - if !object - .parent_form - .as_deref() - .is_some_and(|p| p.eq_ignore_ascii_case(parent)) - { + if !self.forms.belongs_to(&object, parent) { self.err( pos, format!("Control '{object_name}' not found in form '{parent}'"), @@ -941,29 +1008,17 @@ impl Sema { let base = path.split('.').next().unwrap_or(path); let as_key = format!("{base}\u{1}AS"); let plain = self.var_key(base, &None, false); - self.vars_of(scope) - .get(&as_key) - .or_else(|| self.vars_of(scope).get(&plain)) + self.visible_var(scope, &as_key) + .or_else(|| self.visible_var(scope, &plain)) .is_some_and(|v| matches!(v.ty, Ty::Udt(_))) } fn declared_var(&self, scope: &Scope, name: &str) -> Option { let as_key = format!("{name}\u{1}AS"); let plain = self.var_key(name, &None, false); - self.vars_of(scope) - .get(&as_key) - .or_else(|| self.vars_of(scope).get(&plain)) + self.visible_var(scope, &as_key) + .or_else(|| self.visible_var(scope, &plain)) .cloned() - .or_else(|| { - if scope.is_def_fn { - self.module_vars - .get(&as_key) - .or_else(|| self.module_vars.get(&plain)) - .cloned() - } else { - None - } - }) } fn dynamic_property( @@ -1109,6 +1164,14 @@ impl Sema { } } + fn visible_var<'a>(&'a self, scope: &'a Scope, key: &str) -> Option<&'a VarInfo> { + self.vars_of(scope).get(key).or_else(|| { + (!scope.is_module && (scope.is_def_fn || self.shared_vars.contains(key))) + .then(|| self.module_vars.get(key)) + .flatten() + }) + } + fn insert_var(&mut self, scope: &mut Scope, key: String, info: VarInfo) { if scope.is_module { self.module_vars.insert(key, info); @@ -1171,27 +1234,20 @@ impl Sema { // AS-deklarierte Variable hat Vorrang, wenn kein Suffix angegeben let as_key = format!("{name}\u{1}AS"); if suffix.is_none() { - if let Some(v) = self.vars_of(scope).get(&as_key) { + if let Some(v) = self.visible_var(scope, &as_key) { return Some(v.clone()); } - if scope.is_def_fn { - if let Some(v) = self.module_vars.get(&as_key) { - return Some(v.clone()); - } + } else if let Some(v) = self.visible_var(scope, &as_key).cloned() { + if v.ty == suffix_ty(suffix.unwrap()) { + return Some(v); } - } else if self.vars_of(scope).contains_key(&as_key) { self.err(pos, "Duplicate definition"); return None; } let key = self.var_key(name, suffix, false); - if let Some(v) = self.vars_of(scope).get(&key) { + if let Some(v) = self.visible_var(scope, &key) { return Some(v.clone()); } - if scope.is_def_fn { - if let Some(v) = self.module_vars.get(&key) { - return Some(v.clone()); - } - } if self.explicit { self.err(pos, "Variable not defined"); return None; @@ -1425,6 +1481,35 @@ impl Sema { .. } = target { + if args.is_none() && !name.contains('.') && !name.contains('!') { + if let Some((object, property, spec)) = + self.implicit_form_property(scope, name) + { + if !spec.writable { + self.err( + *pos, + format!("Property '{}' is read-only at run time", spec.name), + ); + self.lower_expr(value, scope); + return; + } + let (value, actual) = self.lower_expr(value, scope); + let expected = Self::property_ty(spec); + self.check_assign(&expected, &actual, *pos); + let value = self.coerce_silent(value, &actual, &expected); + self.push( + out, + scope, + HStmtKind::SetObjectProperty { + object, + index: None, + property, + value, + }, + ); + return; + } + } if (name.contains('.') || name.contains('!')) && !self.is_udt_path(scope, name) { if name.contains('!') && !name.contains('.') { @@ -1481,6 +1566,41 @@ impl Sema { } if let Some((object, property, spec, _)) = self.object_property(name, *pos) { + if spec.ty == PropertyType::IntegerArray { + let Some(indices) = args else { + self.err(*pos, "Argument-count mismatch"); + self.lower_expr(value, scope); + return; + }; + let expected = if self.forms.objects[object as usize].array { + 2 + } else { + 1 + }; + if indices.len() != expected { + self.err(*pos, "Argument-count mismatch"); + self.lower_expr(value, scope); + return; + } + let object_index = self.forms.objects[object as usize] + .array + .then(|| self.lower_num_as(&indices[0], scope, NumTy::Lng)); + let index = + self.lower_num_as(&indices[expected - 1], scope, NumTy::Lng); + let value = self.lower_num_as(value, scope, NumTy::Lng); + self.push( + out, + scope, + HStmtKind::SetObjectIndexedProperty { + object, + object_index, + property, + index, + value, + }, + ); + return; + } let Ok(index) = self.object_index(object, args, scope, *pos) else { self.lower_expr(value, scope); return; @@ -1977,8 +2097,20 @@ impl Sema { let c = self.lower_num_as(code, scope, NumTy::Int); self.push(out, scope, HStmtKind::RaiseError(c)); } - Stmt::Dim { redim, decls, .. } => { + Stmt::Dim { + shared, + redim, + decls, + .. + } => { for d in decls { + if *shared && scope.is_module { + self.shared_vars.insert(self.var_key( + &d.name, + &d.suffix, + d.as_type.is_some(), + )); + } self.declare(d, *redim, scope, out); } } @@ -1987,25 +2119,29 @@ impl Sema { for d in decls { let ty = self.decl_ty(d); let key = self.var_key(&d.name, &d.suffix, d.as_type.is_some()); - if !scope.vars.contains_key(&key) { + if let Entry::Vacant(entry) = scope.vars.entry(key) { let slot = self.alloc_global(&format!("STATIC.{}", d.name), &ty, d.dims.is_some()); - scope.vars.insert( - key, - VarInfo { - ty, - array: d.dims.is_some(), - explicit: true, - slot, - global: true, - by_ref: false, - }, - ); + entry.insert(VarInfo { + ty, + array: d.dims.is_some(), + explicit: true, + slot, + global: true, + by_ref: false, + }); } } } - Stmt::CommonDecl { decls, .. } => { + Stmt::CommonDecl { shared, decls, .. } => { for d in decls { + if *shared && scope.is_module { + self.shared_vars.insert(self.var_key( + &d.name, + &d.suffix, + d.as_type.is_some(), + )); + } self.declare(d, false, scope, out); } } @@ -2448,6 +2584,106 @@ impl Sema { }, ); } + Stmt::GraphicsLine { + from, + to, + relative, + color, + fill, + .. + } => { + let mut args = Vec::new(); + if let Some((x, y)) = from { + for expr in [x, y] { + let (value, ty) = self.want_num(expr, scope); + args.push(self.conv_num(value, &ty, NumTy::Lng)); + } + } else { + args.extend([HExpr::Lng(i32::MIN), HExpr::Lng(i32::MIN)]); + } + for expr in [&to.0, &to.1] { + let (value, ty) = self.want_num(expr, scope); + args.push(self.conv_num(value, &ty, NumTy::Lng)); + } + let color = color.as_ref().map_or(HExpr::Lng(-1), |expr| { + let (value, ty) = self.want_num(expr, scope); + self.conv_num(value, &ty, NumTy::Lng) + }); + args.extend([ + color, + HExpr::Lng(i32::from(*relative)), + HExpr::Lng(i32::from(*fill)), + ]); + self.push( + out, + scope, + HStmtKind::BuiltinStmt { + b: Builtin::GraphicsLine, + args, + }, + ); + } + Stmt::GraphicsPaint { + point, + paint, + border, + .. + } => { + let mut args = Vec::new(); + for expr in [&point.0, &point.1] { + let (value, ty) = self.want_num(expr, scope); + args.push(self.conv_num(value, &ty, NumTy::Lng)); + } + args.push( + paint + .as_ref() + .map_or(HExpr::Lng(-1), |expr| self.lower_expr(expr, scope).0), + ); + args.push(border.as_ref().map_or(HExpr::Lng(-1), |expr| { + let (value, ty) = self.want_num(expr, scope); + self.conv_num(value, &ty, NumTy::Lng) + })); + self.push( + out, + scope, + HStmtKind::BuiltinStmt { + b: Builtin::GraphicsPaint, + args, + }, + ); + } + Stmt::GraphicsView { + rect, fill, border, .. + } => { + let mut args = Vec::new(); + if let Some((x1, y1, x2, y2)) = rect { + for expr in [x1, y1, x2, y2] { + let (value, ty) = self.want_num(expr, scope); + args.push(self.conv_num(value, &ty, NumTy::Lng)); + } + } else { + args.extend([ + HExpr::Lng(-1), + HExpr::Lng(-1), + HExpr::Lng(-1), + HExpr::Lng(-1), + ]); + } + for expr in [fill.as_ref(), border.as_ref()] { + args.push(expr.map_or(HExpr::Lng(-1), |expr| { + let (value, ty) = self.want_num(expr, scope); + self.conv_num(value, &ty, NumTy::Lng) + })); + } + self.push( + out, + scope, + HStmtKind::BuiltinStmt { + b: Builtin::GraphicsView, + args, + }, + ); + } Stmt::TrapDef { device, index, @@ -2711,6 +2947,7 @@ impl Sema { } } + #[allow(clippy::too_many_arguments)] fn lower_def_fn( &mut self, name: &str, @@ -2959,6 +3196,83 @@ impl Sema { pos: SourcePos, out: &mut Vec, ) { + if name == "CLIPBOARD.ADDITEM" { + if args.len() != 1 { + self.err(pos, "Argument-count mismatch for CLIPBOARD.ADDITEM"); + return; + } + let (value, _) = self.want_str(&args[0], scope); + self.push( + out, + scope, + HStmtKind::BuiltinStmt { + b: Builtin::ClipboardAdd, + args: vec![value], + }, + ); + return; + } + if name == "PRINTER.PRINT" { + self.push_ziel(out, scope, HExpr::Lng(-2)); + let items = args + .iter() + .map(|arg| HPrintItem::Val(self.lower_expr(arg, scope).0)) + .collect(); + self.push( + out, + scope, + HStmtKind::Print { + items, + trailing: false, + }, + ); + self.push_ziel(out, scope, HExpr::Lng(-1)); + return; + } + if matches!(name, "PRINTER.NEWPAGE" | "PRINTER.ENDDOC") { + if !args.is_empty() { + self.err(pos, format!("Argument-count mismatch for {name}")); + return; + } + if name == "PRINTER.NEWPAGE" { + self.push_ziel(out, scope, HExpr::Lng(-2)); + self.push( + out, + scope, + HStmtKind::Print { + items: vec![HPrintItem::Val(HExpr::Str("\u{c}".into()))], + trailing: true, + }, + ); + self.push_ziel(out, scope, HExpr::Lng(-1)); + } + return; + } + if matches!(name, "SHOW" | "HIDE") && args.is_empty() { + if let Some((object, _)) = self + .forms + .objects + .iter() + .enumerate() + .find(|(_, object)| object.class == ObjectClass::Form) + { + let method = forms::methods(ObjectClass::Form) + .iter() + .position(|method| *method == name) + .unwrap() as u16; + self.push( + out, + scope, + HStmtKind::ObjectMethod { + object: object as u16, + index: None, + method, + args: Vec::new(), + }, + ); + return; + } + } if name.contains('.') || name.contains('!') { if let Some((object, info, member)) = self.object_member_target(name, pos) { let Some(method) = forms::methods(info.class) @@ -2979,29 +3293,40 @@ impl Sema { return; } let member = forms::methods(info.class)[method]; + let (index, actual) = if info.array { + let Some((index, actual)) = args.split_first() else { + self.err(pos, format!("Object '{}' requires an index", info.name)); + return; + }; + (Some(self.lower_num_as(index, scope, NumTy::Lng)), actual) + } else { + (None, args) + }; let (min, max) = forms::method_arity(info.class, member).unwrap(); - if !(min..=max).contains(&args.len()) { + if !(min..=max).contains(&actual.len()) { self.err( pos, format!("Argument-count mismatch for {}.{member}", info.class.name()), ); - for arg in args { + for arg in actual { self.lower_expr(arg, scope); } return; } let hargs = if info.class == ObjectClass::Form && member == "SHOW" { - args.iter() + actual + .iter() .map(|a| self.lower_num_as(a, scope, NumTy::Lng)) .collect() } else { - args.iter().map(|a| self.lower_expr(a, scope).0).collect() + actual.iter().map(|a| self.lower_expr(a, scope).0).collect() }; self.push( out, scope, HStmtKind::ObjectMethod { object, + index, method: method as u16, args: hargs, }, @@ -3199,12 +3524,10 @@ impl Sema { // Variable oder parameterlose Funktion/Builtin? let as_key = format!("{name}\u{1}AS"); let key = self.var_key(name, suffix, false); - let declared = self.vars_of(scope).contains_key(&key) - || (suffix.is_none() && self.vars_of(scope).contains_key(&as_key)) - || (scope.is_def_fn - && (self.module_vars.contains_key(&key) - || (suffix.is_none() - && self.module_vars.contains_key(&as_key)))); + let declared = self.visible_var(scope, &key).is_some() + || self + .visible_var(scope, &as_key) + .is_some_and(|v| suffix.is_none_or(|s| v.ty == suffix_ty(s))); if declared { return true; } @@ -3227,11 +3550,13 @@ impl Sema { let as_key = format!("{name}\u{1}AS"); let key = self.var_key(name, suffix, false); let v = if suffix.is_none() { - self.vars_of(scope) - .get(&as_key) - .or_else(|| self.vars_of(scope).get(&key)) + self.visible_var(scope, &as_key) + .or_else(|| self.visible_var(scope, &key)) } else { - self.vars_of(scope).get(&key) + self.visible_var(scope, &key).or_else(|| { + self.visible_var(scope, &as_key) + .filter(|v| v.ty == suffix_ty(suffix.unwrap())) + }) }; v.map(|v| v.array).unwrap_or(false) } @@ -3367,12 +3692,13 @@ impl Sema { } // Bildschirmanweisungen: ausgelassene Argumente kommen als -1 // durch, damit die Laufzeit „weglassen" von „null" unterscheidet. - "ENVIRON" | "CLEAR" | "TRON" | "TROFF" | "STACK" => { + "ENVIRON" | "CLEAR" | "TRON" | "TROFF" | "STACK" | "MSGBOX" => { let bt = match name { "ENVIRON" => Builtin::EnvironSet, "CLEAR" => Builtin::Clear, "TRON" => Builtin::Tron, "TROFF" => Builtin::Troff, + "MSGBOX" => Builtin::MsgBox, _ => Builtin::StackStmt, }; let args: Vec = lowered.iter().map(|(e, _)| e.clone()).collect(); @@ -3447,15 +3773,6 @@ impl Sema { } } "CLS" | "COLOR" | "LOCATE" | "WIDTH" | "SCREEN" => { - // `SCREEN n` mit literalem n > 0 ist ein Grafikmodus und - // damit schon zur Compile-Zeit abweisbar. - if name == "SCREEN" { - if let Some(Expr::IntLit(n)) = args.first() { - if *n != 0 { - self.err(pos, "Feature unavailable"); - } - } - } let b = match name { "CLS" => Builtin::Cls, "COLOR" => Builtin::Color, @@ -3480,16 +3797,16 @@ impl Sema { args: vec![], }, ), + "RUN" => { + let (target, string) = match lowered.first() { + None => (None, false), + Some((expr, ty)) if is_str(ty) => (Some(expr.clone()), true), + Some((expr, ty)) => (Some(self.conv_num(expr.clone(), ty, NumTy::Lng)), false), + }; + self.push(out, scope, HStmtKind::Run { target, string }); + } // Bildschirm-/Datei-/System-Anweisungen späterer Phasen. - _ => self.push( - out, - scope, - HStmtKind::Unsupported(match name { - "RUN" => "RUN", - "MSGBOX" => "MSGBOX", - _ => "Anweisung", - }), - ), + _ => self.push(out, scope, HStmtKind::Unsupported("Anweisung")), } } @@ -3611,7 +3928,7 @@ impl Sema { // zu einem dokumentierten Element muss es benennen (Guiding // Principle) — sonst ist bei mehreren Aufrufen in einer Zeile // nicht erkennbar, welcher gemeint ist. - self.err(pos, &format!("Argument-count mismatch: {name}")); + self.err(pos, format!("Argument-count mismatch: {name}")); } // Sonderfall INSTR([start%,] s$, such$) let instr_with_start = name == "INSTR" && args.len() == 3; @@ -3766,30 +4083,21 @@ impl Sema { } None => None, }; - // UDT-Feldzugriff über Punktpfad? - if args.is_none() && name.contains('.') { + // UDT-Feldzugriff über Punktpfad, auch auf UDT-Arrays. + if name.contains('.') { let parts: Vec<&str> = name.split('.').collect(); let base = parts[0]; let base_as = format!("{base}\u{1}AS"); let base_info = self - .vars_of(scope) - .get(&base_as) - .or_else(|| self.vars_of(scope).get(&self.var_key(base, &None, false))) - .cloned() - .or_else(|| { - if scope.is_def_fn { - self.module_vars - .get(&base_as) - .or_else(|| { - self.module_vars.get(&self.var_key(base, &None, false)) - }) - .cloned() - } else { - None - } - }); + .visible_var(scope, &base_as) + .or_else(|| self.visible_var(scope, &self.var_key(base, &None, false))) + .cloned(); if let Some(info) = base_info { if matches!(info.ty, Ty::Udt(_)) { + if info.array != indices.is_some() { + self.err(*pos, "Duplicate definition"); + return (None, Ty::Unknown); + } let (fty, fpath) = self.member_path(&info.ty, &parts[1..], *pos); let hty = self.h_ty(&fty); let base_slot = if info.global { @@ -3801,7 +4109,7 @@ impl Sema { Some(HPlace { base: base_slot, base_is_ref: false, - indices: Vec::new(), + indices: indices.unwrap_or_default(), fields: fpath, ty: hty, array_elem: None, @@ -4176,6 +4484,32 @@ impl Sema { pos: SourcePos, scope: &mut Scope, ) -> (HExpr, Ty) { + if name == "CLIPBOARD.GETTEXT" && args.as_ref().is_none_or(Vec::is_empty) { + return ( + HExpr::Builtin { + b: Builtin::ClipboardGet, + args: Vec::new(), + ret: HTy::Str, + }, + Ty::Str, + ); + } + if name == "SCREEN" && args.is_some() { + return self.lower_builtin_fn(name, args.as_deref().unwrap(), scope, pos); + } + if suffix.is_none() && args.is_none() && !name.contains('.') && !name.contains('!') { + if let Some((object, property, spec)) = self.implicit_form_property(scope, name) { + return ( + HExpr::ObjectProperty { + object, + index: None, + property, + ty: self.h_ty(&Self::property_ty(spec)), + }, + Self::property_ty(spec), + ); + } + } if (name.contains('.') || name.contains('!')) && !self.is_udt_path(scope, name) { if name.contains('!') && !name.contains('.') { return match self.object_member_target(name, pos) { @@ -4216,8 +4550,37 @@ impl Sema { Ok(None) => {} } } - return match self.object_property(name, pos) { - Some((object, property, spec, _)) => { + let Some((object, info, member)) = self.object_member_target(name, pos) else { + return (HExpr::Int(0), Ty::Unknown); + }; + if let Some((property, spec)) = forms::property(info.class, &member) { + if spec.name == "LIST" || spec.ty == PropertyType::IntegerArray { + let Some(indices) = args else { + self.err(pos, "Argument-count mismatch for LIST"); + return (HExpr::Int(0), Ty::Unknown); + }; + let expected = if info.array { 2 } else { 1 }; + if indices.len() != expected { + self.err(pos, "Argument-count mismatch for LIST"); + return (HExpr::Int(0), Ty::Unknown); + } + let object_index = info + .array + .then(|| Box::new(self.lower_num_as(&indices[0], scope, NumTy::Lng))); + let index = self.lower_num_as(&indices[expected - 1], scope, NumTy::Lng); + let ty = Self::property_ty(spec); + return ( + HExpr::ObjectIndexedProperty { + object, + object_index, + property, + index: Box::new(index), + ty: self.h_ty(&ty), + }, + ty, + ); + } + return { let Ok(index) = self.object_index(object, args, scope, pos) else { return (HExpr::Int(0), Ty::Unknown); }; @@ -4230,9 +4593,71 @@ impl Sema { }, Self::property_ty(spec), ) + }; + } + if let Some(method) = forms::methods(info.class) + .iter() + .position(|m| m.eq_ignore_ascii_case(&member)) + { + let Some(return_type) = + forms::method_return_type(info.class, forms::methods(info.class)[method]) + else { + self.err(pos, format!("Method '{member}' has no return value")); + return (HExpr::Int(0), Ty::Unknown); + }; + let supplied = args.as_deref().unwrap_or(&[]); + let (index, actual) = if info.array { + let Some((index, actual)) = supplied.split_first() else { + self.err(pos, format!("Object '{}' requires an index", info.name)); + return (HExpr::Int(0), Ty::Unknown); + }; + ( + Some(Box::new(self.lower_num_as(index, scope, NumTy::Lng))), + actual, + ) + } else { + (None, supplied) + }; + let (min, max) = + forms::method_arity(info.class, forms::methods(info.class)[method]).unwrap(); + if !(min..=max).contains(&actual.len()) { + self.err( + pos, + format!("Argument-count mismatch for {}.{member}", info.class.name()), + ); + return (HExpr::Int(0), Ty::Unknown); } - None => (HExpr::Int(0), Ty::Unknown), - }; + let hargs = actual + .iter() + .map(|arg| self.lower_expr(arg, scope).0) + .collect(); + let ty = Self::property_ty(forms::PropertySpec { + name: "", + ty: return_type, + default: forms::PropertyDefault::Empty, + min: None, + max: None, + writable: false, + }); + return ( + HExpr::ObjectMethodCall { + object, + index, + method: method as u16, + args: hargs, + ty: self.h_ty(&ty), + }, + ty, + ); + } + self.err( + pos, + format!( + "Unknown property or method '{member}' of {}", + info.class.name() + ), + ); + return (HExpr::Int(0), Ty::Unknown); } if let Some((object, info)) = self.forms.find(name).map(|(id, info)| (id, info.clone())) { if args.is_none() || info.array { @@ -4282,26 +4707,15 @@ impl Sema { let as_key = format!("{name}\u{1}AS"); let key = self.var_key(name, suffix, false); let existing = if suffix.is_none() { - self.vars_of(scope) - .get(&as_key) - .or_else(|| self.vars_of(scope).get(&key)) + self.visible_var(scope, &as_key) + .or_else(|| self.visible_var(scope, &key)) } else { - self.vars_of(scope).get(&key) + self.visible_var(scope, &key).or_else(|| { + self.visible_var(scope, &as_key) + .filter(|v| v.ty == suffix_ty(suffix.unwrap())) + }) }; - let existing = existing.cloned().or_else(|| { - if scope.is_def_fn { - if suffix.is_none() { - self.module_vars - .get(&as_key) - .or_else(|| self.module_vars.get(&key)) - .cloned() - } else { - self.module_vars.get(&key).cloned() - } - } else { - None - } - }); + let existing = existing.cloned(); if let Some(v) = existing { if v.array { let (place, ty) = self.lower_place( @@ -4391,11 +4805,10 @@ impl Sema { // 2. Parameterlose Builtins (RND, ERR, INKEY$, TIMER …) let as_key = format!("{name}\u{1}AS"); let key = self.var_key(name, suffix, false); - let declared = self.vars_of(scope).contains_key(&key) - || (suffix.is_none() && self.vars_of(scope).contains_key(&as_key)) - || (scope.is_def_fn - && (self.module_vars.contains_key(&key) - || (suffix.is_none() && self.module_vars.contains_key(&as_key)))); + let declared = self.visible_var(scope, &key).is_some() + || self + .visible_var(scope, &as_key) + .is_some_and(|v| suffix.is_none_or(|s| v.ty == suffix_ty(s))); if !declared { if banned_feature(&full_name) { self.err(pos, "Feature unavailable"); @@ -4531,6 +4944,8 @@ impl Sema { let (e, t) = take(&lowered, 0); if is_str(&t) && t != Ty::Unknown { b(Builtin::Len, vec![e], Ty::Int, self) + } else if let Some(size) = self.fixed_width(&self.h_ty(&t)) { + (HExpr::Int(size), Ty::Int) } else { // LEN(zahlvariable) = Bytegröße des Typs let size: i16 = match num_ty(&t) { @@ -4826,7 +5241,7 @@ impl Sema { "TAB" | "SPC" => { // In einer PRINT-Liste werden sie eigens abgesenkt; hier // stehen sie außerhalb und sind dort nicht zulässig. - self.err(pos, &format!("Illegal function call: {full_name}")); + self.err(pos, format!("Illegal function call: {full_name}")); (HExpr::Str(String::new()), Ty::Str) } "CSRLIN" => b(Builtin::Csrlin, vec![], Ty::Int, self), @@ -4847,7 +5262,11 @@ impl Sema { "INKEY$" => b(Builtin::InkeyS, vec![], Ty::Str, self), "INPUT$" => { let n = conv_arg!(0, NumTy::Lng); - b(Builtin::InputS, vec![n], Ty::Str, self) + let mut args = vec![n]; + if get(1).is_some() { + args.push(conv_arg!(1, NumTy::Lng)); + } + b(Builtin::InputS, args, Ty::Str, self) } // --- Fehlerstatus --- "ERR" => (HExpr::Err, Ty::Lng), @@ -4858,15 +5277,20 @@ impl Sema { "TIME$" => b(Builtin::TimeS, vec![], Ty::Str, self), "COMMAND$" => b(Builtin::CommandS, vec![], Ty::Str, self), "DOEVENTS" => b(Builtin::Doevents, vec![], Ty::Int, self), + "MSGBOX" => b( + Builtin::MsgBox, + lowered.into_iter().map(|(expr, _)| expr).collect(), + Ty::Int, + self, + ), + "INPUTBOX$" => b( + Builtin::InputBoxS, + lowered.into_iter().map(|(expr, _)| expr).collect(), + Ty::Str, + self, + ), // --- Spätere Phasen: dokumentiert, aber noch nicht verfügbar --- - _ => { - let name: &'static str = match full_name { - "MSGBOX" => "MSGBOX", - "INPUTBOX$" => "INPUTBOX$", - _ => "Funktion", - }; - (HExpr::Unsupported(name), ret_ty(retk)) - } + _ => (HExpr::Unsupported("Funktion"), ret_ty(retk)), } } } @@ -4917,6 +5341,9 @@ fn stmt_pos(stmt: &Stmt) -> SourcePos { | Stmt::LockStmt { pos, .. } | Stmt::NameStmt { pos, .. } | Stmt::ViewPrint { pos, .. } + | Stmt::GraphicsLine { pos, .. } + | Stmt::GraphicsPaint { pos, .. } + | Stmt::GraphicsView { pos, .. } | Stmt::TrapDef { pos, .. } | Stmt::EventControl { pos, .. } | Stmt::Include { pos, .. } @@ -4929,6 +5356,55 @@ fn stmt_pos(stmt: &Stmt) -> SourcePos { } } +// ---- Ereignis-Traps (Sprachreferenz §8) ------------------------------------- + +pub const TRAP_KEY: u8 = 0; +pub const TRAP_TIMER: u8 = 1; +pub const TRAP_UEVENT: u8 = 2; +pub const TRAP_SIGNAL: u8 = 3; + +/// Quellenname → Art. `None` für die Non-Features. +fn trap_art(device: &str) -> Option { + Some(match device { + "KEY" => TRAP_KEY, + "TIMER" => TRAP_TIMER, + "UEVENT" => TRAP_UEVENT, + "SIGNAL" => TRAP_SIGNAL, + _ => return None, + }) +} + +/// Konstanter Zahlenwert eines Ausdrucks, soweit direkt ablesbar. +fn const_zahl(e: &Expr) -> Option { + match e { + Expr::IntLit(n) => Some(*n as i64), + Expr::LongLit(n) => Some(*n as i64), + Expr::SingleLit(n) => Some(*n as i64), + Expr::DoubleLit(n) => Some(*n as i64), + Expr::Paren(inner) => const_zahl(inner), + _ => None, + } +} + +/// Wertebereiche nach der Original-Hilfe (Belege in `umfang-und-form.md`). +fn trap_bereich_ok(art: u8, n: i64) -> bool { + match art { + TRAP_KEY => matches!(n, 0..=25 | 30 | 31), + TRAP_TIMER => (1..=86_400).contains(&n), + TRAP_SIGNAL => (1..=2).contains(&n), + _ => true, + } +} + +fn trap_bereich_text(art: u8) -> &'static str { + match art { + TRAP_KEY => "ON KEY: Kennung 0, 1-25 oder 30-31 erwartet", + TRAP_TIMER => "ON TIMER: Intervall 1 bis 86400 Sekunden erwartet", + TRAP_SIGNAL => "ON SIGNAL: Kennung 1 (SIGINT) oder 2 (SIGTERM) erwartet", + _ => "Kennung erwartet", + } +} + use crate::hir::literal_value; #[cfg(test)] @@ -5038,6 +5514,8 @@ mod tests { assert!(diags(bad).contains(&"Element not defined".to_string())); let bad2 = "TYPE Kunde\nName AS STRING * 30\nEND TYPE\nDIM k AS Kunde\nk.Name = 5"; assert!(diags(bad2).contains(&"Type mismatch".to_string())); + let array = "TYPE Kunde\nName AS STRING * 30\nEND TYPE\nDIM SHARED k(1 TO 2) AS Kunde\nSUB Setzen\nk(1).Name = \"Ada\"\nPRINT k(1).Name\nEND SUB"; + assert!(diags(array).is_empty(), "{:?}", diags(array)); } #[test] @@ -5045,6 +5523,10 @@ mod tests { let src = "DIM SHARED zaehler%\nSUB Hoch\nSHARED zaehler%\nzaehler% = zaehler% + 1\nEND SUB"; assert!(diags(src).is_empty(), "{:?}", diags(src)); + let src = "TYPE Satz\nWert AS INTEGER\nEND TYPE\nDIM SHARED r AS Satz\nSUB Hoch\nr.Wert = r.Wert + 1\nEND SUB"; + assert!(diags(src).is_empty(), "{:?}", diags(src)); + let src = "DIM SHARED datei AS INTEGER, name AS STRING\nSUB Nutzen\ndatei% = 1\nname$ = \"x\"\nEND SUB"; + assert!(diags(src).is_empty(), "{:?}", diags(src)); assert!(diags("COMMON SHARED /blk/ a%, b$()").is_empty()); } @@ -5066,6 +5548,8 @@ mod tests { c.add("Form1", ObjectClass::Form, None, false); c.add("Text1", ObjectClass::TextBox, Some("Form1"), false); c.add("Command1", ObjectClass::CommandButton, Some("Form1"), true); + c.add("List1", ObjectClass::ListBox, Some("Form1"), false); + c.add("Picture1", ObjectClass::PictureBox, Some("Form1"), false); c } @@ -5111,6 +5595,9 @@ mod tests { "{d:?}" ); assert!(form_diags("Command1(3).Caption = \"drei\"").is_empty()); + assert!(form_diags("zeichen% = SCREEN(1, 1)").is_empty()); + assert!(form_diags("SHOW\nHIDE").is_empty()); + assert!(form_diags("Caption = \"Titel\"\nx% = Width").is_empty()); assert!( form_diags("CALL Aus(Text1)\nSUB Aus(c AS CONTROL)\nc.Visible = 0\nEND SUB").is_empty() ); @@ -5121,11 +5608,7 @@ mod tests { HStmtKind::SetObjectProperty { object: 1, .. } )); let d = form_diags("Text1.Move 1, 2"); - assert!( - d.iter() - .any(|m| m.contains("Feature unavailable: TEXTBOX.MOVE")), - "{d:?}" - ); + assert!(d.is_empty(), "{d:?}"); for src in ["Form1.Hide 1", "Form1.Show 1, 2", "SCREEN.Show 1"] { let d = form_diags(src); assert!( @@ -5151,6 +5634,15 @@ mod tests { .any(|o| o.name == "MEINFORM" && o.class == ObjectClass::Form)); } + #[test] + fn dialoge_listindex_und_zeichenmessung_werden_abgesenkt() { + let source = "MSGBOX \"Hinweis\"\nantwort% = MSGBOX(\"Weiter?\", 4, \"Frage\")\nname$ = INPUTBOX$(\"Name\")\nList1.ADDITEM \"a\"\nPRINT List1.List(0)\nPRINT Picture1.TEXTWIDTH(\"abc\")"; + let diagnostics = form_diags(source); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + let arrays = form_diags("Command1(1).SETFOCUS\nCommand1(1).MOVE 1, 2, 3, 4"); + assert!(arrays.is_empty(), "{arrays:?}"); + } + #[test] fn ereignisprozedur_signatur_und_normale_unterstrich_sub() { let ok = "SUB Form_MouseDown(Button AS INTEGER, Shift AS INTEGER, X AS SINGLE, Y AS SINGLE)\nEND SUB"; @@ -5378,52 +5870,3 @@ mod tests { ); } } - -// ---- Ereignis-Traps (Sprachreferenz §8) ------------------------------------- - -pub const TRAP_KEY: u8 = 0; -pub const TRAP_TIMER: u8 = 1; -pub const TRAP_UEVENT: u8 = 2; -pub const TRAP_SIGNAL: u8 = 3; - -/// Quellenname → Art. `None` für die Non-Features. -fn trap_art(device: &str) -> Option { - Some(match device { - "KEY" => TRAP_KEY, - "TIMER" => TRAP_TIMER, - "UEVENT" => TRAP_UEVENT, - "SIGNAL" => TRAP_SIGNAL, - _ => return None, - }) -} - -/// Konstanter Zahlenwert eines Ausdrucks, soweit direkt ablesbar. -fn const_zahl(e: &Expr) -> Option { - match e { - Expr::IntLit(n) => Some(*n as i64), - Expr::LongLit(n) => Some(*n as i64), - Expr::SingleLit(n) => Some(*n as i64), - Expr::DoubleLit(n) => Some(*n as i64), - Expr::Paren(inner) => const_zahl(inner), - _ => None, - } -} - -/// Wertebereiche nach der Original-Hilfe (Belege in `umfang-und-form.md`). -fn trap_bereich_ok(art: u8, n: i64) -> bool { - match art { - TRAP_KEY => matches!(n, 0..=25 | 30 | 31), - TRAP_TIMER => (1..=86_400).contains(&n), - TRAP_SIGNAL => (1..=2).contains(&n), - _ => true, - } -} - -fn trap_bereich_text(art: u8) -> &'static str { - match art { - TRAP_KEY => "ON KEY: Kennung 0, 1-25 oder 30-31 erwartet", - TRAP_TIMER => "ON TIMER: Intervall 1 bis 86400 Sekunden erwartet", - TRAP_SIGNAL => "ON SIGNAL: Kennung 1 (SIGINT) oder 2 (SIGTERM) erwartet", - _ => "Kennung erwartet", - } -} diff --git a/crates/tb-frontend/tests/inventar.rs b/crates/tb-frontend/tests/inventar.rs index 9b681f6..55a2960 100644 --- a/crates/tb-frontend/tests/inventar.rs +++ b/crates/tb-frontend/tests/inventar.rs @@ -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)", diff --git a/crates/tb-runtime/src/builtins.rs b/crates/tb-runtime/src/builtins.rs index d18ab8c..b50df63 100644 --- a/crates/tb-runtime/src/builtins.rs +++ b/crates/tb-runtime/src/builtins.rs @@ -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, RuntimeError> { + st.clipboard.push_str(&arg_str(a, 0)?); + Ok(None) +} + +fn bi_clipboard_get( + st: &mut RtState, + _: &mut dyn Host, + _: &mut [Value], +) -> Result, RuntimeError> { + Ok(Some(Value::Str(Rc::from(st.clipboard.as_str())))) +} + // ---- Argument-Hilfen -------------------------------------------------------- fn arg_str(args: &[Value], i: usize) -> Result, 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, 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, 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, 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, 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 0–11. fn key_index(n: i32) -> Option { 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, RuntimeError> { + Err(RuntimeError::FEATURE_UNAVAILABLE) +} + +fn bi_inputbox( + _: &mut RtState, + _: &mut dyn Host, + _: &mut [Value], +) -> Result, 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] diff --git a/crates/tb-runtime/src/screen.rs b/crates/tb-runtime/src/screen.rs index dd25442..49fdb4e 100644 --- a/crates/tb-runtime/src/screen.rs +++ b/crates/tb-runtime/src/screen.rs @@ -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. diff --git a/crates/tb-runtime/src/using.rs b/crates/tb-runtime/src/using.rs index 89fd419..56a50e0 100644 --- a/crates/tb-runtime/src/using.rs +++ b/crates/tb-runtime/src/using.rs @@ -316,7 +316,7 @@ fn tausender_gruppieren(s: &str) -> String { let z: Vec = 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"); } diff --git a/crates/tb-runtime/src/value.rs b/crates/tb-runtime/src/value.rs index f8993d2..5d7ad0a 100644 --- a/crates/tb-runtime/src/value.rs +++ b/crates/tb-runtime/src/value.rs @@ -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 diff --git a/crates/tb-ui/src/forms.rs b/crates/tb-ui/src/forms.rs index ddc4b3d..9864602 100644 --- a/crates/tb-ui/src/forms.rs +++ b/crates/tb-ui/src/forms.rs @@ -4,9 +4,14 @@ //! des textbasierten Formular-Dateiformats (`.FRM`). use std::collections::{BTreeMap, VecDeque}; +use std::path::PathBuf; use tb_frontend::forms::{self, FormObject, ObjectClass, PropertyDefault, PropertyType}; use tb_runtime::errors::RuntimeError; +use tb_runtime::host::{taste, umschalt, Ereignis, Host, MausArt, MausEreignis}; +use tb_runtime::screen::TextScreen; + +type ObjectKey = (u16, Option); #[derive(Debug, Clone, PartialEq)] pub enum PropertyValue { @@ -60,8 +65,7 @@ pub enum ShowResult { ModalWait, } -/// Reines Zustandsmodell. Zeichnen und Bedienung kommen im Folge-Change; -/// diese Schicht hält Objekte, Lebenszyklus, Modalität und Ereignisqueue. +/// Zustandsmodell, Bedienung und Darstellung der Forms-Objekte. pub struct FormsModel { pub objects: Vec, dynamic: BTreeMap<(u16, i32), ObjectInstance>, @@ -73,6 +77,17 @@ pub struct FormsModel { width: usize, height: usize, screen_visible: bool, + lists: BTreeMap>, + pictures: BTreeMap>, + timer_last: BTreeMap, + pressed: Option, + spin_repeat: Option<(ObjectKey, i32)>, + spin_last: Option, + dragging: Option, + drag_over: Option, + menu_path: Vec, + last_click: Option<(ObjectKey, u8, usize, usize, u64)>, + dirty: bool, } impl FormsModel { @@ -82,22 +97,10 @@ impl FormsModel { } } fn clear_active_control_for_form(&mut self, form: u16) { - let Some((control, _)) = self.active_control else { + let Some(control) = self.active_control else { return; }; - let Some(form_name) = self - .objects - .get(form as usize) - .map(|o| o.description.name.as_str()) - else { - return; - }; - if self - .objects - .get(control as usize) - .and_then(|o| o.description.parent_form.as_deref()) - .is_some_and(|parent| parent.eq_ignore_ascii_case(form_name)) - { + if self.root_form(control) == Some(form) { self.active_control = None; } } @@ -131,12 +134,27 @@ impl FormsModel { width, height, screen_visible: true, + lists: BTreeMap::new(), + pictures: BTreeMap::new(), + timer_last: BTreeMap::new(), + pressed: None, + spin_repeat: None, + spin_last: None, + dragging: None, + drag_over: None, + menu_path: Vec::new(), + last_click: None, + dirty: true, } } pub fn resize(&mut self, width: usize, height: usize) { + if self.width == width && self.height == height { + return; + } self.width = width; self.height = height; + self.dirty = true; } pub fn get(&mut self, object: u16, property: u16) -> Result { @@ -161,6 +179,68 @@ impl FormsModel { .get(property as usize) .copied() .ok_or(RuntimeError(422))?; + let key = (object, index.filter(|i| *i != 0)); + if spec.name == "LISTCOUNT" { + return Ok(PropertyValue::Integer( + self.lists.get(&key).map_or(0, |items| items.len()) as i32, + )); + } + if spec.name == "TEXT" + && matches!( + obj.description.class, + ObjectClass::ListBox + | ObjectClass::ComboBox + | ObjectClass::DirListBox + | ObjectClass::DriveListBox + | ObjectClass::FileListBox + ) + { + let selected = forms::property(obj.description.class, "LISTINDEX") + .and_then(|(id, _)| match obj.properties.get(id as usize) { + Some(PropertyValue::Integer(v)) => Some(*v), + _ => None, + }) + .unwrap_or(-1); + if selected >= 0 { + if let Some(text) = self + .lists + .get(&key) + .and_then(|items| items.get(selected as usize)) + { + return Ok(PropertyValue::String(text.clone())); + } + } + } + if spec.name == "SELTEXT" + && matches!( + obj.description.class, + ObjectClass::TextBox | ObjectClass::ComboBox + ) + { + let start = forms::property(obj.description.class, "SELSTART") + .and_then(|(id, _)| match obj.properties.get(id as usize) { + Some(PropertyValue::Integer(v)) => Some(*v), + _ => None, + }) + .unwrap_or(0) + .max(0) as usize; + let length = forms::property(obj.description.class, "SELLENGTH") + .and_then(|(id, _)| match obj.properties.get(id as usize) { + Some(PropertyValue::Integer(v)) => Some(*v), + _ => None, + }) + .unwrap_or(0) + .max(0) as usize; + let text = forms::property(obj.description.class, "TEXT") + .and_then(|(id, _)| match obj.properties.get(id as usize) { + Some(PropertyValue::String(v)) => Some(v.as_str()), + _ => None, + }) + .unwrap_or(""); + return Ok(PropertyValue::String( + text.chars().skip(start).take(length).collect(), + )); + } if obj.description.class == ObjectClass::Screen { return Ok(match spec.name { "WIDTH" => PropertyValue::Integer(self.width as i32), @@ -170,9 +250,112 @@ impl FormsModel { _ => obj.properties[property as usize].clone(), }); } + if matches!(spec.name, "SCALEWIDTH" | "SCALEHEIGHT") { + let dimension = if spec.name == "SCALEWIDTH" { + "WIDTH" + } else { + "HEIGHT" + }; + let value = forms::property(obj.description.class, dimension) + .and_then(|(id, _)| match obj.properties.get(id as usize) { + Some(PropertyValue::Integer(v)) => Some(*v), + _ => None, + }) + .unwrap_or(1); + return Ok(PropertyValue::Integer(value.saturating_sub(2))); + } + if spec.name == "PARENT" { + let parent = obj.description.parent_form.as_deref().and_then(|name| { + self.objects + .iter() + .position(|candidate| candidate.description.name.eq_ignore_ascii_case(name)) + .map(|id| (id as u16, None)) + }); + return Ok(PropertyValue::Object(parent)); + } Ok(obj.properties[property as usize].clone()) } + pub fn get_indexed( + &mut self, + object: u16, + property: u16, + index: i32, + ) -> Result { + self.get_indexed_at(object, None, property, index) + } + + pub fn get_indexed_at( + &mut self, + object: u16, + object_index: Option, + property: u16, + index: i32, + ) -> Result { + self.ensure_loaded_at(object, object_index)?; + let key = (object, object_index.filter(|value| *value != 0)); + let obj = self.instance(key)?; + let spec = forms::properties(obj.description.class) + .get(property as usize) + .copied() + .ok_or(RuntimeError(422))?; + if index < 0 { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + if spec.name == "LIST" { + return self + .lists + .get(&key) + .and_then(|items| items.get(index as usize)) + .cloned() + .map(PropertyValue::String) + .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + match obj.properties.get(property as usize) { + Some(PropertyValue::IntegerArray(values)) => values + .get(index as usize) + .copied() + .map(PropertyValue::Integer) + .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL), + _ => Err(RuntimeError::ILLEGAL_FUNCTION_CALL), + } + } + + pub fn set_indexed_at( + &mut self, + object: u16, + object_index: Option, + property: u16, + index: i32, + value: i32, + ) -> Result<(), RuntimeError> { + self.ensure_loaded_at(object, object_index)?; + let key = (object, object_index.filter(|value| *value != 0)); + let obj = self.instance_mut(key)?; + let spec = forms::properties(obj.description.class) + .get(property as usize) + .copied() + .ok_or(RuntimeError(422))?; + if !spec.writable + || index < 0 + || spec.min.is_some_and(|min| value < min) + || spec.max.is_some_and(|max| value > max) + { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + match obj.properties.get_mut(property as usize) { + Some(PropertyValue::IntegerArray(values)) => { + let slot = values + .get_mut(index as usize) + .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?; + *slot = value; + self.dirty = true; + Ok(()) + } + _ => Err(RuntimeError::TYPE_MISMATCH), + } + } + pub fn set( &mut self, object: u16, @@ -190,16 +373,9 @@ impl FormsModel { value: PropertyValue, ) -> Result<(), RuntimeError> { self.ensure_loaded_at(object, index)?; - let obj = if index.is_some_and(|i| i != 0) { - self.dynamic - .get_mut(&(object, index.unwrap())) - .ok_or(RuntimeError(340))? - } else { - self.objects - .get_mut(object as usize) - .ok_or(RuntimeError(420))? - }; - let spec = forms::properties(obj.description.class) + let key = (object, index.filter(|i| *i != 0)); + let class = self.instance(key)?.description.class; + let spec = forms::properties(class) .get(property as usize) .copied() .ok_or(RuntimeError(422))?; @@ -218,20 +394,118 @@ impl FormsModel { if !type_ok { return Err(RuntimeError::TYPE_MISMATCH); } - if let PropertyValue::Integer(v) = value { - if spec.min.is_some_and(|min| v < min) || spec.max.is_some_and(|max| v > max) { + if let PropertyValue::Integer(v) = &value { + if spec.min.is_some_and(|min| *v < min) || spec.max.is_some_and(|max| *v > max) { return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); } - obj.properties[property as usize] = PropertyValue::Integer(v); - } else { + } + if spec.name == "LISTINDEX" { + let PropertyValue::Integer(v) = value else { + unreachable!() + }; + if v < -1 || v as usize >= self.lists.get(&key).map_or(0, Vec::len) { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + self.instance_mut(key)?.properties[property as usize] = PropertyValue::Integer(v); + self.dirty = true; + return Ok(()); + } + if matches!( + class, + ObjectClass::HScrollBar | ObjectClass::VScrollBar | ObjectClass::Spin + ) && spec.name == "VALUE" + { + let PropertyValue::Integer(v) = value else { + unreachable!() + }; + let min = self.integer(key, "MIN").unwrap_or(0); + let max = self.integer(key, "MAX").unwrap_or(32767); + if v < min || v > max { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + self.instance_mut(key)?.properties[property as usize] = PropertyValue::Integer(v); + self.queue_named(key, "CHANGE", vec![]); + self.dirty = true; + return Ok(()); + } + if class == ObjectClass::Menu { + self.validate_menu_value(key, spec.name, &value)?; + } + if spec.name == "SELTEXT" && matches!(class, ObjectClass::TextBox | ObjectClass::ComboBox) { + let PropertyValue::String(replacement) = value else { + unreachable!() + }; + self.replace_selection(key, &replacement)?; + return Ok(()); + } + { + let obj = self.instance_mut(key)?; obj.properties[property as usize] = value; + if spec.name == "VISIBLE" { + obj.visible = matches!( + obj.properties[property as usize], + PropertyValue::Boolean(true) + ); + } } - if spec.name == "VISIBLE" { - obj.visible = matches!( - obj.properties[property as usize], - PropertyValue::Boolean(true) - ); + if matches!(spec.name, "INTERVAL" | "ENABLED") && class == ObjectClass::Timer { + self.timer_last.remove(&key); } + if spec.name == "TEXT" && matches!(class, ObjectClass::TextBox | ObjectClass::ComboBox) { + self.queue_named(key, "CHANGE", vec![]); + } + if spec.name == "VALUE" && class == ObjectClass::CommandButton && self.boolean(key, "VALUE") + { + self.queue_named(key, "CLICK", vec![]); + if let Some((id, _)) = forms::property(class, "VALUE") { + self.instance_mut(key)?.properties[id as usize] = PropertyValue::Boolean(false); + } + } + if spec.name == "VALUE" + && class == ObjectClass::OptionButton + && self.integer(key, "VALUE").is_some_and(|value| value != 0) + { + self.select_option(key)?; + } + if matches!( + class, + ObjectClass::DirListBox | ObjectClass::DriveListBox | ObjectClass::FileListBox + ) && matches!(spec.name, "PATH" | "DRIVE" | "PATTERN") + { + self.refresh_filesystem(key)?; + } + self.dirty = true; + Ok(()) + } + + pub fn set_initial( + &mut self, + object: u16, + property: u16, + value: PropertyValue, + ) -> Result<(), RuntimeError> { + self.set_initial_at(object, None, property, value) + } + + pub fn set_initial_at( + &mut self, + object: u16, + index: Option, + property: u16, + value: PropertyValue, + ) -> Result<(), RuntimeError> { + let key = (object, index.filter(|value| *value != 0)); + let obj = self.instance_mut(key)?; + if property as usize >= obj.properties.len() { + return Err(RuntimeError(422)); + } + if forms::properties(obj.description.class)[property as usize].name == "VISIBLE" + && obj.description.class != ObjectClass::Form + { + obj.visible = matches!(value, PropertyValue::Boolean(true)); + } + obj.properties[property as usize] = value; + self.dirty = true; Ok(()) } @@ -252,6 +526,7 @@ impl FormsModel { args: Vec::new(), }); } + self.dirty = true; Ok(()) } @@ -296,6 +571,7 @@ impl FormsModel { self.visible_forms.retain(|id| *id != object); self.visible_forms.push(object); self.active_form = Some(object); + self.dirty = true; Ok(if modal { ShowResult::ModalWait } else { @@ -321,6 +597,7 @@ impl FormsModel { self.active_form = self.visible_forms.last().copied(); } self.clear_active_control_for_form(object); + self.dirty = true; Ok(()) } @@ -358,6 +635,7 @@ impl FormsModel { self.active_form = self.visible_forms.last().copied(); } self.clear_active_control_for_form(object); + self.dirty = true; Ok(true) } @@ -376,9 +654,23 @@ impl FormsModel { let mut obj = template.clone(); obj.loaded = true; obj.visible = false; + Self::set_visible_property(&mut obj, false); obj.design_time = false; obj.array_index = Some(index); self.dynamic.insert(key, obj); + self.dirty = true; + Ok(()) + } + + pub(crate) fn load_design_array(&mut self, base: u16, index: i32) -> Result<(), RuntimeError> { + self.load_array(base, index)?; + let object = self + .dynamic + .get_mut(&(base, index)) + .ok_or(RuntimeError(340))?; + object.design_time = true; + object.visible = true; + Self::set_visible_property(object, true); Ok(()) } @@ -390,10 +682,20 @@ impl FormsModel { if template.array_index == Some(index) || (template.array_index.is_none() && index == 0) { return Err(RuntimeError(362)); } - self.dynamic + if self + .dynamic + .get(&(base, index)) + .is_some_and(|object| object.design_time) + { + return Err(RuntimeError(362)); + } + let removed = self + .dynamic .remove(&(base, index)) .map(|_| ()) - .ok_or(RuntimeError(340)) + .ok_or(RuntimeError(340)); + self.dirty = true; + removed } pub fn queue(&mut self, event: FormEvent) { @@ -405,6 +707,9 @@ impl FormsModel { pub fn is_visible(&self, object: u16) -> bool { self.objects.get(object as usize).is_some_and(|o| o.visible) } + pub fn has_visible_forms(&self) -> bool { + !self.visible_forms.is_empty() + } pub fn is_loaded(&self, object: u16) -> bool { self.objects.get(object as usize).is_some_and(|o| o.loaded) } @@ -424,6 +729,7 @@ impl FormsModel { index: Option, ) -> Result<(), RuntimeError> { self.ensure_loaded_at(object, index)?; + let key = (object, index.filter(|i| *i != 0)); let description = if index.is_some_and(|i| i != 0) { &self .dynamic @@ -440,19 +746,8 @@ impl FormsModel { if matches!(description.class, ObjectClass::Form | ObjectClass::Screen) { return Err(RuntimeError(421)); } - let parent = description - .parent_form - .as_deref() - .ok_or(RuntimeError(421))?; - self.active_form = self - .objects - .iter() - .position(|o| { - o.description.class == ObjectClass::Form - && o.description.name.eq_ignore_ascii_case(parent) - }) - .map(|id| id as u16); - self.active_control = Some((object, index)); + self.active_form = self.root_form(key); + self.active_control = Some(key); Ok(()) } pub fn modal_top(&self) -> Option { @@ -460,15 +755,1897 @@ impl FormsModel { } pub fn screen_show(&mut self, visible: bool) { self.screen_visible = visible; + self.dirty = true; } pub fn screen_visible(&self) -> bool { self.screen_visible } + + fn instance(&self, key: ObjectKey) -> Result<&ObjectInstance, RuntimeError> { + match key.1 { + Some(index) => self.dynamic.get(&(key.0, index)).ok_or(RuntimeError(340)), + None => self.objects.get(key.0 as usize).ok_or(RuntimeError(420)), + } + } + + fn instance_mut(&mut self, key: ObjectKey) -> Result<&mut ObjectInstance, RuntimeError> { + match key.1 { + Some(index) => self + .dynamic + .get_mut(&(key.0, index)) + .ok_or(RuntimeError(340)), + None => self + .objects + .get_mut(key.0 as usize) + .ok_or(RuntimeError(420)), + } + } + + fn value(&self, key: ObjectKey, name: &str) -> Option<&PropertyValue> { + let obj = self.instance(key).ok()?; + let (id, _) = forms::property(obj.description.class, name)?; + obj.properties.get(id as usize) + } + + fn integer(&self, key: ObjectKey, name: &str) -> Option { + match self.value(key, name) { + Some(PropertyValue::Integer(value)) => Some(*value), + _ => None, + } + } + + fn boolean(&self, key: ObjectKey, name: &str) -> bool { + matches!(self.value(key, name), Some(PropertyValue::Boolean(true))) + } + + fn string(&self, key: ObjectKey, name: &str) -> String { + match self.value(key, name) { + Some(PropertyValue::String(value)) => value.clone(), + _ => String::new(), + } + } + + fn queue_named(&mut self, key: ObjectKey, name: &str, args: Vec) { + let array_index = key.1.or_else(|| { + self.instance(key) + .ok() + .filter(|object| object.description.array) + .map(|_| 0) + }); + self.events.push_back(FormEvent { + object: key.0, + array_index, + name: name.into(), + args, + }); + } + + fn root_form(&self, key: ObjectKey) -> Option { + let mut current = key.0; + for _ in 0..self.objects.len() { + let obj = self.objects.get(current as usize)?; + if obj.description.class == ObjectClass::Form { + return Some(current); + } + let parent = obj.description.parent_form.as_deref()?; + current = self + .objects + .iter() + .position(|candidate| candidate.description.name.eq_ignore_ascii_case(parent))? + as u16; + } + None + } + + fn keys(&self) -> Vec { + let mut keys: Vec<_> = (0..self.objects.len()) + .map(|id| (id as u16, None)) + .collect(); + keys.extend(self.dynamic.keys().map(|(id, index)| (*id, Some(*index)))); + keys + } + + fn focusable(&self, key: ObjectKey) -> bool { + let Ok(obj) = self.instance(key) else { + return false; + }; + !matches!( + obj.description.class, + ObjectClass::Form + | ObjectClass::Frame + | ObjectClass::Label + | ObjectClass::Menu + | ObjectClass::Timer + | ObjectClass::Screen + ) && self.boolean(key, "ENABLED") + && self.boolean(key, "VISIBLE") + && self.boolean(key, "TABSTOP") + && self + .root_form(key) + .is_some_and(|form| self.is_visible(form)) + } + + pub fn focus(&mut self, key: ObjectKey) -> Result<(), RuntimeError> { + if !self.focusable(key) { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + if self.active_control == Some(key) { + return Ok(()); + } + if let Some(old) = self.active_control { + self.queue_named(old, "LOSTFOCUS", vec![]); + } + self.active_form = self.root_form(key); + self.active_control = Some(key); + self.queue_named(key, "GOTFOCUS", vec![]); + self.dirty = true; + Ok(()) + } + + pub fn tab(&mut self, forward: bool) -> bool { + let Some(form) = self + .active_form + .or_else(|| self.visible_forms.last().copied()) + else { + return false; + }; + let mut keys: Vec<_> = self + .keys() + .into_iter() + .filter(|key| self.root_form(*key) == Some(form) && self.focusable(*key)) + .collect(); + keys.sort_by_key(|key| (self.integer(*key, "TABINDEX").unwrap_or(0), key.0, key.1)); + if keys.is_empty() { + return false; + } + let current = self + .active_control + .and_then(|active| keys.iter().position(|key| *key == active)); + let next = match (current, forward) { + (Some(at), true) => (at + 1) % keys.len(), + (Some(at), false) => (at + keys.len() - 1) % keys.len(), + (None, true) => 0, + (None, false) => keys.len() - 1, + }; + self.focus(keys[next]).is_ok() + } + + fn access_key(text: &str) -> Option { + let mut chars = text.chars(); + while let Some(ch) = chars.next() { + if ch == '&' { + return chars.next().map(|c| c.to_ascii_uppercase()); + } + } + None + } + + fn caption(&self, key: ObjectKey) -> String { + self.string(key, "CAPTION") + .replace("&&", "\0") + .replace('&', "") + .replace('\0', "&") + } + + fn replace_selection(&mut self, key: ObjectKey, replacement: &str) -> Result<(), RuntimeError> { + let class = self.instance(key)?.description.class; + let start = self.integer(key, "SELSTART").unwrap_or(0).max(0) as usize; + let length = self.integer(key, "SELLENGTH").unwrap_or(0).max(0) as usize; + let text = self.string(key, "TEXT"); + let chars: Vec = text.chars().collect(); + let start = start.min(chars.len()); + let end = (start + length).min(chars.len()); + let mut changed: String = chars[..start].iter().collect(); + changed.push_str(replacement); + changed.extend(chars[end..].iter()); + let text_id = forms::property(class, "TEXT").unwrap().0 as usize; + let start_id = forms::property(class, "SELSTART").unwrap().0 as usize; + let length_id = forms::property(class, "SELLENGTH").unwrap().0 as usize; + let obj = self.instance_mut(key)?; + obj.properties[text_id] = PropertyValue::String(changed); + obj.properties[start_id] = + PropertyValue::Integer((start + replacement.chars().count()) as i32); + obj.properties[length_id] = PropertyValue::Integer(0); + self.queue_named(key, "CHANGE", vec![]); + self.dirty = true; + Ok(()) + } + + fn select_option(&mut self, key: ObjectKey) -> Result<(), RuntimeError> { + let parent = self.instance(key)?.description.parent_form.clone(); + let value_id = forms::property(ObjectClass::OptionButton, "VALUE") + .unwrap() + .0 as usize; + for other in self.keys() { + if other == key { + continue; + } + let same_group = self.instance(other).is_ok_and(|obj| { + obj.description.class == ObjectClass::OptionButton + && obj.description.parent_form == parent + }); + if same_group { + self.instance_mut(other)?.properties[value_id] = PropertyValue::Integer(0); + } + } + Ok(()) + } + + fn validate_menu_value( + &self, + key: ObjectKey, + name: &str, + value: &PropertyValue, + ) -> Result<(), RuntimeError> { + let separator = if name == "SEPARATOR" { + matches!(value, PropertyValue::Boolean(true)) + } else { + self.boolean(key, "SEPARATOR") + }; + let invalid = separator + && match name { + "CHECKED" => matches!(value, PropertyValue::Boolean(true)), + "ENABLED" => matches!(value, PropertyValue::Boolean(false)), + "SHORTCUT" => matches!(value, PropertyValue::String(text) if !text.is_empty()), + "SEPARATOR" => { + self.boolean(key, "CHECKED") + || !self.boolean(key, "ENABLED") + || !self.string(key, "SHORTCUT").is_empty() + } + _ => false, + }; + let title_shortcut = name == "SHORTCUT" + && matches!(value, PropertyValue::String(text) if !text.is_empty()) + && self + .instance(key) + .ok() + .and_then(|obj| obj.description.parent_form.as_deref()) + .and_then(|parent| { + self.objects + .iter() + .find(|obj| obj.description.name.eq_ignore_ascii_case(parent)) + }) + .is_some_and(|parent| parent.description.class == ObjectClass::Form); + if invalid || title_shortcut { + Err(RuntimeError::ILLEGAL_FUNCTION_CALL) + } else { + Ok(()) + } + } + + pub fn add_item( + &mut self, + key: ObjectKey, + text: String, + at: Option, + ) -> Result<(), RuntimeError> { + let class = self.instance(key)?.description.class; + if !matches!(class, ObjectClass::ListBox | ObjectClass::ComboBox) { + return Err(RuntimeError(421)); + } + let sorted = self.boolean(key, "SORTED"); + let items = self.lists.entry(key).or_default(); + if sorted { + let pos = items.partition_point(|item| item.to_uppercase() <= text.to_uppercase()); + items.insert(pos, text); + } else if let Some(at) = at { + if at < 0 || at as usize > items.len() { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + items.insert(at as usize, text); + } else { + items.push(text); + } + self.dirty = true; + Ok(()) + } + + pub fn remove_item(&mut self, key: ObjectKey, at: i32) -> Result<(), RuntimeError> { + let len = { + let items = self + .lists + .get_mut(&key) + .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?; + if at < 0 || at as usize >= items.len() { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + items.remove(at as usize); + items.len() + }; + let list_index = self.integer(key, "LISTINDEX").unwrap_or(-1); + if list_index >= len as i32 { + let id = forms::property(self.instance(key)?.description.class, "LISTINDEX") + .unwrap() + .0 as usize; + self.instance_mut(key)?.properties[id] = PropertyValue::Integer(-1); + } + self.dirty = true; + Ok(()) + } + + fn wildcard(pattern: &str, name: &str) -> bool { + fn matches(p: &[char], n: &[char]) -> bool { + match p.split_first() { + None => n.is_empty(), + Some(('*', rest)) => matches(rest, n) || (!n.is_empty() && matches(p, &n[1..])), + Some(('?', rest)) => !n.is_empty() && matches(rest, &n[1..]), + Some((head, rest)) => { + !n.is_empty() && head.eq_ignore_ascii_case(&n[0]) && matches(rest, &n[1..]) + } + } + } + matches( + &pattern.chars().collect::>(), + &name.chars().collect::>(), + ) + } + + pub fn refresh_filesystem(&mut self, key: ObjectKey) -> Result<(), RuntimeError> { + let class = self.instance(key)?.description.class; + let mut entries = Vec::new(); + match class { + ObjectClass::DriveListBox => { + entries.push(std::path::MAIN_SEPARATOR.to_string()); + #[cfg(target_os = "macos")] + if let Ok(mounts) = std::fs::read_dir("/Volumes") { + entries.extend( + mounts + .flatten() + .map(|entry| entry.path().display().to_string()), + ); + } + } + ObjectClass::DirListBox | ObjectClass::FileListBox => { + let path = self.string(key, "PATH"); + let path = if path.is_empty() { + PathBuf::from(".") + } else { + PathBuf::from(path) + }; + let pattern = self.string(key, "PATTERN"); + for entry in std::fs::read_dir(&path) + .map_err(|_| RuntimeError(76))? + .flatten() + { + let is_dir = entry.file_type().is_ok_and(|ty| ty.is_dir()); + let name = entry.file_name().to_string_lossy().to_string(); + if (class == ObjectClass::DirListBox && is_dir) + || (class == ObjectClass::FileListBox + && !is_dir + && Self::wildcard( + if pattern.is_empty() { "*" } else { &pattern }, + &name, + )) + { + entries.push(name); + } + } + } + _ => return Err(RuntimeError(421)), + } + entries.sort_by_key(|entry| entry.to_uppercase()); + self.lists.insert(key, entries); + let event = match class { + ObjectClass::DirListBox => "PATHCHANGE", + ObjectClass::FileListBox if !self.string(key, "PATTERN").is_empty() => "PATTERNCHANGE", + _ => "CHANGE", + }; + self.queue_named(key, event, vec![]); + self.dirty = true; + Ok(()) + } + + fn text_of(value: &PropertyValue) -> String { + match value { + PropertyValue::String(value) => value.clone(), + PropertyValue::Integer(value) => value.to_string(), + PropertyValue::Single(value) => value.to_string(), + PropertyValue::Boolean(value) => if *value { "-1" } else { "0" }.into(), + _ => String::new(), + } + } + + pub fn object_method( + &mut self, + object: u16, + name: &str, + args: Vec, + ) -> Result, RuntimeError> { + self.object_method_at(object, None, name, args) + } + + pub fn object_method_at( + &mut self, + object: u16, + index: Option, + name: &str, + args: Vec, + ) -> Result, RuntimeError> { + self.ensure_loaded_at(object, index)?; + let key = (object, index.filter(|value| *value != 0)); + let class = self.instance(key)?.description.class; + match name { + "SETFOCUS" => self.focus(key)?, + "REFRESH" => self.dirty = true, + "ADDITEM" => self.add_item( + key, + args.first().map(Self::text_of).unwrap_or_default(), + args.get(1).and_then(|value| match value { + PropertyValue::Integer(v) => Some(*v), + _ => None, + }), + )?, + "REMOVEITEM" => self.remove_item( + key, + match args.first() { + Some(PropertyValue::Integer(v)) => *v, + _ => return Err(RuntimeError::TYPE_MISMATCH), + }, + )?, + "MOVE" => { + for (property, value) in ["LEFT", "TOP", "WIDTH", "HEIGHT"].into_iter().zip(args) { + let PropertyValue::Integer(value) = value else { + return Err(RuntimeError::TYPE_MISMATCH); + }; + let id = forms::property(class, property).ok_or(RuntimeError(421))?.0; + self.set_at(object, index, id, PropertyValue::Integer(value))?; + } + } + "DRAG" => { + let action = match args.first() { + Some(PropertyValue::Integer(v)) => *v, + _ => return Err(RuntimeError::TYPE_MISMATCH), + }; + match action { + 0 => { + self.dragging = None; + self.drag_over = None; + } + 1 => self.dragging = Some(key), + 2 => self.drop_drag(None), + _ => return Err(RuntimeError::ILLEGAL_FUNCTION_CALL), + } + } + "CLS" => { + self.pictures.remove(&key); + self.dirty = true; + } + "PRINT" => { + let line = args.iter().map(Self::text_of).collect::>().join(" "); + self.pictures.entry(key).or_default().push(line); + self.dirty = true; + } + "TEXTWIDTH" => { + return Ok(Some(PropertyValue::Integer( + args.first() + .map(Self::text_of) + .unwrap_or_default() + .lines() + .map(|line| line.chars().count()) + .max() + .unwrap_or(0) as i32, + ))); + } + "TEXTHEIGHT" => { + return Ok(Some(PropertyValue::Integer( + args.first() + .map(Self::text_of) + .unwrap_or_default() + .lines() + .count() + .max(1) as i32, + ))); + } + _ => return Err(RuntimeError::FEATURE_UNAVAILABLE), + } + Ok(None) + } + + fn activate(&mut self, key: ObjectKey) -> Result<(), RuntimeError> { + let class = self.instance(key)?.description.class; + if !self.boolean(key, "ENABLED") { + return Ok(()); + } + match class { + ObjectClass::CheckBox => { + let id = forms::property(class, "VALUE").unwrap().0 as usize; + let value = (self.integer(key, "VALUE").unwrap_or(0) + 1) % 2; + self.instance_mut(key)?.properties[id] = PropertyValue::Integer(value); + } + ObjectClass::OptionButton => { + let id = forms::property(class, "VALUE").unwrap().0 as usize; + self.instance_mut(key)?.properties[id] = PropertyValue::Integer(-1); + self.select_option(key)?; + } + ObjectClass::Spin => return Ok(()), + _ => {} + } + self.queue_named(key, "CLICK", vec![]); + self.dirty = true; + Ok(()) + } + + fn form_controls(&self, form: u16) -> Vec { + self.keys() + .into_iter() + .filter(|key| self.root_form(*key) == Some(form) && key.0 != form) + .collect() + } + + fn menu_children(&self, parent: ObjectKey) -> Vec { + let Ok(parent) = self.instance(parent) else { + return Vec::new(); + }; + self.keys() + .into_iter() + .filter(|key| { + self.instance(*key).is_ok_and(|object| { + object.description.class == ObjectClass::Menu + && object + .description + .parent_form + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case(&parent.description.name)) + }) && self.boolean(*key, "VISIBLE") + }) + .collect() + } + + fn choose_menu(&mut self, item: ObjectKey) { + if !self.menu_children(item).is_empty() { + self.menu_path.push(item); + } else { + self.queue_named(item, "CLICK", vec![]); + self.menu_path.clear(); + } + self.dirty = true; + } + + pub fn handle_key(&mut self, key: &str, shift: u8) -> bool { + let Some(form) = self.modal_top().or(self.active_form) else { + return false; + }; + if let Some(menu) = self.menu_path.last().copied() { + if key == taste::ESC { + self.menu_path.pop(); + self.dirty = true; + return true; + } + if let Some(ch) = key.chars().next().map(|ch| ch.to_ascii_uppercase()) { + if let Some(item) = self.menu_children(menu).into_iter().find(|candidate| { + Self::access_key(&self.string(*candidate, "CAPTION")) == Some(ch) + && self.boolean(*candidate, "ENABLED") + }) { + self.choose_menu(item); + } + } + return true; + } + if let Some(item) = self.form_controls(form).into_iter().find(|candidate| { + self.instance(*candidate) + .is_ok_and(|obj| obj.description.class == ObjectClass::Menu) + && self.boolean(*candidate, "ENABLED") + && self.boolean(*candidate, "VISIBLE") + && Self::shortcut_matches(&self.string(*candidate, "SHORTCUT"), key, shift) + }) { + self.queue_named(item, "CLICK", vec![]); + return true; + } + if shift & umschalt::ALT != 0 { + if let Some(ch) = key.chars().next().map(|ch| ch.to_ascii_uppercase()) { + if let Some(target) = + self.form_controls(form).into_iter().find(|candidate| { + Self::access_key(&self.string(*candidate, "CAPTION")) == Some(ch) + && self.boolean(*candidate, "ENABLED") + && self.boolean(*candidate, "VISIBLE") + && self.instance(*candidate).is_ok_and(|obj| { + obj.description.class != ObjectClass::Menu + || obj.description.parent_form.as_deref().is_some_and( + |parent| { + parent.eq_ignore_ascii_case( + &self.objects[form as usize].description.name, + ) + }, + ) + }) + }) + { + if self + .instance(target) + .is_ok_and(|obj| obj.description.class == ObjectClass::Menu) + { + self.menu_path.clear(); + self.menu_path.push(target); + } else if matches!( + self.instance(target).map(|obj| obj.description.class), + Ok(ObjectClass::CommandButton + | ObjectClass::CheckBox + | ObjectClass::OptionButton) + ) { + let _ = self.activate(target); + } else { + let _ = self.focus(target); + } + self.dirty = true; + return true; + } + } + } + if key == taste::TAB { + return self.tab(shift & umschalt::SHIFT == 0); + } + if let Some(active) = self + .active_control + .filter(|active| self.root_form(*active) == Some(form)) + { + let code = key.chars().next().map_or(0, |ch| ch as i32); + self.queue_named( + active, + "KEYDOWN", + vec![ + PropertyValue::Integer(code), + PropertyValue::Integer(shift as i32), + ], + ); + let handled = self.control_key(active, key); + if key.chars().count() == 1 { + self.queue_named(active, "KEYPRESS", vec![PropertyValue::Integer(code)]); + } + self.queue_named( + active, + "KEYUP", + vec![ + PropertyValue::Integer(code), + PropertyValue::Integer(shift as i32), + ], + ); + if handled { + return true; + } + } + let button_property = if key == taste::ENTER { + "DEFAULT" + } else if key == taste::ESC { + "CANCEL" + } else { + "" + }; + if !button_property.is_empty() { + if let Some(button) = self.form_controls(form).into_iter().find(|candidate| { + self.instance(*candidate) + .is_ok_and(|obj| obj.description.class == ObjectClass::CommandButton) + && self.boolean(*candidate, button_property) + && self.boolean(*candidate, "ENABLED") + }) { + let _ = self.activate(button); + return true; + } + } + let code = key.chars().next().map_or(0, |ch| ch as i32); + self.queue_named( + (form, None), + "KEYDOWN", + vec![ + PropertyValue::Integer(code), + PropertyValue::Integer(shift as i32), + ], + ); + true + } + + fn control_key(&mut self, key: ObjectKey, input: &str) -> bool { + let Ok(class) = self.instance(key).map(|obj| obj.description.class) else { + return false; + }; + if matches!( + class, + ObjectClass::CommandButton | ObjectClass::CheckBox | ObjectClass::OptionButton + ) && matches!(input, "\r" | " ") + { + let _ = self.activate(key); + return true; + } + if class == ObjectClass::TextBox + || (class == ObjectClass::ComboBox && self.integer(key, "STYLE") != Some(2)) + { + if input == taste::BACKSPACE { + let pos = self + .integer(key, "SELSTART") + .unwrap_or_else(|| self.string(key, "TEXT").chars().count() as i32); + if pos > 0 { + let start = forms::property(class, "SELSTART").unwrap().0 as usize; + let length = forms::property(class, "SELLENGTH").unwrap().0 as usize; + let obj = self.instance_mut(key).unwrap(); + obj.properties[start] = PropertyValue::Integer(pos - 1); + obj.properties[length] = PropertyValue::Integer(1); + let _ = self.replace_selection(key, ""); + } + return true; + } + if input == taste::ENTER + && class == ObjectClass::TextBox + && self.boolean(key, "MULTILINE") + { + let _ = self.replace_selection(key, "\n"); + return true; + } + if input.chars().count() == 1 && input.chars().next().is_some_and(|ch| !ch.is_control()) + { + let _ = self.replace_selection(key, input); + return true; + } + } + let direction = input.chars().nth(1).map(|ch| ch as u8); + if matches!(class, ObjectClass::ListBox | ObjectClass::ComboBox) + && matches!(direction, Some(72 | 80)) + { + let count = self.lists.get(&key).map_or(0, Vec::len) as i32; + if count > 0 { + let current = self.integer(key, "LISTINDEX").unwrap_or(-1); + let next = if direction == Some(72) { + (current - 1).max(0) + } else { + (current + 1).min(count - 1) + }; + let id = forms::property(class, "LISTINDEX").unwrap().0 as usize; + self.instance_mut(key).unwrap().properties[id] = PropertyValue::Integer(next); + self.queue_named(key, "CLICK", vec![]); + self.dirty = true; + } + return true; + } + if matches!(class, ObjectClass::HScrollBar | ObjectClass::VScrollBar) + && matches!(direction, Some(72 | 75 | 77 | 80)) + { + let delta = if matches!(direction, Some(72 | 75)) { + -1 + } else { + 1 + } * self.integer(key, "SMALLCHANGE").unwrap_or(1); + let value = (self.integer(key, "VALUE").unwrap_or(0) + delta).clamp( + self.integer(key, "MIN").unwrap_or(0), + self.integer(key, "MAX").unwrap_or(32767), + ); + let id = forms::property(class, "VALUE").unwrap().0; + let _ = self.set_at(key.0, key.1, id, PropertyValue::Integer(value)); + return true; + } + if class == ObjectClass::Spin { + let delta = match (self.integer(key, "STYLE"), direction) { + (Some(1), Some(75)) | (Some(0), Some(80)) => -1, + (Some(1), Some(77)) | (Some(0), Some(72)) => 1, + _ => return false, + }; + self.spin(key, delta); + return true; + } + false + } + + fn rect(&self, key: ObjectKey) -> Option<(usize, usize, usize, usize)> { + let obj = self.instance(key).ok()?; + let mut left = self.integer(key, "LEFT").unwrap_or(0).max(0) as usize + 1; + let mut top = self.integer(key, "TOP").unwrap_or(0).max(0) as usize + 1; + let width = self.integer(key, "WIDTH").unwrap_or(1).max(1) as usize; + let height = self.integer(key, "HEIGHT").unwrap_or(1).max(1) as usize; + let mut parent = obj.description.parent_form.as_deref(); + for _ in 0..self.objects.len() { + let Some(name) = parent else { break }; + let Some((id, parent_obj)) = self + .objects + .iter() + .enumerate() + .find(|(_, candidate)| candidate.description.name.eq_ignore_ascii_case(name)) + else { + break; + }; + let parent_key = (id as u16, None); + left += self.integer(parent_key, "LEFT").unwrap_or(0).max(0) as usize; + top += self.integer(parent_key, "TOP").unwrap_or(0).max(0) as usize; + parent = parent_obj.description.parent_form.as_deref(); + } + Some((left, top, width, height)) + } + + pub fn hit_test(&self, form: u16, row: usize, col: usize) -> ObjectKey { + if !self + .rect((form, None)) + .is_some_and(|(_, _, width, height)| width >= 3 && height >= 2) + { + return (form, None); + } + self.form_controls(form) + .into_iter() + .rev() + .find(|key| { + self.instance(*key).is_ok_and(|obj| { + !matches!( + obj.description.class, + ObjectClass::Timer | ObjectClass::Menu + ) + }) && self.boolean(*key, "VISIBLE") + && self.rect(*key).is_some_and(|(left, top, width, height)| { + col >= left && col < left + width && row >= top && row < top + height + }) + }) + .unwrap_or((form, None)) + } + + pub fn handle_mouse(&mut self, event: MausEreignis) -> bool { + self.handle_mouse_at(event, 0) + } + + pub fn handle_mouse_at(&mut self, event: MausEreignis, now_ms: u64) -> bool { + let Some(form) = self.modal_top().or(self.active_form) else { + return false; + }; + if !self.menu_path.is_empty() { + return true; + } + let target = self.hit_test(form, event.zeile, event.spalte); + let name = match event.art { + MausArt::Druck => "MOUSEDOWN", + MausArt::Loslassen => "MOUSEUP", + MausArt::Bewegung => "MOUSEMOVE", + }; + self.queue_named( + target, + name, + vec![ + PropertyValue::Integer(event.taste as i32), + PropertyValue::Integer(event.shift as i32), + PropertyValue::Single(event.spalte as f32), + PropertyValue::Single(event.zeile as f32), + ], + ); + match event.art { + MausArt::Druck => { + self.pressed = Some(target); + if self.focusable(target) { + let _ = self.focus(target); + } + if self.instance(target).is_ok_and(|obj| { + matches!( + obj.description.class, + ObjectClass::HScrollBar | ObjectClass::VScrollBar + ) + }) { + self.scroll_mouse(target, event.zeile, event.spalte); + } + if self + .instance(target) + .is_ok_and(|obj| obj.description.class == ObjectClass::Spin) + { + let delta = self.spin_direction(target, event.zeile, event.spalte); + self.spin(target, delta); + self.spin_repeat = Some((target, delta)); + self.spin_last = None; + } + if self.integer(target, "DRAGMODE") == Some(1) { + self.dragging = Some(target); + } + } + MausArt::Bewegung if self.dragging.is_some() => { + if self.drag_over != Some(target) { + if let (Some(source), Some(old)) = (self.dragging, self.drag_over) { + self.queue_named( + old, + "DRAGOVER", + vec![ + PropertyValue::Object(Some(source)), + PropertyValue::Single(event.spalte as f32), + PropertyValue::Single(event.zeile as f32), + PropertyValue::Integer(1), + ], + ); + } + if let Some(source) = self.dragging { + self.queue_named( + target, + "DRAGOVER", + vec![ + PropertyValue::Object(Some(source)), + PropertyValue::Single(event.spalte as f32), + PropertyValue::Single(event.zeile as f32), + PropertyValue::Integer(0), + ], + ); + } + self.drag_over = Some(target); + } else if let Some(source) = self.dragging { + self.queue_named( + target, + "DRAGOVER", + vec![ + PropertyValue::Object(Some(source)), + PropertyValue::Single(event.spalte as f32), + PropertyValue::Single(event.zeile as f32), + PropertyValue::Integer(2), + ], + ); + } + } + MausArt::Loslassen => { + if self.dragging.is_some() { + self.drop_drag(Some((event.spalte as f32, event.zeile as f32))); + } else if self.pressed == Some(target) { + let _ = self.activate(target); + let click = (target, event.taste, event.zeile, event.spalte, now_ms); + if self.last_click.is_some_and(|previous| { + previous.0 == target + && previous.1 == event.taste + && previous.2.abs_diff(event.zeile) <= 1 + && previous.3.abs_diff(event.spalte) <= 1 + && now_ms.saturating_sub(previous.4) <= 500 + }) { + self.queue_named(target, "DBLCLICK", vec![]); + self.last_click = None; + } else { + self.last_click = Some(click); + } + } + self.pressed = None; + self.spin_repeat = None; + self.spin_last = None; + } + _ => {} + } + true + } + + fn scroll_mouse(&mut self, key: ObjectKey, row: usize, col: usize) { + let Ok(class) = self.instance(key).map(|obj| obj.description.class) else { + return; + }; + let Some((left, top, width, height)) = self.rect(key) else { + return; + }; + let before = if class == ObjectClass::HScrollBar { + col < left + width / 2 + } else { + row < top + height / 2 + }; + let delta = self.integer(key, "SMALLCHANGE").unwrap_or(1) * if before { -1 } else { 1 }; + let value = (self.integer(key, "VALUE").unwrap_or(0) + delta).clamp( + self.integer(key, "MIN").unwrap_or(0), + self.integer(key, "MAX").unwrap_or(32767), + ); + if let Some((id, _)) = forms::property(class, "VALUE") { + let _ = self.set_at(key.0, key.1, id, PropertyValue::Integer(value)); + } + } + + fn spin_direction(&self, key: ObjectKey, row: usize, col: usize) -> i32 { + let Some((left, top, width, height)) = self.rect(key) else { + return 1; + }; + if self.integer(key, "STYLE") == Some(1) { + if col < left + width / 2 { + -1 + } else { + 1 + } + } else if row < top + height / 2 { + 1 + } else { + -1 + } + } + + fn spin(&mut self, key: ObjectKey, delta: i32) { + let min = self.integer(key, "MIN").unwrap_or(0); + let max = self.integer(key, "MAX").unwrap_or(32767); + if min > max { + return; + } + let current = self.integer(key, "VALUE").unwrap_or(min); + let value = if delta > 0 && current >= max { + min + } else if delta < 0 && current <= min { + max + } else { + current + delta + }; + if let Some((id, _)) = forms::property(ObjectClass::Spin, "VALUE") { + if self + .set_at(key.0, key.1, id, PropertyValue::Integer(value)) + .is_ok() + { + self.queue_named( + key, + "CUSTOM", + vec![PropertyValue::Integer(if delta > 0 { 1 } else { 2 })], + ); + } + } + } + + fn drop_drag(&mut self, position: Option<(f32, f32)>) { + if let Some(source) = self.dragging.take() { + if let Some(target) = self.drag_over.take() { + let (x, y) = position.unwrap_or((0.0, 0.0)); + self.queue_named( + target, + "DRAGDROP", + vec![ + PropertyValue::Object(Some(source)), + PropertyValue::Single(x), + PropertyValue::Single(y), + ], + ); + } + } + } + + pub fn timers(&mut self, now_ms: u64) { + if !self.menu_path.is_empty() { + return; + } + let mut due: Vec<_> = self + .keys() + .into_iter() + .filter(|key| { + self.instance(*key) + .is_ok_and(|obj| obj.description.class == ObjectClass::Timer) + && self.boolean(*key, "ENABLED") + && self.integer(*key, "INTERVAL").unwrap_or(0) > 0 + && self + .root_form(*key) + .is_some_and(|form| self.is_visible(form)) + }) + .collect(); + due.sort_by_key(|key| { + self.instance(*key) + .map(|obj| obj.description.name.to_uppercase()) + .unwrap_or_default() + }); + for key in due { + let interval = self.integer(key, "INTERVAL").unwrap() as u64; + let mut count = 0; + { + let last = self.timer_last.entry(key).or_insert(0); + while now_ms.saturating_sub(*last) >= interval { + *last += interval; + count += 1; + } + } + for _ in 0..count { + self.queue_named(key, "TIMER", vec![]); + } + } + if let Some((key, delta)) = self.spin_repeat { + let interval = self.integer(key, "INTERVAL").unwrap_or(250).max(0) as u64; + if interval > 0 { + let last = self.spin_last.get_or_insert(now_ms); + let mut count = 0; + while now_ms.saturating_sub(*last) >= interval { + *last += interval; + count += 1; + } + for _ in 0..count { + self.spin(key, delta); + } + } + } + } + + pub fn menu_is_open(&self) -> bool { + !self.menu_path.is_empty() + } + + fn shortcut_matches(shortcut: &str, key: &str, shift: u8) -> bool { + if shortcut.is_empty() { + return false; + } + let mut actual = String::new(); + if shift & umschalt::CTRL != 0 { + actual.push_str("CTRL+"); + } + if shift & umschalt::ALT != 0 { + actual.push_str("ALT+"); + } + if shift & umschalt::SHIFT != 0 { + actual.push_str("SHIFT+"); + } + if let Some(code) = key + .strip_prefix('\0') + .and_then(|tail| tail.chars().next()) + .map(|ch| ch as u8) + { + let name = match code { + 59..=68 => format!("F{}", code - 58), + 133 => "F11".into(), + 134 => "F12".into(), + _ => return false, + }; + actual.push_str(&name); + } else { + actual.push_str(&key.to_uppercase()); + } + shortcut.replace(' ', "").eq_ignore_ascii_case(&actual) + } + + fn fit(text: &str, width: usize) -> String { + let mut out: String = text.chars().take(width).collect(); + out.extend(std::iter::repeat_n( + ' ', + width.saturating_sub(out.chars().count()), + )); + out + } + + fn put_char(line: &mut String, at: usize, value: char) { + let mut chars: Vec<_> = line.chars().collect(); + if let Some(cell) = chars.get_mut(at) { + *cell = value; + *line = chars.into_iter().collect(); + } + } + + fn write_at(screen: &mut TextScreen, row: usize, col: usize, text: &str, fg: u8, bg: u8) { + if row == 0 || col == 0 || row > screen.rows() || col > screen.cols() { + return; + } + let clipped: String = text.chars().take(screen.cols() - col + 1).collect(); + screen.set_color(fg, bg); + let _ = screen.locate(row, col); + screen.print(&clipped); + } + + fn box_lines(width: usize, height: usize, caption: &str, double: bool) -> Vec { + let width = width.max(2); + let height = height.max(2); + let (tl, tr, bl, br, h, v) = if double { + ('╔', '╗', '╚', '╝', '═', '║') + } else { + ('┌', '┐', '└', '┘', '─', '│') + }; + let mut top: Vec = std::iter::once(tl) + .chain(std::iter::repeat_n(h, width - 2)) + .chain(std::iter::once(tr)) + .collect(); + if !caption.is_empty() && width > 4 { + for (at, ch) in format!(" {caption} ").chars().take(width - 2).enumerate() { + top[at + 1] = ch; + } + } + let mut lines = vec![top.into_iter().collect()]; + for _ in 2..height { + lines.push(format!("{v}{}{v}", " ".repeat(width - 2))); + } + lines.push(format!("{bl}{}{br}", h.to_string().repeat(width - 2))); + lines + } + + fn control_lines(&self, key: ObjectKey, width: usize, height: usize) -> Vec { + let Ok(class) = self.instance(key).map(|obj| obj.description.class) else { + return vec![]; + }; + let caption = self.caption(key); + match class { + ObjectClass::CommandButton if height == 1 => { + vec![Self::fit(&format!("<{caption}>"), width)] + } + ObjectClass::CommandButton if height == 2 => { + let mut lines = Self::box_lines(width, 2, &caption, false); + lines.truncate(2); + lines + } + ObjectClass::CommandButton => { + let mut lines = Self::box_lines(width, height, "", false); + let row = height / 2; + if row < lines.len() && width > 2 { + let text = Self::fit(&caption, width - 2); + lines[row] = format!("│{text}│"); + } + lines + } + ObjectClass::Label => { + let text = match self.integer(key, "ALIGNMENT").unwrap_or(0) { + 1 => format!("{caption:>width$}"), + 2 => { + let left = width.saturating_sub(caption.chars().count()) / 2; + Self::fit(&format!("{}{caption}", " ".repeat(left)), width) + } + _ => Self::fit(&caption, width), + }; + if self.integer(key, "BORDERSTYLE").unwrap_or(0) == 0 { + vec![text] + } else { + Self::box_lines( + width, + height.max(2), + &caption, + self.integer(key, "BORDERSTYLE") == Some(2), + ) + } + } + ObjectClass::Frame => Self::box_lines(width, height, &caption, false), + ObjectClass::CheckBox => vec![Self::fit( + &format!( + "[{}] {caption}", + match self.integer(key, "VALUE").unwrap_or(0) { + 1 => "x", + 2 => "-", + _ => " ", + } + ), + width, + )], + ObjectClass::OptionButton => vec![Self::fit( + &format!( + "({}) {caption}", + if self.integer(key, "VALUE").is_some_and(|value| value != 0) { + "•" + } else { + " " + } + ), + width, + )], + ObjectClass::TextBox => { + let text = self.string(key, "TEXT"); + let border = self.integer(key, "BORDERSTYLE") != Some(0); + let multiline = self.boolean(key, "MULTILINE"); + let scrollbars = if multiline { + self.integer(key, "SCROLLBARS").unwrap_or(0) + } else { + 0 + }; + let vertical = scrollbars & 2 != 0; + let horizontal = scrollbars & 1 != 0; + let inset = usize::from(border); + let content_width = width + .saturating_sub(inset * 2 + usize::from(vertical)) + .max(1); + let content_height = height.saturating_sub(inset * 2 + usize::from(horizontal)); + let mut wrapped = Vec::new(); + for line in text.lines().take(if multiline { usize::MAX } else { 1 }) { + let chars: Vec<_> = line.chars().collect(); + if chars.is_empty() { + wrapped.push(String::new()); + } else { + wrapped.extend( + chars + .chunks(content_width) + .map(|chunk| chunk.iter().collect::()), + ); + } + } + let mut lines = if border { + Self::box_lines(width, height.max(2), "", false) + } else { + vec![" ".repeat(width); height] + }; + for (row, text) in wrapped.into_iter().take(content_height).enumerate() { + for (column, value) in Self::fit(&text, content_width).chars().enumerate() { + Self::put_char(&mut lines[row + inset], column + inset, value); + } + } + if vertical && content_height > 0 { + let column = width.saturating_sub(inset + 1); + for row in 0..content_height { + let value = if row == 0 { + '▲' + } else if row + 1 == content_height { + '▼' + } else { + '│' + }; + Self::put_char(&mut lines[row + inset], column, value); + } + } + if horizontal && content_width > 0 && inset + content_height < lines.len() { + let row = inset + content_height; + for column in 0..content_width { + let value = if column == 0 { + '◄' + } else if column + 1 == content_width { + '►' + } else { + '─' + }; + Self::put_char(&mut lines[row], column + inset, value); + } + } + lines + } + ObjectClass::ListBox | ObjectClass::DirListBox | ObjectClass::FileListBox => { + let mut lines = Self::box_lines(width, height.max(2), "", false); + let selected = self.integer(key, "LISTINDEX").unwrap_or(-1); + for (row, item) in self + .lists + .get(&key) + .into_iter() + .flatten() + .take(height.saturating_sub(2)) + .enumerate() + { + let marker = if selected == row as i32 { '>' } else { ' ' }; + lines[row + 1] = + format!("│{marker}{}│", Self::fit(item, width.saturating_sub(3))); + } + lines + } + ObjectClass::ComboBox | ObjectClass::DriveListBox => { + let text = if class == ObjectClass::DriveListBox { + self.lists + .get(&key) + .and_then(|items| items.first()) + .cloned() + .unwrap_or_else(|| std::path::MAIN_SEPARATOR.to_string()) + } else { + let selected = self.integer(key, "LISTINDEX").unwrap_or(-1); + self.lists + .get(&key) + .and_then(|items| items.get(selected.max(0) as usize)) + .cloned() + .unwrap_or_else(|| self.string(key, "TEXT")) + }; + let style = self.integer(key, "STYLE").unwrap_or(0); + let first = if class == ObjectClass::ComboBox && style == 2 { + format!("[{}]▼", Self::fit(&text, width.saturating_sub(3))) + } else { + format!("{text} ▼") + }; + let mut lines = vec![Self::fit(&first, width)]; + if class == ObjectClass::ComboBox && style == 1 { + lines.extend(Self::box_lines( + width, + height.saturating_sub(1).max(2), + "", + false, + )); + } + lines.truncate(height); + lines + } + ObjectClass::HScrollBar => { + let inner = width.saturating_sub(2); + let min = self.integer(key, "MIN").unwrap_or(0); + let max = self.integer(key, "MAX").unwrap_or(32767); + let value = self.integer(key, "VALUE").unwrap_or(min); + let pos = if max == min { + 0 + } else { + ((value - min) as usize * inner.saturating_sub(1)) / (max - min) as usize + }; + let mut track = vec!['─'; inner]; + if !track.is_empty() { + let last = track.len() - 1; + track[pos.min(last)] = '□'; + } + vec![format!("◄{}►", track.into_iter().collect::())] + } + ObjectClass::VScrollBar => { + let mut lines = vec!["▲".into()]; + lines.extend(std::iter::repeat_n("│".into(), height.saturating_sub(2))); + if height > 2 { + lines[height / 2] = "□".into(); + } + if height > 1 { + lines.push("▼".into()); + } + lines + } + ObjectClass::Spin if self.integer(key, "STYLE") == Some(1) => { + vec![Self::fit("◄ ►", width)] + } + ObjectClass::Spin => { + let mut lines = vec!["▲".into()]; + lines.extend(std::iter::repeat_n(" ".into(), height.saturating_sub(2))); + if height > 1 { + lines.push("▼".into()); + } + lines + } + ObjectClass::PictureBox => { + let border = self.integer(key, "BORDERSTYLE").unwrap_or(1); + let mut lines = if border == 0 { + vec![" ".repeat(width); height] + } else { + Self::box_lines(width, height.max(2), "", border == 2) + }; + let offset = usize::from(border != 0); + for (row, text) in self + .pictures + .get(&key) + .into_iter() + .flatten() + .take(height.saturating_sub(offset * 2)) + .enumerate() + { + let fitted = Self::fit(text, width.saturating_sub(offset * 2)); + lines[row + offset] = if offset == 0 { + fitted + } else { + format!("│{fitted}│") + }; + } + lines + } + ObjectClass::Timer | ObjectClass::Menu | ObjectClass::Screen => vec![], + ObjectClass::Form => Self::box_lines( + width, + height, + &caption, + self.integer(key, "BORDERSTYLE") + .is_some_and(|style| matches!(style, 3 | 4)), + ), + } + } + + pub fn render(&mut self, screen: &mut TextScreen) { + if !self.dirty || !self.screen_visible || self.visible_forms.is_empty() { + return; + } + let render_forms: Vec<_> = self + .visible_forms + .iter() + .copied() + .filter(|form| { + self.rect((*form, None)) + .is_some_and(|(_, _, width, height)| width >= 3 && height >= 2) + }) + .collect(); + if render_forms.is_empty() { + self.dirty = false; + return; + } + let cursor = ( + screen.csrlin(), + screen.pos(), + screen.fg, + screen.bg, + screen.cursor_visible, + ); + screen.set_color(7, 0); + screen.cls(); + for form in render_forms { + let form_key = (form, None); + if let Some((left, top, width, height)) = self.rect(form_key) { + if width < 3 || height < 2 { + continue; + } + for (row, line) in self + .control_lines(form_key, width, height) + .into_iter() + .enumerate() + { + Self::write_at(screen, top + row, left, &line, 7, 0); + } + let mut menu_col = left + 1; + for key in self.form_controls(form) { + let Ok(class) = self.instance(key).map(|obj| obj.description.class) else { + continue; + }; + if class == ObjectClass::Menu + && self + .instance(key) + .ok() + .and_then(|obj| obj.description.parent_form.as_deref()) + .is_some_and(|parent| { + parent.eq_ignore_ascii_case( + &self.objects[form as usize].description.name, + ) + }) + { + if self.boolean(key, "VISIBLE") { + let caption = format!(" {} ", self.caption(key)); + let focused = self.menu_path.first() == Some(&key); + Self::write_at( + screen, + top + 1, + menu_col, + &caption, + if focused { + 15 + } else if self.boolean(key, "ENABLED") { + 7 + } else { + 8 + }, + if focused { 1 } else { 0 }, + ); + menu_col += caption.chars().count(); + } + continue; + } + if !self.boolean(key, "VISIBLE") + || matches!(class, ObjectClass::Timer | ObjectClass::Menu) + { + continue; + } + let Some((left, top, width, height)) = self.rect(key) else { + continue; + }; + let enabled = self.boolean(key, "ENABLED"); + let focused = self.active_control == Some(key); + let (fg, bg) = if !enabled { + (8, 0) + } else if focused { + (15, 1) + } else { + ( + self.integer(key, "FORECOLOR").unwrap_or(0) as u8, + self.integer(key, "BACKCOLOR").unwrap_or(7) as u8, + ) + }; + for (row, line) in self + .control_lines(key, width, height) + .into_iter() + .enumerate() + { + Self::write_at(screen, top + row, left, &line, fg, bg); + } + } + if self + .menu_path + .first() + .is_some_and(|root| self.root_form(*root) == Some(form)) + { + let mut popup_left = left + 1; + for root in + self.form_controls(form).into_iter().filter(|key| { + self.instance(*key).is_ok_and(|object| { + object.description.class == ObjectClass::Menu + && object.description.parent_form.as_deref().is_some_and( + |parent| { + parent.eq_ignore_ascii_case( + &self.objects[form as usize].description.name, + ) + }, + ) + }) && self.boolean(*key, "VISIBLE") + }) + { + if Some(&root) == self.menu_path.first() { + break; + } + popup_left += self.caption(root).chars().count() + 2; + } + let mut popup_top = top + 2; + for (depth, parent) in self.menu_path.clone().into_iter().enumerate() { + let children = self.menu_children(parent); + if children.is_empty() { + break; + } + let popup_width = children + .iter() + .map(|child| { + let shortcut = self.string(*child, "SHORTCUT"); + 4 + self.caption(*child).chars().count() + + usize::from(!shortcut.is_empty()) + * (shortcut.chars().count() + 1) + }) + .max() + .unwrap_or(6) + .max(6) + .min(screen.cols().saturating_sub(popup_left).saturating_add(1)); + let mut lines = Self::box_lines(popup_width, children.len() + 2, "", false); + for (row, child) in children.iter().enumerate() { + if self.boolean(*child, "SEPARATOR") || self.caption(*child) == "-" { + lines[row + 1] = + format!("├{}┤", "─".repeat(popup_width.saturating_sub(2))); + continue; + } + let shortcut = self.string(*child, "SHORTCUT"); + let marker = if self.boolean(*child, "CHECKED") { + "√ " + } else { + " " + }; + let suffix = if shortcut.is_empty() { + String::new() + } else { + format!(" {shortcut}") + }; + lines[row + 1] = format!( + "│{}│", + Self::fit( + &format!("{marker}{}{suffix}", self.caption(*child)), + popup_width.saturating_sub(2), + ) + ); + } + for (row, line) in lines.iter().enumerate() { + let selected = self.menu_path.get(depth + 1).is_some_and(|next| { + children.get(row.saturating_sub(1)) == Some(next) + }); + Self::write_at( + screen, + popup_top + row, + popup_left, + line, + if selected { 15 } else { 7 }, + if selected { 1 } else { 0 }, + ); + } + let Some(next) = self.menu_path.get(depth + 1) else { + break; + }; + let Some(row) = children.iter().position(|child| child == next) else { + break; + }; + popup_left += popup_width.saturating_sub(1); + popup_top += row + 1; + } + } + } + } + screen.set_color(cursor.2, cursor.3); + let _ = screen.locate(cursor.0.min(screen.rows()), cursor.1.min(screen.cols())); + screen.cursor_visible = cursor.4; + self.dirty = false; + } +} + +fn dialog_set( + model: &mut FormsModel, + object: u16, + name: &str, + value: PropertyValue, +) -> Result<(), RuntimeError> { + let class = model.objects[object as usize].description.class; + let property = forms::property(class, name).ok_or(RuntimeError(421))?.0; + model.set_initial(object, property, value) +} + +fn dialog_event( + model: &mut FormsModel, + screen: &mut TextScreen, + host: &mut dyn Host, + queued: &mut VecDeque, + plain_access_key: bool, +) -> Option { + loop { + model.render(screen); + host.present(screen); + let event = queued.pop_front().or_else(|| host.next_event(true))?; + match event { + Ereignis::Taste(key, shift) => { + let shift = if plain_access_key && key.chars().count() == 1 { + shift | umschalt::ALT + } else { + shift + }; + model.handle_key(&key, shift); + } + Ereignis::Maus(event) => { + model.handle_mouse_at(event, host.jetzt_ms()); + } + Ereignis::Groesse { cols, rows } => { + screen.resize(cols, rows); + model.resize(cols, rows); + } + event @ (Ereignis::Abbruch | Ereignis::Ende | Ereignis::Signal(_)) => { + queued.push_front(event); + return None; + } + } + while let Some(event) = model.next_event() { + if event.name == "CLICK" { + return Some(event); + } + } + } +} + +/// Zeigt MSGBOX als echtes modales FormsModel-Formular. +pub fn msgbox_dialog( + screen: &mut TextScreen, + host: &mut dyn Host, + queued: &mut VecDeque, + text: &str, + kind: i32, + title: &str, +) -> Result { + let groups: &[&[(i16, &str)]] = &[ + &[(1, "&OK")], + &[(1, "&OK"), (2, "&Cancel")], + &[(3, "&Abort"), (4, "&Retry"), (5, "&Ignore")], + &[(6, "&Yes"), (7, "&No"), (2, "&Cancel")], + &[(6, "&Yes"), (7, "&No")], + &[(4, "&Retry"), (2, "&Cancel")], + ]; + let buttons = groups + .get((kind & 7) as usize) + .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?; + let default = ((kind >> 8) & 3) as usize; + if default >= buttons.len() { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + let button_widths: Vec<_> = buttons + .iter() + .map(|(_, caption)| caption.chars().count() + 3) + .collect(); + let button_line = button_widths.iter().sum::() + buttons.len().saturating_sub(1) * 2; + let width = (text + .lines() + .map(|line| line.chars().count()) + .max() + .unwrap_or(0) + + 4) + .max(button_line + 4) + .max(title.chars().count() + 4) + .min(screen.cols()); + let height = (text.lines().count() + 5).min(screen.rows()); + let left = (screen.cols() - width) / 2; + let top = (screen.rows() - height) / 2; + let mut catalog = forms::FormCatalog::default(); + catalog.add("Dialog", ObjectClass::Form, None, false); + catalog.add("Prompt", ObjectClass::Label, Some("Dialog"), false); + for index in 0..buttons.len() { + catalog.add( + format!("Button{index}"), + ObjectClass::CommandButton, + Some("Dialog"), + false, + ); + } + let mut model = FormsModel::new(catalog.objects, screen.cols(), screen.rows()); + for (name, value) in [ + ("LEFT", PropertyValue::Integer(left as i32)), + ("TOP", PropertyValue::Integer(top as i32)), + ("WIDTH", PropertyValue::Integer(width as i32)), + ("HEIGHT", PropertyValue::Integer(height as i32)), + ("CAPTION", PropertyValue::String(title.into())), + ] { + dialog_set(&mut model, 0, name, value)?; + } + for (name, value) in [ + ("LEFT", PropertyValue::Integer(1)), + ("TOP", PropertyValue::Integer(1)), + ( + "WIDTH", + PropertyValue::Integer(width.saturating_sub(4) as i32), + ), + ( + "HEIGHT", + PropertyValue::Integer(text.lines().count().max(1) as i32), + ), + ("CAPTION", PropertyValue::String(text.into())), + ] { + dialog_set(&mut model, 1, name, value)?; + } + let mut button_left = width.saturating_sub(button_line) / 2; + for (index, ((_, caption), button_width)) in buttons.iter().zip(button_widths).enumerate() { + let object = index as u16 + 2; + for (name, value) in [ + ("LEFT", PropertyValue::Integer(button_left as i32)), + ( + "TOP", + PropertyValue::Integer(height.saturating_sub(3) as i32), + ), + ("WIDTH", PropertyValue::Integer(button_width as i32)), + ("HEIGHT", PropertyValue::Integer(1)), + ("CAPTION", PropertyValue::String((*caption).into())), + ("DEFAULT", PropertyValue::Boolean(index == default)), + ("CANCEL", PropertyValue::Boolean(buttons[index].0 == 2)), + ] { + dialog_set(&mut model, object, name, value)?; + } + button_left += button_width + 2; + } + let old = screen.clone(); + model.show(0, true)?; + model.focus((default as u16 + 2, None))?; + model.events.clear(); + let chosen = loop { + let Some(event) = dialog_event(&mut model, screen, host, queued, true) else { + break buttons[default].0; + }; + if let Some(index) = event.object.checked_sub(2).map(usize::from) { + if let Some((code, _)) = buttons.get(index) { + break *code; + } + } + }; + *screen = old; + Ok(chosen) +} + +/// Zeigt INPUTBOX$ als echtes modales FormsModel-Formular. +pub fn inputbox_dialog( + screen: &mut TextScreen, + host: &mut dyn Host, + queued: &mut VecDeque, + prompt: &str, + title: &str, + initial: &str, + position: Option<(i32, i32)>, +) -> Result { + let width = 46usize.min(screen.cols()); + let height = 16usize.min(screen.rows()); + let (left, top) = position.map_or_else( + || ((screen.cols() - width) / 2, (screen.rows() - height) / 2), + |(x, y)| (x.max(1) as usize - 1, y.max(1) as usize - 1), + ); + if left + width > screen.cols() || top + height > screen.rows() { + return Err(RuntimeError::ILLEGAL_FUNCTION_CALL); + } + let mut catalog = forms::FormCatalog::default(); + catalog.add("InputDialog", ObjectClass::Form, None, false); + catalog.add("Prompt", ObjectClass::Label, Some("InputDialog"), false); + catalog.add("Input", ObjectClass::TextBox, Some("InputDialog"), false); + catalog.add("OK", ObjectClass::CommandButton, Some("InputDialog"), false); + catalog.add( + "Cancel", + ObjectClass::CommandButton, + Some("InputDialog"), + false, + ); + let mut model = FormsModel::new(catalog.objects, screen.cols(), screen.rows()); + for (name, value) in [ + ("LEFT", PropertyValue::Integer(left as i32)), + ("TOP", PropertyValue::Integer(top as i32)), + ("WIDTH", PropertyValue::Integer(width as i32)), + ("HEIGHT", PropertyValue::Integer(height as i32)), + ("CAPTION", PropertyValue::String(title.into())), + ] { + dialog_set(&mut model, 0, name, value)?; + } + for (name, value) in [ + ("LEFT", PropertyValue::Integer(1)), + ("TOP", PropertyValue::Integer(1)), + ("WIDTH", PropertyValue::Integer(42)), + ("HEIGHT", PropertyValue::Integer(8)), + ("CAPTION", PropertyValue::String(prompt.into())), + ] { + dialog_set(&mut model, 1, name, value)?; + } + for (name, value) in [ + ("LEFT", PropertyValue::Integer(2)), + ("TOP", PropertyValue::Integer(10)), + ("WIDTH", PropertyValue::Integer(40)), + ("HEIGHT", PropertyValue::Integer(1)), + ("BORDERSTYLE", PropertyValue::Integer(0)), + ("TEXT", PropertyValue::String(initial.into())), + ] { + dialog_set(&mut model, 2, name, value)?; + } + for (object, left, caption, default, cancel) in + [(3, 27, "&OK", true, false), (4, 34, "&Cancel", false, true)] + { + for (name, value) in [ + ("LEFT", PropertyValue::Integer(left)), + ("TOP", PropertyValue::Integer(12)), + ("WIDTH", PropertyValue::Integer(8)), + ("HEIGHT", PropertyValue::Integer(1)), + ("CAPTION", PropertyValue::String(caption.into())), + ("DEFAULT", PropertyValue::Boolean(default)), + ("CANCEL", PropertyValue::Boolean(cancel)), + ] { + dialog_set(&mut model, object, name, value)?; + } + } + let old = screen.clone(); + model.show(0, true)?; + model.focus((2, None))?; + model.events.clear(); + let accepted = loop { + let Some(event) = dialog_event(&mut model, screen, host, queued, false) else { + break false; + }; + match event.object { + 3 => break true, + 4 => break false, + _ => {} + } + }; + let text = if accepted { + model.string((2, None), "TEXT") + } else { + String::new() + }; + *screen = old; + Ok(text) } #[cfg(test)] mod tests { use super::*; + use tb_runtime::host::CaptureHost; fn model() -> FormsModel { let mut c = forms::FormCatalog::default(); @@ -491,6 +2668,36 @@ mod tests { ); } + #[test] + fn spin_zaehlt_zyklisch_und_wiederholt_nach_interval() { + let mut catalog = forms::FormCatalog::default(); + catalog.add("Form1", ObjectClass::Form, None, false); + catalog.add("Spin1", ObjectClass::Spin, Some("Form1"), false); + let mut model = FormsModel::new(catalog.objects, 80, 25); + for (name, value) in [("MIN", 0), ("MAX", 2), ("VALUE", 2), ("INTERVAL", 50)] { + let property = forms::property(ObjectClass::Spin, name).unwrap().0; + model + .set_initial(1, property, PropertyValue::Integer(value)) + .unwrap(); + } + + model.spin((1, None), 1); + assert_eq!(model.integer((1, None), "VALUE"), Some(0)); + assert_eq!( + model.events.pop_back().unwrap().args, + [PropertyValue::Integer(1)] + ); + model.spin_repeat = Some(((1, None), -1)); + model.timers(100); + model.timers(150); + assert_eq!(model.integer((1, None), "VALUE"), Some(2)); + assert_eq!( + model.events.pop_back().unwrap().args, + [PropertyValue::Integer(2)] + ); + assert_eq!(model.control_lines((1, None), 1, 2), ["▲", "▼"]); + } + #[test] fn hide_entlaedt_nicht_und_unload_ist_abbrechbar() { let mut m = model(); @@ -592,4 +2799,768 @@ mod tests { mdi.set(0, ft, PropertyValue::Integer(1)).unwrap(); assert_eq!(mdi.show(0, true), Err(RuntimeError(403))); } + + fn controls_model() -> FormsModel { + let mut c = forms::FormCatalog::default(); + c.add("Form1", ObjectClass::Form, None, false); + for (name, class) in [ + ("Command1", ObjectClass::CommandButton), + ("Label1", ObjectClass::Label), + ("Frame1", ObjectClass::Frame), + ("Check1", ObjectClass::CheckBox), + ("Option1", ObjectClass::OptionButton), + ("Text1", ObjectClass::TextBox), + ("List1", ObjectClass::ListBox), + ("Combo1", ObjectClass::ComboBox), + ("HScroll1", ObjectClass::HScrollBar), + ("VScroll1", ObjectClass::VScrollBar), + ("Picture1", ObjectClass::PictureBox), + ("Timer1", ObjectClass::Timer), + ("Dir1", ObjectClass::DirListBox), + ("Drive1", ObjectClass::DriveListBox), + ("File1", ObjectClass::FileListBox), + ("mnuDatei", ObjectClass::Menu), + ] { + c.add(name, class, Some("Form1"), false); + } + c.add("mnuOpen", ObjectClass::Menu, Some("mnuDatei"), false); + c.add("Spin1", ObjectClass::Spin, Some("Form1"), false); + c.add("SCREEN", ObjectClass::Screen, None, false); + let mut m = FormsModel::new(c.objects, 80, 25); + for (id, name, value) in [ + (0, "WIDTH", PropertyValue::Integer(70)), + (0, "HEIGHT", PropertyValue::Integer(24)), + (1, "WIDTH", PropertyValue::Integer(8)), + (1, "HEIGHT", PropertyValue::Integer(1)), + (1, "CAPTION", PropertyValue::String("&OK".into())), + (2, "WIDTH", PropertyValue::Integer(8)), + (2, "CAPTION", PropertyValue::String("Text".into())), + (3, "WIDTH", PropertyValue::Integer(10)), + (3, "HEIGHT", PropertyValue::Integer(3)), + (3, "CAPTION", PropertyValue::String("Rahmen".into())), + (4, "WIDTH", PropertyValue::Integer(9)), + (4, "CAPTION", PropertyValue::String("Wahl".into())), + (4, "VALUE", PropertyValue::Integer(1)), + (5, "WIDTH", PropertyValue::Integer(9)), + (5, "CAPTION", PropertyValue::String("Eins".into())), + (5, "VALUE", PropertyValue::Integer(-1)), + (6, "WIDTH", PropertyValue::Integer(8)), + (6, "HEIGHT", PropertyValue::Integer(3)), + (6, "TEXT", PropertyValue::String("abc".into())), + (7, "WIDTH", PropertyValue::Integer(8)), + (7, "HEIGHT", PropertyValue::Integer(3)), + (8, "WIDTH", PropertyValue::Integer(8)), + (9, "WIDTH", PropertyValue::Integer(8)), + (10, "HEIGHT", PropertyValue::Integer(5)), + (11, "WIDTH", PropertyValue::Integer(8)), + (11, "HEIGHT", PropertyValue::Integer(3)), + (13, "WIDTH", PropertyValue::Integer(8)), + (13, "HEIGHT", PropertyValue::Integer(3)), + (14, "WIDTH", PropertyValue::Integer(8)), + (15, "WIDTH", PropertyValue::Integer(8)), + (15, "HEIGHT", PropertyValue::Integer(3)), + (16, "CAPTION", PropertyValue::String("&Datei".into())), + (17, "CAPTION", PropertyValue::String("&Open".into())), + (18, "WIDTH", PropertyValue::Integer(1)), + (18, "HEIGHT", PropertyValue::Integer(2)), + ] { + let class = m.objects[id].description.class; + let property = forms::property(class, name).unwrap().0; + m.set_initial(id as u16, property, value).unwrap(); + } + m + } + + #[test] + fn zeichenbilder_aller_klassen_und_buttonhoehen() { + let mut m = controls_model(); + m.add_item((7, None), "eins".into(), None).unwrap(); + m.add_item((8, None), "wahl".into(), None).unwrap(); + m.object_method(11, "PRINT", vec![PropertyValue::String("Bild".into())]) + .unwrap(); + let snapshots = [ + (1, " "), + (2, "Text "), + (3, "┌ Rahmen ┐"), + (4, "[x] Wahl "), + (5, "(•) Eins "), + (6, "┌──────┐"), + (7, "┌──────┐"), + (8, "wahl ▼ "), + (9, "◄□─────►"), + (10, "▲"), + (11, "┌──────┐"), + (13, "┌──────┐"), + (14, "/ ▼ "), + (15, "┌──────┐"), + (18, "▲"), + ]; + for (id, expected) in snapshots { + let width = m.integer((id, None), "WIDTH").unwrap_or(1) as usize; + let height = m.integer((id, None), "HEIGHT").unwrap_or(1) as usize; + assert_eq!( + m.control_lines((id, None), width, height)[0], + expected, + "{}", + m.objects[id as usize].description.name + ); + } + let width = forms::property(ObjectClass::CommandButton, "WIDTH") + .unwrap() + .0; + let height = forms::property(ObjectClass::CommandButton, "HEIGHT") + .unwrap() + .0; + m.set_initial(1, width, PropertyValue::Integer(8)).unwrap(); + m.set_initial(1, height, PropertyValue::Integer(2)).unwrap(); + assert_eq!(m.control_lines((1, None), 8, 2), ["┌ OK ──┐", "└──────┘"]); + m.set_initial(1, height, PropertyValue::Integer(3)).unwrap(); + assert_eq!( + m.control_lines((1, None), 8, 3), + ["┌──────┐", "│OK │", "└──────┘"] + ); + assert!(m.control_lines((12, None), 1, 1).is_empty()); + assert!(m.control_lines((16, None), 1, 1).is_empty()); + } + + #[test] + fn zustandsfarben_laufen_fuer_alle_controls_ueber_denselben_zeichenpfad() { + let mut m = controls_model(); + for (object, name, value) in [(0, "WIDTH", 40), (0, "HEIGHT", 10)] { + let class = m.objects[object].description.class; + let property = forms::property(class, name).unwrap().0; + m.set_initial(object as u16, property, PropertyValue::Integer(value)) + .unwrap(); + } + let controls = [1u16, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 14, 15, 18]; + for object in controls { + let class = m.objects[object as usize].description.class; + for (name, value) in [("LEFT", 25), ("TOP", 5)] { + m.set_initial( + object, + forms::property(class, name).unwrap().0, + PropertyValue::Integer(value), + ) + .unwrap(); + } + } + m.show(0, false).unwrap(); + let mut screen = TextScreen::new(); + for target in controls { + for object in controls { + let class = m.objects[object as usize].description.class; + m.set_initial( + object, + forms::property(class, "VISIBLE").unwrap().0, + PropertyValue::Boolean(object == target), + ) + .unwrap(); + } + m.active_control = None; + let class = m.objects[target as usize].description.class; + let enabled = forms::property(class, "ENABLED").unwrap().0; + m.set(target, enabled, PropertyValue::Boolean(true)) + .unwrap(); + m.render(&mut screen); + assert_eq!( + (screen.cell(6, 26).fg, screen.cell(6, 26).bg), + (0, 7), + "normal: {:?}", + class + ); + if m.focusable((target, None)) { + m.focus((target, None)).unwrap(); + m.render(&mut screen); + assert_eq!( + (screen.cell(6, 26).fg, screen.cell(6, 26).bg), + (15, 1), + "fokussiert: {:?}", + class + ); + } + m.set(target, enabled, PropertyValue::Boolean(false)) + .unwrap(); + m.render(&mut screen); + assert_eq!( + (screen.cell(6, 26).fg, screen.cell(6, 26).bg), + (8, 0), + "deaktiviert: {:?}", + class + ); + } + } + + #[test] + fn fokus_tab_access_default_und_eingabeprioritaet() { + let mut m = controls_model(); + m.show(0, false).unwrap(); + m.events.clear(); + let tabstop = forms::property(ObjectClass::Label, "TABINDEX").unwrap().0; + m.set_initial(2, tabstop, PropertyValue::Integer(1)) + .unwrap(); + assert!(m.tab(true)); + assert_eq!(m.active_control, Some((1, None))); + assert!(m.tab(true)); + let names: Vec<_> = m.events.drain(..).map(|event| event.name).collect(); + assert_eq!(names, ["GOTFOCUS", "LOSTFOCUS", "GOTFOCUS"]); + m.handle_key("o", umschalt::ALT); + assert_eq!(m.next_event().unwrap().name, "CLICK"); + let default = forms::property(ObjectClass::CommandButton, "DEFAULT") + .unwrap() + .0; + m.set_initial(1, default, PropertyValue::Boolean(true)) + .unwrap(); + m.handle_key(taste::ENTER, 0); + assert!(m.events.iter().any(|event| event.name == "CLICK")); + m.events.clear(); + m.handle_key("d", umschalt::ALT); + assert!(m.menu_is_open()); + let interval = forms::property(ObjectClass::Timer, "INTERVAL").unwrap().0; + m.set(12, interval, PropertyValue::Integer(100)).unwrap(); + m.timers(250); + assert!(!m.events.iter().any(|event| event.name == "TIMER")); + m.handle_key("o", 0); + assert_eq!(m.next_event().unwrap().object, 17); + assert!(!m.menu_is_open()); + m.events.clear(); + m.timers(250); + assert_eq!( + m.events + .iter() + .filter(|event| event.name == "TIMER") + .count(), + 2 + ); + + let cancel = forms::property(ObjectClass::CommandButton, "CANCEL") + .unwrap() + .0; + m.set_initial(1, cancel, PropertyValue::Boolean(true)) + .unwrap(); + m.events.clear(); + m.handle_key(taste::ESC, 0); + assert!(m + .events + .iter() + .any(|event| event.object == 1 && event.name == "CLICK")); + } + + #[test] + fn tabfolge_ueberspringt_und_laeuft_rueckwaerts() { + let mut m = controls_model(); + m.show(0, false).unwrap(); + for id in 1..m.objects.len() { + let class = m.objects[id].description.class; + if let Some((tabstop, _)) = forms::property(class, "TABSTOP") { + m.set_initial( + id as u16, + tabstop, + PropertyValue::Boolean(matches!(id, 1 | 6 | 7)), + ) + .unwrap(); + } + } + for (id, tab_index) in [(1, 0), (6, 1), (7, 2)] { + let class = m.objects[id].description.class; + m.set_initial( + id as u16, + forms::property(class, "TABINDEX").unwrap().0, + PropertyValue::Integer(tab_index), + ) + .unwrap(); + } + let tabstop = forms::property(ObjectClass::TextBox, "TABSTOP").unwrap().0; + m.set_initial(6, tabstop, PropertyValue::Boolean(false)) + .unwrap(); + m.focus((1, None)).unwrap(); + assert!(m.tab(true)); + assert_eq!(m.active_control, Some((7, None))); + assert!(m.tab(false)); + assert_eq!(m.active_control, Some((1, None))); + let enabled = forms::property(ObjectClass::CommandButton, "ENABLED") + .unwrap() + .0; + m.set_initial(1, enabled, PropertyValue::Boolean(false)) + .unwrap(); + assert!(m.tab(false)); + assert_ne!(m.active_control, Some((1, None))); + } + + #[test] + fn text_liste_option_scroll_picture_und_dateisystem() { + let mut m = controls_model(); + let text = forms::property(ObjectClass::TextBox, "TEXT").unwrap().0; + let start = forms::property(ObjectClass::TextBox, "SELSTART").unwrap().0; + let length = forms::property(ObjectClass::TextBox, "SELLENGTH") + .unwrap() + .0; + let selected = forms::property(ObjectClass::TextBox, "SELTEXT").unwrap().0; + m.set(6, text, PropertyValue::String("abcd".into())) + .unwrap(); + m.set(6, start, PropertyValue::Integer(1)).unwrap(); + m.set(6, length, PropertyValue::Integer(2)).unwrap(); + assert_eq!( + m.get(6, selected).unwrap(), + PropertyValue::String("bc".into()) + ); + m.set(6, selected, PropertyValue::String("X".into())) + .unwrap(); + assert_eq!(m.get(6, text).unwrap(), PropertyValue::String("aXd".into())); + let multiline = forms::property(ObjectClass::TextBox, "MULTILINE") + .unwrap() + .0; + let scrollbars = forms::property(ObjectClass::TextBox, "SCROLLBARS") + .unwrap() + .0; + m.set_initial(6, multiline, PropertyValue::Boolean(true)) + .unwrap(); + m.set(6, scrollbars, PropertyValue::Integer(3)).unwrap(); + m.set(6, text, PropertyValue::String("abcdefghijk".into())) + .unwrap(); + let text_lines = m.control_lines((6, None), 8, 5); + assert_eq!(text_lines[1], "│abcde▲│"); + assert_eq!(text_lines[2], "│fghij▼│"); + assert_eq!(text_lines[3], "│◄───► │"); + + let sorted = forms::property(ObjectClass::ListBox, "SORTED").unwrap().0; + m.set(7, sorted, PropertyValue::Boolean(true)).unwrap(); + m.add_item((7, None), "b".into(), None).unwrap(); + m.add_item((7, None), "a".into(), None).unwrap(); + assert_eq!( + m.get_indexed( + 7, + forms::property(ObjectClass::ListBox, "LIST").unwrap().0, + 0 + ) + .unwrap(), + PropertyValue::String("a".into()) + ); + assert_eq!( + m.get( + 7, + forms::property(ObjectClass::ListBox, "LISTCOUNT") + .unwrap() + .0 + ) + .unwrap(), + PropertyValue::Integer(2) + ); + assert_eq!( + m.get( + 7, + forms::property(ObjectClass::ListBox, "LISTINDEX") + .unwrap() + .0 + ) + .unwrap(), + PropertyValue::Integer(-1) + ); + + let style = forms::property(ObjectClass::ComboBox, "STYLE").unwrap().0; + let combo = [0, 1, 2].map(|value| { + m.set_initial(8, style, PropertyValue::Integer(value)) + .unwrap(); + m.control_lines((8, None), 8, 4) + }); + assert_eq!(combo[0].len(), 1); + assert!(combo[1].len() > 1); + assert!(combo[2][0].starts_with('[')); + + let value = forms::property(ObjectClass::HScrollBar, "VALUE").unwrap().0; + m.set(9, value, PropertyValue::Integer(10)).unwrap(); + m.set_active_control(9, None).unwrap(); + m.control_key((9, None), &taste::sonder(77)); + assert_eq!(m.get(9, value).unwrap(), PropertyValue::Integer(11)); + m.scroll_mouse((9, None), 1, 8); + assert_eq!(m.get(9, value).unwrap(), PropertyValue::Integer(12)); + assert_eq!( + m.object_method(11, "TEXTWIDTH", vec![PropertyValue::String("abc".into())]) + .unwrap(), + Some(PropertyValue::Integer(3)) + ); + m.object_method(11, "PRINT", vec![PropertyValue::String("abc".into())]) + .unwrap(); + assert_eq!(m.pictures[&(11, None)], ["abc"]); + + let dir = std::env::temp_dir().join(format!("tb_forms_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("unter")).unwrap(); + std::fs::write(dir.join("a.bas"), "").unwrap(); + std::fs::write(dir.join("b.txt"), "").unwrap(); + let path = forms::property(ObjectClass::FileListBox, "PATH").unwrap().0; + let pattern = forms::property(ObjectClass::FileListBox, "PATTERN") + .unwrap() + .0; + m.set(15, path, PropertyValue::String(dir.display().to_string())) + .unwrap(); + m.set(15, pattern, PropertyValue::String("*.bas".into())) + .unwrap(); + assert_eq!(m.lists[&(15, None)], ["a.bas"]); + let dir_path = forms::property(ObjectClass::DirListBox, "PATH").unwrap().0; + m.set( + 13, + dir_path, + PropertyValue::String(dir.display().to_string()), + ) + .unwrap(); + assert_eq!(m.lists[&(13, None)], ["unter"]); + let drive = forms::property(ObjectClass::DriveListBox, "DRIVE") + .unwrap() + .0; + m.set(14, drive, PropertyValue::String("/".into())).unwrap(); + assert!(!m.lists[&(14, None)].is_empty()); + assert!(m + .events + .iter() + .any(|event| event.object == 13 && event.name == "PATHCHANGE")); + assert!(m + .events + .iter() + .any(|event| event.object == 14 && event.name == "CHANGE")); + assert!(m + .events + .iter() + .any(|event| event.object == 15 && event.name == "PATTERNCHANGE")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn optionen_gruppieren_nach_unmittelbarem_container() { + let mut catalog = forms::FormCatalog::default(); + catalog.add("Form1", ObjectClass::Form, None, false); + catalog.add("Frame1", ObjectClass::Frame, Some("Form1"), false); + catalog.add("Picture1", ObjectClass::PictureBox, Some("Form1"), false); + catalog.add("A1", ObjectClass::OptionButton, Some("Frame1"), false); + catalog.add("A2", ObjectClass::OptionButton, Some("Frame1"), false); + catalog.add("B1", ObjectClass::OptionButton, Some("Picture1"), false); + let mut m = FormsModel::new(catalog.objects, 80, 25); + let value = forms::property(ObjectClass::OptionButton, "VALUE") + .unwrap() + .0; + m.set(3, value, PropertyValue::Integer(-1)).unwrap(); + m.set(5, value, PropertyValue::Integer(-1)).unwrap(); + m.set(4, value, PropertyValue::Integer(-1)).unwrap(); + assert_eq!(m.get(3, value).unwrap(), PropertyValue::Integer(0)); + assert_eq!(m.get(4, value).unwrap(), PropertyValue::Integer(-1)); + assert_eq!(m.get(5, value).unwrap(), PropertyValue::Integer(-1)); + } + + #[test] + fn menuregeln_weisen_separator_und_titel_shortcuts_ab() { + let mut m = controls_model(); + let shortcut = forms::property(ObjectClass::Menu, "SHORTCUT").unwrap().0; + assert_eq!( + m.set(16, shortcut, PropertyValue::String("F3".into())), + Err(RuntimeError::ILLEGAL_FUNCTION_CALL) + ); + let separator = forms::property(ObjectClass::Menu, "SEPARATOR").unwrap().0; + m.set(17, separator, PropertyValue::Boolean(true)).unwrap(); + assert_eq!( + m.set(17, shortcut, PropertyValue::String("F3".into())), + Err(RuntimeError::ILLEGAL_FUNCTION_CALL) + ); + let checked = forms::property(ObjectClass::Menu, "CHECKED").unwrap().0; + assert_eq!( + m.set(17, checked, PropertyValue::Boolean(true)), + Err(RuntimeError::ILLEGAL_FUNCTION_CALL) + ); + } + + #[test] + fn menues_zeichnen_und_navigieren_sechs_ebenen() { + let mut catalog = forms::FormCatalog::default(); + catalog.add("Form1", ObjectClass::Form, None, false); + for (name, parent) in [ + ("MenuA", "Form1"), + ("MenuB", "MenuA"), + ("MenuC", "MenuB"), + ("MenuD", "MenuC"), + ("MenuE", "MenuD"), + ("MenuF", "MenuE"), + ] { + catalog.add(name, ObjectClass::Menu, Some(parent), false); + } + catalog.add("Separator", ObjectClass::Menu, Some("MenuE"), false); + catalog.add("Disabled", ObjectClass::Menu, Some("MenuE"), false); + catalog.add("Hidden", ObjectClass::Menu, Some("MenuE"), false); + let mut m = FormsModel::new(catalog.objects, 80, 25); + for (id, caption) in ["&A", "&B", "&C", "&D", "&E", "&F"].into_iter().enumerate() { + m.set_initial( + id as u16 + 1, + forms::property(ObjectClass::Menu, "CAPTION").unwrap().0, + PropertyValue::String(caption.into()), + ) + .unwrap(); + } + for (object, name, value) in [ + (6, "CHECKED", PropertyValue::Boolean(true)), + (6, "SHORTCUT", PropertyValue::String("F3".into())), + (7, "SEPARATOR", PropertyValue::Boolean(true)), + (8, "CAPTION", PropertyValue::String("&Gesperrt".into())), + (8, "ENABLED", PropertyValue::Boolean(false)), + (9, "CAPTION", PropertyValue::String("Versteckt".into())), + (9, "VISIBLE", PropertyValue::Boolean(false)), + ] { + m.set_initial( + object, + forms::property(ObjectClass::Menu, name).unwrap().0, + value, + ) + .unwrap(); + } + for (name, value) in [("WIDTH", 78), ("HEIGHT", 24)] { + m.set_initial( + 0, + forms::property(ObjectClass::Form, name).unwrap().0, + PropertyValue::Integer(value), + ) + .unwrap(); + } + m.show(0, false).unwrap(); + m.events.clear(); + assert!(m.handle_key("a", umschalt::ALT)); + for key in ["b", "c", "d", "e"] { + assert!(m.handle_key(key, 0)); + } + assert_eq!(m.menu_path.len(), 5); + let mut screen = TextScreen::new(); + m.render(&mut screen); + let snapshot = tb_runtime::snapshot::text(&screen); + assert!(snapshot.contains("√ F F3")); + assert!(snapshot.contains('├')); + assert!(snapshot.contains("Gesperrt")); + assert!(!snapshot.contains("Versteckt")); + assert!(m.handle_key("g", 0)); + assert_eq!(m.menu_path.len(), 5); + assert!(m.handle_key("f", 0)); + assert!(!m.menu_is_open()); + let event = m.next_event().unwrap(); + assert_eq!((event.object, event.name.as_str()), (6, "CLICK")); + } + + #[test] + fn z_reihenfolge_dragdrop_und_timerreihenfolge() { + let mut m = controls_model(); + m.show(0, false).unwrap(); + m.events.clear(); + for id in [1usize, 2] { + for (name, value) in [("LEFT", 2), ("TOP", 2), ("WIDTH", 8), ("HEIGHT", 2)] { + let property = forms::property(m.objects[id].description.class, name) + .unwrap() + .0; + m.set_initial(id as u16, property, PropertyValue::Integer(value)) + .unwrap(); + } + } + assert_eq!(m.hit_test(0, 4, 4), (2, None)); + assert_eq!(m.hit_test(0, 20, 60), (0, None)); + let left = forms::property(ObjectClass::Label, "LEFT").unwrap().0; + m.set_initial(2, left, PropertyValue::Integer(20)).unwrap(); + let drag = forms::property(ObjectClass::CommandButton, "DRAGMODE") + .unwrap() + .0; + m.set(1, drag, PropertyValue::Integer(1)).unwrap(); + m.handle_mouse(MausEreignis { + art: MausArt::Druck, + taste: 1, + shift: 0, + zeile: 4, + spalte: 4, + }); + m.handle_mouse(MausEreignis { + art: MausArt::Bewegung, + taste: 1, + shift: 0, + zeile: 4, + spalte: 22, + }); + m.handle_mouse(MausEreignis { + art: MausArt::Bewegung, + taste: 1, + shift: 0, + zeile: 4, + spalte: 22, + }); + m.handle_mouse(MausEreignis { + art: MausArt::Loslassen, + taste: 1, + shift: 0, + zeile: 4, + spalte: 22, + }); + let drag_events: Vec<_> = m + .events + .iter() + .filter(|event| matches!(event.name.as_str(), "DRAGOVER" | "DRAGDROP")) + .collect(); + assert_eq!(drag_events.len(), 3); + assert_eq!(drag_events[0].object, 2); + assert_eq!(drag_events[0].name, "DRAGOVER"); + assert_eq!(drag_events[0].args[3], PropertyValue::Integer(0)); + assert_eq!(drag_events[1].args[3], PropertyValue::Integer(2)); + assert_eq!(drag_events[2].name, "DRAGDROP"); + assert_eq!( + drag_events[2].args[0], + PropertyValue::Object(Some((1, None))) + ); + + m.events.clear(); + m.object_method(1, "DRAG", vec![PropertyValue::Integer(1)]) + .unwrap(); + assert_eq!(m.dragging, Some((1, None))); + m.handle_mouse(MausEreignis { + art: MausArt::Bewegung, + taste: 1, + shift: 0, + zeile: 1, + spalte: 22, + }); + m.object_method(1, "DRAG", vec![PropertyValue::Integer(2)]) + .unwrap(); + assert!(m.events.iter().any(|event| event.name == "DRAGDROP")); + m.events.clear(); + m.object_method(1, "DRAG", vec![PropertyValue::Integer(1)]) + .unwrap(); + m.object_method(1, "DRAG", vec![PropertyValue::Integer(0)]) + .unwrap(); + assert_eq!(m.dragging, None); + + let interval = forms::property(ObjectClass::Timer, "INTERVAL").unwrap().0; + m.set(12, interval, PropertyValue::Integer(100)).unwrap(); + m.timers(250); + assert_eq!( + m.events + .iter() + .filter(|event| event.name == "TIMER") + .count(), + 2 + ); + m.events.clear(); + m.set(12, interval, PropertyValue::Integer(0)).unwrap(); + m.timers(500); + assert!(!m.events.iter().any(|event| event.name == "TIMER")); + } + + #[test] + fn freier_formularklick_und_doppelklick_werden_zugestellt() { + let mut m = controls_model(); + m.show(0, false).unwrap(); + m.events.clear(); + let click = |art, now_ms, model: &mut FormsModel| { + model.handle_mouse_at( + MausEreignis { + art, + taste: 1, + shift: 0, + zeile: 20, + spalte: 60, + }, + now_ms, + ); + }; + click(MausArt::Druck, 100, &mut m); + click(MausArt::Loslassen, 110, &mut m); + click(MausArt::Druck, 300, &mut m); + click(MausArt::Loslassen, 310, &mut m); + let names: Vec<_> = m + .events + .iter() + .filter(|event| event.object == 0) + .map(|event| event.name.as_str()) + .collect(); + assert_eq!( + names, + [ + "MOUSEDOWN", + "MOUSEUP", + "CLICK", + "MOUSEDOWN", + "MOUSEUP", + "CLICK", + "DBLCLICK" + ] + ); + } + + #[test] + fn gleichzeitig_faellige_timer_laufen_nach_name() { + let mut catalog = forms::FormCatalog::default(); + catalog.add("Form1", ObjectClass::Form, None, false); + catalog.add("TimerZ", ObjectClass::Timer, Some("Form1"), false); + catalog.add("TimerA", ObjectClass::Timer, Some("Form1"), false); + let mut m = FormsModel::new(catalog.objects, 80, 25); + let interval = forms::property(ObjectClass::Timer, "INTERVAL").unwrap().0; + m.set(1, interval, PropertyValue::Integer(100)).unwrap(); + m.set(2, interval, PropertyValue::Integer(100)).unwrap(); + m.show(0, false).unwrap(); + m.events.clear(); + m.timers(250); + let names: Vec<_> = m + .events + .iter() + .map(|event| m.objects[event.object as usize].description.name.as_str()) + .collect(); + assert_eq!(names, ["TIMERA", "TIMERA", "TIMERZ", "TIMERZ"]); + } + + #[test] + fn forms_dialoge_liefern_gruppen_eingabe_und_feste_groesse() { + for (kind, expected) in [ + (0, 1), + (1 | 256, 2), + (2 | 512, 5), + (3, 6), + (4 | 256, 7), + (5, 4), + ] { + let mut screen = TextScreen::new(); + let mut host = CaptureHost::default(); + let mut queued = VecDeque::new(); + host.ereignis(Ereignis::Taste(taste::ENTER.into(), 0)); + assert_eq!( + msgbox_dialog(&mut screen, &mut host, &mut queued, "Frage", kind, "Titel").unwrap(), + expected + ); + } + let mut screen = TextScreen::new(); + let mut host = CaptureHost::default(); + let mut queued = VecDeque::new(); + host.ereignis(Ereignis::Taste("A".into(), 0)); + host.ereignis(Ereignis::Taste(taste::ENTER.into(), 0)); + assert_eq!( + inputbox_dialog( + &mut screen, + &mut host, + &mut queued, + "Eingabe", + "Titel", + "", + None + ) + .unwrap(), + "A" + ); + let dialog = host.screen.unwrap(); + let top = (dialog.rows() - 16) / 2 + 1; + let left = (dialog.cols() - 46) / 2 + 1; + assert_eq!(dialog.cell(top, left).ch, '┌'); + assert_eq!(dialog.cell(top + 15, left + 45).ch, '┘'); + + let mut host = CaptureHost::default(); + let mut queued = VecDeque::new(); + host.ereignis(Ereignis::Taste(taste::ESC.into(), 0)); + assert_eq!( + inputbox_dialog( + &mut screen, + &mut host, + &mut queued, + "Eingabe", + "Titel", + "vorbelegt", + None + ) + .unwrap(), + "" + ); + } } diff --git a/crates/tb-ui/src/frm.rs b/crates/tb-ui/src/frm.rs index ac11e12..1983df2 100644 --- a/crates/tb-ui/src/frm.rs +++ b/crates/tb-ui/src/frm.rs @@ -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, 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, 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, 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) diff --git a/crates/tb-ui/src/frm_pcode.rs b/crates/tb-ui/src/frm_pcode.rs index 533bcfe..5fee242 100644 --- a/crates/tb-ui/src/frm_pcode.rs +++ b/crates/tb-ui/src/frm_pcode.rs @@ -1317,7 +1317,7 @@ fn token_def(code: u16) -> Option { }), 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 { }), 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); diff --git a/crates/tb-vm/src/bytecode.rs b/crates/tb-vm/src/bytecode.rs index 2d96e6a..eb7a0fe 100644 --- a/crates/tb-vm/src/bytecode.rs +++ b/crates/tb-vm/src/bytecode.rs @@ -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 diff --git a/crates/tb-vm/src/codegen.rs b/crates/tb-vm/src/codegen.rs index da19f1a..781d459 100644 --- a/crates/tb-vm/src/codegen.rs +++ b/crates/tb-vm/src/codegen.rs @@ -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>, + /// 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::>(); + 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, } } diff --git a/crates/tb-vm/src/interp.rs b/crates/tb-vm/src/interp.rs index 825776a..b13ac7b 100644 --- a/crates/tb-vm/src/interp.rs +++ b/crates/tb-vm/src/interp.rs @@ -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, + line: Option, + }, /// Unbehandelter Laufzeitfehler. Error { code: u16, @@ -106,6 +110,7 @@ pub struct Vm { tick_zaehler: u32, breakpoints: HashSet, data_ptr: usize, + start_pc: Option, 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 { + match value { + Value::Ref(reference) => self.read_ref(reference), + value => Ok(value.clone()), + } + } + + fn external_string(&self, value: &Value) -> Result { + 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 { + 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 { + 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) { + 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, 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 { + 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 { + 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::, _>>()?; + 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 { 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 diff --git a/crates/tb-vm/tests/vm.rs b/crates/tb-vm/tests/vm.rs index abc0e86..ef3be80 100644 --- a/crates/tb-vm/tests/vm.rs +++ b/crates/tb-vm/tests/vm.rs @@ -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)))); +} diff --git a/docs/dateiformate.md b/docs/dateiformate.md index 89d7cb0..c478b0a 100644 --- a/docs/dateiformate.md +++ b/docs/dateiformate.md @@ -127,7 +127,10 @@ zum Abbruch; eine Ausgabedatei wird erst nach erfolgreichem Lesen angelegt. Reine Textdatei ohne Kopfzeile, **eine Projektdatei pro Zeile** (`.BAS`/`.FRM`; belegt durch die Originalbeispiele). Terminal Basic -akzeptiert zusätzlich Kommentarzeilen mit `'`. +akzeptiert zusätzlich Kommentarzeilen mit `'`. `tbc check`, `build` und +`run` lösen die Einträge relativ zur `.MAK`-Datei und ohne Beachtung der +DOS-Großschreibung auf; Formularobjekte aller `.FRM`-Einträge stehen dem +gemeinsamen Modulverbund zur Verfügung. ## Record-Dateien (`GET`/`PUT`, `OPEN … FOR RANDOM`) diff --git a/docs/forms-referenz.md b/docs/forms-referenz.md index fe7c846..18bed74 100644 --- a/docs/forms-referenz.md +++ b/docs/forms-referenz.md @@ -24,7 +24,7 @@ Maus-Koordinaten in Ereignissen sind SINGLE, aber in Zellen. ## Steuerelemente (exakte Klassennamen) `CheckBox, ComboBox, CommandButton, DirListBox, DriveListBox, FileListBox, -Frame, HScrollBar, Label, ListBox, Menu, OptionButton, PictureBox, TextBox, +Frame, HScrollBar, Label, ListBox, Menu, OptionButton, PictureBox, Spin, TextBox, Timer, VScrollBar` — alle außer `Menu` über die Toolbox platzierbar (Menüs entstehen im Menu Design Window). Default-Namen: `Check1`, `Combo1`, `Command1`, `Dir1`, `Drive1`, `File1`, `Frame1`, `HScroll1`, `Label1`, @@ -83,6 +83,11 @@ LostFocus (**kein** separates Scroll-Ereignis). Eigenschaften: zusätzlich Attached (VBDOS-Spezifikum: am Formularrand angedockt; Laufzeit read-only), LargeChange, Min, Max, SmallChange, Value. +**Spin** — Ereignisse: Custom(EventType), Drag*, GotFocus, Key*, LostFocus. +`Style` wählt vertikal/horizontal, `Interval` steuert die Wiederholung; `Value` +läuft zyklisch zwischen `Min` und `Max`. `BorderStyle`, `Width` und `Height` +sind zur Laufzeit schreibgeschützt. + **PictureBox** — Text-Zeichenfläche (PRINT/CLS aufs Control) und Container für OptionButton-Gruppen. Ereignisse: Click, DblClick, Drag*, GotFocus, Key*, LostFocus, Mouse*, Paint. Methoden: CLS, DRAG, MOVE, PRINT, REFRESH, @@ -93,7 +98,7 @@ TabStop, AutoRedraw, CurrentX, CurrentY, ScaleHeight, ScaleWidth. Index, Interval (0 = aus … 65 535 ms), Parent, Tag. **Menu** — Ereignis Click; Eigenschaften Caption, Checked, CtlName, Enabled, -Index, Parent, Separator, Tag, Visible (Shortcut nur zur Entwurfszeit). +Index, Parent, Separator, Shortcut, Tag, Visible. **DirListBox/DriveListBox/FileListBox** — Datei-Browser-Controls (Path/Drive/FileName/Pattern/Archive/Hidden/Normal/ReadOnly/System; @@ -124,6 +129,61 @@ sie plattformneutral nach (Laufwerksliste → Wurzeln/Mounts). | Text (TextBox/ComboBox) | — | Entwurfszeit = CtlName | | ListIndex | −1 = keine Auswahl | −1 | +## Zeichenbilder und Zustände + +Die Originalquellen belegen Aufbau und Rahmenformen, enthalten aber keine +vollständige Screenshot-Reihe für jede Klasse und jeden Zustand. Die folgende +einheitliche Umsetzung ist deshalb **unsere Festlegung**: normal = die Werte +aus `ForeColor`/`BackColor`, fokussiert = Farbe 15 auf 1, deaktiviert = Farbe +8 auf 0. `&` kennzeichnet +nur den Access-Key und wird nicht ausgegeben. Die Beispiele zeigen jeweils +normal · fokussiert · deaktiviert; Inhalt wird auf die verfügbare Breite +gekürzt und der Rest mit Leerzeichen gefüllt. + +| Klasse | normal | fokussiert | deaktiviert | +|---|---|---|---| +| CommandButton, Höhe 1 | `` | `` | `` | +| CommandButton, Höhe 2 | `┌OK──┐` / `└────┘` | ebenso | ebenso | +| CommandButton, Höhe ≥ 3 | `┌────┐` / `│ OK │` / `└────┘` | ebenso | ebenso | +| Label | `Text` | `Text` | `Text` | +| Frame | `┌ Titel ─┐` / `│ │` / `└────────┘` | ebenso | ebenso | +| CheckBox | `[ ] Text` | `[ ] Text` | `[ ] Text` | +| CheckBox, gewählt/grau | `[x] Text` / `[-] Text` | ebenso | ebenso | +| OptionButton | `( ) Text` | `( ) Text` | `( ) Text` | +| OptionButton, gewählt | `(•) Text` | `(•) Text` | `(•) Text` | +| TextBox | `┌──────┐` / `│Text │` / `└──────┘` | ebenso, Cursor an `SelStart` | ebenso, ohne Cursor | +| ListBox | `┌──────┐` / `│eins │` / `│zwei │` / `└──────┘` | ebenso, aktuelle Zeile in Fokusfarben | ebenso | +| ComboBox, Dropdown | `[Text ▼]` | ebenso in Fokusfarben | ebenso | +| ComboBox, Simple | `[Text ▼]` / `┌──────┐` / `│eins │` / `└──────┘` | ebenso in Fokusfarben | ebenso | +| HScrollBar | `◄──□──►` | ebenso in Fokusfarben | ebenso | +| VScrollBar | `▲` / `│` / `□` / `│` / `▼` | ebenso in Fokusfarben | ebenso | +| Spin | `▲` / `▼` oder `◄ ►` | ebenso in Fokusfarben | ebenso | +| PictureBox | `┌──────┐` / `│Text │` / `└──────┘` | ebenso in Fokusfarben | ebenso | +| DirListBox | `┌──────┐` / `│[dir] │` / `└──────┘` | ebenso, aktuelle Zeile in Fokusfarben | ebenso | +| DriveListBox | `[ / ▼]` | ebenso in Fokusfarben | ebenso | +| FileListBox | `┌──────┐` / `│a.bas │` / `└──────┘` | ebenso, aktuelle Zeile in Fokusfarben | ebenso | +| Menu | ` Datei Bearbeiten ` | gewählter Titel/Eintrag in Fokusfarben | Text in Farbe 8 | +| Timer | keine sichtbaren Zellen | keine sichtbaren Zellen | keine sichtbaren Zellen | + +`BorderStyle = 0` unterdrückt den Rahmen von Label, TextBox und PictureBox; +`BorderStyle = 2` nutzt `╔═╗║╚╝`. Scrollbars erscheinen nur in den von +`ScrollBars` genannten Richtungen. Ein unsichtbares Steuerelement zeichnet +keine Zelle. + +## Festgelegte Reihenfolgen + +Die folgenden Regeln sind **unsere Festlegung**, nicht nachgewiesenes +Referenzverhalten: + +- **Z-Reihenfolge:** Erzeugungsreihenfolge; später erzeugte Elemente liegen + oben. Bei Steuerelement-Arrays entscheidet bei sonst gleicher Position der + aufsteigende Index, sodass der höchste Index oben liegt. Gezeichnet wird von + unten nach oben, die Trefferprüfung läuft umgekehrt. +- **Timer-Reihenfolge:** Sind mehrere Timer am selben Zustellpunkt fällig, + werden ihre Ereignisse in aufsteigender, ASCII-unabhängig + großgeschriebener `CtlName`-Reihenfolge zugestellt. Mehrere verstrichene + Intervalle desselben Timers bleiben in zeitlicher Reihenfolge davor. + **Keine Default-Eigenschaften:** `Text1 = "x"` gibt es nicht — Zugriff immer explizit (`Text1.Text`). „Default" ist nur die CommandButton- Eigenschaft (Enter-Taste); „Cancel" analog für Esc. @@ -188,8 +248,4 @@ Menü-Steuerelement-Arrays sind möglich. ## Offene Detailfragen -- Exakte Optik je Steuerelement-Zustand (normal/fokussiert/deaktiviert): - aus den Original-Screenshots ableiten (Phase 4) -- Z-Reihenfolge/Überlappung: Verhalten bei überlappenden Controls -- Timer-Reihenfolge bei mehreren gleichzeitig fälligen Timern - `.FRM`-Textformat: exakte Serialisierung (siehe dateiformate.md) diff --git a/docs/inventar.md b/docs/inventar.md index 470ce9e..7dbb70a 100644 --- a/docs/inventar.md +++ b/docs/inventar.md @@ -22,7 +22,7 @@ namentlich abgewiesen. `Non-Feature` = abgelehnt, gelistet unter **Fundstelle.** Bei `implementiert` das Modul, bei `Non-Feature` der Abschnitt der Sprachreferenz, bei `offen` ein Strich. -**Abdeckung.** implementiert 689 · offen 61 · Non-Feature 53 · gesamt 803 +**Abdeckung.** implementiert 794 · offen 0 · Non-Feature 50 · gesamt 844 | Name | Art | Gruppe | Status | Fundstelle | Quelle | |---|---|---|---|---|---| @@ -164,14 +164,14 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `FORMAT$` | Funktion | Formatierung | implementiert | tb-runtime::using | bas7advr | | `PRINT USING` | Anweisung | Formatierung | implementiert | tb-runtime::using | bas7advr | | `SetFormatCC` | Routine | Formatierung | implementiert | tb-runtime::using | bas7advr | -| `INPUTBOX$` | Funktion | Forms | offen | - | sprachreferenz | -| `MSGBOX` | Anweisung | Forms | offen | - | sprachreferenz | -| `MSGBOX` | Funktion | Forms | offen | - | sprachreferenz | +| `INPUTBOX$` | Funktion | Forms | implementiert | tb-runtime::builtins | sprachreferenz | +| `MSGBOX` | Anweisung | Forms | implementiert | tb-runtime::builtins | sprachreferenz | +| `MSGBOX` | Funktion | Forms | implementiert | tb-runtime::builtins | sprachreferenz | | `CIRCLE` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr | | `DRAW` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr | | `GET (Grafik)` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr | -| `LINE` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr | -| `PAINT` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr | +| `LINE` | Anweisung | Grafik | implementiert | tb-runtime::screen | bas7advr | +| `PAINT` | Anweisung | Grafik | implementiert | tb-runtime::screen | bas7advr | | `PALETTE` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr | | `PCOPY` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr | | `PMAP` | Funktion | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr | @@ -179,7 +179,7 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `PRESET` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr | | `PSET` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr | | `PUT (Grafik)` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr | -| `VIEW` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr | +| `VIEW` | Anweisung | Grafik | implementiert | tb-runtime::screen | bas7advr | | `WINDOW` | Anweisung | Grafik | Non-Feature | sprachreferenz.md#abweichungen | bas7advr | | `ABSOLUTE` | Routine | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr | | `BLOAD` | Anweisung | Hardware | Non-Feature | sprachreferenz.md#abweichungen | bas7advr | @@ -255,7 +255,7 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `SQR` | Funktion | Mathematik | implementiert | tb-runtime::math | qb45advr | | `TAN` | Funktion | Mathematik | implementiert | tb-runtime::math | bas7advr | | `$DYNAMIC` | Metabefehl | Metabefehl | implementiert | tb-frontend::lexer | bas7advr | -| `$INCLUDE` | Metabefehl | Metabefehl | offen | - | bas7advr | +| `$INCLUDE` | Metabefehl | Metabefehl | implementiert | tb-cli | bas7advr | | `$STATIC` | Metabefehl | Metabefehl | implementiert | tb-frontend::lexer | bas7advr | | `CALL` | Anweisung | Prozeduren | implementiert | tb-frontend::sema | bas7advr | | `DECLARE` | Anweisung | Prozeduren | implementiert | tb-frontend::sema | bas7advr | @@ -301,7 +301,7 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `ERDEV` | Funktion | System | implementiert | tb-runtime::builtins | bas7advr | | `ERDEV$` | Funktion | System | implementiert | tb-runtime::builtins | bas7advr | | `FRE` | Funktion | System | implementiert | tb-runtime::builtins | bas7advr | -| `RUN` | Anweisung | System | offen | - | bas7advr | +| `RUN` | Anweisung | System | implementiert | tb-cli | bas7advr | | `SLEEP` | Anweisung | System | implementiert | tb-runtime::builtins | bas7advr | | `STACK` | Anweisung | System | implementiert | tb-runtime::builtins | bas7advr | | `STACK` | Funktion | System | implementiert | tb-runtime::builtins | bas7advr | @@ -341,17 +341,17 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `FORM.SCALEHEIGHT` | Eigenschaft | Forms/FORM | implementiert | tb-ui::forms | forms-referenz | | `FORM.SCALEWIDTH` | Eigenschaft | Forms/FORM | implementiert | tb-ui::forms | forms-referenz | | `FORM.WINDOWSTATE` | Eigenschaft | Forms/FORM | implementiert | tb-ui::forms | forms-referenz | -| `FORM.CLS` | Methode | Forms/FORM | offen | - | forms-referenz | -| `FORM.DRAG` | Methode | Forms/FORM | offen | - | forms-referenz | +| `FORM.CLS` | Methode | Forms/FORM | implementiert | tb-ui::forms | forms-referenz | +| `FORM.DRAG` | Methode | Forms/FORM | implementiert | tb-ui::forms | forms-referenz | | `FORM.HIDE` | Methode | Forms/FORM | implementiert | tb-ui::forms | forms-referenz | | `FORM.LOAD` | Methode | Forms/FORM | implementiert | tb-ui::forms | forms-referenz | -| `FORM.MOVE` | Methode | Forms/FORM | offen | - | forms-referenz | -| `FORM.PRINT` | Methode | Forms/FORM | offen | - | forms-referenz | -| `FORM.PRINTFORM` | Methode | Forms/FORM | offen | - | forms-referenz | -| `FORM.REFRESH` | Methode | Forms/FORM | offen | - | forms-referenz | +| `FORM.MOVE` | Methode | Forms/FORM | implementiert | tb-ui::forms | forms-referenz | +| `FORM.PRINT` | Methode | Forms/FORM | implementiert | tb-ui::forms | forms-referenz | +| `FORM.PRINTFORM` | Methode | Forms/FORM | implementiert | tb-ui::forms | forms-referenz | +| `FORM.REFRESH` | Methode | Forms/FORM | implementiert | tb-ui::forms | forms-referenz | | `FORM.SHOW` | Methode | Forms/FORM | implementiert | tb-ui::forms | forms-referenz | -| `FORM.TEXTHEIGHT` | Methode | Forms/FORM | offen | - | forms-referenz | -| `FORM.TEXTWIDTH` | Methode | Forms/FORM | offen | - | forms-referenz | +| `FORM.TEXTHEIGHT` | Methode | Forms/FORM | implementiert | tb-ui::forms | forms-referenz | +| `FORM.TEXTWIDTH` | Methode | Forms/FORM | implementiert | tb-ui::forms | forms-referenz | | `FORM.UNLOAD` | Methode | Forms/FORM | implementiert | tb-ui::forms | forms-referenz | | `FORM_CLICK` | Ereignis | Forms/FORM | implementiert | tb-vm::interp | forms-referenz | | `FORM_DBLCLICK` | Ereignis | Forms/FORM | implementiert | tb-vm::interp | forms-referenz | @@ -387,10 +387,10 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `CHECKBOX.DRAGMODE` | Eigenschaft | Forms/CHECKBOX | implementiert | tb-ui::forms | forms-referenz | | `CHECKBOX.CAPTION` | Eigenschaft | Forms/CHECKBOX | implementiert | tb-ui::forms | forms-referenz | | `CHECKBOX.VALUE` | Eigenschaft | Forms/CHECKBOX | implementiert | tb-ui::forms | forms-referenz | -| `CHECKBOX.DRAG` | Methode | Forms/CHECKBOX | offen | - | forms-referenz | -| `CHECKBOX.MOVE` | Methode | Forms/CHECKBOX | offen | - | forms-referenz | -| `CHECKBOX.REFRESH` | Methode | Forms/CHECKBOX | offen | - | forms-referenz | -| `CHECKBOX.SETFOCUS` | Methode | Forms/CHECKBOX | offen | - | forms-referenz | +| `CHECKBOX.DRAG` | Methode | Forms/CHECKBOX | implementiert | tb-ui::forms | forms-referenz | +| `CHECKBOX.MOVE` | Methode | Forms/CHECKBOX | implementiert | tb-ui::forms | forms-referenz | +| `CHECKBOX.REFRESH` | Methode | Forms/CHECKBOX | implementiert | tb-ui::forms | forms-referenz | +| `CHECKBOX.SETFOCUS` | Methode | Forms/CHECKBOX | implementiert | tb-ui::forms | forms-referenz | | `CHECKBOX_CLICK` | Ereignis | Forms/CHECKBOX | implementiert | tb-vm::interp | forms-referenz | | `CHECKBOX_DRAGDROP` | Ereignis | Forms/CHECKBOX | implementiert | tb-vm::interp | forms-referenz | | `CHECKBOX_DRAGOVER` | Ereignis | Forms/CHECKBOX | implementiert | tb-vm::interp | forms-referenz | @@ -424,12 +424,12 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `COMBOBOX.SELTEXT` | Eigenschaft | Forms/COMBOBOX | implementiert | tb-ui::forms | forms-referenz | | `COMBOBOX.STYLE` | Eigenschaft | Forms/COMBOBOX | implementiert | tb-ui::forms | forms-referenz | | `COMBOBOX.TEXT` | Eigenschaft | Forms/COMBOBOX | implementiert | tb-ui::forms | forms-referenz | -| `COMBOBOX.ADDITEM` | Methode | Forms/COMBOBOX | offen | - | forms-referenz | -| `COMBOBOX.DRAG` | Methode | Forms/COMBOBOX | offen | - | forms-referenz | -| `COMBOBOX.MOVE` | Methode | Forms/COMBOBOX | offen | - | forms-referenz | -| `COMBOBOX.REFRESH` | Methode | Forms/COMBOBOX | offen | - | forms-referenz | -| `COMBOBOX.REMOVEITEM` | Methode | Forms/COMBOBOX | offen | - | forms-referenz | -| `COMBOBOX.SETFOCUS` | Methode | Forms/COMBOBOX | offen | - | forms-referenz | +| `COMBOBOX.ADDITEM` | Methode | Forms/COMBOBOX | implementiert | tb-ui::forms | forms-referenz | +| `COMBOBOX.DRAG` | Methode | Forms/COMBOBOX | implementiert | tb-ui::forms | forms-referenz | +| `COMBOBOX.MOVE` | Methode | Forms/COMBOBOX | implementiert | tb-ui::forms | forms-referenz | +| `COMBOBOX.REFRESH` | Methode | Forms/COMBOBOX | implementiert | tb-ui::forms | forms-referenz | +| `COMBOBOX.REMOVEITEM` | Methode | Forms/COMBOBOX | implementiert | tb-ui::forms | forms-referenz | +| `COMBOBOX.SETFOCUS` | Methode | Forms/COMBOBOX | implementiert | tb-ui::forms | forms-referenz | | `COMBOBOX_CHANGE` | Ereignis | Forms/COMBOBOX | implementiert | tb-vm::interp | forms-referenz | | `COMBOBOX_CLICK` | Ereignis | Forms/COMBOBOX | implementiert | tb-vm::interp | forms-referenz | | `COMBOBOX_DBLCLICK` | Ereignis | Forms/COMBOBOX | implementiert | tb-vm::interp | forms-referenz | @@ -463,10 +463,10 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `COMMANDBUTTON.CAPTION` | Eigenschaft | Forms/COMMANDBUTTON | implementiert | tb-ui::forms | forms-referenz | | `COMMANDBUTTON.DEFAULT` | Eigenschaft | Forms/COMMANDBUTTON | implementiert | tb-ui::forms | forms-referenz | | `COMMANDBUTTON.VALUE` | Eigenschaft | Forms/COMMANDBUTTON | implementiert | tb-ui::forms | forms-referenz | -| `COMMANDBUTTON.DRAG` | Methode | Forms/COMMANDBUTTON | offen | - | forms-referenz | -| `COMMANDBUTTON.MOVE` | Methode | Forms/COMMANDBUTTON | offen | - | forms-referenz | -| `COMMANDBUTTON.REFRESH` | Methode | Forms/COMMANDBUTTON | offen | - | forms-referenz | -| `COMMANDBUTTON.SETFOCUS` | Methode | Forms/COMMANDBUTTON | offen | - | forms-referenz | +| `COMMANDBUTTON.DRAG` | Methode | Forms/COMMANDBUTTON | implementiert | tb-ui::forms | forms-referenz | +| `COMMANDBUTTON.MOVE` | Methode | Forms/COMMANDBUTTON | implementiert | tb-ui::forms | forms-referenz | +| `COMMANDBUTTON.REFRESH` | Methode | Forms/COMMANDBUTTON | implementiert | tb-ui::forms | forms-referenz | +| `COMMANDBUTTON.SETFOCUS` | Methode | Forms/COMMANDBUTTON | implementiert | tb-ui::forms | forms-referenz | | `COMMANDBUTTON_CLICK` | Ereignis | Forms/COMMANDBUTTON | implementiert | tb-vm::interp | forms-referenz | | `COMMANDBUTTON_DRAGDROP` | Ereignis | Forms/COMMANDBUTTON | implementiert | tb-vm::interp | forms-referenz | | `COMMANDBUTTON_DRAGOVER` | Ereignis | Forms/COMMANDBUTTON | implementiert | tb-vm::interp | forms-referenz | @@ -489,6 +489,9 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `DIRLISTBOX.TABINDEX` | Eigenschaft | Forms/DIRLISTBOX | implementiert | tb-ui::forms | forms-referenz | | `DIRLISTBOX.TABSTOP` | Eigenschaft | Forms/DIRLISTBOX | implementiert | tb-ui::forms | forms-referenz | | `DIRLISTBOX.CTLNAME` | Eigenschaft | Forms/DIRLISTBOX | implementiert | tb-ui::forms | forms-referenz | +| `DIRLISTBOX.LIST` | Eigenschaft | Forms/DIRLISTBOX | implementiert | tb-ui::forms | forms-referenz | +| `DIRLISTBOX.LISTCOUNT` | Eigenschaft | Forms/DIRLISTBOX | implementiert | tb-ui::forms | forms-referenz | +| `DIRLISTBOX.LISTINDEX` | Eigenschaft | Forms/DIRLISTBOX | implementiert | tb-ui::forms | forms-referenz | | `DIRLISTBOX.PATH` | Eigenschaft | Forms/DIRLISTBOX | implementiert | tb-ui::forms | forms-referenz | | `DIRLISTBOX.TEXT` | Eigenschaft | Forms/DIRLISTBOX | implementiert | tb-ui::forms | forms-referenz | | `DIRLISTBOX_CHANGE` | Ereignis | Forms/DIRLISTBOX | implementiert | tb-vm::interp | forms-referenz | @@ -509,6 +512,9 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `DRIVELISTBOX.TABINDEX` | Eigenschaft | Forms/DRIVELISTBOX | implementiert | tb-ui::forms | forms-referenz | | `DRIVELISTBOX.TABSTOP` | Eigenschaft | Forms/DRIVELISTBOX | implementiert | tb-ui::forms | forms-referenz | | `DRIVELISTBOX.CTLNAME` | Eigenschaft | Forms/DRIVELISTBOX | implementiert | tb-ui::forms | forms-referenz | +| `DRIVELISTBOX.LIST` | Eigenschaft | Forms/DRIVELISTBOX | implementiert | tb-ui::forms | forms-referenz | +| `DRIVELISTBOX.LISTCOUNT` | Eigenschaft | Forms/DRIVELISTBOX | implementiert | tb-ui::forms | forms-referenz | +| `DRIVELISTBOX.LISTINDEX` | Eigenschaft | Forms/DRIVELISTBOX | implementiert | tb-ui::forms | forms-referenz | | `DRIVELISTBOX.DRIVE` | Eigenschaft | Forms/DRIVELISTBOX | implementiert | tb-ui::forms | forms-referenz | | `DRIVELISTBOX.TEXT` | Eigenschaft | Forms/DRIVELISTBOX | implementiert | tb-ui::forms | forms-referenz | | `DRIVELISTBOX_CHANGE` | Ereignis | Forms/DRIVELISTBOX | implementiert | tb-vm::interp | forms-referenz | @@ -526,6 +532,9 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `FILELISTBOX.TABINDEX` | Eigenschaft | Forms/FILELISTBOX | implementiert | tb-ui::forms | forms-referenz | | `FILELISTBOX.TABSTOP` | Eigenschaft | Forms/FILELISTBOX | implementiert | tb-ui::forms | forms-referenz | | `FILELISTBOX.CTLNAME` | Eigenschaft | Forms/FILELISTBOX | implementiert | tb-ui::forms | forms-referenz | +| `FILELISTBOX.LIST` | Eigenschaft | Forms/FILELISTBOX | implementiert | tb-ui::forms | forms-referenz | +| `FILELISTBOX.LISTCOUNT` | Eigenschaft | Forms/FILELISTBOX | implementiert | tb-ui::forms | forms-referenz | +| `FILELISTBOX.LISTINDEX` | Eigenschaft | Forms/FILELISTBOX | implementiert | tb-ui::forms | forms-referenz | | `FILELISTBOX.FILENAME` | Eigenschaft | Forms/FILELISTBOX | implementiert | tb-ui::forms | forms-referenz | | `FILELISTBOX.PATH` | Eigenschaft | Forms/FILELISTBOX | implementiert | tb-ui::forms | forms-referenz | | `FILELISTBOX.PATTERN` | Eigenschaft | Forms/FILELISTBOX | implementiert | tb-ui::forms | forms-referenz | @@ -554,9 +563,9 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `FRAME.CTLNAME` | Eigenschaft | Forms/FRAME | implementiert | tb-ui::forms | forms-referenz | | `FRAME.DRAGMODE` | Eigenschaft | Forms/FRAME | implementiert | tb-ui::forms | forms-referenz | | `FRAME.CAPTION` | Eigenschaft | Forms/FRAME | implementiert | tb-ui::forms | forms-referenz | -| `FRAME.DRAG` | Methode | Forms/FRAME | offen | - | forms-referenz | -| `FRAME.MOVE` | Methode | Forms/FRAME | offen | - | forms-referenz | -| `FRAME.REFRESH` | Methode | Forms/FRAME | offen | - | forms-referenz | +| `FRAME.DRAG` | Methode | Forms/FRAME | implementiert | tb-ui::forms | forms-referenz | +| `FRAME.MOVE` | Methode | Forms/FRAME | implementiert | tb-ui::forms | forms-referenz | +| `FRAME.REFRESH` | Methode | Forms/FRAME | implementiert | tb-ui::forms | forms-referenz | | `FRAME_DRAGDROP` | Ereignis | Forms/FRAME | implementiert | tb-vm::interp | forms-referenz | | `FRAME_DRAGOVER` | Ereignis | Forms/FRAME | implementiert | tb-vm::interp | forms-referenz | | `HSCROLLBAR.BACKCOLOR` | Eigenschaft | Forms/HSCROLLBAR | implementiert | tb-ui::forms | forms-referenz | @@ -581,9 +590,9 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `HSCROLLBAR.MAX` | Eigenschaft | Forms/HSCROLLBAR | implementiert | tb-ui::forms | forms-referenz | | `HSCROLLBAR.SMALLCHANGE` | Eigenschaft | Forms/HSCROLLBAR | implementiert | tb-ui::forms | forms-referenz | | `HSCROLLBAR.VALUE` | Eigenschaft | Forms/HSCROLLBAR | implementiert | tb-ui::forms | forms-referenz | -| `HSCROLLBAR.DRAG` | Methode | Forms/HSCROLLBAR | offen | - | forms-referenz | -| `HSCROLLBAR.MOVE` | Methode | Forms/HSCROLLBAR | offen | - | forms-referenz | -| `HSCROLLBAR.REFRESH` | Methode | Forms/HSCROLLBAR | offen | - | forms-referenz | +| `HSCROLLBAR.DRAG` | Methode | Forms/HSCROLLBAR | implementiert | tb-ui::forms | forms-referenz | +| `HSCROLLBAR.MOVE` | Methode | Forms/HSCROLLBAR | implementiert | tb-ui::forms | forms-referenz | +| `HSCROLLBAR.REFRESH` | Methode | Forms/HSCROLLBAR | implementiert | tb-ui::forms | forms-referenz | | `HSCROLLBAR_CHANGE` | Ereignis | Forms/HSCROLLBAR | implementiert | tb-vm::interp | forms-referenz | | `HSCROLLBAR_DRAGDROP` | Ereignis | Forms/HSCROLLBAR | implementiert | tb-vm::interp | forms-referenz | | `HSCROLLBAR_DRAGOVER` | Ereignis | Forms/HSCROLLBAR | implementiert | tb-vm::interp | forms-referenz | @@ -611,9 +620,9 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `LABEL.AUTOSIZE` | Eigenschaft | Forms/LABEL | implementiert | tb-ui::forms | forms-referenz | | `LABEL.BORDERSTYLE` | Eigenschaft | Forms/LABEL | implementiert | tb-ui::forms | forms-referenz | | `LABEL.CAPTION` | Eigenschaft | Forms/LABEL | implementiert | tb-ui::forms | forms-referenz | -| `LABEL.DRAG` | Methode | Forms/LABEL | offen | - | forms-referenz | -| `LABEL.MOVE` | Methode | Forms/LABEL | offen | - | forms-referenz | -| `LABEL.REFRESH` | Methode | Forms/LABEL | offen | - | forms-referenz | +| `LABEL.DRAG` | Methode | Forms/LABEL | implementiert | tb-ui::forms | forms-referenz | +| `LABEL.MOVE` | Methode | Forms/LABEL | implementiert | tb-ui::forms | forms-referenz | +| `LABEL.REFRESH` | Methode | Forms/LABEL | implementiert | tb-ui::forms | forms-referenz | | `LABEL_CHANGE` | Ereignis | Forms/LABEL | implementiert | tb-vm::interp | forms-referenz | | `LABEL_CLICK` | Ereignis | Forms/LABEL | implementiert | tb-vm::interp | forms-referenz | | `LABEL_DBLCLICK` | Ereignis | Forms/LABEL | implementiert | tb-vm::interp | forms-referenz | @@ -643,12 +652,12 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `LISTBOX.LISTINDEX` | Eigenschaft | Forms/LISTBOX | implementiert | tb-ui::forms | forms-referenz | | `LISTBOX.SORTED` | Eigenschaft | Forms/LISTBOX | implementiert | tb-ui::forms | forms-referenz | | `LISTBOX.TEXT` | Eigenschaft | Forms/LISTBOX | implementiert | tb-ui::forms | forms-referenz | -| `LISTBOX.ADDITEM` | Methode | Forms/LISTBOX | offen | - | forms-referenz | -| `LISTBOX.DRAG` | Methode | Forms/LISTBOX | offen | - | forms-referenz | -| `LISTBOX.MOVE` | Methode | Forms/LISTBOX | offen | - | forms-referenz | -| `LISTBOX.REFRESH` | Methode | Forms/LISTBOX | offen | - | forms-referenz | -| `LISTBOX.REMOVEITEM` | Methode | Forms/LISTBOX | offen | - | forms-referenz | -| `LISTBOX.SETFOCUS` | Methode | Forms/LISTBOX | offen | - | forms-referenz | +| `LISTBOX.ADDITEM` | Methode | Forms/LISTBOX | implementiert | tb-ui::forms | forms-referenz | +| `LISTBOX.DRAG` | Methode | Forms/LISTBOX | implementiert | tb-ui::forms | forms-referenz | +| `LISTBOX.MOVE` | Methode | Forms/LISTBOX | implementiert | tb-ui::forms | forms-referenz | +| `LISTBOX.REFRESH` | Methode | Forms/LISTBOX | implementiert | tb-ui::forms | forms-referenz | +| `LISTBOX.REMOVEITEM` | Methode | Forms/LISTBOX | implementiert | tb-ui::forms | forms-referenz | +| `LISTBOX.SETFOCUS` | Methode | Forms/LISTBOX | implementiert | tb-ui::forms | forms-referenz | | `LISTBOX_CLICK` | Ereignis | Forms/LISTBOX | implementiert | tb-vm::interp | forms-referenz | | `LISTBOX_DBLCLICK` | Ereignis | Forms/LISTBOX | implementiert | tb-vm::interp | forms-referenz | | `LISTBOX_DRAGDROP` | Ereignis | Forms/LISTBOX | implementiert | tb-vm::interp | forms-referenz | @@ -670,6 +679,7 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `MENU.SEPARATOR` | Eigenschaft | Forms/MENU | implementiert | tb-ui::forms | forms-referenz | | `MENU.TAG` | Eigenschaft | Forms/MENU | implementiert | tb-ui::forms | forms-referenz | | `MENU.VISIBLE` | Eigenschaft | Forms/MENU | implementiert | tb-ui::forms | forms-referenz | +| `MENU.SHORTCUT` | Eigenschaft | Forms/MENU | implementiert | tb-ui::forms | forms-referenz | | `MENU_CLICK` | Ereignis | Forms/MENU | implementiert | tb-vm::interp | forms-referenz | | `OPTIONBUTTON.BACKCOLOR` | Eigenschaft | Forms/OPTIONBUTTON | implementiert | tb-ui::forms | forms-referenz | | `OPTIONBUTTON.ENABLED` | Eigenschaft | Forms/OPTIONBUTTON | implementiert | tb-ui::forms | forms-referenz | @@ -689,10 +699,10 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `OPTIONBUTTON.DRAGMODE` | Eigenschaft | Forms/OPTIONBUTTON | implementiert | tb-ui::forms | forms-referenz | | `OPTIONBUTTON.CAPTION` | Eigenschaft | Forms/OPTIONBUTTON | implementiert | tb-ui::forms | forms-referenz | | `OPTIONBUTTON.VALUE` | Eigenschaft | Forms/OPTIONBUTTON | implementiert | tb-ui::forms | forms-referenz | -| `OPTIONBUTTON.DRAG` | Methode | Forms/OPTIONBUTTON | offen | - | forms-referenz | -| `OPTIONBUTTON.MOVE` | Methode | Forms/OPTIONBUTTON | offen | - | forms-referenz | -| `OPTIONBUTTON.REFRESH` | Methode | Forms/OPTIONBUTTON | offen | - | forms-referenz | -| `OPTIONBUTTON.SETFOCUS` | Methode | Forms/OPTIONBUTTON | offen | - | forms-referenz | +| `OPTIONBUTTON.DRAG` | Methode | Forms/OPTIONBUTTON | implementiert | tb-ui::forms | forms-referenz | +| `OPTIONBUTTON.MOVE` | Methode | Forms/OPTIONBUTTON | implementiert | tb-ui::forms | forms-referenz | +| `OPTIONBUTTON.REFRESH` | Methode | Forms/OPTIONBUTTON | implementiert | tb-ui::forms | forms-referenz | +| `OPTIONBUTTON.SETFOCUS` | Methode | Forms/OPTIONBUTTON | implementiert | tb-ui::forms | forms-referenz | | `OPTIONBUTTON_CLICK` | Ereignis | Forms/OPTIONBUTTON | implementiert | tb-vm::interp | forms-referenz | | `OPTIONBUTTON_DBLCLICK` | Ereignis | Forms/OPTIONBUTTON | implementiert | tb-vm::interp | forms-referenz | | `OPTIONBUTTON_DRAGDROP` | Ereignis | Forms/OPTIONBUTTON | implementiert | tb-vm::interp | forms-referenz | @@ -724,14 +734,14 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `PICTUREBOX.CURRENTY` | Eigenschaft | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | | `PICTUREBOX.SCALEHEIGHT` | Eigenschaft | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | | `PICTUREBOX.SCALEWIDTH` | Eigenschaft | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | -| `PICTUREBOX.CLS` | Methode | Forms/PICTUREBOX | offen | - | forms-referenz | -| `PICTUREBOX.DRAG` | Methode | Forms/PICTUREBOX | offen | - | forms-referenz | -| `PICTUREBOX.MOVE` | Methode | Forms/PICTUREBOX | offen | - | forms-referenz | -| `PICTUREBOX.PRINT` | Methode | Forms/PICTUREBOX | offen | - | forms-referenz | -| `PICTUREBOX.REFRESH` | Methode | Forms/PICTUREBOX | offen | - | forms-referenz | -| `PICTUREBOX.SETFOCUS` | Methode | Forms/PICTUREBOX | offen | - | forms-referenz | -| `PICTUREBOX.TEXTHEIGHT` | Methode | Forms/PICTUREBOX | offen | - | forms-referenz | -| `PICTUREBOX.TEXTWIDTH` | Methode | Forms/PICTUREBOX | offen | - | forms-referenz | +| `PICTUREBOX.CLS` | Methode | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | +| `PICTUREBOX.DRAG` | Methode | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | +| `PICTUREBOX.MOVE` | Methode | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | +| `PICTUREBOX.PRINT` | Methode | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | +| `PICTUREBOX.REFRESH` | Methode | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | +| `PICTUREBOX.SETFOCUS` | Methode | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | +| `PICTUREBOX.TEXTHEIGHT` | Methode | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | +| `PICTUREBOX.TEXTWIDTH` | Methode | Forms/PICTUREBOX | implementiert | tb-ui::forms | forms-referenz | | `PICTUREBOX_CLICK` | Ereignis | Forms/PICTUREBOX | implementiert | tb-vm::interp | forms-referenz | | `PICTUREBOX_DBLCLICK` | Ereignis | Forms/PICTUREBOX | implementiert | tb-vm::interp | forms-referenz | | `PICTUREBOX_DRAGDROP` | Ereignis | Forms/PICTUREBOX | implementiert | tb-vm::interp | forms-referenz | @@ -768,10 +778,10 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `TEXTBOX.SELSTART` | Eigenschaft | Forms/TEXTBOX | implementiert | tb-ui::forms | forms-referenz | | `TEXTBOX.SELTEXT` | Eigenschaft | Forms/TEXTBOX | implementiert | tb-ui::forms | forms-referenz | | `TEXTBOX.TEXT` | Eigenschaft | Forms/TEXTBOX | implementiert | tb-ui::forms | forms-referenz | -| `TEXTBOX.DRAG` | Methode | Forms/TEXTBOX | offen | - | forms-referenz | -| `TEXTBOX.MOVE` | Methode | Forms/TEXTBOX | offen | - | forms-referenz | -| `TEXTBOX.REFRESH` | Methode | Forms/TEXTBOX | offen | - | forms-referenz | -| `TEXTBOX.SETFOCUS` | Methode | Forms/TEXTBOX | offen | - | forms-referenz | +| `TEXTBOX.DRAG` | Methode | Forms/TEXTBOX | implementiert | tb-ui::forms | forms-referenz | +| `TEXTBOX.MOVE` | Methode | Forms/TEXTBOX | implementiert | tb-ui::forms | forms-referenz | +| `TEXTBOX.REFRESH` | Methode | Forms/TEXTBOX | implementiert | tb-ui::forms | forms-referenz | +| `TEXTBOX.SETFOCUS` | Methode | Forms/TEXTBOX | implementiert | tb-ui::forms | forms-referenz | | `TEXTBOX_CHANGE` | Ereignis | Forms/TEXTBOX | implementiert | tb-vm::interp | forms-referenz | | `TEXTBOX_DRAGDROP` | Ereignis | Forms/TEXTBOX | implementiert | tb-vm::interp | forms-referenz | | `TEXTBOX_DRAGOVER` | Ereignis | Forms/TEXTBOX | implementiert | tb-vm::interp | forms-referenz | @@ -809,9 +819,9 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `VSCROLLBAR.MAX` | Eigenschaft | Forms/VSCROLLBAR | implementiert | tb-ui::forms | forms-referenz | | `VSCROLLBAR.SMALLCHANGE` | Eigenschaft | Forms/VSCROLLBAR | implementiert | tb-ui::forms | forms-referenz | | `VSCROLLBAR.VALUE` | Eigenschaft | Forms/VSCROLLBAR | implementiert | tb-ui::forms | forms-referenz | -| `VSCROLLBAR.DRAG` | Methode | Forms/VSCROLLBAR | offen | - | forms-referenz | -| `VSCROLLBAR.MOVE` | Methode | Forms/VSCROLLBAR | offen | - | forms-referenz | -| `VSCROLLBAR.REFRESH` | Methode | Forms/VSCROLLBAR | offen | - | forms-referenz | +| `VSCROLLBAR.DRAG` | Methode | Forms/VSCROLLBAR | implementiert | tb-ui::forms | forms-referenz | +| `VSCROLLBAR.MOVE` | Methode | Forms/VSCROLLBAR | implementiert | tb-ui::forms | forms-referenz | +| `VSCROLLBAR.REFRESH` | Methode | Forms/VSCROLLBAR | implementiert | tb-ui::forms | forms-referenz | | `VSCROLLBAR_CHANGE` | Ereignis | Forms/VSCROLLBAR | implementiert | tb-vm::interp | forms-referenz | | `VSCROLLBAR_DRAGDROP` | Ereignis | Forms/VSCROLLBAR | implementiert | tb-vm::interp | forms-referenz | | `VSCROLLBAR_DRAGOVER` | Ereignis | Forms/VSCROLLBAR | implementiert | tb-vm::interp | forms-referenz | @@ -820,6 +830,37 @@ Abschnitt der Sprachreferenz, bei `offen` ein Strich. | `VSCROLLBAR_KEYPRESS` | Ereignis | Forms/VSCROLLBAR | implementiert | tb-vm::interp | forms-referenz | | `VSCROLLBAR_KEYUP` | Ereignis | Forms/VSCROLLBAR | implementiert | tb-vm::interp | forms-referenz | | `VSCROLLBAR_LOSTFOCUS` | Ereignis | Forms/VSCROLLBAR | implementiert | tb-vm::interp | forms-referenz | +| `SPIN.BACKCOLOR` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.BORDERSTYLE` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.CTLNAME` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.ENABLED` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.HEIGHT` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.INDEX` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.INTERVAL` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.LEFT` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.MAX` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.MIN` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.MOUSEPOINTER` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.PARENT` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.STYLE` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.TABINDEX` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.TABSTOP` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.TAG` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.TOP` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.VALUE` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.VISIBLE` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.WIDTH` | Eigenschaft | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.DRAG` | Methode | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.REFRESH` | Methode | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN.SETFOCUS` | Methode | Forms/SPIN | implementiert | tb-ui::forms | forms-referenz | +| `SPIN_CUSTOM` | Ereignis | Forms/SPIN | implementiert | tb-vm::interp | forms-referenz | +| `SPIN_DRAGDROP` | Ereignis | Forms/SPIN | implementiert | tb-vm::interp | forms-referenz | +| `SPIN_DRAGOVER` | Ereignis | Forms/SPIN | implementiert | tb-vm::interp | forms-referenz | +| `SPIN_GOTFOCUS` | Ereignis | Forms/SPIN | implementiert | tb-vm::interp | forms-referenz | +| `SPIN_KEYDOWN` | Ereignis | Forms/SPIN | implementiert | tb-vm::interp | forms-referenz | +| `SPIN_KEYPRESS` | Ereignis | Forms/SPIN | implementiert | tb-vm::interp | forms-referenz | +| `SPIN_KEYUP` | Ereignis | Forms/SPIN | implementiert | tb-vm::interp | forms-referenz | +| `SPIN_LOSTFOCUS` | Ereignis | Forms/SPIN | implementiert | tb-vm::interp | forms-referenz | | `SCREEN.ACTIVECONTROL` | Eigenschaft | Forms/SCREEN | implementiert | tb-ui::forms | forms-referenz | | `SCREEN.ACTIVEFORM` | Eigenschaft | Forms/SCREEN | implementiert | tb-ui::forms | forms-referenz | | `SCREEN.AUTOREDRAW` | Eigenschaft | Forms/SCREEN | implementiert | tb-ui::forms | forms-referenz | diff --git a/docs/sprachreferenz.md b/docs/sprachreferenz.md index cd97eb9..9b180fd 100644 --- a/docs/sprachreferenz.md +++ b/docs/sprachreferenz.md @@ -106,6 +106,9 @@ stehen am Ende dieses Dokuments; nur diese Liste rechtfertigt Abweisungen. - `CONST name = ausdruck` — Konstanten (konstante Ausdrücke zur Compilezeit). - `DECLARE SUB/FUNCTION name (parameter)` — Prototyp; die IDE erzeugt sie automatisch beim Speichern. +- `'$INCLUDE: 'datei'` — bindet eine Textquelle relativ zur einschließenden + Datei ein; Namen werden wie unter DOS ohne Beachtung der Großschreibung + aufgelöst, Zyklen werden namentlich abgewiesen. ## 4. Operatoren (nach Priorität, hoch → niedrig) @@ -148,6 +151,9 @@ INTEGER −1 (wahr) / 0 (falsch). als Direktmodus-Befehl der IDE (Phase 5) und ist nicht kompilierbar. - `SLEEP [sekunden]`, `DO EVENTS`/`DOEVENTS` (Ereignisse verarbeiten — zentral für Forms) +- `RUN` startet das aktuelle Programm neu; `RUN zeile` startet es nach den + globalen Deklarationen an der angegebenen Zeile, `RUN datei$` lädt relativ + zur laufenden Quelle eine `.BAS`-, `.FRM`- oder `.MAK`-Datei neu. ## 6. Prozeduren @@ -517,16 +523,12 @@ Dialoge: Kombinationen) liefern nichts — eine eigene Kodierung dafür wäre eine Erweiterung und gehört in Stufe 2. 4. **Kein `CHAIN`/Overlay-Mechanismus**; `SHELL` startet die System-Shell. -5. **Grafik-Anweisungen** (`SCREEN n>0`, `PSET`, `PRESET`, `LINE`, - `CIRCLE`, `PAINT`, `DRAW`, Grafik-`GET`/`PUT`, `PALETTE`, `WINDOW`, - `VIEW`, `PMAP`, `PCOPY`, `POINT`) sind Nicht-Ziel und werden **zur - Compile-Zeit** mit „Feature unavailable" abgewiesen. Die Formen, die - sich ein Schlüsselwort mit einer unterstützten Anweisung teilen, erkennt - der Compiler an ihrer Syntax: `LINE` ohne `INPUT`, `GET`/`PUT` mit - `(`, `VIEW` ohne `PRINT`. Hinweis: das Vorbild **hatte** die - Grafikmodi 0–13, aber nicht gleichzeitig mit Forms (dort Fehler 272 - „Invalid when forms are showing") — wir übernehmen nur den - Forms-/Textmodus-Zweig. +5. **Grafik im Zellenpuffer:** `SCREEN 0` bis `SCREEN 13`, `LINE` (auch + `B`/`BF` und `STEP`), `PAINT` und `VIEW` werden ausgeführt. Historische + Pixelkoordinaten werden dabei in 8×8-Blöcken auf Terminalzellen + abgebildet. `PSET`, `PRESET`, `CIRCLE`, `DRAW`, Grafik-`GET`/`PUT`, + `PALETTE`, `WINDOW`, `PMAP`, `PCOPY` und `POINT` bleiben Nicht-Ziel und + werden zur Compile-Zeit mit „Feature unavailable" abgewiesen. 6. **`PLAY`/`SOUND`**: `BEEP` = Terminal-Bell; Rest Nicht-Ziel (Fehler 73). 5a. **`LPRINT` schreibt in eine Datei** (2026-09-03): einen plattformübergreifenden Druckerkanal gibt es nicht. Der Druckerpuffer @@ -576,3 +578,13 @@ Dialoge: werden nicht abgebildet. Unter Windows ist `SIGTERM` von außen nicht zustellbar — `SIGNAL(2)` bleibt dort wirkungslos. Trifft `SIGINT` ohne aktiven Trap ein, bleibt es beim bisherigen Abbruch. + +9. **Forms-Darstellung und Dateisystem-Steuerelemente** (2026-09-05): + Controls zeichnen ausschließlich in den Unicode-Zellenpuffer. Mangels + vollständiger Original-Screenshots gelten die Zeichenbilder und + Fokus-/Deaktiviert-Farben aus `forms-referenz.md` als unsere Festlegung. + Dasselbe gilt für die Z-Reihenfolge (Erzeugungsreihenfolge, später liegt + oben) und gleichzeitig fällige Timer (aufsteigend nach `CtlName`). + `DirListBox` und `FileListBox` verwenden native Pfade und `*`/`?`-Muster; + `DriveListBox` zeigt unter Unix `/` und unter macOS zusätzlich Mounts aus + `/Volumes` statt DOS-Laufwerksbuchstaben. diff --git a/openspec/changes/phase-4-steuerelemente/.openspec.yaml b/openspec/changes/archive/2026-09-05-phase-4-steuerelemente/.openspec.yaml similarity index 100% rename from openspec/changes/phase-4-steuerelemente/.openspec.yaml rename to openspec/changes/archive/2026-09-05-phase-4-steuerelemente/.openspec.yaml diff --git a/openspec/changes/phase-4-steuerelemente/design.md b/openspec/changes/archive/2026-09-05-phase-4-steuerelemente/design.md similarity index 91% rename from openspec/changes/phase-4-steuerelemente/design.md rename to openspec/changes/archive/2026-09-05-phase-4-steuerelemente/design.md index b793779..2dcb1c9 100644 --- a/openspec/changes/phase-4-steuerelemente/design.md +++ b/openspec/changes/archive/2026-09-05-phase-4-steuerelemente/design.md @@ -5,7 +5,7 @@ Siehe proposal.md — Why. Dieser Change setzt auf drei fertigen Stücken auf: dem Zellenpuffer aus Phase 3, der Ereignisschleife samt Mausquelle (`phase-4-ereignisschleife`) und dem Objektmodell -(`phase-4-objektmodell`). Er ist der breiteste Change der Phase — 15 +(`phase-4-objektmodell`). Er ist der breiteste Change der Phase — 16 Klassen, Menüs, Dialoge —, aber der mit den wenigsten offenen Architekturfragen. @@ -94,9 +94,16 @@ Einhängepunkte der Plattform; DirListBox und FileListBox arbeiten auf Pfaden. `Pattern` bleibt das Muster des Vorbilds. Die Abweichung wird in der Sprachreferenz festgehalten. +### D7 — Korpusbefunde werden im gemeinsamen Ausführungspfad geschlossen + +Die VBDOS-Projekte laufen durch denselben CLI-, Frontend-, VM- und +Zellenpufferpfad wie eigene Programme. Projekt- und Include-Auflösung, +VSpin/HSpin, Common-Dialog-Routinen und die tatsächlich verwendete Grafik +werden deshalb dort implementiert und getestet, nicht im Korpus umgangen. + ## Risks / Trade-offs -- **Breite statt Tiefe** → 15 Klassen sind viel Fläche für Flüchtigkeit; +- **Breite statt Tiefe** → 16 Klassen sind viel Fläche für Flüchtigkeit; jede Klasse bekommt ihren eigenen Korpusnachweis, nicht nur einen gemeinsamen Sammeltest. - **Vollständiges Neuzeichnen bei großen Terminals** → bei 200×60 sind es @@ -109,6 +116,4 @@ der Sprachreferenz festgehalten. ## Open Questions -- Ob die PictureBox als Container für OptionButton-Gruppen dieselbe - Fokuslogik wie der Frame braucht, zeigt der erste Korpusnachweis mit - gruppierten Optionsfeldern. +Keine. diff --git a/openspec/changes/phase-4-steuerelemente/proposal.md b/openspec/changes/archive/2026-09-05-phase-4-steuerelemente/proposal.md similarity index 84% rename from openspec/changes/phase-4-steuerelemente/proposal.md rename to openspec/changes/archive/2026-09-05-phase-4-steuerelemente/proposal.md index f0586b6..ba9dd9e 100644 --- a/openspec/changes/phase-4-steuerelemente/proposal.md +++ b/openspec/changes/archive/2026-09-05-phase-4-steuerelemente/proposal.md @@ -18,7 +18,7 @@ zwischen die Aufgaben. - **Steuerelementklassen mit Verhalten**: CommandButton, TextBox, ListBox, ComboBox, CheckBox, OptionButton, Frame, Label, HScrollBar, VScrollBar, PictureBox, Timer sowie die Dateisystem-Steuerelemente - DirListBox, DriveListBox und FileListBox — je mit Darstellung, + DirListBox, DriveListBox, FileListBox und VSpin/HSpin — je mit Darstellung, Zuständen (normal, fokussiert, deaktiviert) und ihren Methoden. - **Darstellung im Zellenpuffer**: Steuerelemente zeichnen in den vorhandenen Textbildschirm; es entsteht keine zweite Zeichenschicht. @@ -43,10 +43,13 @@ zwischen die Aufgaben. Positionierung in Zeichen. - **Timer als Steuerelement**: `Interval` 0–65 535 ms auf der Zeitquelle der Ereignisschleife, Ereignis `Timer`. +- **VBDOS-Kompatibilität**: `.MAK`- und `$INCLUDE`-Auflösung, `RUN`, + VSpin/HSpin, die verwendete Zellgrafik (`SCREEN`, `LINE`, `PAINT`, `VIEW`) + und die von den Beispielen benutzten Common-Dialog-Routinen. -**Non-Goals:** Der Formular-Designer und die Projektverwaltung (Phase 5); -native Erweiterungssteuerelemente (Stufe 2); grafische Ausgabe in der -PictureBox — sie ist eine Textzeichenfläche. +**Non-Goals:** Der Formular-Designer (Phase 5). Für den geforderten +VBDOS-Kompatibilitätsnachweis notwendige Projektauflösung, native +Erweiterungssteuerelemente und Grafikausgabe gehören zu diesem Change. ## Capabilities @@ -66,10 +69,10 @@ PictureBox — sie ist eine Textzeichenfläche. - `crates/tb-ui`: Darstellung und Eingabebehandlung je Klasse, Menüs, Dialoge, Trefferprüfung, Fokusverwaltung. -- `crates/tb-runtime`: Fehler 260–480 des Forms-Bereichs als Auslöser; - `MSGBOX`/`INPUTBOX$` als Sprachelemente. +- `crates/tb-runtime`: Fehler 260–480 des Forms-Bereichs als Auslöser, + `MSGBOX`/`INPUTBOX$` und die benötigte Zellgrafik. - `crates/tb-frontend`: Signaturen von `MSGBOX` (Anweisung und Funktion) - und `INPUTBOX$`; die `Unsupported`-Absenkung von `MSGBOX` entfällt. + und `INPUTBOX$`, Projektquellen, `RUN` und die verwendete Grafiksyntax. - `tests/compat`: Formularprogramme mit Sollausgabe; die Programme aus `github.com/cout/vbdos` als Kompatibilitätsnachweis. - `docs/`: `forms-referenz.md` — die offenen Detailfragen (Optik je diff --git a/openspec/changes/phase-4-steuerelemente/specs/forms-steuerelemente/spec.md b/openspec/changes/archive/2026-09-05-phase-4-steuerelemente/specs/forms-steuerelemente/spec.md similarity index 100% rename from openspec/changes/phase-4-steuerelemente/specs/forms-steuerelemente/spec.md rename to openspec/changes/archive/2026-09-05-phase-4-steuerelemente/specs/forms-steuerelemente/spec.md diff --git a/openspec/changes/phase-4-steuerelemente/specs/kompat-testkorpus/spec.md b/openspec/changes/archive/2026-09-05-phase-4-steuerelemente/specs/kompat-testkorpus/spec.md similarity index 79% rename from openspec/changes/phase-4-steuerelemente/specs/kompat-testkorpus/spec.md rename to openspec/changes/archive/2026-09-05-phase-4-steuerelemente/specs/kompat-testkorpus/spec.md index 6971452..6e66c33 100644 --- a/openspec/changes/phase-4-steuerelemente/specs/kompat-testkorpus/spec.md +++ b/openspec/changes/archive/2026-09-05-phase-4-steuerelemente/specs/kompat-testkorpus/spec.md @@ -19,11 +19,10 @@ Listen-Steuerelement, ein Timer und ein modaler Dialog. ### Requirement: Kompatibilitätsnachweis an Fremdprogrammen Die Formularprogramme aus dem öffentlichen Bestand des Vorbilds SHALL -sich ohne Non-Features übersetzen lassen und bedienbar sein. Ein -Programm, das an einem Non-Feature scheitert, MUST das Element -namentlich nennen; ein Scheitern aus anderem Grund MUST als Befund -festgehalten werden. +sich ohne Diagnosen oder Non-Features übersetzen lassen und bedienbar +sein. Jeder entdeckte fehlende Sprach-, Projekt-, Formular- oder +Laufzeitpfad MUST implementiert und als Befund festgehalten werden. #### Scenario: Fremdprogramm übersetzt - **WHEN** ein Formularprogramm des öffentlichen Bestands übersetzt wird -- **THEN** entstehen keine Diagnosen außer namentlich genannten Non-Features +- **THEN** entstehen keine Diagnosen und das Programm ist bedienbar diff --git a/openspec/changes/phase-4-steuerelemente/tasks.md b/openspec/changes/archive/2026-09-05-phase-4-steuerelemente/tasks.md similarity index 63% rename from openspec/changes/phase-4-steuerelemente/tasks.md rename to openspec/changes/archive/2026-09-05-phase-4-steuerelemente/tasks.md index 64f6fce..24220bb 100644 --- a/openspec/changes/phase-4-steuerelemente/tasks.md +++ b/openspec/changes/archive/2026-09-05-phase-4-steuerelemente/tasks.md @@ -1,49 +1,49 @@ ## 1. Festlegungen vorab -- [ ] 1.1 Optik je Klasse und Zustand (normal, fokussiert, deaktiviert) als Zeichenbild in `docs/forms-referenz.md` festhalten, aus den Original-Screenshots abgeleitet (D3); verifiziert durch je ein Zeichenbild pro Klasse und Zustand -- [ ] 1.2 Z-Reihenfolge und Timer-Reihenfolge festlegen und als unsere Festlegung kennzeichnen (D3); verifiziert durch die beiden Abschnitte in der Forms-Referenz +- [x] 1.1 Optik je Klasse und Zustand (normal, fokussiert, deaktiviert) als Zeichenbild in `docs/forms-referenz.md` festhalten, aus den Original-Screenshots abgeleitet (D3); verifiziert durch je ein Zeichenbild pro Klasse und Zustand +- [x] 1.2 Z-Reihenfolge und Timer-Reihenfolge festlegen und als unsere Festlegung kennzeichnen (D3); verifiziert durch die beiden Abschnitte in der Forms-Referenz ## 2. Darstellung und Eingabekette -- [ ] 2.1 Zeichnen je Klasse aus dem Eigenschaftszustand in den Zellenpuffer (D1); verifiziert durch Snapshot-Tests je Klasse, darunter die drei Höhenformen des CommandButton -- [ ] 2.2 Eingabekette für Tastatur und Maus (D2); verifiziert durch Unit-Tests, die je Station den Vorrang prüfen -- [ ] 2.3 Trefferprüfung über die Z-Reihenfolge; verifiziert durch einen Test mit überlappenden Steuerelementen und einem Klick ins Formular +- [x] 2.1 Zeichnen je Klasse aus dem Eigenschaftszustand in den Zellenpuffer (D1); verifiziert durch Snapshot-Tests je Klasse, darunter die drei Höhenformen des CommandButton +- [x] 2.2 Eingabekette für Tastatur und Maus (D2); verifiziert durch Unit-Tests, die je Station den Vorrang prüfen +- [x] 2.3 Trefferprüfung über die Z-Reihenfolge; verifiziert durch einen Test mit überlappenden Steuerelementen und einem Klick ins Formular ## 3. Fokus und Tastatur -- [ ] 3.1 Tabreihenfolge über `TabIndex`/`TabStop` vorwärts und rückwärts, deaktivierte Elemente überspringen; verifiziert durch Unit-Tests je Fall -- [ ] 3.2 Access-Keys aus `&`, `Default` für Enter, `Cancel` für Esc; verifiziert durch Unit-Tests je Auslöser -- [ ] 3.3 `SETFOCUS`, `GotFocus`/`LostFocus` in der festgelegten Reihenfolge; verifiziert durch einen Test, der die Reihenfolge der beiden Ereignisse prüft +- [x] 3.1 Tabreihenfolge über `TabIndex`/`TabStop` vorwärts und rückwärts, deaktivierte Elemente überspringen; verifiziert durch Unit-Tests je Fall +- [x] 3.2 Access-Keys aus `&`, `Default` für Enter, `Cancel` für Esc; verifiziert durch Unit-Tests je Auslöser +- [x] 3.3 `SETFOCUS`, `GotFocus`/`LostFocus` in der festgelegten Reihenfolge; verifiziert durch einen Test, der die Reihenfolge der beiden Ereignisse prüft ## 4. Steuerelementklassen -- [ ] 4.1 CommandButton, Label, Frame, CheckBox, OptionButton (inkl. Gruppierung im Container); verifiziert durch Snapshot- und Verhaltenstests je Klasse -- [ ] 4.2 TextBox mit `Text`, `SelStart`/`SelLength`/`SelText`, `MultiLine`, `ScrollBars`, Ereignis `Change`; verifiziert durch Tests für Eingabe, Auswahl und Umbruch -- [ ] 4.3 ListBox und ComboBox mit `ADDITEM`/`REMOVEITEM`, `List`/`ListCount`/`ListIndex`, `Sorted`, den drei ComboBox-Stilarten; verifiziert durch Tests für Einfügen, Sortierung und „keine Auswahl" -- [ ] 4.4 HScrollBar/VScrollBar mit `Min`/`Max`/`Value`/`SmallChange`/`LargeChange`/`Attached` und Ereignis `Change`; verifiziert durch Tests für Tastatur- und Mausbedienung -- [ ] 4.5 PictureBox als Textzeichenfläche (`PRINT`, `CLS`, `CurrentX`/`CurrentY`, `TEXTWIDTH`/`TEXTHEIGHT`) und als Container; verifiziert durch Snapshot-Tests -- [ ] 4.6 Timer auf der Zeitquelle der Ereignisschleife, Reihenfolge nach 1.2; verifiziert durch Tests mit virtueller Zeit für Abstand, `Interval = 0` und zwei gleichzeitig fällige Timer -- [ ] 4.7 DirListBox, DriveListBox, FileListBox plattformneutral (D6) mit `Path`/`Drive`/`FileName`/`Pattern` und ihren Ereignissen; verifiziert durch Tests auf einem angelegten Verzeichnisbaum -- [ ] 4.8 Ziehen und Ablegen: `DragMode`, `DRAG action%`, `DragOver` mit Zustand, `DragDrop` mit Quelle; verifiziert durch Tests für automatisches und manuelles Ziehen sowie Abbruch +- [x] 4.1 CommandButton, Label, Frame, CheckBox, OptionButton (inkl. Gruppierung im Container); verifiziert durch Snapshot- und Verhaltenstests je Klasse +- [x] 4.2 TextBox mit `Text`, `SelStart`/`SelLength`/`SelText`, `MultiLine`, `ScrollBars`, Ereignis `Change`; verifiziert durch Tests für Eingabe, Auswahl und Umbruch +- [x] 4.3 ListBox und ComboBox mit `ADDITEM`/`REMOVEITEM`, `List`/`ListCount`/`ListIndex`, `Sorted`, den drei ComboBox-Stilarten; verifiziert durch Tests für Einfügen, Sortierung und „keine Auswahl" +- [x] 4.4 HScrollBar/VScrollBar mit `Min`/`Max`/`Value`/`SmallChange`/`LargeChange`/`Attached` und Ereignis `Change`; verifiziert durch Tests für Tastatur- und Mausbedienung +- [x] 4.5 PictureBox als Textzeichenfläche (`PRINT`, `CLS`, `CurrentX`/`CurrentY`, `TEXTWIDTH`/`TEXTHEIGHT`) und als Container; verifiziert durch Snapshot-Tests +- [x] 4.6 Timer auf der Zeitquelle der Ereignisschleife, Reihenfolge nach 1.2; verifiziert durch Tests mit virtueller Zeit für Abstand, `Interval = 0` und zwei gleichzeitig fällige Timer +- [x] 4.7 DirListBox, DriveListBox, FileListBox plattformneutral (D6) mit `Path`/`Drive`/`FileName`/`Pattern` und ihren Ereignissen; verifiziert durch Tests auf einem angelegten Verzeichnisbaum +- [x] 4.8 Ziehen und Ablegen: `DragMode`, `DRAG action%`, `DragOver` mit Zustand, `DragDrop` mit Quelle; verifiziert durch Tests für automatisches und manuelles Ziehen sowie Abbruch ## 5. Menüs und Dialoge -- [ ] 5.1 Menüleiste mit bis zu sechs Ebenen, Access-Keys, Shortcuts, `Checked`/`Enabled`/`Visible`/`Separator`, Menü-Arrays; verifiziert durch Snapshot- und Verhaltenstests, darunter die Regelverstöße (Separator mit Shortcut) -- [ ] 5.2 Ereigniszustellung ruht im geöffneten Menü über den vorhandenen `STOP`-Zustand (D5); verifiziert durch einen Test mit fälligem Zeit-Trap bei offenem Menü -- [ ] 5.3 `MSGBOX` als Anweisung und Funktion mit Schaltflächengruppen, Vorgabeschaltfläche und Rückgabewerten; verifiziert durch Tests je Gruppe und Rückgabewert -- [ ] 5.4 `INPUTBOX$` mit fester Größe 46×16, Positionierung in Zeichen, leerem String bei Abbruch; verifiziert durch Snapshot-Test und Abbruchtest -- [ ] 5.5 Frontend-Signaturen für `MSGBOX` (Anweisung und Funktion) und `INPUTBOX$`, `Unsupported`-Absenkung entfernen; verifiziert durch `cargo test -p tb-frontend` +- [x] 5.1 Menüleiste mit bis zu sechs Ebenen, Access-Keys, Shortcuts, `Checked`/`Enabled`/`Visible`/`Separator`, Menü-Arrays; verifiziert durch Snapshot- und Verhaltenstests, darunter die Regelverstöße (Separator mit Shortcut) +- [x] 5.2 Ereigniszustellung ruht im geöffneten Menü über den vorhandenen `STOP`-Zustand (D5); verifiziert durch einen Test mit fälligem Zeit-Trap bei offenem Menü +- [x] 5.3 `MSGBOX` als Anweisung und Funktion mit Schaltflächengruppen, Vorgabeschaltfläche und Rückgabewerten; verifiziert durch Tests je Gruppe und Rückgabewert +- [x] 5.4 `INPUTBOX$` mit fester Größe 46×16, Positionierung in Zeichen, leerem String bei Abbruch; verifiziert durch Snapshot-Test und Abbruchtest +- [x] 5.5 Frontend-Signaturen für `MSGBOX` (Anweisung und Funktion) und `INPUTBOX$`, `Unsupported`-Absenkung entfernen; verifiziert durch `cargo test -p tb-frontend` ## 6. Korpus und Kompatibilität -- [ ] 6.1 Ereignisfolge-Direktive im Programmkopf und ihre Einspeisung durch den Harness; verifiziert durch einen Harness-Test mit deklarierten Tasten-, Maus- und Zeitereignissen -- [ ] 6.2 Formular-Korpusprogramme für Fokus/Tab, Access-Key, Mausklick, Menüauswahl, Liste, Timer und modalen Dialog; verifiziert durch `cargo test -p tb-cli` gegen die Sollausgaben -- [ ] 6.3 Meilenstein: die Formularprogramme aus `github.com/cout/vbdos` übersetzen und bedienen, jeden Befund festhalten; verifiziert durch den Lauf über den Bestand mit Befundliste -- [ ] 6.4 Meilenstein: die Beispiel-Formularprogramme des Korpus laufen; verifiziert durch den grünen Korpuslauf +- [x] 6.1 Ereignisfolge-Direktive im Programmkopf und ihre Einspeisung durch den Harness; verifiziert durch einen Harness-Test mit deklarierten Tasten-, Maus- und Zeitereignissen +- [x] 6.2 Formular-Korpusprogramme für Fokus/Tab, Access-Key, Mausklick, Menüauswahl, Liste, Timer und modalen Dialog; verifiziert durch `cargo test -p tb-cli` gegen die Sollausgaben +- [x] 6.3 Meilenstein: die Formularprogramme aus `github.com/cout/vbdos` übersetzen und bedienen, jeden Befund festhalten; verifiziert durch den Lauf über den Bestand mit Befundliste +- [x] 6.4 Meilenstein: die Beispiel-Formularprogramme des Korpus laufen; verifiziert durch den grünen Korpuslauf ## 7. Inventar, Dokumentation, Abschluss -- [ ] 7.1 `docs/inventar.md`: Einträge dieses Changes auf `implementiert`; verifiziert durch `inventar_stimmt_mit_code_ueberein` mit Abdeckung 0 offen für Phase 4 -- [ ] 7.2 Abweichungen (Dateisystem-Steuerelemente, festgelegte Optik, Z- und Timer-Reihenfolge) in `docs/sprachreferenz.md` eintragen; verifiziert durch den Abschnitt „Abweichungen" -- [ ] 7.3 PLAN.md: die verbleibenden Phase-4-Punkte abhaken und die Befunde der Phase festhalten; verifiziert durch die abgeschlossene Phase-4-Liste -- [ ] 7.4 `cargo test` grün und `openspec validate phase-4-steuerelemente --strict` ohne Befund; verifiziert durch beide Kommandos +- [x] 7.1 `docs/inventar.md`: Einträge dieses Changes auf `implementiert`; verifiziert durch `inventar_stimmt_mit_code_ueberein` mit Abdeckung 0 offen für Phase 4 +- [x] 7.2 Abweichungen (Dateisystem-Steuerelemente, festgelegte Optik, Z- und Timer-Reihenfolge) in `docs/sprachreferenz.md` eintragen; verifiziert durch den Abschnitt „Abweichungen" +- [x] 7.3 PLAN.md: die verbleibenden Phase-4-Punkte abhaken und die Befunde der Phase festhalten; verifiziert durch die abgeschlossene Phase-4-Liste +- [x] 7.4 `cargo test` grün und `openspec validate phase-4-steuerelemente --strict` ohne Befund; verifiziert durch beide Kommandos diff --git a/openspec/changes/archive/2026-09-05-phase-4-steuerelemente/vbdos-befunde.md b/openspec/changes/archive/2026-09-05-phase-4-steuerelemente/vbdos-befunde.md new file mode 100644 index 0000000..d8404ad --- /dev/null +++ b/openspec/changes/archive/2026-09-05-phase-4-steuerelemente/vbdos-befunde.md @@ -0,0 +1,44 @@ +# Befunde am öffentlichen VBDOS-Bestand + +Geprüft wurde `cout/vbdos` im Commit +`1cdd2b32b829fe1721d0b6aecc433abc47a96fb6`. Maßgeblich waren die +vorhandenen `.MAK`-Projekte; wo keines existiert, wurde die `.FRM` direkt +verwendet. Jeder Eintrag bestand `tbc check` ohne Diagnose. Die Programme +wurden anschließend in einem 100×30-Pseudoterminal gestartet und blieben nach +dem Ende des Modulrumpfs bedienbar, bis ein Steuerelement das Formular schloss +oder der Lauf bewusst abgebrochen wurde. + +| Programm | Check | Bediennachweis | +|---|---:|---| +| `graphics/graphics.mak` | 0 Diagnosen | Alt+X löst `cmdExit_Click` aus und entlädt das Formular | +| `microsoft/check.mak` | 0 Diagnosen | Alt+F öffnet das Menü; X erreicht den Exit-/Speicherdialogpfad | +| `microsoft/qlbview.mak` | 0 Diagnosen | Esc löst die Cancel-Schaltfläche aus und entlädt das Formular | +| `microsoft/seek.mak` | 0 Diagnosen | Alt+X löst `cmdExit_Click` aus und entlädt das Formular | +| `microsoft/spindemo.mak` | 0 Diagnosen | Tab und Pfeiltaste werden dem Text-/Spin-Steuerpfad zugestellt | +| `microsoft/notepad.frm` | 0 Diagnosen | Alt+F öffnet das Menü; X löst `mnuFileExit_Click` aus | +| `misc/mentors/mentors.frm` | 0 Diagnosen | Alt+F öffnet das Menü; X erreicht den Exit-/Speicherdialogpfad | + +## Geschlossene Befunde + +- `.MAK`-Einträge, `$INCLUDE` und `RUN` werden relativ und + DOS-großschreibungsunabhängig aufgelöst; mehrere Module und Formulare + werden als gemeinsames Programm übersetzt. +- Binärformular-P-Code behält Block- und Anweisungsgrenzen, numerische + Suffixe, Record-/UDT-Ausdrücke und Control-Arrays korrekt bei. +- Globale Arrays werden vor `Form_Load` initialisiert; `LEN(record)` liefert + die feste Satzbreite. `INPUT$` liest auch Binärdateien. +- `SCREEN.CONTROLPANEL(index)`, `CLIPBOARD` und `PRINTER` haben + Laufzeitverhalten; die von Notepad verwendeten Common-Dialog-Deklarationen + arbeiten über die vorhandenen Textdialoge. +- Die nativen Controls `VSpin` und `HSpin` werden als bedienbares `Spin` + importiert, einschließlich Wertgrenzen, Wiederholung und `Custom`-Ereignis. +- `SCREEN` 0–13 sowie die im Grafikbeispiel verwendeten Anweisungen `LINE`, + `PAINT` und `VIEW` zeichnen auf den Zellenpuffer. +- Modellose Formulare halten nach dem Modulrumpf die VM-Ereignisschleife + offen; Formularereignisse laufen auf derselben VM weiter, bis alle Formulare + geschlossen sind. Derselbe Pfad funktioniert im Terminal und mit + vorab eingelesener Pipe-Eingabe. + +Damit sind alle während dieses Kompatibilitätslaufs gefundenen +Implementierungslücken geschlossen; es verbleibt kein dokumentierter oder +umgangener Befund. diff --git a/openspec/specs/forms-steuerelemente/spec.md b/openspec/specs/forms-steuerelemente/spec.md new file mode 100644 index 0000000..9baa3f6 --- /dev/null +++ b/openspec/specs/forms-steuerelemente/spec.md @@ -0,0 +1,140 @@ +# forms-steuerelemente Specification + +## Purpose + +Die Steuerelemente machen ein Formular bedienbar: sie stellen sich im +Textbildschirm dar, nehmen Tastatur und Maus entgegen, führen Fokus und +Tabreihenfolge, tragen die Menüleiste und stellen die vordefinierten +Dialoge bereit. + +## Requirements + +### Requirement: Darstellung im Zellenpuffer +Steuerelemente SHALL sich im vorhandenen Textbildschirm darstellen; es +MUST NOT eine zweite Zeichenschicht neben ihm entstehen. Jede Klasse +SHALL die in der Forms-Referenz festgelegte Optik für die Zustände +normal, fokussiert und deaktiviert zeigen. Der CommandButton SHALL seine +Darstellung nach der Höhe wählen: eine Zeile als ``, zwei Zeilen +mit einzeiligem Rahmen, ab drei Zeilen als Kasten. Überlappen +Steuerelemente, SHALL die festgelegte Z-Reihenfolge entscheiden, welches +sichtbar ist. + +#### Scenario: Schaltfläche nach Höhe +- **WHEN** ein CommandButton mit `Height = 1` und `Caption = "OK"` gezeichnet wird +- **THEN** erscheint im Zellenpuffer `` + +#### Scenario: Deaktivierter Zustand +- **WHEN** ein Steuerelement `Enabled = 0` trägt +- **THEN** unterscheidet sich seine Darstellung sichtbar vom aktiven Zustand und es nimmt keinen Fokus an + +### Requirement: Fokus, Tabreihenfolge und Access-Keys +Der Fokus SHALL mit Tab in aufsteigender `TabIndex`-Folge und mit +Umschalt-Tab rückwärts wechseln; Elemente mit `TabStop = 0` oder +`Enabled = 0` MUST übersprungen werden. Ein `&` im Text SHALL den +folgenden Buchstaben zum Access-Key machen, der mit Alt das Element +auslöst oder ihm den Fokus gibt. Enter SHALL die `Default`-Schaltfläche +auslösen, Esc die `Cancel`-Schaltfläche. Fokuswechsel MUST `LostFocus` +am alten und `GotFocus` am neuen Element auslösen, in dieser Reihenfolge. + +#### Scenario: Tab überspringt +- **WHEN** das mittlere von drei Elementen `TabStop = 0` trägt und Tab gedrückt wird +- **THEN** erhält das dritte Element den Fokus + +#### Scenario: Access-Key +- **WHEN** eine Schaltfläche `Caption = "&OK"` trägt und Alt+O gedrückt wird +- **THEN** wird ihr `Click`-Ereignis ausgelöst + +#### Scenario: Reihenfolge der Fokusereignisse +- **WHEN** der Fokus von `Text1` auf `Text2` wechselt +- **THEN** läuft erst `Text1_LostFocus`, danach `Text2_GotFocus` + +### Requirement: Maussteuerung mit Trefferprüfung +Ein Mausereignis SHALL dem obersten Steuerelement an seiner Position +zugestellt werden; liegt dort keines, dem Formular. Klick, Doppelklick +sowie `MouseDown`, `MouseMove` und `MouseUp` SHALL mit Taste, +Umschaltzustand und Position in Zellen zugestellt werden. Bei +`DragMode = 1` SHALL das Ziehen automatisch beginnen; `DRAG action%` +SHALL es manuell beginnen, ablegen oder abbrechen, mit `DragOver` und +`DragDrop` am Ziel. + +#### Scenario: Treffer nach Z-Reihenfolge +- **WHEN** zwei Steuerelemente überlappen und in den gemeinsamen Bereich geklickt wird +- **THEN** erhält das obere das Ereignis + +#### Scenario: Klick ohne Steuerelement +- **WHEN** auf eine freie Stelle des Formulars geklickt wird +- **THEN** erhält das Formular das Ereignis + +#### Scenario: Ziehen und Ablegen +- **WHEN** ein Element mit `DragMode = 1` auf ein anderes gezogen und dort losgelassen wird +- **THEN** läuft am Ziel `DragOver` mit dem Zustand Over und danach `DragDrop` mit der Quelle als Argument + +### Requirement: Steuerelemente mit Listeninhalt +ListBox und ComboBox SHALL `ADDITEM` und `REMOVEITEM` unterstützen und +`List`, `ListCount`, `ListIndex` und `Text` konsistent führen; bei +`Sorted = -1` SHALL die Einfügereihenfolge der Sortierung folgen. +`ListIndex = -1` SHALL „keine Auswahl" bedeuten. Die ComboBox SHALL die +drei Stilarten (Dropdown, Simple, Dropdown List) darstellen. + +#### Scenario: Element hinzufügen +- **WHEN** `List1.ADDITEM "b"` und `List1.ADDITEM "a"` bei `Sorted = -1` ausgeführt werden +- **THEN** liefert `List1.List(0)` den Wert `a` und `List1.ListCount` den Wert 2 + +#### Scenario: Keine Auswahl +- **WHEN** eine ListBox ohne Auswahl gelesen wird +- **THEN** liefert `ListIndex` den Wert −1 + +### Requirement: Timer-Steuerelement +Ein Timer SHALL bei `Enabled = -1` und `Interval > 0` sein +`Timer`-Ereignis im eingestellten Abstand auslösen, gestützt auf die +Zeitquelle der Ereignissteuerung. `Interval = 0` SHALL ihn abschalten. +Sind mehrere Timer gleichzeitig fällig, SHALL die Reihenfolge festgelegt +und dokumentiert sein. + +#### Scenario: Timer feuert im Abstand +- **WHEN** ein Timer mit `Interval = 100` läuft und die Zeit um 250 ms vorrückt +- **THEN** ist sein Ereignis zweimal gelaufen + +#### Scenario: Interval 0 schaltet ab +- **WHEN** `Timer1.Interval = 0` gesetzt wird +- **THEN** läuft kein weiteres Ereignis + +### Requirement: Menüsystem +Ein Formular SHALL eine Menüleiste mit bis zu sechs Ebenen tragen. +Menüeinträge SHALL Access-Keys (`&`), Shortcuts, `Checked`, `Enabled`, +`Visible` und Separatoren (`-`) unterstützen; ein Separator MUST NOT +`Checked`, deaktiviert oder mit Shortcut versehen sein, ein Menütitel +MUST NOT einen Shortcut tragen. Solange ein Menü geöffnet ist, MUST die +Zustellung von Zeitereignissen und klassischen Traps ruhen und danach +fortgesetzt werden. + +#### Scenario: Menüauswahl löst Click aus +- **WHEN** ein Menüeintrag über seinen Access-Key gewählt wird +- **THEN** läuft seine `Click`-Prozedur + +#### Scenario: Traps ruhen im geöffneten Menü +- **WHEN** ein Menü geöffnet ist und ein Zeit-Trap fällig wird +- **THEN** läuft sein Handler erst, nachdem das Menü geschlossen wurde + +### Requirement: Vordefinierte Dialoge +`MSGBOX text$ [, typ% [, titel$]]` SHALL als Anweisung und als Funktion +verfügbar sein; die Funktion SHALL die gedrückte Schaltfläche als +INTEGER liefern (1 OK, 2 Cancel/Esc, 3 Abort, 4 Retry, 5 Ignore, 6 Yes, +7 No). `typ%` SHALL die Schaltflächengruppe (0–5) und die +Vorgabeschaltfläche (0/256/512) tragen. `INPUTBOX$(text$ [, titel$ +[, vorgabe$ [, x%, y%]]])` SHALL eine Zeichenkette liefern und bei +Abbruch den leeren String. Beide Dialoge SHALL modal sein; `INPUTBOX$` +SHALL 46×16 Zeichen messen und ohne Positionsangabe zentriert +erscheinen. + +#### Scenario: MSGBOX als Funktion +- **WHEN** `a% = MSGBOX("Weiter?", 4, "Frage")` ausgeführt und `Yes` gewählt wird +- **THEN** liefert der Aufruf 6 + +#### Scenario: INPUTBOX$ abgebrochen +- **WHEN** ein `INPUTBOX$`-Dialog mit Esc geschlossen wird +- **THEN** liefert er den leeren String + +#### Scenario: Dialog ist modal +- **WHEN** ein Dialog offen ist +- **THEN** wird die Anweisung nach dem Aufruf erst nach dem Schließen ausgeführt diff --git a/openspec/specs/kompat-testkorpus/spec.md b/openspec/specs/kompat-testkorpus/spec.md index a54e6b6..17ebdce 100644 --- a/openspec/specs/kompat-testkorpus/spec.md +++ b/openspec/specs/kompat-testkorpus/spec.md @@ -128,3 +128,30 @@ Harness den Lauf abweisen statt auf die Systemuhr auszuweichen. #### Scenario: Fehlende Zeitdeklaration - **WHEN** ein Korpusprogramm zeitgesteuerte Traps nutzt, aber keinen Zeitverlauf deklariert - **THEN** schlägt der Harness mit einer Meldung fehl, die die fehlende Deklaration nennt + +### Requirement: Korpusabdeckung der Formularprogramme +Der Testkorpus SHALL Formularprogramme mit byte-genauer +Bildschirm-Sollausgabe führen. Ihre Ereignisfolge (Tasten, Maus, Zeit) +SHALL im Programmkopf deklariert und vom Harness eingespeist werden, +sodass ein Lauf ohne Terminal und ohne Wartezeit auskommt und zweimal +dasselbe Ergebnis liefert. Abgedeckt SHALL sein: Fokus- und +Tabreihenfolge, Access-Key, Klick über die Maus, Menüauswahl, ein +Listen-Steuerelement, ein Timer und ein modaler Dialog. + +#### Scenario: Formularprogramm im Korpus +- **WHEN** ein Formular-Korpusprogramm mit deklarierter Ereignisfolge ausgeführt wird +- **THEN** entspricht der Bildschirminhalt byte-genau der Sollausgabe + +#### Scenario: Wiederholbarkeit +- **WHEN** dasselbe Programm zweimal ausgeführt wird +- **THEN** ist die Ausgabe beide Male identisch + +### Requirement: Kompatibilitätsnachweis an Fremdprogrammen +Die Formularprogramme aus dem öffentlichen Bestand des Vorbilds SHALL +sich ohne Diagnosen oder Non-Features übersetzen lassen und bedienbar +sein. Jeder entdeckte fehlende Sprach-, Projekt-, Formular- oder +Laufzeitpfad MUST implementiert und als Befund festgehalten werden. + +#### Scenario: Fremdprogramm übersetzt +- **WHEN** ein Formularprogramm des öffentlichen Bestands übersetzt wird +- **THEN** entstehen keine Diagnosen und das Programm ist bedienbar diff --git a/tests/compat/formular.frm b/tests/compat/formular.frm new file mode 100644 index 0000000..f6f57a0 --- /dev/null +++ b/tests/compat/formular.frm @@ -0,0 +1,97 @@ +VERSION 1.00 +Begin Form Formular + Caption = "Steuerelemente" + Height = 16 + Left = 0 + Top = 0 + Width = 46 + Begin Menu mnuDatei + Caption = "&Datei" + Begin Menu mnuOpen + Caption = "&Open" + End + End + Begin TextBox Text1 + Height = 3 + Left = 2 + TabIndex = 0 + Text = "Eingabe" + Top = 2 + Width = 18 + End + Begin CommandButton Command1 + Caption = "&OK" + Default = -1 + Height = 1 + Left = 23 + TabIndex = 1 + Top = 3 + Width = 10 + End + Begin ListBox List1 + Height = 5 + Left = 2 + Sorted = -1 + TabIndex = 2 + Top = 6 + Width = 18 + End + Begin Label Status + Caption = "bereit" + Height = 1 + Left = 23 + Top = 7 + Width = 18 + End + Begin Label TimerStatus + Caption = "Timer 0" + Height = 1 + Left = 23 + Top = 9 + Width = 18 + End + Begin Timer Timer1 + Enabled = -1 + Interval = 100 + End +End + +' tb-screen: 80x25 +' tb-events: key: | mouse:down,1,0,4,25 | mouse:up,1,0,4,25 | time:0ms | key: | key:%d | key:o | time:250ms +DIM SHARED Klicks%, Ticks% +Formular.Show +DOEVENTS +DOEVENTS +DOEVENTS +DOEVENTS +DOEVENTS +DOEVENTS +DOEVENTS +DOEVENTS +DOEVENTS +DOEVENTS +DOEVENTS +DOEVENTS +END + +SUB Form_Load() +List1.ADDITEM "zwei" +List1.ADDITEM "eins" +END SUB + +SUB Command1_Click() +SHARED Klicks% +Klicks% = Klicks% + 1 +r% = MSGBOX("Weiter?", 0, "Klick") +Status.Caption = "Dialog " + STR$(r%) +END SUB + +SUB mnuOpen_Click() +Status.Caption = "Menü" +END SUB + +SUB Timer1_Timer() +SHARED Ticks% +Ticks% = Ticks% + 1 +TimerStatus.Caption = "Timer " + STR$(Ticks%) +END SUB diff --git a/tests/compat/formular.out b/tests/compat/formular.out new file mode 100644 index 0000000..7e63925 --- /dev/null +++ b/tests/compat/formular.out @@ -0,0 +1,33 @@ +┌ Steuerelemente ────────────────────────────┐ +│ Datei │ +│ ┌────────────────┐ │ +│ │Eingabe │ │ +│ └────────────────┘ │ +│ │ +│ ┌────────────────┐ │ +│ │ eins │ Menü │ +│ │ zwei │ │ +│ │ │ Timer 2 │ +│ └────────────────┘ │ +│ │ +│ │ +│ │ +│ │ +└────────────────────────────────────────────┘ +--- Attribute (Vordergrund/Hintergrund je Zelle, hex) --- +70707070707070707070707070707070707070707070707070707070707070707070707070707070707070707070 +70707070707070707070707070707070707070707070707070707070707070707070707070707070707070707070 +70700707070707070707070707070707070707077070707070707070707070707070707070707070707070707070 +70700707070707070707070707070707070707077070700707070707070707070770707070707070707070707070 +70700707070707070707070707070707070707077070707070707070707070707070707070707070707070707070 +70707070707070707070707070707070707070707070707070707070707070707070707070707070707070707070 +70700707070707070707070707070707070707077070707070707070707070707070707070707070707070707070 +70700707070707070707070707070707070707077070700707070707070707070707070707070707077070707070 +70700707070707070707070707070707070707077070707070707070707070707070707070707070707070707070 +70700707070707070707070707070707070707077070700707070707070707070707070707070707077070707070 +70700707070707070707070707070707070707077070707070707070707070707070707070707070707070707070 +70707070707070707070707070707070707070707070707070707070707070707070707070707070707070707070 +70707070707070707070707070707070707070707070707070707070707070707070707070707070707070707070 +70707070707070707070707070707070707070707070707070707070707070707070707070707070707070707070 +70707070707070707070707070707070707070707070707070707070707070707070707070707070707070707070 +70707070707070707070707070707070707070707070707070707070707070707070707070707070707070707070