Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 33 additions & 4 deletions docs/progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<CrosstermBackend<Stdout>>`. 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

Expand All @@ -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).
88 changes: 76 additions & 12 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -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<CrosstermBackend<Stdout>>;

fn setup_terminal() -> Result<Tui> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If execute! fails here, the function returns early via the ? operator, but the terminal has already been put into raw mode by the previous line. This will leave the user's terminal in a broken state (no echo, no line buffering) upon exit. You should ensure raw mode is disabled if subsequent setup steps fail.

    if let Err(err) = execute!(stdout, EnterAlternateScreen, EnableMouseCapture) {
        let _ = disable_raw_mode();
        return Err(err);
    }

Terminal::new(CrosstermBackend::new(stdout))
Comment on lines +23 to +26
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Roll back raw mode when setup_terminal errors

If enable_raw_mode() succeeds but either execute!(..., EnterAlternateScreen, EnableMouseCapture) or Terminal::new(...) fails, this function returns early and leaves the user’s shell in raw mode. That produces a broken terminal session (no normal line editing/echo) on startup failures, which is exactly when cleanup is most needed; add an error path that best-effort calls disable_raw_mode() (and exits alt screen if entered) before propagating the error.

Useful? React with 👍 / 👎.

}

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,
_ => {}
}
}
Comment on lines +64 to 75
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The use of "let-chains" (if let ... && ...) is currently an unstable Rust feature (see RFC 2497). Unless this project specifically targets the nightly compiler and enables the #![feature(let_chains)] attribute, this code will fail to compile on stable Rust. It is safer to use a nested if let or a matches! guard.

Suggested change
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,
_ => {}
}
}
if let Event::Key(key) = event {
if 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
}
11 changes: 11 additions & 0 deletions src/ui.rs
Original file line number Diff line number Diff line change
@@ -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());
}
Loading