use std::io;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Position};
use ratatui::style::{Style, Stylize};
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Paragraph, Tabs, Wrap};
use ratatui::{DefaultTerminal, Frame};
use crate::clipboard;
use ikigai_engine::config::Keybindings;
use ikigai_engine::{Action, CacheStats, Engine, Entry, HELP};
const SCROLL_STEP: u16 = 5;
pub fn run(engine: Engine, keys: Keybindings) -> io::Result<()> {
let mut terminal = ratatui::init();
let result = event_loop(&mut terminal, &engine, keys);
ratatui::restore();
result
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Edit {
Insert(char),
DeleteLeft, DeleteRight, Left,
Right,
WordLeft,
WordRight,
Home,
End,
KillToEnd, KillToStart, SetMark, Copy, Cut, Yank, HistoryPrev,
HistoryNext,
ScrollUp,
ScrollDown,
Clear, ViInsert, ViAppend, ViInsertHome, ViAppendEnd, ViChangeToEnd, ViNormal, ViWordFwd, ViWordEnd, ViOperator(ViOp), ViMotionApply(ViMotion), Submit,
Quit,
Ignore,
}
#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
enum ViMode {
#[default]
Insert,
Normal,
Operator(ViOp),
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ViOp {
Delete,
Change,
Yank,
}
impl ViOp {
fn key(self) -> char {
match self {
ViOp::Delete => 'd',
ViOp::Change => 'c',
ViOp::Yank => 'y',
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ViMotion {
WordFwd, WordBack, WordEnd, LineStart, LineEnd, WholeLine, }
#[derive(Default)]
struct State {
input: String,
cursor: usize,
kill: String,
mark: Option<usize>,
keys: Keybindings,
vi_mode: ViMode,
transcript: Vec<Entry>,
history: Vec<String>,
history_pos: Option<usize>,
scroll_back: u16,
tab: usize,
demos: Vec<DemoData>,
demo_out: String,
docs: String,
}
struct StepData {
label: String,
cmd: String,
note: String,
}
struct DemoData {
label: String,
intro: String,
steps: Vec<StepData>,
}
impl State {
fn region(&self) -> Option<(usize, usize)> {
let mark = self.mark?.min(self.input.len());
Some((mark.min(self.cursor), mark.max(self.cursor)))
}
}
fn event_loop(
terminal: &mut DefaultTerminal,
engine: &Engine,
keys: Keybindings,
) -> io::Result<()> {
let mut state = State {
keys,
..State::default()
};
if ikigai_embedded::history_flag().load(std::sync::atomic::Ordering::Relaxed) {
state.history = ikigai_embedded::load_history();
}
loop {
let demo_on = ikigai_embedded::demo_flag().load(std::sync::atomic::Ordering::Relaxed);
if demo_on && state.demos.is_empty() {
load_demos(&mut state, engine);
} else if !demo_on && !state.demos.is_empty() {
state.demos.clear();
state.docs.clear();
state.tab = 0;
state.demo_out.clear();
}
terminal.draw(|frame| draw(frame, &state))?;
if !event::poll(std::time::Duration::from_millis(250))? {
continue;
}
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(());
}
if !state.demos.is_empty() {
let n = state.demos.len() + 2; match key.code {
KeyCode::Tab => {
state.tab = (state.tab + 1) % n;
state.demo_out.clear();
state.scroll_back = 0; continue;
}
KeyCode::BackTab => {
state.tab = (state.tab + n - 1) % n;
state.demo_out.clear();
state.scroll_back = 0;
continue;
}
_ => {}
}
if state.tab == 1 {
match key.code {
KeyCode::PageDown => {
state.scroll_back = state.scroll_back.saturating_add(SCROLL_STEP);
}
KeyCode::PageUp => {
state.scroll_back = state.scroll_back.saturating_sub(SCROLL_STEP);
}
KeyCode::Esc => {
state.tab = 0;
state.scroll_back = 0;
}
_ => {}
}
continue;
}
if state.tab > 1 {
match key.code {
KeyCode::Char(c @ '1'..='9') => {
run_step(&mut state, engine, c as usize - '1' as usize);
}
KeyCode::Char('0') => run_step(&mut state, engine, 9),
KeyCode::Esc => {
state.tab = 0;
state.demo_out.clear();
}
_ => {}
}
continue;
}
}
match decode(key, &state) {
Edit::Quit => return Ok(()),
Edit::Submit if submit(&mut state, engine) => return Ok(()),
Edit::Submit => {}
action => apply(&mut state, action),
}
}
}
}
fn load_demos(state: &mut State, engine: &Engine) {
let Some(entries) = engine.entries() else {
return;
};
for entry in entries {
let Some(id) = entry.pattern.strip_prefix("urn:runbook:") else {
continue;
};
let Action::Output(out) =
engine.eval(&format!("source urn:runbook:{id} as=application/json"))
else {
continue;
};
let Ok(json) = out.result else { continue };
let Ok(value) = serde_json::from_str::<serde_json::Value>(&json) else {
continue;
};
let steps = value["steps"]
.as_array()
.map(|arr| {
arr.iter()
.map(|s| StepData {
label: s["label"].as_str().unwrap_or_default().to_string(),
cmd: s["cmd"].as_str().unwrap_or_default().to_string(),
note: s["note"].as_str().unwrap_or_default().to_string(),
})
.collect()
})
.unwrap_or_default();
state.demos.push(DemoData {
label: value["label"].as_str().unwrap_or(id).to_string(),
intro: value["intro"].as_str().unwrap_or_default().to_string(),
steps,
});
}
state.docs = match engine.eval(
"source urn:kernel:catalog | urn:rdf:transrept as=application/rdf+xml \
| urn:xslt:transform stylesheet=urn:style:catalog as=text/plain",
) {
Action::Output(out) => out.result.unwrap_or_else(|e| format!("error: {e}")),
_ => String::new(),
};
}
fn run_step(state: &mut State, engine: &Engine, idx: usize) {
let Some(demo) = state.demos.get(state.tab.wrapping_sub(2)) else {
return;
};
let Some(step) = demo.steps.get(idx) else {
return;
};
let cmd = step.cmd.clone();
let out = match engine.eval(&cmd) {
Action::Output(entry) => match entry.result {
Ok(text) => {
let tag = entry
.cache
.label()
.map(|l| format!(" [{l}]"))
.unwrap_or_default();
format!("{text}{tag}")
}
Err(e) => format!("error: {e}"),
},
Action::Help => HELP.to_string(),
Action::Clear => {
state.transcript.clear();
String::new()
}
Action::Quit | Action::Noop => String::new(),
};
state.demo_out = format!("$ {cmd}\n{out}");
}
fn decode(key: KeyEvent, state: &State) -> Edit {
match state.keys {
Keybindings::Emacs | Keybindings::Native => emacs(key, state.input.is_empty()),
Keybindings::Vi => vi(key, state.vi_mode),
}
}
fn vi(key: KeyEvent, mode: ViMode) -> Edit {
match mode {
ViMode::Insert => vi_insert(key),
ViMode::Normal => vi_normal(key),
ViMode::Operator(op) => vi_pending(key, op),
}
}
fn vi_insert(key: KeyEvent) -> Edit {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
match key.code {
KeyCode::Esc => Edit::ViNormal,
KeyCode::Char('c') if ctrl => Edit::Quit,
KeyCode::Char('w') if ctrl => Edit::Cut,
KeyCode::Char('u') if ctrl => Edit::KillToStart,
KeyCode::Char(c) if !ctrl && !alt => Edit::Insert(c),
KeyCode::Backspace => Edit::DeleteLeft,
KeyCode::Delete => Edit::DeleteRight,
KeyCode::Left => Edit::Left,
KeyCode::Right => Edit::Right,
KeyCode::Home => Edit::Home,
KeyCode::End => Edit::End,
KeyCode::Up => Edit::HistoryPrev,
KeyCode::Down => Edit::HistoryNext,
KeyCode::PageUp => Edit::ScrollUp,
KeyCode::PageDown => Edit::ScrollDown,
KeyCode::Enter => Edit::Submit,
_ => Edit::Ignore,
}
}
fn vi_normal(key: KeyEvent) -> Edit {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('c') if ctrl => Edit::Quit,
KeyCode::Char('i') => Edit::ViInsert,
KeyCode::Char('a') => Edit::ViAppend,
KeyCode::Char('I') => Edit::ViInsertHome,
KeyCode::Char('A') => Edit::ViAppendEnd,
KeyCode::Char('C') => Edit::ViChangeToEnd,
KeyCode::Char('h') | KeyCode::Left => Edit::Left,
KeyCode::Char('l') | KeyCode::Right => Edit::Right,
KeyCode::Char('w') => Edit::ViWordFwd,
KeyCode::Char('e') => Edit::ViWordEnd,
KeyCode::Char('b') => Edit::WordLeft,
KeyCode::Char('0') => Edit::Home,
KeyCode::Char('$') => Edit::End,
KeyCode::Char('x') | KeyCode::Delete => Edit::DeleteRight,
KeyCode::Char('X') => Edit::DeleteLeft,
KeyCode::Char('D') => Edit::KillToEnd,
KeyCode::Char('d') => Edit::ViOperator(ViOp::Delete),
KeyCode::Char('c') => Edit::ViOperator(ViOp::Change),
KeyCode::Char('y') => Edit::ViOperator(ViOp::Yank),
KeyCode::Char('p') | KeyCode::Char('P') => Edit::Yank,
KeyCode::Char('j') | KeyCode::Down => Edit::HistoryNext,
KeyCode::Char('k') | KeyCode::Up => Edit::HistoryPrev,
KeyCode::Backspace => Edit::Left, KeyCode::PageUp => Edit::ScrollUp,
KeyCode::PageDown => Edit::ScrollDown,
KeyCode::Enter => Edit::Submit,
_ => Edit::Ignore,
}
}
fn vi_pending(key: KeyEvent, op: ViOp) -> Edit {
match key.code {
KeyCode::Char(c) if c == op.key() => Edit::ViMotionApply(ViMotion::WholeLine),
KeyCode::Char('w') => Edit::ViMotionApply(ViMotion::WordFwd),
KeyCode::Char('b') => Edit::ViMotionApply(ViMotion::WordBack),
KeyCode::Char('e') => Edit::ViMotionApply(ViMotion::WordEnd),
KeyCode::Char('0') => Edit::ViMotionApply(ViMotion::LineStart),
KeyCode::Char('$') => Edit::ViMotionApply(ViMotion::LineEnd),
_ => Edit::ViNormal, }
}
fn emacs(key: KeyEvent, input_empty: bool) -> Edit {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
match key.code {
KeyCode::Char('c') if ctrl => Edit::Quit,
KeyCode::Char('d') if ctrl => {
if input_empty {
Edit::Quit
} else {
Edit::DeleteRight
}
}
KeyCode::Char('a') if ctrl => Edit::Home,
KeyCode::Char('e') if ctrl => Edit::End,
KeyCode::Char('f') if ctrl => Edit::Right,
KeyCode::Char('b') if ctrl => Edit::Left,
KeyCode::Char('f') if alt => Edit::WordRight,
KeyCode::Char('b') if alt => Edit::WordLeft,
KeyCode::Char('p') if ctrl => Edit::HistoryPrev,
KeyCode::Char('n') if ctrl => Edit::HistoryNext,
KeyCode::Char('k') if ctrl => Edit::KillToEnd,
KeyCode::Char('u') if ctrl => Edit::KillToStart,
KeyCode::Char('w') if ctrl => Edit::Cut,
KeyCode::Char('w') if alt => Edit::Copy,
KeyCode::Char('y') if ctrl => Edit::Yank,
KeyCode::Char('@') if ctrl => Edit::SetMark,
KeyCode::Char(' ') if ctrl => Edit::SetMark,
KeyCode::Null => Edit::SetMark,
KeyCode::Char(c) if !ctrl && !alt => Edit::Insert(c),
KeyCode::Backspace => Edit::DeleteLeft,
KeyCode::Delete => Edit::DeleteRight,
KeyCode::Left => Edit::Left,
KeyCode::Right => Edit::Right,
KeyCode::Home => Edit::Home,
KeyCode::End => Edit::End,
KeyCode::Up => Edit::HistoryPrev,
KeyCode::Down => Edit::HistoryNext,
KeyCode::PageUp => Edit::ScrollUp,
KeyCode::PageDown => Edit::ScrollDown,
KeyCode::Esc => Edit::Clear,
KeyCode::Enter => Edit::Submit,
_ => Edit::Ignore,
}
}
fn apply(state: &mut State, action: Edit) {
if action == Edit::Yank {
if let Some(text) = clipboard::paste() {
state.kill = text;
}
}
let before = state.kill.clone();
edit(state, action);
if state.kill != before {
clipboard::copy(&state.kill);
}
}
fn edit(state: &mut State, action: Edit) {
match action {
Edit::Insert(c) => {
state.input.insert(state.cursor, c);
state.cursor += c.len_utf8();
state.mark = None;
}
Edit::DeleteLeft => {
if state.cursor > 0 {
let from = prev_boundary(&state.input, state.cursor);
state.input.replace_range(from..state.cursor, "");
state.cursor = from;
}
state.mark = None;
}
Edit::DeleteRight => {
let to = next_boundary(&state.input, state.cursor);
state.input.replace_range(state.cursor..to, "");
state.mark = None;
}
Edit::Left => state.cursor = prev_boundary(&state.input, state.cursor),
Edit::Right => state.cursor = next_boundary(&state.input, state.cursor),
Edit::WordLeft => state.cursor = word_left(&state.input, state.cursor),
Edit::WordRight => state.cursor = word_right(&state.input, state.cursor),
Edit::Home => state.cursor = 0,
Edit::End => state.cursor = state.input.len(),
Edit::KillToEnd => kill(state, state.cursor, state.input.len()),
Edit::KillToStart => kill(state, 0, state.cursor),
Edit::SetMark => state.mark = Some(state.cursor),
Edit::Copy => {
if let Some((lo, hi)) = state.region() {
state.kill = state.input[lo..hi].to_string();
}
state.mark = None;
}
Edit::Cut => match state.region() {
Some((lo, hi)) => kill(state, lo, hi),
None => kill(state, word_left(&state.input, state.cursor), state.cursor),
},
Edit::Yank => {
let yanked = state.kill.clone();
state.input.insert_str(state.cursor, &yanked);
state.cursor += yanked.len();
state.mark = None;
}
Edit::HistoryPrev => recall(state, -1),
Edit::HistoryNext => recall(state, 1),
Edit::ScrollUp => state.scroll_back = state.scroll_back.saturating_add(SCROLL_STEP),
Edit::ScrollDown => state.scroll_back = state.scroll_back.saturating_sub(SCROLL_STEP),
Edit::Clear => {
state.input.clear();
state.cursor = 0;
state.mark = None;
}
Edit::ViInsert => state.vi_mode = ViMode::Insert,
Edit::ViNormal => state.vi_mode = ViMode::Normal,
Edit::ViAppend => {
state.cursor = next_boundary(&state.input, state.cursor);
state.vi_mode = ViMode::Insert;
}
Edit::ViInsertHome => {
state.cursor = 0;
state.vi_mode = ViMode::Insert;
}
Edit::ViAppendEnd => {
state.cursor = state.input.len();
state.vi_mode = ViMode::Insert;
}
Edit::ViChangeToEnd => {
kill(state, state.cursor, state.input.len());
state.vi_mode = ViMode::Insert;
}
Edit::ViWordFwd => state.cursor = vi_word_forward(&state.input, state.cursor),
Edit::ViWordEnd => state.cursor = vi_word_end(&state.input, state.cursor),
Edit::ViOperator(op) => state.vi_mode = ViMode::Operator(op),
Edit::ViMotionApply(motion) => {
if let ViMode::Operator(op) = state.vi_mode {
let (lo, hi) = vi_range(&state.input, state.cursor, op, motion);
match op {
ViOp::Delete => {
kill(state, lo, hi);
state.vi_mode = ViMode::Normal;
}
ViOp::Change => {
kill(state, lo, hi);
state.vi_mode = ViMode::Insert;
}
ViOp::Yank => {
state.kill = state.input[lo..hi].to_string();
state.cursor = lo;
state.mark = None;
state.vi_mode = ViMode::Normal;
}
}
}
}
Edit::Submit | Edit::Quit | Edit::Ignore => {}
}
}
fn vi_range(input: &str, cursor: usize, op: ViOp, motion: ViMotion) -> (usize, usize) {
match motion {
ViMotion::WordFwd if matches!(op, ViOp::Change) => (cursor, word_right(input, cursor)),
ViMotion::WordFwd => (cursor, vi_word_forward(input, cursor)),
ViMotion::WordEnd => (cursor, word_right(input, cursor)),
ViMotion::LineEnd => (cursor, input.len()),
ViMotion::WordBack => (word_left(input, cursor), cursor),
ViMotion::LineStart => (0, cursor),
ViMotion::WholeLine => (0, input.len()),
}
}
fn vi_word_forward(s: &str, cursor: usize) -> usize {
let next = |i: usize| s[i..].chars().next().map(|c| (i + c.len_utf8(), c));
let mut i = cursor;
while let Some((n, c)) = next(i) {
if c.is_whitespace() {
break;
}
i = n;
}
while let Some((n, c)) = next(i) {
if c.is_whitespace() {
i = n;
} else {
break;
}
}
i
}
fn vi_word_end(s: &str, cursor: usize) -> usize {
let end = word_right(s, cursor);
if end > cursor {
prev_boundary(s, end)
} else {
cursor
}
}
fn kill(state: &mut State, lo: usize, hi: usize) {
state.kill = state.input[lo..hi].to_string();
state.input.replace_range(lo..hi, "");
state.cursor = lo;
state.mark = None;
}
fn prev_boundary(s: &str, cursor: usize) -> usize {
s[..cursor]
.chars()
.next_back()
.map_or(cursor, |c| cursor - c.len_utf8())
}
fn next_boundary(s: &str, cursor: usize) -> usize {
s[cursor..]
.chars()
.next()
.map_or(cursor, |c| cursor + c.len_utf8())
}
fn word_left(s: &str, cursor: usize) -> usize {
let prev = |i: usize| s[..i].chars().next_back().map(|c| (i - c.len_utf8(), c));
let mut i = cursor;
while let Some((p, c)) = prev(i) {
if c.is_whitespace() {
i = p;
} else {
break;
}
}
while let Some((p, c)) = prev(i) {
if c.is_whitespace() {
break;
}
i = p;
}
i
}
fn word_right(s: &str, cursor: usize) -> usize {
let next = |i: usize| s[i..].chars().next().map(|c| (i + c.len_utf8(), c));
let mut i = cursor;
while let Some((n, c)) = next(i) {
if c.is_whitespace() {
i = n;
} else {
break;
}
}
while let Some((n, c)) = next(i) {
if c.is_whitespace() {
break;
}
i = n;
}
i
}
fn submit(state: &mut State, engine: &Engine) -> bool {
let line = std::mem::take(&mut state.input);
state.cursor = 0;
state.mark = None;
state.vi_mode = ViMode::default(); state.history_pos = None;
state.scroll_back = 0;
if !line.trim().is_empty() {
state.history.push(line.clone());
ikigai_embedded::append_history(&line);
}
match engine.eval(&line) {
Action::Quit => return true,
Action::Help => state.transcript.push(Entry {
input: line,
result: Ok(HELP.to_string()),
cache: CacheStats::default(),
}),
Action::Output(entry) => state.transcript.push(entry),
Action::Clear => state.transcript.clear(),
Action::Noop => {}
}
false
}
fn recall(state: &mut State, dir: i32) {
if state.history.is_empty() {
return;
}
let last = state.history.len() - 1;
let next = match (state.history_pos, dir) {
(None, d) if d < 0 => Some(last), (None, _) => None, (Some(p), d) if d < 0 => Some(p.saturating_sub(1)),
(Some(p), _) if p < last => Some(p + 1), (Some(_), _) => None, };
state.history_pos = next;
state.input = next.map(|p| state.history[p].clone()).unwrap_or_default();
state.cursor = state.input.len(); state.mark = None; }
fn draw(frame: &mut Frame, state: &State) {
let chunks = Layout::vertical([
Constraint::Length(1), Constraint::Min(1), Constraint::Length(3), ])
.split(frame.area());
if state.demos.is_empty() {
let title = Line::from(vec![
format!("ikigai {} ", env!("CARGO_PKG_VERSION")).bold(),
"— resource-resolution REPL".into(),
format!(
" (help · demo on → guided tabs · ↑↓ history · {} · PgUp/PgDn scroll · Ctrl-C exit)",
mode_label(state)
)
.dim(),
]);
frame.render_widget(Paragraph::new(title), chunks[0]);
} else {
let mut titles = vec![Line::from("REPL"), Line::from("Docs")];
titles.extend(state.demos.iter().map(|d| Line::from(d.label.clone())));
let tabs = Tabs::new(titles)
.select(state.tab)
.highlight_style(Style::new().reversed())
.divider(" ");
frame.render_widget(tabs, chunks[0]);
}
if state.tab == 0 {
let lines = transcript_lines(&state.transcript);
let bottom = (lines.len() as u16).saturating_sub(chunks[1].height);
let scroll_y = bottom.saturating_sub(state.scroll_back);
frame.render_widget(Paragraph::new(lines).scroll((scroll_y, 0)), chunks[1]);
} else if state.tab == 1 {
let lines = docs_lines(&state.docs);
let max = (lines.len() as u16).saturating_sub(chunks[1].height);
let scroll_y = state.scroll_back.min(max);
frame.render_widget(Paragraph::new(lines).scroll((scroll_y, 0)), chunks[1]);
} else if let Some(demo) = state.demos.get(state.tab - 2) {
let page = Paragraph::new(demo_lines(demo, &state.demo_out)).wrap(Wrap { trim: false });
frame.render_widget(page, chunks[1]);
}
if state.tab == 0 {
let input = Paragraph::new(state.input.as_str())
.block(Block::default().borders(Borders::ALL).title(" request "));
frame.render_widget(input, chunks[2]);
let col = state.input[..state.cursor].chars().count() as u16;
let cursor_x = (chunks[2].x + 1 + col).min(chunks[2].x + chunks[2].width.saturating_sub(1));
frame.set_cursor_position(Position::new(cursor_x, chunks[2].y + 1));
} else if state.tab == 1 {
let hint = Paragraph::new(
"PgUp/PgDn scroll · Tab/⇧Tab switch · Esc back to REPL · Ctrl-C exit".dim(),
)
.block(Block::default().borders(Borders::ALL).title(" docs "));
frame.render_widget(hint, chunks[2]);
} else {
let hint = Paragraph::new(
"1–9 run a step · Tab/⇧Tab switch · Esc back to REPL · Ctrl-C exit".dim(),
)
.block(Block::default().borders(Borders::ALL).title(" runbook "));
frame.render_widget(hint, chunks[2]);
}
}
fn docs_lines(docs: &str) -> Vec<Line<'static>> {
let mut lines = vec![
Line::from("the catalog · every bound endpoint, as cards".bold()),
Line::from("urn:kernel:catalog | urn:rdf:transrept as=rdf/xml | urn:xslt:transform".cyan()),
Line::from(""),
];
if docs.trim().is_empty() {
lines.push(Line::from("(catalog unavailable)".dim()));
} else {
for l in docs.lines() {
let is_title = l.starts_with("│ ") && !l.starts_with("│ ");
if is_title {
lines.push(Line::from(l.to_string().cyan().bold()));
} else {
lines.push(Line::from(l.to_string().dim()));
}
}
}
lines
}
fn demo_lines(demo: &DemoData, out: &str) -> Vec<Line<'static>> {
let mut lines = vec![
Line::from(demo.intro.clone()),
Line::from(""),
Line::from("steps:".bold()),
];
for (i, step) in demo.steps.iter().enumerate() {
let key = if i < 9 { (b'1' + i as u8) as char } else { '0' };
lines.push(Line::from(vec![
format!(" {key}. ").bold(),
step.label.clone().bold(),
]));
lines.push(Line::from(format!(" {}", step.cmd).cyan()));
lines.push(Line::from(format!(" — {}", step.note).dim()));
}
if !out.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from("─ output ─".dim()));
for l in out.lines() {
lines.push(Line::from(l.to_string().green()));
}
}
lines
}
fn mode_label(state: &State) -> String {
match state.keys {
Keybindings::Emacs => "emacs keys".to_string(),
Keybindings::Native => "native keys".to_string(),
Keybindings::Vi => {
let mode = match state.vi_mode {
ViMode::Insert => "insert".to_string(),
ViMode::Normal => "normal".to_string(),
ViMode::Operator(op) => format!("normal {}", op.key()),
};
format!("vi · {mode}")
}
}
}
fn transcript_lines(transcript: &[Entry]) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for entry in transcript {
let mut prompt = vec![format!("ikigai> {}", entry.input).cyan()];
if let Some(label) = entry.cache.label() {
prompt.push(format!(" ({label})").dim());
}
lines.push(Line::from(prompt));
match &entry.result {
Ok(out) => lines.extend(out.lines().map(|l| Line::from(l.to_string().green()))),
Err(err) => lines.push(Line::from(format!("error: {err}").red())),
}
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn render(width: u16, height: u16, state: &State) {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal.draw(|frame| draw(frame, state)).unwrap();
}
#[test]
fn draws_without_panicking() {
let mut state = State::default();
render(80, 24, &state); render(1, 1, &state);
state.input = "source urn:fn:toUpper hi".into();
state.cursor = 7; state.transcript.push(Entry {
input: "source urn:fn:toUpper hi".into(),
result: Ok("line one\nline two".into()),
cache: CacheStats::default(),
});
state.transcript.push(Entry {
input: "source urn:fn:nope".into(),
result: Err("no endpoint resolved".into()),
cache: CacheStats::default(),
});
render(80, 5, &state); render(80, 24, &state);
state.input = "x".repeat(200);
state.cursor = state.input.len();
render(40, 24, &state);
}
#[test]
fn draws_demo_tabs_without_panicking() {
let mut state = State::default();
state.demos.push(DemoData {
label: "Basics".into(),
intro: "A resource is resolved by name; functions are resources too.".into(),
steps: vec![
StepData {
label: "uppercase".into(),
cmd: "source urn:fn:toUpper hello".into(),
note: "a function resource".into(),
},
StepData {
label: "pipe".into(),
cmd: "source urn:fn:toUpper hi | urn:demo:wrap".into(),
note: "pipe output into the next stage".into(),
},
],
});
state.tab = 0; render(80, 24, &state);
render(1, 1, &state);
state.tab = 1; render(80, 24, &state);
state.demo_out = "$ source urn:fn:toUpper hello\nHELLO".into();
render(80, 24, &state); render(20, 6, &state); }
fn state_with(input: &str, cursor: usize) -> State {
State {
input: input.to_string(),
cursor,
..State::default()
}
}
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, mods)
}
#[test]
fn inserts_and_deletes_at_the_cursor() {
let mut s = state_with("ac", 1);
edit(&mut s, Edit::Insert('b'));
assert_eq!((s.input.as_str(), s.cursor), ("abc", 2));
edit(&mut s, Edit::Home);
edit(&mut s, Edit::Right);
edit(&mut s, Edit::DeleteRight); assert_eq!((s.input.as_str(), s.cursor), ("ac", 1));
edit(&mut s, Edit::DeleteLeft); assert_eq!((s.input.as_str(), s.cursor), ("c", 0));
edit(&mut s, Edit::DeleteLeft); assert_eq!((s.input.as_str(), s.cursor), ("c", 0));
edit(&mut s, Edit::End);
assert_eq!(s.cursor, 1);
}
#[test]
fn kills_to_end_start_and_word_into_the_buffer() {
let mut s = state_with("foo bar baz", 8);
edit(&mut s, Edit::KillToEnd);
assert_eq!(s.input, "foo bar ");
assert_eq!(s.kill, "baz");
let mut s = state_with("foo bar", 4);
edit(&mut s, Edit::KillToStart);
assert_eq!(
(s.input.as_str(), s.cursor, s.kill.as_str()),
("bar", 0, "foo ")
);
let mut s = state_with("foo bar", 7);
edit(&mut s, Edit::Cut);
assert_eq!(
(s.input.as_str(), s.cursor, s.kill.as_str()),
("foo ", 4, "bar")
);
}
#[test]
fn yank_pastes_the_kill_buffer_at_the_cursor() {
let mut s = state_with("foo bar", 7);
edit(&mut s, Edit::Cut); edit(&mut s, Edit::Home);
edit(&mut s, Edit::Yank); assert_eq!((s.input.as_str(), s.cursor), ("barfoo ", 3));
}
#[test]
fn copy_takes_the_region_without_deleting() {
let mut s = state_with("hello world", 0);
edit(&mut s, Edit::SetMark); edit(&mut s, Edit::WordRight); edit(&mut s, Edit::Copy);
assert_eq!(s.input, "hello world"); assert_eq!(s.kill, "hello");
assert_eq!(s.mark, None); }
#[test]
fn cut_removes_the_region_when_a_mark_is_set() {
let mut s = state_with("hello world", 11);
edit(&mut s, Edit::SetMark); edit(&mut s, Edit::WordLeft); edit(&mut s, Edit::Cut);
assert_eq!(
(s.input.as_str(), s.cursor, s.kill.as_str()),
("hello ", 6, "world")
);
}
#[test]
fn editing_text_clears_the_mark() {
let mut s = state_with("abc", 0);
edit(&mut s, Edit::SetMark);
edit(&mut s, Edit::Insert('x'));
assert_eq!(s.mark, None);
assert!(s.region().is_none());
}
#[test]
fn moves_by_word_over_runs_of_spaces() {
let mut s = state_with("foo bar", 0);
edit(&mut s, Edit::WordRight);
assert_eq!(s.cursor, 3); edit(&mut s, Edit::WordRight);
assert_eq!(s.cursor, 8); edit(&mut s, Edit::WordLeft);
assert_eq!(s.cursor, 5); }
#[test]
fn edits_respect_utf8_boundaries() {
let mut s = state_with("", 0);
edit(&mut s, Edit::Insert('é')); assert_eq!(s.cursor, 2);
edit(&mut s, Edit::Insert('x'));
assert_eq!((s.input.as_str(), s.cursor), ("éx", 3));
edit(&mut s, Edit::Left);
assert_eq!(s.cursor, 2);
edit(&mut s, Edit::DeleteLeft); assert_eq!((s.input.as_str(), s.cursor), ("x", 0));
}
#[test]
fn emacs_maps_the_core_motions() {
let c = KeyModifiers::CONTROL;
let a = KeyModifiers::ALT;
assert_eq!(emacs(key(KeyCode::Char('a'), c), false), Edit::Home);
assert_eq!(emacs(key(KeyCode::Char('e'), c), false), Edit::End);
assert_eq!(emacs(key(KeyCode::Char('f'), c), false), Edit::Right);
assert_eq!(emacs(key(KeyCode::Char('b'), c), false), Edit::Left);
assert_eq!(emacs(key(KeyCode::Char('f'), a), false), Edit::WordRight);
assert_eq!(emacs(key(KeyCode::Char('b'), a), false), Edit::WordLeft);
assert_eq!(emacs(key(KeyCode::Char('p'), c), false), Edit::HistoryPrev);
assert_eq!(emacs(key(KeyCode::Char('n'), c), false), Edit::HistoryNext);
assert_eq!(
emacs(key(KeyCode::Char('x'), KeyModifiers::NONE), false),
Edit::Insert('x')
);
}
#[test]
fn emacs_maps_the_kill_ring() {
let c = KeyModifiers::CONTROL;
let a = KeyModifiers::ALT;
assert_eq!(emacs(key(KeyCode::Char('y'), c), false), Edit::Yank);
assert_eq!(emacs(key(KeyCode::Char('w'), c), false), Edit::Cut);
assert_eq!(emacs(key(KeyCode::Char('w'), a), false), Edit::Copy);
assert_eq!(emacs(key(KeyCode::Char('k'), c), false), Edit::KillToEnd);
assert_eq!(emacs(key(KeyCode::Char(' '), c), false), Edit::SetMark);
assert_eq!(emacs(key(KeyCode::Char('@'), c), false), Edit::SetMark);
assert_eq!(
emacs(key(KeyCode::Null, KeyModifiers::NONE), false),
Edit::SetMark
);
}
#[test]
fn emacs_ctrl_d_is_eof_only_on_an_empty_line() {
let c = KeyModifiers::CONTROL;
assert_eq!(emacs(key(KeyCode::Char('d'), c), true), Edit::Quit);
assert_eq!(emacs(key(KeyCode::Char('d'), c), false), Edit::DeleteRight);
assert_eq!(emacs(key(KeyCode::Char('c'), c), false), Edit::Quit);
}
fn vi_key(c: char) -> KeyEvent {
key(KeyCode::Char(c), KeyModifiers::NONE)
}
#[test]
fn vi_normal_maps_motions_and_edits() {
use ViMode::Normal;
assert_eq!(vi(vi_key('h'), Normal), Edit::Left);
assert_eq!(vi(vi_key('l'), Normal), Edit::Right);
assert_eq!(vi(vi_key('w'), Normal), Edit::ViWordFwd);
assert_eq!(vi(vi_key('e'), Normal), Edit::ViWordEnd);
assert_eq!(vi(vi_key('b'), Normal), Edit::WordLeft);
assert_eq!(vi(vi_key('0'), Normal), Edit::Home);
assert_eq!(vi(vi_key('$'), Normal), Edit::End);
assert_eq!(vi(vi_key('x'), Normal), Edit::DeleteRight);
assert_eq!(vi(vi_key('X'), Normal), Edit::DeleteLeft);
assert_eq!(vi(vi_key('D'), Normal), Edit::KillToEnd);
assert_eq!(vi(vi_key('p'), Normal), Edit::Yank);
assert_eq!(vi(vi_key('j'), Normal), Edit::HistoryNext);
assert_eq!(vi(vi_key('k'), Normal), Edit::HistoryPrev);
assert_eq!(vi(vi_key('z'), Normal), Edit::Ignore);
}
#[test]
fn vi_mode_switch_keys_enter_insert() {
use ViMode::Normal;
assert_eq!(vi(vi_key('i'), Normal), Edit::ViInsert);
assert_eq!(vi(vi_key('a'), Normal), Edit::ViAppend);
assert_eq!(vi(vi_key('I'), Normal), Edit::ViInsertHome);
assert_eq!(vi(vi_key('A'), Normal), Edit::ViAppendEnd);
assert_eq!(vi(vi_key('C'), Normal), Edit::ViChangeToEnd);
}
#[test]
fn vi_insert_types_until_escape() {
use ViMode::Insert;
assert_eq!(vi(vi_key('x'), Insert), Edit::Insert('x'));
assert_eq!(
vi(key(KeyCode::Esc, KeyModifiers::NONE), Insert),
Edit::ViNormal
);
assert_eq!(
vi(key(KeyCode::Char('w'), KeyModifiers::CONTROL), Insert),
Edit::Cut
);
assert_eq!(
vi(key(KeyCode::Backspace, KeyModifiers::NONE), Insert),
Edit::DeleteLeft
);
}
#[test]
fn vi_mode_transitions_move_and_switch() {
let mut s = state_with("ab", 0);
s.keys = Keybindings::Vi;
s.vi_mode = ViMode::Normal;
edit(&mut s, Edit::ViAppend);
assert_eq!((s.cursor, s.vi_mode), (1, ViMode::Insert));
let mut s = state_with("hello", 2);
edit(&mut s, Edit::ViAppendEnd);
assert_eq!((s.cursor, s.vi_mode), (5, ViMode::Insert));
edit(&mut s, Edit::ViNormal);
edit(&mut s, Edit::ViInsertHome);
assert_eq!((s.cursor, s.vi_mode), (0, ViMode::Insert));
let mut s = state_with("hello", 2);
edit(&mut s, Edit::ViChangeToEnd);
assert_eq!(
(s.input.as_str(), s.kill.as_str(), s.vi_mode),
("he", "llo", ViMode::Insert)
);
}
#[test]
fn decode_dispatches_by_scheme() {
let mut s = State {
keys: Keybindings::Native,
..State::default()
};
assert_eq!(
decode(key(KeyCode::Char('a'), KeyModifiers::CONTROL), &s),
Edit::Home
);
s.keys = Keybindings::Vi;
s.vi_mode = ViMode::Normal;
assert_eq!(decode(vi_key('x'), &s), Edit::DeleteRight);
s.vi_mode = ViMode::Insert;
assert_eq!(decode(vi_key('x'), &s), Edit::Insert('x'));
}
fn vi_keys(input: &str, cursor: usize, chars: &str) -> State {
let mut s = state_with(input, cursor);
s.keys = Keybindings::Vi;
s.vi_mode = ViMode::Normal;
for ch in chars.chars() {
let action = decode(vi_key(ch), &s);
edit(&mut s, action);
}
s
}
#[test]
fn vi_operator_pending_decodes_motions() {
let pending = State {
keys: Keybindings::Vi,
vi_mode: ViMode::Operator(ViOp::Delete),
..State::default()
};
assert_eq!(
decode(vi_key('d'), &state_normal()),
Edit::ViOperator(ViOp::Delete)
);
assert_eq!(
decode(vi_key('w'), &pending),
Edit::ViMotionApply(ViMotion::WordFwd)
);
assert_eq!(
decode(vi_key('d'), &pending),
Edit::ViMotionApply(ViMotion::WholeLine)
);
assert_eq!(decode(vi_key('z'), &pending), Edit::ViNormal);
}
fn state_normal() -> State {
State {
keys: Keybindings::Vi,
vi_mode: ViMode::Normal,
..State::default()
}
}
#[test]
fn vi_delete_operator_spans_the_motion() {
let s = vi_keys("foo bar", 0, "dw");
assert_eq!(
(s.input.as_str(), s.cursor, s.kill.as_str()),
("bar", 0, "foo ")
);
let s = vi_keys("foo bar", 7, "db");
assert_eq!((s.input.as_str(), s.kill.as_str()), ("foo ", "bar"));
let s = vi_keys("hello world", 6, "d$");
assert_eq!((s.input.as_str(), s.kill.as_str()), ("hello ", "world"));
let s = vi_keys("hello", 2, "dd");
assert_eq!((s.input.as_str(), s.kill.as_str()), ("", "hello"));
}
#[test]
fn vi_change_word_acts_like_change_to_end() {
let s = vi_keys("foo bar", 0, "cw");
assert_eq!(
(s.input.as_str(), s.kill.as_str(), s.vi_mode),
(" bar", "foo", ViMode::Insert)
);
}
#[test]
fn vi_yank_copies_without_deleting() {
let s = vi_keys("foo bar", 0, "yw");
assert_eq!(s.input, "foo bar"); assert_eq!(s.kill, "foo ");
assert_eq!(s.vi_mode, ViMode::Normal);
}
#[test]
fn vi_operator_then_escape_cancels() {
let mut s = state_with("hello", 2);
s.keys = Keybindings::Vi;
s.vi_mode = ViMode::Normal;
let enter = decode(vi_key('d'), &s); edit(&mut s, enter);
assert_eq!(s.vi_mode, ViMode::Operator(ViOp::Delete));
let escape = decode(key(KeyCode::Esc, KeyModifiers::NONE), &s);
edit(&mut s, escape);
assert_eq!((s.input.as_str(), s.vi_mode), ("hello", ViMode::Normal));
}
#[test]
fn vi_word_motions_move_by_word() {
let s = vi_keys("foo bar", 0, "w");
assert_eq!(s.cursor, 4);
let s = vi_keys("foo bar", 0, "e");
assert_eq!(s.cursor, 2); }
#[test]
fn recall_lands_the_cursor_at_the_end() {
let mut s = State {
history: vec!["source x".into()],
..State::default()
};
edit(&mut s, Edit::HistoryPrev);
assert_eq!((s.input.as_str(), s.cursor), ("source x", 8));
}
#[test]
fn history_recall_steps_and_clears() {
let mut state = State {
history: vec!["a".into(), "b".into()],
..State::default()
};
recall(&mut state, -1); assert_eq!(state.input, "b");
recall(&mut state, -1); assert_eq!(state.input, "a");
recall(&mut state, 1); assert_eq!(state.input, "b");
recall(&mut state, 1); assert_eq!(state.input, "");
assert_eq!(state.history_pos, None);
}
}