Tiny editor for GUI
This commit is contained in:
436
src/tui.rs
436
src/tui.rs
@@ -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, ®istry))?;
|
terminal.draw(|frame| render(frame, &screen, prog, ®istry, 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, ®istry)? {
|
||||||
// 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, ®istry)? {
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user