A tui-rs öröksége és a ratatui születése

Ha valaha próbáltál terminál-alapú felhasználói felületet írni Rustban, valószínűleg találkoztál a tui-rs csomaggal. Fdehnel Antoine (a projekt eredeti szerzője) évekig karban tartotta, és a mai napig ez az egyik legismertebb TUI könyvtár a Rust ökoszisztémában — csak épp az elmúlt hónapokban jelentősen lelassult a fejlesztése. A karbantartó más projektekre koncentrált, a PR-ek és issue-k pedig egyre csak gyűltek.

A közösség erre klasszikus open-source válasszal reagált: fork. Így született meg a ratatui, ami a tui-rs kódbázisából indult, de aktív karbantartói csapattal, gyorsabb release ciklussal és nyitottabb hozzáállással a közösségi hozzájárulásokhoz. Jelenleg a 0.21 a legfrissebb stabil verzió, és ha most kezdenél egy új TUI projektet, ez az, amit érdemes választani — az API nagyrészt kompatibilis a tui-rs-szel, tehát a régebbi tutorialok és példák is jól hasznosíthatók.

Megjegyzés

A ratatui maga nem foglalkozik a terminál nyers vezérlésével (billentyűzet olvasás, raw mode be- és kikapcsolása, stb.) — ehhez egy "backend" csomagra van szükség. A leggyakoribb választás a crossterm, ami platformfüggetlenül (Linux, macOS, Windows) is jól működik, ezért ezt fogjuk használni mi is.

Projekt indítása és a widget rendszer alapjai

Kezdjük egy friss projekttel:

cargo new tui-dashboard
cd tui-dashboard

A Cargo.toml-ba a következő függőségeket vesszük fel:

[dependencies]
ratatui = "0.21"
crossterm = "0.26"

A ratatui alapfilozófiája, hogy minden képernyőfrissítéskor teljesen újra rajzolod a felhasználói felületet — nincs diffelés, nincs "csak a változott rész frissül" logika a te oldaladon (ezt a terminál driver és a backend intézi optimalizáltan). Ez elsőre furcsa lehet, ha webes vagy asztali UI-hoz vagy szokva, de a gyakorlatban nagyon egyszerűvé teszi a mentális modellt: van egy Frame, amire widgeteket rajzolsz, és minden ciklusban újra meghívod a rajzoló függvényt.

A widget rendszer alapja három koncepció:

  • Layout — hogyan osztod fel a terminál területét (sorokra, oszlopokra, arányokra).
  • Widget — a tényleges megjeleníthető elem (Paragraph, Table, List, Block, stb.).
  • Terminal — a tényleges renderelést és a backend-del való kommunikációt intéző objektum.

Íme egy minimális szkeleton, amivel a terminált raw módba állítjuk, és egy egyszerű keretet rajzolunk:

use std::io;
use crossterm::{
    execute,
    terminal::{enable_raw_mode, disable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal, widgets::{Block, Borders}};

fn main() -> io::Result<()> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;

    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    terminal.draw(|f| {
        let size = f.size();
        let block = Block::default()
            .title("Rust TUI Dashboard")
            .borders(Borders::ALL);
        f.render_widget(block, size);
    })?;

    // rövid demonstráció kedvéért kis várakozás
    std::thread::sleep(std::time::Duration::from_secs(2));

    disable_raw_mode()?;
    execute!(io::stdout(), LeaveAlternateScreen)?;
    Ok(())
}
Tipp

Ha kifagy a terminálod egy hibás program után (pl. panic a raw mode visszaállítása előtt), írd be vaktában a reset parancsot a shellbe — ez visszaállítja a terminál állapotát.

Dashboard felépítése szövegdobozokkal és táblázattal

A Layout API a legfontosabb eszköz ahhoz, hogy a rendelkezésre álló területet feldaraboljuk. A Constraint enum segítségével megadhatjuk, hogy egy terület mekkora legyen: százalékos, fix hosszúságú, vagy arányos (Ratio).

Nézzünk egy kétsoros elrendezést: felül egy státusz-panel, alul egy táblázat.

use ratatui::{
    layout::{Constraint, Direction, Layout},
    style::{Color, Modifier, Style},
    text::{Span, Spans},
    widgets::{Block, Borders, Cell, Paragraph, Row, Table},
    Frame,
};
use ratatui::backend::Backend;

fn render_dashboard<B: Backend>(f: &mut Frame<B>) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(3), Constraint::Min(0)].as_ref())
        .split(f.size());

    let status = Paragraph::new(Spans::from(vec![
        Span::styled("Állapot: ", Style::default().add_modifier(Modifier::BOLD)),
        Span::styled("FUT", Style::default().fg(Color::Green)),
    ]))
    .block(Block::default().borders(Borders::ALL).title("Rendszer"));

    f.render_widget(status, chunks[0]);

    let header = Row::new(vec!["Feladat", "Állapot", "Idő"])
        .style(Style::default().add_modifier(Modifier::BOLD));

    let rows = vec![
        Row::new(vec!["build", "OK", "1.2s"]),
        Row::new(vec!["test", "FAIL", "3.4s"]),
        Row::new(vec!["lint", "OK", "0.8s"]),
    ];

    let table = Table::new(rows)
        .header(header)
        .block(Block::default().borders(Borders::ALL).title("Feladatok"))
        .widths(&[
            Constraint::Percentage(40),
            Constraint::Percentage(30),
            Constraint::Percentage(30),
        ]);

    f.render_widget(table, chunks[1]);
}

