use std::cell::Cell;
use std::path::PathBuf;
use std::ptr::NonNull;
use std::rc::Rc;
use std::str::FromStr;
use rizz::RizzError;
use rizz::runtime::{self, Env, NativeFn, RuntimeError, Value};
use crate::action::Action;
use crate::buffer::MoveKind;
use crate::keymap::KeyEvent;
use crate::mode::EditingMode;
use crate::position::Position;
use crate::slots::{BuiltinId, LispRenderable, SegmentSide, Slot, SlotCategory, SlotKind};
use crate::state::State;
use crate::styling::{normalize_style_value, rgb_value, style_from_value, style_to_value};
use crate::window::{FocusDir, SplitDir};
thread_local! {
static EDITOR: Cell<Option<NonNull<State>>> = const { Cell::new(None) };
static RENDER_PHASE: Cell<bool> = const { Cell::new(false) };
}
pub(crate) struct RenderPhaseGuard;
impl RenderPhaseGuard {
pub(crate) fn enter() -> Self {
RENDER_PHASE.with(|c| c.set(true));
Self
}
}
impl Drop for RenderPhaseGuard {
fn drop(&mut self) {
RENDER_PHASE.with(|c| c.set(false));
}
}
fn in_render_phase() -> bool {
RENDER_PHASE.with(|c| c.get())
}
pub(crate) struct EditorGuard {
prev: Option<NonNull<State>>,
}
impl EditorGuard {
pub(crate) fn new(state: &mut State) -> Self {
let prev = EDITOR.with(|c| c.replace(Some(NonNull::from(state))));
Self { prev }
}
}
impl Drop for EditorGuard {
fn drop(&mut self) {
EDITOR.with(|c| c.set(self.prev));
}
}
fn with_editor_mut<R>(f: impl FnOnce(&mut State) -> R) -> R {
let ptr = EDITOR
.with(|c| c.get())
.expect("editor bridge not active: lisp builtin called outside eval_lisp");
unsafe { f(ptr.as_ptr().as_mut().unwrap()) }
}
pub struct LispRuntime {
env: Env,
}
impl LispRuntime {
pub fn new() -> Self {
let env = rizz::prelude::install(builtins());
Self { env }
}
pub fn eval_str(&mut self, src: &str) -> Result<Rc<Value>, RizzError> {
let (v, env) = rizz::parse_and_run_with_env(src.as_bytes(), &self.env)?;
self.env = env;
Ok(v)
}
pub fn eval_value(&mut self, form: Rc<Value>) -> Result<Rc<Value>, RizzError> {
let (v, env) = runtime::eval(form, &self.env)?;
self.env = env;
Ok(v)
}
pub fn eval_script(&mut self, src: &str) -> Result<(), RizzError> {
self.eval_str(src)?;
Ok(())
}
pub fn env(&self) -> &Env {
&self.env
}
}
impl Default for LispRuntime {
fn default() -> Self {
Self::new()
}
}
pub fn init_script_path() -> Option<PathBuf> {
let dir = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
Some(dir.join("editor").join("init.lisp"))
}
pub fn wrap_shell_style(input: &str) -> String {
let trimmed = input.trim();
if trimmed.is_empty() {
return "()".into();
}
if trimmed.starts_with('(') {
return trimmed.into();
}
if let Ok(n) = trimmed.parse::<i64>() {
return format!("(line {n})");
}
let mut parts = trimmed.split_whitespace();
let head = parts.next().unwrap();
let mut out = String::with_capacity(trimmed.len() + 4);
out.push('(');
out.push_str(head);
for arg in parts {
out.push(' ');
out.push_str(arg);
}
out.push(')');
out
}
fn builtins() -> Env {
let mut entries: Vec<(&str, NativeFn)> = Vec::new();
let mut aliases: Vec<(&str, &str)> = Vec::new();
macro_rules! b {
($name:expr, $nargs:expr, $f:expr) => {
entries.push(($name, NativeFn::impure($name.into(), $nargs, $f)));
};
}
macro_rules! alias {
($a:expr => $t:expr) => {
aliases.push(($a, $t));
};
}
b!("quit", 0, |_, env| {
apply(Action::Quit)?;
ok_unit(env)
});
alias!("q" => "quit");
b!("set-mode", 1, |args, env| {
let mode = parse_mode_ident(&args[0])?;
apply(Action::SetMode(mode))?;
ok_unit(env)
});
b!("insert-char", 1, |args, env| {
let s = as_str(&args[0], "insert-char")?;
let c = s
.chars()
.next()
.ok_or_else(|| str_mismatch("insert-char", "non-empty str"))?;
apply(Action::InsertChar(c))?;
ok_unit(env)
});
b!("insert", 1, |args, env| {
let s = as_str(&args[0], "insert")?;
with_editor_mut(|st| {
for c in s.chars() {
let _ = st.apply(&[Rc::new(Action::InsertChar(c))]);
}
});
ok_unit(env)
});
b!("delete-char", 0, |_, env| {
apply(Action::DeleteChar)?;
ok_unit(env)
});
b!("newline", 0, |_, env| {
apply(Action::InsertNewline)?;
ok_unit(env)
});
b!("move-cursor", 1, |args, env| {
let sym = as_ident(&args[0], "move-cursor")?;
let mk = MoveKind::from_str(&sym).map_err(|_| unknown_variant("move-cursor", &sym))?;
apply(Action::MoveCursor(mk))?;
ok_unit(env)
});
b!("move-cursor-rel", 2, |args, env| {
let dx = as_int(&args[0], "move-cursor-rel")?;
let dy = as_int(&args[1], "move-cursor-rel")?;
let mk = MoveKind::Relative(Position::new(dx as i16, dy as i16));
apply(Action::MoveCursor(mk))?;
ok_unit(env)
});
b!("line", 1, |args, env| {
let n = as_int(&args[0], "line")?;
let mk = MoveKind::LineNum(n.max(0) as usize);
apply(Action::MoveCursor(mk))?;
ok_unit(env)
});
b!("buf-create", 0, |_, env| {
apply(Action::BufCreate {
set_active: true,
path: None,
})?;
ok_unit(env)
});
alias!("bc" => "buf-create");
b!("buf-delete", 0, |_, env| {
apply(Action::BufDelete)?;
ok_unit(env)
});
alias!("bd" => "buf-delete");
b!("buf-next", 0, |_, env| {
apply(Action::BufNext)?;
ok_unit(env)
});
alias!("bn" => "buf-next");
b!("buf-prev", 0, |_, env| {
apply(Action::BufPrev)?;
ok_unit(env)
});
alias!("bp" => "buf-prev");
b!("edit", 1, |args, env| {
let p = as_str(&args[0], "edit")?;
let path = std::path::PathBuf::from_str(&p).unwrap();
apply(Action::BufEdit(path.into()))?;
ok_unit(env)
});
alias!("e" => "edit");
b!("write", 0, |_, env| {
apply(Action::BufWrite(None))?;
ok_unit(env)
});
alias!("w" => "write");
b!("write-as", 1, |args, env| {
let p = as_str(&args[0], "write-as")?;
let path = std::path::PathBuf::from_str(&p).unwrap();
apply(Action::BufWrite(Some(path.into())))?;
ok_unit(env)
});
b!("window-split", 1, |args, env| {
let dir = match as_ident(&args[0], "window-split")?.as_ref() {
"vertical" => SplitDir::Vertical,
"horizontal" => SplitDir::Horizontal,
other => return Err(unknown_variant("window-split", other)),
};
apply(Action::WindowSplit(dir))?;
ok_unit(env)
});
b!("window-close", 0, |_, env| {
apply(Action::WindowClose)?;
ok_unit(env)
});
b!("window-focus", 1, |args, env| {
let dir = match as_ident(&args[0], "window-focus")?.as_ref() {
"left" => FocusDir::Left,
"right" => FocusDir::Right,
"up" => FocusDir::Up,
"down" => FocusDir::Down,
other => return Err(unknown_variant("window-focus", other)),
};
apply(Action::WindowFocus(dir))?;
ok_unit(env)
});
b!("window-focus-next", 0, |_, env| {
apply(Action::WindowFocusNext)?;
ok_unit(env)
});
b!("keymap-set", 3, |args, env| {
let mode = parse_mode_ident(&args[0])?;
let lhs_str = as_str(&args[1], "keymap-set")?;
let lhs =
KeyEvent::parse_sequence(&lhs_str).map_err(|e| str_mismatch_msg("keymap-set", &e))?;
let form = args[2].clone();
apply(Action::KeymapSet {
mode,
lhs,
rhs: Rc::new(Action::EvalLisp(form)),
})?;
ok_unit(env)
});
b!("keymap-remove", 2, |args, env| {
let mode = parse_mode_ident(&args[0])?;
let lhs_str = as_str(&args[1], "keymap-remove")?;
let lhs = KeyEvent::parse_sequence(&lhs_str)
.map_err(|e| str_mismatch_msg("keymap-remove", &e))?;
apply(Action::KeymapRemove { mode, lhs })?;
ok_unit(env)
});
b!("command-submit", 0, |_, env| {
let cmd = with_editor_mut(|st| st.take_minibuffer_command());
let src = wrap_shell_style(&cmd);
match rizz::parse_and_run_with_env(src.as_bytes(), env) {
Ok((v, new_env)) => {
if !v.is_unit() {
with_editor_mut(|st| st.set_minibuffer_message(&v.display()));
}
Ok((unit(), new_env))
}
Err(e) => {
let msg = e.to_string();
with_editor_mut(|st| st.set_minibuffer_message(&msg));
ok_unit(env)
}
}
});
b!("command-cancel", 0, |_, env| {
apply(Action::CommandCancel)?;
ok_unit(env)
});
b!("eval", 0, |_, env| {
let src = with_editor_mut(|st| {
st.focused_buf()
.selected_text()
.unwrap_or_else(|| st.focused_buf().text())
});
match rizz::parse_and_run_with_env(src.as_bytes(), env) {
Ok((v, new_env)) => {
if !v.is_unit() {
with_editor_mut(|st| st.set_minibuffer_message(&v.display()));
}
Ok((unit(), new_env))
}
Err(e) => {
let msg = e.to_string();
with_editor_mut(|st| st.set_minibuffer_message(&msg));
ok_unit(env)
}
}
});
b!("message", 1, |args, env| {
let s = as_str(&args[0], "message")?;
with_editor_mut(|st| st.set_minibuffer_message(&s));
ok_unit(env)
});
b!("buffer-text", 0, |_, env| {
let s = with_editor_mut(|st| st.focused_buf().text());
Ok((Rc::new(s.into()), env.clone()))
});
b!("selected-text", 0, |_, env| {
let s = with_editor_mut(|st| st.focused_buf().selected_text());
Ok((Rc::new(s.into()), env.clone()))
});
b!("cursor-line", 0, |_, env| {
let n = with_editor_mut(|st| {
let b = st.focused_buf();
b.cursor_pos().row as i64 + b.file_pos().row as i64
});
Ok((Rc::new(n.into()), env.clone()))
});
b!("cursor-col", 0, |_, env| {
let n = with_editor_mut(|st| {
let b = st.focused_buf();
b.cursor_pos().col as i64 + b.file_pos().col as i64
});
Ok((Rc::new(n.into()), env.clone()))
});
b!("face-define", 2, |args, env| {
let name = as_ident_or_str(&args[0], "face-define")?;
let style = with_editor_mut(|st| {
let theme = st.theme().borrow();
style_from_value(&args[1], &theme)
})?;
with_editor_mut(|st| {
st.theme().borrow_mut().insert(name, style);
});
ok_unit(env)
});
b!("face-of", 1, |args, env| {
let name = as_ident_or_str(&args[0], "face-of")?;
let v = with_editor_mut(|st| {
st.theme()
.borrow()
.lookup(&name)
.map(style_to_value)
.unwrap_or_else(|| Rc::new(Value::Unit))
});
Ok((v, env.clone()))
});
b!("rgb", 3, |args, env| {
let r = as_u8(&args[0], "rgb")?;
let g = as_u8(&args[1], "rgb")?;
let b = as_u8(&args[2], "rgb")?;
Ok((rgb_value(r, g, b), env.clone()))
});
b!("span", 2, |args, env| {
use im::HashMap as ImHashMap;
let text = as_str(&args[0], "span")?;
let style_val = with_editor_mut(|st| {
let theme = st.theme().borrow();
normalize_style_value(&args[1], &theme)
})?;
let mut m: ImHashMap<Rc<Value>, Rc<Value>> = ImHashMap::new();
m.insert(
Rc::new(Value::Str("text".into())),
Rc::new(Value::Str(text)),
);
if !style_val.is_unit() {
m.insert(Rc::new(Value::Str("style".into())), style_val);
}
Ok((Rc::new(Value::Map(m)), env.clone()))
});
b!("status-segment-add", 3, |args, env| {
let name = as_ident_or_str(&args[0], "status-segment-add")?;
let side = parse_segment_side(&args[1])?;
let renderable =
parse_handler(&args[2], "status-segment-add", SlotCategory::StatusSegment)?;
with_editor_mut(|st| {
st.slots_mut().add(Slot {
name,
kind: SlotKind::StatusSegment { side },
renderable,
});
});
ok_unit(env)
});
b!("status-segment-remove", 1, |args, env| {
let name = as_ident_or_str(&args[0], "status-segment-remove")?;
with_editor_mut(|st| {
st.slots_mut().remove(SlotCategory::StatusSegment, &name);
});
ok_unit(env)
});
b!("gutter-add", 3, |args, env| {
let name = as_ident_or_str(&args[0], "gutter-add")?;
let width = as_int(&args[1], "gutter-add")?;
let width = u16::try_from(width.max(0)).unwrap_or(0);
let renderable = parse_handler(&args[2], "gutter-add", SlotCategory::Gutter)?;
with_editor_mut(|st| {
st.slots_mut().add(Slot {
name,
kind: SlotKind::Gutter { width },
renderable,
});
});
ok_unit(env)
});
b!("gutter-remove", 1, |args, env| {
let name = as_ident_or_str(&args[0], "gutter-remove")?;
with_editor_mut(|st| {
st.slots_mut().remove(SlotCategory::Gutter, &name);
});
ok_unit(env)
});
b!("decorator-add", 2, |args, env| {
let name = as_ident_or_str(&args[0], "decorator-add")?;
let renderable = parse_handler(&args[1], "decorator-add", SlotCategory::Decorator)?;
with_editor_mut(|st| {
st.slots_mut().add(Slot {
name,
kind: SlotKind::Decorator,
renderable,
});
});
ok_unit(env)
});
b!("decorator-remove", 1, |args, env| {
let name = as_ident_or_str(&args[0], "decorator-remove")?;
with_editor_mut(|st| {
st.slots_mut().remove(SlotCategory::Decorator, &name);
});
ok_unit(env)
});
b!("bottom-add", 3, |args, env| {
let name = as_ident_or_str(&args[0], "bottom-add")?;
let rows = as_int(&args[1], "bottom-add")?.max(1);
let rows = u16::try_from(rows).unwrap_or(1);
let renderable = parse_handler(&args[2], "bottom-add", SlotCategory::Bottom)?;
with_editor_mut(|st| {
st.slots_mut().add(Slot {
name,
kind: SlotKind::Bottom { rows },
renderable,
});
});
ok_unit(env)
});
b!("bottom-remove", 1, |args, env| {
let name = as_ident_or_str(&args[0], "bottom-remove")?;
with_editor_mut(|st| {
st.slots_mut().remove(SlotCategory::Bottom, &name);
});
ok_unit(env)
});
b!("focused-mode", 0, |_, env| {
let m = with_editor_mut(|st| st.focused_buf().mode());
let s: &str = match m {
EditingMode::Normal => "normal",
EditingMode::Insert => "insert",
EditingMode::Visual => "visual",
EditingMode::VisualLine => "visual-line",
EditingMode::VisualBlock => "visual-block",
EditingMode::Command => "command",
};
Ok((Rc::new(Value::Str(s.into())), env.clone()))
});
let mut env = Env::of_builtins(entries);
for (a, t) in aliases {
let v = env.get(&Rc::<str>::from(t)).expect("alias target").clone();
env = env.update(a.into(), v);
}
env
}
fn unit() -> Rc<Value> {
Rc::new(Value::Unit)
}
fn ok_unit(env: &Env) -> Result<(Rc<Value>, Env), RuntimeError> {
Ok((unit(), env.clone()))
}
fn apply(action: Action) -> Result<(), RuntimeError> {
if in_render_phase() {
return Err(RuntimeError::TypeMismatch {
name: "editor-action".into(),
expected: "non-mutating call".into(),
got: "called from a render callback".into(),
});
}
with_editor_mut(|st| {
let _ = st.apply(&[Rc::new(action)]);
});
Ok(())
}
fn as_str(v: &Rc<Value>, name: &str) -> Result<Rc<str>, RuntimeError> {
v.as_str()
.ok_or_else(|| RuntimeError::type_mismatch(name, "str", v))
}
fn as_int(v: &Rc<Value>, name: &str) -> Result<i64, RuntimeError> {
v.as_int()
.ok_or_else(|| RuntimeError::type_mismatch(name, "int", v))
}
fn as_ident(v: &Rc<Value>, name: &str) -> Result<Rc<str>, RuntimeError> {
match &**v {
Value::Ident(s) => Ok(s.clone()),
_ => Err(RuntimeError::type_mismatch(name, "ident", v)),
}
}
fn as_ident_or_str(v: &Rc<Value>, name: &str) -> Result<Rc<str>, RuntimeError> {
match &**v {
Value::Ident(s) | Value::Str(s) => Ok(s.clone()),
_ => Err(RuntimeError::type_mismatch(name, "ident|str", v)),
}
}
fn as_u8(v: &Rc<Value>, name: &str) -> Result<u8, RuntimeError> {
let n = as_int(v, name)?;
u8::try_from(n).map_err(|_| RuntimeError::TypeMismatch {
name: name.into(),
expected: "0..=255".into(),
got: n.to_string().into(),
})
}
fn parse_segment_side(v: &Rc<Value>) -> Result<SegmentSide, RuntimeError> {
let s = as_ident(v, "segment-side")?;
Ok(match s.as_ref() {
"left" => SegmentSide::Left,
"right" => SegmentSide::Right,
other => return Err(unknown_variant("segment-side", other)),
})
}
fn parse_handler(
v: &Rc<Value>,
name: &str,
expected: SlotCategory,
) -> Result<LispRenderable, RuntimeError> {
if let Value::Ident(s) = &**v
&& let Some(b) = BuiltinId::parse(s)
{
if b.category() != expected {
return Err(RuntimeError::TypeMismatch {
name: name.into(),
expected: format!("builtin for {:?}", expected).into(),
got: format!("builtin for {:?}", b.category()).into(),
});
}
return Ok(LispRenderable::Builtin(b));
}
if v.is_callable() {
Ok(LispRenderable::Callable(v.clone()))
} else {
Ok(LispRenderable::Static(v.clone()))
}
}
fn parse_mode_ident(v: &Rc<Value>) -> Result<EditingMode, RuntimeError> {
let s = as_ident(v, "mode")?;
s.parse().map_err(|_| unknown_variant("mode", &s))
}
fn unknown_variant(name: &str, got: &str) -> RuntimeError {
RuntimeError::TypeMismatch {
name: name.into(),
expected: "known symbol".into(),
got: got.into(),
}
}
fn str_mismatch(name: &str, expected: &str) -> RuntimeError {
RuntimeError::TypeMismatch {
name: name.into(),
expected: expected.into(),
got: "?".into(),
}
}
fn str_mismatch_msg(name: &str, msg: &str) -> RuntimeError {
RuntimeError::TypeMismatch {
name: name.into(),
expected: "valid key sequence".into(),
got: msg.into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::test_support::test_state;
#[test]
fn arithmetic_from_prelude_works() {
let mut s = test_state();
let v = s.eval_lisp("(+ 1 2)").unwrap();
assert_eq!(*v, Value::Int(3));
}
#[test]
fn insert_char_from_lisp_mutates_buffer() {
let mut s = test_state();
s.eval_lisp("(insert-char \"a\")").unwrap();
assert!(s.focused_buf().text().starts_with('a'));
}
#[test]
fn keymap_set_from_lisp_binds_key() {
use crossterm::event::{KeyCode, KeyModifiers};
let mut s = test_state();
s.eval_lisp("(keymap-set 'normal \"q\" '(quit))").unwrap();
s.handle_key_event(crossterm::event::KeyEvent::new(
KeyCode::Char('q'),
KeyModifiers::NONE,
))
.unwrap();
assert!(s.quit_requested());
}
#[test]
fn keymap_set_from_lisp_binds_modified_key() {
use crossterm::event::{KeyCode, KeyModifiers};
let mut s = test_state();
s.eval_lisp(r#"(keymap-set 'normal "<c-w>q" '(quit))"#)
.unwrap();
s.handle_key_event(crossterm::event::KeyEvent::new(
KeyCode::Char('w'),
KeyModifiers::CONTROL,
))
.unwrap();
s.handle_key_event(crossterm::event::KeyEvent::new(
KeyCode::Char('q'),
KeyModifiers::NONE,
))
.unwrap();
assert!(s.quit_requested());
}
#[test]
fn command_submit_via_minibuffer_does_not_recurse() {
use crossterm::event::{KeyCode, KeyModifiers};
let mut s = test_state();
for (code, mods) in [
(KeyCode::Char(':'), KeyModifiers::NONE),
(KeyCode::Char('q'), KeyModifiers::NONE),
(KeyCode::Char('u'), KeyModifiers::NONE),
(KeyCode::Char('i'), KeyModifiers::NONE),
(KeyCode::Char('t'), KeyModifiers::NONE),
(KeyCode::Enter, KeyModifiers::NONE),
] {
s.handle_key_event(crossterm::event::KeyEvent::new(code, mods))
.unwrap();
}
assert!(s.quit_requested());
}
#[test]
fn wrap_shell_style_translates_input() {
assert_eq!(wrap_shell_style("quit"), "(quit)");
assert_eq!(wrap_shell_style("edit foo.txt"), "(edit foo.txt)");
assert_eq!(wrap_shell_style("(+ 1 2)"), "(+ 1 2)");
assert_eq!(wrap_shell_style("+ 1 2"), "(+ 1 2)");
assert_eq!(wrap_shell_style("42"), "(line 42)");
assert_eq!(wrap_shell_style(" "), "()");
}
#[test]
fn face_define_then_face_of_round_trips() {
let mut s = test_state();
s.eval_lisp(r#"(face-define "header" {"fg": 'cyan "bold": 1})"#)
.unwrap();
let v = s.eval_lisp(r#"(face-of "header")"#).unwrap();
assert!(matches!(&*v, Value::Map(m) if !m.is_empty()));
}
#[test]
fn rgb_builtin_round_trips_through_color_from_value() {
let mut s = test_state();
let v = s.eval_lisp("(rgb 60 90 130)").unwrap();
let c = crate::styling::color_from_value(&v).unwrap();
assert_eq!(c, Some(crate::styling::Color::Rgb(60, 90, 130)));
}
#[test]
fn span_builtin_emits_text_and_style_fields() {
let mut s = test_state();
let v = s.eval_lisp(r#"(span "hi" 'header)"#).unwrap();
match &*v {
Value::Map(m) => {
let text = m
.get(&Rc::new(Value::Str("text".into())))
.expect("text field");
assert_eq!(text.as_str().as_deref(), Some("hi"));
let style = m
.get(&Rc::new(Value::Str("style".into())))
.expect("style field");
assert!(matches!(&**style, Value::Str(s) if s.as_ref() == "header"));
}
other => panic!("expected map, got {other:?}"),
}
}
#[test]
fn status_segment_add_via_lisp_changes_frame() {
let mut s = test_state();
s.eval_lisp(r#"(status-segment-add 'star 'right "★")"#)
.unwrap();
let (frame, err) = s.precompute_frame();
assert!(err.is_none(), "no slot errors: {err:?}");
let texts: Vec<&str> = frame
.status_right
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(texts.contains(&"★"), "star segment missing: {texts:?}");
}
#[test]
fn status_segment_remove_via_lisp_drops_segment() {
let mut s = test_state();
s.eval_lisp("(status-segment-remove 'mode)").unwrap();
s.eval_lisp("(status-segment-remove 'brand)").unwrap();
s.eval_lisp("(status-segment-remove 'sel-hint)").unwrap();
let (frame, err) = s.precompute_frame();
assert!(err.is_none());
assert!(frame.status_left.is_empty());
}
#[test]
fn callable_status_segment_runs_each_frame() {
let mut s = test_state();
s.eval_lisp("(status-segment-remove 'mode)").unwrap();
s.eval_lisp("(status-segment-remove 'brand)").unwrap();
s.eval_lisp("(status-segment-remove 'sel-hint)").unwrap();
s.eval_lisp(r#"(status-segment-add 'probe 'left (fn _p () (focused-mode)))"#)
.unwrap();
let (frame, _err) = s.precompute_frame();
let s_left: Vec<&str> = frame
.status_left
.iter()
.map(|s| s.content.as_ref())
.collect();
assert_eq!(s_left, vec!["normal"]);
}
#[test]
fn status_segment_with_cjk_uses_display_width() {
use unicode_width::UnicodeWidthStr;
let mut s = test_state();
for name in ["cursor", "pip", "last-key", "spacer", "bufno"] {
s.eval_lisp(&format!("(status-segment-remove '{name})"))
.unwrap();
}
s.eval_lisp(r#"(status-segment-add 'cjk 'right "漢字")"#)
.unwrap();
let (frame, _) = s.precompute_frame();
let total: usize = frame
.status_right
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
assert_eq!(total, 4);
}
#[test]
fn render_phase_blocks_mutating_builtins() {
let mut s = test_state();
s.eval_lisp(
r#"(status-segment-add 'naughty 'left
(fn _naughty () (do (insert-char "x") "")))"#,
)
.unwrap();
let pre = s.focused_buf().text();
let (_, err) = s.precompute_frame();
assert!(err.is_some(), "expected a render-phase error");
let after = s.focused_buf().text();
assert_eq!(pre, after, "render callback must not mutate the buffer");
}
#[test]
fn default_lisp_binds_normal_mode_keys() {
use crossterm::event::{KeyCode, KeyModifiers};
let mut s = test_state();
s.eval_lisp("(set-mode 'insert)").unwrap();
s.eval_lisp("(insert \"ab\")").unwrap();
s.eval_lisp("(newline)").unwrap();
s.eval_lisp("(insert \"cd\")").unwrap();
s.eval_lisp("(set-mode 'normal)").unwrap();
s.eval_lisp("(move-cursor 'file-start)").unwrap();
s.handle_key_event(crossterm::event::KeyEvent::new(
KeyCode::Char('j'),
KeyModifiers::NONE,
))
.unwrap();
let b = s.focused_buf();
assert_eq!(b.cursor_pos().row as i64 + b.file_pos().row as i64, 1);
}
}