//! Terminal-Host: stellt den Zellenpuffer über Ratatui dar und liefert //! Tastatur- und Größenereignisse aus crossterm. //! //! 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::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, }; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; 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. /// /// Der Konstruktor schaltet in den Alternativschirm und den Rohmodus, /// `Drop` stellt beides wieder her — auch bei Panik oder Laufzeitfehler. pub struct TerminalHost { terminal: Terminal>, /// 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 { pub fn new() -> io::Result { enable_raw_mode()?; let mut out = io::stdout(); execute!(out, EnterAlternateScreen, EnableMouseCapture)?; 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 { terminal, start: Instant::now(), signale: Signalquelle::neu(), flaeche, }) } /// Aktuelle Terminalgröße, damit der Bildschirm sie von Beginn an nutzt. pub fn groesse(&self) -> io::Result<(usize, usize)> { let s = self.terminal.size()?; Ok((s.width as usize, s.height as usize)) } } 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 present(&mut self, screen: &TextScreen) { let _ = self .terminal .draw(|f| f.render_widget(ScreenWidget(screen), f.area())); } fn jetzt_ms(&mut self) -> u64 { self.start.elapsed().as_millis() as u64 } fn warten(&mut self, deadline_ms: Option) -> Option { loop { if let Some(e) = self.next_event(false) { return Some(e); } let timeout = match deadline_ms { Some(deadline) => { let rest = deadline.saturating_sub(self.jetzt_ms()); if rest == 0 { return None; } Duration::from_millis(rest.min(10)) } None => Duration::from_millis(10), }; // Begrenzt warten, damit auch SIGINT/SIGTERM ohne Terminaltaste // abgeholt werden. Die VM kennt dieses Backend-Intervall nicht. if event::poll(timeout).is_err() { return Some(Ereignis::Ende); } } } fn next_event(&mut self, blockierend: bool) -> Option { if blockierend { return self.warten(None); } 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; } match event::read() { Ok(Event::Key(k)) if k.kind == KeyEventKind::Press => { if let Some(e) = taste_zu_ereignis(k) { return Some(e); } } 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), } } } } /// 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 { 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 { // Strg+Untbr bzw. Strg+C: Abbruchwunsch. if k.modifiers.contains(KeyModifiers::CONTROL) && matches!(k.code, KeyCode::Char('c') | KeyCode::Pause) { return Some(Ereignis::Abbruch); } let s = match k.code { KeyCode::Char(c) => c.to_string(), KeyCode::Enter => taste::ENTER.to_string(), KeyCode::Backspace => taste::BACKSPACE.to_string(), KeyCode::Esc => taste::ESC.to_string(), KeyCode::Tab | KeyCode::BackTab => taste::TAB.to_string(), // Sondertasten in der Kodierung des Vorbilds: Nullzeichen + Kennung. KeyCode::F(n @ 1..=10) => taste::sonder(58 + n), KeyCode::Home => taste::sonder(71), KeyCode::Up => taste::sonder(72), KeyCode::PageUp => taste::sonder(73), KeyCode::Left => taste::sonder(75), KeyCode::Right => taste::sonder(77), KeyCode::End => taste::sonder(79), KeyCode::Down => taste::sonder(80), KeyCode::PageDown => taste::sonder(81), KeyCode::Insert => taste::sonder(82), KeyCode::Delete => taste::sonder(83), _ => return None, }; let mut shift = 0u8; if k.modifiers.contains(KeyModifiers::SHIFT) || k.code == KeyCode::BackTab { 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)] mod tests { use super::*; #[test] fn backtab_ist_tab_mit_shift_auch_ohne_modifier() { for modifiers in [KeyModifiers::NONE, KeyModifiers::SHIFT, KeyModifiers::ALT] { assert_eq!( taste_zu_ereignis(KeyEvent::new(KeyCode::BackTab, modifiers)), taste_zu_ereignis(KeyEvent::new(KeyCode::Tab, modifiers | KeyModifiers::SHIFT)) ); } } #[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(), 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 { panic!("keine Taste"); }; assert_eq!(s.chars().count(), 2); assert_eq!(s.chars().next(), Some('\0')); 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); assert_eq!(taste_zu_ereignis(k), Some(Ereignis::Abbruch)); } }