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
129 changes: 129 additions & 0 deletions crates/pidgin-napi/schema/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -1503,6 +1503,135 @@
}
}
]
},
{
"name": "SelectListCore",
"doc": "The Rust-backed select-list core, exposed to JavaScript as `SelectListCore`.\nConstructed with an identity theme and no `truncatePrimary` override; the\nshim only builds one when pi's theme is identity and no override is set.",
"single_threaded": true,
"ops": [
{
"name": "new",
"doc": "Build a core from pi's `items` (JSON array of `{ value, label,\ndescription? }`), `maxVisible`, and the optional\n`minPrimaryColumnWidth`/`maxPrimaryColumnWidth` layout bounds.",
"shape": "ctor",
"params": [
{
"name": "itemsJson",
"type": "string"
},
{
"name": "maxVisible",
"type": "int64"
},
{
"name": "minPrimaryColumnWidth",
"type": {
"nullable": "int64"
}
},
{
"name": "maxPrimaryColumnWidth",
"type": {
"nullable": "int64"
}
}
],
"returns": "void"
},
{
"name": "setFilter",
"doc": "pi's `setFilter(filter)`: case-insensitive `value` prefix filter.",
"shape": "unary",
"infallible": true,
"readonly": false,
"params": [
{
"name": "filter",
"type": "string"
}
],
"returns": "void",
"bindings": {
"node": {
"name": "setFilter"
}
}
},
{
"name": "setSelectedIndex",
"doc": "pi's `setSelectedIndex(index)`: clamp the selection into range.",
"shape": "unary",
"infallible": true,
"readonly": false,
"params": [
{
"name": "index",
"type": "int64"
}
],
"returns": "void",
"bindings": {
"node": {
"name": "setSelectedIndex"
}
}
},
{
"name": "handleInput",
"doc": "pi's `handleInput(keyData)`: move/confirm/cancel. Callbacks are handled by\nthe shim's original instance; the core only advances selection state.",
"shape": "unary",
"infallible": true,
"readonly": false,
"params": [
{
"name": "keyData",
"type": "string"
}
],
"returns": "void",
"bindings": {
"node": {
"name": "handleInput"
}
}
},
{
"name": "getSelectedItemJson",
"doc": "pi's `getSelectedItem()` as JSON (`{ value, label, description? }`), or\n`null` when the filtered list is empty.",
"shape": "unary",
"infallible": false,
"readonly": true,
"params": [],
"returns": {
"nullable": "string"
},
"bindings": {
"node": {
"name": "getSelectedItemJson"
}
}
},
{
"name": "render",
"doc": "pi's `render(width)`: render the list to lines (identity theme baked in).",
"shape": "unary",
"infallible": true,
"readonly": true,
"params": [
{
"name": "width",
"type": "uint32"
}
],
"returns": {
"list": "string"
},
"bindings": {
"node": {
"name": "render"
}
}
}
]
}
]
}
93 changes: 93 additions & 0 deletions crates/pidgin-napi/src/core_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1003,3 +1003,96 @@ impl crate::generated::InputCoreCore for InputCoreImpl {
self.inner.render_lines(width as usize)
}
}

/// Deserialized shape of pi's select-list `items` (a JSON array of `{ value,
/// label, description? }`) as they cross into [`SelectListCoreImpl::new`].
#[derive(serde::Deserialize)]
struct SelectItemIn {
value: String,
label: String,
description: Option<String>,
}

/// The identity theme baked into [`SelectListCoreImpl`]: every theme hook is the
/// identity function. pi's real theme hooks are JS closures that cannot cross
/// the addon boundary, so the shim only routes `render` through this core when
/// pi's theme is identity and no `truncatePrimary` override is set.
fn identity_select_theme() -> pidgin_tui::SelectListTheme {
pidgin_tui::SelectListTheme {
selected_prefix: Box::new(|s| s.to_string()),
selected_text: Box::new(|s| s.to_string()),
description: Box::new(|s| s.to_string()),
scroll_info: Box::new(|s| s.to_string()),
no_match: Box::new(|s| s.to_string()),
}
}

/// The engine-backed implementation of the generated `SelectListCore` contract.
///
/// Authored `#[fluessig(single_threaded)]`, so the generated handle holds this
/// core THREAD-CONFINED in a `RefCell<SelectListCoreImpl>` — no `Arc`, no
/// `Send`/`Sync` — which is exactly what lets a `!Send` core compile: it owns
/// pi's `SelectList`, whose baked-in [`SelectListTheme`] hooks are non-`Send`
/// boxed closures (`Box<dyn Fn(&str) -> String>`). 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 SelectListCoreImpl {
inner: pidgin_tui::SelectList,
}

