Phase 5: Ausführung und Output implementieren und archivieren
This commit is contained in:
@@ -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(¤t_arg))?;
|
||||
let mut vm = Vm::new(module);
|
||||
if let Some(line) = start_line.take() {
|
||||
if let Err(error) = vm.start_at_line(line) {
|
||||
eprintln!("Runtime error {}: {}", error.0, error);
|
||||
return Err(ExitCode::from(2));
|
||||
}
|
||||
}
|
||||
if let Some((cols, rows)) = size {
|
||||
vm.rt.screen.resize(cols, rows);
|
||||
}
|
||||
vm.rt.command.clone_from(&command);
|
||||
vm.set_poll_interrupt(true);
|
||||
let mut vm = new_execution(module, &command, size, start_line.take()).map_err(|error| {
|
||||
eprintln!("Runtime error {}: {}", error.0, error);
|
||||
ExitCode::from(2)
|
||||
})?;
|
||||
if !vm.rt.zeitpunkt().1 {
|
||||
eprintln!("Zeitzone nicht ermittelbar — Zeitfunktionen rechnen in UTC.");
|
||||
}
|
||||
let event = vm.run(host);
|
||||
let event = if event == RunEvent::Ended && vm.forms.has_visible_forms() {
|
||||
vm.run_visible_forms(host)
|
||||
} else {
|
||||
event
|
||||
};
|
||||
let event =
|
||||
if event == RunEvent::Ended && vm.forms.has_visible_forms() && !vm.is_terminated() {
|
||||
vm.run_visible_forms(host)
|
||||
} else {
|
||||
event
|
||||
};
|
||||
match event {
|
||||
RunEvent::Restart { program, line } => {
|
||||
if let Some(program) = program {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
|
||||
327
crates/tb-ide/src/execution.rs
Normal file
327
crates/tb-ide/src/execution.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,5 @@ pub mod render;
|
||||
pub mod terminal;
|
||||
|
||||
pub mod editor;
|
||||
|
||||
pub mod execution;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(' '));
|
||||
|
||||
532
crates/tb-ide/tests/execution.rs
Normal file
532
crates/tb-ide/tests/execution.rs
Normal 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
|
||||
));
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
190
crates/tb-ui/src/terminal.rs
Normal file
190
crates/tb-ui/src/terminal.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
252
crates/tb-vm/tests/cooperative.rs
Normal file
252
crates/tb-vm/tests/cooperative.rs
Normal 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))));
|
||||
}
|
||||
Reference in New Issue
Block a user