Move help to a separate dialog box

This commit is contained in:
2026-05-21 19:26:30 +03:00
parent a6741da14c
commit cb0abb3e3b

View File

@@ -7,7 +7,7 @@ use crossterm::terminal::{
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use ratatui::{Frame, Terminal};
use std::io::{stdout, Stdout};
use std::time::{Duration, Instant};
@@ -324,6 +324,7 @@ fn main_loop(
let (mut screen, mut log) = initial_state(prog, &registry);
let mut focus = default_focus(&screen);
let mut editor = Editor::new();
let mut help_open = false;
// Resume case: if the bottom of the log is a LevelPrompt, animate it
// from scratch so resumed sessions feel the same as fresh ones.
@@ -336,7 +337,15 @@ fn main_loop(
loop {
terminal.draw(|frame| {
render(frame, &screen, focus, &editor, &log, typewriter.as_ref());
render(
frame,
&screen,
focus,
&editor,
&log,
typewriter.as_ref(),
help_open,
);
})?;
let poll_timeout = typewriter
@@ -357,6 +366,7 @@ fn main_loop(
&mut focus,
&mut editor,
&mut log,
&mut help_open,
key,
prog,
&registry,
@@ -403,17 +413,32 @@ fn step(
focus: &mut Focus,
editor: &mut Editor,
log: &mut HistoryLog,
help_open: &mut bool,
key: KeyEvent,
prog: &mut Progress,
registry: &[Box<dyn Level>],
) -> Result<bool> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
// Global: Ctrl-Q quits from anywhere.
// Global: Ctrl-Q quits from anywhere, including the help dialog.
if ctrl && key.code == KeyCode::Char('q') {
return Ok(true);
}
// Help: Ctrl-H opens (idempotent).
if ctrl && key.code == KeyCode::Char('h') {
*help_open = true;
return Ok(false);
}
// While help is open, only Esc closes it; everything else is swallowed.
if *help_open {
if key.code == KeyCode::Esc {
*help_open = false;
}
return Ok(false);
}
// Global: Ctrl-X grades while in Game.
if ctrl && key.code == KeyCode::Char('x') && matches!(screen, Screen::Game) {
grade(editor.text(), prog, registry, log)?;
@@ -582,6 +607,7 @@ fn render(
editor: &Editor,
log: &HistoryLog,
typewriter: Option<&Typewriter>,
help_open: bool,
) {
// Outer layout: two-column area on top, 1-row status bar across the
// full width below.
@@ -609,29 +635,98 @@ fn render(
render_editor_inactive(frame, right);
}
render_status_bar(frame, status, screen, focus);
render_status_bar(frame, status, screen, focus, help_open);
// Help dialog overlay — drawn last so it sits on top of everything.
if help_open {
let area = centered_rect(70, 60, frame.size());
frame.render_widget(Clear, area);
frame.render_widget(help_widget(), area);
}
}
fn render_status_bar(frame: &mut Frame, area: Rect, screen: &Screen, focus: Focus) {
/// Center a rectangle of `pct_x` × `pct_y` percent inside `area`.
fn centered_rect(pct_x: u16, pct_y: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Percentage((100 - pct_y) / 2),
Constraint::Percentage(pct_y),
Constraint::Percentage((100 - pct_y) / 2),
]
.as_ref(),
)
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Percentage((100 - pct_x) / 2),
Constraint::Percentage(pct_x),
Constraint::Percentage((100 - pct_x) / 2),
]
.as_ref(),
)
.split(vertical[1])[1]
}
fn help_widget<'a>() -> Paragraph<'a> {
let body = "\n\
⌨ Controls\n\
\n\
[Tab] swap focus between log and editor\n\
[Ctrl-X] grade your YAML\n\
[Ctrl-H] open this help\n\
[Esc] close help · or focus log when editing\n\
[↑/↓] scroll the log (when log is focused)\n\
[PgUp/PgDn] scroll faster\n\
[r] reset progress\n\
[q] / [Ctrl-Q] quit\n\
\n\
Press [Esc] to close."
.to_string();
Paragraph::new(body)
.block(
Block::default()
.borders(Borders::ALL)
.title(" 📜 Controls "),
)
.wrap(Wrap { trim: false })
}
fn render_status_bar(
frame: &mut Frame,
area: Rect,
screen: &Screen,
focus: Focus,
help_open: bool,
) {
let style = Style::default().fg(Color::White).bg(Color::DarkGray);
let p = Paragraph::new(status_text(screen, focus)).style(style);
let p = Paragraph::new(status_text(screen, focus, help_open)).style(style);
frame.render_widget(p, area);
}
fn status_text(screen: &Screen, focus: Focus) -> String {
fn status_text(screen: &Screen, focus: Focus, help_open: bool) -> String {
if help_open {
return " [Esc] close help · [Ctrl-Q] quit".into();
}
match (screen, focus) {
(Screen::Welcome, _) => {
" [Enter] begin · [↑/↓] scroll · [PgUp/PgDn] page · [q] quit".into()
" [Enter] begin · [↑/↓] scroll · [PgUp/PgDn] page · [Ctrl-H] help · [q] quit".into()
}
(Screen::Game, Focus::Editor) => {
" [Ctrl-X] grade · [Tab] focus log · [Esc] focus log · [Ctrl-Q] quit".into()
" [Ctrl-X] grade · [Tab] focus log · [Esc] focus log · [Ctrl-H] help · [Ctrl-Q] quit"
.into()
}
(Screen::Game, Focus::Game) => {
" [Ctrl-X] grade · [Tab] focus editor · [↑/↓] scroll · [r] reset · [q] quit".into()
" [Ctrl-X] grade · [Tab] focus editor · [↑/↓] scroll · [r] reset · [Ctrl-H] help · [q] quit"
.into()
}
(Screen::ResetConfirm, _) => " [y] confirm wipe · any other key cancels".into(),
(Screen::Completed, _) => {
" [↑/↓] scroll · [PgUp/PgDn] page · [r] reset · [q] quit".into()
" [↑/↓] scroll · [PgUp/PgDn] page · [r] reset · [Ctrl-H] help · [q] quit".into()
}
}
}
@@ -723,13 +818,7 @@ fn entry_lines(entry: &LogEntry) -> Vec<String> {
" Score ≥ 95 % → 🥇 Gold · ≥ 80 % → 🥈 Silver · ≥ 70 % → 🥉 Bronze.".to_string(),
" Below 70 % means retry — refine your YAML and press Ctrl-X again.".to_string(),
String::new(),
" ⌨ Controls".to_string(),
" [Tab] swap focus between log and editor".to_string(),
" [Ctrl-X] grade your YAML".to_string(),
" [↑/↓] scroll the log (when log is focused)".to_string(),
" [PgUp/PgDn] scroll faster".to_string(),
" [r] reset progress".to_string(),
" [q] / [Ctrl-Q] quit".to_string(),
" Press [Ctrl-H] any time for the full controls.".to_string(),
],
LogEntry::LevelPrompt {
level_id,