From 8647ee9a57529f1713cde0cee6f55822d55253d6 Mon Sep 17 00:00:00 2001 From: Simonas Kareiva Date: Thu, 21 May 2026 17:51:22 +0300 Subject: [PATCH] Tiny editor for GUI --- src/tui.rs | 438 ++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 332 insertions(+), 106 deletions(-) diff --git a/src/tui.rs b/src/tui.rs index 929e8ca..8afda32 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,11 +1,11 @@ use anyhow::Result; -use crossterm::event::{self, Event, KeyCode}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; use ratatui::backend::CrosstermBackend; -use ratatui::layout::Rect; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::{Frame, Terminal}; use std::io::{stdout, Stdout}; @@ -14,15 +14,14 @@ use crate::levels::{self, Difficulty, Level}; use crate::progress::{self, Progress}; use crate::similarity; +// -- State ------------------------------------------------------------------ + enum Screen { Welcome, TierSelect { cursor: u8, }, Level, - Submit { - buf: String, - }, Result { score: f64, passed: bool, @@ -35,6 +34,110 @@ enum Screen { Completed, } +#[derive(Copy, Clone, PartialEq, Eq)] +enum Focus { + Game, + Editor, +} + +/// Multi-line YAML editor backing the right column. Byte-indexed; assumes +/// ASCII content (YAML is conventionally ASCII). Splitting at a multi-byte +/// boundary would panic — that's flagged as risk R6. +struct Editor { + buffer: Vec, + cursor: (usize, usize), // (row, col) +} + +impl Editor { + fn new() -> Self { + Self { + buffer: vec![String::new()], + cursor: (0, 0), + } + } + + fn clear(&mut self) { + self.buffer = vec![String::new()]; + self.cursor = (0, 0); + } + + fn text(&self) -> String { + self.buffer.join("\n") + } + + fn insert_char(&mut self, c: char) { + let (r, col) = self.cursor; + let line = &mut self.buffer[r]; + let col = col.min(line.len()); + line.insert(col, c); + self.cursor = (r, col + 1); + } + + fn backspace(&mut self) { + let (r, col) = self.cursor; + if col > 0 { + self.buffer[r].remove(col - 1); + self.cursor = (r, col - 1); + } else if r > 0 { + let cur = self.buffer.remove(r); + let prev_len = self.buffer[r - 1].len(); + self.buffer[r - 1].push_str(&cur); + self.cursor = (r - 1, prev_len); + } + } + + fn newline(&mut self) { + let (r, col) = self.cursor; + let col = col.min(self.buffer[r].len()); + let rest = self.buffer[r].split_off(col); + self.buffer.insert(r + 1, rest); + self.cursor = (r + 1, 0); + } + + fn left(&mut self) { + let (r, col) = self.cursor; + if col > 0 { + self.cursor.1 = col - 1; + } else if r > 0 { + self.cursor = (r - 1, self.buffer[r - 1].len()); + } + } + + fn right(&mut self) { + let (r, col) = self.cursor; + let line_len = self.buffer[r].len(); + if col < line_len { + self.cursor.1 = col + 1; + } else if r + 1 < self.buffer.len() { + self.cursor = (r + 1, 0); + } + } + + fn up(&mut self) { + if self.cursor.0 > 0 { + let r = self.cursor.0 - 1; + self.cursor = (r, self.cursor.1.min(self.buffer[r].len())); + } + } + + fn down(&mut self) { + if self.cursor.0 + 1 < self.buffer.len() { + let r = self.cursor.0 + 1; + self.cursor = (r, self.cursor.1.min(self.buffer[r].len())); + } + } + + fn home(&mut self) { + self.cursor.1 = 0; + } + + fn end(&mut self) { + self.cursor.1 = self.buffer[self.cursor.0].len(); + } +} + +// -- Entry ------------------------------------------------------------------ + pub fn run(prog: &mut Progress) -> Result<()> { install_panic_hook(); let mut terminal = enter()?; @@ -73,55 +176,132 @@ fn initial_screen(prog: &Progress, registry_len: usize) -> Screen { } } +fn default_focus(screen: &Screen) -> Focus { + match screen { + Screen::Level | Screen::InvalidYaml { .. } => Focus::Editor, + _ => Focus::Game, + } +} + +fn editor_active(screen: &Screen) -> bool { + matches!( + screen, + Screen::Level | Screen::Result { .. } | Screen::InvalidYaml { .. } + ) +} + +fn editor_interactive(screen: &Screen) -> bool { + matches!(screen, Screen::Level | Screen::InvalidYaml { .. }) +} + +// -- Main loop -------------------------------------------------------------- + fn main_loop( terminal: &mut Terminal>, prog: &mut Progress, ) -> Result<()> { let registry = levels::registry(); let mut screen = initial_screen(prog, registry.len()); + let mut focus = default_focus(&screen); + let mut editor = Editor::new(); loop { - terminal.draw(|frame| render(frame, &screen, prog, ®istry))?; + terminal.draw(|frame| render(frame, &screen, prog, ®istry, focus, &editor))?; let Event::Key(key) = event::read()? else { continue; }; + if !matches!(key.kind, KeyEventKind::Press) { + continue; + } - // Swap `screen` out so we can match by value, then assign the new - // screen back. `Screen::Welcome` is just a placeholder during the - // swap; `step` never observes it. - let current = std::mem::replace(&mut screen, Screen::Welcome); - match step(current, key.code, prog, ®istry)? { - Transition::To(next) => screen = next, - Transition::Quit => return Ok(()), + if step(&mut screen, &mut focus, &mut editor, key, prog, ®istry)? { + return Ok(()); } } } -enum Transition { - To(Screen), - Quit, -} - +/// Returns `true` when the user wants to quit. fn step( - screen: Screen, - key: KeyCode, + screen: &mut Screen, + focus: &mut Focus, + editor: &mut Editor, + key: KeyEvent, prog: &mut Progress, registry: &[Box], -) -> Result { - use Transition::*; +) -> Result { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - Ok(match (screen, key) { - // Welcome ------------------------------------------------------------ - (Screen::Welcome, KeyCode::Enter) => To(Screen::TierSelect { cursor: 0 }), - (Screen::Welcome, KeyCode::Char('q')) => Quit, + // Global: Ctrl-Q quits from anywhere. + if ctrl && key.code == KeyCode::Char('q') { + return Ok(true); + } - // TierSelect --------------------------------------------------------- - (Screen::TierSelect { cursor }, KeyCode::Up) => To(Screen::TierSelect { - cursor: cursor.saturating_sub(1), - }), - (Screen::TierSelect { cursor }, KeyCode::Down) => To(Screen::TierSelect { - cursor: (cursor + 1).min(2), - }), + // Global: Ctrl-S grades when the editor is in play. + if ctrl && key.code == KeyCode::Char('s') && editor_interactive(screen) { + let next = grade(editor.text(), prog, registry)?; + *focus = default_focus(&next); + *screen = next; + return Ok(false); + } + + // Tab toggles focus while the editor is interactive. + if key.code == KeyCode::Tab && editor_interactive(screen) { + *focus = match *focus { + Focus::Game => Focus::Editor, + Focus::Editor => Focus::Game, + }; + return Ok(false); + } + + // Editor keys (only when Editor is focused and screen is interactive). + if *focus == Focus::Editor && editor_interactive(screen) { + let was_invalid = matches!(screen, Screen::InvalidYaml { .. }); + let mut consumed = true; + match key.code { + KeyCode::Char(c) if !ctrl => editor.insert_char(c), + KeyCode::Backspace => editor.backspace(), + KeyCode::Enter => editor.newline(), + KeyCode::Left => editor.left(), + KeyCode::Right => editor.right(), + KeyCode::Up => editor.up(), + KeyCode::Down => editor.down(), + KeyCode::Home => editor.home(), + KeyCode::End => editor.end(), + KeyCode::Esc => { + // Esc dismisses InvalidYaml (without retry) or returns focus + // to the Game column on Level. + if was_invalid { + *screen = Screen::Level; + *focus = default_focus(screen); + } else { + *focus = Focus::Game; + } + } + _ => consumed = false, + } + if consumed && was_invalid { + // Any edit implicitly dismisses the error overlay. + *screen = Screen::Level; + } + return Ok(false); + } + + // Game-focus keys. + match (&*screen, key.code) { + (Screen::Welcome, KeyCode::Enter) => { + *screen = Screen::TierSelect { cursor: 0 }; + *focus = default_focus(screen); + } + (Screen::Welcome, KeyCode::Char('q')) => return Ok(true), + + (Screen::TierSelect { cursor }, KeyCode::Up) => { + let c = cursor.saturating_sub(1); + *screen = Screen::TierSelect { cursor: c }; + } + (Screen::TierSelect { cursor }, KeyCode::Down) => { + let c = (*cursor + 1).min(2); + *screen = Screen::TierSelect { cursor: c }; + } (Screen::TierSelect { cursor }, KeyCode::Enter) => { let tier = match cursor { 0 => Difficulty::Easy, @@ -134,71 +314,76 @@ fn step( prog.current_seed = rand::random(); } progress::save(prog)?; - To(Screen::Level) + editor.clear(); + *screen = Screen::Level; + *focus = default_focus(screen); } - (Screen::TierSelect { .. }, KeyCode::Char('q')) => Quit, + (Screen::TierSelect { .. }, KeyCode::Char('q')) => return Ok(true), - // Level -------------------------------------------------------------- - (Screen::Level, KeyCode::Char('s')) => To(Screen::Submit { buf: String::new() }), - (Screen::Level, KeyCode::Char('r')) => To(Screen::ResetConfirm), - (Screen::Level, KeyCode::Char('q')) => Quit, - - // Submit ------------------------------------------------------------- - (Screen::Submit { mut buf }, KeyCode::Char(c)) => { - buf.push(c); - To(Screen::Submit { buf }) + (Screen::Level, KeyCode::Char('r')) => { + *screen = Screen::ResetConfirm; + *focus = default_focus(screen); } - (Screen::Submit { mut buf }, KeyCode::Backspace) => { - buf.pop(); - To(Screen::Submit { buf }) - } - (Screen::Submit { .. }, KeyCode::Esc) => To(Screen::Level), - (Screen::Submit { buf }, KeyCode::Enter) => To(grade(buf.trim(), prog, registry)?), + (Screen::Level, KeyCode::Char('q')) => return Ok(true), - // Result ------------------------------------------------------------- - (Screen::Result { passed: true, .. }, _) => { - if (prog.current_level as usize) > registry.len() { - To(Screen::Completed) + (Screen::Result { passed, .. }, KeyCode::Enter) => { + let pass = *passed; + if pass { + editor.clear(); + if (prog.current_level as usize) > registry.len() { + *screen = Screen::Completed; + } else { + *screen = Screen::Level; + } } else { - To(Screen::Level) + *screen = Screen::Level; } + *focus = default_focus(screen); } - (Screen::Result { passed: false, .. }, _) => To(Screen::Level), - // InvalidYaml -------------------------------------------------------- - (Screen::InvalidYaml { .. }, _) => To(Screen::Submit { buf: String::new() }), + (Screen::InvalidYaml { .. }, KeyCode::Enter) => { + *screen = Screen::Level; + *focus = default_focus(screen); + } + (Screen::InvalidYaml { .. }, KeyCode::Esc) => { + *screen = Screen::Level; + *focus = default_focus(screen); + } + (Screen::InvalidYaml { .. }, KeyCode::Char('r')) => { + *screen = Screen::ResetConfirm; + *focus = default_focus(screen); + } + (Screen::InvalidYaml { .. }, KeyCode::Char('q')) => return Ok(true), - // ResetConfirm ------------------------------------------------------- (Screen::ResetConfirm, KeyCode::Char('y')) => { let _ = std::fs::remove_file(progress::save_path()); *prog = Progress::default(); - To(Screen::Welcome) + editor.clear(); + *screen = Screen::Welcome; + *focus = default_focus(screen); + } + (Screen::ResetConfirm, _) => { + *screen = Screen::Level; + *focus = default_focus(screen); } - (Screen::ResetConfirm, _) => To(Screen::Level), - // Completed ---------------------------------------------------------- - (Screen::Completed, KeyCode::Char('q')) => Quit, - (Screen::Completed, KeyCode::Char('r')) => To(Screen::ResetConfirm), + (Screen::Completed, KeyCode::Char('q')) => return Ok(true), + (Screen::Completed, KeyCode::Char('r')) => { + *screen = Screen::ResetConfirm; + *focus = default_focus(screen); + } - // Anything else: stay where we are. - (other, _) => To(other), - }) + _ => {} + } + + Ok(false) } -/// Read the player's file, parse it, score it, advance progress on a pass, -/// and choose the next screen. Returns `Result` (`InvalidYaml` or `Result`). -fn grade(path: &str, prog: &mut Progress, registry: &[Box]) -> Result { - let candidate = match std::fs::read_to_string(path) { - Ok(s) => s, - Err(e) => { - return Ok(Screen::InvalidYaml { - error: format!("could not read file: {e}"), - }); - } - }; - - // Parse-first guard: invalid YAML is a wrong *format*, not a wrong - // *answer* — no scoring, no attempts bump. +fn grade( + candidate: String, + prog: &mut Progress, + registry: &[Box], +) -> Result { if let Err(e) = serde_yaml::from_str::(&candidate) { return Ok(Screen::InvalidYaml { error: e.to_string() }); } @@ -231,28 +416,42 @@ fn grade(path: &str, prog: &mut Progress, registry: &[Box]) -> Result }) } -// -- rendering -------------------------------------------------------------- +// -- Rendering -------------------------------------------------------------- fn render( frame: &mut Frame, screen: &Screen, prog: &Progress, registry: &[Box], + focus: Focus, + editor: &Editor, ) { let area = frame.size(); + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) + .split(area); + let (left, right) = (chunks[0], chunks[1]); + match screen { - Screen::Welcome => render_welcome(frame, area), - Screen::TierSelect { cursor } => render_tier_select(frame, area, *cursor), - Screen::Level => render_level(frame, area, prog, registry), - Screen::Submit { buf } => render_submit(frame, area, buf), + Screen::Welcome => render_welcome(frame, left), + Screen::TierSelect { cursor } => render_tier_select(frame, left, *cursor), + Screen::Level => render_level(frame, left, prog, registry), Screen::Result { score, passed, level_name, - } => render_result(frame, area, *score, *passed, level_name, prog), - Screen::InvalidYaml { error } => render_invalid_yaml(frame, area, error), - Screen::ResetConfirm => render_reset_confirm(frame, area), - Screen::Completed => render_completed(frame, area), + } => render_result(frame, left, *score, *passed, level_name, prog), + Screen::InvalidYaml { error } => render_invalid_yaml(frame, left, error), + Screen::ResetConfirm => render_reset_confirm(frame, left), + Screen::Completed => render_completed(frame, left), + } + + if editor_active(screen) { + let editor_focused = focus == Focus::Editor && editor_interactive(screen); + render_editor(frame, right, editor, editor_focused); + } else { + render_editor_inactive(frame, right); } } @@ -266,7 +465,7 @@ fn render_welcome(frame: &mut Frame, area: Rect) { let body = "\n\ You stand at the entrance to the YAML labyrinth.\n\ Within, broken syntax festers and dictionaries\n\ - sprawl like vines. Reach the heart of it.\n\ + sprawl like vines.\n\ \n\ [Enter] begin · [q] flee" .to_string(); @@ -305,19 +504,12 @@ fn render_level( tier_label ); let body = format!( - "\n{}\n\n{}\nWrite your YAML answer in a file, then\n[s] submit · [r] reset · [q] flee", + "\n{}\n\n{}\n\n[Tab] swap focus · [Ctrl-S] grade · [r] reset · [q] quit", g.flavor, g.description ); frame.render_widget(screen_widget(title, body), area); } -fn render_submit(frame: &mut Frame, area: Rect, buf: &str) { - let body = format!( - "\nPath to your answer YAML:\n\n> {buf}_\n\n[Enter] grade · [Esc] cancel" - ); - frame.render_widget(screen_widget(" Submit your answer ".to_string(), body), area); -} - fn render_result( frame: &mut Frame, area: Rect, @@ -326,10 +518,7 @@ fn render_result( level_name: &str, prog: &Progress, ) { - let threshold = prog - .tier - .map(|t| t.threshold()) - .unwrap_or(0.0); + let threshold = prog.tier.map(|t| t.threshold()).unwrap_or(0.0); let (title, narration) = if passed { ( format!(" {level_name} cleared "), @@ -352,7 +541,9 @@ fn render_result( fn render_invalid_yaml(frame: &mut Frame, area: Rect, error: &str) { let body = format!( - "\n{error}\n\nFix the syntax and try again.\n\n[Enter] back to submit" + "\n{error}\n\nFix the YAML in the editor (it's still there) and press\n\ + [Ctrl-S] to retry. Attempts counter is not bumped.\n\n\ + [Enter] dismiss · [r] reset · [q] quit" ); frame.render_widget( screen_widget(" Couldn't parse your YAML ".to_string(), body), @@ -361,11 +552,46 @@ fn render_invalid_yaml(frame: &mut Frame, area: Rect, error: &str) { } fn render_reset_confirm(frame: &mut Frame, area: Rect) { - let body = "\nWipe progress and start over?\n\n[y] yes, wipe · any other key cancels".to_string(); + let body = + "\nWipe progress and start over?\n\n[y] yes, wipe · any other key cancels".to_string(); frame.render_widget(screen_widget(" Reset? ".to_string(), body), area); } fn render_completed(frame: &mut Frame, area: Rect) { - let body = "\nYou cleared the YAML labyrinth.\n\n[r] reset and replay · [q] quit".to_string(); + let body = + "\nYou cleared the YAML labyrinth.\n\n[r] reset and replay · [q] quit".to_string(); frame.render_widget(screen_widget(" Labyrinth complete ".to_string(), body), area); } + +fn render_editor(frame: &mut Frame, area: Rect, editor: &Editor, focused: bool) { + let title = if focused { + " Your YAML * ".to_string() + } else { + " Your YAML ".to_string() + }; + let body = if focused { + let (cr, cc) = editor.cursor; + let mut lines: Vec = Vec::with_capacity(editor.buffer.len()); + for (r, line) in editor.buffer.iter().enumerate() { + if r == cr { + let col = cc.min(line.len()); + let mut with_cursor = String::with_capacity(line.len() + 1); + with_cursor.push_str(&line[..col]); + with_cursor.push('_'); + with_cursor.push_str(&line[col..]); + lines.push(with_cursor); + } else { + lines.push(line.clone()); + } + } + lines.join("\n") + } else { + editor.text() + }; + frame.render_widget(screen_widget(title, body), area); +} + +fn render_editor_inactive(frame: &mut Frame, area: Rect) { + let body = "\n The editor opens when you start a level.".to_string(); + frame.render_widget(screen_widget(" Editor (inactive) ".to_string(), body), area); +}