392 lines
13 KiB
Rust
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));
|
|
}
|
|
}
|