use std::cell::RefCell;
use inkferro_core::dom::{Arena, Op, apply, decode_ops};
use inkferro_core::input::{
EventType, InputEvent as CoreInputEvent, Key as CoreKey, Parser, parse_keypress,
};
use inkferro_core::layout::LayoutEngine;
use inkferro_core::render::{ColorLevel, build_layout_engine, render_static, render_styled};
use inkferro_rt::{CursorPos as RtCursorPos, FrameParams, FrameWriter};
use napi::Env;
use napi::bindgen_prelude::{Buffer, Error, FnArgs, Function, FunctionRef, Result, Status};
use napi_derive::napi;
type BoxedTransform<'a> = Box<dyn Fn(&str, usize) -> String + 'a>;
const ROOT_ID: u32 = 0;
const MAX_NODE_ID: u32 = (1 << 16) - 1;
fn max_op_id(op: &Op) -> u32 {
match op {
Op::Create { id, .. }
| Op::SetText { id, .. }
| Op::SetStyle { id, .. }
| Op::SetAttribute { id, .. }
| Op::SetTransform { id, .. }
| Op::SetTextStyle { id, .. }
| Op::ClearTextStyle { id }
| Op::SetStatic { id, .. }
| Op::Hide { id }
| Op::Unhide { id }
| Op::Free { id } => *id,
Op::AppendChild { parent, child } | Op::RemoveChild { parent, child } => {
(*parent).max(*child)
}
Op::InsertBefore {
parent,
child,
before,
} => (*parent).max(*child).max(*before),
}
}
#[napi(string_enum)]
#[derive(Clone, Copy)]
pub enum RenderMode {
Diff,
Debug,
ScreenReader,
NonInteractive,
}
#[napi(object)]
pub struct RenderOpts {
pub cols: u16,
pub rows: u16,
pub color_level: u8,
pub include_plain_output: Option<bool>,
}
#[napi(object)]
pub struct FrameResult {
pub bytes: Buffer,
pub output_height: u32,
pub plain_output: String,
pub static_output: String,
pub render_time_ms: f64,
pub changed_lines: u32,
}
#[napi(object)]
pub struct CursorPos {
pub x: u32,
pub y: u32,
}
#[napi(object)]
pub struct Rect {
pub width: u32,
pub height: u32,
pub left: i32,
pub top: i32,
}
impl Rect {
fn zero() -> Self {
Rect {
width: 0,
height: 0,
left: 0,
top: 0,
}
}
}
#[napi(string_enum)]
pub enum InputEventKind {
Key,
Paste,
}
#[napi(object)]
pub struct InputEvent {
pub kind: InputEventKind,
pub key: Option<Key>,
pub input: Option<String>,
pub paste: Option<String>,
}
impl InputEvent {
fn from_core(ev: CoreInputEvent) -> Self {
match ev {
CoreInputEvent::Key(k) => {
let input = derive_input(&k);
let mut key = Key::from_core(&k);
apply_uppercase_shift(&mut key, &input);
InputEvent {
kind: InputEventKind::Key,
key: Some(key),
input: Some(input),
paste: None,
}
}
CoreInputEvent::Paste(bytes) => InputEvent {
kind: InputEventKind::Paste,
key: None,
input: None,
paste: Some(String::from_utf8_lossy(&bytes).into_owned()),
},
}
}
}
#[napi(object)]
pub struct Key {
pub up_arrow: bool,
pub down_arrow: bool,
pub left_arrow: bool,
pub right_arrow: bool,
pub page_down: bool,
pub page_up: bool,
pub home: bool,
pub end: bool,
pub r#return: bool,
pub escape: bool,
pub ctrl: bool,
pub shift: bool,
pub tab: bool,
pub backspace: bool,
pub delete: bool,
pub meta: bool,
#[napi(js_name = "super")]
pub super_key: bool,
pub hyper: bool,
pub caps_lock: bool,
pub num_lock: bool,
pub event_type: Option<String>,
}
impl Key {
fn from_core(k: &CoreKey) -> Self {
Key {
up_arrow: k.name == "up",
down_arrow: k.name == "down",
left_arrow: k.name == "left",
right_arrow: k.name == "right",
page_down: k.name == "pagedown",
page_up: k.name == "pageup",
home: k.name == "home",
end: k.name == "end",
r#return: k.name == "return",
escape: k.name == "escape",
ctrl: k.ctrl,
shift: k.shift,
tab: k.name == "tab",
backspace: k.name == "backspace",
delete: k.name == "delete",
meta: k.meta,
super_key: k.super_key,
hyper: k.hyper,
caps_lock: k.caps_lock,
num_lock: k.num_lock,
event_type: k.event_type.map(|t| {
match t {
EventType::Press => "press",
EventType::Repeat => "repeat",
EventType::Release => "release",
}
.to_owned()
}),
}
}
}
const NON_ALPHANUMERIC_KEYS: &[&str] = &[
"f1",
"f2",
"f3",
"f4",
"f5",
"f6",
"f7",
"f8",
"f9",
"f10",
"f11",
"f12",
"up",
"down",
"right",
"left",
"clear",
"end",
"home",
"insert",
"delete",
"pageup",
"pagedown",
"tab",
"backspace",
];
fn derive_input(k: &CoreKey) -> String {
let mut input: String = if k.is_kitty_protocol {
if k.is_printable == Some(true) {
k.text.clone().unwrap_or_else(|| k.name.clone())
} else if k.ctrl && k.name.chars().count() == 1 {
k.name.clone()
} else {
String::new()
}
} else if k.ctrl {
k.name.clone()
} else {
k.sequence.clone()
};
if !k.is_kitty_protocol && NON_ALPHANUMERIC_KEYS.contains(&k.name.as_str()) {
input = String::new();
}
if let Some(stripped) = input.strip_prefix('\u{1b}') {
input = stripped.to_owned();
}
input
}
fn apply_uppercase_shift(key: &mut Key, input: &str) {
let mut chars = input.chars();
if let (Some(c), None) = (chars.next(), chars.next())
&& c.is_ascii_uppercase()
{
key.shift = true;
}
}
#[napi]
pub struct InkRoot {
arena: Arena,
frame_writer: FrameWriter,
root_id: u32,
dispatcher: FunctionRef<FnArgs<(u32, String, u32)>, String>,
cursor_dirty: bool,
cursor_position: Option<RtCursorPos>,
last_render_width: Option<u16>,
input_parser: Parser,
rendering: bool,
}
#[napi]
impl InkRoot {
#[napi(constructor)]
pub fn new(transform_dispatcher: Function<FnArgs<(u32, String, u32)>, String>) -> Result<Self> {
Ok(InkRoot {
arena: Arena::new(),
frame_writer: FrameWriter::new(),
root_id: ROOT_ID,
dispatcher: transform_dispatcher.create_ref()?,
cursor_dirty: false,
cursor_position: None,
last_render_width: None,
input_parser: Parser::new(),
rendering: false,
})
}
#[napi]
pub fn commit(&mut self, ops: Buffer) -> Result<()> {
if self.rendering {
return Err(reentrancy_error("commit"));
}
let ops = decode_ops(ops.as_ref())
.map_err(|e| Error::new(Status::InvalidArg, format!("op-buffer decode failed: {e}")))?;
if let Some(id) = ops.iter().map(max_op_id).find(|&id| id > MAX_NODE_ID) {
return Err(Error::new(
Status::InvalidArg,
format!("op-buffer rejected: node id {id} exceeds MAX_NODE_ID {MAX_NODE_ID}"),
));
}
apply(&mut self.arena, &ops);
Ok(())
}
#[napi]
pub fn render_frame(
&mut self,
env: Env,
mode: RenderMode,
opts: RenderOpts,
) -> Result<Option<FrameResult>> {
if self.rendering {
return Err(reentrancy_error("render_frame"));
}
let InkRoot {
arena,
frame_writer,
root_id,
dispatcher,
cursor_dirty,
cursor_position,
last_render_width,
input_parser: _,
rendering,
} = self;
*last_render_width = Some(opts.cols);
let active_cursor = if *cursor_dirty {
*cursor_position
} else {
None
};
let is_debug = matches!(mode, RenderMode::Debug | RenderMode::ScreenReader);
let _guard = RenderingGuard::engage(rendering);
let result = render_frame_impl(
arena,
frame_writer,
dispatcher,
*root_id,
active_cursor,
env,
mode,
opts,
);
if !is_debug {
*cursor_dirty = false;
}
result
}
#[napi]
pub fn render_to_string(&mut self, env: Env, width: u16, color_level: u8) -> Result<String> {
let opts = RenderOpts {
cols: width,
rows: 0,
color_level,
include_plain_output: Some(true),
};
let result = self.render_frame(env, RenderMode::Debug, opts)?;
Ok(result.map(|r| r.plain_output).unwrap_or_default())
}
#[napi]
pub fn free(&mut self, id: u32) -> Result<()> {
if self.rendering {
return Err(reentrancy_error("free"));
}
apply(&mut self.arena, &[Op::Free { id }]);
Ok(())
}
#[napi]
pub fn set_cursor(&mut self, pos: Option<CursorPos>) -> Result<()> {
if self.rendering {
return Err(reentrancy_error("set_cursor"));
}
self.cursor_position = pos.map(|p| RtCursorPos {
x: p.x as usize,
y: p.y as usize,
});
self.cursor_dirty = true;
Ok(())
}
#[napi]
pub fn measure(&self, id: u32) -> Rect {
if self.rendering {
return Rect::zero();
}
let Some(width) = self.last_render_width else {
return Rect::zero();
};
let rect = build_layout_engine(&self.arena, self.root_id, width)
.and_then(|(engine, _root_rect)| engine.computed(id));
match rect {
Some(r) => Rect {
width: u32::from(r.width),
height: u32::from(r.height),
left: r.x,
top: r.y,
},
None => Rect::zero(),
}
}
#[napi]
pub fn measure_absolute(&self, id: u32) -> Rect {
if self.rendering {
return Rect::zero();
}
let Some(width) = self.last_render_width else {
return Rect::zero();
};
let rect = build_layout_engine(&self.arena, self.root_id, width)
.and_then(|(engine, _root_rect)| engine.computed_absolute(id));
match rect {
Some(r) => Rect {
width: u32::from(r.width),
height: u32::from(r.height),
left: r.x,
top: r.y,
},
None => Rect::zero(),
}
}
#[napi]
pub fn clear(&mut self) -> Result<Buffer> {
if self.rendering {
return Err(reentrancy_error("clear"));
}
Ok(self.frame_writer.clear().into())
}
#[napi]
pub fn sync_baseline(&mut self) -> Result<()> {
if self.rendering {
return Err(reentrancy_error("sync_baseline"));
}
self.frame_writer.sync_baseline();
Ok(())
}
#[napi]
pub fn restore_last_output(&mut self) -> Result<Buffer> {
if self.rendering {
return Err(reentrancy_error("restore_last_output"));
}
Ok(self.frame_writer.restore_last_output().into())
}
#[napi]
pub fn compose_console_write(&mut self, data: Buffer, sync: bool) -> Result<Buffer> {
if self.rendering {
return Err(reentrancy_error("compose_console_write"));
}
Ok(self.frame_writer.compose_console_write(&data, sync).into())
}
#[napi]
pub fn compose_console_prefix(&mut self, sync: bool) -> Result<Buffer> {
if self.rendering {
return Err(reentrancy_error("compose_console_prefix"));
}
Ok(self.frame_writer.compose_console_prefix(sync).into())
}
#[napi]
pub fn compose_console_suffix(&mut self, sync: bool) -> Result<Buffer> {
if self.rendering {
return Err(reentrancy_error("compose_console_suffix"));
}
Ok(self.frame_writer.compose_console_suffix(sync).into())
}
#[napi]
pub fn forget_last_output(&mut self) -> Result<()> {
if self.rendering {
return Err(reentrancy_error("forget_last_output"));
}
self.frame_writer.forget_last_output();
Ok(())
}
#[napi]
pub fn reset_static_output(&mut self) -> Result<()> {
if self.rendering {
return Err(reentrancy_error("reset_static_output"));
}
self.frame_writer.reset_static_output();
Ok(())
}
#[napi]
pub fn push_input(&mut self, bytes: Buffer) -> Result<Vec<InputEvent>> {
if self.rendering {
return Err(reentrancy_error("push_input"));
}
Ok(self
.input_parser
.feed(bytes.as_ref())
.into_iter()
.map(InputEvent::from_core)
.collect())
}
#[napi]
pub fn has_pending_escape(&self) -> bool {
if self.rendering {
return false;
}
self.input_parser.has_pending_escape()
}
#[napi]
pub fn flush_pending_escape(&mut self) -> Result<Vec<InputEvent>> {
if self.rendering {
return Err(reentrancy_error("flush_pending_escape"));
}
Ok(self
.input_parser
.flush_pending_escape()
.map(|bytes| {
vec![InputEvent::from_core(CoreInputEvent::Key(parse_keypress(
&bytes,
)))]
})
.unwrap_or_default())
}
}
struct RenderingGuard<'a>(&'a mut bool);
impl<'a> RenderingGuard<'a> {
fn engage(flag: &'a mut bool) -> Self {
*flag = true;
RenderingGuard(flag)
}
}
impl Drop for RenderingGuard<'_> {
fn drop(&mut self) {
*self.0 = false;
}
}
fn reentrancy_error(method: &str) -> Error {
Error::new(
Status::GenericFailure,
format!(
"InkRoot.{method} re-entered during a render — a transform dispatcher must not call \
back into commit/render_frame (the receiver is already borrowed; reentry is rejected \
to avoid memory corruption)"
),
)
}
#[allow(clippy::too_many_arguments)]
fn render_frame_impl(
arena: &Arena,
frame_writer: &mut FrameWriter,
dispatcher: &FunctionRef<FnArgs<(u32, String, u32)>, String>,
root_id: u32,
cursor: Option<RtCursorPos>,
env: Env,
mode: RenderMode,
opts: RenderOpts,
) -> Result<Option<FrameResult>> {
let start = std::time::Instant::now();
let err_cell: RefCell<Option<Error>> = RefCell::new(None);
let err_ref = &err_cell;
let transform_of = |id: u32| -> Option<BoxedTransform<'_>> {
let node = arena.get(id)?;
if !node.has_transform {
return None;
}
Some(Box::new(move |s: &str, index: usize| -> String {
if err_ref.borrow().is_some() {
return s.to_owned();
}
let f = match dispatcher.borrow_back(&env) {
Ok(f) => f,
Err(e) => {
*err_ref.borrow_mut() = Some(e);
return s.to_owned();
}
};
match f.call((id, s.to_owned(), index as u32).into()) {
Ok(out) => out,
Err(e) => {
*err_ref.borrow_mut() = Some(e);
s.to_owned()
}
}
}))
};
let color_level = ColorLevel::from_u8(opts.color_level);
let (plain_output, height) =
render_styled(arena, root_id, opts.cols, &transform_of, color_level);
let static_output = render_static(arena, root_id, opts.cols, &transform_of, color_level);
if let Some(err) = err_cell.into_inner() {
return Err(err);
}
let debug = matches!(mode, RenderMode::Debug | RenderMode::ScreenReader);
let is_tty = !matches!(mode, RenderMode::NonInteractive);
let params = FrameParams {
is_tty,
viewport_rows: opts.rows as usize,
output: &plain_output,
output_height: height as usize,
static_output: &static_output,
is_unmounting: false,
cursor_dirty: cursor.is_some(),
cursor,
interactive: None,
is_in_ci: false,
debug,
};
let (bytes, changed_lines) = if debug {
let mut w = FrameWriter::new();
let b = w.write_frame(¶ms);
(b, w.last_changed_lines())
} else {
let b = frame_writer.write_frame(¶ms);
(b, frame_writer.last_changed_lines())
};
let render_time_ms = start.elapsed().as_secs_f64() * 1000.0;
if bytes.is_empty() {
return Ok(None);
}
Ok(Some(FrameResult {
bytes: bytes.into(),
output_height: height as u32,
plain_output: if opts.include_plain_output.unwrap_or(true) {
plain_output
} else {
String::new()
},
static_output,
render_time_ms,
changed_lines,
}))
}