Phase 4: Ereignisschleife und klassische Traps

This commit is contained in:
2026-09-04 15:56:47 +02:00
parent 060674b466
commit 5cf5a6582c
40 changed files with 4024 additions and 176 deletions

View File

@@ -5,11 +5,11 @@
//! Terminal-Abhängigkeit bleibt (Entscheidung 2026-09-03, D1/D2).
use std::io::{self, Stdout};
use std::time::Duration;
use std::time::{Duration, Instant};
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
KeyModifiers,
KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{
@@ -18,10 +18,11 @@ use crossterm::terminal::{
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use tb_runtime::host::{taste, Ereignis, Host};
use tb_runtime::host::{taste, umschalt, Ereignis, Host, MausArt, MausEreignis};
use tb_runtime::screen::TextScreen;
use crate::screen::ScreenWidget;
use crate::signale::Signalquelle;
/// Host für laufende Programme im Terminal.
///
@@ -29,6 +30,12 @@ use crate::screen::ScreenWidget;
/// `Drop` stellt beides wieder her — auch bei Panik oder Laufzeitfehler.
pub struct TerminalHost {
terminal: Terminal<CrosstermBackend<Stdout>>,
/// Nullpunkt der monotonen Zeit (`Host::jetzt_ms`).
start: Instant,
signale: Signalquelle,
/// Zuletzt bekannte Darstellungsgröße in Zellen. Mausereignisse
/// außerhalb dieser Fläche werden verworfen (design.md, D7).
flaeche: (usize, usize),
}
impl TerminalHost {
@@ -37,7 +44,16 @@ impl TerminalHost {
let mut out = io::stdout();
execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
let terminal = Terminal::new(CrosstermBackend::new(out))?;
Ok(TerminalHost { terminal })
let flaeche = terminal
.size()
.map(|s| (s.width as usize, s.height as usize))
.unwrap_or((80, 25));
Ok(TerminalHost {
terminal,
start: Instant::now(),
signale: Signalquelle::neu(),
flaeche,
})
}
/// Aktuelle Terminalgröße, damit der Bildschirm sie von Beginn an nutzt.
@@ -66,8 +82,17 @@ impl Host for TerminalHost {
.draw(|f| f.render_widget(ScreenWidget(screen), f.area()));
}
fn jetzt_ms(&mut self) -> u64 {
self.start.elapsed().as_millis() as u64
}
fn next_event(&mut self, blockierend: bool) -> Option<Ereignis> {
loop {
// Signale vor dem Terminal: sie liegen schon an, wenn wir hier
// ankommen, und ein blockierendes `read` wuerde sie liegenlassen.
if let Some(n) = self.signale.abholen() {
return Some(Ereignis::Signal(n));
}
if !blockierend && !event::poll(Duration::ZERO).unwrap_or(false) {
return None;
}
@@ -78,10 +103,16 @@ impl Host for TerminalHost {
}
}
Ok(Event::Resize(cols, rows)) => {
self.flaeche = (cols as usize, rows as usize);
return Some(Ereignis::Groesse {
cols: cols as usize,
rows: rows as usize,
})
});
}
Ok(Event::Mouse(m)) => {
if let Some(e) = maus_zu_ereignis(m, self.flaeche) {
return Some(e);
}
}
Ok(_) => {}
Err(_) => return Some(Ereignis::Ende),
@@ -90,6 +121,53 @@ impl Host for TerminalHost {
}
}
/// crossterm-Maus → Ereignis in Zellenkoordinaten (design.md, D7).
///
/// 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> {
let (cols, rows) = flaeche;
let spalte = m.column as usize + 1;
let zeile = m.row as usize + 1;
if spalte > cols || zeile > rows {
return None;
}
let knopf = |b: MouseButton| match b {
MouseButton::Left => 1,
MouseButton::Right => 2,
MouseButton::Middle => 4,
};
let (art, taste) = match m.kind {
MouseEventKind::Down(b) => (MausArt::Druck, knopf(b)),
MouseEventKind::Up(b) => (MausArt::Loslassen, knopf(b)),
MouseEventKind::Drag(b) => (MausArt::Bewegung, knopf(b)),
MouseEventKind::Moved => (MausArt::Bewegung, 0),
// Rollrad kennt das Vorbild nicht — Stufe 2 (PLAN.md).
MouseEventKind::ScrollDown
| MouseEventKind::ScrollUp
| MouseEventKind::ScrollLeft
| MouseEventKind::ScrollRight => return None,
};
let mut shift = 0u8;
if m.modifiers.contains(KeyModifiers::SHIFT) {
shift |= 1;
}
if m.modifiers.contains(KeyModifiers::CONTROL) {
shift |= 2;
}
if m.modifiers.contains(KeyModifiers::ALT) {
shift |= 4;
}
Some(Ereignis::Maus(MausEreignis {
art,
taste,
shift,
zeile,
spalte,
}))
}
/// crossterm-Taste → `INKEY$`-Form. `None` für Tasten ohne Entsprechung.
fn taste_zu_ereignis(k: KeyEvent) -> Option<Ereignis> {
// Strg+Untbr bzw. Strg+C: Abbruchwunsch.
@@ -118,7 +196,17 @@ fn taste_zu_ereignis(k: KeyEvent) -> Option<Ereignis> {
KeyCode::Delete => taste::sonder(83),
_ => return None,
};
Some(Ereignis::Taste(s))
let mut shift = 0u8;
if k.modifiers.contains(KeyModifiers::SHIFT) {
shift |= umschalt::SHIFT;
}
if k.modifiers.contains(KeyModifiers::CONTROL) {
shift |= umschalt::CTRL;
}
if k.modifiers.contains(KeyModifiers::ALT) {
shift |= umschalt::ALT;
}
Some(Ereignis::Taste(s, shift))
}
#[cfg(test)]
@@ -128,13 +216,13 @@ mod tests {
#[test]
fn zeichentaste_wird_zu_inkey_form() {
let k = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
assert_eq!(taste_zu_ereignis(k), Some(Ereignis::Taste("a".into())));
assert_eq!(taste_zu_ereignis(k), Some(Ereignis::Taste("a".into(), 0)));
}
#[test]
fn sondertaste_traegt_fuehrendes_nullzeichen() {
let k = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
let Some(Ereignis::Taste(s)) = taste_zu_ereignis(k) else {
let Some(Ereignis::Taste(s, _)) = taste_zu_ereignis(k) else {
panic!("keine Taste");
};
assert_eq!(s.chars().count(), 2);
@@ -142,6 +230,123 @@ mod tests {
assert_eq!(s.chars().nth(1), Some(59 as char)); // F1 = 59
}
fn maus(kind: MouseEventKind, col: u16, row: u16, mods: KeyModifiers) -> MouseEvent {
MouseEvent {
kind,
column: col,
row,
modifiers: mods,
}
}
#[test]
fn maus_zaehlt_ab_eins() {
let e = maus_zu_ereignis(
maus(
MouseEventKind::Down(MouseButton::Left),
0,
0,
KeyModifiers::NONE,
),
(80, 25),
);
assert_eq!(
e,
Some(Ereignis::Maus(MausEreignis {
art: MausArt::Druck,
taste: 1,
shift: 0,
zeile: 1,
spalte: 1,
}))
);
}
#[test]
fn letzte_zelle_liegt_noch_drin() {
let e = maus_zu_ereignis(
maus(
MouseEventKind::Up(MouseButton::Right),
79,
24,
KeyModifiers::NONE,
),
(80, 25),
);
let Some(Ereignis::Maus(m)) = e else {
panic!("kein Mausereignis")
};
assert_eq!((m.zeile, m.spalte), (25, 80));
assert_eq!((m.art, m.taste), (MausArt::Loslassen, 2));
}
#[test]
fn ausserhalb_der_flaeche_wird_verworfen() {
let e = maus_zu_ereignis(
maus(MouseEventKind::Moved, 80, 0, KeyModifiers::NONE),
(80, 25),
);
assert_eq!(e, None);
let e = maus_zu_ereignis(
maus(MouseEventKind::Moved, 0, 25, KeyModifiers::NONE),
(80, 25),
);
assert_eq!(e, None);
}
#[test]
fn nach_groessenaenderung_gilt_die_neue_flaeche() {
// Dieselbe Position: bei 80x25 draussen, bei 120x40 drin.
let p = maus(MouseEventKind::Moved, 100, 30, KeyModifiers::NONE);
assert_eq!(maus_zu_ereignis(p, (80, 25)), None);
let Some(Ereignis::Maus(m)) = maus_zu_ereignis(p, (120, 40)) else {
panic!("kein Mausereignis")
};
assert_eq!((m.zeile, m.spalte), (31, 101));
}
#[test]
fn umschaltzustand_als_bitfeld() {
let e = maus_zu_ereignis(
maus(
MouseEventKind::Down(MouseButton::Middle),
5,
5,
KeyModifiers::SHIFT | KeyModifiers::ALT,
),
(80, 25),
);
let Some(Ereignis::Maus(m)) = e else {
panic!("kein Mausereignis")
};
assert_eq!(m.shift, 1 | 4);
assert_eq!(m.taste, 4);
}
#[test]
fn rollrad_hat_keine_entsprechung() {
let e = maus_zu_ereignis(
maus(MouseEventKind::ScrollUp, 1, 1, KeyModifiers::NONE),
(80, 25),
);
assert_eq!(e, None);
}
#[test]
fn umschaltzustand_der_taste_wird_mitgefuehrt() {
// Strg+A: Strg allein ist kein Abbruch, nur Strg+C ist es.
let k = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
assert_eq!(
taste_zu_ereignis(k),
Some(Ereignis::Taste("a".into(), umschalt::CTRL))
);
let k = KeyEvent::new(KeyCode::F(1), KeyModifiers::ALT | KeyModifiers::SHIFT);
let Some(Ereignis::Taste(_, shift)) = taste_zu_ereignis(k) else {
panic!("keine Taste")
};
assert_eq!(shift, umschalt::ALT | umschalt::SHIFT);
}
#[test]
fn strg_c_ist_abbruch() {
let k = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);

View File

@@ -8,7 +8,8 @@
//! (Schaltflächen, Textfelder, Listen …), Menüs, Fokusreihenfolge,
//! Tastatur- und Mausereignisse.
pub mod screen;
pub mod host; // Terminal-Host: Anzeige + Tastatur-/Größenereignisse
pub mod forms;
pub mod events;
pub mod forms;
pub mod host; // Terminal-Host: Anzeige + Tastatur-/Größenereignisse
pub mod screen;
pub mod signale; // Betriebssystemsignale als Ereignisquelle (SIGNAL)

112
crates/tb-ui/src/signale.rs Normal file
View File

@@ -0,0 +1,112 @@
//! Betriebssystemsignale als Ereignisquelle (design.md, D8).
//!
//! Der Signalhandler tut nur eines: er setzt eine Marke. Übersetzt wird sie
//! erst beim Abholen durch den Host, an einem gewöhnlichen Zustellpunkt —
//! im Signalkontext läuft damit kein Programmcode und nichts von der
//! Laufzeit.
//!
//! Abgebildet wird nur, was auf allen Zielplattformen besteht:
//! `SIGNAL(1)` = `SIGINT`, `SIGNAL(2)` = `SIGTERM` (Sprachreferenz,
//! Abweichung 8). Unter Windows nutzt `signal-hook` das `signal` der
//! C-Laufzeit; `SIGTERM` ist dort von außen nicht zustellbar.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use signal_hook::consts::{SIGINT, SIGTERM};
/// Kennung von `SIGNAL(n%)`.
pub const SIGNAL_INT: u8 = 1;
pub const SIGNAL_TERM: u8 = 2;
pub struct Signalquelle {
int: Arc<AtomicBool>,
term: Arc<AtomicBool>,
}
impl Signalquelle {
/// Handler eintragen. Schlägt das fehl, bleibt die Quelle stumm — ein
/// Programm ohne `SIGNAL`-Trap merkt davon nichts, und eines mit Trap
/// verhält sich wie unter `SIGNAL(n) OFF`.
pub fn neu() -> Self {
let int = Arc::new(AtomicBool::new(false));
let term = Arc::new(AtomicBool::new(false));
let _ = signal_hook::flag::register(SIGINT, Arc::clone(&int));
let _ = signal_hook::flag::register(SIGTERM, Arc::clone(&term));
Signalquelle { int, term }
}
/// Höchstens ein anstehendes Signal abholen und die Marke zurücksetzen.
/// `SIGINT` hat Vorrang: es ist das dringendere der beiden.
pub fn abholen(&self) -> Option<u8> {
if self.int.swap(false, Ordering::SeqCst) {
return Some(SIGNAL_INT);
}
if self.term.swap(false, Ordering::SeqCst) {
return Some(SIGNAL_TERM);
}
None
}
}
#[cfg(test)]
impl Signalquelle {
/// Quelle ohne eingetragene Handler. Ein echtes Signal erreicht **jede**
/// im Prozess registrierte Quelle; die reinen Übersetzungstests müssen
/// deshalb aus dem Weg bleiben.
fn ohne_registrierung() -> Self {
Signalquelle {
int: Arc::new(AtomicBool::new(false)),
term: Arc::new(AtomicBool::new(false)),
}
}
}
impl Default for Signalquelle {
fn default() -> Self {
Signalquelle::neu()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Marke → Ereignis, ohne echtes Signal: prüft die Übersetzung.
#[test]
fn marke_wird_zu_kennung_und_verfaellt_danach() {
let q = Signalquelle::ohne_registrierung();
assert_eq!(q.abholen(), None);
q.int.store(true, Ordering::SeqCst);
assert_eq!(q.abholen(), Some(SIGNAL_INT));
assert_eq!(q.abholen(), None, "eine Marke gilt genau einmal");
q.term.store(true, Ordering::SeqCst);
assert_eq!(q.abholen(), Some(SIGNAL_TERM));
}
#[test]
fn sigint_hat_vorrang_vor_sigterm() {
let q = Signalquelle::ohne_registrierung();
q.int.store(true, Ordering::SeqCst);
q.term.store(true, Ordering::SeqCst);
assert_eq!(q.abholen(), Some(SIGNAL_INT));
assert_eq!(q.abholen(), Some(SIGNAL_TERM));
}
/// Ganze Kette: echtes Signal an den eigenen Prozess. Der eingetragene
/// Handler ersetzt die Vorgabewirkung, der Test überlebt es also.
#[test]
fn echtes_signal_setzt_die_marke() {
let q = Signalquelle::neu();
signal_hook::low_level::raise(SIGINT).expect("raise");
// Zustellung ist asynchron; ein paar Versuche genuegen.
for _ in 0..1000 {
if let Some(n) = q.abholen() {
assert_eq!(n, SIGNAL_INT);
return;
}
std::thread::yield_now();
}
panic!("SIGINT kam nicht an");
}
}