//! 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; use crossterm::event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, }; 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, Ereignis, Host}; use tb_runtime::screen::TextScreen; use crate::screen::ScreenWidget; /// 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>, } 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))?; Ok(TerminalHost { terminal }) } /// 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 next_event(&mut self, blockierend: bool) -> Option { loop { 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)) => { return Some(Ereignis::Groesse { cols: cols as usize, rows: rows as usize, }) } Ok(_) => {} Err(_) => return Some(Ereignis::Ende), } } } } /// 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 => 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, }; Some(Ereignis::Taste(s)) } #[cfg(test)] mod tests { use super::*; #[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()))); } #[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 } #[test] fn strg_c_ist_abbruch() { let k = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); assert_eq!(taste_zu_ereignis(k), Some(Ereignis::Abbruch)); } }