Figyeld meg, hogy a Row::new egyszerű &str vagy String iterátort is elfogad, de ha finomabb kontrollt szeretnél a cellák stílusán, Cell-eket is összeállíthatsz explicit módon (Cell::from("szöveg").style(...)).

Jó tudni

A Table::widths metódusnak mindig ugyanannyi Constraint-et kell kapnia, mint ahány oszlopod van, különben futásidőben panic-ol a program.

Billentyűzet-események kezelése és állapotkezelés

Egy statikus dashboard szép, de egy valódi TUI alkalmazásnak reagálnia kell a felhasználóra. A crossterm event::poll és event::read függvényeivel tudunk nem blokkoló módon billentyűzet-eseményeket olvasni.

Az állapotot érdemes egy külön struktúrában tartani, amit minden ciklusban módosítunk, majd a rajzoló zárásban felhasználunk:

use std::time::{Duration, Instant};
use crossterm::event::{self, Event, KeyCode};

struct AppState {
    selected_row: usize,
    should_quit: bool,
}

impl AppState {
    fn new() -> Self {
        Self { selected_row: 0, should_quit: false }
    }

    fn handle_key(&mut self, code: KeyCode) {
        match code {
            KeyCode::Char('q') => self.should_quit = true,
            KeyCode::Down => self.selected_row = self.selected_row.saturating_add(1),
            KeyCode::Up => self.selected_row = self.selected_row.saturating_sub(1),
            _ => {}
        }
    }
}

fn run_event_loop(state: &mut AppState) -> std::io::Result<()> {
    let tick_rate = Duration::from_millis(200);
    let mut last_tick = Instant::now();

    loop {
        let timeout = tick_rate
            .checked_sub(last_tick.elapsed())
            .unwrap_or_else(|| Duration::from_secs(0));

        if event::poll(timeout)? {
            let Event::Key(key) = event::read()? else {
                continue;
            };
            state.handle_key(key.code);
        }

        if last_tick.elapsed() >= tick_rate {
            last_tick = Instant::now();
        }

        if state.should_quit {
            break;
        }
    }

    Ok(())
}

Itt jól látszik a Rust 1.65 óta stabil let-else szintaxis, amivel elegánsan tudunk kiszűrni egy nem érdekes eseménytípust anélkül, hogy egy egész match blokkot kellene írnunk csak azért, hogy az Event::Key ágat kezeljük.

Tipp

A tick_rate-hez hasonló időzítés azért fontos, mert ha csak blokkoló event::read()-et hívnál, a dashboard sosem frissülne automatikusan (pl. egy óra widget), amíg a felhasználó nem nyom le egy gombot.

Az első saját TUI alkalmazás összeállítása

Rakjuk össze a fenti darabokat egy futtatható programmá. A main függvény felel a terminál előkészítéséért, majd meghívja a fő ciklust, végül garantáltan visszaállítja a terminál állapotát — még hiba esetén is.

fn main() -> std::io::Result<()> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;

    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;
    let mut state = AppState::new();

    let result = loop {
        terminal.draw(|f| render_dashboard(f))?;

        if event::poll(Duration::from_millis(200))? {
            let Event::Key(key) = event::read()? else {
                continue;
            };
            state.handle_key(key.code);
        }

        if state.should_quit {
            break Ok(());
        }
    };

    disable_raw_mode()?;
    execute!(io::stdout(), LeaveAlternateScreen)?;

    result
}

Ez a szerkezet már egy valódi, futtatható TUI alkalmazás alapja: rajzolunk, olvasunk egy eseményt, frissítjük az állapotot, majd újra rajzolunk. Innen már csak bővíteni kell — például a selected_row alapján kiemelhetnéd a táblázat egy sorát a Table::highlight_style beállításával, vagy hozzáadhatnál egy List widgetet egy oldalsó menühöz.

Figyelem

Ne feledd, hogy panic esetén a raw mode és az alternate screen nem áll vissza automatikusan, ha nincs hozzá catch_unwind vagy panic::set_hook a takarításhoz. Production kódban érdemes egy egyszerű panic hook-ot beállítani, ami előbb visszaállítja a terminált, majd újra panic-ol.

Összefoglalás

A ratatui remek választás, ha gyors, natívan futó, mégis kellemesen kinéző terminál alkalmazást szeretnél írni Rustban — legyen szó monitoring dashboardról, fájlkezelőről vagy fejlesztői eszközről. A widget rendszer (Layout + Widget + Terminal) egyszerű mentális modellt ad, a crossterm backend pedig gondoskodik a platformfüggetlen billentyűzet- és terminálkezelésről. A let-else szintaxis kényelmesen segít az esemény szűrésben, és mivel a projekt a tui-rs örökségére épül, a régebbi dokumentáció és közösségi példák nagy része is közvetlenül hasznosítható. Innen már csak a saját ötleteid szabnak határt: építs progress bar-t, chart widgetet, vagy akár egész terminál-alapú admin felületet.