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.
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(())
}
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(...)).
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.
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.
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.