Phase 5: Ausführung und Output implementieren und archivieren

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

View File

@@ -14,7 +14,7 @@ path = "src/main.rs"
tb-frontend.workspace = true
tb-vm.workspace = true
tb-runtime.workspace = true
tb-ui.workspace = true
tb-ui = { workspace = true, features = ["terminal"] }
ratatui.workspace = true
crossterm.workspace = true
anyhow.workspace = true

View File

@@ -21,8 +21,12 @@ pub enum Mode {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Execution {
Idle,
Compiling,
Paused,
Running,
Waiting,
Ended,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WindowKind {
@@ -213,6 +217,9 @@ pub enum DialogKind {
Dirty(AfterSave),
LoadText,
SaveText,
Print,
CommandLine,
ResumeRevision,
Display,
Paths,
RightMouse,
@@ -256,6 +263,7 @@ pub enum Hit {
}
pub struct App {
pub session: crate::execution::Session,
pub editor: crate::editor::Editor,
pub project: Project,
pub options: Options,
@@ -293,6 +301,7 @@ impl App {
let (options, errors, config_disk) = Options::load(&config_path);
project.include_paths = options.include_paths.clone();
let mut app = Self {
session: Default::default(),
editor: Default::default(),
project,
options: options.clone(),
@@ -423,6 +432,7 @@ impl App {
)
.trim_end()
.to_string(),
WindowKind::Output if self.session.old_revision => "Output · ALTES KOMPILAT".into(),
_ => format!("{:?}", w.kind),
}
}
@@ -452,6 +462,9 @@ impl App {
return Some(format!("Fachfunktion folgt in Phase-5-Change {phase:02}"));
}
use Command::*;
if command == Shell && self.session.host.shell_request.is_some() {
return Some("Shell-Übergabe bereits angefordert".into());
}
if matches!(
command,
Undo | Cut | Paste | Clear | LoadText | NewSub | NewFunction | Replace
@@ -467,6 +480,7 @@ impl App {
if matches!(
command,
LoadText
| Print
| SaveText
| Cut
| Copy
@@ -532,6 +546,40 @@ impl App {
use Command::*;
let id = self.active_document();
match command {
Start => self.start_execution()?,
Restart => self.restart_target()?,
Continue => self.continue_execution()?,
Pause => self.pause_execution(),
OutputScreen => {
self.session.fullscreen = !self.session.fullscreen;
}
CommandLine => self.open_dialog(
"COMMAND$",
DialogKind::CommandLine,
vec![Field::text(
"Argumente für den nächsten Start",
&self.session.command,
)],
),
Shell => {
self.session.file_shell = true;
self.session.host.shell_request = Some(String::new());
}
Print => self.open_dialog(
"Print",
DialogKind::Print,
vec![
Field::text("UTF-8-Ausgabedatei", self.output_default("LPT1.TXT")),
Field::toggle("Bestehendes Ziel überschreiben", false),
Field::toggle(
"Nur Auswahl",
self.project
.view(self.editor_view()?)?
.selection()
.is_some(),
),
],
),
Cut | Copy | Paste | Clear | NewSub | NewFunction | IncludedFile | IncludedLines
| Find | SelectedText | FindNext | Replace | Procedures | PreviousCode
| Diagnostics => self.editor_command(command)?,
@@ -706,6 +754,9 @@ impl App {
_ => WindowKind::Project,
};
self.show_tool(kind);
if command == Output {
self.session.fullscreen = false;
}
}
MenuBar => {
self.properties = !self.properties;
@@ -856,7 +907,7 @@ impl App {
let offset = (self.windows.len() % 5) as u16 * 2;
clamp(Rect::new(offset, 1 + offset, 60, 16), self.area())
}
fn show_tool(&mut self, kind: WindowKind) {
pub(crate) fn show_tool(&mut self, kind: WindowKind) {
if let Some(w) = self.windows.iter_mut().find(|w| w.kind == kind) {
self.active = w.id;
w.state = WindowState::Normal;
@@ -932,6 +983,8 @@ impl App {
match after {
AfterSave::Stay => return Ok(()),
AfterSave::Exit => {
self.session.finish();
self.basic_events.clear();
self.quit = true;
return Ok(());
}
@@ -943,6 +996,9 @@ impl App {
self.project.open_project(&path, Decision::Discard)?;
}
}
self.session = Default::default();
self.basic_events.clear();
self.execution = Execution::Idle;
self.base = self.project.directory().to_path_buf();
self.editor = Default::default();
self.windows.clear();
@@ -1141,11 +1197,25 @@ impl App {
let text = std::fs::read_to_string(self.base.join(d.fields[0].string()))?;
self.editor_insert(&text)?;
}
DialogKind::SaveText => {
DialogKind::CommandLine => {
self.session.command = d.fields[0].string();
}
DialogKind::ResumeRevision => {
if d.fields[0].index() == 0 {
self.restart_target()?;
} else {
self.resume_execution(true);
}
}
DialogKind::SaveText | DialogKind::Print => {
let (id, _) = self.code_cursor()?;
self.project.save_text(
id,
self.project.view(self.editor_view()?)?.selection(),
if matches!(d.kind, DialogKind::Print) && !d.fields[2].flag() {
None
} else {
self.project.view(self.editor_view()?)?.selection()
},
&Destination {
path: d.fields[0].string().into(),
overwrite: d.fields[1].flag(),
@@ -1255,8 +1325,20 @@ impl App {
self.line_leave(old);
}
fn handle_event(&mut self, event: Event) {
if matches!(event, Event::Key(k) if k.kind != KeyEventKind::Release && k.code == K::Pause && k.modifiers.contains(M::CONTROL))
{
self.pause_execution();
return;
}
if let Event::Resize(w, h) = event {
self.size = (w, h);
if let Some(vm) = self.session.vm.as_mut() {
vm.rt.ereignis(tb_runtime::host::Ereignis::Groesse {
cols: w as usize,
rows: h as usize,
});
vm.forms.resize(w as usize, h as usize);
}
return;
}
if self.size.0 < 80 || self.size.1 < 25 {
@@ -1269,6 +1351,10 @@ impl App {
self.key(key);
} else if let Event::Mouse(mouse) = event {
if self.dialog.is_none() && self.menu.is_none() && self.program_focus() {
if self.session.fullscreen {
self.basic_events.push(Event::Mouse(mouse));
return;
}
if let Some(w) = self.active_window() {
let r = self.rect(w);
let inner = Rect::new(
@@ -1278,6 +1364,9 @@ impl App {
r.height.saturating_sub(2),
);
if inner.contains((mouse.column, mouse.row).into()) {
let mut mouse = mouse;
mouse.column -= inner.x;
mouse.row -= inner.y;
self.basic_events.push(Event::Mouse(mouse));
return;
}
@@ -1399,13 +1488,18 @@ impl App {
}
}
fn program_focus(&self) -> bool {
self.execution == Execution::Running
&& matches!(
self.active_window().map(|w| w.kind),
Some(WindowKind::Output)
)
matches!(self.execution, Execution::Running | Execution::Waiting)
&& (self.session.fullscreen
|| matches!(
self.active_window().map(|w| w.kind),
Some(WindowKind::Output)
))
}
fn key(&mut self, key: KeyEvent) {
if self.session.fullscreen && self.dialog.is_none() && key.code == K::F(4) {
self.execute(Command::OutputScreen);
return;
}
if self.editor.chord.is_some()
&& self.dialog.is_none()
&& self.menu.is_none()
@@ -1586,7 +1680,7 @@ impl App {
return;
}
if self.program_focus() && key.modifiers.contains(M::CONTROL) && key.code == K::Char('c') {
self.basic_events.push(Event::Key(key));
self.pause_execution();
return;
}
if let Some(command) = shortcut(key) {
@@ -1634,7 +1728,8 @@ impl App {
DialogKind::OpenProject
| DialogKind::AddFile
| DialogKind::LoadText
| DialogKind::SaveText => index == 0,
| DialogKind::SaveText
| DialogKind::Print => index == 0,
DialogKind::Save { .. } => index % 2 == 0,
DialogKind::Export(_) => index == 3,
_ => false,

View File

@@ -41,6 +41,7 @@ pub enum Command {
Start,
Restart,
Continue,
Pause,
CommandLine,
MakeExe,
MakeLibrary,
@@ -100,7 +101,6 @@ impl Command {
pub fn feature_phase(self) -> Option<u8> {
use Command::*;
match self {
Print | Shell | Start | Restart | Continue | CommandLine | OutputScreen => Some(4),
Events | Grid | Palette | MenuDesign | Toolbox | Tool(_) => Some(5),
NextStatement | AddWatch | InstantWatch | Watchpoint | DeleteWatch | DeleteWatches
| Trace | History | Breakpoint | ClearBreakpoints | BreakErrors | SetStatement
@@ -251,6 +251,7 @@ pub fn menus(designer: bool) -> Vec<Menu> {
item("&Start", Start),
item("&Restart", Restart),
item("&Continue", Continue),
item("&Pause (Ctrl+Break)", Pause),
item("Modify COMMAND&$…", CommandLine),
sep(),
item("Make &EXE File…", MakeExe),

View File

@@ -27,6 +27,8 @@ pub struct Search {
pub document: Option<DocumentId>,
pub wrap: bool,
}
pub(crate) type Revision = (ProjectStamp, Vec<SourceUnit>, Vec<String>);
#[derive(Default)]
pub struct Editor {
pub clipboard: String,
@@ -37,7 +39,7 @@ pub struct Editor {
pub checked: BTreeMap<DocumentId, u64>,
pub compiler: ProjectCompiler,
pub diagnostics: Vec<Diagnostic>,
revision: Option<(ProjectStamp, Vec<SourceUnit>, Vec<String>)>,
pub(crate) revision: Option<Revision>,
compiled: Option<CompiledModule>,
}
@@ -789,20 +791,12 @@ impl App {
self.editor.revision = None;
self.editor.diagnostics.clear();
let sources = self.project.sources()?;
let mut catalog = tb_frontend::forms::FormCatalog::default();
for f in &sources.forms {
catalog.append(&f.catalog());
}
self.editor.revision = Some((
ProjectStamp::capture(&self.project),
sources.units.clone(),
sources.forms.iter().map(tb_ui::frm::write_text).collect(),
));
match self
.editor
.compiler
.compile("IDE", &sources.units, &catalog, &sources.forms)
{
match sources.compile(&mut self.editor.compiler, "IDE") {
Ok(code) => {
self.editor.compiled = Some(code);
Ok(self.editor.compiled.as_ref().unwrap())

View File

@@ -0,0 +1,327 @@
//! Cooperative session owned by the IDE event loop, on the VM's thread.
use crate::app::{App, DialogKind, Execution, Field, WindowKind};
use anyhow::{anyhow, Result};
use crossterm::event::Event;
use std::{collections::VecDeque, path::PathBuf};
use tb_runtime::{
errors::RuntimeError,
host::{Ereignis, Host},
screen::TextScreen,
};
use tb_vm::{
interp::{PollResult, RunEvent, Vm},
project_io::{load_program, new_execution, run_target},
};
#[derive(Default)]
pub struct IdeHost {
pub events: VecDeque<Ereignis>,
pub now: u64,
pub shell_request: Option<String>,
pub shell_result: Option<Result<i32, RuntimeError>>,
}
impl Host for IdeHost {
fn present(&mut self, _: &TextScreen) {}
fn next_event(&mut self, _: bool) -> Option<Ereignis> {
self.events.pop_front()
}
fn warten(&mut self, _: Option<u64>) -> Option<Ereignis> {
panic!("IDE poll must never block")
}
fn jetzt_ms(&mut self) -> u64 {
self.now
}
fn shell(&mut self, command: &str) -> Result<Option<i32>, RuntimeError> {
if let Some(result) = self.shell_result.take() {
result.map(Some)
} else {
self.shell_request.get_or_insert_with(|| command.into());
Ok(None)
}
}
}
pub struct Session {
pub vm: Option<Vm>,
pub host: IdeHost,
pub command: String,
pub target: Option<PathBuf>,
pub project_target: bool,
pub old_revision: bool,
pub fullscreen: bool,
pub forms_phase: bool,
pub file_shell: bool,
revision: Vec<u8>,
source_revision: Option<crate::editor::Revision>,
output: TextScreen,
}
impl Default for Session {
fn default() -> Self {
Self {
vm: None,
host: IdeHost::default(),
command: String::new(),
target: None,
project_target: true,
old_revision: false,
fullscreen: false,
forms_phase: false,
file_shell: false,
revision: Vec::new(),
source_revision: None,
output: TextScreen::default(),
}
}
}
impl Session {
pub fn screen(&self) -> &TextScreen {
self.vm.as_ref().map_or(&self.output, |vm| &vm.rt.screen)
}
pub fn finish(&mut self) {
if let Some(mut vm) = self.vm.take() {
self.output = std::mem::take(&mut vm.rt.screen);
}
self.host.events.clear();
self.host.shell_request = None;
self.host.shell_result = None;
self.file_shell = false;
}
}
impl App {
pub fn start_execution(&mut self) -> Result<()> {
self.reset_execution(None, None, true)
}
fn reset_execution(
&mut self,
target: Option<PathBuf>,
line: Option<u32>,
project: bool,
) -> Result<()> {
self.execution = Execution::Compiling;
let result = (|| {
let module = if project {
self.compile_current()?.clone()
} else {
load_program(
target
.as_deref()
.ok_or_else(|| anyhow!("Kein Ausführungsziel"))?,
)
.map_err(|e| anyhow!(e))?
};
let revision = module.to_tbc();
let vm = new_execution(
module,
&self.session.command,
Some((self.size.0 as usize, self.size.1 as usize)),
line,
)?;
let target = if project {
self.project.path().map(PathBuf::from).or_else(|| {
self.project
.members()
.first()
.and_then(|id| self.project.document(*id).ok())
.map(|d| d.source_path().to_path_buf())
})
} else {
target
};
self.session.finish();
self.basic_events.clear();
self.session.vm = Some(vm);
self.session.target = target;
self.session.project_target = project;
self.session.revision = revision;
self.session.source_revision = if project {
self.editor.revision.clone()
} else {
None
};
self.session.old_revision = false;
self.session.forms_phase = false;
self.execution = Execution::Running;
self.show_tool(WindowKind::Output);
self.message = "Running".into();
Ok(())
})();
if let Err(e) = &result {
self.session.finish();
self.execution = Execution::Error;
self.message = format!("{e:#}");
}
result
}
pub fn pause_execution(&mut self) {
if matches!(self.execution, Execution::Running | Execution::Waiting) {
self.execution = Execution::Paused;
self.editor.chord = None;
self.message = self.session.vm.as_ref().map_or_else(
|| "Paused".into(),
|vm| {
format!(
"Paused · {}:{} · F5: Continue",
vm.current_file(),
vm.current_line()
)
},
);
}
}
pub fn continue_execution(&mut self) -> Result<()> {
if matches!(self.execution, Execution::Running | Execution::Waiting) {
return Ok(());
}
if self.execution != Execution::Paused {
return self.start_execution();
}
let current = if self.session.project_target {
self.compile_current().map(|m| m.to_tbc())
} else {
self.session
.target
.as_deref()
.ok_or_else(|| anyhow!("Kein Ausführungsziel"))
.and_then(|p| load_program(p).map_err(|e| anyhow!(e)))
.map(|m| m.to_tbc())
};
if current
.as_ref()
.is_ok_and(|bytes| *bytes == self.session.revision)
&& (!self.session.project_target
|| self.editor.revision == self.session.source_revision)
{
self.resume_execution(false);
} else {
self.open_dialog(
"Quellstand geändert",
DialogKind::ResumeRevision,
vec![Field::choice(
"Fortsetzen",
vec![
"Neu starten (aktueller Quellstand)".into(),
"Altes Kompilat ausdrücklich fortsetzen".into(),
],
0,
)],
);
}
Ok(())
}
pub fn resume_execution(&mut self, old: bool) {
if let Some(vm) = self.session.vm.as_mut() {
vm.rt.abbruch = false;
self.session.old_revision = old;
self.execution = Execution::Running;
self.message = if old {
"Running · ALTES KOMPILAT"
} else {
"Running"
}
.into();
}
}
pub fn restart_target(&mut self) -> Result<()> {
self.reset_execution(
self.session.target.clone(),
None,
self.session.project_target,
)
}
/// One bounded slice. Tests supply virtual milliseconds, main uses Instant.
pub fn tick_execution(&mut self, now: u64) {
self.session.host.now = now;
if !matches!(self.execution, Execution::Running | Execution::Waiting) {
return;
}
for event in std::mem::take(&mut self.basic_events) {
match event {
Event::Key(k) => {
if let Some(e) = tb_ui::host::taste_zu_ereignis(k) {
self.session.host.events.push_back(e);
}
}
Event::Mouse(m) => {
if let Some(e) = tb_ui::host::maus_zu_ereignis(
m,
(self.size.0 as usize, self.size.1 as usize),
) {
self.session.host.events.push_back(e);
}
}
Event::Paste(s) => {
self.session.host.events.extend(s.chars().map(|c| {
Ereignis::Taste(
if c == '\n' {
"\r".into()
} else {
c.to_string()
},
0,
)
}));
}
_ => {}
}
}
let Some(vm) = self.session.vm.as_mut() else {
return;
};
vm.rt.pump(&mut self.session.host, false);
let result = if self.session.forms_phase {
vm.poll_visible_forms(&mut self.session.host, 4096)
} else {
vm.poll(&mut self.session.host, 4096)
};
match result {
PollResult::Yield => self.execution = Execution::Running,
PollResult::Waiting { .. } => self.execution = Execution::Waiting,
PollResult::Event(event) => match event {
RunEvent::Ended
if !self.session.forms_phase
&& vm.forms.has_visible_forms()
&& !vm.is_terminated() =>
{
self.session.forms_phase = true;
self.execution = Execution::Waiting;
}
RunEvent::Ended => {
self.session.finish();
self.execution = Execution::Ended;
self.message = "Ended".into();
}
RunEvent::Stopped { .. }
| RunEvent::Interrupted { .. }
| RunEvent::Breakpoint { .. }
| RunEvent::Stepped { .. } => self.pause_execution(),
RunEvent::Error {
code,
line,
message,
} => {
self.session.finish();
self.execution = Execution::Error;
self.message = format!("Runtime error {code} in Zeile {line}: {message}");
}
RunEvent::Restart { program, line } => {
let target = program
.as_deref()
.map(|p| run_target(self.session.target.as_deref().unwrap(), p))
.transpose();
match target {
Ok(target) => {
let project = self.session.project_target
&& (target.is_none() || target == self.session.target);
let target = target.or_else(|| self.session.target.clone());
let _ = self.reset_execution(target, line, project);
}
Err(error) => {
self.session.finish();
self.execution = Execution::Error;
self.message = error;
}
}
}
},
}
}
}

View File

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

View File

@@ -5,7 +5,11 @@ use std::{
io::{self, IsTerminal},
path::PathBuf,
};
use tb_ide::{app::App, options, terminal::TerminalGuard};
use tb_ide::{
app::{App, Execution},
options,
terminal::TerminalGuard,
};
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
@@ -23,11 +27,58 @@ fn main() -> Result<()> {
if let Some(path) = args.first() {
app.load_initial_project(PathBuf::from(path))?;
}
let _guard = TerminalGuard::enter(io::stdout())?;
let mut guard = TerminalGuard::enter(io::stdout())?;
let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
let start = std::time::Instant::now();
let mut last_draw = start - std::time::Duration::from_millis(16);
let signals = tb_ui::signale::Signalquelle::neu();
while !app.quit {
terminal.draw(|f| app.render(f))?;
app.handle(event::read()?);
if signals.abholen().is_some() {
app.pause_execution();
}
let shell = if app.session.file_shell
|| matches!(app.execution, Execution::Running | Execution::Waiting)
{
app.session.host.shell_request.take()
} else {
None
};
if let Some(command) = shell {
let result = guard.shell(&command, &signals);
terminal.clear()?;
let (cols, rows) = crossterm::terminal::size()?;
app.handle(event::Event::Resize(cols, rows));
if app.session.file_shell {
app.session.file_shell = false;
app.message = match result {
Ok(code) => format!("Shell beendet: {code}"),
Err(e) => format!("Shell: {e}"),
};
} else {
app.session.host.shell_result =
Some(result.map_err(|_| tb_runtime::errors::RuntimeError(53)));
}
}
if last_draw.elapsed() >= std::time::Duration::from_millis(16) {
terminal.draw(|f| app.render(f))?;
last_draw = std::time::Instant::now();
}
// Bounded input batch: both keyboard and VM make progress under load.
for _ in 0..64 {
if !event::poll(std::time::Duration::ZERO)? {
break;
}
app.handle(event::read()?);
}
app.tick_execution(start.elapsed().as_millis() as u64);
let timeout = if app.execution == Execution::Running {
std::time::Duration::ZERO
} else {
std::time::Duration::from_millis(10)
};
if event::poll(timeout)? {
app.handle(event::read()?);
}
}
Ok(())
}

View File

@@ -76,6 +76,10 @@ impl App {
);
return;
}
if self.session.fullscreen && self.dialog.is_none() && self.menu.is_none() {
f.render_widget(tb_ui::screen::ScreenWidget(self.session.screen()), area);
return;
}
let fill = self.options.desktop.to_string().repeat(area.width as usize);
for row in 0..area.height {
put(
@@ -330,11 +334,13 @@ impl App {
self.hits.push((r, Hit::ProjectMember(row)));
}
}
WindowKind::Output => {
f.render_widget(tb_ui::screen::ScreenWidget(self.session.screen()), inner)
}
kind => put(
f,
inner,
match kind {
WindowKind::Output => "Output · Ausführung folgt in Change 04",
WindowKind::Help => "Help · Inhalte folgen in Change 07",
_ => "Debugger-Inhalte folgen in Change 06",
},

View File

@@ -1,109 +1 @@
use crossterm::{
cursor::{Hide, Show},
event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture},
execute,
style::ResetColor,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::io::{self, Write};
/// Ein Besitzer für Raw Mode und Terminalzustand, auch bei teilweiser Initialisierung.
pub struct TerminalGuard<W: Write> {
writer: W,
raw: fn(bool) -> io::Result<()>,
}
impl<W: Write> TerminalGuard<W> {
pub fn enter(writer: W) -> io::Result<Self> {
Self::with_raw(writer, |on| {
if on {
enable_raw_mode()
} else {
disable_raw_mode()
}
})
}
fn with_raw(writer: W, raw: fn(bool) -> io::Result<()>) -> io::Result<Self> {
let mut guard = Self { writer, raw };
(guard.raw)(true)?;
execute!(
guard.writer,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste,
Hide
)?;
Ok(guard)
}
}
impl<W: Write> Drop for TerminalGuard<W> {
fn drop(&mut self) {
let _ = (self.raw)(false);
let _ = execute!(
self.writer,
ResetColor,
Show,
DisableMouseCapture,
DisableBracketedPaste,
LeaveAlternateScreen
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
cell::RefCell,
rc::Rc,
sync::atomic::{AtomicI32, Ordering},
};
static RAW: AtomicI32 = AtomicI32::new(0);
#[derive(Clone)]
struct Output(Rc<RefCell<Vec<u8>>>);
impl Write for Output {
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
self.0.borrow_mut().extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn raw(on: bool) -> io::Result<()> {
RAW.store(i32::from(on), Ordering::SeqCst);
Ok(())
}
#[test]
fn terminal_cleanup_on_normal_error_and_partial_initialization() {
for fail in [false, true] {
let out = Output(Rc::default());
let copy = out.clone();
let result = (|| -> io::Result<()> {
let _guard = TerminalGuard::with_raw(out, raw)?;
if fail {
return Err(io::Error::other("Renderfehler"));
}
Ok(())
})();
assert_eq!(result.is_err(), fail);
assert_eq!(RAW.load(Ordering::SeqCst), 0);
let s = String::from_utf8(copy.0.borrow().clone()).unwrap();
assert!(s.contains("?1049h") && s.contains("?1049l") && s.contains("?25h"));
}
let out = Output(Rc::default());
let copy = out.clone();
assert!(TerminalGuard::with_raw(out, |on| {
raw(on)?;
if on {
Err(io::Error::other("Raw-Fehler"))
} else {
Ok(())
}
})
.is_err());
assert_eq!(RAW.load(Ordering::SeqCst), 0);
assert!(String::from_utf8(copy.0.borrow().clone())
.unwrap()
.contains("?25h"));
}
}
pub use tb_ui::terminal::TerminalGuard;

View File

@@ -530,10 +530,14 @@ fn mouse_focus_is_modal_and_program_abort_is_not_editor_copy() {
menu(&mut app, Command::Output);
app.execution = Execution::Running;
key(&mut app, K::Char('c'), M::CONTROL);
assert_eq!(
app.basic_events,
vec![Event::Key(KeyEvent::new(K::Char('c'), M::CONTROL))]
assert_eq!(app.execution, Execution::Paused);
assert!(app.basic_events.is_empty());
plain(&mut app, K::Char('x'));
assert!(
app.basic_events.is_empty(),
"Tasten nach Break gehören nicht mehr BASIC"
);
app.execution = Execution::Running;
let r = app.rect(app.active_window().unwrap());
let mouse = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Right),
@@ -542,7 +546,10 @@ fn mouse_focus_is_modal_and_program_abort_is_not_editor_copy() {
modifiers: M::NONE,
};
app.handle(Event::Mouse(mouse));
assert_eq!(app.basic_events.last(), Some(&Event::Mouse(mouse)));
let mut local = mouse;
local.column = 1;
local.row = 1;
assert_eq!(app.basic_events.last(), Some(&Event::Mouse(local)));
app.execution = Execution::Idle;
menu(&mut app, Command::RightMouse);
plain(&mut app, K::Char(' '));

View File

@@ -0,0 +1,532 @@
use crossterm::event::{Event, KeyCode as K, KeyEvent, KeyModifiers as M};
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicUsize, Ordering},
};
use tb_ide::{
app::{App, DialogKind, Execution, Field, WindowKind},
commands::Command,
};
use tb_runtime::{snapshot, value::Value};
struct Temp(PathBuf);
impl Temp {
fn new() -> Self {
static N: AtomicUsize = AtomicUsize::new(0);
let p = std::env::temp_dir().join(format!(
"tb-execution-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&p).unwrap();
Self(p.canonicalize().unwrap())
}
fn app(&self, source: &str) -> App {
let path = self.0.join("main.bas");
fs::write(&path, source).unwrap();
let mut app = App::new(&self.0, self.0.join("options"), (100, 30)).unwrap();
app.load_initial_project(path).unwrap();
app.options.syntax_checking = false;
app
}
}
impl Drop for Temp {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn key(a: &mut App, k: K, m: M) {
a.handle(Event::Key(KeyEvent::new(k, m)));
}
fn tick(a: &mut App) {
for _ in 0..20 {
a.tick_execution(0);
}
}
fn output(a: &App) -> String {
snapshot::text(a.session.screen())
}
fn edit_window(a: &mut App) {
let id = a
.windows
.iter()
.find(|w| matches!(w.kind, WindowKind::Code(_)))
.unwrap()
.id;
a.execute(Command::FocusWindow(id));
}
#[test]
fn f5_input_pause_copy_and_single_delivery_keep_the_same_vm() {
let t = Temp::new();
let mut a = t.app("LINE INPUT \"Name: \"; s$\nPRINT s$\nEND\n");
key(&mut a, K::F(5), M::NONE);
tick(&mut a);
assert_eq!(a.execution, Execution::Waiting);
key(&mut a, K::Char('a'), M::NONE);
tick(&mut a);
edit_window(&mut a);
key(&mut a, K::Char('a'), M::CONTROL);
key(&mut a, K::Char('c'), M::CONTROL);
assert!(a.editor.clipboard.starts_with("LINE INPUT"));
assert_eq!(a.execution, Execution::Waiting);
key(&mut a, K::Pause, M::CONTROL);
assert_eq!(a.execution, Execution::Paused);
assert!(a.message.contains("main.bas"));
key(&mut a, K::F(5), M::NONE);
assert!(a.dialog.is_none());
a.execute(Command::Output);
key(&mut a, K::Char('b'), M::NONE);
key(&mut a, K::Enter, M::NONE);
tick(&mut a);
assert_eq!(a.execution, Execution::Ended, "{}", a.message);
assert_eq!(output(&a), "Name: ? ab\nab\n");
assert!(!a.quit);
assert!(a.session.vm.is_none());
key(&mut a, K::F(5), M::SHIFT);
tick(&mut a);
key(&mut a, K::Char('c'), M::CONTROL);
tick(&mut a);
assert_eq!(a.execution, Execution::Paused);
assert_eq!(output(&a), "Name: ? \n");
}
#[test]
fn changed_paused_source_requires_explicit_old_revision_or_restart() {
let t = Temp::new();
let mut a = t.app("STOP\nPRINT \"old\"\nEND\n");
a.execute(Command::Start);
tick(&mut a);
assert_eq!(a.execution, Execution::Paused);
let id = a.project.members()[0];
a.project
.replace_text(
id,
0..a.project.document(id).unwrap().code().len(),
"STOP\nPRINT \"new\"\nEND\n",
)
.unwrap();
a.execute(Command::Continue);
assert!(matches!(
a.dialog.as_ref().unwrap().kind,
DialogKind::ResumeRevision
));
a.dialog.as_mut().unwrap().fields[0] =
Field::choice("Fortsetzen", vec!["restart".into(), "old".into()], 1);
key(&mut a, K::Enter, M::NONE);
assert!(a.session.old_revision);
assert!(a
.title(a.active_window().unwrap())
.contains("ALTES KOMPILAT"));
tick(&mut a);
assert_eq!(output(&a), "old\n");
a.execute(Command::Restart);
tick(&mut a);
a.project
.replace_text(
id,
0..a.project.document(id).unwrap().code().len(),
"' comment\nPRINT \"latest\"\nEND\n",
)
.unwrap();
a.execute(Command::Continue);
key(&mut a, K::Enter, M::NONE);
tick(&mut a);
assert_eq!(output(&a), "latest\n");
assert!(!a.session.old_revision);
a.project
.replace_text(
id,
0..a.project.document(id).unwrap().code().len(),
"PRINT (\n",
)
.unwrap();
a.execute(Command::Start);
assert_eq!(a.execution, Execution::Error);
assert!(a.session.vm.is_none());
}
#[test]
fn output_viewports_resize_and_end_preserve_buffer_and_basic_colors() {
let t = Temp::new();
let mut a = t.app("COLOR 4,1\nPRINT \"visible\"\nSTOP\nEND\n");
a.execute(Command::Start);
tick(&mut a);
let original = output(&a);
let size = (a.session.screen().cols(), a.session.screen().rows());
let active = a.active;
let w = a.windows.iter_mut().find(|w| w.id == active).unwrap();
w.normal = ratatui::layout::Rect::new(3, 3, 30, 8);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
terminal.draw(|f| a.render(f)).unwrap();
assert_eq!(terminal.backend().buffer()[(4, 4)].symbol(), "v");
assert_eq!(
terminal.backend().buffer()[(4, 4)].fg,
tb_ui::screen::basic_color(4)
);
key(&mut a, K::F(4), M::NONE);
terminal.draw(|f| a.render(f)).unwrap();
assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), "v");
assert_eq!((a.session.screen().cols(), a.session.screen().rows()), size);
a.handle(Event::Resize(120, 40));
assert_eq!(
(a.session.screen().cols(), a.session.screen().rows()),
(120, 40)
);
key(&mut a, K::F(4), M::NONE);
a.execute(Command::Continue);
tick(&mut a);
assert_eq!(a.execution, Execution::Ended);
assert_eq!(output(&a), original);
}
#[test]
fn restart_command_run_targets_and_missing_target_preserve_documents() {
let t = Temp::new();
let mut a = t.app("PRINT COMMAND$\nn%=n%+1\nSTOP\nEND\n");
a.execute(Command::CommandLine);
a.dialog.as_mut().unwrap().fields[0] = Field::text("args", "one two");
key(&mut a, K::Enter, M::NONE);
for _ in 0..3 {
a.execute(Command::Restart);
tick(&mut a);
assert_eq!(output(&a), "one two\n");
assert!(matches!(
a.session.vm.as_ref().unwrap().inspect("n"),
Some(Value::Int(1))
));
}
let id = a.project.members()[0];
let path = a.project.document(id).unwrap().source_path().to_path_buf();
fs::write(t.0.join("other.bas"), "PRINT \"foreign\"\nEND\n").unwrap();
a.project
.replace_text(
id,
0..a.project.document(id).unwrap().code().len(),
"RUN \"OTHER\"\n",
)
.unwrap();
a.execute(Command::Start);
tick(&mut a);
assert_eq!(output(&a), "foreign\n");
assert_eq!(a.project.document(id).unwrap().source_path(), path);
assert_eq!(a.project.document(id).unwrap().code(), "RUN \"OTHER\"\n");
a.project
.replace_text(
id,
0..a.project.document(id).unwrap().code().len(),
"RUN \"missing\"\n",
)
.unwrap();
a.execute(Command::Start);
tick(&mut a);
assert_eq!(a.execution, Execution::Error);
assert!(a.message.contains("nicht gefunden"));
assert_eq!(a.project.document(id).unwrap().code(), "RUN \"missing\"\n");
assert!(!a.quit);
a.project
.replace_text(
id,
0..a.project.document(id).unwrap().code().len(),
"10 PRINT \"skip\"\n20 STOP\n30 RUN 40\n40 PRINT \"target\"\n50 END\n",
)
.unwrap();
a.execute(Command::Start);
tick(&mut a);
a.execute(Command::Continue);
tick(&mut a);
assert_eq!(output(&a), "target\n");
}
#[test]
fn print_uses_utf8_protected_output_and_does_not_mutate_source() {
let t = Temp::new();
let mut a = t.app("PRINT \"Grüße\"\n");
let id = a.project.members()[0];
let WindowKind::Code(v) = a.active_window().unwrap().kind else {
panic!()
};
a.project.view_mut(v).unwrap().anchor = Some(0);
a.project.view_mut(v).unwrap().cursor = 5;
a.execute(Command::Print);
assert!(a.dialog.as_ref().unwrap().fields[0]
.string()
.ends_with("LPT1.TXT"));
key(&mut a, K::Enter, M::NONE);
assert_eq!(fs::read_to_string(t.0.join("LPT1.TXT")).unwrap(), "PRINT");
a.execute(Command::Print);
key(&mut a, K::Enter, M::NONE);
assert!(!a.dialog.as_ref().unwrap().error.is_empty());
a.dialog.as_mut().unwrap().fields[1] = Field::toggle("overwrite", true);
a.dialog.as_mut().unwrap().fields[2] = Field::toggle("selection", false);
key(&mut a, K::Enter, M::NONE);
assert_eq!(
fs::read_to_string(t.0.join("LPT1.TXT")).unwrap(),
"PRINT \"Grüße\"\n"
);
a.execute(Command::Print);
a.dialog.as_mut().unwrap().fields[0] =
Field::text("path", t.0.join("main.bas").display().to_string());
a.dialog.as_mut().unwrap().fields[1] = Field::toggle("overwrite", true);
key(&mut a, K::Enter, M::NONE);
assert!(!a.dialog.as_ref().unwrap().error.is_empty());
assert_eq!(a.project.document(id).unwrap().code(), "PRINT \"Grüße\"\n");
assert_eq!(
fs::read_to_string(t.0.join("main.bas")).unwrap(),
"PRINT \"Grüße\"\n"
);
key(&mut a, K::Esc, M::NONE);
fs::write(t.0.join("blocked"), "existing destination parent").unwrap();
a.execute(Command::Print);
a.dialog.as_mut().unwrap().fields[0] =
Field::text("path", t.0.join("blocked/print.txt").display().to_string());
key(&mut a, K::Enter, M::NONE);
assert!(!a.dialog.as_ref().unwrap().error.is_empty());
assert_eq!(
fs::read_to_string(t.0.join("blocked")).unwrap(),
"existing destination parent"
);
assert_eq!(a.project.document(id).unwrap().code(), "PRINT \"Grüße\"\n");
}
#[test]
fn shell_request_is_resumed_once_and_error_is_basic_error() {
let t = Temp::new();
let mut a = t.app("PRINT SHELL(\"exit 7\")\nEND\n");
a.execute(Command::Start);
tick(&mut a);
assert_eq!(
a.session.host.shell_request.take().as_deref(),
Some("exit 7")
);
a.session.host.shell_result = Some(Ok(7));
tick(&mut a);
assert_eq!(output(&a), " 7 \n");
assert!(a.session.host.shell_request.is_none());
a.execute(Command::Start);
tick(&mut a);
a.session.host.shell_request = None;
a.session.host.shell_result = Some(Err(tb_runtime::errors::RuntimeError(53)));
tick(&mut a);
assert_eq!(a.execution, Execution::Error);
assert!(a.message.contains("53"));
}
#[test]
fn startup_selection_matches_shared_cli_pipeline_and_legacy_mak() {
use tb_vm::project_io::{new_execution, SourceLoader};
let t = Temp::new();
fs::write(t.0.join("a.bas"), "PRINT \"A\"\n").unwrap();
fs::write(t.0.join("b.bas"), "PRINT \"B\"\n").unwrap();
for (file, name) in [("one.frm", "One"), ("two.frm", "Two")] {
fs::write(
t.0.join(file),
format!("VERSION 1.00\nBEGIN Form {name}\nEND\n"),
)
.unwrap();
}
for startup in [None, Some("b.bas"), Some("two.frm")] {
fs::write(
t.0.join("p.mak"),
format!(
"a.bas\nb.bas\none.frm\ntwo.frm\n{}",
startup
.map(|s| format!("' $STARTUP: \"{s}\"\n"))
.unwrap_or_default()
),
)
.unwrap();
let mut a = App::new(&t.0, t.0.join("options"), (80, 25)).unwrap();
a.load_initial_project(t.0.join("p.mak")).unwrap();
a.session.command = "args".into();
a.execute(Command::Start);
let sources = SourceLoader::default().load(&t.0.join("p.mak")).unwrap();
let module = sources.compile(&mut Default::default(), "CLI").unwrap();
let module = tb_vm::bytecode::CompiledModule::from_tbc(&module.to_tbc()).unwrap();
let mut cli = new_execution(module, "args", Some((80, 25)), None).unwrap();
let ide = a.session.vm.as_ref().unwrap();
assert_eq!(ide.rt.command, cli.rt.command);
assert_eq!(ide.forms.active_form(), cli.forms.active_form());
assert_eq!(ide.forms.has_visible_forms(), startup != Some("b.bas"));
assert_eq!(
sources.units[0].name.to_ascii_uppercase(),
if startup == Some("two.frm") {
"TWO"
} else if startup == Some("b.bas") {
"B"
} else {
"A"
}
);
if startup == Some("b.bas") {
tick(&mut a);
assert_eq!(
cli.run(&mut tb_runtime::host::CaptureHost::default()),
tb_vm::interp::RunEvent::Ended
);
assert_eq!(output(&a), "B\nA\n");
assert_eq!(output(&a), snapshot::text(&cli.rt.screen));
}
}
}
#[test]
fn ide_and_cli_run_wrapper_match_input_time_and_forms_script() {
let t = Temp::new();
let form="VERSION 1.00\nBEGIN Form Form1\n BEGIN Timer Timer1\n END\nEND\nTimer1.Interval=100\nTimer1.Enabled=-1\nSLEEP 10\nForm1.Hide\nCLS\nLINE INPUT s$\nPRINT s$; COMMAND$\nEND\nSUB Timer1_Timer\nTimer1.Enabled=0\nEND SUB\n";
fs::write(t.0.join("form.frm"), form).unwrap();
let mut a = App::new(&t.0, t.0.join("options"), (80, 25)).unwrap();
a.load_initial_project(t.0.join("form.frm")).unwrap();
a.session.command = "!".into();
a.execute(Command::Start);
a.tick_execution(0);
assert_eq!(a.execution, Execution::Waiting);
a.tick_execution(100);
a.handle(Event::Paste("hello\n".into()));
for _ in 0..20 {
a.tick_execution(100);
}
assert_eq!(a.execution, Execution::Ended, "{}", a.message);
// CLI wrapper gets the same scripted input at the same host deadline.
struct ScriptHost {
now: u64,
events: std::collections::VecDeque<tb_runtime::host::Ereignis>,
}
impl tb_runtime::host::Host for ScriptHost {
fn present(&mut self, _: &tb_runtime::screen::TextScreen) {}
fn next_event(&mut self, _: bool) -> Option<tb_runtime::host::Ereignis> {
self.events.pop_front()
}
fn jetzt_ms(&mut self) -> u64 {
self.now
}
fn warten(&mut self, deadline: Option<u64>) -> Option<tb_runtime::host::Ereignis> {
if let Some(d) = deadline {
self.now = d;
None
} else {
self.events.extend(
"hello\r"
.chars()
.map(|c| tb_runtime::host::Ereignis::Taste(c.to_string(), 0)),
);
self.events.pop_front()
}
}
}
let module = tb_vm::project_io::load_program(&t.0.join("form.frm")).unwrap();
let mut cli = tb_vm::project_io::new_execution(module, "!", Some((80, 25)), None).unwrap();
assert_eq!(
cli.run(&mut ScriptHost {
now: 0,
events: Default::default()
}),
tb_vm::interp::RunEvent::Ended
);
assert_eq!(output(&a), snapshot::text(&cli.rt.screen));
assert_eq!(output(&a), "? hello\nhello!\n");
}
#[test]
fn restart_drops_files_data_traps_and_queued_actions() {
let t = Temp::new();
let mut a=t.app(&format!("DATA 7\nSTOP\nREAD n%\nOPEN \"{}\" FOR OUTPUT AS #1\nON TIMER(1) GOSUB H\nTIMER ON\nSTOP\nEND\nH:\nRETURN\n",t.0.join("opened.txt").display()));
a.execute(Command::Shell);
assert!(a.session.file_shell);
a.execute(Command::Start);
tick(&mut a);
assert!(!a.session.file_shell);
assert!(a.session.host.shell_request.is_none());
for _ in 0..3 {
let vm = a.session.vm.as_ref().unwrap();
assert!(!vm.rt.dateien.ist_offen(1));
assert_eq!(vm.rt.traps.naechste_frist(), None);
assert!(matches!(vm.inspect("n"), Some(Value::Int(0))));
a.execute(Command::Continue);
tick(&mut a);
let vm = a.session.vm.as_ref().unwrap();
assert!(vm.rt.dateien.ist_offen(1));
assert!(vm.rt.traps.naechste_frist().is_some());
assert!(matches!(vm.inspect("n"), Some(Value::Int(7))));
a.session
.host
.events
.push_back(tb_runtime::host::Ereignis::Taste("stale".into(), 0));
a.execute(Command::Restart);
tick(&mut a);
assert!(a.session.host.events.is_empty());
}
}
#[test]
fn named_run_of_current_target_uses_edited_sources() {
let t = Temp::new();
let mut a = t.app("PRINT \"disk\"\nEND\n");
let id = a.project.members()[0];
a.project
.replace_text(
id,
0..a.project.document(id).unwrap().code().len(),
"RUN \"MAIN.BAS\"\n",
)
.unwrap();
a.execute(Command::Start);
a.tick_execution(0);
assert!(a.session.project_target);
a.project
.replace_text(
id,
0..a.project.document(id).unwrap().code().len(),
"PRINT \"edited\"\nEND\n",
)
.unwrap();
tick(&mut a);
assert_eq!(output(&a), "edited\n");
}
#[test]
fn explicit_end_and_system_end_visible_forms_but_natural_end_keeps_them_live() {
let t = Temp::new();
for ending in ["END", "SYSTEM", ""] {
let path = t.0.join("form.frm");
fs::write(
&path,
format!("VERSION 1.00\nBEGIN Form Form1\nEND\n{ending}\n"),
)
.unwrap();
let mut a = App::new(&t.0, t.0.join("options"), (80, 25)).unwrap();
a.load_initial_project(path).unwrap();
a.execute(Command::Start);
assert!(a.session.vm.as_ref().unwrap().forms.has_visible_forms());
tick(&mut a);
if ending.is_empty() {
assert_eq!(a.execution, Execution::Waiting);
key(&mut a, K::F(4), M::NONE);
assert!(a.session.fullscreen);
a.handle(Event::Resize(40, 10));
key(&mut a, K::Pause, M::CONTROL);
assert_eq!(a.execution, Execution::Paused);
a.handle(Event::Resize(80, 25));
key(&mut a, K::F(5), M::NONE);
tick(&mut a);
assert_eq!(a.execution, Execution::Waiting);
assert!(a.session.vm.as_ref().unwrap().forms.has_visible_forms());
} else {
assert_eq!(a.execution, Execution::Ended);
assert!(a.session.vm.is_none());
}
assert!(!a.quit);
}
}
#[test]
fn comment_only_source_change_also_requires_revision_choice() {
let t = Temp::new();
let mut a = t.app("STOP ' old\nPRINT 1\nEND\n");
a.execute(Command::Start);
tick(&mut a);
let compiled = a.current_compilation().unwrap().to_tbc();
let id = a.project.members()[0];
a.project
.replace_text(
id,
0..a.project.document(id).unwrap().code().len(),
"STOP ' new\nPRINT 1\nEND\n",
)
.unwrap();
assert_eq!(a.compile_current().unwrap().to_tbc(), compiled);
a.execute(Command::Continue);
assert!(matches!(
a.dialog.as_ref().unwrap().kind,
DialogKind::ResumeRevision
));
}