Move help to a separate dialog box
This commit is contained in:
125
src/tui.rs
125
src/tui.rs
@@ -7,7 +7,7 @@ use crossterm::terminal::{
|
|||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
use ratatui::style::{Color, Style};
|
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 ratatui::{Frame, Terminal};
|
||||||
use std::io::{stdout, Stdout};
|
use std::io::{stdout, Stdout};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -324,6 +324,7 @@ fn main_loop(
|
|||||||
let (mut screen, mut log) = initial_state(prog, ®istry);
|
let (mut screen, mut log) = initial_state(prog, ®istry);
|
||||||
let mut focus = default_focus(&screen);
|
let mut focus = default_focus(&screen);
|
||||||
let mut editor = Editor::new();
|
let mut editor = Editor::new();
|
||||||
|
let mut help_open = false;
|
||||||
|
|
||||||
// Resume case: if the bottom of the log is a LevelPrompt, animate it
|
// Resume case: if the bottom of the log is a LevelPrompt, animate it
|
||||||
// from scratch so resumed sessions feel the same as fresh ones.
|
// from scratch so resumed sessions feel the same as fresh ones.
|
||||||
@@ -336,7 +337,15 @@ fn main_loop(
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
terminal.draw(|frame| {
|
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
|
let poll_timeout = typewriter
|
||||||
@@ -357,6 +366,7 @@ fn main_loop(
|
|||||||
&mut focus,
|
&mut focus,
|
||||||
&mut editor,
|
&mut editor,
|
||||||
&mut log,
|
&mut log,
|
||||||
|
&mut help_open,
|
||||||
key,
|
key,
|
||||||
prog,
|
prog,
|
||||||
®istry,
|
®istry,
|
||||||
@@ -403,17 +413,32 @@ fn step(
|
|||||||
focus: &mut Focus,
|
focus: &mut Focus,
|
||||||
editor: &mut Editor,
|
editor: &mut Editor,
|
||||||
log: &mut HistoryLog,
|
log: &mut HistoryLog,
|
||||||
|
help_open: &mut bool,
|
||||||
key: KeyEvent,
|
key: KeyEvent,
|
||||||
prog: &mut Progress,
|
prog: &mut Progress,
|
||||||
registry: &[Box<dyn Level>],
|
registry: &[Box<dyn Level>],
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
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') {
|
if ctrl && key.code == KeyCode::Char('q') {
|
||||||
return Ok(true);
|
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.
|
// Global: Ctrl-X grades while in Game.
|
||||||
if ctrl && key.code == KeyCode::Char('x') && matches!(screen, Screen::Game) {
|
if ctrl && key.code == KeyCode::Char('x') && matches!(screen, Screen::Game) {
|
||||||
grade(editor.text(), prog, registry, log)?;
|
grade(editor.text(), prog, registry, log)?;
|
||||||
@@ -582,6 +607,7 @@ fn render(
|
|||||||
editor: &Editor,
|
editor: &Editor,
|
||||||
log: &HistoryLog,
|
log: &HistoryLog,
|
||||||
typewriter: Option<&Typewriter>,
|
typewriter: Option<&Typewriter>,
|
||||||
|
help_open: bool,
|
||||||
) {
|
) {
|
||||||
// Outer layout: two-column area on top, 1-row status bar across the
|
// Outer layout: two-column area on top, 1-row status bar across the
|
||||||
// full width below.
|
// full width below.
|
||||||
@@ -609,29 +635,98 @@ fn render(
|
|||||||
render_editor_inactive(frame, right);
|
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 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);
|
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) {
|
match (screen, focus) {
|
||||||
(Screen::Welcome, _) => {
|
(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) => {
|
(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) => {
|
(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::ResetConfirm, _) => " [y] confirm wipe · any other key cancels".into(),
|
||||||
(Screen::Completed, _) => {
|
(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(),
|
" Score ≥ 95 % → 🥇 Gold · ≥ 80 % → 🥈 Silver · ≥ 70 % → 🥉 Bronze.".to_string(),
|
||||||
" Below 70 % means retry — refine your YAML and press Ctrl-X again.".to_string(),
|
" Below 70 % means retry — refine your YAML and press Ctrl-X again.".to_string(),
|
||||||
String::new(),
|
String::new(),
|
||||||
" ⌨ Controls".to_string(),
|
" Press [Ctrl-H] any time for the full 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(),
|
|
||||||
],
|
],
|
||||||
LogEntry::LevelPrompt {
|
LogEntry::LevelPrompt {
|
||||||
level_id,
|
level_id,
|
||||||
|
|||||||
Reference in New Issue
Block a user