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