From 3b0a2c718f8ed995eabee50f427b528ccc982035 Mon Sep 17 00:00:00 2001 From: Li Xu Date: Thu, 14 May 2026 21:35:30 -0700 Subject: [PATCH] tui-skeleton: terminal lifecycle, event loop, panic hook Replace the temporary App-exercising placeholder in src/main.rs with a real TUI shell. setup_terminal enables raw mode, enters the alternate screen, and enables mouse capture; restore_terminal reverses the order (mouse capture off before leaving alt screen). install_panic_hook chains a custom hook so the terminal restores on panic before the default panic message prints. The main loop polls events on a 100ms timeout and dispatches to handle_event, which filters KeyEventKind::Press (Windows double-fire) and quits on q, Esc, or Ctrl+C. Mouse/resize/paste fall through to a no-op until later tasks. Add src/ui.rs as a stub renderer (bordered "Calculator" block) so the binary compiles and runs; real layout lands in ui-display and ui-buttons. Co-Authored-By: Claude Opus 4.7 --- docs/TASKS.md | 2 +- docs/progress.md | 37 +++++++++++++++++--- src/main.rs | 88 +++++++++++++++++++++++++++++++++++++++++------- src/ui.rs | 11 ++++++ 4 files changed, 121 insertions(+), 17 deletions(-) create mode 100644 src/ui.rs diff --git a/docs/TASKS.md b/docs/TASKS.md index 9824026..a479d0e 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -5,7 +5,7 @@ [ ] app-result-state: Refactor result field to a proper enum [ ] app-display-split: Separate display string from internal expression [ ] app-ui-state: Extract UI state from App into its own struct/file -[ ] tui-skeleton: Terminal setup and event loop +[x] tui-skeleton: Terminal setup and event loop [ ] ui-display: Render display box [ ] ui-buttons: Render button grid with focus [ ] key-input: Direct keyboard input handling diff --git a/docs/progress.md b/docs/progress.md index d4cf2ed..25506ef 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -19,8 +19,37 @@ Key implementation details: - `format_number`: integers as `"8"` (not `"8.0"`), decimals trimmed to 10 places with trailing zeros stripped -`src/main.rs` has a temporary placeholder that exercises `App`; it will be -replaced entirely by `tui-skeleton`. +### tui-skeleton — `src/main.rs`, `src/ui.rs` +Terminal lifecycle, main event loop, and a stub renderer. No unit tests +(manual verification: launch, quit via `q`/`Esc`/`Ctrl+C`, terminal restored). + +Key implementation details: +- `setup_terminal`: `enable_raw_mode` → `EnterAlternateScreen` → + `EnableMouseCapture`. `restore_terminal` reverses in the right order + (mouse capture off *before* leaving alt screen). +- `install_panic_hook` chains a custom hook in front of the original so the + terminal is restored on panic before the default panic message prints. +- Main loop polls `event::poll(100ms)` and dispatches to `handle_event`. + `app.should_quit` is the exit signal. +- `handle_event` filters `KeyEventKind::Press` (Windows fires Press / Repeat / + Release for every keystroke; without the filter every tap counts multiple + times). Quit keys: `q`, `Esc`, `Ctrl+C`. Mouse / resize / paste events fall + through to a no-op. +- `Ctrl+C` is handled explicitly — in raw mode the kernel does *not* turn it + into `SIGINT`; the app receives the keypress and must act on it. +- `ui::draw` is a stub (`Block::bordered().title("Calculator")`); real layout + comes in `ui-display` and `ui-buttons`. + +`Tui` is deliberately concrete: `Terminal>`. The +`Backend` trait already abstracts rendering inside `ui::draw`, so making +`main.rs` generic over `B: Backend` would only abstract the part that's +already abstract — setup, teardown, and event reading are inherently +crossterm-specific. If a non-terminal backend is ever needed, the right +factoring is a separate binary, not generics here. + +Build currently emits 11 "never used" warnings for `App` methods and the +`eval` module: nothing in `handle_event` yet calls `press_button`, +`evaluate`, etc. These clear as soon as `key-input` lands. ## Known Issues / Deferred @@ -45,5 +74,5 @@ Three follow-up tasks were added to `docs/TASKS.md` during implementation: ## Next Task -**tui-skeleton** — `docs/tasks/tui-skeleton.md` -Terminal setup and event loop using Ratatui + Crossterm. +**ui-display** — `docs/tasks/ui-display.md` +Render the display box (top of the screen, showing expression and/or result). diff --git a/src/main.rs b/src/main.rs index 863847d..2d8884f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,21 +1,85 @@ mod app; mod eval; +mod ui; + +use std::io::{self, Result, Stdout}; +use std::time::Duration; + +use crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, +}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; use app::App; -use eval::eval; -fn main() { - let mut app = App::new(); - let _ = app.should_quit; - app.move_focus(1, 1); - let _ = app.focused_label(); - for ch in "1+1=".chars() { - app.press_button(&ch.to_string()); +type Tui = Terminal>; + +fn setup_terminal() -> Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + Terminal::new(CrosstermBackend::new(stdout)) +} + +fn restore_terminal(terminal: &mut Tui) -> Result<()> { + // Reverse of setup: drop mouse capture *before* leaving alt screen. + execute!( + terminal.backend_mut(), + DisableMouseCapture, + LeaveAlternateScreen + )?; + disable_raw_mode()?; + terminal.show_cursor()?; + Ok(()) +} + +/// Restore the terminal on panic so the user lands back in a cooked shell +/// instead of a frozen raw-mode terminal. +fn install_panic_hook() { + let original = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = execute!(io::stdout(), DisableMouseCapture, LeaveAlternateScreen); + let _ = disable_raw_mode(); + original(info); + })); +} + +fn run(terminal: &mut Tui, app: &mut App) -> Result<()> { + while !app.should_quit { + terminal.draw(|frame| ui::draw(frame, app))?; + if event::poll(Duration::from_millis(100))? { + handle_event(event::read()?, app); + } } + Ok(()) +} - app.evaluate(); - match app.result { - Some(ref res) => println!("Result: {res}"), - None => println!("No result"), +/// Dispatches a single terminal event to the app. +fn handle_event(event: Event, app: &mut App) { + if let Event::Key(key) = event + && key.kind == KeyEventKind::Press + { + match key.code { + KeyCode::Char('q') => app.should_quit = true, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.should_quit = true + } + KeyCode::Esc => app.should_quit = true, + _ => {} + } } } + +fn main() -> Result<()> { + install_panic_hook(); + let mut terminal = setup_terminal()?; + let mut app = App::new(); + let result = run(&mut terminal, &mut app); + restore_terminal(&mut terminal)?; + result +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..0d2e459 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,11 @@ +use ratatui::Frame; +use ratatui::widgets::Block; + +use crate::app::App; + +/// Stub renderer. Real layout (display + button grid) lands in `ui-display` +/// and `ui-buttons`. +pub fn draw(frame: &mut Frame, _app: &App) { + let block = Block::bordered().title("Calculator"); + frame.render_widget(block, frame.area()); +}