use std::io;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect};
use ratatui::style::{Style, Stylize};
use ratatui::text::{Line, Span};
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;
const TAB_REPL: usize = 0;
const TAB_SCRATCH: usize = 1;
const TAB_DOCS: usize = 2;
const TAB_CONTROL: usize = 3;
const TAB_DEMO_BASE: usize = 4;
fn demo_index(tab: usize) -> Option<usize> {
tab.checked_sub(TAB_DEMO_BASE)
}
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,
control: String,
clock: String,
scratch: String,
scratch_cursor: usize,
scratch_mark: Option<usize>,
scratch_result: Option<Result<String, String>>,
scratch_cc: bool,
}
struct StepData {
label: String,
cmd: String,
note: String,
}
struct DemoData {
label: String,
intro: String,
steps: Vec<StepData>,
}
impl State {
fn tab_count(&self) -> usize {
if self.demos.is_empty() {
2
} else {
TAB_DEMO_BASE + self.demos.len()
}
}
}
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();
}
let mut last_refresh: Option<std::time::Instant> = None;
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.control.clear();
if state.tab >= state.tab_count() {
state.tab = TAB_REPL;
}
state.demo_out.clear();
}
if last_refresh.is_none_or(|t| t.elapsed() >= std::time::Duration::from_secs(1)) {
state.clock = clock_text(engine);
if state.tab == TAB_CONTROL {
if let Action::Output(out) =
engine.eval("source urn:fn:compose src=urn:data:control")
{
state.control = out.result.unwrap_or_else(|e| format!("error: {e}"));
}
}
last_refresh = Some(std::time::Instant::now());
}
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;
}
match key.code {
KeyCode::Tab => {
state.tab = (state.tab + 1) % state.tab_count();
state.demo_out.clear();
state.scroll_back = 0; continue;
}
KeyCode::BackTab => {
let n = state.tab_count();
state.tab = (state.tab + n - 1) % n;
state.demo_out.clear();
state.scroll_back = 0;
continue;
}
_ => {}
}
if key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& state.tab != TAB_SCRATCH
{
return Ok(());
}
match state.tab {
TAB_SCRATCH => {
if scratch_key(&mut state, engine, key) {
return Ok(());
}
}
TAB_DOCS | TAB_CONTROL => {
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 = TAB_REPL;
state.scroll_back = 0;
}
_ => {}
}
}
t if t >= TAB_DEMO_BASE => {
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 = TAB_REPL;
state.demo_out.clear();
}
_ => {}
}
}
_ => {
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(),
};
state.control = match engine.eval("source urn:fn:compose src=urn:data:control") {
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) = demo_index(state.tab).and_then(|i| state.demos.get(i)) 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 {
decode_line(key, state.keys, state.vi_mode, state.input.is_empty())
}
fn decode_line(key: KeyEvent, keys: Keybindings, vi_mode: ViMode, input_empty: bool) -> Edit {
match keys {
Keybindings::Emacs | Keybindings::Native => emacs(key, input_empty),
Keybindings::Vi => vi(key, 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::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;
}
other => edit_text(
&mut state.input,
&mut state.cursor,
&mut state.mark,
&mut state.kill,
&mut state.vi_mode,
other,
),
}
}
fn edit_text(
text: &mut String,
cursor: &mut usize,
mark: &mut Option<usize>,
kill: &mut String,
vi_mode: &mut ViMode,
action: Edit,
) {
match action {
Edit::Insert(c) => {
text.insert(*cursor, c);
*cursor += c.len_utf8();
*mark = None;
}
Edit::DeleteLeft => {
if *cursor > 0 {
let from = prev_boundary(text, *cursor);
text.replace_range(from..*cursor, "");
*cursor = from;
}
*mark = None;
}
Edit::DeleteRight => {
let to = next_boundary(text, *cursor);
text.replace_range(*cursor..to, "");
*mark = None;
}
Edit::Left => *cursor = prev_boundary(text, *cursor),
Edit::Right => *cursor = next_boundary(text, *cursor),
Edit::WordLeft => *cursor = word_left(text, *cursor),
Edit::WordRight => *cursor = word_right(text, *cursor),
Edit::Home => *cursor = line_start(text, *cursor),
Edit::End => *cursor = line_end(text, *cursor),
Edit::KillToEnd => {
let hi = line_end(text, *cursor);
kill_range(text, cursor, mark, kill, *cursor, hi);
}
Edit::KillToStart => {
let lo = line_start(text, *cursor);
kill_range(text, cursor, mark, kill, lo, *cursor);
}
Edit::SetMark => *mark = Some(*cursor),
Edit::Copy => {
if let Some((lo, hi)) = region(text, *cursor, *mark) {
*kill = text[lo..hi].to_string();
}
*mark = None;
}
Edit::Cut => match region(text, *cursor, *mark) {
Some((lo, hi)) => kill_range(text, cursor, mark, kill, lo, hi),
None => {
let lo = word_left(text, *cursor);
kill_range(text, cursor, mark, kill, lo, *cursor);
}
},
Edit::Yank => {
let yanked = kill.clone();
text.insert_str(*cursor, &yanked);
*cursor += yanked.len();
*mark = None;
}
Edit::ViInsert => *vi_mode = ViMode::Insert,
Edit::ViNormal => *vi_mode = ViMode::Normal,
Edit::ViAppend => {
*cursor = next_boundary(text, *cursor);
*vi_mode = ViMode::Insert;
}
Edit::ViInsertHome => {
*cursor = line_start(text, *cursor);
*vi_mode = ViMode::Insert;
}
Edit::ViAppendEnd => {
*cursor = line_end(text, *cursor);
*vi_mode = ViMode::Insert;
}
Edit::ViChangeToEnd => {
let hi = line_end(text, *cursor);
kill_range(text, cursor, mark, kill, *cursor, hi);
*vi_mode = ViMode::Insert;
}
Edit::ViWordFwd => *cursor = vi_word_forward(text, *cursor),
Edit::ViWordEnd => *cursor = vi_word_end(text, *cursor),
Edit::ViOperator(op) => *vi_mode = ViMode::Operator(op),
Edit::ViMotionApply(motion) => {
if let ViMode::Operator(op) = *vi_mode {
let (lo, hi) = vi_range(text, *cursor, op, motion);
match op {
ViOp::Delete => {
kill_range(text, cursor, mark, kill, lo, hi);
*vi_mode = ViMode::Normal;
}
ViOp::Change => {
kill_range(text, cursor, mark, kill, lo, hi);
*vi_mode = ViMode::Insert;
}
ViOp::Yank => {
*kill = text[lo..hi].to_string();
*cursor = lo;
*mark = None;
*vi_mode = ViMode::Normal;
}
}
}
}
Edit::HistoryPrev
| Edit::HistoryNext
| Edit::ScrollUp
| Edit::ScrollDown
| Edit::Clear
| 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_range(
text: &mut String,
cursor: &mut usize,
mark: &mut Option<usize>,
kill: &mut String,
lo: usize,
hi: usize,
) {
*kill = text[lo..hi].to_string();
text.replace_range(lo..hi, "");
*cursor = lo;
*mark = None;
}
fn region(text: &str, cursor: usize, mark: Option<usize>) -> Option<(usize, usize)> {
let mark = mark?.min(text.len());
Some((mark.min(cursor), mark.max(cursor)))
}
fn line_start(s: &str, cursor: usize) -> usize {
s[..cursor].rfind('\n').map_or(0, |i| i + 1)
}
fn line_end(s: &str, cursor: usize) -> usize {
s[cursor..].find('\n').map_or(s.len(), |i| cursor + i)
}
fn line_up(s: &str, cursor: usize) -> usize {
let start = line_start(s, cursor);
if start == 0 {
return cursor;
}
let col = s[start..cursor].chars().count();
let prev_start = line_start(s, start - 1);
byte_at_col(s, prev_start, start - 1, col)
}
fn line_down(s: &str, cursor: usize) -> usize {
let end = line_end(s, cursor);
if end == s.len() {
return cursor;
}
let col = s[line_start(s, cursor)..cursor].chars().count();
let next_start = end + 1;
byte_at_col(s, next_start, line_end(s, next_start), col)
}
fn byte_at_col(s: &str, start: usize, end: usize, col: usize) -> usize {
let mut i = start;
for _ in 0..col {
if i >= end {
break;
}
i = next_boundary(s, i);
}
i.min(end)
}
fn cursor_row_col(s: &str, cursor: usize) -> (u16, u16) {
let row = s[..cursor].matches('\n').count() as u16;
let col = s[line_start(s, cursor)..cursor].chars().count() as u16;
(row, col)
}
fn scratch_eval_src(text: &str, cursor: usize, mark: Option<usize>) -> String {
match region(text, cursor, mark) {
Some((lo, hi)) if hi > lo => text[lo..hi].to_string(),
_ => text.to_string(),
}
}
fn scratch_key(state: &mut State, engine: &Engine, key: KeyEvent) -> bool {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let emacs = matches!(state.keys, Keybindings::Emacs | Keybindings::Native);
if key.code == KeyCode::F(5) {
state.scratch_cc = false;
eval_scratch(state, engine);
return false;
}
if key.code == KeyCode::Enter && (ctrl || alt) {
state.scratch_cc = false;
eval_scratch(state, engine);
return false;
}
if emacs && ctrl && key.code == KeyCode::Char('c') {
if state.scratch_cc {
state.scratch_cc = false;
eval_scratch(state, engine);
} else {
state.scratch_cc = true;
}
return false;
}
state.scratch_cc = false;
if ctrl && key.code == KeyCode::Char('c') {
return true;
}
match decode_line(key, state.keys, state.vi_mode, state.scratch.is_empty()) {
Edit::Submit => apply_scratch(state, Edit::Insert('\n')),
Edit::Quit => return true,
action => apply_scratch(state, action),
}
false
}
fn apply_scratch(state: &mut State, action: Edit) {
if action == Edit::Yank {
if let Some(text) = clipboard::paste() {
state.kill = text;
}
}
let before = state.kill.clone();
scratch_edit(state, action);
if state.kill != before {
clipboard::copy(&state.kill);
}
}
fn scratch_edit(state: &mut State, action: Edit) {
match action {
Edit::HistoryPrev => state.scratch_cursor = line_up(&state.scratch, state.scratch_cursor),
Edit::HistoryNext => state.scratch_cursor = line_down(&state.scratch, state.scratch_cursor),
Edit::ScrollUp | Edit::ScrollDown | Edit::Clear => {}
other => edit_text(
&mut state.scratch,
&mut state.scratch_cursor,
&mut state.scratch_mark,
&mut state.kill,
&mut state.vi_mode,
other,
),
}
}
fn eval_scratch(state: &mut State, engine: &Engine) {
let src = scratch_eval_src(&state.scratch, state.scratch_cursor, state.scratch_mark);
if src.trim().is_empty() {
return;
}
if let Action::Output(entry) = engine.eval_lisp(&src) {
state.scratch_result = Some(entry.result.clone());
state.transcript.push(entry);
}
}
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 clock_text(engine: &Engine) -> String {
match engine.eval("source urn:time:now") {
Action::Output(out) => out.result.unwrap_or_default().trim().to_string(),
_ => String::new(),
}
}
fn clock_spans(clock: &str) -> Vec<Span<'static>> {
let Some((h, m)) = clock.split_once(':') else {
return vec![clock.to_string().into()];
};
let colon_on = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_millis() < 500)
.unwrap_or(true);
let colon = if colon_on { ":" } else { " " };
vec![
h.to_string().cyan().bold(),
colon.cyan().bold(),
m.to_string().cyan().bold(),
]
}
fn draw(frame: &mut Frame, state: &State) {
let tab_rows = if state.demos.is_empty() { 1 } else { 2 };
let chunks = Layout::vertical([
Constraint::Length(tab_rows), Constraint::Min(1), Constraint::Length(3), ])
.split(frame.area());
let first_row = Rect {
height: 1,
..chunks[0]
};
let top = Layout::horizontal([Constraint::Min(1), Constraint::Length(7)]).split(first_row);
frame.render_widget(
Paragraph::new(Line::from(clock_spans(&state.clock))).alignment(Alignment::Right),
top[1],
);
let mut core_titles = vec![Line::from("REPL"), Line::from("Scratch")];
if !state.demos.is_empty() {
core_titles.push(Line::from("Docs"));
core_titles.push(Line::from("Control"));
}
let core_len = core_titles.len();
let core = Tabs::new(core_titles)
.select(if state.tab < core_len {
state.tab
} else {
core_len
}) .highlight_style(Style::new().reversed())
.divider(" ");
frame.render_widget(core, top[0]);
if !state.demos.is_empty() && chunks[0].height >= 2 {
let demo_row = Rect {
y: chunks[0].y + 1,
height: 1,
..chunks[0]
};
let demo_titles: Vec<Line> = state
.demos
.iter()
.map(|d| Line::from(d.label.clone()))
.collect();
let demo_tabs = Tabs::new(demo_titles)
.select(if state.tab >= TAB_DEMO_BASE {
state.tab - TAB_DEMO_BASE
} else {
state.demos.len() })
.highlight_style(Style::new().reversed())
.divider(" ");
frame.render_widget(demo_tabs, demo_row);
}
if state.tab == TAB_REPL {
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 == TAB_SCRATCH {
draw_scratch(frame, state, chunks[1]);
} else if state.tab == TAB_DOCS {
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 state.tab == TAB_CONTROL {
let lines = control_lines(&state.control);
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) = demo_index(state.tab).and_then(|i| state.demos.get(i)) {
let page = Paragraph::new(demo_lines(demo, &state.demo_out)).wrap(Wrap { trim: false });
frame.render_widget(page, chunks[1]);
}
if state.tab == TAB_REPL {
let input = Paragraph::new(state.input.as_str()).block(
Block::default()
.borders(Borders::ALL)
.title(Line::from(format!(" request · {} ", mode_label(state)))),
);
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 == TAB_SCRATCH {
let hint = if matches!(state.keys, Keybindings::Vi) {
"F5 evaluate (or Ctrl/Alt-Enter) · Enter newline · Tab switch · Ctrl-C exit"
} else {
"F5 evaluate (or Ctrl/Alt-Enter · C-c C-c) · Enter newline · Tab switch"
};
let hint = Paragraph::new(hint.dim())
.block(Block::default().borders(Borders::ALL).title(" lisp "));
frame.render_widget(hint, chunks[2]);
} else if state.tab == TAB_DOCS || state.tab == TAB_CONTROL {
let title = if state.tab == TAB_DOCS {
" docs "
} else {
" control "
};
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(title));
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 draw_scratch(frame: &mut Frame, state: &State, area: Rect) {
let result_h = match &state.scratch_result {
Some(Ok(out)) => (out.lines().count() as u16 + 1).clamp(1, 8),
Some(Err(_)) => 2,
None => 0,
};
let parts = Layout::vertical([Constraint::Min(1), Constraint::Length(result_h)]).split(area);
let (crow, ccol) = cursor_row_col(&state.scratch, state.scratch_cursor);
let inner_h = parts[0].height.saturating_sub(2);
let scroll_y = crow.saturating_sub(inner_h.saturating_sub(1));
let buf = Paragraph::new(scratch_lines(&state.scratch))
.block(
Block::default()
.borders(Borders::ALL)
.title(Line::from(format!(
" scratch (lisp) · {} ",
mode_label(state)
))),
)
.scroll((scroll_y, 0));
frame.render_widget(buf, parts[0]);
let cx = (parts[0].x + 1 + ccol).min(parts[0].x + parts[0].width.saturating_sub(1));
let cy = (parts[0].y + 1 + crow.saturating_sub(scroll_y))
.min(parts[0].y + parts[0].height.saturating_sub(1));
frame.set_cursor_position(Position::new(cx, cy));
if result_h > 0 {
if let Some(result) = &state.scratch_result {
frame.render_widget(Paragraph::new(scratch_result_lines(result)), parts[1]);
}
}
}
fn scratch_lines(buf: &str) -> Vec<Line<'static>> {
if buf.is_empty() {
return vec![Line::from("(empty — type Lisp, then F5 to evaluate)".dim())];
}
buf.split('\n').map(|l| Line::from(l.to_string())).collect()
}
fn scratch_result_lines(result: &Result<String, String>) -> Vec<Line<'static>> {
let mut lines = vec![Line::from("─ result ─".dim())];
match 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
}
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 control_lines(control: &str) -> Vec<Line<'static>> {
let mut lines = vec![
Line::from(
"the control plane · scheduler + cache + time jobs, one composed resource".bold(),
),
Line::from("source urn:fn:compose src=urn:data:control".cyan()),
Line::from(""),
];
if control.trim().is_empty() {
lines.push(Line::from("(control plane unavailable)".dim()));
} else {
for l in control.lines() {
let is_header = !l.is_empty() && !l.starts_with(' ') && !l.starts_with("urn:");
if is_header {
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 demo_index_maps_tabs_to_demos_after_the_scratch_tab() {
assert_eq!(demo_index(TAB_REPL), None);
assert_eq!(demo_index(TAB_SCRATCH), None);
assert_eq!(demo_index(TAB_DOCS), None);
assert_eq!(demo_index(TAB_CONTROL), None);
assert_eq!(demo_index(TAB_DEMO_BASE), Some(0));
assert_eq!(demo_index(TAB_DEMO_BASE + 1), Some(1));
}
#[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.docs = "│ urn:fn:toUpper\n│ a function resource".into();
state.control =
"scheduler\n backend single\n threads 1\ncache\n entries 3".into();
state.tab = TAB_REPL; render(80, 24, &state);
render(1, 1, &state);
state.tab = TAB_SCRATCH; render(80, 24, &state);
state.tab = TAB_DOCS; render(80, 24, &state);
state.tab = TAB_CONTROL; render(80, 24, &state);
state.tab = TAB_DEMO_BASE; render(80, 24, &state);
state.demo_out = "$ source urn:fn:toUpper hello\nHELLO".into();
render(80, 24, &state); render(20, 6, &state); }
#[test]
fn clock_top_right_and_demo_tabs_wrap_to_row_two() {
let mut state = State {
clock: "12:34".into(),
..State::default()
};
for label in ["Basics", "Piping", "HTTP"] {
state.demos.push(DemoData {
label: label.into(),
intro: String::new(),
steps: vec![],
});
}
let mut terminal = Terminal::new(TestBackend::new(60, 10)).unwrap();
terminal.draw(|f| draw(f, &state)).unwrap();
let buf = terminal.backend().buffer();
let row = |y: u16| -> String { (0..60).map(|x| buf[(x, y)].symbol()).collect() };
let (row0, row1) = (row(0), row(1));
assert!(
row0.contains("REPL") && row0.contains("Control"),
"core tabs: {row0:?}"
);
assert!(
row0.contains("12") && row0.trim_end().ends_with("34"),
"clock far right: {row0:?}"
);
assert!(
row1.contains("Basics") && row1.contains("Piping"),
"demo tabs row1: {row1:?}"
);
assert!(!row0.contains("Basics"), "demo tabs NOT on row0: {row0:?}");
}
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!(region(&s.input, s.cursor, s.mark).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);
}
fn scratch_state(buf: &str, cursor: usize) -> State {
State {
scratch: buf.to_string(),
scratch_cursor: cursor,
..State::default()
}
}
#[test]
fn scratch_enter_inserts_a_newline_not_submits() {
let mut s = scratch_state("(+ 1", 4);
apply_scratch(&mut s, Edit::Insert('\n'));
apply_scratch(&mut s, Edit::Insert('2'));
assert_eq!(s.scratch, "(+ 1\n2");
assert_eq!(s.scratch_cursor, 6);
}
#[test]
fn scratch_up_down_move_across_lines_keeping_column() {
let mut s = scratch_state("abcdef\nghijkl\nmno", 10);
apply_scratch(&mut s, Edit::HistoryPrev); assert_eq!(s.scratch_cursor, 3); apply_scratch(&mut s, Edit::HistoryNext); assert_eq!(s.scratch_cursor, 10);
apply_scratch(&mut s, Edit::HistoryNext); assert_eq!(s.scratch_cursor, 17); apply_scratch(&mut s, Edit::HistoryNext); assert_eq!(s.scratch_cursor, 17);
}
#[test]
fn scratch_home_end_are_line_relative() {
let mut s = scratch_state("foo\nbarbaz", 8); apply_scratch(&mut s, Edit::Home);
assert_eq!(s.scratch_cursor, 4); apply_scratch(&mut s, Edit::End);
assert_eq!(s.scratch_cursor, 10); }
#[test]
fn scratch_reuses_the_shared_kill_op_line_relative() {
let mut s = scratch_state("foo bar\nbaz", 4); apply_scratch(&mut s, Edit::KillToEnd);
assert_eq!(s.scratch, "foo \nbaz");
assert_eq!(s.kill, "bar");
}
#[test]
fn scratch_eval_src_picks_the_region_or_whole_buffer() {
assert_eq!(scratch_eval_src("(+ 1 2)", 7, None), "(+ 1 2)");
assert_eq!(scratch_eval_src("(+ 1 2)(+ 3 4)", 14, Some(7)), "(+ 3 4)");
assert_eq!(scratch_eval_src("(+ 1 2)", 3, Some(3)), "(+ 1 2)");
}
#[test]
fn cursor_row_col_tracks_line_and_column() {
assert_eq!(cursor_row_col("abc", 2), (0, 2));
assert_eq!(cursor_row_col("abc\nde", 5), (1, 1));
assert_eq!(cursor_row_col("abc\n", 4), (1, 0)); }
#[test]
fn scratch_setmark_then_move_bounds_the_eval_region() {
let mut s = scratch_state("(a)\n(b)", 0);
apply_scratch(&mut s, Edit::SetMark);
apply_scratch(&mut s, Edit::End); let src = scratch_eval_src(&s.scratch, s.scratch_cursor, s.scratch_mark);
assert_eq!(src, "(a)");
}
fn echo_lisp_engine() -> Engine {
use ikigai_core::{
EndpointSpace, Exact, FnEndpoint, Invocation, Kernel, ReprType, Representation,
};
let eval = FnEndpoint::new("lisp", |inv: &Invocation<'_>| {
let src = inv.inline_str("in")?;
Ok(Representation::new(
ReprType::new("text/plain"),
format!("ok: {src}").into_bytes(),
))
});
let space = EndpointSpace::new().bind(Exact::new("urn:lisp:eval"), eval);
Engine::new(Kernel::new(std::sync::Arc::new(space)))
}
#[test]
fn scratch_f5_triggers_an_eval() {
let engine = echo_lisp_engine();
for keys in [Keybindings::Emacs, Keybindings::Vi] {
let mut s = State {
scratch: "(+ 1 2)".into(),
scratch_cursor: 7,
keys,
..State::default()
};
let quit = scratch_key(&mut s, &engine, key(KeyCode::F(5), KeyModifiers::NONE));
assert!(!quit, "F5 must not quit");
assert_eq!(s.scratch_result, Some(Ok("ok: (+ 1 2)".to_string())));
assert_eq!(
s.transcript.len(),
1,
"result also appended to the transcript"
);
}
}
#[test]
fn draws_the_scratch_tab_without_panicking() {
let mut state = State {
scratch: "(+ 1 2)\n(list 1 2 3)".into(),
scratch_cursor: 5,
tab: TAB_SCRATCH,
..State::default()
};
render(80, 24, &state);
render(1, 1, &state);
state.scratch_result = Some(Ok("3".into()));
render(80, 24, &state);
state.scratch_result = Some(Err("unbound symbol: foo".into()));
render(20, 6, &state);
state.scratch.clear();
state.scratch_cursor = 0;
state.scratch_result = None;
render(80, 24, &state);
}
}