Tiny editor for GUI

This commit is contained in:
2026-05-21 17:51:22 +03:00
parent 3039da35fd
commit 8647ee9a57

View File

@@ -1,11 +1,11 @@
use anyhow::Result; use anyhow::Result;
use crossterm::event::{self, Event, KeyCode}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::execute; use crossterm::execute;
use crossterm::terminal::{ use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
}; };
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::layout::Rect; use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::{Frame, Terminal}; use ratatui::{Frame, Terminal};
use std::io::{stdout, Stdout}; use std::io::{stdout, Stdout};
@@ -14,15 +14,14 @@ use crate::levels::{self, Difficulty, Level};
use crate::progress::{self, Progress}; use crate::progress::{self, Progress};
use crate::similarity; use crate::similarity;
// -- State ------------------------------------------------------------------
enum Screen { enum Screen {
Welcome, Welcome,
TierSelect { TierSelect {
cursor: u8, cursor: u8,
}, },
Level, Level,
Submit {
buf: String,
},
Result { Result {
score: f64, score: f64,
passed: bool, passed: bool,
@@ -35,6 +34,110 @@ enum Screen {
Completed, 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<String>,
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<()> { pub fn run(prog: &mut Progress) -> Result<()> {
install_panic_hook(); install_panic_hook();
let mut terminal = enter()?; 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( fn main_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>, terminal: &mut Terminal<CrosstermBackend<Stdout>>,
prog: &mut Progress, prog: &mut Progress,
) -> Result<()> { ) -> Result<()> {
let registry = levels::registry(); let registry = levels::registry();
let mut screen = initial_screen(prog, registry.len()); let mut screen = initial_screen(prog, registry.len());
let mut focus = default_focus(&screen);
let mut editor = Editor::new();
loop { loop {
terminal.draw(|frame| render(frame, &screen, prog, &registry))?; terminal.draw(|frame| render(frame, &screen, prog, &registry, focus, &editor))?;
let Event::Key(key) = event::read()? else { let Event::Key(key) = event::read()? else {
continue; continue;
}; };
if !matches!(key.kind, KeyEventKind::Press) {
continue;
}
// Swap `screen` out so we can match by value, then assign the new if step(&mut screen, &mut focus, &mut editor, key, prog, &registry)? {
// screen back. `Screen::Welcome` is just a placeholder during the return Ok(());
// swap; `step` never observes it.
let current = std::mem::replace(&mut screen, Screen::Welcome);
match step(current, key.code, prog, &registry)? {
Transition::To(next) => screen = next,
Transition::Quit => return Ok(()),
} }
} }
} }
enum Transition { /// Returns `true` when the user wants to quit.
To(Screen),
Quit,
}
fn step( fn step(
screen: Screen, screen: &mut Screen,
key: KeyCode, focus: &mut Focus,
editor: &mut Editor,
key: KeyEvent,
prog: &mut Progress, prog: &mut Progress,
registry: &[Box<dyn Level>], registry: &[Box<dyn Level>],
) -> Result<Transition> { ) -> Result<bool> {
use Transition::*; let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
Ok(match (screen, key) { // Global: Ctrl-Q quits from anywhere.
// Welcome ------------------------------------------------------------ if ctrl && key.code == KeyCode::Char('q') {
(Screen::Welcome, KeyCode::Enter) => To(Screen::TierSelect { cursor: 0 }), return Ok(true);
(Screen::Welcome, KeyCode::Char('q')) => Quit, }
// TierSelect --------------------------------------------------------- // Global: Ctrl-S grades when the editor is in play.
(Screen::TierSelect { cursor }, KeyCode::Up) => To(Screen::TierSelect { if ctrl && key.code == KeyCode::Char('s') && editor_interactive(screen) {
cursor: cursor.saturating_sub(1), let next = grade(editor.text(), prog, registry)?;
}), *focus = default_focus(&next);
(Screen::TierSelect { cursor }, KeyCode::Down) => To(Screen::TierSelect { *screen = next;
cursor: (cursor + 1).min(2), 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) => { (Screen::TierSelect { cursor }, KeyCode::Enter) => {
let tier = match cursor { let tier = match cursor {
0 => Difficulty::Easy, 0 => Difficulty::Easy,
@@ -134,71 +314,76 @@ fn step(
prog.current_seed = rand::random(); prog.current_seed = rand::random();
} }
progress::save(prog)?; 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('r')) => {
(Screen::Level, KeyCode::Char('s')) => To(Screen::Submit { buf: String::new() }), *screen = Screen::ResetConfirm;
(Screen::Level, KeyCode::Char('r')) => To(Screen::ResetConfirm), *focus = default_focus(screen);
(Screen::Level, KeyCode::Char('q')) => Quit,
// Submit -------------------------------------------------------------
(Screen::Submit { mut buf }, KeyCode::Char(c)) => {
buf.push(c);
To(Screen::Submit { buf })
} }
(Screen::Submit { mut buf }, KeyCode::Backspace) => { (Screen::Level, KeyCode::Char('q')) => return Ok(true),
buf.pop();
To(Screen::Submit { buf })
}
(Screen::Submit { .. }, KeyCode::Esc) => To(Screen::Level),
(Screen::Submit { buf }, KeyCode::Enter) => To(grade(buf.trim(), prog, registry)?),
// Result ------------------------------------------------------------- (Screen::Result { passed, .. }, KeyCode::Enter) => {
(Screen::Result { passed: true, .. }, _) => { let pass = *passed;
if pass {
editor.clear();
if (prog.current_level as usize) > registry.len() { if (prog.current_level as usize) > registry.len() {
To(Screen::Completed) *screen = Screen::Completed;
} else { } else {
To(Screen::Level) *screen = Screen::Level;
} }
} else {
*screen = Screen::Level;
}
*focus = default_focus(screen);
} }
(Screen::Result { passed: false, .. }, _) => To(Screen::Level),
// InvalidYaml -------------------------------------------------------- (Screen::InvalidYaml { .. }, KeyCode::Enter) => {
(Screen::InvalidYaml { .. }, _) => To(Screen::Submit { buf: String::new() }), *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')) => { (Screen::ResetConfirm, KeyCode::Char('y')) => {
let _ = std::fs::remove_file(progress::save_path()); let _ = std::fs::remove_file(progress::save_path());
*prog = Progress::default(); *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')) => return Ok(true),
(Screen::Completed, KeyCode::Char('q')) => Quit, (Screen::Completed, KeyCode::Char('r')) => {
(Screen::Completed, KeyCode::Char('r')) => To(Screen::ResetConfirm), *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, fn grade(
/// and choose the next screen. Returns `Result` (`InvalidYaml` or `Result`). candidate: String,
fn grade(path: &str, prog: &mut Progress, registry: &[Box<dyn Level>]) -> Result<Screen> { prog: &mut Progress,
let candidate = match std::fs::read_to_string(path) { registry: &[Box<dyn Level>],
Ok(s) => s, ) -> Result<Screen> {
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.
if let Err(e) = serde_yaml::from_str::<serde_yaml::Value>(&candidate) { if let Err(e) = serde_yaml::from_str::<serde_yaml::Value>(&candidate) {
return Ok(Screen::InvalidYaml { error: e.to_string() }); return Ok(Screen::InvalidYaml { error: e.to_string() });
} }
@@ -231,28 +416,42 @@ fn grade(path: &str, prog: &mut Progress, registry: &[Box<dyn Level>]) -> Result
}) })
} }
// -- rendering -------------------------------------------------------------- // -- Rendering --------------------------------------------------------------
fn render( fn render(
frame: &mut Frame, frame: &mut Frame,
screen: &Screen, screen: &Screen,
prog: &Progress, prog: &Progress,
registry: &[Box<dyn Level>], registry: &[Box<dyn Level>],
focus: Focus,
editor: &Editor,
) { ) {
let area = frame.size(); 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 { match screen {
Screen::Welcome => render_welcome(frame, area), Screen::Welcome => render_welcome(frame, left),
Screen::TierSelect { cursor } => render_tier_select(frame, area, *cursor), Screen::TierSelect { cursor } => render_tier_select(frame, left, *cursor),
Screen::Level => render_level(frame, area, prog, registry), Screen::Level => render_level(frame, left, prog, registry),
Screen::Submit { buf } => render_submit(frame, area, buf),
Screen::Result { Screen::Result {
score, score,
passed, passed,
level_name, level_name,
} => render_result(frame, area, *score, *passed, level_name, prog), } => render_result(frame, left, *score, *passed, level_name, prog),
Screen::InvalidYaml { error } => render_invalid_yaml(frame, area, error), Screen::InvalidYaml { error } => render_invalid_yaml(frame, left, error),
Screen::ResetConfirm => render_reset_confirm(frame, area), Screen::ResetConfirm => render_reset_confirm(frame, left),
Screen::Completed => render_completed(frame, area), 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\ let body = "\n\
You stand at the entrance to the YAML labyrinth.\n\ You stand at the entrance to the YAML labyrinth.\n\
Within, broken syntax festers and dictionaries\n\ Within, broken syntax festers and dictionaries\n\
sprawl like vines. Reach the heart of it.\n\ sprawl like vines.\n\
\n\ \n\
[Enter] begin · [q] flee" [Enter] begin · [q] flee"
.to_string(); .to_string();
@@ -305,19 +504,12 @@ fn render_level(
tier_label tier_label
); );
let body = format!( 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 g.flavor, g.description
); );
frame.render_widget(screen_widget(title, body), area); 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( fn render_result(
frame: &mut Frame, frame: &mut Frame,
area: Rect, area: Rect,
@@ -326,10 +518,7 @@ fn render_result(
level_name: &str, level_name: &str,
prog: &Progress, prog: &Progress,
) { ) {
let threshold = prog let threshold = prog.tier.map(|t| t.threshold()).unwrap_or(0.0);
.tier
.map(|t| t.threshold())
.unwrap_or(0.0);
let (title, narration) = if passed { let (title, narration) = if passed {
( (
format!(" {level_name} cleared "), format!(" {level_name} cleared "),
@@ -352,7 +541,9 @@ fn render_result(
fn render_invalid_yaml(frame: &mut Frame, area: Rect, error: &str) { fn render_invalid_yaml(frame: &mut Frame, area: Rect, error: &str) {
let body = format!( 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( frame.render_widget(
screen_widget(" Couldn't parse your YAML ".to_string(), body), 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) { 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); frame.render_widget(screen_widget(" Reset? ".to_string(), body), area);
} }
fn render_completed(frame: &mut Frame, area: Rect) { 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); 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<String> = 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);
}