feat(core): Add error types, config, and data model for items and events.
This commit is contained in:
29
crates/pikl-core/src/config.rs
Normal file
29
crates/pikl-core/src/config.rs
Normal file
@@ -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<String>,
|
||||||
|
|
||||||
|
/// Shell command to run when the user cancels.
|
||||||
|
pub on_cancel: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
crates/pikl-core/src/error.rs
Normal file
25
crates/pikl-core/src/error.rs
Normal file
@@ -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),
|
||||||
|
}
|
||||||
22
crates/pikl-core/src/lib.rs
Normal file
22
crates/pikl-core/src/lib.rs
Normal file
@@ -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;
|
||||||
73
crates/pikl-core/src/model/event.rs
Normal file
73
crates/pikl-core/src/model/event.rs
Normal file
@@ -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<Value>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<VisibleItem>,
|
||||||
|
/// Index of the cursor within `visible_items`
|
||||||
|
/// (not the full list).
|
||||||
|
pub cursor: usize,
|
||||||
|
pub filter_text: Arc<str>,
|
||||||
|
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,
|
||||||
|
}
|
||||||
131
crates/pikl-core/src/model/item.rs
Normal file
131
crates/pikl-core/src/model/item.rs
Normal file
@@ -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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
self.value.serialize(serializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> serde::Deserialize<'de> for Item {
|
||||||
|
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
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"}"#);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
crates/pikl-core/src/model/mod.rs
Normal file
3
crates/pikl-core/src/model/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod event;
|
||||||
|
pub mod item;
|
||||||
|
pub mod traits;
|
||||||
41
crates/pikl-core/src/model/traits.rs
Normal file
41
crates/pikl-core/src/model/traits.rs
Normal file
@@ -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<serde_json::Value>);
|
||||||
|
|
||||||
|
/// 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>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user