diff --git a/crates/pikl-core/src/config.rs b/crates/pikl-core/src/config.rs new file mode 100644 index 0000000..d646b7e --- /dev/null +++ b/crates/pikl-core/src/config.rs @@ -0,0 +1,29 @@ +//! Runtime configuration for a menu instance. Built from CLI +//! args or programmatic defaults. This is not the on-disk +//! config file (that's a future thing). + +/// Controls how the menu behaves for a single invocation. +pub struct MenuConfig { + /// JSON key used to extract display labels from object + /// items. Ignored for plain-text (string) items. + pub label_key: String, + + /// Shell command to run when the user confirms a selection. + /// The selected item's JSON is piped to stdin. + pub on_select: Option, + + /// Shell command to run when the user cancels. + pub on_cancel: Option, +} + +impl Default for MenuConfig { + /// Returns a config with `label_key` set to `"label"` and + /// no hooks. + fn default() -> Self { + Self { + label_key: "label".to_string(), + on_select: None, + on_cancel: None, + } + } +} diff --git a/crates/pikl-core/src/error.rs b/crates/pikl-core/src/error.rs new file mode 100644 index 0000000..c1d3a81 --- /dev/null +++ b/crates/pikl-core/src/error.rs @@ -0,0 +1,25 @@ +//! Error types for pikl-core. Every failure mode gets its own +//! variant. No stringly typed errors, no anyhow. + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PiklError { + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + + #[error("hook failed: {command} exited with {status}")] + HookFailed { + command: String, + status: std::process::ExitStatus, + }, + + #[error("channel closed")] + ChannelClosed, + + #[error("{0}")] + Script(#[from] crate::script::action_fd::ScriptError), +} diff --git a/crates/pikl-core/src/lib.rs b/crates/pikl-core/src/lib.rs new file mode 100644 index 0000000..0ce92b7 --- /dev/null +++ b/crates/pikl-core/src/lib.rs @@ -0,0 +1,22 @@ +//! Core engine for pikl-menu. Handles filtering, navigation, +//! hooks, and the menu event loop. No rendering or terminal +//! deps. Frontends are separate crates that talk to this +//! through channels. + +mod model; +mod query; +mod runtime; +pub mod script; + +pub mod error; + +// Re-export submodules at crate root so the public API stays flat. +pub use model::event; +pub use model::item; +pub use model::traits; +pub use query::filter; +pub use query::navigation; +pub use runtime::hook; +pub use runtime::input; +pub use runtime::json_menu; +pub use runtime::menu; diff --git a/crates/pikl-core/src/model/event.rs b/crates/pikl-core/src/model/event.rs new file mode 100644 index 0000000..1769b8b --- /dev/null +++ b/crates/pikl-core/src/model/event.rs @@ -0,0 +1,73 @@ +//! Types that flow between the menu engine and frontends. +//! +//! - [`Action`]: commands sent into the menu (keypresses, +//! script actions). +//! - [`MenuEvent`]: notifications sent out to subscribers +//! (state changes, terminal events). +//! - [`ViewState`]: snapshot of what the frontend should +//! render right now. + +use std::sync::Arc; + +use serde_json::Value; + +/// A command the menu should process. Frontends and headless +/// scripts both produce these. The menu loop consumes them +/// sequentially. +#[derive(Debug, Clone, PartialEq)] +pub enum Action { + UpdateFilter(String), + MoveUp(usize), + MoveDown(usize), + MoveToTop, + MoveToBottom, + PageUp(usize), + PageDown(usize), + Confirm, + Cancel, + Resize { height: u16 }, + AddItems(Vec), +} + +/// Broadcast from the menu loop to all subscribers +/// (frontends, tests). +#[derive(Debug, Clone)] +pub enum MenuEvent { + StateChanged(ViewState), + Selected(Value), + Cancelled, +} + +/// Snapshot of the menu's visible state. Sent on every state +/// change so frontends can render without querying back into +/// the engine. +#[must_use] +#[derive(Debug, Clone)] +pub struct ViewState { + pub visible_items: Vec, + /// Index of the cursor within `visible_items` + /// (not the full list). + pub cursor: usize, + pub filter_text: Arc, + pub total_items: usize, + pub total_filtered: usize, +} + +/// A single item in the current viewport window. Has the +/// display label pre-resolved and the position in the +/// filtered list. +#[must_use] +#[derive(Debug, Clone)] +pub struct VisibleItem { + pub label: String, + pub index: usize, +} + +/// Final outcome of [`crate::menu::MenuRunner::run`]. The +/// user either picked something or bailed. +#[must_use] +#[derive(Debug)] +pub enum MenuResult { + Selected(Value), + Cancelled, +} diff --git a/crates/pikl-core/src/model/item.rs b/crates/pikl-core/src/model/item.rs new file mode 100644 index 0000000..4ee6ec3 --- /dev/null +++ b/crates/pikl-core/src/model/item.rs @@ -0,0 +1,131 @@ +//! The core data unit. An [`Item`] is a single entry in the +//! menu, either a plain-text string or a JSON object with +//! structured fields. + +use serde_json::Value; + +/// A menu entry wrapping a JSON value. Plain text is stored +/// as `Value::String`, structured entries as +/// `Value::Object`. The label is extracted and cached at +/// construction time so display calls are just a pointer +/// dereference. +/// +/// Serializes as the inner Value (transparent for output) +/// but construction always requires label extraction. +#[derive(Debug, Clone)] +pub struct Item { + pub value: Value, + label_cache: String, +} + +impl Item { + /// Create an Item from a JSON value, extracting the display + /// label from the given key. String values use the string + /// itself. Object values look up the key. + pub fn new(value: Value, label_key: &str) -> Self { + let label_cache = extract_label(&value, label_key).to_string(); + Self { value, label_cache } + } + + /// Wrap a plain-text string as an Item. Stored internally + /// as `Value::String` with the label cached. + pub fn from_plain_text(line: &str) -> Self { + Self { + value: Value::String(line.to_string()), + label_cache: line.to_string(), + } + } + + /// Get the display label for this item. Cached at + /// construction time, so this is just a borrow. + pub fn label(&self) -> &str { + &self.label_cache + } +} + +/// Extract the display label from a JSON value. +fn extract_label<'a>(value: &'a Value, key: &str) -> &'a str { + match value { + Value::String(s) => s.as_str(), + Value::Object(map) => map.get(key).and_then(|v| v.as_str()).unwrap_or(""), + _ => "", + } +} + +impl serde::Serialize for Item { + fn serialize(&self, serializer: S) -> Result { + self.value.serialize(serializer) + } +} + +impl<'de> serde::Deserialize<'de> for Item { + fn deserialize>(deserializer: D) -> Result { + let value = Value::deserialize(deserializer)?; + // Default to "label" key when deserializing. Callers that need + // a different key should construct via Item::new() instead. + Ok(Item::new(value, "label")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn label_from_string() { + let item = Item::new(json!("hello"), "label"); + assert_eq!(item.label(), "hello"); + } + + #[test] + fn label_from_object_with_key() { + let item = Item::new(json!({"label": "foo", "value": 42}), "label"); + assert_eq!(item.label(), "foo"); + } + + #[test] + fn label_from_object_missing_key() { + let item = Item::new(json!({"name": "bar"}), "label"); + assert_eq!(item.label(), ""); + } + + #[test] + fn label_from_object_custom_key() { + let item = Item::new(json!({"name": "bar", "label": "ignored"}), "name"); + assert_eq!(item.label(), "bar"); + } + + #[test] + fn label_from_number() { + let item = Item::new(json!(42), "label"); + assert_eq!(item.label(), ""); + } + + #[test] + fn label_from_null() { + let item = Item::new(json!(null), "label"); + assert_eq!(item.label(), ""); + } + + #[test] + fn from_plain_text() { + let item = Item::from_plain_text("hello world"); + assert_eq!(item.label(), "hello world"); + assert!(item.value.is_string()); + } + + #[test] + fn serialize_string_item() { + let item = Item::from_plain_text("test"); + let json = serde_json::to_string(&item).unwrap_or_default(); + assert_eq!(json, "\"test\""); + } + + #[test] + fn serialize_object_item() { + let item = Item::new(json!({"label": "foo"}), "label"); + let json = serde_json::to_string(&item).unwrap_or_default(); + assert_eq!(json, r#"{"label":"foo"}"#); + } +} diff --git a/crates/pikl-core/src/model/mod.rs b/crates/pikl-core/src/model/mod.rs new file mode 100644 index 0000000..150b115 --- /dev/null +++ b/crates/pikl-core/src/model/mod.rs @@ -0,0 +1,3 @@ +pub mod event; +pub mod item; +pub mod traits; diff --git a/crates/pikl-core/src/model/traits.rs b/crates/pikl-core/src/model/traits.rs new file mode 100644 index 0000000..537aebb --- /dev/null +++ b/crates/pikl-core/src/model/traits.rs @@ -0,0 +1,41 @@ +//! Trait abstractions for the menu data layer. [`Menu`] +//! is what the event loop drives. [`MenuItem`] is the display +//! contract for a single entry. Different backends (JSON, +//! CSV, Postgres, etc.) implement these. + +/// Display contract for a single entry in a menu. +/// Provides a label for display and serialization for output. +pub trait MenuItem: Send + Sync + 'static { + /// Human-readable display text for this entry. + fn label(&self) -> &str; + + /// Serialize this entry for structured output (stdout, hooks). + fn serialize(&self) -> serde_json::Value; +} + +/// A filterable collection that the menu event loop drives. +/// Each implementation owns its data and handles filtering +/// internally. The event loop only needs labels (strings) +/// and serialized output (JSON values). +pub trait Menu: Send + 'static { + /// Total number of items before filtering. + fn total(&self) -> usize; + + /// Apply a filter query. Implementations decide how to + /// match (fuzzy, regex, exact, SQL WHERE, etc.). + fn apply_filter(&mut self, query: &str); + + /// Number of items that passed the current filter. + fn filtered_count(&self) -> usize; + + /// Get the display label for a filtered item by its + /// position in the filtered results. + fn filtered_label(&self, filtered_index: usize) -> Option<&str>; + + /// Add raw values from streaming input or AddItems actions. + fn add_raw(&mut self, values: Vec); + + /// Get the JSON value of a filtered item for output. + /// Returns a reference to the stored value. + fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::Value>; +}