diff --git a/crates/pidgin-napi/schema/api.json b/crates/pidgin-napi/schema/api.json index e7d7c84..d9a0538 100644 --- a/crates/pidgin-napi/schema/api.json +++ b/crates/pidgin-napi/schema/api.json @@ -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" + } + } + } + ] } ] } diff --git a/crates/pidgin-napi/src/core_impl.rs b/crates/pidgin-napi/src/core_impl.rs index d62d932..a6b754c 100644 --- a/crates/pidgin-napi/src/core_impl.rs +++ b/crates/pidgin-napi/src/core_impl.rs @@ -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, +} + +/// 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` — 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 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, + max_primary_column_width: Option, + ) -> anyhow::Result { + let items_in: Vec = + serde_json::from_str(&items_json).map_err(|e| anyhow::anyhow!("invalid items: {e}"))?; + let items: Vec = 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> { + 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 { + self.inner.render_lines(width as usize) + } +} diff --git a/crates/pidgin-napi/src/generated.rs b/crates/pidgin-napi/src/generated.rs index 3081c88..8d25039 100644 --- a/crates/pidgin-napi/src/generated.rs +++ b/crates/pidgin-napi/src/generated.rs @@ -190,6 +190,21 @@ pub trait InputCoreCore: Sized + 'static { fn render(&mut self, width: u32) -> Vec; } +/// 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, + max_primary_column_width: Option, + ) -> anyhow::Result; + 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>; + fn render(&mut self, width: u32) -> Vec; +} + /// Returns the crate version. Proves the native addon builds and loads. /// /// Exported to JavaScript as `pidginNativeVersion`. @@ -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, +} + +#[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, + max_primary_column_width: Option, + ) -> Result { + Ok(Self { + core: RefCell::new( + ::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> { + 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 { + self.core.borrow_mut().render(width) + } +} diff --git a/crates/pidgin-napi/src/lib.rs b/crates/pidgin-napi/src/lib.rs index dbb1ce9..890f57e 100644 --- a/crates/pidgin-napi/src/lib.rs +++ b/crates/pidgin-napi/src/lib.rs @@ -879,112 +879,13 @@ pub fn markdown_render(source: String, width: u32) -> Vec { // --- tui select-list layer (packages/tui/src/components/select-list.ts) ----- // -// A stateful `#[napi]` class wrapping `pidgin_tui::SelectList`. pi's `render` -// composes JS theme callbacks (`selectedText`, `description`, `scrollInfo`, -// `noMatch`, `selectedPrefix`) and an optional `truncatePrimary` override — JS -// closures that cannot cross the addon boundary. The hand-written -// `select-list.ts` shim therefore routes `render` through this core ONLY when -// the theme functions are all identity and no `truncatePrimary` override is -// supplied (the core bakes in an identity theme and no override); every other -// construction delegates to pi's original class. Item text and layout bounds -// cross as JSON / numbers; selection and filter state live in the core so the -// shim can keep it in sync for `render`. - -#[derive(serde::Deserialize)] -struct SelectItemIn { - value: String, - label: String, - description: Option, -} - -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 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(js_name = "SelectListCore")] -pub struct SelectListCore { - inner: pidgin_tui::SelectList, -} - -#[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, - max_primary_column_width: Option, - ) -> napi::Result { - let items_in: Vec = serde_json::from_str(&items_json) - .map_err(|e| napi::Error::from_reason(format!("invalid items: {e}")))?; - let items: Vec = 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), - }) - } - - /// pi's `setFilter(filter)`: case-insensitive `value` prefix filter. - #[napi(js_name = "setFilter")] - pub fn set_filter(&mut self, filter: String) { - self.inner.set_filter(&filter); - } - - /// pi's `setSelectedIndex(index)`: clamp the selection into range. - #[napi(js_name = "setSelectedIndex")] - pub fn set_selected_index(&mut self, index: i64) { - self.inner.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(&mut self, key_data: String) { - self.inner.handle_input_str(&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) -> napi::Result> { - 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| napi::Error::from_reason(e.to_string())), - None => Ok(None), - } - } - - /// pi's `render(width)`: render the list to lines (identity theme baked in). - #[napi(js_name = "render")] - pub fn render(&self, width: u32) -> Vec { - self.inner.render_lines(width as usize) - } -} +// The select-list `SelectListCore` now GENERATES from the fluessig api schema +// through `crate::generated` + `crate::core_impl` (the `SelectListCoreImpl` +// engine seam). It is authored `#[fluessig(single_threaded)]`, so the generated +// handle holds the `!Send` core THREAD-CONFINED in a `RefCell` — no `Arc`, +// no `Send`/`Sync` bound — which is exactly what lets it compile: the core owns +// pi's `SelectList`, whose baked-in identity theme hooks are non-`Send` boxed +// closures (`Box String>`). The JS `select-list.ts` shim keeps +// pi's theme callbacks and `truncatePrimary` override in JS and only routes +// `render` through this core when the theme is identity and no override is set — +// unchanged by the swap. See src/generated.rs and schema/api.json. Additive.