Phase 5: Ausführung und Output implementieren und archivieren

This commit is contained in:
2026-09-06 18:56:47 +02:00
parent 9c85349a4d
commit e3dc9028bf
37 changed files with 2404 additions and 450 deletions

View File

@@ -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<String> = 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<PathBuf, String> {
let base = current.parent().unwrap_or(Path::new("."));
if Path::new(program).extension().is_some() {
return relative_case_insensitive(base, program).map_err(|error| error.to_string());
}
for extension in ["bas", "frm", "mak", "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(&current_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 {

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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<u8> {
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<Menu> {
item("&Start", Start),
item("&Restart", Restart),
item("&Continue", Continue),
item("&Pause (Ctrl+Break)", Pause),
item("Modify COMMAND&$…", CommandLine),
sep(),
item("Make &EXE File…", MakeExe),

View File

@@ -27,6 +27,8 @@ pub struct Search {
pub document: Option<DocumentId>,
pub wrap: bool,
}
pub(crate) type Revision = (ProjectStamp, Vec<SourceUnit>, Vec<String>);
#[derive(Default)]
pub struct Editor {
pub clipboard: String,
@@ -37,7 +39,7 @@ pub struct Editor {
pub checked: BTreeMap<DocumentId, u64>,
pub compiler: ProjectCompiler,
pub diagnostics: Vec<Diagnostic>,
revision: Option<(ProjectStamp, Vec<SourceUnit>, Vec<String>)>,
pub(crate) revision: Option<Revision>,
compiled: Option<CompiledModule>,
}
@@ -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())

View File

@@ -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<Ereignis>,
pub now: u64,
pub shell_request: Option<String>,
pub shell_result: Option<Result<i32, RuntimeError>>,
}
impl Host for IdeHost {
fn present(&mut self, _: &TextScreen) {}
fn next_event(&mut self, _: bool) -> Option<Ereignis> {
self.events.pop_front()
}
fn warten(&mut self, _: Option<u64>) -> Option<Ereignis> {
panic!("IDE poll must never block")
}
fn jetzt_ms(&mut self) -> u64 {
self.now
}
fn shell(&mut self, command: &str) -> Result<Option<i32>, 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<Vm>,
pub host: IdeHost,
pub command: String,
pub target: Option<PathBuf>,
pub project_target: bool,
pub old_revision: bool,
pub fullscreen: bool,
pub forms_phase: bool,
pub file_shell: bool,
revision: Vec<u8>,
source_revision: Option<crate::editor::Revision>,
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<PathBuf>,
line: Option<u32>,
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;
}
}
}
},
}
}
}

View File

@@ -8,3 +8,5 @@ pub mod render;
pub mod terminal;
pub mod editor;
pub mod execution;

View File

@@ -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(())
}

View File

@@ -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",
},

View File

@@ -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<W: Write> {
writer: W,
raw: fn(bool) -> io::Result<()>,
}
impl<W: Write> TerminalGuard<W> {
pub fn enter(writer: W) -> io::Result<Self> {
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<Self> {
let mut guard = Self { writer, raw };
(guard.raw)(true)?;
execute!(
guard.writer,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste,
Hide
)?;
Ok(guard)
}
}
impl<W: Write> Drop for TerminalGuard<W> {
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<RefCell<Vec<u8>>>);
impl Write for Output {
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
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;

View File

@@ -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(' '));

View File

@@ -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<tb_runtime::host::Ereignis>,
}
impl tb_runtime::host::Host for ScriptHost {
fn present(&mut self, _: &tb_runtime::screen::TextScreen) {}
fn next_event(&mut self, _: bool) -> Option<tb_runtime::host::Ereignis> {
self.events.pop_front()
}
fn jetzt_ms(&mut self) -> u64 {
self.now
}
fn warten(&mut self, deadline: Option<u64>) -> Option<tb_runtime::host::Ereignis> {
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
));
}

View File

@@ -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<i32, RuntimeError> {
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<Option<Value>, 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<Option<Value>, 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.

View File

@@ -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<Option<i32>, crate::errors::RuntimeError> {
shell_command(command)
.status()
.map(|s| Some(s.code().unwrap_or(0)))
.map_err(|_| crate::errors::RuntimeError(53))
}
/// Aktuellen Bildschirmzustand anzeigen.
fn present(&mut self, screen: &TextScreen);
@@ -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
}

View File

