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

@@ -2584,55 +2584,123 @@ fn dialog_set(
model.set_initial(object, property, value)
}
fn dialog_event(
model: &mut FormsModel,
screen: &mut TextScreen,
host: &mut dyn Host,
queued: &mut VecDeque<tb_runtime::builtins::Eingabe>,
plain_access_key: bool,
) -> Option<FormEvent> {
loop {
model.render(screen);
/// Fortsetzbarer modaler Dialog; Modell, Fokus und Hintergrund bleiben erhalten.
pub struct Dialog {
model: FormsModel,
old: TextScreen,
buttons: Vec<i16>,
default: i16,
}
#[derive(Debug, PartialEq)]
pub enum DialogValue {
Number(i16),
Text(String),
}
impl Dialog {
pub fn poll(
&mut self,
screen: &mut TextScreen,
host: &mut dyn Host,
queued: &mut VecDeque<tb_runtime::builtins::Eingabe>,
) -> Option<DialogValue> {
if (self.old.cols(), self.old.rows()) != (screen.cols(), screen.rows()) {
self.old.resize(screen.cols(), screen.rows());
self.model.resize(screen.cols(), screen.rows());
}
self.model.render(screen);
host.present(screen);
// One event per poll also bounds arbitrarily long queued input.
let event = queued
.pop_front()
.map(|e| e.ereignis)
.or_else(|| host.next_event(true))?;
.or_else(|| host.next_event(false))?;
match event {
Ereignis::Taste(key, shift) => {
let shift = if plain_access_key && key.chars().count() == 1 {
let shift = if !self.buttons.is_empty() && key.chars().count() == 1 {
shift | umschalt::ALT
} else {
shift
};
model.handle_key(&key, shift);
self.model.handle_key(&key, shift);
}
Ereignis::Maus(event) => {
let now = if model.mouse_needs_time(event) {
let now = if self.model.mouse_needs_time(event) {
host.jetzt_ms()
} else {
0
};
model.handle_mouse_at(event, now);
self.model.handle_mouse_at(event, now);
}
Ereignis::Groesse { cols, rows } => {
screen.resize(cols, rows);
model.resize(cols, rows);
self.old.resize(cols, rows);
self.model.resize(cols, rows);
}
event @ (Ereignis::Abbruch | Ereignis::Ende | Ereignis::Signal(_)) => {
queued.push_front(event.into());
return None;
}
}
while let Some(event) = model.next_event() {
if event.name == "CLICK" {
return Some(event);
while let Some(event) = self.model.next_event() {
if event.name != "CLICK" {
continue;
}
let value = if self.buttons.is_empty() {
match event.object {
3 => DialogValue::Text(self.model.string((2, None), "TEXT")),
4 => DialogValue::Text(String::new()),
_ => continue,
}
} else {
let Some(code) = event
.object
.checked_sub(2)
.and_then(|i| self.buttons.get(i as usize))
else {
continue;
};
DialogValue::Number(*code)
};
*screen = self.old.clone();
return Some(value);
}
None
}
fn run(
&mut self,
screen: &mut TextScreen,
host: &mut dyn Host,
queued: &mut VecDeque<tb_runtime::builtins::Eingabe>,
) -> DialogValue {
loop {
if let Some(value) = self.poll(screen, host, queued) {
return value;
}
if queued.front().is_some_and(|e| {
matches!(
e.ereignis,
Ereignis::Abbruch | Ereignis::Ende | Ereignis::Signal(_)
)
}) {
break;
}
if !queued.is_empty() {
continue;
}
match host.warten(None) {
Some(e) => queued.push_back(e.into()),
None => break,
}
}
*screen = self.old.clone();
if self.buttons.is_empty() {
DialogValue::Text(String::new())
} else {
DialogValue::Number(self.default)
}
}
}
/// Zeigt MSGBOX als echtes modales FormsModel-Formular.
pub fn msgbox_dialog(
screen: &mut TextScreen,
host: &mut dyn Host,
@@ -2641,6 +2709,33 @@ pub fn msgbox_dialog(
kind: i32,
title: &str,
) -> Result<i16, RuntimeError> {
match message_dialog(screen, text, kind, title)?.run(screen, host, queued) {
DialogValue::Number(n) => Ok(n),
_ => unreachable!(),
}
}
pub fn inputbox_dialog(
screen: &mut TextScreen,
host: &mut dyn Host,
queued: &mut VecDeque<tb_runtime::builtins::Eingabe>,
prompt: &str,
title: &str,
initial: &str,
position: Option<(i32, i32)>,
) -> Result<String, RuntimeError> {
match input_dialog(screen, prompt, title, initial, position)?.run(screen, host, queued) {
DialogValue::Text(s) => Ok(s),
_ => unreachable!(),
}
}
/// Zeigt MSGBOX als echtes modales FormsModel-Formular.
pub fn message_dialog(
screen: &TextScreen,
text: &str,
kind: i32,
title: &str,
) -> Result<Dialog, RuntimeError> {
let groups: &[&[(i16, &str)]] = &[
&[(1, "&OK")],
&[(1, "&OK"), (2, "&Cancel")],
@@ -2732,30 +2827,22 @@ pub fn msgbox_dialog(
model.show(0, true)?;
model.focus((default as u16 + 2, None))?;
model.events.clear();
let chosen = loop {
let Some(event) = dialog_event(&mut model, screen, host, queued, true) else {
break buttons[default].0;
};
if let Some(index) = event.object.checked_sub(2).map(usize::from) {
if let Some((code, _)) = buttons.get(index) {
break *code;
}
}
};
*screen = old;
Ok(chosen)
Ok(Dialog {
model,
old,
buttons: buttons.iter().map(|b| b.0).collect(),
default: buttons[default].0,
})
}
/// Zeigt INPUTBOX$ als echtes modales FormsModel-Formular.
pub fn inputbox_dialog(
screen: &mut TextScreen,
host: &mut dyn Host,
queued: &mut VecDeque<tb_runtime::builtins::Eingabe>,
pub fn input_dialog(
screen: &TextScreen,
prompt: &str,
title: &str,
initial: &str,
position: Option<(i32, i32)>,
) -> Result<String, RuntimeError> {
) -> Result<Dialog, RuntimeError> {
let width = 46usize.min(screen.cols());
let height = 16usize.min(screen.rows());
let (left, top) = position.map_or_else(
@@ -2824,23 +2911,12 @@ pub fn inputbox_dialog(
model.show(0, true)?;
model.focus((2, None))?;
model.events.clear();
let accepted = loop {
let Some(event) = dialog_event(&mut model, screen, host, queued, false) else {
break false;
};
match event.object {
3 => break true,
4 => break false,
_ => {}
}
};
let text = if accepted {
model.string((2, None), "TEXT")
} else {
String::new()
};
*screen = old;
Ok(text)
Ok(Dialog {
model,
old,
buttons: Vec::new(),
default: 0,
})
}
#[cfg(test)]

View File

@@ -4,16 +4,12 @@
//! Er lebt hier statt in `tb-runtime`, damit die Ausführungsschicht ohne
//! Terminal-Abhängigkeit bleibt (Entscheidung 2026-09-03, D1/D2).
use std::io::{self, Stdout};
use std::io::{self, IsTerminal, Stdout};
use std::time::{Duration, Instant};
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
@@ -29,6 +25,7 @@ use crate::signale::Signalquelle;
/// Der Konstruktor schaltet in den Alternativschirm und den Rohmodus,
/// `Drop` stellt beides wieder her — auch bei Panik oder Laufzeitfehler.
pub struct TerminalHost {
guard: crate::terminal::TerminalGuard<Stdout>,
terminal: Terminal<CrosstermBackend<Stdout>>,
/// Nullpunkt der monotonen Zeit (`Host::jetzt_ms`).
start: Instant,
@@ -40,15 +37,18 @@ pub struct TerminalHost {
impl TerminalHost {
pub fn new() -> io::Result<Self> {
enable_raw_mode()?;
let mut out = io::stdout();
execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
return Err(io::Error::other("Kein interaktives Terminal"));
}
let guard = crate::terminal::TerminalGuard::enter(io::stdout())?;
let out = io::stdout();
let terminal = Terminal::new(CrosstermBackend::new(out))?;
let flaeche = terminal
.size()
.map(|s| (s.width as usize, s.height as usize))
.unwrap_or((80, 25));
Ok(TerminalHost {
guard,
terminal,
start: Instant::now(),
signale: Signalquelle::neu(),
@@ -63,23 +63,25 @@ impl TerminalHost {
}
}
impl Drop for TerminalHost {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(
self.terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
);
let _ = self.terminal.show_cursor();
}
}
impl Host for TerminalHost {
fn shell(&mut self, command: &str) -> Result<Option<i32>, tb_runtime::errors::RuntimeError> {
let result = self.guard.shell(command, &self.signale);
let _ = self.terminal.clear();
result
.map(Some)
.map_err(|_| tb_runtime::errors::RuntimeError(53))
}
fn present(&mut self, screen: &TextScreen) {
let _ = self
.terminal
.draw(|f| f.render_widget(ScreenWidget(screen), f.area()));
let _ = self.terminal.draw(|f| {
if f.area().width < 80 || f.area().height < 25 {
f.render_widget(
ratatui::widgets::Paragraph::new("Terminal zu klein: Minimum 80x25"),
f.area(),
);
} else {
f.render_widget(ScreenWidget(screen), f.area());
}
});
}
fn jetzt_ms(&mut self) -> u64 {
@@ -152,7 +154,7 @@ impl Host for TerminalHost {
/// crossterm zählt ab 0, der Dialekt ab 1 — umgerechnet wird hier, damit es
/// in der Laufzeit nur eine Zählweise gibt. Ereignisse außerhalb der
/// Darstellungsfläche und Ereignisse ohne Entsprechung liefern `None`.
fn maus_zu_ereignis(m: MouseEvent, flaeche: (usize, usize)) -> Option<Ereignis> {
pub fn maus_zu_ereignis(m: MouseEvent, flaeche: (usize, usize)) -> Option<Ereignis> {
let (cols, rows) = flaeche;
let spalte = m.column as usize + 1;
let zeile = m.row as usize + 1;
@@ -195,7 +197,7 @@ fn maus_zu_ereignis(m: MouseEvent, flaeche: (usize, usize)) -> Option<Ereignis>
}
/// crossterm-Taste → `INKEY$`-Form. `None` für Tasten ohne Entsprechung.
fn taste_zu_ereignis(k: KeyEvent) -> Option<Ereignis> {
pub fn taste_zu_ereignis(k: KeyEvent) -> Option<Ereignis> {
// Strg+Untbr bzw. Strg+C: Abbruchwunsch.
if k.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(k.code, KeyCode::Char('c') | KeyCode::Pause)

View File

@@ -14,3 +14,6 @@ pub mod host; // Terminal-Host: Anzeige + Tastatur-/Größenereignisse
pub mod screen;
#[cfg(feature = "terminal")]
pub mod signale; // Betriebssystemsignale als Ereignisquelle (SIGNAL)
#[cfg(feature = "terminal")]
pub mod terminal;

View File

@@ -22,20 +22,9 @@ pub fn basic_color(n: u8) -> Color {
/// Zeichenbares Gegenstück zu [`TextScreen`].
pub struct ScreenWidget<'a>(pub &'a TextScreen);
/// Rendert den Bildschirm oben links in die verfügbare Fläche. Ist das
/// Terminal kleiner als die Mindestgröße, erscheint nur ein Hinweis
/// (btop-artig); die Anwendung ruft bei Resize `TextScreen::resize` auf,
/// damit Puffer und Terminal deckungsgleich bleiben.
/// Clipped viewport; only the terminal owner enforces physical minimum size.
impl Widget for ScreenWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if (area.width as usize) < MIN_COLS || (area.height as usize) < MIN_ROWS {
let msg = format!(
"Terminal zu klein: {}x{} — Minimum {}x{}",
area.width, area.height, MIN_COLS, MIN_ROWS
);
buf.set_string(area.x, area.y, msg, Style::default().fg(Color::Red));
return;
}
let cols = self.0.cols().min(area.width as usize);
let rows = self.0.rows().min(area.height as usize);
for row in 0..rows {

View File

@@ -0,0 +1,190 @@
use crossterm::{
cursor::{Hide, Show},
event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture},
execute,
style::ResetColor,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use std::io::{self, Write};
/// Ein Besitzer für Raw Mode und Terminalzustand, auch bei teilweiser Initialisierung.
pub struct TerminalGuard<W: Write> {
writer: W,
raw: fn(bool) -> io::Result<()>,
}
impl<W: Write> TerminalGuard<W> {
pub fn enter(writer: W) -> io::Result<Self> {
Self::with_raw(writer, |on| {
if on {
enable_raw_mode()
} else {
disable_raw_mode()
}
})
}
fn with_raw(writer: W, raw: fn(bool) -> io::Result<()>) -> io::Result<Self> {
let mut guard = Self { writer, raw };
guard.resume()?;
Ok(guard)
}
fn resume(&mut self) -> io::Result<()> {
(self.raw)(true)?;
execute!(
self.writer,
EnterAlternateScreen,
EnableMouseCapture,
EnableBracketedPaste,
Hide
)
}
fn suspend(&mut self) -> io::Result<()> {
(self.raw)(false)?;
execute!(
self.writer,
ResetColor,
Show,
DisableMouseCapture,
DisableBracketedPaste,
LeaveAlternateScreen
)
}
/// Foreground child owns cooked terminal input. Always restore the IDE,
/// including spawn/wait errors and a child interrupted by Ctrl+C.
pub fn shell(
&mut self,
command: &str,
signals: &crate::signale::Signalquelle,
) -> io::Result<i32> {
let result = self.shell_with_interrupt(command, || signals.abholen().is_some());
// A child may exit before try_wait observes the terminal signal.
// It belongs to the handed-off terminal, not the resumed BASIC run.
while signals.abholen().is_some() {}
result
}
fn shell_with_interrupt(
&mut self,
command: &str,
mut interrupted: impl FnMut() -> bool,
) -> io::Result<i32> {
let result = self.suspend().and_then(|_| {
let mut child = tb_runtime::host::shell_command(command).spawn()?;
loop {
match child.try_wait() {
Ok(Some(status)) => return Ok(status.code().unwrap_or(0)),
Ok(None) => {}
Err(error) => {
let _ = child.kill();
let _ = child.wait();
return Err(error);
}
}
if interrupted() {
let _ = child.kill();
return child.wait().map(|status| status.code().unwrap_or(0));
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
});
let restored = self.resume();
restored.and(result)
}
}
impl<W: Write> Drop for TerminalGuard<W> {
fn drop(&mut self) {
let _ = (self.raw)(false);
let _ = execute!(
self.writer,
ResetColor,
Show,
DisableMouseCapture,
DisableBracketedPaste,
LeaveAlternateScreen
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
cell::RefCell,
rc::Rc,
sync::atomic::{AtomicI32, Ordering},
};
static RAW: AtomicI32 = AtomicI32::new(0);
#[derive(Clone)]
struct Output(Rc<RefCell<Vec<u8>>>);
impl Write for Output {
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
self.0.borrow_mut().extend_from_slice(b);
Ok(b.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn raw(on: bool) -> io::Result<()> {
RAW.store(i32::from(on), Ordering::SeqCst);
Ok(())
}
#[test]
fn terminal_cleanup_on_normal_error_and_partial_initialization() {
for fail in [false, true] {
let out = Output(Rc::default());
let copy = out.clone();
let result = (|| -> io::Result<()> {
let _guard = TerminalGuard::with_raw(out, raw)?;
if fail {
return Err(io::Error::other("Renderfehler"));
}
Ok(())
})();
assert_eq!(result.is_err(), fail);
assert_eq!(RAW.load(Ordering::SeqCst), 0);
let s = String::from_utf8(copy.0.borrow().clone()).unwrap();
assert!(s.contains("?1049h") && s.contains("?1049l") && s.contains("?25h"));
}
for (command, abort, expected) in [
("exit 0", false, 0),
("exit 7", false, 7),
(
if cfg!(windows) {
"ping -n 30 127.0.0.1 >nul"
} else {
"exec sleep 30"
},
true,
0,
),
] {
let out = Output(Rc::default());
let copy = out.clone();
let mut guard = TerminalGuard::with_raw(out, raw).unwrap();
assert_eq!(
guard.shell_with_interrupt(command, || abort).unwrap(),
expected
);
assert_eq!(RAW.load(Ordering::SeqCst), 1);
let text = String::from_utf8(copy.0.borrow().clone()).unwrap();
assert_eq!(text.matches("?1049h").count(), 2);
assert_eq!(text.matches("?1049l").count(), 1);
drop(guard);
assert_eq!(RAW.load(Ordering::SeqCst), 0);
}
let out = Output(Rc::default());
let copy = out.clone();
assert!(TerminalGuard::with_raw(out, |on| {
raw(on)?;
if on {
Err(io::Error::other("Raw-Fehler"))
} else {
Ok(())
}
})
.is_err());
assert_eq!(RAW.load(Ordering::SeqCst), 0);
assert!(String::from_utf8(copy.0.borrow().clone())
.unwrap()
.contains("?25h"));
}
}