Files
rustkeep/.scratch/rustkeep-1.0/research/06-ratatui-architektur.md

65 KiB
Raw Blame History

06 — Research: ratatui-App-Architektur

Recherchiert am 2026-08-11. Alle Versions- und Empfehlungsangaben sind gegen Live-Quellen geprüft (crates.io, docs.rs, ratatui.rs, GitHub-Rohdateien), nicht aus Trainingswissen.

Ergebnis ist eine begründete Empfehlung, keine Entscheidung.


0. Kurzfassung

Frage Antwort in einem Satz
App-Muster ratatui empfiehlt nichts — es dokumentiert drei Muster (Elm, Component, Flux) neutral. Für rustkeep passt ein Screen-Stack (Vec<Screen>) über einem zentralen Game-State, kein Elm, kein Component-Trait.
Popups Kein Widget-Stack. Der Standard ist: Screen rendern, dann Clear auf ein zentriertes Sub-Rect, dann Popup.
Logik/Rendering Rendering ist reine Funktion (&Game, &Ui) -> Buffer. Testbar ohne Terminal über TestBackend — besser noch: Spiellogik komplett ohne ratatui-Typen.
Event-Loop Blockierendes event::read(). Ein Tick ist für ein rundenbasiertes Spiel nicht nötig.
Dungeon-Grid Muss selbst gebaut werden. Es gibt kein Grid-/Tilemap-Widget, weder in ratatui noch als Drittanbieter-Crate. impl Widget mit direktem Buffer-Zugriff.
Panic ratatui::init() bzw. ratatui::run() installieren den Panic-Hook selbst. Die Rezeptseite dazu ist veraltet.
Versionen ratatui 0.30.2 (2026-06-19, MSRV 1.88), crossterm 0.29.0 (2025-04-05).
Windows Ein Fallstrick, der zwingend adressiert werden muss: doppelte Key-Events. Lösung ist eine Zeile. Dazu ein paar Dinge, die auf Windows schlicht nicht gehen (Kitty-Protokoll, Event::Paste) und Spaltenversatz bei doppelbreiten Zeichen in conhost.

1. Versionsstand

Crate Version Datum Quelle
ratatui 0.30.2 2026-06-19 https://crates.io/api/v1/crates/ratatui, https://raw.githubusercontent.com/ratatui/ratatui/main/CHANGELOG.md
crossterm 0.29.0 2025-04-05 https://crates.io/api/v1/crates/crossterm, https://github.com/crossterm-rs/crossterm/releases/tag/0.29

Vorgängerversionen zur Einordnung: ratatui 0.30.1 (2026-06-05), 0.30.0 (2025-12-26), 0.29.0 (2024-10-21) — Quelle wie oben.

