Phase 4: Ereignisschleife und klassische Traps
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
112
crates/tb-ui/src/signale.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user