Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions crates/pidgin-napi/schema/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,22 @@
"nullable": false
}
]
},
{
"name": "InputEvent",
"doc": "Event surfaced by [`InputCore::handle_input`] so the JS shim can fire pi's\n`onSubmit`/`onEscape` callbacks. `submit` is the submitted value (pi passes\nthe current value) or `null` when no submit fired; `escape` is `true` when a\ncancel/escape fired.",
"fields": [
{
"name": "submit",
"type": "string",
"nullable": true
},
{
"name": "escape",
"type": "boolean",
"nullable": false
}
]
}
],
"unions": [],
Expand Down Expand Up @@ -1379,6 +1395,114 @@
}
}
]
},
{
"name": "InputCore",
"doc": "The Rust-backed single-line input core, exposed to JavaScript as\n`InputCore`.",
"single_threaded": true,
"ops": [
{
"name": "new",
"doc": "Create an empty input core, wiring pi's `onSubmit`/`onEscape` seams to a\nshared event cell that `handle_input` drains after each call.",
"shape": "ctor",
"params": [],
"returns": "void"
},
{
"name": "getValue",
"doc": "pi's `getValue()`: the current value.",
"shape": "unary",
"infallible": true,
"readonly": true,
"params": [],
"returns": "string",
"bindings": {
"node": {
"name": "getValue"
}
}
},
{
"name": "setValue",
"doc": "pi's `setValue(value)`: set the value, clamping the cursor.",
"shape": "unary",
"infallible": true,
"readonly": false,
"params": [
{
"name": "value",
"type": "string"
}
],
"returns": "void",
"bindings": {
"node": {
"name": "setValue"
}
}
},
{
"name": "setFocused",
"doc": "pi's `focused` field setter — routed here because render reads it.",
"shape": "unary",
"infallible": true,
"readonly": false,
"params": [
{
"name": "focused",
"type": "boolean"
}
],
"returns": "void",
"bindings": {
"node": {
"name": "setFocused"
}
}
},
{
"name": "handleInput",
"doc": "pi's `handleInput(data)`: process a chunk of terminal input, returning any\n`onSubmit`/`onEscape` that fired so the shim can replay it onto the JS\ncallbacks.",
"shape": "unary",
"infallible": true,
"readonly": false,
"params": [
{
"name": "data",
"type": "string"
}
],
"returns": {
"model": "InputEvent"
},
"bindings": {
"node": {
"name": "handleInput"
}
}
},
{
"name": "render",
"doc": "pi's `render(width)`: render the input to a single line.",
"shape": "unary",
"infallible": true,
"readonly": true,
"params": [
{
"name": "width",
"type": "uint32"
}
],
"returns": {
"list": "string"
},
"bindings": {
"node": {
"name": "render"
}
}
}
]
}
]
}
72 changes: 72 additions & 0 deletions crates/pidgin-napi/src/core_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -931,3 +931,75 @@ impl crate::generated::StdinBufferCoreCore for StdinBufferCoreImpl {
self.inner.lock().unwrap().clear();
}
}

use crate::generated::InputEvent;

/// Internal per-call event cell for [`InputCoreImpl`]. pi's `Input` fires
/// `onSubmit`/`onEscape` synchronously during `handleInput`; the core cannot
/// call JS closures, so the wired seams record any submit/escape that fired into
/// this cell and `handle_input` drains it into an [`InputEvent`] for the shim to
/// replay.
#[derive(Default)]
struct InputEventState {
submit: Option<String>,
escape: bool,
}

/// The engine-backed implementation of the generated `InputCore` contract.
///
/// Authored `#[fluessig(single_threaded)]`, so the generated handle holds this
/// core THREAD-CONFINED in a `RefCell<InputCoreImpl>` — no `Arc`, no
/// `Send`/`Sync` — which is exactly what lets a `!Send` core compile: it owns
/// pi's `Input` (whose `on_submit`/`on_escape` are non-`Send` boxed closures)
/// plus an `Rc<RefCell<…>>` event cell captured by those closures. Every op is
/// `&mut self`, reached from the handle through `RefCell::borrow_mut()`, so the
/// JS-visible behavior is byte-for-byte unchanged from the pre-swap hand-written
/// class.
pub struct InputCoreImpl {
inner: pidgin_tui::Input,
events: std::rc::Rc<std::cell::RefCell<InputEventState>>,
}