MSRV: ratatui 0.30.2 verlangt Rust 1.88.0 (rust-version = "1.88.0" in https://github.com/ratatui/ratatui/blob/main/Cargo.toml; 0.30.0 lag noch bei 1.86.0). ratatui selbst steht auf Edition 2024 — rustkeep also auch, passt. crossterm 0.29.0 verlangt nur 1.63.0 (crates.io rust_version).

Hinweis für später: Auf crossterms master liegt ein unveröffentlichter Unreleased-Abschnitt mit Breaking Changes — MSRV 1.63 → 1.85, Entfernung des IsTty-Traits zugunsten von std::io::IsTerminal, Umstellung auf Edition 2024 (https://raw.githubusercontent.com/crossterm-rs/crossterm/master/CHANGELOG.md). Ein 0.30 ist noch nicht veröffentlicht. Nebenbei: crossterm 0.28.0 ist yanked, nutzbar ist 0.28.1.

1.1 ratatui 0.30 ist aufgeteilt

Seit 0.30.0 ist ratatui ein Facade-Crate über mehrere Teilcrates (Quellen: https://docs.rs/crate/ratatui/latest/features, https://docs.rs/crate/ratatui/latest):

Crate Version Rolle
ratatui 0.30.2 Umbrella-Crate — das nimmt man in Anwendungen
ratatui-core 0.1.2 Buffer, Rect, Style, Widget-Traits (für Widget-Autoren)
ratatui-widgets 0.3.2 die mitgelieferten Widgets
ratatui-crossterm 0.1.2 Crossterm-Backend (Default)
ratatui-termion 0.1.2 Termion-Backend
ratatui-termwiz 0.1.2 Termwiz-Backend
ratatui-termina 0.1.0 Termina-Backend, neu in 0.30.2
ratatui-macros 0.7.2 Makros

Default-Features: crossterm, all-widgets, layout-cache, macros, underline-color, std, widget-calendar (ebd.).

Windows-relevant: ratatuis eigene Cargo.toml schließt das Termion-Backend aus den default-members aus, wörtlich kommentiert:

# this is not included as it doesn't compile on windows
# "ratatui-termion",

Quelle: https://github.com/ratatui/ratatui/blob/main/Cargo.toml

Die Backend-Wahl ist damit für rustkeep entschieden, bevor sie gestellt wird — und deckt sich mit map.md. ratatui selbst rät: "Choose Crossterm for most tasks", mit einem ausdrücklichen Entscheidungszweig "Is Windows compatibility important?" (https://ratatui.rs/concepts/backends/comparison/).

Für rustkeep heißt das: ratatui = "0.30" mit Defaults genügt. Man muss crossterm nicht separat als Dependency führen, solange man nur die von ratatui re-exportierten Teile nutzt — und sollte es auch nicht, siehe 1.2.

1.2 Fallstrick: zwei crossterm-Versionen im Graph

ratatui bindet crossterm = "0.29" in [workspace.dependencies] (https://github.com/ratatui/ratatui/blob/main/Cargo.toml). ratatui-crossterm 0.1.2 unterstützt zwei crossterm-Versionen über sich gegenseitig ausschließende Features, durchgereicht bis zum Umbrella-Crate: crossterm_0_29 ist Default, crossterm_0_28 ist opt-in (https://docs.rs/crate/ratatui-crossterm/latest/features, https://docs.rs/crate/ratatui/latest/features).

Zieht man crossterm zusätzlich direkt in Cargo.toml und trifft eine andere Major-Version als die von ratatui kompilierte, gibt es Typ-Mismatch-Fehler, die schwer zu lesen sind. Die ratatui-FAQ dokumentiert das explizit:

"You can resolve these conflicts by selecting the Crossterm major version used by Ratatui via feature flags."

Quelle: https://ratatui.rs/faq/ (Eintrag "How do I fix errors when multiple Crossterm versions are used?")

Empfehlung: crossterm nur über den ratatui-Re-Export benutzen, keine eigene crossterm-Zeile in Cargo.toml. Falls doch nötig: exakt die Version pinnen, die ratatui zieht.


2. Welches App-Muster — was ratatui sagt

2.1 ratatui dokumentiert drei Muster und empfiehlt keines

Die Übersichtsseite https://ratatui.rs/concepts/application-patterns/ listet:

The Elm Architecture (TEA) — https://ratatui.rs/concepts/application-patterns/the-elm-architecture/ Model (aller State), Message (Enum aller Eingaben/Aktionen), Update (Model + Message -> Model), View (Model -> UI). Die update-Funktion darf Option<Message> zurückgeben, wodurch sich Updates verketten und Zustandsautomaten bauen lassen. Die Seite nennt zwei Reibungspunkte selbst: Reine Immutabilität ist in Rust unpraktisch ("Rust developers can choose the most suitable approach based on performance and their application's needs"), und StatefulWidgets brauchen &mut, was die View-Reinheit bricht. Dazu die Immediate-Mode-Einschränkung: "the view function is only aware of the area available to draw in at render time" — wer die Größe zwischenspeichert, handelt sich einen Frame Verzögerung und Flackern ein.

Component Architecture — https://ratatui.rs/concepts/application-patterns/component-architecture/ Objektorientierter: ein Component-Trait mit init, handle_events / handle_key_events / handle_mouse_events, update, render(&mut self, f: &mut Frame, rect: Rect). State ist komponentenprivat statt global. Genannter Vorteil, wörtlich:

"One advantage of this approach is that it incentivizes co-locating the handle_events, update and render functions on a component level."

Nachteile nennt die Seite keine. Verwiesen wird auf TaKO8Ki/gobang, nomadiz/edma und das offizielle component-Template.

Flux Architecture — https://ratatui.rs/concepts/application-patterns/flux-architecture/ Dispatcher / Stores / Actions / Views, unidirektionaler Datenfluss. Begründung auf der Seite: "Terminal applications often have to deal with complex user interactions, multiple views, and dynamic data sources." Auch hier: keine Nachteile genannt.

Es gibt keine Präferenz. Die Übersichtsseite ist wörtlich neutral:

"This page covers several patterns one can use for their application and acts as a top-level page for the following articles where these patterns are explored more in-depth."

Bestätigt durch:

2.2 Zu Multi-Screen gibt es keine offizielle Doku

Verifiziert negativ: Auf ratatui.rs existiert keine Seite über Screen-Wechsel, Routing oder Multi-Screen-Struktur. Die nächstgelegenen offiziellen Artefakte sind die drei Musterseiten und die Templates.

Was es gibt, ist eine Aussage des ratatui-Maintainers joshka im offiziellen Forum (https://forum.ratatui.rs/t/multiple-screens-in-ratatui/82, Beitrag vom 27. Juli):

"I'd still implement this as an enum with all the screens included and have logic to choose which screen is rendered and when to change between them."

Das ist Maintainer-Meinung im Forum, keine dokumentierte Empfehlung — entsprechend gewichten. Im selben Thread rät er außerdem zu "rendering on a single thread", weil "stdio is a global lock".

2.3 Modulstruktur der offiziellen Templates

Repo: https://github.com/ratatui/templates, Branch main. Sechs Templates: hello-world, simple, simple-async, event-driven, event-driven-async, component.

src/
├── main.rs        Einstiegspunkt
├── app.rs         App-State + Loop, hält Vec<Box<dyn Component>>
├── action.rs      Action-Enum
├── components.rs  das Component-Trait
├── components/
│   ├── home.rs
│   └── fps.rs
├── tui.rs         Terminal-Setup/-Teardown + Event-Stream
├── cli.rs         clap
├── config.rs
├── errors.rs      Panic-/Error-Hooks
└── logging.rs     tracing

Regel des Templates, wörtlich:

"Once you have set up the project, you shouldn't need to change the contents of anything outside the components folder."

Es zieht tokio, tracing, clap, color-eyre, better-panic und human-panic mit. Das ist für rustkeep deutlich zu viel Apparat — und es hat von Haus aus kein Popup-/Overlay-Konzept.

Randnotiz: Die ältere Tutorial-Aufteilung app.rs / event.rs / tui.rs / ui.rs / update.rs liegt unter https://ratatui.rs/tutorials/counter-app/_multiple-files/. Der führende Unterstrich schließt die Seite aus der Navigation aus — als Alt-Bestand behandeln, nicht als aktuelle Empfehlung.


3. Was echte Projekte tun

Untersucht wurden neun Projekte anhand ihres tatsächlichen Quellcodes. Das Bild ist eindeutig: niemand implementiert reines Elm, und praktisch alle nutzen ein Enum oder einen Stack für Screens.

3.1 chess-tui — das nächste Vorbild für rustkeep

https://github.com/thomas-mauran/chess-tui — rundenbasiertes Spiel, Menü + Spielfeld + mehrere Popups, in ratatuis offizieller Showcase-Liste (https://ratatui.rs/showcase/apps/).

src/state/ui_state.rs (https://raw.githubusercontent.com/thomas-mauran/chess-tui/main/src/state/ui_state.rs):

pub struct UIState {
    pub current_page: Pages,
    pub current_popup: Option<Popups>,
    pub popup_message: Option<String>,
    pub end_screen_dismissed: bool,
    pub menu_cursor: u8,
}

src/constants.rs (https://raw.githubusercontent.com/thomas-mauran/chess-tui/main/src/constants.rs):

pub enum Pages { Home, Solo, Multiplayer, Lichess, LichessMenu,
                 OngoingGames, Bot, Credit, GameModeMenu, PgnViewer }

pub enum Popups { EnterHostIP, WaitingForOpponentToJoin, EnginePathError, Help,
                  EndScreen, PuzzleEndScreen, Error, Success, SeekingLichessGame,
                  EnterGameCode, EnterLichessToken, ResignConfirmation,
                  MoveInputSelection, LoadPgnPath, Loading }

Dispatch in src/ui/main_ui.rs (https://raw.githubusercontent.com/thomas-mauran/chess-tui/main/src/ui/main_ui.rs):

match app.ui_state.current_page {
    Pages::Solo => render_game_ui(frame, app, main_area),
    Pages::Bot  => render_bot_page(frame, app, main_area),
    // ...
    Pages::Home | Pages::Credit => render_menu_ui(frame, app, main_area),
}
// danach ein zweites match auf app.ui_state.current_popup

Genau das Muster, das rustkeep braucht — nur dass Option<Popups> bei uns ein Stack werden sollte (siehe 4).

3.2 sxtetris — der sauberste Screen-Stack

https://github.com/shixinhuang99/sxtetris, src/state/focus.rs (vollständig, ~50 Zeilen):

#[derive(PartialEq, Eq, Clone, Copy)]
pub enum Scene { StartMenu, Game, PauseMenu, GameOverMenu,
                 SettingMenu, Scores, Help, About }

pub struct Focus { history: Vec<Scene> }

impl Focus {
    pub fn new() -> Self { Self { history: vec![Scene::StartMenu] } }
    pub fn to(&mut self, focus: Scene)   { self.history.clear(); self.history.push(focus); }
    pub fn push(&mut self, focus: Scene) { self.history.push(focus); }
    pub fn back(&mut self)               { self.history.pop(); }
    pub fn current(&self) -> &Scene      { self.history.last().expect("should be a least one") }
    pub fn contains(&self, scene: Scene) -> bool { self.history.contains(&scene) }
}

Quelle: https://raw.githubusercontent.com/shixinhuang99/sxtetris/main/src/state/focus.rs

Der Clou ist contains(): das Rendering fragt nicht nur nach dem obersten Scene, sondern zeichnet jede Szene, die im Stack liegt. Dadurch bleibt das Spielfeld unter dem Pausenmenü sichtbar, ohne Sonderfall. push(Scene::PauseMenu) lässt den Spielzustand unangetastet.

Für rustkeep: Inventar über dem Dungeon-Grid, Bestätigungsdialog über dem Kampf — genau diese Semantik.

3.3 gitui — Component-Trait und Event-Verbrauch

https://github.com/gitui-org/gitui, src/components/mod.rs:

pub trait DrawableComponent { fn draw(&self, f: &mut Frame, rect: Rect) -> Result<()>; }

pub trait Component {
    fn commands(&self, out: &mut Vec<CommandInfo>, force_all: bool) -> CommandBlocking;
    fn event(&mut self, ev: &Event) -> Result<EventState>;
    fn focused(&self) -> bool { false }
    fn is_visible(&self) -> bool { true }
    fn hide(&mut self) {}
    fn show(&mut self) -> Result<()> { Ok(()) }
    // ...
}

pub enum EventState { Consumed, NotConsumed }

Quelle: https://raw.githubusercontent.com/gitui-org/gitui/master/src/components/mod.rs

src/app.rs:

if event_pump(&ev, self.components_mut().as_mut_slice())?.is_consumed() {
    flags.insert(NeedsUpdate::COMMANDS);
} else if let Event::Key(k) = &ev { /* globale Shortcuts */ }

Das ist die eine Idee aus gitui, die sich lohnt: Consumed/NotConsumed als Rückgabewert der Eingabebehandlung. Oberster Screen bekommt das Event zuerst; was er nicht verbraucht, fällt auf globale Tasten durch.

Wichtige Korrektur eines verbreiteten Missverständnisses: gitui hat keinen Vec<Box<dyn Component>>-Popup-Stack. Die Popups sind ~25 benannte konkrete Felder auf App (help_popup, msg_popup, confirm_popup, …), generiert über accessors!/setup_popups!-Makros; is_visible() entscheidet, ob gezeichnet wird. src/popup_stack.rs ist 298 Byte groß und speichert nur, welches Popup wieder zu öffnen ist (Breadcrumb für „Zurück"), nicht Render-Objekte:

pub struct PopupStack { stack: Vec<StackablePopupOpen> }

Quelle: https://raw.githubusercontent.com/gitui-org/gitui/master/src/popup_stack.rs

3.4 ratatuis eigenes Flaggschiff-Beispiel: demo2

https://ratatui.rs/examples/apps/demo2/ — Tab-Enum, ein Modul pro Tab, impl Widget for &App:

enum Tab { About, Recipe, Email, Traceroute, Weather }

fn render_selected_tab(&self, area: Rect, buf: &mut Buffer) {
    match self.tab {
        Tab::About  => self.about_tab.render(area, buf),
        Tab::Recipe => self.recipe_tab.render(area, buf),
        // ...
    };
}

Module: app.rs, tabs.rs + tabs/{about,email,recipe,traceroute,weather}.rs, theme.rs, colors.rs, destroy.rs.

Das ist offizieller ratatui-Code und deckt sich mit joshkas Forum-Aussage.

3.5 Weitere, kurz

Projekt Muster Bemerkung
Rebels in the Sky (https://github.com/ricott1/rebels-in-the-sky) UiState-Enum + UiTab-Enum + Screen-Trait pro Panel + Vec<PopupMessage> + UiCallback-Enum (faktisch Elm-Msg) Größtes Spiel-Vorbild. Explizite Layer-Nummer (set_active_layer(1) bei Popup). Nur popup_messages[0] wird gezeichnet und bekommt alle Eingaben.
taskwarrior-tui (https://github.com/kdheepak/taskwarrior-tui) verschachteltes Enum: Mode::Tasks(Action::HelpPopup) Warnendes Beispiel: app.rs ist 234 KB. Popup im State-Enum zu kodieren skaliert schlecht.
yazi (https://github.com/sxyazi/yazi) Maler-Algorithmus ohne Clear; jedes Overlay ein Modul mit active-Flag, feste Renderreihenfolge in root.rs Overlays malen ihren eigenen opaken Hintergrund.
television (https://github.com/alexpasmantier/television) Action-Enum + zentraler State + draw.rs mit fester Reihenfolge Nur das echte Popup nutzt Clear; Hilfe/Picker übernehmen bewusst ein bestehendes Panel.
atuin (https://github.com/atuinsh/atuin) ein großer App-Struct, popup_mode: bool → Clear über den ganzen Frame interactive.rs ist 105 KB.
bottom (https://github.com/ClementTsang/bottom) handgebauter Widget-Layout-Baum, Message-Loop Kein Screen-Stack (Single-Dashboard).

Verifiziert negativ: Ich habe kein prominentes ratatui-basiertes Roguelike gefunden. Die bekannten Rust-Roguelikes (rusty-rogue u. a.) nutzen bracket-lib/rltk, nicht ratatui. In ratatuis Showcase stehen als Spiele nur chess-tui, minesweep-rs und twozero48 (https://ratatui.rs/showcase/apps/). rustkeep betritt hier also kein ausgetretenes Terrain, aber chess-tui und Rebels in the Sky sind nahe genug.


4. Empfehlung für rustkeep

4.1 Muster: Screen-Stack über zentralem Game-State

Nicht Elm, nicht Component-Trait, nicht Flux. Begründung:

  • Gegen Elm/TEA: Der Charter legt bereits einen Game-Ownership-Baum mit ID-Verweisen fest (map.md). Ein zusätzliches Message/update-Sandwich über jede Zustandsänderung zu legen, verdoppelt die Schreibarbeit für jede Aktion, ohne dass ein Nutzen dagegensteht — es gibt kein Undo, kein Time-Travel-Debugging, keine Netzwerkreplikation. Und ratatui selbst räumt ein, dass die View-Reinheit in Rust an StatefulWidget scheitert (https://ratatui.rs/concepts/application-patterns/the-elm-architecture/).
  • Gegen Component-Trait: Der Vorteil des Traits ist Polymorphie über eine offene Menge von Komponenten. rustkeep hat eine geschlossene, bekannte Menge von sieben Bildschirmen. Ein Enum bildet das exakt ab, der Compiler prüft Vollständigkeit im match, und es fällt kein Box<dyn>/Downcasting an. Das offizielle component-Template zieht außerdem tokio, clap, tracing, color-eyre mit — Apparat, den rustkeep nicht braucht.
  • Gegen Flux: Dispatcher und Stores sind eine Antwort auf verteilten State in großen Client-Apps. rustkeep hat einen Ownership-Baum und einen einzigen Thread.
  • Für den Stack statt eines einzelnen current_screen: Inventar über Kampf, Bestätigung über Inventar, „Zurück" muss überall funktionieren. Ein Vec<Screen> löst das in ~30 Zeilen (sxtetris), ein einzelnes Feld erzwingt ein previous_screen-Flickwerk.

Nicht aus dem Blick verlieren: Die Verwandtschaft zu Elm bleibt bestehen — der Rückgabewert der Eingabebehandlung ist eine Action, die der Kern verarbeitet. Das ist die eine Elm-Idee, die trägt, ohne dass man das ganze Muster übernimmt.

4.2 Skizze der Modulstruktur

src/
├── main.rs              Terminal init/restore, ruft ui::run(&mut game)
│
├── game/                ← kennt ratatui NICHT. Testbar ohne Terminal.
│   ├── mod.rs           struct Game — der Ownership-Baum aus map.md
│   ├── stats.rs         (bestehend)
│   ├── player.rs        (bestehend, ohne print_box)
│   ├── dungeon.rs       Grid, Etagen, Räume
│   ├── combat.rs        Kampfzustand als Daten
│   ├── inventory.rs
│   └── action.rs        enum Action — was der Spieler tun kann.
│                        Genau EIN Eintrittspunkt: Game::apply(Action) -> Vec<GameEvent>
│
├── data/                ← TOML-Laden, Mod-Registry (Tickets 02/03)
│   ├── mod.rs
│   ├── items.rs         (bestehend, umgezogen)
│   └── registry.rs
│
└── ui/                  ← kennt Game nur lesend, ändert ihn nie direkt
    ├── mod.rs           run(): Event-Loop. terminal.draw(..) + event::read()
    ├── screen.rs        enum Screen { MainMenu, Dungeon, Combat, Inventory,
    │                                  Shop, Questlog, CharSheet }
    │                    struct ScreenStack { stack: Vec<Screen> }
    │                        push/pop/replace/current/contains
    ├── input.rs         Screen + KeyEvent -> Option<UiCommand>
    │                    enum UiCommand { Game(Action), Push(Screen), Pop, Quit }
    │                    enum Consumed { Yes, No }   ← gitui-Idee
    ├── theme.rs         Farben und Symbole an EINER Stelle
    ├── widgets/
    │   ├── mod.rs
    │   ├── grid.rs      ← Eigenbau: impl Widget für die Dungeon-Karte
    │   ├── statusbar.rs HP/Stamina via Gauge
    │   └── popup.rs     Popup { width, height, title } -> render() gibt inneres Rect zurück
    └── screens/
        ├── mod.rs
        ├── main_menu.rs
        ├── dungeon.rs   fn render(game: &Game, area: Rect, buf: &mut Buffer)
        ├── combat.rs
        ├── inventory.rs
        ├── shop.rs
        ├── questlog.rs
        └── char_sheet.rs

Die harte Regel dahinter: game/ darf ratatui nicht importieren. Das ist mechanisch prüfbar (grep -r ratatui src/game/ muss leer sein) und garantiert Testbarkeit ohne Terminal.

Skizze des Loops:

// ui/mod.rs
pub fn run(game: &mut Game) -> io::Result<()> {
    ratatui::run(|terminal| {
        let mut ui = UiState::new();               // ScreenStack + Listen-States
        while !ui.quit {
            terminal.draw(|f| render(f, game, &mut ui))?;
            if let Some(key) = event::read()?.as_key_press_event() {
                match input::map(&ui, key) {        // Screen entscheidet
                    Some(UiCommand::Game(a)) => { let ev = game.apply(a); ui.react(ev); }
                    Some(UiCommand::Push(s)) => ui.stack.push(s),
                    Some(UiCommand::Pop)     => ui.stack.pop(),
                    Some(UiCommand::Quit)    => ui.quit = true,
                    None => {}
                }
            }
        }
        Ok(())
    })
}

ratatui::run gibt es seit 0.30 (https://docs.rs/ratatui/latest/ratatui/fn.run.html): "creates a new DefaultTerminal with init and then runs the given closure … After the closure completes, the terminal is restored to its original state with restore."

4.3 Was ich bewusst NICHT empfehle

  • Kein Box<dyn Screen>. Keines der untersuchten Projekte macht das für Screens oder Popups, und die geschlossene Menge macht es überflüssig.
  • Kein tokio. Siehe 6.
  • Kein Mode::Combat(Popup::Confirm) (taskwarrior-tui-Muster). Es hat dort zu einer 234-KB-app.rs geführt. Popup-Stack getrennt vom Screen-Stack halten — oder beides im selben Vec<Screen>, wenn Popups auch nur Screens sind. Letzteres ist der sxtetris-Weg und der einfachere.

5. Popups und Overlay-Stacking

5.1 Das Verfahren

ratatui hat kein Popup-Widget und keinen Layer-Mechanismus. Es gibt nur einen Buffer, in den nacheinander geschrieben wird — später gezeichnet gewinnt. Das offizielle Rezept:

"Widgets do not always clear every cell in the area that they are rendering to. This may cause symbols and styles that were previously rendered to the buffer to 'bleed' through into the cells that are rendered on top of those cells."

"Use the Clear widget to clear areas of the screen to avoid style and symbols from leaking from previously rendered widgets."

Quelle: https://ratatui.rs/recipes/render/overwrite-regions/

Offizielles Beispiel (https://github.com/ratatui/ratatui/blob/main/examples/apps/popup/src/main.rs):

let centered_area = area.centered(Constraint::Percentage(60), Constraint::Percentage(20));
frame.render_widget(Clear, centered_area);
frame.render_widget(popup_block, centered_area);

Rect::centered(h, v) ist neu in 0.30, dazu centered_horizontally, centered_vertically, outer, resize, clamp, union, intersection (https://docs.rs/ratatui/latest/ratatui/layout/struct.Rect.html). Vorher musste man sich das aus verschachtelten Layouts bauen.

5.2 Die fünf in freier Wildbahn belegten Strategien

Strategie Wer Beleg
A. Clear auf zentriertem Sub-Rect, nach dem Screen gezeichnet — der Normalfall ratatui-Beispiel, gitui, taskwarrior-tui, television, sxtetris, chess-tui s. o.
B. Maler-Algorithmus ohne Clear; Overlays malen eigenen opaken Hintergrund yazi root.rs, sxtetris (Spielfeld bleibt unter dem Pausenmenü sichtbar) https://raw.githubusercontent.com/sxyazi/yazi/main/yazi-fm/src/root.rs
C. Vec<Popup>-Warteschlange, nur Index 0 gezeichnet, verbraucht alle Eingaben Rebels in the Sky (popup_messages, Kappe 8, Dedup) src/ui/ui_screen.rs
D. Popup im State-Enum kodiert taskwarrior-tui (Mode::Tasks(Action::HelpPopup)), sxtetris (Scene::PauseMenu auf dem Stack) s. 3.2/3.5
E. Separater „welches Popup wieder öffnen"-Breadcrumb, getrennt vom Rendering gitui PopupStack https://raw.githubusercontent.com/gitui-org/gitui/master/src/popup_stack.rs
F. Clear über den ganzen Frame (Vollbild-Modal) atuin (if popup_mode { f.render_widget(Clear, f.area()); }) .../search/interactive.rs

Wichtiger Negativbefund: Ich habe kein produktives Projekt gefunden, das Vec<Box<dyn Component>> als Popup-Stack benutzt. Das ist eine Forum-Idee, keine gelebte Praxis.

5.3 Empfehlung für rustkeep

Ein wiederverwendbarer Helfer nach sxtetris-Vorbild (src/ui/utils.rs dort): Popup { width, height, title, padding }, dessen render() das zentrierte Rect berechnet, Clear darauf rendert, den Rahmen zeichnet und das innere Rect zurückgibt, das der Aufrufer füllt. Das ist die Popup-Abstraktion in ~20 Zeilen, ohne Trait.

Für „Inventar über Kampf" ist Strategie B/D die richtige: das Inventar liegt als weiterer Screen auf dem Stack, der Kampfbildschirm wird trotzdem gerendert (stack.contains(Screen::Combat)), und das Inventar bekommt ein Clear-Fenster darauf. Der Kampfzustand bleibt unangetastet.

Optional: widgets::Shadow und widgets::Dimmed sind neu in 0.30 und geben Popups Tiefe bzw. dimmen den Hintergrund (https://docs.rs/ratatui/latest/ratatui/widgets/index.html).


6. Event-Loop

6.1 Braucht ein rundenbasiertes Spiel einen Tick? Nein.

ratatui liefert selbst keine Eingabebehandlung:

"Ratatui does not include any input handling. Instead event handling can be implemented by calling backend library methods directly."

Quelle: https://docs.rs/ratatui/latest/ratatui/

Der dokumentierte Standardfall ist blockierendes event::read(). Das Counter-Tutorial (https://ratatui.rs/tutorials/counter-app/basic-app/):

pub fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
    while !self.exit {
        terminal.draw(|frame| self.draw(frame))?;
        self.handle_events()?;
    }
    Ok(())
}

mit dem Kommentar "The event::read function blocks until there is an event."

Das simple-Template sagt dazu, wann man stattdessen pollen sollte:

"If your application needs to perform work in between handling events, you can use the event::poll function to check if there are any events available with a timeout."

Quelle: https://raw.githubusercontent.com/ratatui/templates/main/simple/template/src/main.rs

Für rustkeep heißt das: Solange nichts von allein passiert (keine Animation, keine Echtzeit-Gegner, kein Netzwerk), ist der Tick überflüssig. Redraw nur nach Eingabe. CPU-Last im Leerlauf: null.

Das Konzept dahinter, wörtlich:

"In Immediate mode rendering, the onus of triggering rendering lies on the programmer. Every visual update necessitates a call to Backend.draw()."

Quelle: https://ratatui.rs/concepts/rendering/

Einschränkung, explizit: ratatui dokumentiert nirgends direkt den Vergleich read() vs. poll() vs. EventStream. <https://ratatui.rs/concepts/event-handling/> behandelt nur die Architektur (zentrales match / zentral mit Message-Passing / verteilte Sub-Loops), nicht die Loop-Mechanik. Die Aussage „blocking read ist der dokumentierte Default" leite ich aus Crate-Doku, Tutorial und Template ab, nicht aus einem Satz, der das so sagt.

6.2 Was echte Projekte tun (Übersicht)

Projekt Mechanik
ratatui simple-Template blockierendes event::read()
gitui eigener OS-Thread, poll(dur) + read(), crossbeam-Channel; adaptives Polling: 100 ms direkt nach Eingabe, 10 s im Leerlauf
bottom Input-Thread mit poll(20ms), Main-Loop blockiert auf receiver.recv()
atuin tokio::select! über spawn_blocking(poll(250ms)), dann Drain-Loop while poll(ZERO)
Rebels in the Sky tokio, aber intern poll(no_wait) + read() + sleep, kein EventStream
taskwarrior-tui, sxtetris crossterm::event::EventStream + tokio::select! mit Tick-Task

Das offizielle Rezept https://ratatui.rs/recipes/apps/terminal-and-event-handler/ geht voll auf async: EventStream + tokio::select! + getrennte tick_rate(4.0) / frame_rate(30.0) + CancellationToken. Für rustkeep ist das Overkill und würde tokio als Abhängigkeit einschleppen.

Falls später doch ein Tick nötig wird (Animationen, Autosave, Statuseffekt-Timer): gituis adaptives Polling ist der billigste Weg — ein Thread, poll mit kurzem Intervall nach Aktivität, langem im Leerlauf, kein async.

6.3 Eine Sache, die man früh richtig machen sollte

joshka im Forum: "rendering on a single thread", weil "stdio is a global lock" (https://forum.ratatui.rs/t/multiple-screens-in-ratatui/82). Also: nie aus zwei Stellen zeichnen. Für rustkeep automatisch erfüllt, solange es einen Thread gibt.


7. Trennung von Spiellogik und Rendering, Testbarkeit

7.1 Was ratatui dazu sagt

Kein eigenes Doktrin-Kapitel, aber verstreut:

Der Immediate-Mode selbst hilft: Da jeder Frame neu aus dem State gezeichnet wird, gibt es keinen UI-State, der aus dem Tritt geraten kann. Ausnahme sind die drei StatefulWidgets (ListState, TableState, ScrollbarState) — die gehören in ui/, nicht in game/.

7.2 Testen ohne Terminal

Zwei Ebenen, und die wichtigere ist die erste:

(a) Spiellogik ohne ratatui. Wenn game/ kein ratatui importiert, sind Kampfformeln, Bewegungslogik, Inventar und Questbedingungen ganz normale Unit-Tests. Das ist der eigentliche Testbarkeitsgewinn und kostet nur Disziplin. src/stats.rs macht das heute schon richtig.

(b) Rendering mit TestBackend.

let mut terminal = Terminal::new(TestBackend::new(80, 24))?;
terminal.draw(|f| render(f, &game, &mut ui))?;
terminal.backend().assert_buffer_lines([...]);

API auf TestBackend (https://docs.rs/ratatui/latest/ratatui/backend/struct.TestBackend.html): buffer(), assert_buffer(), assert_buffer_lines(), assert_scrollback(), assert_scrollback_lines(), assert_scrollback_empty(), assert_cursor_position().

Die Doku selbst rät allerdings, das nicht zu übertreiben:

"it is preferable to write unit tests for widgets directly against the buffer rather than using this backend."

Also: für ein Eigenbau-Widget wie grid.rs direkt Widget::render(area, &mut buf) aufrufen und den Buffer prüfen — dafür braucht man gar kein Terminal.

Snapshot-Tests mit insta sind das einzige offizielle Test-Rezept (https://ratatui.rs/recipes/testing/snapshots/): feste Terminalgröße (80x20) für Reproduzierbarkeit, cargo insta review zum Abnehmen. Einschränkung auf der Seite: "Asserting with color is not supported as of now" (ratatui-Issue #1402).

Korrektur zu einer verbreiteten Annahme: Terminal::with_backend existiert in 0.30.2 nicht. Die Konstruktoren heißen Terminal::new und Terminal::with_options (https://docs.rs/ratatui/0.30.2/ratatui/struct.Terminal.html).


8. Mitgelieferte Widgets vs. Eigenbau

8.1 Was ratatui 0.30.2 mitbringt

Quelle: https://docs.rs/ratatui/latest/ratatui/widgets/index.html

Structs (31): Axis, Bar, BarChart, BarGroup, Block, Borders, Cell, Chart, Clear, Dataset, Dimmed, Fill, Gauge, LineGauge, List, ListItem, ListState, Padding, Paragraph, RatatuiLogo, RatatuiMascot, Row, Scrollbar, ScrollbarState, Shadow, Sparkline, SparklineBar, Table, TableState, Tabs, Wrap.

Enums (11): BorderType, GraphType, HighlightSpacing, LegendPosition, ListDirection, MascotEyeColor, RatatuiLogoSize, RenderDirection, ScrollDirection, ScrollbarOrientation, TitlePosition.

Traits: Widget, StatefulWidget, BlockExt, CellEffect; hinter dem Feature unstable-widget-ref zusätzlich WidgetRef, StatefulWidgetRef, FrameExt.

Stateful sind genau drei: List/ListState, Table/TableState, Scrollbar/ScrollbarState. Rendern über frame.render_stateful_widget(w, area, &mut state).

Feature-gated: widgets::calendar (Monthly) braucht das Feature widget-calendar.

Neu seit 0.29 und für rustkeep interessant: Fill (Fläche mit wiederholtem Symbol füllen), Shadow + Dimmed + Trait CellEffect (Schatten unter Popups).

Es gibt kein Popup-Widget. Popup = Clear + Block, siehe 5.

8.2 Was rustkeep direkt nutzen kann

Bildschirm Mitgeliefert
Hauptmenü List + ListState, oder Paragraph mit tui-big-text für den Titel
Inventar Table + TableState, Scrollbar
Shop Table (zwei nebeneinander: Ware / eigenes Inventar)
Questlog List + Paragraph für Details
Charakterbogen Paragraph / Table, Gauge oder LineGauge für HP/Stamina/EXP
Kampf Gauge für HP-Leisten, List für das Aktionsmenü, Paragraph für das Kampflog
Rahmen/Titel überall Block mit Borders
Popup Clear + Block (+ optional Shadow)

Damit ist alles außer dem Dungeon-Grid abgedeckt.

8.3 Dungeon-Grid: Eigenbau, und zwar so

Verifiziert negativ: Es gibt kein Grid-/Tilemap-Widget — weder in ratatui::widgets (vollständige Liste oben), noch in der offiziellen Drittanbieter-Liste (https://ratatui.rs/showcase/third-party-widgets/: ratatui-image, ratatui-textarea, throbber-widgets-tui, tui-big-text, tui-checkbox, tui-logger, tui-menu, tui-nodes, tui-piechart, tui-scrollview, tui-term, tui-tree-widget, tui-widget-list — kein Tilemap).

Drei Optionen, in dieser Reihenfolge zu bewerten:

  1. Custom impl Widget mit direktem Buffer-Zugriff — die Empfehlung.

    pub trait Widget { fn render(self, area: Rect, buf: &mut Buffer); }
    

    (https://ratatui.rs/recipes/widgets/custom/) Im render pro Kachel buf[(x, y)].set_char(sym).set_style(style). Eine Terminalzelle = eine Kachel. Keine Allokation pro Frame, volle Kontrolle über Vorder-/Hintergrundfarbe und Modifier (nützlich für Sichtfeld: gesehen aber nicht sichtbar = DIM). Ideal für ein @-und-#-Grid. Camera-/Viewport-Logik (welcher Ausschnitt bei großer Karte) gehört ebenfalls hierhin.

  2. Paragraph aus Lines von Spans. Eine Line pro Kartenzeile, Span pro Farbwechsel. Einfacher zu schreiben, alloziert aber pro Frame. Gut als erster Wurf, später auf (1) umstellen.

  3. Canvas — nicht für Kacheln geeignet. Canvas ist eine Vektor-Zeichenfläche mit Float-Koordinatenraum (x_bounds/y_bounds), die auf Sub-Zell-Marker rastert. Mitgelieferte Shapes: Circle, Line, FilledLine, Rectangle, Map, Points, Label (https://docs.rs/ratatui/latest/ratatui/widgets/canvas/index.html). Man kann kein @ auf eine Sub-Zell-Position setzen; Label ist der einzige Text-Ausweg. Sinnvoll für eine Minimap, Sichtfeldkegel oder Sichtlinien-Strahlen — nicht für die Hauptkarte.

    Die verfügbaren symbols::Marker (https://docs.rs/ratatui/latest/ratatui/symbols/enum.Marker.html), falls doch Canvas:

    Variante Auflösung pro Zelle Anmerkung
    Dot (•), Block (█), Bar (▄), Custom(char) 1×1
    HalfBlock 2×2 portabelste Wahl
    Quadrant 2×2 wie HalfBlock, aber ohne sichtbare Bänder zwischen Zellen
    Braille 2×4 höchste klassische Dichte, sichtbare Lücken
    Sextant 2×3 braucht Font-Unterstützung
    Octant 2×4 2×4 ohne Bänder, neueste Variante, braucht Font-Unterstützung

8.4 Drittanbieter-Crates, Versionen geprüft (2026-08-11)

Crate Version Datum Zweck
ratatui-image 11.0.6 2026-06-25 Bilder via sixel/kitty/iTerm2/Halbblöcke
ratatui-textarea 0.9.2 2026-06-12 Texteingabe-Widget (Nachfolger von tui-textarea)
tui-popup 0.7.6 2026-06-14 Popup-Helfer, Repo ratatui/tui-widgets
tui-widgets 0.7.10 2026-06-18 Sammel-Crate: Big Text, Box Text, Cards, Popup, Prompts, Scrollview u. a.
tui-widget-list 0.15.3 2026-07-18 Liste aus beliebigen Widgets

Weitere Crates aus der Showcase (tui-scrollview, tui-tree-widget, tui-term, tui-logger, tui-big-text, throbber-widgets-tui, tui-menu, tui-nodes, tui-piechart, tui-checkbox) — Versionen nicht einzeln geprüft.

Meine Einschätzung: rustkeep braucht davon zunächst keines. tui-popup ersetzt ~20 Zeilen Eigenbau, ratatui-textarea wird erst relevant, wenn Freitext-Eingabe dazukommt (Charaktername). Jede Abhängigkeit hier ist eine, die bei ratatui-Major-Bumps nachziehen muss.


9. Terminal-Wiederherstellung bei Panic

9.1 ratatui macht das inzwischen selbst

Die API liegt im Modul ratatui::init und ist im Crate-Root re-exportiert (https://docs.rs/ratatui/latest/ratatui/init/index.html):

Funktion Alt-Screen Raw-Mode Fehler Zweck
run(f) ✓ ✓ räumt automatisch auf die empfohlene Standardform
init() ✓ ✓ paniced normale Vollbild-Apps
try_init() ✓ ✓ Result dito, mit Fehlerbehandlung
init_with_options() ✗ ✓ paniced eigener Viewport
try_init_with_options() ✗ ✓ Result dito
restore() — — schreibt nach stderr, ignoriert Teardown
try_restore() — — Result Teardown mit Fehlerbehandlung

DefaultTerminal = Terminal<CrosstermBackend<Stdout>> (https://docs.rs/ratatui/latest/ratatui/type.DefaultTerminal.html). pub fn run<F, R>(f: F) -> R where F: FnOnce(&mut DefaultTerminal) -> R (https://docs.rs/ratatui/latest/ratatui/fn.run.html).

Alle Init-Funktionen installieren einen Panic-Hook. Modul-Doku: "All initialization functions install a panic hook that automatically restores the terminal state before panicking." Die Implementierung (https://github.com/ratatui/ratatui/blob/main/ratatui/src/init.rs):

fn set_panic_hook() {
    let hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        restore();
        hook(info);
    }));
}

Für rustkeep genügt also ratatui::run(...). Kein eigener Hook nötig.

9.1a Drei Fallen dabei

(1) Reihenfolge. ratatui wickelt den aktuellen Hook ein. Wer color_eyre, better-panic oder human-panic nach init() installiert, wirft ratatuis Restore weg. Doku wörtlich:

"Ensure that this method is called after your app installs any other panic hooks to ensure the terminal is restored before the other hooks are called."

(https://docs.rs/ratatui/latest/ratatui/fn.init.html). Also: color_eyre::install()? zuerst, dann ratatui::run(...).

(2) Drop stellt das Terminal NICHT wieder her. Der gesamte Drop-Body von Terminal<B> zeigt nur den Cursor wieder an (https://github.com/ratatui/ratatui/blob/main/ratatui-core/src/terminal.rs):

fn drop(&mut self) {
    if self.hidden_cursor {
        if let Err(err) = self.show_cursor() { /* eprintln */ }
    }
}

Die Struct-Doku bestätigt: "Terminal itself focuses on rendering and does not implicitly change those modes." Ohne restore() (oder run()) bleibt das Terminal im Raw-Mode zurück.

(3) Terminal::new() direkt zu benutzen umgeht den Hook. Doku: "unlike ratatui::init, this does not install a panic hook … the terminal may be left in an unusable state."

9.2 Die Rezeptseiten sind veraltet — Warnung

Gleich drei Seiten stammen aus der Zeit vor ratatui::init und erwähnen den eingebauten Hook nicht:

Der Kern der ersten Seite:

pub fn init_panic_hook() {
    let original_hook = take_hook();
    set_hook(Box::new(move |panic_info| {
        let _ = restore_tui();
        original_hook(panic_info);
    }));
}

mit "you want to take the original panic hook and execute it after cleaning up the terminal" und "intentionally ignore errors here since we're already in a panic".

Das ist nicht falsch, nur überflüssig, wenn man ratatui::init()/run() benutzt. Wer eigene Hooks (color-eyre, better-panic, human-panic) installieren will, muss das vor ratatui::init() tun — siehe Zitat oben.

9.3 Bezug zur Fehlerstrategie aus map.md

map.md legt fest: „Result statt panic! — kaputte Mod-TOMLs sind der Normalfall". Das passt zusammen: Panics sollen echte Bugs sein, und für die reicht der eingebaute Hook. Fürs Laden gilt weiter Result. Konkret bedeutet das aber auch, dass src/items.rs heute mit unwrap_or_else(|| panic!(...)) gegen die eigene Regel verstößt (Ticket 04).


10. Windows-Fallstricke

rustkeep wird auf Windows 11 entwickelt. Das ist gut — die meisten dieser Fallen fallen dann sofort auf statt erst beim ersten Windows-Nutzer. Ein paar sind trotzdem still.

10.1 Doppelte Key-Events — der wichtigste Stolperstein

Auf Windows liefert crossterm sowohl Press- als auch Release-Events; auf Linux und macOS nur Press. Wer das nicht filtert, bewegt sich zwei Felder pro Tastendruck.

Ursache belegt: crossterm-PR https://github.com/crossterm-rs/crossterm/pull/745, ausgeliefert in crossterm 0.26.0 (CHANGELOG: "Add key release event for windows. (#745)").

ratatui-FAQ, Eintrag "Why am I getting duplicate key events on Windows?" (https://ratatui.rs/faq/):

"on Windows, when using Crossterm, this will send the same Event::Key(e) twice; one for … KeyEventKind::Press and one for … KeyEventKind::Release. On MacOS and Linux only KeyEventKind::Press … is generated."

und ratatui-Issue #347 (geschlossen, https://github.com/ratatui/ratatui/issues/347):

"To avoid registering an event twice, ensure that you check the kind field of the KeyEvent."

Weitere offene Issues dazu: crossterm #772 (reproduziert in CMD, PowerShell und Windows Terminal), #797, #778 (Vorschlag, das opt-in zu machen), #752 (spurioses Enter-Release beim Start); ratatui #1810, #1062.

Lösung, eine Zeile. Klassisch:

if key.kind == KeyEventKind::Press { /* ... */ }

Seit crossterm 0.29 eleganter über die Helfer auf Event (https://docs.rs/crossterm/latest/crossterm/event/enum.Event.html): is_key_press(), is_key_release(), is_key_repeat(), as_key_press_event(), as_key_event(), as_mouse_event(), as_paste_event(), as_resize_event().

if let Some(key) = event::read()?.as_key_press_event() { ... }

Das ist auch die Form, die ratatuis eigene 0.30-Doku im run()-Beispiel benutzt.

Achtung, feiner Punkt: Strikt auf Press zu filtern verwirft auch KeyEventKind::Repeat. Wer gehaltene Tasten mit Autorepeat will (Laufen im Dungeon-Grid durch Halten der Pfeiltaste!), braucht matches!(kind, KeyEventKind::Press | KeyEventKind::Repeat). Für rustkeep ist das eine echte Design-Frage, kein Detail: rundenbasiert heißt eigentlich „ein Zug pro Tastendruck", also wahrscheinlich nur Press.

gitui filtert Key-Release im Input-Thread aus demselben Grund heraus (src/input.rs).

10.2 ANSI vs. WinAPI — und die Asymmetrie zwischen Aus- und Eingabe

crossterm 0.29 (https://docs.rs/crossterm/latest/crossterm/):

"supports all UNIX and Windows terminals down to Windows 7 (not all terminals are tested see Tested Terminals for more info)"

"Linux and Windows 10 systems support ANSI escape codes."

Für ältere Systeme fällt crossterm auf WinAPI-Aufrufe zurück (ebd.). Die Weiche liegt in Command::is_ansi_code_supported() (https://github.com/crossterm-rs/crossterm/blob/master/src/command.rs), die Erkennung in https://github.com/crossterm-rs/crossterm/blob/master/src/ansi_support.rs (setzt ENABLE_VIRTUAL_TERMINAL_PROCESSING, cached das Ergebnis). 256-Farben und RGB gibt es erst ab Windows 10 (crossterm-README).

Die Asymmetrie, die man kennen muss: Die Ausgabe läuft über ANSI, die Eingabe aber immer über die Console-API. crossterm-PR https://github.com/crossterm-rs/crossterm/pull/1030 (offen):

"The console strips VT escape sequences (including bracketed paste markers) from these events."

Daraus folgt fast alles in 10.5.

10.3 enable_raw_mode verhält sich auf Windows anders

Die Doku (https://docs.rs/crossterm/latest/crossterm/terminal/fn.enable_raw_mode.html) schweigt zu Plattformunterschieden, der Quellcode nicht:

  • Unix: sichert termios in einem prozessglobalen Mutex<Option<Termios>> und stellt exakt diesen Zustand wieder her. Idempotent.
  • Windows: speichert keinen Zustand. enable_raw_mode löscht ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT am Console-Input-Handle; disable_raw_mode() ODERt diese drei blind wieder hinein. Das ist keine echte Wiederherstellung — das Handle ist über die ganze Console-Session geteilt und wird an Kindprozesse vererbt. is_raw_mode_enabled() fragt unter Windows den Live-Zustand der Console ab, ein Kindprozess kann das Ergebnis also verändern.
  • Windows-Raw-Mode setzt ENABLE_VIRTUAL_TERMINAL_INPUT nicht.

Praktisch für rustkeep: ratatui::run() benutzen und nicht darauf bauen, dass die Console nach dem Beenden exakt so ist wie vorher.

10.4 Zeichen, Schriftart und Doppelbreite

ratatui-FAQ, Eintrag "Some characters appear to be missing / look weird" (https://ratatui.rs/faq/): Empfohlen wird eine Nerd Font; "If your font doesn't support such features, it may display a white square (□) or a replacement character (<28>)." Betroffen sind Box-Drawing-Zeichen, Braille-Muster und Icons. Die Schriftgröße kann ratatui nicht steuern — das macht der Terminalemulator; die App soll stattdessen sauber auf Resize reagieren (ebd.).

Belegte Windows-Probleme:

  • Spaltenversatz bei doppelbreiten Glyphen, am schlimmsten in conhost. ratatui-Issue #2651 (offen), wörtlich: "After printing a double-width grapheme at x, the terminal cursor sits at x + 2, but the backend believes it is at x + 1" — mit ausdrücklicher Nennung von Windows conhost / Command Prompt. Symptom: "Panel borders shift outside their intended boundaries". Verwandt: #2652, #2526, #2431, #1271.
  • Emoji-Eingabe kaputt: crossterm-Issue #1072 (offen) — Surrogate-Paare aus dem Windows-Emoji-Panel werden zerstört, weil seit #745 auch Key-Up-Records weitergereicht werden. Auch #1008.
  • Codepage: crossterm-Issue #607 (offen) — ▄ wird im Windows-Terminal von VS Code verstümmelt; Abhilfe ist SetConsoleOutputCP(CP_UTF8). crossterm setzt die Codepage nicht für einen.
  • unicode-width-Unruhe: ratatui hat die Crate wiederholt gepinnt und wieder entpinnt, weil Minor-Bumps Zeichenbreiten ändern (#1403 pinnte auf 0.2.0, #2482 hob die Schranke auf). Aktuell: unicode-width = ">=0.2.0", ohne Feature-Flag.

Konsequenz für das Dungeon-Grid:

  • Bei ASCII bleiben, wo es geht (#, ., @, +, <, >), Farbe statt exotischer Glyphen.
  • Keine Emoji in der Karte, in Rahmen oder in Item-Namen. Eine Kachel muss genau eine Zelle breit sein, sonst verschiebt sich die ganze Zeile.
  • Quadrant/Sextant/Octant-Marker (8.3) brauchen Font-Unterstützung; HalfBlock ist die sichere Wahl.
  • Falls doch Unicode-Rahmen: Codepage auf UTF-8 setzen.

10.5 Was auf Windows schlicht nicht funktioniert

Quelle für die Tabelle: https://github.com/crossterm-rs/crossterm/blob/master/src/event.rs und src/terminal/sys/windows.rs.

Fähigkeit conhost (Legacy) Windows Terminal Mechanismus
Mouse Capture ✅ ✅ WinAPI, erzwungen (is_ansi_code_supported → false)
Bracketed Paste (Kommando) ❌ Unsupported ✅ liefert Ok ANSI, wenn VT verfügbar
Event::Paste feuert wirklich ❌ ❌ ReadConsoleInput entfernt die Marker
Kitty-Keyboard-Protokoll ❌ ❌ fest verdrahtet Err(Unsupported) / Ok(false)
window_size() in Pixeln ❌ ❌ "not implemented for the Windows API"
  • Kitty-Protokoll: supports_keyboard_enhancement() ist unter Windows dokumentiert als "This always returns Ok(false) on Windows.", und PushKeyboardEnhancementFlags liefert immer Err(Unsupported) — auch im Windows Terminal. Folgen in freier Wildbahn: crossterm-Issue #842, das mitgelieferte Beispiel bricht mit Exit-Code 1 ab. Immer mit terminal::supports_keyboard_enhancement().unwrap_or(false) absichern. Relevant für rustkeep, sobald man Tastenkombis wie Shift+Pfeil oder Key-Release unterscheiden will — geht auf Windows nicht.
  • Bracketed Paste: #737 (offen) — "just yields a series of Event::char"; #962 (offen) — Einfügen mit Zeilenumbrüchen hängt Enter ein falsches CONTROL-Modifier an.
  • Maus-Teardown-Reihenfolge (undokumentiert): enable_mouse_capture setzt den Console-Mode absolut und schaltet dabei nebenbei Line-Input/Echo aus. disable_mouse_capture stellt einen einmal pro Prozess genommenen Snapshot wieder her — der typischerweise Line-Input/Echo wieder einschaltet und damit enable_raw_mode() still rückgängig macht. Also: erst DisableMouseCapture, dann disable_raw_mode(). Verwandt: #825. Für rustkeep irrelevant, solange keine Maus benutzt wird — und das spricht dafür, es dabei zu belassen.

10.6 Windows 7 und underline-color

Der einzige Windows-Vorbehalt in ratatuis Feature-Doku (https://docs.rs/ratatui/latest/ratatui/#crate-features): das Default-Feature underline-color "is not supported on Windows 7". Ursprung: ratatui-Issue #555 — ein bedingungsloses SetUnderlineColor erzeugte "SetUnderlineColor not supported by winapi." und blockierte das gesamte Rendering. Wer Windows 7 unterstützen will, muss das Feature abschalten. Die Website-Seite https://ratatui.rs/installation/feature-flags/ erwähnt das nicht — docs.rs ist maßgeblich.

Für rustkeep: irrelevant, solange Windows 7 kein Ziel ist. Falls doch, ist es eine Zeile in Cargo.toml.

10.7 Sonstiges

  • Mehrere crossterm-Versionen im Graph — plattformunabhängig, aber siehe 1.2.
  • Buffer-Panics: Die FAQ hat einen Eintrag "How do I avoid panics due to out of range calls on the Buffer?" (https://ratatui.rs/faq/). Relevant für das Eigenbau-Grid-Widget: beim direkten Buffer-Schreiben immer gegen area clippen, sonst paniced ein zu kleines Terminal die App.
  • stdout vs. stderr: Eigener FAQ-Eintrag. Für rustkeep irrelevant (kein Pipe-Betrieb), aber gut zu wissen, falls Logging dazukommt.

10.8 Windows-Checkliste in Kurzform

  1. ratatui::run(...) benutzen; falls color_eyre, dann install()? davor.
  2. Auf jedem Key-Event KeyEventKind::Press filtern (ggf. | Repeat). Nicht verhandelbar.
  3. PushKeyboardEnhancementFlags nie ungeprüft aufrufen.
  4. Sich nicht auf Event::Paste verlassen.
  5. Teardown-Reihenfolge: DisableMouseCapture → disable_raw_mode(). Besser: gar keine Maus.
  6. Bei Unicode-Rahmen die Console-Codepage selbst auf UTF-8 setzen.
  7. Im Grid ASCII/BMP statt Emoji — conhost verschiebt sonst Spalten.
  8. Nicht auf Drop von Terminal bauen; das zeigt nur den Cursor wieder an.

11. Was ich NICHT verifizieren konnte

Explizit als ungeprüft markiert, statt geraten:

  1. Versionen von 10 der 15 Drittanbieter-Widget-Crates (8.4) — nur ratatui-image, ratatui-textarea, tui-popup, tui-widgets und tui-widget-list sind geprüft.
  2. Ob ratatui irgendwo doch read() vs. poll() vergleicht. Geprüft wurden Crate-Root, /concepts/rendering/, /concepts/event-handling/ und der /recipes/apps/-Index — kein vollständiger Site-Crawl.
  3. Ob television/yazi in nicht geöffneten Untermodulen doch Clear benutzen.
  4. Ein ratatui-basiertes Roguelike als direktes Vorbild existiert nach meiner Suche nicht; ich kann nicht ausschließen, dass es eines gibt, das schlecht auffindbar ist.
  5. Es gibt keine pauschale Regel „Windows Terminal zeichnet Box-Drawing korrekt, conhost nicht". Keine Primärquelle sagt das so. Die conhost-Korruption in 10.4 ist eine Folge der Breitenberechnung, kein eigener Defekt von conhost.
  6. crossterm hat keinen zusammenfassenden „Known Limitations"-Abschnitt. Die Unsupported-Fehler in 10.5 sind nur im Quellcode auffindbar, nicht in der Doku — was heißt, dass diese Liste unvollständig sein kann.
  7. Die crossterm-README widerspricht der Cargo.toml bei der Feature-Tabelle. Maßgeblich sind Cargo.toml und docs.rs.

12. Quellen

Offizielle ratatui-Dokumentation

API-Dokumentation

Versionen

Repos und Realcode

Windows-Issues (alle geprüft)


13. Offene Anschlussfragen für den Menschen

Nicht entschieden, bewusst offengelassen:

  1. Ein Vec<Screen> für Screens und Popups, oder zwei getrennte Stacks? sxtetris nimmt einen, chess-tui nimmt Pages + Option<Popups>. Einer ist einfacher, zwei sind klarer bei „Popup über Popup über Screen".
  2. Wo lebt der Listen-/Tabellen-State? ListState gehört nach ratatui-Logik in die UI. Wenn er pro Screen gehalten wird, muss er beim pop() verworfen oder aufbewahrt werden — das ist eine spürbare UX-Entscheidung (kehrt man ins Inventar zurück, steht der Cursor wieder oben?).
  3. Wird game/ wirklich ratatui-frei bleiben? Sobald Effekte Farben oder Symbole aus TOML mitbringen (Ticket 01/02), ist die Versuchung groß, Color in game/ zu ziehen. Alternative: eigene Farb-/Symbol-Typen in data/ und Übersetzung in ui/theme.rs.
  4. Wann fliegt print_box raus? map.md sagt „sobald ratatui steht". Das betrifft items.rs, player.rs, armor.rs, weapon.rs und deren print_values.
  5. Nur KeyEventKind::Press oder auch Repeat? (10.1) Rundenbasiert spricht für nur Press — ein Zug pro Tastendruck. Aber ein Dungeon-Grid, das man Feld für Feld antippen muss, fühlt sich zäh an. Das ist eine UX-Entscheidung, keine technische.
  6. Wird ratatui gleich richtig eingezogen oder erst ein Durchstich? Der Umbau von print_box auf ratatui berührt jedes bestehende Modul. Ein Prototyp-Ticket, das nur Hauptmenü + Dungeon-Grid zeigt, würde die Struktur aus 4.2 validieren, bevor sieben Bildschirme darauf gebaut werden.