Files
TerminalBasic/crates/tb-ui/src/host.rs

392 lines
13 KiB
Rust

//! 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<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 {
pub fn new() -> io::Result<Self> {
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<u64>) -> Option<Ereignis> {
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<Ereignis> {
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<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.
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));
}
}