impl crate::generated::SelectListCoreCore for SelectListCoreImpl {
fn new(
items_json: String,
max_visible: i64,
min_primary_column_width: Option<i64>,
max_primary_column_width: Option<i64>,
) -> anyhow::Result<Self> {
let items_in: Vec<SelectItemIn> =
serde_json::from_str(&items_json).map_err(|e| anyhow::anyhow!("invalid items: {e}"))?;
let items: Vec<pidgin_tui::SelectItem> = items_in
.into_iter()
.map(|i| pidgin_tui::SelectItem {
value: i.value,
label: i.label,
description: i.description,
})
.collect();
let layout = pidgin_tui::SelectListLayoutOptions {
min_primary_column_width,
max_primary_column_width,
truncate_primary: None,
};
Ok(Self {
inner: pidgin_tui::SelectList::new(items, max_visible, identity_select_theme(), layout),
})
}

fn set_filter(&mut self, filter: String) {
self.inner.set_filter(&filter);
}

fn set_selected_index(&mut self, index: i64) {
self.inner.set_selected_index(index);
}

fn handle_input(&mut self, key_data: String) {
self.inner.handle_input_str(&key_data);
}

fn get_selected_item_json(&mut self) -> anyhow::Result<Option<String>> {
match self.inner.get_selected_item() {
Some(item) => serde_json::to_string(&serde_json::json!({
"value": item.value,
"label": item.label,
"description": item.description,
}))
.map(Some)
.map_err(|e| anyhow::anyhow!(e.to_string())),
None => Ok(None),
}
}

fn render(&mut self, width: u32) -> Vec<String> {
self.inner.render_lines(width as usize)
}
}
77 changes: 77 additions & 0 deletions crates/pidgin-napi/src/generated.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,21 @@ pub trait InputCoreCore: Sized + 'static {
fn render(&mut self, width: u32) -> Vec<String>;
}

/// The `SelectListCore` contract — implement over the engine in `crate::core_impl`.
pub trait SelectListCoreCore: Sized + 'static {
fn new(
items_json: String,
max_visible: i64,
min_primary_column_width: Option<i64>,
max_primary_column_width: Option<i64>,
) -> anyhow::Result<Self>;
fn set_filter(&mut self, filter: String) -> ();
fn set_selected_index(&mut self, index: i64) -> ();
fn handle_input(&mut self, key_data: String) -> ();
fn get_selected_item_json(&mut self) -> anyhow::Result<Option<String>>;
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 @@ -772,3 +787,65 @@ impl InputCore {
self.core.borrow_mut().render(width)
}
}

/// The Rust-backed select-list core, exposed to JavaScript as `SelectListCore`.
/// Constructed with an identity theme and no `truncatePrimary` override; the
/// shim only builds one when pi's theme is identity and no override is set.
#[napi]
pub struct SelectListCore {
// pub(crate): the @manual ops in lib.rs extend this class and need the core
pub(crate) core: RefCell<crate::core_impl::SelectListCoreImpl>,
}

#[napi]
impl SelectListCore {
/// Build a core from pi's `items` (JSON array of `{ value, label,
/// description? }`), `maxVisible`, and the optional
/// `minPrimaryColumnWidth`/`maxPrimaryColumnWidth` layout bounds.
#[napi(constructor)]
pub fn new(
items_json: String,
max_visible: i64,
min_primary_column_width: Option<i64>,
max_primary_column_width: Option<i64>,
) -> Result<Self> {
Ok(Self {
core: RefCell::new(
<crate::core_impl::SelectListCoreImpl as SelectListCoreCore>::new(
items_json,
max_visible,
min_primary_column_width,
max_primary_column_width,
)
.map_err(err)?,
),
})
}
/// pi's `setFilter(filter)`: case-insensitive `value` prefix filter.
#[napi(js_name = "setFilter")]
pub fn set_filter(&self, filter: String) -> () {
self.core.borrow_mut().set_filter(filter)
}
/// pi's `setSelectedIndex(index)`: clamp the selection into range.
#[napi(js_name = "setSelectedIndex")]
pub fn set_selected_index(&self, index: i64) -> () {
self.core.borrow_mut().set_selected_index(index)
}
/// pi's `handleInput(keyData)`: move/confirm/cancel. Callbacks are handled by
/// the shim's original instance; the core only advances selection state.
#[napi(js_name = "handleInput")]
pub fn handle_input(&self, key_data: String) -> () {
self.core.borrow_mut().handle_input(key_data)
}
/// pi's `getSelectedItem()` as JSON (`{ value, label, description? }`), or
/// `null` when the filtered list is empty.
#[napi(js_name = "getSelectedItemJson")]
pub fn get_selected_item_json(&self) -> Result<Option<String>> {
self.core.borrow_mut().get_selected_item_json().map_err(err)
}
/// pi's `render(width)`: render the list to lines (identity theme baked in).
#[napi(js_name = "render")]
pub fn render(&self, width: u32) -> Vec<String> {
self.core.borrow_mut().render(width)
}
}
Loading