@@ -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<tb_runtime::builtins::Eingabe>,
plain_access_key: bool,
) -> Option<FormEvent> {
loop {
model.render(screen);
/// Fortsetzbarer modaler Dialog; Modell, Fokus und Hintergrund bleiben erhalten.
pub struct Dialog {
model: FormsModel,
old: TextScreen,
buttons: Vec<i16>,
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<tb_runtime::builtins::Eingabe>,
) -> Option<DialogValue> {
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<tb_runtime::builtins::Eingabe>,
) -> 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<i16, RuntimeError> {
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<tb_runtime::builtins::Eingabe>,
prompt: &str,
title: &str,
initial: &str,
position: Option<(i32, i32)>,
) -> Result<String, RuntimeError> {
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<Dialog, RuntimeError> {
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<tb_runtime::builtins::Eingabe>,
pub fn input_dialog(
screen: &TextScreen,
prompt: &str,
title: &str,
initial: &str,
position: Option<(i32, i32)>,
) -> Result<String, RuntimeError> {
) -> Result<Dialog, RuntimeError> {
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)]

View File

@@ -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<Stdout>,
terminal: Terminal<CrosstermBackend<Stdout>>,
/// Nullpunkt der monotonen Zeit (`Host::jetzt_ms`).
start: Instant,
@@ -40,15 +37,18 @@ pub struct TerminalHost {
impl TerminalHost {
pub fn new() -> io::Result<Self> {
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<Option<i32>, 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<Ereignis> {
pub fn maus_zu_ereignis(m: MouseEvent, flaeche: (usize, usize)) -> Option<Ereignis> {
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<Ereignis>
}
/// crossterm-Taste → `INKEY$`-Form. `None` für Tasten ohne Entsprechung.
fn taste_zu_ereignis(k: KeyEvent) -> Option<Ereignis> {
pub fn taste_zu_ereignis(k: KeyEvent) -> Option<Ereignis> {
// Strg+Untbr bzw. Strg+C: Abbruchwunsch.
if k.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(k.code, KeyCode::Char('c') | KeyCode::Pause)

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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<W: Write> {
writer: W,
raw: fn(bool) -> io::Result<()>,
}
impl<W: Write> TerminalGuard<W> {
pub fn enter(writer: W) -> io::Result<Self> {
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<Self> {
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<i32> {
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<i32> {
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<W: Write> Drop for TerminalGuard<W> {
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<RefCell<Vec<u8>>>);
impl Write for Output {
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
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"));
}
}

View File

@@ -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) => {

View File

@@ -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<u64> },
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<FormEventReturn>,
handler_start: bool,
eingabe: Option<KonsolenEingabe>,
sleep: Option<Option<u64>>,
}
struct KonsolenEingabe {
@@ -130,6 +139,9 @@ pub struct Vm {
data_ptr: usize,
start_pc: Option<usize>,
pub forms: FormsModel,
waiting: Option<Option<u64>>,
dialog: Option<tb_ui::forms::Dialog>,
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<Option<Value>, RuntimeError> {
match id {
ids::MSGBOX => {
let text =
self.external_string(args.first().ok_or(RuntimeError::TYPE_MISMATCH)?)?;
let kind = args
.get(1)
.map_or(Ok(0), |value| self.dialog_integer(value))?;
let title = args
.get(2)
.map_or_else(|| Ok(String::new()), |value| self.external_string(value))?;
let mut queued = self.take_dialog_input();
let result = tb_ui::forms::msgbox_dialog(
&mut self.rt.screen,
host,
&mut queued,
&text,
kind,
&title,
);
self.restore_dialog_input(queued);
result.map(|value| Some(Value::Int(value)))
}
ids::INPUTBOX_S => {
let prompt =
self.external_string(args.first().ok_or(RuntimeError::TYPE_MISMATCH)?)?;
let title = args
.get(1)
.map_or_else(|| Ok(String::new()), |value| self.external_string(value))?;
let initial = args
.get(2)
.map_or_else(|| Ok(String::new()), |value| self.external_string(value))?;
let position = match (args.get(3), args.get(4)) {
(None, None) => None,
(Some(x), Some(y)) => Some((self.dialog_integer(x)?, self.dialog_integer(y)?)),
_ => return Err(RuntimeError::TYPE_MISMATCH),
};
let mut queued = self.take_dialog_input();
let result = tb_ui::forms::inputbox_dialog(
&mut self.rt.screen,
host,
&mut queued,
&prompt,
&title,
&initial,
position,
);
self.restore_dialog_input(queued);
result.map(|value| Some(Value::Str(Rc::from(value))))
}
_ => Err(RuntimeError::FEATURE_UNAVAILABLE),
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<u64>) -> Option<u64> {
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(

View File

@@ -258,6 +258,83 @@ pub struct ProjectSources {
pub units: Vec<SourceUnit>,
pub forms: Vec<FormFile>,
}
impl ProjectSources {
/// One startup contract for IDE, CLI and serialized bytecode.
pub fn compile(
&self,
compiler: &mut crate::project::ProjectCompiler,
name: &str,
) -> Result<crate::bytecode::CompiledModule, Vec<tb_frontend::Diagnostic>> {
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<PathBuf, String> {
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<crate::bytecode::CompiledModule, String> {
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::<Vec<_>>()
.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<u32>,
) -> Result<crate::interp::Vm, tb_runtime::errors::RuntimeError> {
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<ProjectSources, String> {
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),

View File

@@ -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<Ereignis>,
}
impl Host for HostStub {
fn present(&mut self, _: &TextScreen) {}
fn next_event(&mut self, blocking: bool) -> Option<Ereignis> {
assert!(!blocking);
self.input.pop_front()
}
fn warten(&mut self, _: Option<u64>) -> Option<Ereignis> {
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))));
}

View File

@@ -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.

View File

@@ -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 ───

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.11.5: VM-Poll, Frame-Fortsetzungen, Dialogmodell, Forms-Pump und untenstehender Wartepfad-Audit; sieben gezielte VM-Tests plus bestehende Ereignis-/Forms-Regressionen.
- 2.12.5: `execution.rs`, App-Befehle und gemeinsame Projekt-/Reset-Funktionen; zwölf IDE-Ausführungstests, CLI-Startup-/TBC- und bestehende STOP-Exitcode-Tests.
- 3.13.4: Input-Dispatcher, ScreenWidget, gemeinsamer TerminalGuard, Print-Dialog; IDE-, UI- und reale PTY-Nachweise.
- 4.14.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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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})