impl crate::generated::InputCoreCore for InputCoreImpl {
fn new() -> anyhow::Result<Self> {
let events = std::rc::Rc::new(std::cell::RefCell::new(InputEventState::default()));
let mut inner = pidgin_tui::Input::new();
{
let ev = events.clone();
inner.on_submit = Some(Box::new(move |value| {
ev.borrow_mut().submit = Some(value);
}));
let ev = events.clone();
inner.on_escape = Some(Box::new(move || {
ev.borrow_mut().escape = true;
}));
}
Ok(Self { inner, events })
}

fn get_value(&mut self) -> String {
self.inner.get_value()
}

fn set_value(&mut self, value: String) {
self.inner.set_value(&value);
}

fn set_focused(&mut self, focused: bool) {
self.inner.focused = focused;
}

fn handle_input(&mut self, data: String) -> InputEvent {
*self.events.borrow_mut() = InputEventState::default();
self.inner.handle_input_str(&data);
let ev = self.events.borrow();
InputEvent {
submit: ev.submit.clone(),
escape: ev.escape,
}
}

fn render(&mut self, width: u32) -> Vec<String> {
self.inner.render_lines(width as usize)
}
}
71 changes: 71 additions & 0 deletions crates/pidgin-napi/src/generated.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// The fixed prelude — generated code uses fully-qualified paths elsewhere.
use napi::bindgen_prelude::Result;
use napi_derive::napi;
use std::cell::RefCell;
use std::sync::Arc;

fn err(e: impl std::fmt::Display) -> napi::Error {
Expand Down Expand Up @@ -64,6 +65,17 @@ pub struct FuzzyMatchResult {
pub score: f64,
}

/// Event surfaced by [`InputCore::handle_input`] so the JS shim can fire pi's
/// `onSubmit`/`onEscape` callbacks. `submit` is the submitted value (pi passes
/// the current value) or `null` when no submit fired; `escape` is `true` when a
/// cancel/escape fired.
#[napi(object)]
#[derive(Clone)]
pub struct InputEvent {
pub submit: Option<String>,
pub escape: bool,
}

/// The `Pidgin` contract — implement over the engine in `crate::core_impl`.
pub trait PidginCore: Sized + Send + Sync + 'static {
fn version() -> String;
Expand Down Expand Up @@ -168,6 +180,16 @@ pub trait StdinBufferCoreCore: Sized + Send + Sync + 'static {
fn clear(&self) -> ();
}

/// The `InputCore` contract — implement over the engine in `crate::core_impl`.
pub trait InputCoreCore: Sized + 'static {
fn new() -> anyhow::Result<Self>;
fn get_value(&mut self) -> String;
fn set_value(&mut self, value: String) -> ();
fn set_focused(&mut self, focused: bool) -> ();
fn handle_input(&mut self, data: String) -> InputEvent;
fn render(&mut self, width: u32) -> Vec<String>;
}

/// Returns the crate version. Proves the native addon builds and loads.
///
/// Exported to JavaScript as `pidginNativeVersion`.
Expand Down Expand Up @@ -701,3 +723,52 @@ impl StdinBufferCore {
self.core.clear()
}
}

/// The Rust-backed single-line input core, exposed to JavaScript as
/// `InputCore`.
#[napi]
pub struct InputCore {
// pub(crate): the @manual ops in lib.rs extend this class and need the core
pub(crate) core: RefCell<crate::core_impl::InputCoreImpl>,
}

#[napi]
impl InputCore {
/// Create an empty input core, wiring pi's `onSubmit`/`onEscape` seams to a
/// shared event cell that `handle_input` drains after each call.
#[napi(constructor)]
pub fn new() -> Result<Self> {
Ok(Self {
core: RefCell::new(
<crate::core_impl::InputCoreImpl as InputCoreCore>::new().map_err(err)?,
),
})
}
/// pi's `getValue()`: the current value.
#[napi(js_name = "getValue")]
pub fn get_value(&self) -> String {
self.core.borrow_mut().get_value()
}
/// pi's `setValue(value)`: set the value, clamping the cursor.
#[napi(js_name = "setValue")]
pub fn set_value(&self, value: String) -> () {
self.core.borrow_mut().set_value(value)
}
/// pi's `focused` field setter — routed here because render reads it.
#[napi(js_name = "setFocused")]
pub fn set_focused(&self, focused: bool) -> () {
self.core.borrow_mut().set_focused(focused)
}
/// pi's `handleInput(data)`: process a chunk of terminal input, returning any
/// `onSubmit`/`onEscape` that fired so the shim can replay it onto the JS
/// callbacks.
#[napi(js_name = "handleInput")]
pub fn handle_input(&self, data: String) -> InputEvent {
self.core.borrow_mut().handle_input(data)
}
/// pi's `render(width)`: render the input to a single line.
#[napi(js_name = "render")]
pub fn render(&self, width: u32) -> Vec<String> {
self.core.borrow_mut().render(width)
}
}
Loading