From e3dc9028bf5d66d9fe5e748d4f1a4683768f583f Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 6 Sep 2026 18:56:47 +0200 Subject: [PATCH] =?UTF-8?q?Phase=205:=20Ausf=C3=BChrung=20und=20Output=20i?= =?UTF-8?q?mplementieren=20und=20archivieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tb-cli/src/main.rs | 50 +- crates/tb-cli/tests/project.rs | 51 ++ crates/tb-ide/Cargo.toml | 2 +- crates/tb-ide/src/app.rs | 115 +++- crates/tb-ide/src/commands.rs | 3 +- crates/tb-ide/src/editor.rs | 14 +- crates/tb-ide/src/execution.rs | 327 +++++++++++ crates/tb-ide/src/lib.rs | 2 + crates/tb-ide/src/main.rs | 59 +- crates/tb-ide/src/render.rs | 8 +- crates/tb-ide/src/terminal.rs | 110 +--- crates/tb-ide/tests/app.rs | 15 +- crates/tb-ide/tests/execution.rs | 532 ++++++++++++++++++ crates/tb-runtime/src/builtins.rs | 35 +- crates/tb-runtime/src/host.rs | 18 + crates/tb-ui/src/forms.rs | 182 ++++-- crates/tb-ui/src/host.rs | 56 +- crates/tb-ui/src/lib.rs | 3 + crates/tb-ui/src/screen.rs | 13 +- crates/tb-ui/src/terminal.rs | 190 +++++++ crates/tb-vm/src/codegen.rs | 2 +- crates/tb-vm/src/interp.rs | 372 ++++++++---- crates/tb-vm/src/project_io.rs | 88 ++- crates/tb-vm/tests/cooperative.rs | 252 +++++++++ docs/ide-bedienung.md | 75 ++- docs/ide-referenz.md | 2 +- .../phase-5-uebersicht.md | 2 +- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 4 +- .../specs/ide-ausfuehrung/spec.md | 0 .../tasks.md | 32 +- .../verification.md | 79 +++ .../proposal.md | 2 +- .../proposal.md | 2 +- openspec/specs/ide-ausfuehrung/spec.md | 61 ++ tests/support/ide-execution-pty.py | 96 ++++ 37 files changed, 2404 insertions(+), 450 deletions(-) create mode 100644 crates/tb-ide/src/execution.rs create mode 100644 crates/tb-ide/tests/execution.rs create mode 100644 crates/tb-ui/src/terminal.rs create mode 100644 crates/tb-vm/tests/cooperative.rs rename openspec/changes/{phase-5-04-ausfuehrung-und-output => archive/2026-09-06-phase-5-04-ausfuehrung-und-output}/.openspec.yaml (100%) rename openspec/changes/{phase-5-04-ausfuehrung-und-output => archive/2026-09-06-phase-5-04-ausfuehrung-und-output}/design.md (100%) rename openspec/changes/{phase-5-04-ausfuehrung-und-output => archive/2026-09-06-phase-5-04-ausfuehrung-und-output}/proposal.md (72%) rename openspec/changes/{phase-5-04-ausfuehrung-und-output => archive/2026-09-06-phase-5-04-ausfuehrung-und-output}/specs/ide-ausfuehrung/spec.md (100%) rename openspec/changes/{phase-5-04-ausfuehrung-und-output => archive/2026-09-06-phase-5-04-ausfuehrung-und-output}/tasks.md (69%) create mode 100644 openspec/changes/archive/2026-09-06-phase-5-04-ausfuehrung-und-output/verification.md create mode 100644 openspec/specs/ide-ausfuehrung/spec.md create mode 100644 tests/support/ide-execution-pty.py diff --git a/crates/tb-cli/src/main.rs b/crates/tb-cli/src/main.rs index 9590a1f..6befeb6 100644 --- a/crates/tb-cli/src/main.rs +++ b/crates/tb-cli/src/main.rs @@ -15,7 +15,7 @@ use std::process::ExitCode; use tb_runtime::host::{Ereignis, Host}; use tb_ui::host::TerminalHost; use tb_vm::interp::{RunEvent, Vm}; -use tb_vm::project_io::{module_name, relative_case_insensitive, SourceLoader}; +use tb_vm::project_io::{module_name, new_execution, run_target, SourceLoader}; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); @@ -105,12 +105,7 @@ fn compile( return Err(ExitCode::from(1)); } }; - let mut catalog = tb_frontend::forms::FormCatalog::default(); - for form in &input.forms { - catalog.append(&form.catalog()); - } - let compiled = - tb_vm::compile_project(&module_name(&path), &input.units, &catalog, &input.forms); + let compiled = input.compile(&mut Default::default(), &module_name(&path)); match compiled { Ok(m) => Ok((path, m)), Err(diags) => { @@ -204,20 +199,6 @@ fn cmd_run(args: &[String]) -> ExitCode { } } -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", "tbc"] { - 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, @@ -233,27 +214,20 @@ fn run_chain( loop { let current_arg = current.display().to_string(); let (path, module) = compile(Some(¤t_arg))?; - let mut vm = Vm::new(module); - 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); + let mut vm = new_execution(module, &command, size, start_line.take()).map_err(|error| { + eprintln!("Runtime error {}: {}", error.0, error); + ExitCode::from(2) + })?; if !vm.rt.zeitpunkt().1 { 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 - }; + let event = + if event == RunEvent::Ended && vm.forms.has_visible_forms() && !vm.is_terminated() { + vm.run_visible_forms(host) + } else { + event + }; match event { RunEvent::Restart { program, line } => { if let Some(program) = program { diff --git a/crates/tb-cli/tests/project.rs b/crates/tb-cli/tests/project.rs index d77eefc..f51fc08 100644 --- a/crates/tb-cli/tests/project.rs +++ b/crates/tb-cli/tests/project.rs @@ -183,3 +183,54 @@ fn erlaubte_grafik_und_benannte_nonfeatures_werden_per_cli_geprueft() { assert!(out.status.success(), "{out:?}"); std::fs::remove_dir_all(dir).unwrap(); } + +#[test] +fn explicit_startup_order_and_form_selection_survive_tbc() { + let dir = std::env::temp_dir().join(format!("tb-startup-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a.bas"), "PRINT \"A\"\n").unwrap(); + std::fs::write(dir.join("b.bas"), "PRINT \"B\"\n").unwrap(); + for (file, name) in [("one.frm", "One"), ("two.frm", "Two")] { + std::fs::write( + dir.join(file), + format!("VERSION 1.00\nBEGIN Form {name}\nEND\n"), + ) + .unwrap(); + } + for startup in [None, Some("b.bas"), Some("two.frm")] { + std::fs::write( + dir.join("p.mak"), + format!( + "a.bas\nb.bas\none.frm\ntwo.frm\n{}", + startup + .map(|s| format!("' $STARTUP: \"{s}\"\n")) + .unwrap_or_default() + ), + ) + .unwrap(); + let out = tbc(&dir, "build", "p.mak"); + assert!(out.status.success(), "{out:?}"); + let m = + tb_vm::bytecode::CompiledModule::from_tbc(&std::fs::read(dir.join("p.tbc")).unwrap()) + .unwrap(); + let chosen = m + .startup_form + .map(|i| m.objects[i as usize].name.to_ascii_uppercase()); + assert_eq!( + chosen.as_deref(), + match startup { + None => Some("ONE"), + Some("two.frm") => Some("TWO"), + _ => None, + } + ); + if startup == Some("b.bas") { + let direct = tbc(&dir, "run", "p.mak"); + let serialized = tbc(&dir, "run", "p.tbc"); + assert!(direct.status.success() && serialized.status.success()); + assert_eq!(direct.stdout, b"B\nA\n"); + assert_eq!(direct.stdout, serialized.stdout); + } + } + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/tb-ide/Cargo.toml b/crates/tb-ide/Cargo.toml index 7f70e9e..3a69fb0 100644 --- a/crates/tb-ide/Cargo.toml +++ b/crates/tb-ide/Cargo.toml @@ -14,7 +14,7 @@ path = "src/main.rs" tb-frontend.workspace = true tb-vm.workspace = true tb-runtime.workspace = true -tb-ui.workspace = true +tb-ui = { workspace = true, features = ["terminal"] } ratatui.workspace = true crossterm.workspace = true anyhow.workspace = true diff --git a/crates/tb-ide/src/app.rs b/crates/tb-ide/src/app.rs index 54e6a64..54e2444 100644 --- a/crates/tb-ide/src/app.rs +++ b/crates/tb-ide/src/app.rs @@ -21,8 +21,12 @@ pub enum Mode { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Execution { Idle, + Compiling, Paused, Running, + Waiting, + Ended, + Error, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WindowKind { @@ -213,6 +217,9 @@ pub enum DialogKind { Dirty(AfterSave), LoadText, SaveText, + Print, + CommandLine, + ResumeRevision, Display, Paths, RightMouse, @@ -256,6 +263,7 @@ pub enum Hit { } pub struct App { + pub session: crate::execution::Session, pub editor: crate::editor::Editor, pub project: Project, pub options: Options, @@ -293,6 +301,7 @@ impl App { let (options, errors, config_disk) = Options::load(&config_path); project.include_paths = options.include_paths.clone(); let mut app = Self { + session: Default::default(), editor: Default::default(), project, options: options.clone(), @@ -423,6 +432,7 @@ impl App { ) .trim_end() .to_string(), + WindowKind::Output if self.session.old_revision => "Output · ALTES KOMPILAT".into(), _ => format!("{:?}", w.kind), } } @@ -452,6 +462,9 @@ impl App { return Some(format!("Fachfunktion folgt in Phase-5-Change {phase:02}")); } use Command::*; + if command == Shell && self.session.host.shell_request.is_some() { + return Some("Shell-Übergabe bereits angefordert".into()); + } if matches!( command, Undo | Cut | Paste | Clear | LoadText | NewSub | NewFunction | Replace @@ -467,6 +480,7 @@ impl App { if matches!( command, LoadText + | Print | SaveText | Cut | Copy @@ -532,6 +546,40 @@ impl App { use Command::*; let id = self.active_document(); match command { + Start => self.start_execution()?, + Restart => self.restart_target()?, + Continue => self.continue_execution()?, + Pause => self.pause_execution(), + OutputScreen => { + self.session.fullscreen = !self.session.fullscreen; + } + CommandLine => self.open_dialog( + "COMMAND$", + DialogKind::CommandLine, + vec![Field::text( + "Argumente für den nächsten Start", + &self.session.command, + )], + ), + Shell => { + self.session.file_shell = true; + self.session.host.shell_request = Some(String::new()); + } + Print => self.open_dialog( + "Print", + DialogKind::Print, + vec![ + Field::text("UTF-8-Ausgabedatei", self.output_default("LPT1.TXT")), + Field::toggle("Bestehendes Ziel überschreiben", false), + Field::toggle( + "Nur Auswahl", + self.project + .view(self.editor_view()?)? + .selection() + .is_some(), + ), + ], + ), Cut | Copy | Paste | Clear | NewSub | NewFunction | IncludedFile | IncludedLines | Find | SelectedText | FindNext | Replace | Procedures | PreviousCode | Diagnostics => self.editor_command(command)?, @@ -706,6 +754,9 @@ impl App { _ => WindowKind::Project, }; self.show_tool(kind); + if command == Output { + self.session.fullscreen = false; + } } MenuBar => { self.properties = !self.properties; @@ -856,7 +907,7 @@ impl App { let offset = (self.windows.len() % 5) as u16 * 2; clamp(Rect::new(offset, 1 + offset, 60, 16), self.area()) } - fn show_tool(&mut self, kind: WindowKind) { + pub(crate) fn show_tool(&mut self, kind: WindowKind) { if let Some(w) = self.windows.iter_mut().find(|w| w.kind == kind) { self.active = w.id; w.state = WindowState::Normal; @@ -932,6 +983,8 @@ impl App { match after { AfterSave::Stay => return Ok(()), AfterSave::Exit => { + self.session.finish(); + self.basic_events.clear(); self.quit = true; return Ok(()); } @@ -943,6 +996,9 @@ impl App { self.project.open_project(&path, Decision::Discard)?; } } + self.session = Default::default(); + self.basic_events.clear(); + self.execution = Execution::Idle; self.base = self.project.directory().to_path_buf(); self.editor = Default::default(); self.windows.clear(); @@ -1141,11 +1197,25 @@ impl App { let text = std::fs::read_to_string(self.base.join(d.fields[0].string()))?; self.editor_insert(&text)?; } - DialogKind::SaveText => { + DialogKind::CommandLine => { + self.session.command = d.fields[0].string(); + } + DialogKind::ResumeRevision => { + if d.fields[0].index() == 0 { + self.restart_target()?; + } else { + self.resume_execution(true); + } + } + DialogKind::SaveText | DialogKind::Print => { let (id, _) = self.code_cursor()?; self.project.save_text( id, - self.project.view(self.editor_view()?)?.selection(), + if matches!(d.kind, DialogKind::Print) && !d.fields[2].flag() { + None + } else { + self.project.view(self.editor_view()?)?.selection() + }, &Destination { path: d.fields[0].string().into(), overwrite: d.fields[1].flag(), @@ -1255,8 +1325,20 @@ impl App { self.line_leave(old); } fn handle_event(&mut self, event: Event) { + if matches!(event, Event::Key(k) if k.kind != KeyEventKind::Release && k.code == K::Pause && k.modifiers.contains(M::CONTROL)) + { + self.pause_execution(); + return; + } if let Event::Resize(w, h) = event { self.size = (w, h); + if let Some(vm) = self.session.vm.as_mut() { + vm.rt.ereignis(tb_runtime::host::Ereignis::Groesse { + cols: w as usize, + rows: h as usize, + }); + vm.forms.resize(w as usize, h as usize); + } return; } if self.size.0 < 80 || self.size.1 < 25 { @@ -1269,6 +1351,10 @@ impl App { self.key(key); } else if let Event::Mouse(mouse) = event { if self.dialog.is_none() && self.menu.is_none() && self.program_focus() { + if self.session.fullscreen { + self.basic_events.push(Event::Mouse(mouse)); + return; + } if let Some(w) = self.active_window() { let r = self.rect(w); let inner = Rect::new( @@ -1278,6 +1364,9 @@ impl App { r.height.saturating_sub(2), ); if inner.contains((mouse.column, mouse.row).into()) { + let mut mouse = mouse; + mouse.column -= inner.x; + mouse.row -= inner.y; self.basic_events.push(Event::Mouse(mouse)); return; } @@ -1399,13 +1488,18 @@ impl App { } } fn program_focus(&self) -> bool { - self.execution == Execution::Running - && matches!( - self.active_window().map(|w| w.kind), - Some(WindowKind::Output) - ) + matches!(self.execution, Execution::Running | Execution::Waiting) + && (self.session.fullscreen + || matches!( + self.active_window().map(|w| w.kind), + Some(WindowKind::Output) + )) } fn key(&mut self, key: KeyEvent) { + if self.session.fullscreen && self.dialog.is_none() && key.code == K::F(4) { + self.execute(Command::OutputScreen); + return; + } if self.editor.chord.is_some() && self.dialog.is_none() && self.menu.is_none() @@ -1586,7 +1680,7 @@ impl App { return; } if self.program_focus() && key.modifiers.contains(M::CONTROL) && key.code == K::Char('c') { - self.basic_events.push(Event::Key(key)); + self.pause_execution(); return; } if let Some(command) = shortcut(key) { @@ -1634,7 +1728,8 @@ impl App { DialogKind::OpenProject | DialogKind::AddFile | DialogKind::LoadText - | DialogKind::SaveText => index == 0, + | DialogKind::SaveText + | DialogKind::Print => index == 0, DialogKind::Save { .. } => index % 2 == 0, DialogKind::Export(_) => index == 3, _ => false, diff --git a/crates/tb-ide/src/commands.rs b/crates/tb-ide/src/commands.rs index 4cc59ea..eafa531 100644 --- a/crates/tb-ide/src/commands.rs +++ b/crates/tb-ide/src/commands.rs @@ -41,6 +41,7 @@ pub enum Command { Start, Restart, Continue, + Pause, CommandLine, MakeExe, MakeLibrary, @@ -100,7 +101,6 @@ impl Command { pub fn feature_phase(self) -> Option { use Command::*; match self { - Print | Shell | Start | Restart | Continue | CommandLine | OutputScreen => Some(4), Events | Grid | Palette | MenuDesign | Toolbox | Tool(_) => Some(5), NextStatement | AddWatch | InstantWatch | Watchpoint | DeleteWatch | DeleteWatches | Trace | History | Breakpoint | ClearBreakpoints | BreakErrors | SetStatement @@ -251,6 +251,7 @@ pub fn menus(designer: bool) -> Vec { item("&Start", Start), item("&Restart", Restart), item("&Continue", Continue), + item("&Pause (Ctrl+Break)", Pause), item("Modify COMMAND&$…", CommandLine), sep(), item("Make &EXE File…", MakeExe), diff --git a/crates/tb-ide/src/editor.rs b/crates/tb-ide/src/editor.rs index b904f49..e8c300f 100644 --- a/crates/tb-ide/src/editor.rs +++ b/crates/tb-ide/src/editor.rs @@ -27,6 +27,8 @@ pub struct Search { pub document: Option, pub wrap: bool, } +pub(crate) type Revision = (ProjectStamp, Vec, Vec); + #[derive(Default)] pub struct Editor { pub clipboard: String, @@ -37,7 +39,7 @@ pub struct Editor { pub checked: BTreeMap, pub compiler: ProjectCompiler, pub diagnostics: Vec, - revision: Option<(ProjectStamp, Vec, Vec)>, + pub(crate) revision: Option, compiled: Option, } @@ -789,20 +791,12 @@ impl App { self.editor.revision = None; self.editor.diagnostics.clear(); let sources = self.project.sources()?; - let mut catalog = tb_frontend::forms::FormCatalog::default(); - for f in &sources.forms { - catalog.append(&f.catalog()); - } self.editor.revision = Some(( ProjectStamp::capture(&self.project), sources.units.clone(), sources.forms.iter().map(tb_ui::frm::write_text).collect(), )); - match self - .editor - .compiler - .compile("IDE", &sources.units, &catalog, &sources.forms) - { + match sources.compile(&mut self.editor.compiler, "IDE") { Ok(code) => { self.editor.compiled = Some(code); Ok(self.editor.compiled.as_ref().unwrap()) diff --git a/crates/tb-ide/src/execution.rs b/crates/tb-ide/src/execution.rs new file mode 100644 index 0000000..99f030d --- /dev/null +++ b/crates/tb-ide/src/execution.rs @@ -0,0 +1,327 @@ +//! Cooperative session owned by the IDE event loop, on the VM's thread. +use crate::app::{App, DialogKind, Execution, Field, WindowKind}; +use anyhow::{anyhow, Result}; +use crossterm::event::Event; +use std::{collections::VecDeque, path::PathBuf}; +use tb_runtime::{ + errors::RuntimeError, + host::{Ereignis, Host}, + screen::TextScreen, +}; +use tb_vm::{ + interp::{PollResult, RunEvent, Vm}, + project_io::{load_program, new_execution, run_target}, +}; + +#[derive(Default)] +pub struct IdeHost { + pub events: VecDeque, + pub now: u64, + pub shell_request: Option, + pub shell_result: Option>, +} +impl Host for IdeHost { + fn present(&mut self, _: &TextScreen) {} + fn next_event(&mut self, _: bool) -> Option { + self.events.pop_front() + } + fn warten(&mut self, _: Option) -> Option { + panic!("IDE poll must never block") + } + fn jetzt_ms(&mut self) -> u64 { + self.now + } + fn shell(&mut self, command: &str) -> Result, RuntimeError> { + if let Some(result) = self.shell_result.take() { + result.map(Some) + } else { + self.shell_request.get_or_insert_with(|| command.into()); + Ok(None) + } + } +} +pub struct Session { + pub vm: Option, + pub host: IdeHost, + pub command: String, + pub target: Option, + pub project_target: bool, + pub old_revision: bool, + pub fullscreen: bool, + pub forms_phase: bool, + pub file_shell: bool, + revision: Vec, + source_revision: Option, + output: TextScreen, +} +impl Default for Session { + fn default() -> Self { + Self { + vm: None, + host: IdeHost::default(), + command: String::new(), + target: None, + project_target: true, + old_revision: false, + fullscreen: false, + forms_phase: false, + file_shell: false, + revision: Vec::new(), + source_revision: None, + output: TextScreen::default(), + } + } +} +impl Session { + pub fn screen(&self) -> &TextScreen { + self.vm.as_ref().map_or(&self.output, |vm| &vm.rt.screen) + } + pub fn finish(&mut self) { + if let Some(mut vm) = self.vm.take() { + self.output = std::mem::take(&mut vm.rt.screen); + } + self.host.events.clear(); + self.host.shell_request = None; + self.host.shell_result = None; + self.file_shell = false; + } +} +impl App { + pub fn start_execution(&mut self) -> Result<()> { + self.reset_execution(None, None, true) + } + fn reset_execution( + &mut self, + target: Option, + line: Option, + project: bool, + ) -> Result<()> { + self.execution = Execution::Compiling; + let result = (|| { + let module = if project { + self.compile_current()?.clone() + } else { + load_program( + target + .as_deref() + .ok_or_else(|| anyhow!("Kein Ausführungsziel"))?, + ) + .map_err(|e| anyhow!(e))? + }; + let revision = module.to_tbc(); + let vm = new_execution( + module, + &self.session.command, + Some((self.size.0 as usize, self.size.1 as usize)), + line, + )?; + let target = if project { + self.project.path().map(PathBuf::from).or_else(|| { + self.project + .members() + .first() + .and_then(|id| self.project.document(*id).ok()) + .map(|d| d.source_path().to_path_buf()) + }) + } else { + target + }; + self.session.finish(); + self.basic_events.clear(); + self.session.vm = Some(vm); + self.session.target = target; + self.session.project_target = project; + self.session.revision = revision; + self.session.source_revision = if project { + self.editor.revision.clone() + } else { + None + }; + self.session.old_revision = false; + self.session.forms_phase = false; + self.execution = Execution::Running; + self.show_tool(WindowKind::Output); + self.message = "Running".into(); + Ok(()) + })(); + if let Err(e) = &result { + self.session.finish(); + self.execution = Execution::Error; + self.message = format!("{e:#}"); + } + result + } + pub fn pause_execution(&mut self) { + if matches!(self.execution, Execution::Running | Execution::Waiting) { + self.execution = Execution::Paused; + self.editor.chord = None; + self.message = self.session.vm.as_ref().map_or_else( + || "Paused".into(), + |vm| { + format!( + "Paused · {}:{} · F5: Continue", + vm.current_file(), + vm.current_line() + ) + }, + ); + } + } + pub fn continue_execution(&mut self) -> Result<()> { + if matches!(self.execution, Execution::Running | Execution::Waiting) { + return Ok(()); + } + if self.execution != Execution::Paused { + return self.start_execution(); + } + let current = if self.session.project_target { + self.compile_current().map(|m| m.to_tbc()) + } else { + self.session + .target + .as_deref() + .ok_or_else(|| anyhow!("Kein Ausführungsziel")) + .and_then(|p| load_program(p).map_err(|e| anyhow!(e))) + .map(|m| m.to_tbc()) + }; + if current + .as_ref() + .is_ok_and(|bytes| *bytes == self.session.revision) + && (!self.session.project_target + || self.editor.revision == self.session.source_revision) + { + self.resume_execution(false); + } else { + self.open_dialog( + "Quellstand geändert", + DialogKind::ResumeRevision, + vec![Field::choice( + "Fortsetzen", + vec![ + "Neu starten (aktueller Quellstand)".into(), + "Altes Kompilat ausdrücklich fortsetzen".into(), + ], + 0, + )], + ); + } + Ok(()) + } + pub fn resume_execution(&mut self, old: bool) { + if let Some(vm) = self.session.vm.as_mut() { + vm.rt.abbruch = false; + self.session.old_revision = old; + self.execution = Execution::Running; + self.message = if old { + "Running · ALTES KOMPILAT" + } else { + "Running" + } + .into(); + } + } + pub fn restart_target(&mut self) -> Result<()> { + self.reset_execution( + self.session.target.clone(), + None, + self.session.project_target, + ) + } + /// One bounded slice. Tests supply virtual milliseconds, main uses Instant. + pub fn tick_execution(&mut self, now: u64) { + self.session.host.now = now; + if !matches!(self.execution, Execution::Running | Execution::Waiting) { + return; + } + for event in std::mem::take(&mut self.basic_events) { + match event { + Event::Key(k) => { + if let Some(e) = tb_ui::host::taste_zu_ereignis(k) { + self.session.host.events.push_back(e); + } + } + Event::Mouse(m) => { + if let Some(e) = tb_ui::host::maus_zu_ereignis( + m, + (self.size.0 as usize, self.size.1 as usize), + ) { + self.session.host.events.push_back(e); + } + } + Event::Paste(s) => { + self.session.host.events.extend(s.chars().map(|c| { + Ereignis::Taste( + if c == '\n' { + "\r".into() + } else { + c.to_string() + }, + 0, + ) + })); + } + _ => {} + } + } + let Some(vm) = self.session.vm.as_mut() else { + return; + }; + vm.rt.pump(&mut self.session.host, false); + let result = if self.session.forms_phase { + vm.poll_visible_forms(&mut self.session.host, 4096) + } else { + vm.poll(&mut self.session.host, 4096) + }; + match result { + PollResult::Yield => self.execution = Execution::Running, + PollResult::Waiting { .. } => self.execution = Execution::Waiting, + PollResult::Event(event) => match event { + RunEvent::Ended + if !self.session.forms_phase + && vm.forms.has_visible_forms() + && !vm.is_terminated() => + { + self.session.forms_phase = true; + self.execution = Execution::Waiting; + } + RunEvent::Ended => { + self.session.finish(); + self.execution = Execution::Ended; + self.message = "Ended".into(); + } + RunEvent::Stopped { .. } + | RunEvent::Interrupted { .. } + | RunEvent::Breakpoint { .. } + | RunEvent::Stepped { .. } => self.pause_execution(), + RunEvent::Error { + code, + line, + message, + } => { + self.session.finish(); + self.execution = Execution::Error; + self.message = format!("Runtime error {code} in Zeile {line}: {message}"); + } + RunEvent::Restart { program, line } => { + let target = program + .as_deref() + .map(|p| run_target(self.session.target.as_deref().unwrap(), p)) + .transpose(); + match target { + Ok(target) => { + let project = self.session.project_target + && (target.is_none() || target == self.session.target); + let target = target.or_else(|| self.session.target.clone()); + let _ = self.reset_execution(target, line, project); + } + Err(error) => { + self.session.finish(); + self.execution = Execution::Error; + self.message = error; + } + } + } + }, + } + } +} diff --git a/crates/tb-ide/src/lib.rs b/crates/tb-ide/src/lib.rs index ab88162..a5f2783 100644 --- a/crates/tb-ide/src/lib.rs +++ b/crates/tb-ide/src/lib.rs @@ -8,3 +8,5 @@ pub mod render; pub mod terminal; pub mod editor; + +pub mod execution; diff --git a/crates/tb-ide/src/main.rs b/crates/tb-ide/src/main.rs index 47bceb0..a18b680 100644 --- a/crates/tb-ide/src/main.rs +++ b/crates/tb-ide/src/main.rs @@ -5,7 +5,11 @@ use std::{ io::{self, IsTerminal}, path::PathBuf, }; -use tb_ide::{app::App, options, terminal::TerminalGuard}; +use tb_ide::{ + app::{App, Execution}, + options, + terminal::TerminalGuard, +}; fn main() -> Result<()> { let args: Vec<_> = std::env::args().skip(1).collect(); @@ -23,11 +27,58 @@ fn main() -> Result<()> { if let Some(path) = args.first() { app.load_initial_project(PathBuf::from(path))?; } - let _guard = TerminalGuard::enter(io::stdout())?; + let mut guard = TerminalGuard::enter(io::stdout())?; let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + let start = std::time::Instant::now(); + let mut last_draw = start - std::time::Duration::from_millis(16); + let signals = tb_ui::signale::Signalquelle::neu(); while !app.quit { - terminal.draw(|f| app.render(f))?; - app.handle(event::read()?); + if signals.abholen().is_some() { + app.pause_execution(); + } + let shell = if app.session.file_shell + || matches!(app.execution, Execution::Running | Execution::Waiting) + { + app.session.host.shell_request.take() + } else { + None + }; + if let Some(command) = shell { + let result = guard.shell(&command, &signals); + terminal.clear()?; + let (cols, rows) = crossterm::terminal::size()?; + app.handle(event::Event::Resize(cols, rows)); + if app.session.file_shell { + app.session.file_shell = false; + app.message = match result { + Ok(code) => format!("Shell beendet: {code}"), + Err(e) => format!("Shell: {e}"), + }; + } else { + app.session.host.shell_result = + Some(result.map_err(|_| tb_runtime::errors::RuntimeError(53))); + } + } + if last_draw.elapsed() >= std::time::Duration::from_millis(16) { + terminal.draw(|f| app.render(f))?; + last_draw = std::time::Instant::now(); + } + // Bounded input batch: both keyboard and VM make progress under load. + for _ in 0..64 { + if !event::poll(std::time::Duration::ZERO)? { + break; + } + app.handle(event::read()?); + } + app.tick_execution(start.elapsed().as_millis() as u64); + let timeout = if app.execution == Execution::Running { + std::time::Duration::ZERO + } else { + std::time::Duration::from_millis(10) + }; + if event::poll(timeout)? { + app.handle(event::read()?); + } } Ok(()) } diff --git a/crates/tb-ide/src/render.rs b/crates/tb-ide/src/render.rs index 50ab363..a2ec874 100644 --- a/crates/tb-ide/src/render.rs +++ b/crates/tb-ide/src/render.rs @@ -76,6 +76,10 @@ impl App { ); return; } + if self.session.fullscreen && self.dialog.is_none() && self.menu.is_none() { + f.render_widget(tb_ui::screen::ScreenWidget(self.session.screen()), area); + return; + } let fill = self.options.desktop.to_string().repeat(area.width as usize); for row in 0..area.height { put( @@ -330,11 +334,13 @@ impl App { self.hits.push((r, Hit::ProjectMember(row))); } } + WindowKind::Output => { + f.render_widget(tb_ui::screen::ScreenWidget(self.session.screen()), inner) + } kind => put( f, inner, match kind { - WindowKind::Output => "Output · Ausführung folgt in Change 04", WindowKind::Help => "Help · Inhalte folgen in Change 07", _ => "Debugger-Inhalte folgen in Change 06", }, diff --git a/crates/tb-ide/src/terminal.rs b/crates/tb-ide/src/terminal.rs index 27b9414..00125c8 100644 --- a/crates/tb-ide/src/terminal.rs +++ b/crates/tb-ide/src/terminal.rs @@ -1,109 +1 @@ -use crossterm::{ - cursor::{Hide, Show}, - event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture}, - execute, - style::ResetColor, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, -}; -use std::io::{self, Write}; - -/// Ein Besitzer für Raw Mode und Terminalzustand, auch bei teilweiser Initialisierung. -pub struct TerminalGuard { - writer: W, - raw: fn(bool) -> io::Result<()>, -} -impl TerminalGuard { - pub fn enter(writer: W) -> io::Result { - Self::with_raw(writer, |on| { - if on { - enable_raw_mode() - } else { - disable_raw_mode() - } - }) - } - fn with_raw(writer: W, raw: fn(bool) -> io::Result<()>) -> io::Result { - let mut guard = Self { writer, raw }; - (guard.raw)(true)?; - execute!( - guard.writer, - EnterAlternateScreen, - EnableMouseCapture, - EnableBracketedPaste, - Hide - )?; - Ok(guard) - } -} -impl Drop for TerminalGuard { - fn drop(&mut self) { - let _ = (self.raw)(false); - let _ = execute!( - self.writer, - ResetColor, - Show, - DisableMouseCapture, - DisableBracketedPaste, - LeaveAlternateScreen - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::{ - cell::RefCell, - rc::Rc, - sync::atomic::{AtomicI32, Ordering}, - }; - static RAW: AtomicI32 = AtomicI32::new(0); - #[derive(Clone)] - struct Output(Rc>>); - impl Write for Output { - fn write(&mut self, b: &[u8]) -> io::Result { - self.0.borrow_mut().extend_from_slice(b); - Ok(b.len()) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - fn raw(on: bool) -> io::Result<()> { - RAW.store(i32::from(on), Ordering::SeqCst); - Ok(()) - } - #[test] - fn terminal_cleanup_on_normal_error_and_partial_initialization() { - for fail in [false, true] { - let out = Output(Rc::default()); - let copy = out.clone(); - let result = (|| -> io::Result<()> { - let _guard = TerminalGuard::with_raw(out, raw)?; - if fail { - return Err(io::Error::other("Renderfehler")); - } - Ok(()) - })(); - assert_eq!(result.is_err(), fail); - assert_eq!(RAW.load(Ordering::SeqCst), 0); - let s = String::from_utf8(copy.0.borrow().clone()).unwrap(); - assert!(s.contains("?1049h") && s.contains("?1049l") && s.contains("?25h")); - } - let out = Output(Rc::default()); - let copy = out.clone(); - assert!(TerminalGuard::with_raw(out, |on| { - raw(on)?; - if on { - Err(io::Error::other("Raw-Fehler")) - } else { - Ok(()) - } - }) - .is_err()); - assert_eq!(RAW.load(Ordering::SeqCst), 0); - assert!(String::from_utf8(copy.0.borrow().clone()) - .unwrap() - .contains("?25h")); - } -} +pub use tb_ui::terminal::TerminalGuard; diff --git a/crates/tb-ide/tests/app.rs b/crates/tb-ide/tests/app.rs index 236c0de..711b719 100644 --- a/crates/tb-ide/tests/app.rs +++ b/crates/tb-ide/tests/app.rs @@ -530,10 +530,14 @@ fn mouse_focus_is_modal_and_program_abort_is_not_editor_copy() { menu(&mut app, Command::Output); app.execution = Execution::Running; key(&mut app, K::Char('c'), M::CONTROL); - assert_eq!( - app.basic_events, - vec![Event::Key(KeyEvent::new(K::Char('c'), M::CONTROL))] + assert_eq!(app.execution, Execution::Paused); + assert!(app.basic_events.is_empty()); + plain(&mut app, K::Char('x')); + assert!( + app.basic_events.is_empty(), + "Tasten nach Break gehören nicht mehr BASIC" ); + app.execution = Execution::Running; let r = app.rect(app.active_window().unwrap()); let mouse = MouseEvent { kind: MouseEventKind::Down(MouseButton::Right), @@ -542,7 +546,10 @@ fn mouse_focus_is_modal_and_program_abort_is_not_editor_copy() { modifiers: M::NONE, }; app.handle(Event::Mouse(mouse)); - assert_eq!(app.basic_events.last(), Some(&Event::Mouse(mouse))); + let mut local = mouse; + local.column = 1; + local.row = 1; + assert_eq!(app.basic_events.last(), Some(&Event::Mouse(local))); app.execution = Execution::Idle; menu(&mut app, Command::RightMouse); plain(&mut app, K::Char(' ')); diff --git a/crates/tb-ide/tests/execution.rs b/crates/tb-ide/tests/execution.rs new file mode 100644 index 0000000..237ac71 --- /dev/null +++ b/crates/tb-ide/tests/execution.rs @@ -0,0 +1,532 @@ +use crossterm::event::{Event, KeyCode as K, KeyEvent, KeyModifiers as M}; +use std::{ + fs, + path::PathBuf, + sync::atomic::{AtomicUsize, Ordering}, +}; +use tb_ide::{ + app::{App, DialogKind, Execution, Field, WindowKind}, + commands::Command, +}; +use tb_runtime::{snapshot, value::Value}; +struct Temp(PathBuf); +impl Temp { + fn new() -> Self { + static N: AtomicUsize = AtomicUsize::new(0); + let p = std::env::temp_dir().join(format!( + "tb-execution-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&p).unwrap(); + Self(p.canonicalize().unwrap()) + } + fn app(&self, source: &str) -> App { + let path = self.0.join("main.bas"); + fs::write(&path, source).unwrap(); + let mut app = App::new(&self.0, self.0.join("options"), (100, 30)).unwrap(); + app.load_initial_project(path).unwrap(); + app.options.syntax_checking = false; + app + } +} +impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} +fn key(a: &mut App, k: K, m: M) { + a.handle(Event::Key(KeyEvent::new(k, m))); +} +fn tick(a: &mut App) { + for _ in 0..20 { + a.tick_execution(0); + } +} +fn output(a: &App) -> String { + snapshot::text(a.session.screen()) +} +fn edit_window(a: &mut App) { + let id = a + .windows + .iter() + .find(|w| matches!(w.kind, WindowKind::Code(_))) + .unwrap() + .id; + a.execute(Command::FocusWindow(id)); +} +#[test] +fn f5_input_pause_copy_and_single_delivery_keep_the_same_vm() { + let t = Temp::new(); + let mut a = t.app("LINE INPUT \"Name: \"; s$\nPRINT s$\nEND\n"); + key(&mut a, K::F(5), M::NONE); + tick(&mut a); + assert_eq!(a.execution, Execution::Waiting); + key(&mut a, K::Char('a'), M::NONE); + tick(&mut a); + edit_window(&mut a); + key(&mut a, K::Char('a'), M::CONTROL); + key(&mut a, K::Char('c'), M::CONTROL); + assert!(a.editor.clipboard.starts_with("LINE INPUT")); + assert_eq!(a.execution, Execution::Waiting); + key(&mut a, K::Pause, M::CONTROL); + assert_eq!(a.execution, Execution::Paused); + assert!(a.message.contains("main.bas")); + key(&mut a, K::F(5), M::NONE); + assert!(a.dialog.is_none()); + a.execute(Command::Output); + key(&mut a, K::Char('b'), M::NONE); + key(&mut a, K::Enter, M::NONE); + tick(&mut a); + assert_eq!(a.execution, Execution::Ended, "{}", a.message); + assert_eq!(output(&a), "Name: ? ab\nab\n"); + assert!(!a.quit); + assert!(a.session.vm.is_none()); + key(&mut a, K::F(5), M::SHIFT); + tick(&mut a); + key(&mut a, K::Char('c'), M::CONTROL); + tick(&mut a); + assert_eq!(a.execution, Execution::Paused); + assert_eq!(output(&a), "Name: ? \n"); +} +#[test] +fn changed_paused_source_requires_explicit_old_revision_or_restart() { + let t = Temp::new(); + let mut a = t.app("STOP\nPRINT \"old\"\nEND\n"); + a.execute(Command::Start); + tick(&mut a); + assert_eq!(a.execution, Execution::Paused); + let id = a.project.members()[0]; + a.project + .replace_text( + id, + 0..a.project.document(id).unwrap().code().len(), + "STOP\nPRINT \"new\"\nEND\n", + ) + .unwrap(); + a.execute(Command::Continue); + assert!(matches!( + a.dialog.as_ref().unwrap().kind, + DialogKind::ResumeRevision + )); + a.dialog.as_mut().unwrap().fields[0] = + Field::choice("Fortsetzen", vec!["restart".into(), "old".into()], 1); + key(&mut a, K::Enter, M::NONE); + assert!(a.session.old_revision); + assert!(a + .title(a.active_window().unwrap()) + .contains("ALTES KOMPILAT")); + tick(&mut a); + assert_eq!(output(&a), "old\n"); + a.execute(Command::Restart); + tick(&mut a); + a.project + .replace_text( + id, + 0..a.project.document(id).unwrap().code().len(), + "' comment\nPRINT \"latest\"\nEND\n", + ) + .unwrap(); + a.execute(Command::Continue); + key(&mut a, K::Enter, M::NONE); + tick(&mut a); + assert_eq!(output(&a), "latest\n"); + assert!(!a.session.old_revision); + a.project + .replace_text( + id, + 0..a.project.document(id).unwrap().code().len(), + "PRINT (\n", + ) + .unwrap(); + a.execute(Command::Start); + assert_eq!(a.execution, Execution::Error); + assert!(a.session.vm.is_none()); +} +#[test] +fn output_viewports_resize_and_end_preserve_buffer_and_basic_colors() { + let t = Temp::new(); + let mut a = t.app("COLOR 4,1\nPRINT \"visible\"\nSTOP\nEND\n"); + a.execute(Command::Start); + tick(&mut a); + let original = output(&a); + let size = (a.session.screen().cols(), a.session.screen().rows()); + let active = a.active; + let w = a.windows.iter_mut().find(|w| w.id == active).unwrap(); + w.normal = ratatui::layout::Rect::new(3, 3, 30, 8); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap(); + terminal.draw(|f| a.render(f)).unwrap(); + assert_eq!(terminal.backend().buffer()[(4, 4)].symbol(), "v"); + assert_eq!( + terminal.backend().buffer()[(4, 4)].fg, + tb_ui::screen::basic_color(4) + ); + key(&mut a, K::F(4), M::NONE); + terminal.draw(|f| a.render(f)).unwrap(); + assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), "v"); + assert_eq!((a.session.screen().cols(), a.session.screen().rows()), size); + a.handle(Event::Resize(120, 40)); + assert_eq!( + (a.session.screen().cols(), a.session.screen().rows()), + (120, 40) + ); + key(&mut a, K::F(4), M::NONE); + a.execute(Command::Continue); + tick(&mut a); + assert_eq!(a.execution, Execution::Ended); + assert_eq!(output(&a), original); +} +#[test] +fn restart_command_run_targets_and_missing_target_preserve_documents() { + let t = Temp::new(); + let mut a = t.app("PRINT COMMAND$\nn%=n%+1\nSTOP\nEND\n"); + a.execute(Command::CommandLine); + a.dialog.as_mut().unwrap().fields[0] = Field::text("args", "one two"); + key(&mut a, K::Enter, M::NONE); + for _ in 0..3 { + a.execute(Command::Restart); + tick(&mut a); + assert_eq!(output(&a), "one two\n"); + assert!(matches!( + a.session.vm.as_ref().unwrap().inspect("n"), + Some(Value::Int(1)) + )); + } + let id = a.project.members()[0]; + let path = a.project.document(id).unwrap().source_path().to_path_buf(); + fs::write(t.0.join("other.bas"), "PRINT \"foreign\"\nEND\n").unwrap(); + a.project + .replace_text( + id, + 0..a.project.document(id).unwrap().code().len(), + "RUN \"OTHER\"\n", + ) + .unwrap(); + a.execute(Command::Start); + tick(&mut a); + assert_eq!(output(&a), "foreign\n"); + assert_eq!(a.project.document(id).unwrap().source_path(), path); + assert_eq!(a.project.document(id).unwrap().code(), "RUN \"OTHER\"\n"); + a.project + .replace_text( + id, + 0..a.project.document(id).unwrap().code().len(), + "RUN \"missing\"\n", + ) + .unwrap(); + a.execute(Command::Start); + tick(&mut a); + assert_eq!(a.execution, Execution::Error); + assert!(a.message.contains("nicht gefunden")); + assert_eq!(a.project.document(id).unwrap().code(), "RUN \"missing\"\n"); + assert!(!a.quit); + a.project + .replace_text( + id, + 0..a.project.document(id).unwrap().code().len(), + "10 PRINT \"skip\"\n20 STOP\n30 RUN 40\n40 PRINT \"target\"\n50 END\n", + ) + .unwrap(); + a.execute(Command::Start); + tick(&mut a); + a.execute(Command::Continue); + tick(&mut a); + assert_eq!(output(&a), "target\n"); +} +#[test] +fn print_uses_utf8_protected_output_and_does_not_mutate_source() { + let t = Temp::new(); + let mut a = t.app("PRINT \"Grüße\"\n"); + let id = a.project.members()[0]; + let WindowKind::Code(v) = a.active_window().unwrap().kind else { + panic!() + }; + a.project.view_mut(v).unwrap().anchor = Some(0); + a.project.view_mut(v).unwrap().cursor = 5; + a.execute(Command::Print); + assert!(a.dialog.as_ref().unwrap().fields[0] + .string() + .ends_with("LPT1.TXT")); + key(&mut a, K::Enter, M::NONE); + assert_eq!(fs::read_to_string(t.0.join("LPT1.TXT")).unwrap(), "PRINT"); + a.execute(Command::Print); + key(&mut a, K::Enter, M::NONE); + assert!(!a.dialog.as_ref().unwrap().error.is_empty()); + a.dialog.as_mut().unwrap().fields[1] = Field::toggle("overwrite", true); + a.dialog.as_mut().unwrap().fields[2] = Field::toggle("selection", false); + key(&mut a, K::Enter, M::NONE); + assert_eq!( + fs::read_to_string(t.0.join("LPT1.TXT")).unwrap(), + "PRINT \"Grüße\"\n" + ); + a.execute(Command::Print); + a.dialog.as_mut().unwrap().fields[0] = + Field::text("path", t.0.join("main.bas").display().to_string()); + a.dialog.as_mut().unwrap().fields[1] = Field::toggle("overwrite", true); + key(&mut a, K::Enter, M::NONE); + assert!(!a.dialog.as_ref().unwrap().error.is_empty()); + assert_eq!(a.project.document(id).unwrap().code(), "PRINT \"Grüße\"\n"); + assert_eq!( + fs::read_to_string(t.0.join("main.bas")).unwrap(), + "PRINT \"Grüße\"\n" + ); + key(&mut a, K::Esc, M::NONE); + fs::write(t.0.join("blocked"), "existing destination parent").unwrap(); + a.execute(Command::Print); + a.dialog.as_mut().unwrap().fields[0] = + Field::text("path", t.0.join("blocked/print.txt").display().to_string()); + key(&mut a, K::Enter, M::NONE); + assert!(!a.dialog.as_ref().unwrap().error.is_empty()); + assert_eq!( + fs::read_to_string(t.0.join("blocked")).unwrap(), + "existing destination parent" + ); + assert_eq!(a.project.document(id).unwrap().code(), "PRINT \"Grüße\"\n"); +} +#[test] +fn shell_request_is_resumed_once_and_error_is_basic_error() { + let t = Temp::new(); + let mut a = t.app("PRINT SHELL(\"exit 7\")\nEND\n"); + a.execute(Command::Start); + tick(&mut a); + assert_eq!( + a.session.host.shell_request.take().as_deref(), + Some("exit 7") + ); + a.session.host.shell_result = Some(Ok(7)); + tick(&mut a); + assert_eq!(output(&a), " 7 \n"); + assert!(a.session.host.shell_request.is_none()); + a.execute(Command::Start); + tick(&mut a); + a.session.host.shell_request = None; + a.session.host.shell_result = Some(Err(tb_runtime::errors::RuntimeError(53))); + tick(&mut a); + assert_eq!(a.execution, Execution::Error); + assert!(a.message.contains("53")); +} +#[test] +fn startup_selection_matches_shared_cli_pipeline_and_legacy_mak() { + use tb_vm::project_io::{new_execution, SourceLoader}; + let t = Temp::new(); + fs::write(t.0.join("a.bas"), "PRINT \"A\"\n").unwrap(); + fs::write(t.0.join("b.bas"), "PRINT \"B\"\n").unwrap(); + for (file, name) in [("one.frm", "One"), ("two.frm", "Two")] { + fs::write( + t.0.join(file), + format!("VERSION 1.00\nBEGIN Form {name}\nEND\n"), + ) + .unwrap(); + } + for startup in [None, Some("b.bas"), Some("two.frm")] { + fs::write( + t.0.join("p.mak"), + format!( + "a.bas\nb.bas\none.frm\ntwo.frm\n{}", + startup + .map(|s| format!("' $STARTUP: \"{s}\"\n")) + .unwrap_or_default() + ), + ) + .unwrap(); + let mut a = App::new(&t.0, t.0.join("options"), (80, 25)).unwrap(); + a.load_initial_project(t.0.join("p.mak")).unwrap(); + a.session.command = "args".into(); + a.execute(Command::Start); + let sources = SourceLoader::default().load(&t.0.join("p.mak")).unwrap(); + let module = sources.compile(&mut Default::default(), "CLI").unwrap(); + let module = tb_vm::bytecode::CompiledModule::from_tbc(&module.to_tbc()).unwrap(); + let mut cli = new_execution(module, "args", Some((80, 25)), None).unwrap(); + let ide = a.session.vm.as_ref().unwrap(); + assert_eq!(ide.rt.command, cli.rt.command); + assert_eq!(ide.forms.active_form(), cli.forms.active_form()); + assert_eq!(ide.forms.has_visible_forms(), startup != Some("b.bas")); + assert_eq!( + sources.units[0].name.to_ascii_uppercase(), + if startup == Some("two.frm") { + "TWO" + } else if startup == Some("b.bas") { + "B" + } else { + "A" + } + ); + if startup == Some("b.bas") { + tick(&mut a); + assert_eq!( + cli.run(&mut tb_runtime::host::CaptureHost::default()), + tb_vm::interp::RunEvent::Ended + ); + assert_eq!(output(&a), "B\nA\n"); + assert_eq!(output(&a), snapshot::text(&cli.rt.screen)); + } + } +} +#[test] +fn ide_and_cli_run_wrapper_match_input_time_and_forms_script() { + let t = Temp::new(); + let form="VERSION 1.00\nBEGIN Form Form1\n BEGIN Timer Timer1\n END\nEND\nTimer1.Interval=100\nTimer1.Enabled=-1\nSLEEP 10\nForm1.Hide\nCLS\nLINE INPUT s$\nPRINT s$; COMMAND$\nEND\nSUB Timer1_Timer\nTimer1.Enabled=0\nEND SUB\n"; + fs::write(t.0.join("form.frm"), form).unwrap(); + let mut a = App::new(&t.0, t.0.join("options"), (80, 25)).unwrap(); + a.load_initial_project(t.0.join("form.frm")).unwrap(); + a.session.command = "!".into(); + a.execute(Command::Start); + a.tick_execution(0); + assert_eq!(a.execution, Execution::Waiting); + a.tick_execution(100); + a.handle(Event::Paste("hello\n".into())); + for _ in 0..20 { + a.tick_execution(100); + } + assert_eq!(a.execution, Execution::Ended, "{}", a.message); + // CLI wrapper gets the same scripted input at the same host deadline. + struct ScriptHost { + now: u64, + events: std::collections::VecDeque, + } + impl tb_runtime::host::Host for ScriptHost { + fn present(&mut self, _: &tb_runtime::screen::TextScreen) {} + fn next_event(&mut self, _: bool) -> Option { + self.events.pop_front() + } + fn jetzt_ms(&mut self) -> u64 { + self.now + } + fn warten(&mut self, deadline: Option) -> Option { + if let Some(d) = deadline { + self.now = d; + None + } else { + self.events.extend( + "hello\r" + .chars() + .map(|c| tb_runtime::host::Ereignis::Taste(c.to_string(), 0)), + ); + self.events.pop_front() + } + } + } + let module = tb_vm::project_io::load_program(&t.0.join("form.frm")).unwrap(); + let mut cli = tb_vm::project_io::new_execution(module, "!", Some((80, 25)), None).unwrap(); + assert_eq!( + cli.run(&mut ScriptHost { + now: 0, + events: Default::default() + }), + tb_vm::interp::RunEvent::Ended + ); + assert_eq!(output(&a), snapshot::text(&cli.rt.screen)); + assert_eq!(output(&a), "? hello\nhello!\n"); +} + +#[test] +fn restart_drops_files_data_traps_and_queued_actions() { + let t = Temp::new(); + let mut a=t.app(&format!("DATA 7\nSTOP\nREAD n%\nOPEN \"{}\" FOR OUTPUT AS #1\nON TIMER(1) GOSUB H\nTIMER ON\nSTOP\nEND\nH:\nRETURN\n",t.0.join("opened.txt").display())); + a.execute(Command::Shell); + assert!(a.session.file_shell); + a.execute(Command::Start); + tick(&mut a); + assert!(!a.session.file_shell); + assert!(a.session.host.shell_request.is_none()); + for _ in 0..3 { + let vm = a.session.vm.as_ref().unwrap(); + assert!(!vm.rt.dateien.ist_offen(1)); + assert_eq!(vm.rt.traps.naechste_frist(), None); + assert!(matches!(vm.inspect("n"), Some(Value::Int(0)))); + a.execute(Command::Continue); + tick(&mut a); + let vm = a.session.vm.as_ref().unwrap(); + assert!(vm.rt.dateien.ist_offen(1)); + assert!(vm.rt.traps.naechste_frist().is_some()); + assert!(matches!(vm.inspect("n"), Some(Value::Int(7)))); + a.session + .host + .events + .push_back(tb_runtime::host::Ereignis::Taste("stale".into(), 0)); + a.execute(Command::Restart); + tick(&mut a); + assert!(a.session.host.events.is_empty()); + } +} +#[test] +fn named_run_of_current_target_uses_edited_sources() { + let t = Temp::new(); + let mut a = t.app("PRINT \"disk\"\nEND\n"); + let id = a.project.members()[0]; + a.project + .replace_text( + id, + 0..a.project.document(id).unwrap().code().len(), + "RUN \"MAIN.BAS\"\n", + ) + .unwrap(); + a.execute(Command::Start); + a.tick_execution(0); + assert!(a.session.project_target); + a.project + .replace_text( + id, + 0..a.project.document(id).unwrap().code().len(), + "PRINT \"edited\"\nEND\n", + ) + .unwrap(); + tick(&mut a); + assert_eq!(output(&a), "edited\n"); +} +#[test] +fn explicit_end_and_system_end_visible_forms_but_natural_end_keeps_them_live() { + let t = Temp::new(); + for ending in ["END", "SYSTEM", ""] { + let path = t.0.join("form.frm"); + fs::write( + &path, + format!("VERSION 1.00\nBEGIN Form Form1\nEND\n{ending}\n"), + ) + .unwrap(); + let mut a = App::new(&t.0, t.0.join("options"), (80, 25)).unwrap(); + a.load_initial_project(path).unwrap(); + a.execute(Command::Start); + assert!(a.session.vm.as_ref().unwrap().forms.has_visible_forms()); + tick(&mut a); + if ending.is_empty() { + assert_eq!(a.execution, Execution::Waiting); + key(&mut a, K::F(4), M::NONE); + assert!(a.session.fullscreen); + a.handle(Event::Resize(40, 10)); + key(&mut a, K::Pause, M::CONTROL); + assert_eq!(a.execution, Execution::Paused); + a.handle(Event::Resize(80, 25)); + key(&mut a, K::F(5), M::NONE); + tick(&mut a); + assert_eq!(a.execution, Execution::Waiting); + assert!(a.session.vm.as_ref().unwrap().forms.has_visible_forms()); + } else { + assert_eq!(a.execution, Execution::Ended); + assert!(a.session.vm.is_none()); + } + assert!(!a.quit); + } +} +#[test] +fn comment_only_source_change_also_requires_revision_choice() { + let t = Temp::new(); + let mut a = t.app("STOP ' old\nPRINT 1\nEND\n"); + a.execute(Command::Start); + tick(&mut a); + let compiled = a.current_compilation().unwrap().to_tbc(); + let id = a.project.members()[0]; + a.project + .replace_text( + id, + 0..a.project.document(id).unwrap().code().len(), + "STOP ' new\nPRINT 1\nEND\n", + ) + .unwrap(); + assert_eq!(a.compile_current().unwrap().to_tbc(), compiled); + a.execute(Command::Continue); + assert!(matches!( + a.dialog.as_ref().unwrap().kind, + DialogKind::ResumeRevision + )); +} diff --git a/crates/tb-runtime/src/builtins.rs b/crates/tb-runtime/src/builtins.rs index 7f5c3cc..ee07dda 100644 --- a/crates/tb-runtime/src/builtins.rs +++ b/crates/tb-runtime/src/builtins.rs @@ -1668,44 +1668,23 @@ fn bi_lpos( Ok(Some(Value::Int(spalte as i16 + 1))) } -/// Gemeinsamer Kern von `SHELL` als Anweisung und als Funktion. -fn shell_ausfuehren(befehl: &str) -> Result { - let mut cmd = if cfg!(windows) { - let mut c = std::process::Command::new("cmd"); - c.args(["/C", befehl]); - c - } else { - let mut c = std::process::Command::new("sh"); - c.args(["-c", befehl]); - c - }; - cmd.status() - .map(|s| s.code().unwrap_or(0)) - .map_err(|_| RuntimeError(53)) -} - fn bi_shell_stmt( _: &mut RtState, - _: &mut dyn Host, + host: &mut dyn Host, a: &mut [Value], ) -> Result, RuntimeError> { - let befehl = match a.first() { - Some(Value::Str(s)) => s.to_string(), - _ => String::new(), + let command = match a.first() { + Some(Value::Str(s)) => s.as_ref(), + _ => "", }; - if befehl.is_empty() { - return Ok(None); - } - shell_ausfuehren(&befehl).map(|_| None) + host.shell(command).map(|_| None) } - fn bi_shell_fn( _: &mut RtState, - _: &mut dyn Host, + host: &mut dyn Host, a: &mut [Value], ) -> Result, RuntimeError> { - let code = shell_ausfuehren(&arg_str(a, 0)?)?; - Ok(Some(Value::Lng(code))) + host.shell(&arg_str(a, 0)?).map(|code| code.map(Value::Lng)) } /// `MKI$`/`MKL$`/`MKS$`/`MKD$`/`MKC$` — Zahl in ihre Bytedarstellung. diff --git a/crates/tb-runtime/src/host.rs b/crates/tb-runtime/src/host.rs index 1d722c1..43f42ec 100644 --- a/crates/tb-runtime/src/host.rs +++ b/crates/tb-runtime/src/host.rs @@ -158,6 +158,15 @@ pub mod taste { } pub trait Host { + /// None means the frontend has scheduled a terminal handoff; retry the + /// same request after it supplies the child result. + fn shell(&mut self, command: &str) -> Result, crate::errors::RuntimeError> { + shell_command(command) + .status() + .map(|s| Some(s.code().unwrap_or(0))) + .map_err(|_| crate::errors::RuntimeError(53)) + } + /// Aktuellen Bildschirmzustand anzeigen. fn present(&mut self, screen: &TextScreen); @@ -388,3 +397,12 @@ mod tests { assert_eq!(h.next_event(false), None); } } + +/// Shared child command, inheriting the foreground terminal and stdio. +pub fn shell_command(command: &str) -> std::process::Command { + let mut child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "sh" }); + if !command.is_empty() { + child.args([if cfg!(windows) { "/C" } else { "-c" }, command]); + } + child +} diff --git a/crates/tb-ui/src/forms.rs b/crates/tb-ui/src/forms.rs index 68ca18a..4230dc8 100644 --- a/crates/tb-ui/src/forms.rs +++ b/crates/tb-ui/src/forms.rs @@ -2584,55 +2584,123 @@ fn dialog_set( 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); +/// Fortsetzbarer modaler Dialog; Modell, Fokus und Hintergrund bleiben erhalten. +pub struct Dialog { + model: FormsModel, + old: TextScreen, + buttons: Vec, + default: i16, +} +#[derive(Debug, PartialEq)] +pub enum DialogValue { + Number(i16), + Text(String), +} +impl Dialog { + pub fn poll( + &mut self, + screen: &mut TextScreen, + host: &mut dyn Host, + queued: &mut VecDeque, + ) -> Option { + if (self.old.cols(), self.old.rows()) != (screen.cols(), screen.rows()) { + self.old.resize(screen.cols(), screen.rows()); + self.model.resize(screen.cols(), screen.rows()); + } + self.model.render(screen); host.present(screen); + // One event per poll also bounds arbitrarily long queued input. let event = queued .pop_front() .map(|e| e.ereignis) - .or_else(|| host.next_event(true))?; + .or_else(|| host.next_event(false))?; match event { Ereignis::Taste(key, shift) => { - let shift = if plain_access_key && key.chars().count() == 1 { + let shift = if !self.buttons.is_empty() && key.chars().count() == 1 { shift | umschalt::ALT } else { shift }; - model.handle_key(&key, shift); + self.model.handle_key(&key, shift); } Ereignis::Maus(event) => { - let now = if model.mouse_needs_time(event) { + let now = if self.model.mouse_needs_time(event) { host.jetzt_ms() } else { 0 }; - model.handle_mouse_at(event, now); + self.model.handle_mouse_at(event, now); } Ereignis::Groesse { cols, rows } => { screen.resize(cols, rows); - model.resize(cols, rows); + self.old.resize(cols, rows); + self.model.resize(cols, rows); } event @ (Ereignis::Abbruch | Ereignis::Ende | Ereignis::Signal(_)) => { queued.push_front(event.into()); return None; } } - while let Some(event) = model.next_event() { - if event.name == "CLICK" { - return Some(event); + while let Some(event) = self.model.next_event() { + if event.name != "CLICK" { + continue; } + let value = if self.buttons.is_empty() { + match event.object { + 3 => DialogValue::Text(self.model.string((2, None), "TEXT")), + 4 => DialogValue::Text(String::new()), + _ => continue, + } + } else { + let Some(code) = event + .object + .checked_sub(2) + .and_then(|i| self.buttons.get(i as usize)) + else { + continue; + }; + DialogValue::Number(*code) + }; + *screen = self.old.clone(); + return Some(value); + } + None + } + fn run( + &mut self, + screen: &mut TextScreen, + host: &mut dyn Host, + queued: &mut VecDeque, + ) -> DialogValue { + loop { + if let Some(value) = self.poll(screen, host, queued) { + return value; + } + if queued.front().is_some_and(|e| { + matches!( + e.ereignis, + Ereignis::Abbruch | Ereignis::Ende | Ereignis::Signal(_) + ) + }) { + break; + } + if !queued.is_empty() { + continue; + } + match host.warten(None) { + Some(e) => queued.push_back(e.into()), + None => break, + } + } + *screen = self.old.clone(); + if self.buttons.is_empty() { + DialogValue::Text(String::new()) + } else { + DialogValue::Number(self.default) } } } -/// Zeigt MSGBOX als echtes modales FormsModel-Formular. pub fn msgbox_dialog( screen: &mut TextScreen, host: &mut dyn Host, @@ -2641,6 +2709,33 @@ pub fn msgbox_dialog( kind: i32, title: &str, ) -> Result { + match message_dialog(screen, text, kind, title)?.run(screen, host, queued) { + DialogValue::Number(n) => Ok(n), + _ => unreachable!(), + } +} +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 { + match input_dialog(screen, prompt, title, initial, position)?.run(screen, host, queued) { + DialogValue::Text(s) => Ok(s), + _ => unreachable!(), + } +} + +/// Zeigt MSGBOX als echtes modales FormsModel-Formular. +pub fn message_dialog( + screen: &TextScreen, + text: &str, + kind: i32, + title: &str, +) -> Result { let groups: &[&[(i16, &str)]] = &[ &[(1, "&OK")], &[(1, "&OK"), (2, "&Cancel")], @@ -2732,30 +2827,22 @@ pub fn msgbox_dialog( 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) + Ok(Dialog { + model, + old, + buttons: buttons.iter().map(|b| b.0).collect(), + default: buttons[default].0, + }) } /// Zeigt INPUTBOX$ als echtes modales FormsModel-Formular. -pub fn inputbox_dialog( - screen: &mut TextScreen, - host: &mut dyn Host, - queued: &mut VecDeque, +pub fn input_dialog( + screen: &TextScreen, prompt: &str, title: &str, initial: &str, position: Option<(i32, i32)>, -) -> Result { +) -> Result { let width = 46usize.min(screen.cols()); let height = 16usize.min(screen.rows()); let (left, top) = position.map_or_else( @@ -2824,23 +2911,12 @@ pub fn inputbox_dialog( 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) + Ok(Dialog { + model, + old, + buttons: Vec::new(), + default: 0, + }) } #[cfg(test)] diff --git a/crates/tb-ui/src/host.rs b/crates/tb-ui/src/host.rs index 7f1f6f8..7399b33 100644 --- a/crates/tb-ui/src/host.rs +++ b/crates/tb-ui/src/host.rs @@ -4,16 +4,12 @@ //! Er lebt hier statt in `tb-runtime`, damit die Ausführungsschicht ohne //! Terminal-Abhängigkeit bleibt (Entscheidung 2026-09-03, D1/D2). -use std::io::{self, Stdout}; +use std::io::{self, IsTerminal, Stdout}; use std::time::{Duration, Instant}; use crossterm::event::{ - self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, - KeyModifiers, MouseButton, MouseEvent, MouseEventKind, -}; -use crossterm::execute; -use crossterm::terminal::{ - disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, + self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, + MouseEventKind, }; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; @@ -29,6 +25,7 @@ use crate::signale::Signalquelle; /// Der Konstruktor schaltet in den Alternativschirm und den Rohmodus, /// `Drop` stellt beides wieder her — auch bei Panik oder Laufzeitfehler. pub struct TerminalHost { + guard: crate::terminal::TerminalGuard, terminal: Terminal>, /// Nullpunkt der monotonen Zeit (`Host::jetzt_ms`). start: Instant, @@ -40,15 +37,18 @@ pub struct TerminalHost { impl TerminalHost { pub fn new() -> io::Result { - enable_raw_mode()?; - let mut out = io::stdout(); - execute!(out, EnterAlternateScreen, EnableMouseCapture)?; + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + return Err(io::Error::other("Kein interaktives Terminal")); + } + let guard = crate::terminal::TerminalGuard::enter(io::stdout())?; + let out = io::stdout(); let terminal = Terminal::new(CrosstermBackend::new(out))?; let flaeche = terminal .size() .map(|s| (s.width as usize, s.height as usize)) .unwrap_or((80, 25)); Ok(TerminalHost { + guard, terminal, start: Instant::now(), signale: Signalquelle::neu(), @@ -63,23 +63,25 @@ impl TerminalHost { } } -impl Drop for TerminalHost { - fn drop(&mut self) { - let _ = disable_raw_mode(); - let _ = execute!( - self.terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - ); - let _ = self.terminal.show_cursor(); - } -} - impl Host for TerminalHost { + fn shell(&mut self, command: &str) -> Result, tb_runtime::errors::RuntimeError> { + let result = self.guard.shell(command, &self.signale); + let _ = self.terminal.clear(); + result + .map(Some) + .map_err(|_| tb_runtime::errors::RuntimeError(53)) + } fn present(&mut self, screen: &TextScreen) { - let _ = self - .terminal - .draw(|f| f.render_widget(ScreenWidget(screen), f.area())); + let _ = self.terminal.draw(|f| { + if f.area().width < 80 || f.area().height < 25 { + f.render_widget( + ratatui::widgets::Paragraph::new("Terminal zu klein: Minimum 80x25"), + f.area(), + ); + } else { + f.render_widget(ScreenWidget(screen), f.area()); + } + }); } fn jetzt_ms(&mut self) -> u64 { @@ -152,7 +154,7 @@ impl Host for TerminalHost { /// crossterm zählt ab 0, der Dialekt ab 1 — umgerechnet wird hier, damit es /// in der Laufzeit nur eine Zählweise gibt. Ereignisse außerhalb der /// Darstellungsfläche und Ereignisse ohne Entsprechung liefern `None`. -fn maus_zu_ereignis(m: MouseEvent, flaeche: (usize, usize)) -> Option { +pub fn maus_zu_ereignis(m: MouseEvent, flaeche: (usize, usize)) -> Option { let (cols, rows) = flaeche; let spalte = m.column as usize + 1; let zeile = m.row as usize + 1; @@ -195,7 +197,7 @@ fn maus_zu_ereignis(m: MouseEvent, flaeche: (usize, usize)) -> Option } /// crossterm-Taste → `INKEY$`-Form. `None` für Tasten ohne Entsprechung. -fn taste_zu_ereignis(k: KeyEvent) -> Option { +pub fn taste_zu_ereignis(k: KeyEvent) -> Option { // Strg+Untbr bzw. Strg+C: Abbruchwunsch. if k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('c') | KeyCode::Pause) diff --git a/crates/tb-ui/src/lib.rs b/crates/tb-ui/src/lib.rs index ac98a64..43f3ea9 100644 --- a/crates/tb-ui/src/lib.rs +++ b/crates/tb-ui/src/lib.rs @@ -14,3 +14,6 @@ pub mod host; // Terminal-Host: Anzeige + Tastatur-/Größenereignisse pub mod screen; #[cfg(feature = "terminal")] pub mod signale; // Betriebssystemsignale als Ereignisquelle (SIGNAL) + +#[cfg(feature = "terminal")] +pub mod terminal; diff --git a/crates/tb-ui/src/screen.rs b/crates/tb-ui/src/screen.rs index 4fa7008..8727b91 100644 --- a/crates/tb-ui/src/screen.rs +++ b/crates/tb-ui/src/screen.rs @@ -22,20 +22,9 @@ pub fn basic_color(n: u8) -> Color { /// Zeichenbares Gegenstück zu [`TextScreen`]. pub struct ScreenWidget<'a>(pub &'a TextScreen); -/// Rendert den Bildschirm oben links in die verfügbare Fläche. Ist das -/// Terminal kleiner als die Mindestgröße, erscheint nur ein Hinweis -/// (btop-artig); die Anwendung ruft bei Resize `TextScreen::resize` auf, -/// damit Puffer und Terminal deckungsgleich bleiben. +/// Clipped viewport; only the terminal owner enforces physical minimum size. impl Widget for ScreenWidget<'_> { fn render(self, area: Rect, buf: &mut Buffer) { - if (area.width as usize) < MIN_COLS || (area.height as usize) < MIN_ROWS { - let msg = format!( - "Terminal zu klein: {}x{} — Minimum {}x{}", - area.width, area.height, MIN_COLS, MIN_ROWS - ); - buf.set_string(area.x, area.y, msg, Style::default().fg(Color::Red)); - return; - } let cols = self.0.cols().min(area.width as usize); let rows = self.0.rows().min(area.height as usize); for row in 0..rows { diff --git a/crates/tb-ui/src/terminal.rs b/crates/tb-ui/src/terminal.rs new file mode 100644 index 0000000..77493b2 --- /dev/null +++ b/crates/tb-ui/src/terminal.rs @@ -0,0 +1,190 @@ +use crossterm::{ + cursor::{Hide, Show}, + event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture}, + execute, + style::ResetColor, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use std::io::{self, Write}; + +/// Ein Besitzer für Raw Mode und Terminalzustand, auch bei teilweiser Initialisierung. +pub struct TerminalGuard { + writer: W, + raw: fn(bool) -> io::Result<()>, +} +impl TerminalGuard { + pub fn enter(writer: W) -> io::Result { + Self::with_raw(writer, |on| { + if on { + enable_raw_mode() + } else { + disable_raw_mode() + } + }) + } + fn with_raw(writer: W, raw: fn(bool) -> io::Result<()>) -> io::Result { + let mut guard = Self { writer, raw }; + guard.resume()?; + Ok(guard) + } + fn resume(&mut self) -> io::Result<()> { + (self.raw)(true)?; + execute!( + self.writer, + EnterAlternateScreen, + EnableMouseCapture, + EnableBracketedPaste, + Hide + ) + } + fn suspend(&mut self) -> io::Result<()> { + (self.raw)(false)?; + execute!( + self.writer, + ResetColor, + Show, + DisableMouseCapture, + DisableBracketedPaste, + LeaveAlternateScreen + ) + } + /// Foreground child owns cooked terminal input. Always restore the IDE, + /// including spawn/wait errors and a child interrupted by Ctrl+C. + pub fn shell( + &mut self, + command: &str, + signals: &crate::signale::Signalquelle, + ) -> io::Result { + let result = self.shell_with_interrupt(command, || signals.abholen().is_some()); + // A child may exit before try_wait observes the terminal signal. + // It belongs to the handed-off terminal, not the resumed BASIC run. + while signals.abholen().is_some() {} + result + } + fn shell_with_interrupt( + &mut self, + command: &str, + mut interrupted: impl FnMut() -> bool, + ) -> io::Result { + let result = self.suspend().and_then(|_| { + let mut child = tb_runtime::host::shell_command(command).spawn()?; + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status.code().unwrap_or(0)), + Ok(None) => {} + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + } + if interrupted() { + let _ = child.kill(); + return child.wait().map(|status| status.code().unwrap_or(0)); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + }); + let restored = self.resume(); + restored.and(result) + } +} +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = (self.raw)(false); + let _ = execute!( + self.writer, + ResetColor, + Show, + DisableMouseCapture, + DisableBracketedPaste, + LeaveAlternateScreen + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + cell::RefCell, + rc::Rc, + sync::atomic::{AtomicI32, Ordering}, + }; + static RAW: AtomicI32 = AtomicI32::new(0); + #[derive(Clone)] + struct Output(Rc>>); + impl Write for Output { + fn write(&mut self, b: &[u8]) -> io::Result { + self.0.borrow_mut().extend_from_slice(b); + Ok(b.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + fn raw(on: bool) -> io::Result<()> { + RAW.store(i32::from(on), Ordering::SeqCst); + Ok(()) + } + #[test] + fn terminal_cleanup_on_normal_error_and_partial_initialization() { + for fail in [false, true] { + let out = Output(Rc::default()); + let copy = out.clone(); + let result = (|| -> io::Result<()> { + let _guard = TerminalGuard::with_raw(out, raw)?; + if fail { + return Err(io::Error::other("Renderfehler")); + } + Ok(()) + })(); + assert_eq!(result.is_err(), fail); + assert_eq!(RAW.load(Ordering::SeqCst), 0); + let s = String::from_utf8(copy.0.borrow().clone()).unwrap(); + assert!(s.contains("?1049h") && s.contains("?1049l") && s.contains("?25h")); + } + for (command, abort, expected) in [ + ("exit 0", false, 0), + ("exit 7", false, 7), + ( + if cfg!(windows) { + "ping -n 30 127.0.0.1 >nul" + } else { + "exec sleep 30" + }, + true, + 0, + ), + ] { + let out = Output(Rc::default()); + let copy = out.clone(); + let mut guard = TerminalGuard::with_raw(out, raw).unwrap(); + assert_eq!( + guard.shell_with_interrupt(command, || abort).unwrap(), + expected + ); + assert_eq!(RAW.load(Ordering::SeqCst), 1); + let text = String::from_utf8(copy.0.borrow().clone()).unwrap(); + assert_eq!(text.matches("?1049h").count(), 2); + assert_eq!(text.matches("?1049l").count(), 1); + drop(guard); + assert_eq!(RAW.load(Ordering::SeqCst), 0); + } + let out = Output(Rc::default()); + let copy = out.clone(); + assert!(TerminalGuard::with_raw(out, |on| { + raw(on)?; + if on { + Err(io::Error::other("Raw-Fehler")) + } else { + Ok(()) + } + }) + .is_err()); + assert_eq!(RAW.load(Ordering::SeqCst), 0); + assert!(String::from_utf8(copy.0.borrow().clone()) + .unwrap() + .contains("?25h")); + } +} diff --git a/crates/tb-vm/src/codegen.rs b/crates/tb-vm/src/codegen.rs index 726a8ce..776ae5b 100644 --- a/crates/tb-vm/src/codegen.rs +++ b/crates/tb-vm/src/codegen.rs @@ -704,7 +704,7 @@ impl Codegen { ctx.emit(Instr::EraseSlot(global, s)); } } - HStmtKind::End => ctx.emit(Instr::End), + HStmtKind::End => ctx.emit(Instr::SystemInstr), HStmtKind::Stop => ctx.emit(Instr::StopInstr), HStmtKind::System => ctx.emit(Instr::SystemInstr), HStmtKind::Unsupported(name) => { diff --git a/crates/tb-vm/src/interp.rs b/crates/tb-vm/src/interp.rs index cd00617..fa00488 100644 --- a/crates/tb-vm/src/interp.rs +++ b/crates/tb-vm/src/interp.rs @@ -49,6 +49,14 @@ pub enum RunEvent { }, } +/// Scheduling result, deliberately separate from debugger events. +#[derive(Debug, Clone, PartialEq)] +pub enum PollResult { + Yield, + Waiting { deadline: Option }, + Event(RunEvent), +} + const F_STEP: u32 = 1; const F_BREAK: u32 = 2; const F_POLL: u32 = 4; @@ -82,6 +90,7 @@ struct Frame { form_event: Option, handler_start: bool, eingabe: Option, + sleep: Option>, } struct KonsolenEingabe { @@ -130,6 +139,9 @@ pub struct Vm { data_ptr: usize, start_pc: Option, pub forms: FormsModel, + waiting: Option>, + dialog: Option, + terminated: bool, } /// Quellenarten, wie der Codegenerator sie kodiert. @@ -198,6 +210,9 @@ impl Vm { breakpoints: HashSet::new(), data_ptr: 0, start_pc: None, + waiting: None, + dialog: None, + terminated: false, forms, module, }; @@ -260,6 +275,7 @@ impl Vm { form_event: None, handler_start: false, eingabe: None, + sleep: None, }); } @@ -316,56 +332,51 @@ impl Vm { 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), + if self.dialog.is_none() { + let text = self.external_string(args.first().ok_or(RuntimeError::TYPE_MISMATCH)?)?; + self.dialog = Some(match id { + ids::MSGBOX => { + let kind = args.get(1).map_or(Ok(0), |v| self.dialog_integer(v))?; + let title = args + .get(2) + .map_or_else(|| Ok(String::new()), |v| self.external_string(v))?; + tb_ui::forms::message_dialog(&self.rt.screen, &text, kind, &title)? + } + ids::INPUTBOX_S => { + let title = args + .get(1) + .map_or_else(|| Ok(String::new()), |v| self.external_string(v))?; + let initial = args + .get(2) + .map_or_else(|| Ok(String::new()), |v| self.external_string(v))?; + 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), + }; + tb_ui::forms::input_dialog(&self.rt.screen, &text, &title, &initial, position)? + } + _ => return Err(RuntimeError::FEATURE_UNAVAILABLE), + }); + } + let mut queued = self.take_dialog_input(); + let value = self + .dialog + .as_mut() + .unwrap() + .poll(&mut self.rt.screen, host, &mut queued); + self.restore_dialog_input(queued); + if let Some(value) = value { + self.dialog = None; + Ok(Some(match value { + tb_ui::forms::DialogValue::Number(n) => Value::Int(n), + tb_ui::forms::DialogValue::Text(s) => Value::Str(Rc::from(s)), + })) + } else { + self.waiting = Some(None); + Ok(None) } } @@ -398,7 +409,7 @@ impl Vm { ) { return Ok(false); } - let mut args = self.stack.split_off(self.stack.len().saturating_sub(argc)); + let args = self.stack[self.stack.len().saturating_sub(argc)..].to_vec(); match name.as_str() { "CMNDLGREGISTER" => self.external_set(&args, 0, Value::Int(-1))?, "CMNDLGCLOSE" => {} @@ -408,7 +419,9 @@ impl Vm { Value::Lng(0), Value::Str(Rc::from("About")), ]; - self.forms_dialog(ids::MSGBOX, &dialog, host)?; + if self.forms_dialog(ids::MSGBOX, &dialog, host)?.is_none() { + return Ok(true); + } } "FILEOPEN" | "FILESAVE" => { let file = self.external_string(&args[0])?; @@ -428,9 +441,9 @@ impl Vm { 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 Some(result) = self.forms_dialog(ids::INPUTBOX_S, &dialog, host)? else { + return Ok(true); + }; let Value::Str(result) = result else { return Err(RuntimeError::TYPE_MISMATCH); }; @@ -460,9 +473,9 @@ impl Vm { 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 Some(result) = self.forms_dialog(ids::INPUTBOX_S, &dialog, host)? else { + return Ok(true); + }; let target = usize::from(name == "CHANGETEXT"); self.external_set(&args, target, result.clone())?; let empty = matches!(&result, Value::Str(text) if text.is_empty()); @@ -475,7 +488,7 @@ impl Vm { } _ => unreachable!(), } - args.clear(); + self.stack.truncate(self.stack.len() - argc); Ok(true) } @@ -766,25 +779,6 @@ impl Vm { /// Ein zugestelltes Ereignis beendet SLEEP; Fristen und Warten gehören /// ausschließlich dem Host, einschließlich einer virtuellen Uhr. - fn schlafen(&mut self, host: &mut dyn Host, sekunden: f64) { - let deadline = - (sekunden > 0.0).then(|| host.jetzt_ms().saturating_add((sekunden * 1000.0) as u64)); - loop { - self.tick(host); - if self.zustellen(host, Zustellpunkt::Kooperativ) || self.rt.ende || self.rt.abbruch { - return; - } - if let Some(deadline) = deadline { - if host.jetzt_ms() >= deadline { - return; - } - } else if self.rt.naechste_taste(host, false).is_some() { - return; - } - self.warten(host, deadline); - } - } - /// Trap-Handler aufsetzen: ein Frame auf den Modulrumpf, der dessen /// Locals mitbenutzt, mit eigenem `GOSUB`-Stapel und einer Rückmarke /// auf die unterbrochene Stelle (design.md, D2). @@ -808,6 +802,7 @@ impl Vm { form_event: None, handler_start: true, eingabe: None, + sleep: None, }); } @@ -994,39 +989,114 @@ impl Vm { // ---- Hauptschleife -------------------------------------------------------- + pub fn is_terminated(&self) -> bool { + self.terminated + } + /// 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 { - self.forms.sync_timers(|| host.jetzt_ms()); - while self.forms.has_visible_forms() { - self.tick(host); - let dispatched = self.zustellen(host, Zustellpunkt::Kooperativ); - if dispatched { - match self.run(host) { - RunEvent::Ended => {} - event => return event, + // Legacy explicit entry point: the caller deliberately starts a new + // forms pump, even after END. IDE/CLI only call it after natural end. + self.terminated = false; + loop { + match self.poll_visible_forms(host, usize::MAX) { + PollResult::Event(event) => return event, + PollResult::Waiting { deadline } => { + if (self.dialog.is_none() || self.rt.eingaben.is_empty()) + && !self.rt.abbruch + && !self.rt.ende + { + self.warten(host, deadline); + } } - } - if !dispatched && self.rt.ende { - return RunEvent::Ended; - } - if self.rt.abbruch { - return RunEvent::Interrupted { - line: self.current_line(), - }; - } - if !dispatched && self.forms.has_visible_forms() { - self.warten(host, None); + PollResult::Yield => {} } } - RunEvent::Ended } pub fn run(&mut self, host: &mut dyn Host) -> RunEvent { - self.forms.sync_timers(|| host.jetzt_ms()); loop { + match self.poll(host, usize::MAX) { + PollResult::Event(e) => return e, + PollResult::Waiting { deadline } => { + if (self.dialog.is_none() || self.rt.eingaben.is_empty()) + && !self.rt.abbruch + && !self.rt.ende + { + self.warten(host, deadline); + } + } + PollResult::Yield => {} + } + } + } + + /// Polls modeless forms after the module body has ended. + pub fn poll_visible_forms(&mut self, host: &mut dyn Host, budget: usize) -> PollResult { + if self.terminated { + return PollResult::Event(RunEvent::Ended); + } + if self + .frames + .last() + .is_some_and(|f| f.pc < self.module.procs[f.proc].code.len()) + { + return match self.poll(host, budget) { + PollResult::Event(RunEvent::Ended) if !self.terminated => PollResult::Yield, + result => result, + }; + } + if !self.forms.has_visible_forms() { + return PollResult::Event(RunEvent::Ended); + } + self.tick(host); + if self.rt.abbruch { + return PollResult::Event(RunEvent::Interrupted { + line: self.current_line(), + }); + } + if self.zustellen(host, Zustellpunkt::Kooperativ) { + match self.poll(host, budget) { + PollResult::Event(RunEvent::Ended) if !self.terminated => PollResult::Yield, + result => result, + } + } else if self.rt.ende || !self.forms.has_visible_forms() { + PollResult::Event(RunEvent::Ended) + } else { + PollResult::Waiting { + deadline: self.wait_deadline(None), + } + } + } + + fn wait_deadline(&self, deadline: Option) -> Option { + deadline + .into_iter() + .chain(self.forms.next_deadline()) + .chain(if self.forms.menu_is_open() { + None + } else { + self.rt.traps.naechste_frist() + }) + .min() + } + + /// Executes at most `budget` instructions without entering a host wait. + pub fn poll(&mut self, host: &mut dyn Host, budget: usize) -> PollResult { + self.waiting = None; + self.forms.sync_timers(|| host.jetzt_ms()); + for _ in 0..budget { + if self.dialog.is_some() && self.rt.ende { + return PollResult::Event(RunEvent::Ended); + } + if self.rt.abbruch { + return PollResult::Event(RunEvent::Interrupted { + line: self.current_line(), + }); + } 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; @@ -1034,39 +1104,45 @@ impl Vm { self.tick(host); let dispatched = self.zustellen(host, Zustellpunkt::Kooperativ); if !dispatched && self.rt.ende { - return RunEvent::Ended; - } - if self.rt.abbruch { - return RunEvent::Interrupted { - line: self.current_line(), - }; + return PollResult::Event(RunEvent::Ended); } if !dispatched { - self.warten(host, None); + return PollResult::Waiting { + deadline: self.wait_deadline(None), + }; } continue; } } - let frame = self.frames.last_mut().expect("kein Frame"); - let proc = frame.proc; + let Some(frame) = self.frames.last_mut() else { + return PollResult::Event(RunEvent::Ended); + }; let pc = frame.pc; - let code = &self.module.procs[proc].code; - if pc >= code.len() { - // Sollte nicht passieren (Exit wird emittiert) — defensiv. - return RunEvent::Ended; - } - let instr = code[pc].clone(); + let Some(instr) = self.module.procs[frame.proc].code.get(pc).cloned() else { + return PollResult::Event(RunEvent::Ended); + }; frame.pc = pc + 1; match self.exec(instr, pc, host) { Ok(Flow::Normal) => {} - Ok(Flow::Event(ev)) => return ev, + Ok(Flow::Event(ev)) => return PollResult::Event(ev), Err(e) => { + self.dialog = None; + self.waiting = None; + if let Some(f) = self.frames.last_mut() { + f.sleep = None; + } if let Some(ev) = self.handle_error(e.0, host) { - return ev; + return PollResult::Event(ev); } } } + if let Some(deadline) = self.waiting { + return PollResult::Waiting { + deadline: self.wait_deadline(deadline), + }; + } } + PollResult::Yield } // ---- Fehlerbehandlung ------------------------------------------------------- @@ -1112,6 +1188,7 @@ impl Vm { }; self.stack.truncate(stack_base); self.frames.last_mut().unwrap().eingabe = None; + self.frames.last_mut().unwrap().sleep = None; self.err = code; match handler { Handler::Goto(pc) => { @@ -1421,8 +1498,27 @@ impl Vm { Ok(Flow::Normal) } I::Sleep(mit_arg) => { - let sekunden = if mit_arg { self.pop_f64()? } else { 0.0 }; - self.schlafen(host, sekunden); + let caller = self.frames.len() - 1; + if self.frames[caller].sleep.is_none() { + let seconds = if mit_arg { self.pop_f64()? } else { 0.0 }; + self.frames[caller].sleep = Some( + (seconds > 0.0) + .then(|| host.jetzt_ms().saturating_add((seconds * 1000.0) as u64)), + ); + } + let deadline = self.frames[caller].sleep.unwrap(); + self.tick(host); + let dispatched = self.zustellen(host, Zustellpunkt::Kooperativ); + if dispatched + || self.rt.ende + || deadline.is_some_and(|d| host.jetzt_ms() >= d) + || (deadline.is_none() && self.rt.gepufferte_taste().is_some()) + { + self.frames[caller].sleep = None; + } else { + self.frames[caller].pc = pc; + self.waiting = Some(deadline); + } Ok(Flow::Normal) } I::EventSwitch(an) => { @@ -1433,7 +1529,11 @@ impl Vm { self.zeile_nr = n; Ok(Flow::Normal) } - I::End | I::SystemInstr => Ok(Flow::Event(RunEvent::Ended)), + I::End => Ok(Flow::Event(RunEvent::Ended)), + I::SystemInstr => { + self.terminated = true; + Ok(Flow::Event(RunEvent::Ended)) + } I::StopInstr => Ok(Flow::Event(RunEvent::Stopped { line: self.current_line(), })), @@ -2336,6 +2436,9 @@ impl Vm { // ---- Prozeduren ---- I::Call(proc, argc) => { if self.external_dialog(proc as usize, argc as usize, host)? { + if self.waiting.is_some() { + self.frames.last_mut().unwrap().pc = pc; + } return Ok(Flow::Normal); } self.push_frame(proc as usize, argc as usize); @@ -2412,6 +2515,26 @@ impl Vm { if matches!(id, ids::MSGBOX | ids::INPUTBOX_S) { if let Some(value) = self.forms_dialog(id, &args, host)? { self.push(value); + } else { + self.stack.extend(args); + self.frames.last_mut().unwrap().pc = pc; + } + return Ok(Flow::Normal); + } + if matches!(id, ids::SHELL_STMT | ids::SHELL_FN) { + let command = args + .first() + .map(|v| self.external_string(v)) + .transpose()? + .unwrap_or_default(); + if let Some(code) = host.shell(&command)? { + if id == ids::SHELL_FN { + self.push(Value::Lng(code)); + } + } else { + self.stack.extend(args); + self.frames.last_mut().unwrap().pc = pc; + self.waiting = Some(None); } return Ok(Flow::Normal); } @@ -2562,11 +2685,14 @@ impl Vm { text: String::new(), rest, }); - loop { + for _ in 0..64 { if self.frames[caller].eingabe.as_ref().unwrap().rest == Some(0) { return Ok(Some(self.frames[caller].eingabe.take().unwrap().text)); } self.tick(host); + if self.rt.abbruch { + return Ok(None); + } if self.zustellen(host, Zustellpunkt::Konsole) { return Ok(None); } @@ -2599,12 +2725,14 @@ impl Vm { } continue; } - if self.rt.ende || self.rt.abbruch { + if self.rt.ende { self.frames[caller].eingabe = None; return Err(RuntimeError(62)); } - self.warten(host, None); + self.waiting = Some(None); + return Ok(None); } + Ok(None) } fn do_input( diff --git a/crates/tb-vm/src/project_io.rs b/crates/tb-vm/src/project_io.rs index c0729e4..98c7259 100644 --- a/crates/tb-vm/src/project_io.rs +++ b/crates/tb-vm/src/project_io.rs @@ -258,6 +258,83 @@ pub struct ProjectSources { pub units: Vec, pub forms: Vec, } +impl ProjectSources { + /// One startup contract for IDE, CLI and serialized bytecode. + pub fn compile( + &self, + compiler: &mut crate::project::ProjectCompiler, + name: &str, + ) -> Result> { + let mut catalog = tb_frontend::forms::FormCatalog::default(); + for form in &self.forms { + catalog.append(&form.catalog()); + } + let mut module = compiler.compile(name, &self.units, &catalog, &self.forms)?; + if self + .manifest + .startup + .as_ref() + .is_some_and(|p| !has_extension(p, "frm")) + { + module.startup_form = None; + } + Ok(module) + } +} + +/// BASIC RUN resolves relative to the execution target, never to process cwd. +pub 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) + .and_then(|p| identity(&p)) + .map_err(|e| e.to_string()); + } + for extension in ["bas", "frm", "mak", "tbc"] { + if let Ok(path) = relative_case_insensitive(base, &format!("{program}.{extension}")) { + return identity(&path).map_err(|e| e.to_string()); + } + } + Err(format!("{program}: Programm nicht gefunden")) +} + +pub fn load_program(path: &Path) -> Result { + if has_extension(path, "tbc") { + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + return crate::bytecode::CompiledModule::from_tbc(&bytes).map_err(|e| e.to_string()); + } + SourceLoader::default() + .load(path)? + .compile(&mut Default::default(), &module_name(path)) + .map_err(|errors| { + errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + }) +} + +/// Fresh runtime for every start, restart and RUN in both frontends. +pub fn new_execution( + module: crate::bytecode::CompiledModule, + command: &str, + size: Option<(usize, usize)>, + line: Option, +) -> Result { + let mut vm = crate::interp::Vm::new(module); + if let Some(line) = line { + vm.start_at_line(line)?; + } + if let Some((cols, rows)) = size { + vm.rt.screen.resize(cols, rows); + vm.forms.resize(cols, rows); + } + vm.rt.command = command.to_owned(); + vm.set_poll_interrupt(true); + Ok(vm) +} + impl SourceLoader { pub fn insert(&mut self, path: &Path, content: Content) -> Result<(), String> { let key = identity(path).map_err(|e| e.to_string())?; @@ -362,7 +439,16 @@ impl SourceLoader { pub fn load_manifest(&self, manifest: Manifest) -> Result { let mut units = Vec::new(); let mut forms = Vec::new(); - for path in manifest.members() { + let mut members: Vec<_> = manifest.members().collect(); + if let Some(startup) = &manifest.startup { + let index = members + .iter() + .position(|p| *p == startup) + .ok_or("Startdatei ist kein Projektmitglied")?; + let selected = members.remove(index); + members.insert(0, selected); + } + for path in members { let content = self.read(path)?; let (name, code, first_line) = match content { Content::Text(text) => (module_name(path), text, 1), diff --git a/crates/tb-vm/tests/cooperative.rs b/crates/tb-vm/tests/cooperative.rs new file mode 100644 index 0000000..98605df --- /dev/null +++ b/crates/tb-vm/tests/cooperative.rs @@ -0,0 +1,252 @@ +use std::collections::VecDeque; +use tb_runtime::{ + host::{Ereignis, Host}, + screen::TextScreen, + snapshot, + value::Value, +}; +use tb_vm::interp::{PollResult, RunEvent, Vm}; +#[derive(Default)] +struct HostStub { + now: u64, + clocks: usize, + input: VecDeque, +} +impl Host for HostStub { + fn present(&mut self, _: &TextScreen) {} + fn next_event(&mut self, blocking: bool) -> Option { + assert!(!blocking); + self.input.pop_front() + } + fn warten(&mut self, _: Option) -> Option { + panic!("poll blocked") + } + fn jetzt_ms(&mut self) -> u64 { + self.clocks += 1; + self.now + } +} +fn vm(source: &str) -> Vm { + Vm::new(tb_vm::compile_source("TEST", source).unwrap()) +} +fn text(vm: &Vm, name: &str) -> String { + match vm.inspect(name) { + Some(Value::Str(s)) => s.to_string(), + v => panic!("{v:?}"), + } +} +fn input(host: &mut HostStub, chars: &str) { + host.input + .extend(chars.chars().map(|c| Ereignis::Taste(c.to_string(), 0))); +} +fn poll(vm: &mut Vm, host: &mut HostStub) -> PollResult { + vm.poll(host, 200) +} +fn end(vm: &mut Vm, host: &mut HostStub) { + for _ in 0..1000 { + match poll(vm, host) { + PollResult::Event(RunEvent::Ended) => return, + PollResult::Yield | PollResult::Waiting { .. } => {} + other => panic!("{other:?}"), + } + } + panic!("did not end"); +} +fn pause(vm: &mut Vm, host: &mut HostStub) { + vm.rt.abbruch = true; + assert!(matches!( + poll(vm, host), + PollResult::Event(RunEvent::Interrupted { .. }) + )); + vm.rt.abbruch = false; +} +#[test] +fn finite_slices_preserve_events_without_clock_queries() { + for source in ["DO\nLOOP", "10 GOTO 10"] { + let mut vm = vm(source); + vm.set_poll_interrupt(true); + let mut host = HostStub::default(); + for _ in 0..10 { + assert_eq!(poll(&mut vm, &mut host), PollResult::Yield); + } + assert_eq!(host.clocks, 0); + pause(&mut vm, &mut host); + } + let mut vm = vm("STOP\nPRINT 42\nEND"); + let mut host = HostStub::default(); + assert!(matches!( + poll(&mut vm, &mut host), + PollResult::Event(RunEvent::Stopped { .. }) + )); + end(&mut vm, &mut host); + assert!(snapshot::text(&vm.rt.screen).contains("42")); + assert_eq!(host.clocks, 0); +} +#[test] +fn partial_console_operations_keep_prompt_arguments_and_assignment() { + for instruction in [ + "INPUT \"Name\"; s$", + "LINE INPUT \"Name\"; s$", + "s$=INPUT$(3)", + ] { + let mut vm = vm(&format!("{instruction}\nn%=n%+1\nEND")); + let mut host = HostStub::default(); + input(&mut host, "a"); + assert!(matches!( + poll(&mut vm, &mut host), + PollResult::Waiting { .. } + )); + let before = snapshot::text(&vm.rt.screen); + pause(&mut vm, &mut host); + assert_eq!(snapshot::text(&vm.rt.screen), before); + input( + &mut host, + if instruction.contains("INPUT$(") { + "bc" + } else { + "bc\r" + }, + ); + end(&mut vm, &mut host); + assert_eq!(text(&vm, "s"), "abc"); + assert!(matches!(vm.inspect("n"), Some(Value::Int(1)))); + assert!(snapshot::text(&vm.rt.screen).matches("Name").count() <= 1); + } + let mut vm = vm("RANDOMIZE\nPRINT RND\nEND"); + let mut host = HostStub::default(); + input(&mut host, "12"); + assert!(matches!( + poll(&mut vm, &mut host), + PollResult::Waiting { .. } + )); + pause(&mut vm, &mut host); + input(&mut host, "3\r"); + end(&mut vm, &mut host); + assert_eq!( + snapshot::text(&vm.rt.screen) + .matches("Random Number Seed") + .count(), + 1 + ); +} +#[test] +fn sleep_deadline_survives_pause_and_does_not_pop_argument_twice() { + let mut vm = vm("SLEEP 2\nn%=n%+1\nEND"); + let mut host = HostStub::default(); + assert_eq!( + poll(&mut vm, &mut host), + PollResult::Waiting { + deadline: Some(2000) + } + ); + host.now = 1000; + pause(&mut vm, &mut host); + assert_eq!( + poll(&mut vm, &mut host), + PollResult::Waiting { + deadline: Some(2000) + } + ); + host.now = 2000; + end(&mut vm, &mut host); + assert!(matches!(vm.inspect("n"), Some(Value::Int(1)))); +} +#[test] +fn dialogs_keep_partial_text_focus_and_caller_arguments() { + for (source, chars) in [ + ("s$=INPUTBOX$(\"Prompt\",\"Title\")\nEND", "ab"), + ("n%=MSGBOX(\"Prompt\",256+1,\"Title\")\nEND", ""), + ] { + let mut vm = vm(source); + let mut host = HostStub::default(); + input(&mut host, chars); + for _ in 0..5 { + assert!(matches!( + poll(&mut vm, &mut host), + PollResult::Waiting { .. } + )); + } + pause(&mut vm, &mut host); + input(&mut host, "\r"); + end(&mut vm, &mut host); + if chars.is_empty() { + assert!(matches!(vm.inspect("n"), Some(Value::Int(2)))); + } else { + assert_eq!(text(&vm, "s"), "ab"); + } + } +} +#[test] +fn modal_and_modeless_forms_resume_handlers_across_tiny_budgets() { + use tb_frontend::forms::{FormCatalog, ObjectClass}; + let mut catalog = FormCatalog::default(); + catalog.add("Form1", ObjectClass::Form, None, false); + catalog.add("Timer1", ObjectClass::Timer, Some("Form1"), false); + for modal in [false, true] { + let source = format!("Timer1.Interval=100\nTimer1.Enabled=-1\nForm1.Show {}\n{}\nSUB Timer1_Timer\nFOR i%=1 TO 100\nNEXT\nForm1.Hide\nEND SUB", if modal {"1"} else {"0"}, if modal {"END"} else {""}); + let mut vm = Vm::new(tb_vm::compile_source_with_forms("FORM1", &source, &catalog).unwrap()); + let mut host = HostStub::default(); + let result = poll(&mut vm, &mut host); + if modal { + assert!(matches!(result, PollResult::Waiting { .. })); + } else { + assert_eq!(result, PollResult::Event(RunEvent::Ended)); + } + pause(&mut vm, &mut host); + host.now = 100; + for _ in 0..1000 { + let result = if modal { + vm.poll(&mut host, 3) + } else { + vm.poll_visible_forms(&mut host, 3) + }; + if result == PollResult::Event(RunEvent::Ended) && !vm.forms.has_visible_forms() { + break; + } + } + assert!(!vm.forms.has_visible_forms()); + } +} +#[test] +fn pending_dialog_keeps_physical_resize_and_modeless_handler_finishes_after_hide() { + let mut vm = vm("s$=INPUTBOX$(\"Prompt\")\nEND"); + let mut host = HostStub::default(); + assert!(matches!( + poll(&mut vm, &mut host), + PollResult::Waiting { .. } + )); + vm.rt.ereignis(Ereignis::Groesse { + cols: 120, + rows: 40, + }); + input(&mut host, "\r"); + end(&mut vm, &mut host); + assert_eq!((vm.rt.screen.cols(), vm.rt.screen.rows()), (120, 40)); + use tb_frontend::forms::{FormCatalog, ObjectClass}; + let mut catalog = FormCatalog::default(); + catalog.add("Form1", ObjectClass::Form, None, false); + let module=tb_vm::compile_source_with_forms("FORM1","Form1.Show\nSUB Form_KeyPress(KeyAscii AS INTEGER)\nForm1.Hide\nPRINT \"after hide\"\nEND SUB",&catalog).unwrap(); + let mut vm = Vm::new(module); + assert_eq!(poll(&mut vm, &mut host), PollResult::Event(RunEvent::Ended)); + input(&mut host, "a"); + for _ in 0..100 { + if vm.poll_visible_forms(&mut host, 1) == PollResult::Event(RunEvent::Ended) { + break; + } + } + assert!(snapshot::text(&vm.rt.screen).contains("after hide")); +} +#[test] +fn common_dialog_external_call_keeps_byref_arguments_until_completion() { + let mut vm=vm("DECLARE SUB FindText (s AS STRING, flags AS INTEGER, cancelled AS INTEGER)\ns$=\"before\"\nFindText s$, flags%, cancelled%\nEND"); + let mut host = HostStub::default(); + assert!(matches!( + poll(&mut vm, &mut host), + PollResult::Waiting { .. } + )); + pause(&mut vm, &mut host); + input(&mut host, "\r"); + end(&mut vm, &mut host); + assert_eq!(text(&vm, "s"), "before"); + assert!(matches!(vm.inspect("cancelled"), Some(Value::Int(0)))); +} diff --git a/docs/ide-bedienung.md b/docs/ide-bedienung.md index 2b24b1c..46c3c8f 100644 --- a/docs/ide-bedienung.md +++ b/docs/ide-bedienung.md @@ -133,24 +133,81 @@ Alle Referenzbefehle sind in ihrer Menüfolge vorhanden. Fachbefehle der folgenden Changes sind sichtbar deaktiviert und zeigen einen Verfügbarkeitsgrund; ihre Kürzel gelangen nicht versehentlich an BASIC. Ctrl+C im Editor ist Kopieren, bei laufendem Programm mit -Output-Fokus wird Ctrl+C an den Programmeingabepfad weitergereicht. +Output-Fokus unterbricht Ctrl+C sofort die Sitzung, ohne eine BASIC-Taste einzureihen. | Bereich | Rahmenaktion jetzt | Fachaktion im Folgechange | | --- | --- | --- | -| File | New/Open/Save Project, New Form/Module, Add/Remove File, Save File/As, Load/Save Text, Exit | Print/Shell: 04 | +| File | New/Open/Save Project, New Form/Module, Add/Remove File, Save File/As, Load/Save Text, Print (UTF-8-Datei), Shell, Exit | — | | Edit | Undo, Cut/Copy/Paste/Clear, New Sub/Function | Event Procedures: 05 | -| View | Code/Form-Auswahl, Prozedurauswahl, Included File/Lines, Diagnostics, Designer Menu Bar/Value-Fokus | Output Screen: 04; Grid Lines: 05; Next Statement: 06 | +| View | Code/Form-Auswahl, Prozedurauswahl, Included File/Lines, Diagnostics, Designer Menu Bar/Value-Fokus, Output Screen | Grid Lines: 05; Next Statement: 06 | | Search | Find, Selected Text, Repeat Last Find, Change | — | -| Run | Set Start-up File und beide Make-Dialoge | Start/Restart/Continue/COMMAND$ und Startup-Ausführungswirkung: 04; native Erzeugung: Phase 6 | +| Run | Start/Restart/Continue/Pause, COMMAND$, wirksame Startdatei und beide Make-Dialoge | Native Erzeugung: Phase 6 | | Debug | Vollständiges Menü und Kürzelzuordnung, einschließlich F7/F8/F9/F10 und History | Watches, Watchpoints, Trace, History, Breakpoints, Break on Errors, Set Next Statement: 06 | | Options | Display, Set Paths, Right Mouse, Save, Syntax Checking einschließlich Zeilenprüfung | Kontext-Hilfe: 07 | -| Window | New Window, Arrange All, Calls/Debug/Help/Immediate/Output/Project, aktive Codeliste, alle Fensterkürzel | Output-Inhalt: 04; Debug/Calls/Immediate-Inhalte: 06; Hilfeinhalt: 07 | +| Window | New Window, Arrange All, Calls/Debug/Help/Immediate/Output/Project, aktive Codeliste, alle Fensterkürzel | Debug/Calls/Immediate-Inhalte: 06; Hilfeinhalt: 07 | | Designer Tools/Window | Tools-Menü, Formliste und gemeinsamer Fenster-/Modusrahmen | Controls/Toolbox, Color Palette, Menu Design Window: 05 | | Help | Vollständiges Menü, About und Help-Fensterrahmen | Index, Contents, Keyboard, Topic, Using Help, Tutorial: 07 | -Der Editor einschließlich inkrementeller Übersetzung ist umgesetzt. Die gesamte +Editor, inkrementelle Übersetzung und Ausführung sind umgesetzt. Die gesamte Phase-5-Abnahme erfolgt in Change 08. +## Programme ausführen + +Shift+F5/Run → Start kompiliert den aktuellen Projektstand einschließlich +ungespeicherter Dokumente und startet eine frische TBVM. F5/Continue startet +im Bearbeitungsmodus oder setzt eine Pause fort. Run → Pause und Ctrl+Break +unterbrechen auch bei Editorfokus; Ctrl+C unterbricht ausschließlich bei +Programmfokus. Die Statusmeldung nennt Quelldatei und Unterbrechungszeile. +STOP pausiert. END/SYSTEM beenden die Sitzung, erhalten die letzte Ausgabe +und lassen die IDE offen. Natürliches Modulende hält sichtbare Formulare +weiter bedienbar. Laufzeitfehler erscheinen als Diagnose +mit Fehlernummer und Zeile; Kompilierfehler verhindern einen neuen Lauf. + +Nach Quelländerungen während einer Pause verlangt Continue eine Entscheidung: +aktuellen Stand neu starten oder das alte Kompilat ausdrücklich fortsetzen. +Die zweite Wahl kennzeichnet das Output-Fenster mit **ALTES KOMPILAT**. +Laufender Code wird nicht nachträglich ersetzt. Der Dialog Modify COMMAND$ +setzt Argumente für den nächsten Start. Restart und BASIC RUN erzeugen eine +neue VM samt Variablen, DATA-Zeiger, Fehlerzustand, Forms, Timern und Dateien. +RUN mit Dateiname löst das Ziel relativ zum Ausführungsziel auf; fremde Ziele +ersetzen das bearbeitete IDE-Projekt nicht. RUN auf dasselbe Ziel verwendet +den aktuellen Editorstand. Fehlende Ziele erhalten die Dokumente. + +Eine ausgewählte Startdatei läuft zuerst; die übrigen Modulrümpfe behalten +ihre relative Reihenfolge. Ein ausgewähltes FRM wird Startformular, ein +ausgewähltes BAS verhindert den automatischen Formularstart allein aufgrund +der Projektmitgliedschaft. Ohne Auswahl bleiben Projektreihenfolge und erstes +Startformular wie bisher. CLI und gespeichertes TBC verwenden denselben Vertrag. + +Window → Output und F4 zeigen denselben TextScreen. Das kleine Fenster schneidet +die Ansicht ab, verändert aber weder BASIC-Farben noch logische Bildschirmgröße. +Nur ein echtes Terminal-Resize verändert den Laufzeitbildschirm. Auch während +INPUT, LINE INPUT, INPUT$, RANDOMIZE ohne Argument, SLEEP und modaler Dialoge +bleiben Umschalten und Pausieren möglich; Eingabeteile, Prompt, Fokus und +Deadline bleiben erhalten. Eine Pause friert die Hostuhr nicht ein: Eine +verstrichene SLEEP-Deadline wird nach Continue sofort erkannt. + +File → Shell und BASIC SHELL geben Raw Mode, Maus-/Paste-Erfassung und +Alternativschirm an ein Vordergrund-Kindprogramm mit geerbtem Standard-I/O ab. +Nach Ende, Fehler oder Ctrl+C werden Terminal und bisheriger IDE-Fokus +wiederhergestellt. SHELL als Funktion liefert den Exit-Code (bei unterbrochenem +Kind ohne regulären Exit-Code wie bisher: 0); ein fehlgeschlagener Prozessstart wird +BASIC-Fehler 53. Die IDE führt während der Übergabe keine VM-Schritte aus. + +File → Print ist eine Plattformanpassung: Der Dialog schreibt Auswahl oder +Gesamttext als UTF-8 in eine wählbare Druckdatei (Vorgabe `LPT1.TXT`). Er nutzt +dieselben geschützten, atomaren Dateipfade wie Save Text. Überschreiben muss +explizit gewählt werden; Projekt-/Quelldateien und originale Binärdateien sind +weiter geschützt. Fehler halten den Dialog offen und verändern den Quelltext +nicht. Ein Druckertreiber ist dafür nicht erforderlich. + +Die VM gibt nach einem festen Instruktionsbudget oder bei ausstehender Eingabe +die Kontrolle zurück. Synchrone Betriebssystemoperationen wie Öffnen, Lesen, +Schreiben, Datenbank-I/O oder Dateiauswahl lassen sich innerhalb eines laufenden +Systemaufrufs nicht unterbrechen. Sie sind keine Eingabewarte-Schleifen; nach +Rückkehr gelten wieder die normalen Unterbrechungsgrenzen. Es gibt keinen +VM-Hintergrundthread und keine zweite Kopie des Programmbildschirms. + ## Reproduzierbarer Testpfad `cargo test -p tb-ide` prüft denselben `App::handle(Event)`-Dispatcher und @@ -244,3 +301,9 @@ geöffnete Includes werden erneut vom Loader gelesen und verglichen. Start und Fortsetzen der VM werden in Change 04 an diese Schnittstelle angebunden. Release-Messungen und Aufruf stehen in [tbvm-design.md](tbvm-design.md). + +Die Ausführungstests liegen in `crates/tb-ide/tests/execution.rs` und +`crates/tb-vm/tests/cooperative.rs`. Zusätzlich prüft +`cargo build -p tb-ide && python3 tests/support/ide-execution-pty.py` unter Unix +in einem unsichtbaren PTY die echten Shell-/Terminalübergaben und das Fortsetzen +nach STOP. Das Skript verwendet ausschließlich temporäre Projekte. diff --git a/docs/ide-referenz.md b/docs/ide-referenz.md index 31f5b6f..775ced0 100644 --- a/docs/ide-referenz.md +++ b/docs/ide-referenz.md @@ -27,7 +27,7 @@ Window Help**. `───` = Separator, `•` = Toggle-Markierung. *(Designer-Modus: … ─ Menu Bar **F10** / Grid Lines)* - **Search (Alt+S):** Find… / Selected Text **Ctrl+\\** / Repeat Last Find **F3** / Change… -- **Run (Alt+R):** Start **Shift+F5** / Restart / Continue **F5** / +- **Run (Alt+R):** Start **Shift+F5** / Restart / Continue **F5** / Pause (Ctrl+Break) / Modify COMMAND$… ─── Make EXE File… / Make Library… ─── Set Start-up File… - **Debug (Alt+D):** Add Watch… / Instant Watch… **Shift+F9** / Watchpoint… / Delete Watch… / Delete All Watch ─── •Trace On / •History On ─── diff --git a/openspec/changes/archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md b/openspec/changes/archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md index 52ad37f..5366cc1 100644 --- a/openspec/changes/archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md +++ b/openspec/changes/archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md @@ -31,7 +31,7 @@ Besondere Risiken sind unverändert ausgeführte alte Kompilate nach einer Text | 01 | [Projekt- und Dokumentmodell](proposal.md) | keine | Gemeinsame Lade-/Speicheraktionen, bearbeitete Quellen, MAK und Startup-Metadaten | | 02 | [IDE-Rahmen](../2026-09-06-phase-5-02-ide-rahmen/proposal.md) | 01 | Terminalbesitz, Menüs, Fenster, Dialoge, Optionen und Eingabetestpfad | | 03 | [Editor und inkrementelle Übersetzung](../2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md) | 01, 02 | Vollständige Codebearbeitung, Diagnose und schneller aktueller Compile | -| 04 | [Ausführung und Output](../../phase-5-04-ausfuehrung-und-output/proposal.md) | 01, 02, 03 | Fortsetzbare VM-Sitzung, Reset, Ausgabe, Shell und Textdruck | +| 04 | [Ausführung und Output](../2026-09-06-phase-5-04-ausfuehrung-und-output/proposal.md) | 01, 02, 03 | Fortsetzbare VM-Sitzung, Reset, Ausgabe, Shell und Textdruck | | 05 | [Formular-Designer](../../phase-5-05-formular-designer/proposal.md) | 01, 02, 03 | Visuelles Gestalten und konsistenter Ereigniscode | | 06 | [Debugger und Direktfenster](../../phase-5-06-debugger-und-direktfenster/proposal.md) | 03, 04 | Quellgenaues Debuggen, Watches, Immediate, History und Fehlerhalte | | 07 | [Hilfesystem](../../phase-5-07-hilfesystem/proposal.md) | 02, 03 | Offline-Markdown-Hilfe und Kontextnavigation | diff --git a/openspec/changes/phase-5-04-ausfuehrung-und-output/.openspec.yaml b/openspec/changes/archive/2026-09-06-phase-5-04-ausfuehrung-und-output/.openspec.yaml similarity index 100% rename from openspec/changes/phase-5-04-ausfuehrung-und-output/.openspec.yaml rename to openspec/changes/archive/2026-09-06-phase-5-04-ausfuehrung-und-output/.openspec.yaml diff --git a/openspec/changes/phase-5-04-ausfuehrung-und-output/design.md b/openspec/changes/archive/2026-09-06-phase-5-04-ausfuehrung-und-output/design.md similarity index 100% rename from openspec/changes/phase-5-04-ausfuehrung-und-output/design.md rename to openspec/changes/archive/2026-09-06-phase-5-04-ausfuehrung-und-output/design.md diff --git a/openspec/changes/phase-5-04-ausfuehrung-und-output/proposal.md b/openspec/changes/archive/2026-09-06-phase-5-04-ausfuehrung-und-output/proposal.md similarity index 72% rename from openspec/changes/phase-5-04-ausfuehrung-und-output/proposal.md rename to openspec/changes/archive/2026-09-06-phase-5-04-ausfuehrung-und-output/proposal.md index c7b733a..36d76b6 100644 --- a/openspec/changes/phase-5-04-ausfuehrung-und-output/proposal.md +++ b/openspec/changes/archive/2026-09-06-phase-5-04-ausfuehrung-und-output/proposal.md @@ -23,6 +23,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt tb-ide-Sitzung, tb-vm/interp.rs, CLI-run_chain, tb-runtime/host.rs und builtins.rs sowie tb-ui/host.rs und screen.rs. Vorhandene Ereignisse, TextScreen und CLI-Ausführung bleiben die Grundlage. -**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md). +**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md). -**Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. +**Gesamtplanung:** [Phase-5-Übersicht](../2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. diff --git a/openspec/changes/phase-5-04-ausfuehrung-und-output/specs/ide-ausfuehrung/spec.md b/openspec/changes/archive/2026-09-06-phase-5-04-ausfuehrung-und-output/specs/ide-ausfuehrung/spec.md similarity index 100% rename from openspec/changes/phase-5-04-ausfuehrung-und-output/specs/ide-ausfuehrung/spec.md rename to openspec/changes/archive/2026-09-06-phase-5-04-ausfuehrung-und-output/specs/ide-ausfuehrung/spec.md diff --git a/openspec/changes/phase-5-04-ausfuehrung-und-output/tasks.md b/openspec/changes/archive/2026-09-06-phase-5-04-ausfuehrung-und-output/tasks.md similarity index 69% rename from openspec/changes/phase-5-04-ausfuehrung-und-output/tasks.md rename to openspec/changes/archive/2026-09-06-phase-5-04-ausfuehrung-und-output/tasks.md index 850c004..2ccc1b4 100644 --- a/openspec/changes/phase-5-04-ausfuehrung-und-output/tasks.md +++ b/openspec/changes/archive/2026-09-06-phase-5-04-ausfuehrung-und-output/tasks.md @@ -1,27 +1,27 @@ ## 1. Fortsetzbare Ausführung -- [ ] 1.1 Einen begrenzten VM-Pollpfad mit eigenem Yield-Zustand ergänzen und run darüber treiben; Tests belegen Unterbrechbarkeit leerer Schleifen, unveränderte RunEvents und keine zusätzlichen Clock-Abfragen im trapfreien Standardlauf. -- [ ] 1.2 INPUT/LINE INPUT einschließlich teilweise gelesener Eingabe als fortsetzbare Operation modellieren; Pause/Resume-Tests belegen einmaligen Prompt, erhaltenen Stack und keine doppelten Zuweisungen. -- [ ] 1.3 INPUT$ und SLEEP an fortsetzbare Eingabe-/Deadline-Zustände anbinden; Tests belegen Zeichenreste, Timeout und Pause ohne wiederholte Nebenwirkungen. -- [ ] 1.4 MSGBOX, INPUTBOX, modales SHOW und die sichtbare Forms-Schleife kooperativ treiben; Tests belegen Unterbrechen/Fortsetzen einschließlich Timer-, Fokus- und Handlerzustand. -- [ ] 1.5 Alle weiteren Host-Warteaufrufer gegen den Pollpfad abgleichen und eine konkrete Liste in verification.md führen; jede wartende Operation besitzt einen Fortsetzungsnachweis oder eine dokumentierte synchrone Betriebssystemgrenze. +- [x] 1.1 Einen begrenzten VM-Pollpfad mit eigenem Yield-Zustand ergänzen und run darüber treiben; Tests belegen Unterbrechbarkeit leerer Schleifen, unveränderte RunEvents und keine zusätzlichen Clock-Abfragen im trapfreien Standardlauf. +- [x] 1.2 INPUT/LINE INPUT einschließlich teilweise gelesener Eingabe als fortsetzbare Operation modellieren; Pause/Resume-Tests belegen einmaligen Prompt, erhaltenen Stack und keine doppelten Zuweisungen. +- [x] 1.3 INPUT$ und SLEEP an fortsetzbare Eingabe-/Deadline-Zustände anbinden; Tests belegen Zeichenreste, Timeout und Pause ohne wiederholte Nebenwirkungen. +- [x] 1.4 MSGBOX, INPUTBOX, modales SHOW und die sichtbare Forms-Schleife kooperativ treiben; Tests belegen Unterbrechen/Fortsetzen einschließlich Timer-, Fokus- und Handlerzustand. +- [x] 1.5 Alle weiteren Host-Warteaufrufer gegen den Pollpfad abgleichen und eine konkrete Liste in verification.md führen; jede wartende Operation besitzt einen Fortsetzungsnachweis oder eine dokumentierte synchrone Betriebssystemgrenze. ## 2. IDE-Sitzung und gemeinsamer Reset -- [ ] 2.1 Die Sitzungszustände und aktuellen Kompilatrevisionen an Start/Continue/Unterbrechen anbinden; App-Eventtests prüfen F5/Shift+F5/Ctrl+Break und die explizite Wahl bei Quelländerung im Break-Modus. -- [ ] 2.2 STOP, END, SYSTEM und Fehler auf IDE-Zustände abbilden; Tests bestätigen Pause bei STOP, offene IDE nach Ende und unveränderte CLI-Exit-Codes. -- [ ] 2.3 Explizite Startup-Auswahl aus 01 im gemeinsamen Compiler umsetzen; IDE-/CLI-/TBC-Tests belegen BAS ohne implizites Startformular, ausgewähltes FRM und unverändertes Verhalten alter MAK-Dateien. -- [ ] 2.4 run_target/run_chain-Reset wiederverwenden und IDE-Restart/BASIC-RUN anschließen; Tests belegen neue VM/Runtime, relative Fremdziele, Startzeile und Erhalt des bearbeiteten Projekts bei fehlendem Ziel. -- [ ] 2.5 COMMAND$-Dialog und kontrolliertes Sitzungsende implementieren; wiederholte Starts/Resets prüfen Argumente sowie fehlende alte Timer, Handles und ausstehende Aktionen. +- [x] 2.1 Die Sitzungszustände und aktuellen Kompilatrevisionen an Start/Continue/Unterbrechen anbinden; App-Eventtests prüfen F5/Shift+F5/Ctrl+Break und die explizite Wahl bei Quelländerung im Break-Modus. +- [x] 2.2 STOP, END, SYSTEM und Fehler auf IDE-Zustände abbilden; Tests bestätigen Pause bei STOP, offene IDE nach Ende und unveränderte CLI-Exit-Codes. +- [x] 2.3 Explizite Startup-Auswahl aus 01 im gemeinsamen Compiler umsetzen; IDE-/CLI-/TBC-Tests belegen BAS ohne implizites Startformular, ausgewähltes FRM und unverändertes Verhalten alter MAK-Dateien. +- [x] 2.4 run_target/run_chain-Reset wiederverwenden und IDE-Restart/BASIC-RUN anschließen; Tests belegen neue VM/Runtime, relative Fremdziele, Startzeile und Erhalt des bearbeiteten Projekts bei fehlendem Ziel. +- [x] 2.5 COMMAND$-Dialog und kontrolliertes Sitzungsende implementieren; wiederholte Starts/Resets prüfen Argumente sowie fehlende alte Timer, Handles und ausstehende Aktionen. ## 3. Terminal und Ausgabe -- [ ] 3.1 IDE-/Programmeingaben über den Dispatcher aus 02 exklusiv zustellen; Eventtests prüfen Copy bei Editorfokus, Break bei Outputfokus und normale BASIC-Tasten genau einmal. -- [ ] 3.2 Output-Fenster und F4-Vollbild auf demselben TextScreen darstellen und die physische Mindestgrößenprüfung vom Viewport lösen; Snapshots prüfen kleine Output-Fenster, echtes Resize und erhaltene Endausgabe. -- [ ] 3.3 Terminalübergabe für File→Shell und BASIC-SHELL über einen gemeinsamen Hostpfad implementieren; kontrollierte Kindprozess-Tests prüfen Erfolg, Exit-Code, Benutzerabbruch und Terminalwiederherstellung. -- [ ] 3.4 File→Print mit Auswahl/Gesamttext und UTF-8-Zieldatei an die geschützte Dateiausgabe aus 01 binden; Tests belegen Inhalt, Überschreibentscheidung und Schreibfehler ohne Datenverlust. +- [x] 3.1 IDE-/Programmeingaben über den Dispatcher aus 02 exklusiv zustellen; Eventtests prüfen Copy bei Editorfokus, Break bei Outputfokus und normale BASIC-Tasten genau einmal. +- [x] 3.2 Output-Fenster und F4-Vollbild auf demselben TextScreen darstellen und die physische Mindestgrößenprüfung vom Viewport lösen; Snapshots prüfen kleine Output-Fenster, echtes Resize und erhaltene Endausgabe. +- [x] 3.3 Terminalübergabe für File→Shell und BASIC-SHELL über einen gemeinsamen Hostpfad implementieren; kontrollierte Kindprozess-Tests prüfen Erfolg, Exit-Code, Benutzerabbruch und Terminalwiederherstellung. +- [x] 3.4 File→Print mit Auswahl/Gesamttext und UTF-8-Zieldatei an die geschützte Dateiausgabe aus 01 binden; Tests belegen Inhalt, Überschreibentscheidung und Schreibfehler ohne Datenverlust. ## 4. Gemeinsame Regression und Dokumentation -- [ ] 4.1 IDE- und CLI-Läufe mit identischem Input-/Zeit-/Forms-Skript vergleichen; vorhandene VM-, Fehlerhandler-, Runtime-, Forms- und CLI-Regressionen müssen nach dem Poll-Umbau bestehen. -- [ ] 4.2 Sitzungsübergänge, Startup-Metadatenwirkung, Shell-/Druckanpassung und Unterbrechungsgrenzen dokumentieren; verification.md ordnet alle Spec-Szenarien den ausgeführten Nachweisen zu und Format-/Clippy-Prüfung besteht. +- [x] 4.1 IDE- und CLI-Läufe mit identischem Input-/Zeit-/Forms-Skript vergleichen; vorhandene VM-, Fehlerhandler-, Runtime-, Forms- und CLI-Regressionen müssen nach dem Poll-Umbau bestehen. +- [x] 4.2 Sitzungsübergänge, Startup-Metadatenwirkung, Shell-/Druckanpassung und Unterbrechungsgrenzen dokumentieren; verification.md ordnet alle Spec-Szenarien den ausgeführten Nachweisen zu und Format-/Clippy-Prüfung besteht. diff --git a/openspec/changes/archive/2026-09-06-phase-5-04-ausfuehrung-und-output/verification.md b/openspec/changes/archive/2026-09-06-phase-5-04-ausfuehrung-und-output/verification.md new file mode 100644 index 0000000..fd8246a --- /dev/null +++ b/openspec/changes/archive/2026-09-06-phase-5-04-ausfuehrung-und-output/verification.md @@ -0,0 +1,79 @@ +# Verifizierung: Phase 5 / 04 – Ausführung und Output + +Stand: 2026-09-06. Geprüft gegen proposal.md, design.md, alle 16 Aufgaben und +alle sechs Requirements mit neun Szenarien in specs/ide-ausfuehrung/spec.md. + +## Ergebnis + +Vollständig umgesetzt. Keine offenen Befunde in Vollständigkeit, Korrektheit +oder Kohärenz. Am 2026-09-06 wurde die Hauptspezifikation `ide-ausfuehrung` +synchronisiert, mit 19 Hauptspezifikationen erfolgreich validiert und der +Change unter `archive/2026-09-06-phase-5-04-ausfuehrung-und-output` archiviert. + +Die VM läuft auf dem IDE-Thread in begrenzten Intervallen. Yield und Waiting +sind von RunEvent und Debugger-Ereignissen getrennt. Der CLI-Wrapper treibt +dieselben Operationen bis zum nächsten RunEvent. Bestehende Capture-/Forms- +und Fehlerhandler-Regressionen bleiben grün. Es gibt keine neuen Abhängigkeiten. + +## Szenarien und Nachweise + +| Spec-Szenario | Umsetzung | Ausgeführter Nachweis | +| --- | --- | --- | +| Startauswahl in IDE und CLI | `ProjectSources::compile`, nach Startdatei geordnete SourceUnits, explizites BAS ohne Startformular, FRM-Auswahl im TBC | IDE `startup_selection_matches_shared_cli_pipeline_and_legacy_mak`; CLI `explicit_startup_order_and_form_selection_survive_tbc`: Reihenfolge B/A, ausgewähltes zweites FRM, Legacy-Standard, COMMAND$, TBC-Rundlauf | +| Änderung während einer Pause | Sitzung bindet Bytecode und vollständige Quellrevision; Entscheidung Neustart/ausdrücklich altes Kompilat, sichtbare Kennzeichnung | IDE `changed_paused_source_requires_explicit_old_revision_or_restart`, `comment_only_source_change_also_requires_revision_choice`; ungültiger neuer Stand startet keine VM | +| Abbruch während INPUT | Frame besitzt Teiltext, Restlänge und Stack-Argumente; Pause nennt Quelldatei/Zeile | IDE `f5_input_pause_copy_and_single_delivery_keep_the_same_vm`; VM `partial_console_operations_keep_prompt_arguments_and_assignment`: INPUT, LINE INPUT, INPUT$, RANDOMIZE, Prompt und Zuweisung genau einmal | +| Enge Schleife und Formular | Feste Instruktionsbudgets, fortsetzbares SHOW und Forms-Pump, Pause/F5/F4 | VM `finite_slices_preserve_events_without_clock_queries`, `modal_and_modeless_forms_resume_handlers_across_tiny_budgets`; IDE `explicit_end_and_system_end_visible_forms_but_natural_end_keeps_them_live`, einschließlich Ctrl+Break unterhalb der Terminal-Mindestgröße | +| Wiederholter Neustart | `new_execution` als gemeinsamer Reset; altes VM-/Runtime-Objekt wird verworfen; separates Ausführungsziel | IDE `restart_drops_files_data_traps_and_queued_actions`, `restart_command_run_targets_and_missing_target_preserve_documents`, `named_run_of_current_target_uses_edited_sources`: offene Datei, DATA, Timer-Trap, Variablen, COMMAND$, ausstehende Shell/Eingaben, RUN-Startzeile, relative/fehlende Ziele und erhaltene Dokumente | +| Kleines Ausgabefenster | ScreenWidget als abgeschnittene Ansicht des einzigen Programmpuffers; physische Mindestgröße beim Terminalbesitzer | IDE `output_viewports_resize_and_end_preserve_buffer_and_basic_colors`: 30×8-Fenster, F4, BASIC-Farben, 120×40-Resize, erhaltene Endausgabe; VM `pending_dialog_keeps_physical_resize_and_modeless_handler_finishes_after_hide` | +| Copy und Programmeingabe | App-Dispatcher entscheidet vor Runtime-Zustellung; lokale Mauskoordinaten im Output-Viewport | IDE `f5_input_pause_copy_and_single_delivery_keep_the_same_vm`, bestehender `mouse_focus_is_modal_and_program_abort_is_not_editor_copy`: Ctrl+C im Editor kopiert, im Output pausiert es; Zeichen kommen einmal an | +| Shell kehrt zurück | Gemeinsamer Host-Shell-Einstieg und gemeinsamer TerminalGuard; IDE plant Übergabe außerhalb des VM-Polls; Kind wird abgewartet bzw. beendet | IDE `shell_request_is_resumed_once_and_error_is_basic_error`; UI `terminal_cleanup_on_normal_error_and_partial_initialization`: Exit 0/7, kontrollierter Abbruch ohne regulären Exit-Code mit unverändertem Rückgabewert 0, Modusrestaurierung; echter PTY-Smoke für BASIC-SHELL, Ctrl+C und File→Shell mit Rückkehr, Continue und identischen Terminalattributen | +| Druckziel nicht schreibbar | Print nutzt geschütztes `Project::save_text` und atomare Ausgabe; Auswahl/Gesamttext, Vorgabe LPT1.TXT | IDE `print_uses_utf8_protected_output_and_does_not_mutate_source`: UTF-8, Auswahl/Gesamttext, explizites Überschreiben, geschütztes Quellziel und nicht beschreibbarer Pfad unter einer regulären Datei; Quelldatei und bestehender Zielbestand bleiben unverändert | + +Zusätzliche Nachweise: `sleep_deadline_survives_pause_and_does_not_pop_argument_twice`, +`dialogs_keep_partial_text_focus_and_caller_arguments` und +`common_dialog_external_call_keeps_byref_arguments_until_completion` prüfen +Deadline, MSGBOX/INPUTBOX samt Fokus und Common-Dialog-Aufrufe mit ByRef-Argumenten. +`ide_and_cli_run_wrapper_match_input_time_and_forms_script` vergleicht einen +identischen Forms-Timer-/SLEEP-/Eingabe-/COMMAND$-Ablauf und die fertige Ausgabe. + +## Aufgabenabdeckung + +- 1.1–1.5: VM-Poll, Frame-Fortsetzungen, Dialogmodell, Forms-Pump und untenstehender Wartepfad-Audit; sieben gezielte VM-Tests plus bestehende Ereignis-/Forms-Regressionen. +- 2.1–2.5: `execution.rs`, App-Befehle und gemeinsame Projekt-/Reset-Funktionen; zwölf IDE-Ausführungstests, CLI-Startup-/TBC- und bestehende STOP-Exitcode-Tests. +- 3.1–3.4: Input-Dispatcher, ScreenWidget, gemeinsamer TerminalGuard, Print-Dialog; IDE-, UI- und reale PTY-Nachweise. +- 4.1–4.2: gemeinsamer Skriptvergleich, vollständige Workspace-Regression, Bedienungsdokumentation, dieser Report und Format-/Clippy-Prüfung. + +## Audit aller Wartepfade + +| Pfad | Verhalten im IDE-Poll / Grenze | +| --- | --- | +| `Vm::konsoleneingabe` → INPUT/LINE INPUT | Teiltext im Frame; höchstens 64 gepufferte Tasten je Leseraufruf; danach Fortschritt oder Waiting, kein Host-Warten. Referenzen bleiben bis zur Zuweisung auf dem Stack. | +| `CallBuiltin(INPUT_S, 1)` | Derselbe Leser mit erhaltener Restlänge und Tastencode-Rest; Datei-Variante mit zwei Argumenten bleibt synchrones Datei-I/O. | +| `CallBuiltin(RANDOMIZE, 0)` | Derselbe Leser; Seed-Prompt einmal, Saatsetzung erst nach vollständiger Eingabe. Direkter Runtime-Fallback `bi_randomize`/`read_line` wird von der VM nicht verwendet. | +| `Instr::Sleep` | Absolute Host-Deadline im Frame; Waiting nennt auch frühere Trap-/Forms-Fristen. Ereignishandler beenden SLEEP wie bisher. Der direkte Runtime-Fallback `bi_sleep` wird vom Codegenerator nicht als Wartepfad verwendet. | +| `forms_dialog` → MSGBOX/INPUTBOX sowie ABOUT/FILEOPEN/FILESAVE/FINDTEXT/CHANGETEXT | `Dialog::poll` bewahrt Modell, Fokus, Eingabetext und Hintergrund; ein Ereignis pro Aufruf, kein blockierendes next_event. Common-Dialog-Argumente verbleiben bis Abschluss auf dem Stack. Resize erreicht auch den aufbewahrten Hintergrund. | +| Modales SHOW / `waiting_form` | Waiting statt `warten`; Handler und wartender Aufrufer bleiben getrennte Frames. | +| `poll_visible_forms` | Handler laufen auch über Budgetgrenzen und nach Hide vollständig zu Ende; EOF verarbeitet zuvor angenommene Ereignisse. | +| `Vm::warten`, `run`, `run_visible_forms` | Blockierende Komfort-Wrapper für CLI/Capture; ausschließlich diese Wrapper rufen den Host-Wartepfad auf. Der explizite alte `run_visible_forms`-Einstieg bleibt für bestehende Einbetter erhalten; IDE/CLI unterscheiden natürliches Ende von END/SYSTEM. | +| `Dialog::run`, `msgbox_dialog`, `inputbox_dialog` | Synchrone UI-Komfort-Wrapper um denselben Dialog-Poll. Die VM verwendet die fortsetzbaren Konstruktoren und `Dialog::poll`. | +| `RtState::naechste_taste(true)`, `read_line`, direkte `bi_input_s`/`bi_sleep` | Kompatibilitäts-APIs für direkte Runtime-Nutzer; kein erreichbarer IDE-VM-Wartepfad. INKEY$ bleibt nicht blockierend. | +| `Host::shell` | IDE: Anfrage/Ergebnis-Fortsetzung. `main` gibt das Terminal an den Vordergrundprozess ab; Erfolg, Fehler und Abbruch stellen es wieder her. CLI/sonstige synchrone Hosts dürfen den Kindprozess in diesem ausdrücklich synchronen Host-Einstieg abwarten. | +| `TerminalHost::warten`, CLI `PipeHost::warten`/stdin | Terminal-/CLI-Ereignisschleifen, außerhalb des IDE-Hosts. `IdeHost::warten` würde im Test sofort fehlschlagen. | +| OPEN/CLOSE, INPUT#/LINE INPUT#/INPUT$(…, Datei), GET/PUT, Verzeichnis-/Dateioperationen, ISAM, Projekt-/Include-I/O, Compiler, Print und Save | Synchrone Betriebssystem-/Rechenoperationen, keine auf Benutzertasten wartenden Schleifen. Ein laufender Systemaufruf ist nicht unterbrechbar. Die Grenze ist in design.md vorgesehen und in der Bedienungsdokumentation beschrieben. | + +## Ausgeführte Prüfungen + +- `cargo test --workspace`: **549 bestanden, 0 fehlgeschlagen, 2 absichtlich ignoriert**, 30 Testgruppen einschließlich Doc-Tests. Ignoriert bleiben der Golden-Ausgabe-Generator und der externe VBDOS-Formularlauf mit `TB_VBDOS_REPO`. +- Nach letzter Korrektur der Sitzungsbereinigung: `cargo test -p tb-ide --test execution`: **12 bestanden**. +- `cargo clippy -p tb-ide -p tb-vm -p tb-ui -p tb-cli --all-targets -- -D warnings`: bestanden. +- `cargo fmt --all -- --check`, `git diff --check`: bestanden. +- `cargo build -p tb-ide`; `python3 tests/support/ide-execution-pty.py`: drei PTY-Szenarien bestanden; inklusive Prüfung der Terminalattribute nach IDE-Ende. +- `openspec validate --all --strict`: **23 bestanden**, keine Fehler. +- `openspec instructions apply --change phase-5-04-ausfuehrung-und-output --json`: **16/16**, `all_done`. + +Die während der Verifizierung gefundenen Differenzen wurden behoben und mit +Regressionen abgesichert: Pipe-Ausgabe bei fehlgeschlagener Terminalinitialisierung, +Forms-Ende/EOF und Budgetfortsetzung nach Hide, Dialog-Resize, identische RUN-Ziele +mit anderer Schreibweise, Kommentaränderungen bei pausiertem Kompilat sowie +veraltete Shell-Anfragen beim Reset, sofortige Eingabeumschaltung nach Ctrl+C und der unveränderte SHELLFN-Vertrag bei Signalende. Es verbleiben keine Befunde innerhalb des +Change-Vertrags. Native EXE-/Library-Erzeugung bleibt gemäß PLAN.md in Phase 6. diff --git a/openspec/changes/phase-5-06-debugger-und-direktfenster/proposal.md b/openspec/changes/phase-5-06-debugger-und-direktfenster/proposal.md index 0fefa2d..0f9a109 100644 --- a/openspec/changes/phase-5-06-debugger-und-direktfenster/proposal.md +++ b/openspec/changes/phase-5-06-debugger-und-direktfenster/proposal.md @@ -23,6 +23,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt tb-vm/interp.rs, Debug-Quellorte und Frontend/Codegen für Ausdrücke und Direktfenster; tb-ide-Debugfenster. Keine zweite Auswertungsmaschine, kein Zeitreise-Debugger. -**Abhängigkeiten:** [phase-5-03-editor-und-inkrementelle-uebersetzung](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../phase-5-04-ausfuehrung-und-output/proposal.md). +**Abhängigkeiten:** [phase-5-03-editor-und-inkrementelle-uebersetzung](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../archive/2026-09-06-phase-5-04-ausfuehrung-und-output/proposal.md). **Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. diff --git a/openspec/changes/phase-5-08-integration-und-phasenabnahme/proposal.md b/openspec/changes/phase-5-08-integration-und-phasenabnahme/proposal.md index 9294aa8..f41582a 100644 --- a/openspec/changes/phase-5-08-integration-und-phasenabnahme/proposal.md +++ b/openspec/changes/phase-5-08-integration-und-phasenabnahme/proposal.md @@ -24,6 +24,6 @@ Keine bestehenden Anforderungen werden ersetzt; die neue IDE-Fähigkeit ergänzt tb-ide-Integrationstests, vorhandene CLI-/VM-/Forms-Tests und Benchmarks, docs/ sowie PLAN.md. Featureimplementierung bleibt jeweils beim verantwortlichen Change; dieser Change schließt Integration und Nachweise. -**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../phase-5-04-ausfuehrung-und-output/proposal.md), [phase-5-05-formular-designer](../phase-5-05-formular-designer/proposal.md), [phase-5-06-debugger-und-direktfenster](../phase-5-06-debugger-und-direktfenster/proposal.md), [phase-5-07-hilfesystem](../phase-5-07-hilfesystem/proposal.md). +**Abhängigkeiten:** [phase-5-01-projekt-und-dokumentmodell](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/proposal.md), [phase-5-02-ide-rahmen](../archive/2026-09-06-phase-5-02-ide-rahmen/proposal.md), [phase-5-03-editor-und-inkrementelle-uebersetzung](../archive/2026-09-06-phase-5-03-editor-und-inkrementelle-uebersetzung/proposal.md), [phase-5-04-ausfuehrung-und-output](../archive/2026-09-06-phase-5-04-ausfuehrung-und-output/proposal.md), [phase-5-05-formular-designer](../phase-5-05-formular-designer/proposal.md), [phase-5-06-debugger-und-direktfenster](../phase-5-06-debugger-und-direktfenster/proposal.md), [phase-5-07-hilfesystem](../phase-5-07-hilfesystem/proposal.md). **Gesamtplanung:** [Phase-5-Übersicht](../archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md). Die Nummern geben eine gültige Umsetzungsreihenfolge an; OpenSpec erzwingt Change-Abhängigkeiten nicht automatisch. diff --git a/openspec/specs/ide-ausfuehrung/spec.md b/openspec/specs/ide-ausfuehrung/spec.md new file mode 100644 index 0000000..1691611 --- /dev/null +++ b/openspec/specs/ide-ausfuehrung/spec.md @@ -0,0 +1,61 @@ +# ide-ausfuehrung Specification + +## Purpose + +Führt bearbeitete Projekte innerhalb der IDE mit derselben TBVM wie die CLI aus und erhält dabei verlässliche Unterbrechung, Ausgabe und Terminalbedienung. + +## Requirements + +### Requirement: Start und revisionsgebundene Sitzung +Shift+F5/Start SHALL den aktuellen gültigen Projektstand mit gewähltem COMMAND$ in einer frischen Sitzung starten. Eine explizite Startdatei SHALL als erstes Modul ausgeführt werden; übrige Modulrümpfe folgen in ihrer relativen Projektreihenfolge. Ist die explizite Startdatei ein Formular, SHALL dieses das Startformular sein; bei explizitem BAS-Start SHALL kein Formular allein aufgrund seiner Projektmitgliedschaft automatisch gestartet werden. Ohne Auswahl SHALL die bisherige CLI-Semantik gelten. Kompilierfehler MUST einen neuen Lauf verhindern. + +#### Scenario: Startauswahl in IDE und CLI +- **WHEN** dasselbe gespeicherte Projekt mit expliziter BAS- oder FRM-Startdatei in IDE und CLI gestartet wird +- **THEN** stimmen Modulreihenfolge, initial sichtbare Formulare und COMMAND$ überein + +#### Scenario: Änderung während einer Pause +- **WHEN** Quelltext einer pausierten Sitzung geändert wird und Continue gewählt wird +- **THEN** verlangt die IDE einen Neustart oder die ausdrückliche Fortsetzung des gekennzeichneten alten Stands; sie ersetzt keinen laufenden Code unbemerkt + +### Requirement: Unterbrechen und Fortsetzen +Die IDE SHALL laufende Rechenschleifen, Forms-Ereignisverarbeitung und Eingabewarten kontrolliert unterbrechen können. Ctrl+Break, Strg+C in der Programmausgabe und ein Unterbrechen-Befehl SHALL den Break-Modus an einer sicheren Grenze aktivieren. F5/Continue SHALL den erhaltenen VM-Zustand fortsetzen. STOP SHALL in der IDE pausieren; END und SYSTEM SHALL die Sitzung beenden und die IDE offen lassen. Der CLI-STOP-Vertrag SHALL unverändert bleiben. + +#### Scenario: Abbruch während INPUT +- **WHEN** ein Programm auf INPUT wartet und der Benutzer unterbricht +- **THEN** reagiert die IDE ohne zusätzliche BASIC-Eingabe, zeigt die Unterbrechungsstelle und kann die ausstehende Eingabe beim Fortsetzen ohne doppelte Nebenwirkungen weiterführen + +#### Scenario: Enge Schleife und Formular +- **WHEN** ein Programm in einer leeren Schleife oder einer sichtbaren Form auf Ereignisse wartet +- **THEN** bleiben Output-Umschaltung und Unterbrechen bedienbar und ein Fortsetzen erhält Variablen und Formularzustand + +### Requirement: Einheitlicher Reset und RUN +Restart und BASIC-RUN SHALL denselben vollständigen Sitzungsreset nutzen: Variablen, Frames, DATA-Zeiger, Fehler-/Trapzustand, Forms, Timer, Laufzeithandles und Ausgabezustand werden wie bei einem frischen Lauf neu aufgebaut. RUN mit Ziel SHALL die bestehende relative Zielauflösung und optionale Startzeile behalten. Ein fehlendes oder ungültiges Ziel SHALL eine Diagnose liefern und die IDE sowie bearbeitete Dokumente erhalten. BASIC-RUN zu einem anderen Ziel SHALL das bearbeitete IDE-Projekt nicht ersetzen. + +#### Scenario: Wiederholter Neustart +- **WHEN** ein Programm Variablen, Dateien und Timer verändert und danach mehrfach neu startet +- **THEN** beginnt jeder Lauf mit den spezifizierten Anfangswerten und es bleiben keine alten Timer, Dateihandles oder parallelen Sitzungen zurück + +### Requirement: Output-Fenster und Vollbild +Window→Output und F4 SHALL denselben Programmbildschirm als Fenster beziehungsweise Vollbild zeigen. Eine kleinere Fensteransicht SHALL den Bildschirm abschneiden oder scrollen und MUST NOT allein durch den Ansichtswechsel die für BASIC sichtbaren Bildschirmmaße ändern. Echte Terminalgrößenänderungen SHALL über den vorhandenen Laufzeitpfad zugestellt werden. Nach Programmende SHALL die letzte Ausgabe sichtbar bleiben. + +#### Scenario: Kleines Ausgabefenster +- **WHEN** auf einem ausreichend großen Terminal ein Output-Fenster kleiner als 80×25 dargestellt und mit F4 umgeschaltet wird +- **THEN** bleiben Programminhalt und logische Laufzeitgröße erhalten; im kleinen Fenster erscheint die Ausgabe statt des Terminal-Mindestgrößenhinweises + +### Requirement: Eigentum an Eingaben und Beenden +Eine Taste oder Mausaktion SHALL genau einmal dem aktiven IDE-Kontext oder dem laufenden Programm zugestellt werden. IDE-eigene Steueraktionen MUST NOT zusätzlich in BASIC-Eingabepuffern landen. IDE-Ende und Neustart SHALL die bisherige Sitzung kontrolliert beenden, bevor Terminalressourcen freigegeben oder eine neue Sitzung gestartet werden. + +#### Scenario: Copy und Programmeingabe +- **WHEN** Ctrl+C bei Editorfokus und danach ein normales Zeichen bei Programmfokus eingegeben wird +- **THEN** kopiert die erste Aktion nur im Editor und die zweite erreicht genau einmal das BASIC-Programm + +### Requirement: Shell und Druckausgabe +File→Shell und BASIC-SHELL SHALL für ein interaktives Kindprogramm den Terminalmodus kontrolliert abgeben und anschließend die IDE wiederherstellen; BASIC-SHELLFN SHALL den bisherigen Exit-Code-Vertrag behalten. File→Print SHALL Auswahl oder gesamten aktiven Quelltext über einen Dialog in eine wählbare UTF-8-Druckdatei ausgeben und Fehler sichtbar machen. Dies SHALL keine Druckerhardware voraussetzen und als Plattformanpassung dokumentiert sein. + +#### Scenario: Shell kehrt zurück +- **WHEN** ein Shell-Kommando regulär, mit Fehler oder durch Benutzerabbruch endet +- **THEN** sind Terminal und IDE-Fokus wiederhergestellt und das Programm erhält den passenden bestehenden Rückgabewert beziehungsweise Fehler + +#### Scenario: Druckziel nicht schreibbar +- **WHEN** File→Print ein unbeschreibbares Ziel erhält +- **THEN** erscheint eine Dateidiagnose und Quelltext sowie bestehende Zieldatei bleiben erhalten diff --git a/tests/support/ide-execution-pty.py b/tests/support/ide-execution-pty.py new file mode 100644 index 0000000..b422070 --- /dev/null +++ b/tests/support/ide-execution-pty.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Headless Unix PTY smoke: real terminal handoff, child Ctrl+C and IDE resume. +Run after cargo build -p tb-ide: python3 tests/support/ide-execution-pty.py +""" +import fcntl +import os +from pathlib import Path +import select +import signal +import struct +import subprocess +import tempfile +import termios +import time + +binary = Path(__file__).resolve().parents[2] / 'target/debug/tb' + + +def exercise(abort=False, file_shell=False): + with tempfile.TemporaryDirectory(prefix='tb-pty-') as directory: + source = Path(directory) / 'main.bas' + command = "printf '\\nWAITING_CHILD\\n'; exec sleep 30" if abort else "printf '\\nSHELL_PROOF\\n'; exit 7" + source.write_text(f'PRINT SHELL("{command}")\nSTOP\nPRINT "CONTINUED"\nEND\n') + master, slave = os.openpty() + original = termios.tcgetattr(slave) + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 30, 100, 0, 0)) + pid = os.fork() + if pid == 0: + os.setsid() + fcntl.ioctl(slave, termios.TIOCSCTTY, 0) + for fd in (0, 1, 2): + os.dup2(slave, fd) + os.close(master) + os.close(slave) + os.environ['TERM'] = 'xterm-256color' + os.environ['XDG_CONFIG_HOME'] = directory + # Keep the controlling terminal alive while checking restoration. + signal.signal(signal.SIGINT, lambda *_: None) + result = subprocess.run([str(binary), str(source)], check=False) + restored = termios.tcgetattr(0) == original + os._exit(result.returncode if restored else 99) + transcript = bytearray() + reaped = False + + def until(marker, start=0): + deadline = time.monotonic() + 10 + while marker not in transcript[start:]: + assert time.monotonic() < deadline, (marker, bytes(transcript[-2000:])) + if select.select([master], [], [], .1)[0]: + transcript.extend(os.read(master, 65536)) + + try: + until(b'?1049h') + start = len(transcript) + if file_shell: + os.write(master, b'\x1bfh') # File -> Shell + until(b'?1049l', start) + os.write(master, b"printf '\\nFILE_SHELL_PROOF\\n'; exit\n") + until(b'\r\nFILE_SHELL_PROOF\r\n', start) + until(b'?1049h', start) + else: + os.write(master, b'\x1b[15~') # F5 + until(b'\r\nWAITING_CHILD\r\n' if abort else b'\r\nSHELL_PROOF\r\n', start) + if abort: + os.write(master, b'\x03') # cooked terminal SIGINT to child + IDE + until(b'Paused', start) + start = len(transcript) + os.write(master, b'\x1b[15~') + until(b'Ended', start) + start = len(transcript) + os.write(master, b'\x1b[1;3S') # Alt+F4 + until(b'Ungespeicherte', start) # standalone BAS has an unsaved project manifest + os.write(master, b'\x1b[C\x1b[C\r') # discard temporary project + deadline = time.monotonic() + 10 + while True: + child, status = os.waitpid(pid, os.WNOHANG) + if child: + reaped = True + assert os.waitstatus_to_exitcode(status) == 0 + break + assert time.monotonic() < deadline, bytes(transcript[-1000:]) + if select.select([master], [], [], .05)[0]: + transcript.extend(os.read(master, 65536)) + assert transcript.count(b'?1049h') >= 2 + assert transcript.count(b'?1049l') >= 2 + finally: + if not reaped: + os.killpg(pid, signal.SIGKILL) + os.waitpid(pid, 0) + os.close(master) + os.close(slave) + + +for kwargs in ({}, {'abort': True}, {'file_shell': True}): + exercise(**kwargs) + print('PASS', kwargs or {'shell_exit': 7})