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