Compare commits

..

7 Commits

Author SHA1 Message Date
f6f1efdf8e doc: Add install guide instructions.
Some checks are pending
CI / Lint (push) Waiting to run
CI / Test (push) Blocked by required conditions
2026-03-14 12:50:27 -04:00
c6d80a9650 log: Add tracing events through core for future debugability. 2026-03-14 12:31:20 -04:00
bb3f200141 refactor(core): Split up the large menu trait. 2026-03-14 11:48:27 -04:00
a27d529fa7 feat(frontend): Add generations counter to inform frontends when a list should be re-rendered. 2026-03-14 11:47:27 -04:00
e5c875389c feat: Add graceful shutdown of active hooks. 2026-03-14 11:46:04 -04:00
e89c6cb16f refactor: Eliminate unnecessary empty Vec constructions. 2026-03-14 11:46:04 -04:00
0ebb5e79dd feat(action): Add gitea action for continuous integration.
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
2026-03-14 01:55:32 -04:00
19 changed files with 621 additions and 121 deletions

View File

@@ -10,30 +10,16 @@ env:
CARGO_TERM_COLOR: always
jobs:
check:
name: Check
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
lint:
name: Lint
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- name: Check
run: cargo check --workspace --all-targets
clippy:
name: Clippy (strict)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- name: Format
run: cargo fmt --all -- --check
- name: Clippy
run: |
cargo clippy --workspace --all-targets -- \
@@ -47,26 +33,12 @@ jobs:
-D clippy::print_stdout \
-D clippy::print_stderr
fmt:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Rustfmt
run: cargo fmt --all -- --check
test:
name: Test
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: self-hosted
needs: lint
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Run tests
run: cargo test --workspace

1
Cargo.lock generated
View File

@@ -1117,6 +1117,7 @@ dependencies = [
"pikl-core",
"ratatui",
"tokio",
"tracing",
]
[[package]]

View File

@@ -1,4 +1,4 @@
# pikl-menu
# pikl
Pipe stuff in, pick stuff out.
@@ -95,6 +95,30 @@ or at all:
overlay on Wayland (layer-shell) and X11. Auto-detects your
environment.
## Install
See [docs/guides/install.md](docs/guides/install.md) for full details.
The short version:
```sh
cargo install pikl
```
Or from source:
```sh
git clone https://github.com/maplecool/pikl-menu.git
cd pikl-menu
cargo install --path .
```
## Guides
- **[Install](docs/guides/install.md):** cargo install, building from source
- **[App Launcher](docs/guides/app-launcher.md):** set up pikl as a
keyboard-driven app launcher on Hyprland, i3, macOS (Raycast), or
in a terminal
## Building
```sh

View File

@@ -72,6 +72,10 @@ pub struct ViewState {
pub total_items: usize,
pub total_filtered: usize,
pub mode: Mode,
/// Monotonically increasing counter. Each call to
/// `build_view_state()` bumps this, so frontends can
/// detect duplicate broadcasts and skip redundant redraws.
pub generation: u64,
}
/// A single item in the current viewport window. Has the

View File

@@ -32,9 +32,6 @@ pub trait Menu: Send + 'static {
/// 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>;
@@ -44,14 +41,6 @@ pub trait Menu: Send + 'static {
/// for output and hook events.
fn original_index(&self, filtered_index: usize) -> Option<usize>;
/// Replace all items with a new set of values. Used by
/// handler hook `replace_items` responses.
fn replace_all(&mut self, values: Vec<serde_json::Value>);
/// Remove items at the given original indices. Used by
/// handler hook `remove_items` responses.
fn remove_by_indices(&mut self, indices: Vec<usize>);
/// Get the formatted display text for a filtered item,
/// if a format template is configured. Returns None if
/// no template is set, in which case the raw label is
@@ -73,3 +62,20 @@ pub trait Menu: Send + 'static {
.collect()
}
}
/// Extension of [`Menu`] with mutation methods. Required by
/// [`MenuRunner`] which needs to add, replace, and remove
/// items. Embedders that only need read-only access can
/// depend on `Menu` alone.
pub trait MutableMenu: Menu {
/// Add raw values from streaming input or AddItems actions.
fn add_raw(&mut self, values: Vec<serde_json::Value>);
/// Replace all items with a new set of values. Used by
/// handler hook `replace_items` responses.
fn replace_all(&mut self, values: Vec<serde_json::Value>);
/// Remove items at the given original indices. Used by
/// handler hook `remove_items` responses.
fn remove_by_indices(&mut self, indices: Vec<usize>);
}

View File

@@ -4,6 +4,7 @@
//! caching: unchanged stages keep their results.
use serde_json::Value;
use tracing::{debug, trace};
use super::filter::{Filter, FuzzyFilter};
use super::strategy::{self, FilterKind};
@@ -135,11 +136,15 @@ impl FilterPipeline {
/// Evaluate all dirty stages in order. Each stage filters
/// against the previous stage's cached_indices.
fn evaluate(&mut self) {
let _span = tracing::trace_span!("pipeline_evaluate").entered();
for stage_idx in 0..self.stages.len() {
if !self.stages[stage_idx].dirty {
trace!(stage_idx, dirty = false, "skipping clean stage");
continue;
}
trace!(stage_idx, dirty = true, kind = ?self.stages[stage_idx].kind, "evaluating stage");
let input_indices: Vec<usize> = if stage_idx == 0 {
self.items.iter().map(|(idx, _)| *idx).collect()
} else {
@@ -174,6 +179,7 @@ impl FilterPipeline {
}
};
trace!(stage_idx, matched = result.len(), "stage complete");
self.stages[stage_idx].cached_indices = result;
self.stages[stage_idx].dirty = false;
}
@@ -275,6 +281,7 @@ impl FilterPipeline {
/// items. Used after replace_all or remove_by_indices
/// to rebuild the pipeline from scratch.
pub fn rebuild(&mut self, items: &[(usize, &str)]) {
debug!(item_count = items.len(), "pipeline rebuilt");
self.items.clear();
self.item_values.clear();
self.stages.clear();
@@ -288,6 +295,7 @@ impl FilterPipeline {
/// Clear all items and stages, then re-push with values.
/// Used when field filters need access to item JSON.
pub fn rebuild_with_values(&mut self, items: &[(usize, &str, &Value)]) {
debug!(item_count = items.len(), "pipeline rebuilt");
self.items.clear();
self.item_values.clear();
self.stages.clear();
@@ -325,6 +333,7 @@ impl Filter for FilterPipeline {
fn set_query(&mut self, query: &str) {
self.last_raw_query = query.to_string();
let segments = split_pipeline(query);
debug!(query = %query, stage_count = segments.len(), "pipeline query set");
// Reconcile stages with new segments
let mut new_len = segments.len();

View File

@@ -8,6 +8,7 @@ use std::time::Duration;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tracing::Instrument;
use crate::event::Action;
use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
@@ -29,7 +30,7 @@ pub enum DebounceMode {
/// behavior. Each event kind can have its own mode.
pub struct DebouncedDispatcher {
handler: Arc<dyn HookHandler>,
action_tx: mpsc::Sender<Action>,
_action_tx: mpsc::Sender<Action>,
modes: HashMap<HookEventKind, DebounceMode>,
in_flight: HashMap<HookEventKind, JoinHandle<()>>,
}
@@ -41,7 +42,7 @@ impl DebouncedDispatcher {
) -> Self {
Self {
handler,
action_tx,
_action_tx: action_tx,
modes: HashMap::new(),
in_flight: HashMap::new(),
}
@@ -76,6 +77,8 @@ impl DebouncedDispatcher {
.cloned()
.unwrap_or(DebounceMode::None);
tracing::debug!(event_kind = ?kind, mode = ?mode, "dispatching hook event");
match mode {
DebounceMode::None => {
self.fire_now(event);
@@ -95,19 +98,15 @@ impl DebouncedDispatcher {
fn fire_now(&self, event: HookEvent) {
let handler = Arc::clone(&self.handler);
let action_tx = self.action_tx.clone();
tokio::spawn(async move {
match handler.handle(event) {
Ok(responses) => {
for resp in responses {
let _ = action_tx.send(Action::ProcessHookResponse(resp)).await;
}
}
Err(e) => {
let span = tracing::debug_span!("hook_fire", event_kind = ?event.kind());
tokio::spawn(
async move {
if let Err(e) = handler.handle(event) {
tracing::warn!(error = %e, "hook handler error");
}
}
});
.instrument(span),
);
}
fn fire_debounced(&mut self, event: HookEvent, delay: Duration, cancel: bool) {
@@ -116,27 +115,27 @@ impl DebouncedDispatcher {
self.cancel_in_flight(kind);
}
let delay_ms = delay.as_millis() as u64;
tracing::debug!(delay_ms, cancel_stale = cancel, "debounced hook scheduled");
let handler = Arc::clone(&self.handler);
let action_tx = self.action_tx.clone();
let handle = tokio::spawn(async move {
let span = tracing::debug_span!("hook_fire", event_kind = ?kind);
let handle = tokio::spawn(
async move {
tokio::time::sleep(delay).await;
match handler.handle(event) {
Ok(responses) => {
for resp in responses {
let _ = action_tx.send(Action::ProcessHookResponse(resp)).await;
}
}
Err(e) => {
if let Err(e) = handler.handle(event) {
tracing::warn!(error = %e, "hook handler error");
}
}
});
.instrument(span),
);
self.in_flight.insert(kind, handle);
}
fn cancel_in_flight(&mut self, kind: HookEventKind) {
if let Some(handle) = self.in_flight.remove(&kind) {
tracing::debug!(event_kind = ?kind, "cancelled in-flight hook");
handle.abort();
}
}
@@ -165,11 +164,11 @@ mod tests {
}
impl HookHandler for RecordingHandler {
fn handle(&self, event: HookEvent) -> Result<Vec<HookResponse>, PiklError> {
fn handle(&self, event: HookEvent) -> Result<(), PiklError> {
if let Ok(mut events) = self.events.lock() {
events.push(event.kind());
}
Ok(vec![])
Ok(())
}
}

View File

@@ -65,16 +65,15 @@ pub enum HookResponse {
}
/// Handler trait for lifecycle hooks. Implementations
/// receive events and optionally return responses.
/// Exec hooks return empty vecs. Handler hooks send
/// responses back through the action channel asynchronously
/// and also return empty vecs.
/// receive events and may produce side effects (spawning
/// processes, sending to channels). Responses flow through
/// the action channel, not the return value.
///
/// This is deliberately synchronous for dyn-compatibility.
/// Implementations that need async work (spawning processes,
/// writing to channels) should use `tokio::spawn` internally.
pub trait HookHandler: Send + Sync {
fn handle(&self, event: HookEvent) -> Result<Vec<HookResponse>, PiklError>;
fn handle(&self, event: HookEvent) -> Result<(), PiklError>;
}
/// Parse a single line of JSON as a [`HookResponse`].
@@ -82,7 +81,10 @@ pub trait HookHandler: Send + Sync {
/// tracing.
pub fn parse_hook_response(line: &str) -> Option<HookResponse> {
match serde_json::from_str::<HookResponse>(line) {
Ok(resp) => Some(resp),
Ok(resp) => {
tracing::debug!(response = ?resp, "parsed hook response");
Some(resp)
}
Err(e) => {
tracing::warn!(line, error = %e, "failed to parse hook response");
None

View File

@@ -34,6 +34,7 @@ pub fn read_items_sync(
}
items.push(parse_line(&line, label_key));
}
tracing::debug!(count = items.len(), "items read");
Ok(items)
}
@@ -51,6 +52,7 @@ pub async fn read_items(
}
items.push(parse_line(&line, label_key));
}
tracing::debug!(count = items.len(), "items read");
Ok(items)
}

View File

@@ -5,7 +5,7 @@
use crate::filter::Filter;
use crate::format::FormatTemplate;
use crate::item::Item;
use crate::model::traits::Menu;
use crate::model::traits::{Menu, MutableMenu};
use crate::pipeline::FilterPipeline;
/// A menu backed by a flat list of JSON items. Handles
@@ -24,6 +24,8 @@ pub struct JsonMenu {
impl JsonMenu {
/// Create a new JSON menu with the given items and label key.
pub fn new(items: Vec<Item>, label_key: String) -> Self {
let item_count = items.len();
tracing::debug!(item_count, label_key = %label_key, "json menu created");
let mut filter = FilterPipeline::new();
for (i, item) in items.iter().enumerate() {
filter.push_with_value(i, item.label(), &item.value);
@@ -120,16 +122,6 @@ impl Menu for JsonMenu {
.map(|idx| self.items[idx].label())
}
fn add_raw(&mut self, values: Vec<serde_json::Value>) {
for value in values {
let idx = self.items.len();
let item = Item::new(value, &self.label_key);
let text = self.extract_filter_text(&item);
self.filter.push_with_value(idx, &text, &item.value);
self.items.push(item);
}
}
fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::Value> {
self.filter
.matched_index(filtered_index)
@@ -140,7 +132,28 @@ impl Menu for JsonMenu {
self.filter.matched_index(filtered_index)
}
fn formatted_label(&self, filtered_index: usize) -> Option<String> {
let template = self.format_template.as_ref()?;
let orig_idx = self.filter.matched_index(filtered_index)?;
Some(template.render(&self.items[orig_idx].value))
}
}
impl MutableMenu for JsonMenu {
fn add_raw(&mut self, values: Vec<serde_json::Value>) {
let count = values.len();
for value in values {
let idx = self.items.len();
let item = Item::new(value, &self.label_key);
let text = self.extract_filter_text(&item);
self.filter.push_with_value(idx, &text, &item.value);
self.items.push(item);
}
tracing::debug!(count, new_total = self.items.len(), "adding items to menu");
}
fn replace_all(&mut self, values: Vec<serde_json::Value>) {
tracing::debug!(count = values.len(), "replacing all menu items");
self.items = values
.into_iter()
.map(|v| Item::new(v, &self.label_key))
@@ -149,6 +162,7 @@ impl Menu for JsonMenu {
}
fn remove_by_indices(&mut self, indices: Vec<usize>) {
let count = indices.len();
// Sort descending to remove from the end first,
// preserving earlier indices.
let mut sorted = indices;
@@ -159,12 +173,7 @@ impl Menu for JsonMenu {
self.items.remove(idx);
}
}
tracing::debug!(count, remaining = self.items.len(), "items removed from menu");
self.rebuild_pipeline();
}
fn formatted_label(&self, filtered_index: usize) -> Option<String> {
let template = self.format_template.as_ref()?;
let orig_idx = self.filter.matched_index(filtered_index)?;
Some(template.render(&self.items[orig_idx].value))
}
}

View File

@@ -6,12 +6,13 @@
use std::sync::Arc;
use tokio::sync::{broadcast, mpsc};
use tracing::{debug, info, trace};
use crate::debounce::{hook_response_to_action, DebouncedDispatcher};
use crate::error::PiklError;
use crate::event::{Action, MenuEvent, MenuResult, Mode, ViewState, VisibleItem};
use crate::hook::{HookEvent, HookHandler};
use crate::model::traits::Menu;
use crate::model::traits::MutableMenu;
use crate::navigation::Viewport;
use serde_json::Value;
@@ -37,7 +38,7 @@ pub enum ActionOutcome {
/// drives it with an action/event channel loop. Create one,
/// grab the action sender and event subscriber, then call
/// [`MenuRunner::run`] to start the event loop.
pub struct MenuRunner<M: Menu> {
pub struct MenuRunner<M: MutableMenu> {
menu: M,
viewport: Viewport,
filter_text: Arc<str>,
@@ -46,9 +47,10 @@ pub struct MenuRunner<M: Menu> {
event_tx: broadcast::Sender<MenuEvent>,
dispatcher: Option<DebouncedDispatcher>,
previous_cursor: Option<usize>,
generation: u64,
}
impl<M: Menu> MenuRunner<M> {
impl<M: MutableMenu> MenuRunner<M> {
/// Create a menu runner wrapping the given menu backend.
/// Returns the runner and an action sender. Call
/// [`subscribe`](Self::subscribe) to get an event handle,
@@ -69,6 +71,7 @@ impl<M: Menu> MenuRunner<M> {
event_tx,
dispatcher: None,
previous_cursor: None,
generation: 0,
};
(runner, action_tx)
}
@@ -99,13 +102,25 @@ impl<M: Menu> MenuRunner<M> {
/// Re-run the filter against all items with the current
/// filter text. Updates the viewport with the new count.
fn run_filter(&mut self) {
let start = std::time::Instant::now();
self.menu.apply_filter(&self.filter_text);
self.viewport.set_filtered_count(self.menu.filtered_count());
let matched = self.menu.filtered_count();
let total = self.menu.total();
let duration_us = start.elapsed().as_micros() as u64;
debug!(
filter = %self.filter_text,
matched,
total,
duration_us,
"filter applied"
);
self.viewport.set_filtered_count(matched);
}
/// Build a [`ViewState`] snapshot from the current filter
/// results and viewport position.
fn build_view_state(&self) -> ViewState {
fn build_view_state(&mut self) -> ViewState {
self.generation += 1;
let range = self.viewport.visible_range();
let visible_items: Vec<VisibleItem> = range
.clone()
@@ -134,19 +149,25 @@ impl<M: Menu> MenuRunner<M> {
total_items: self.menu.total(),
total_filtered: self.menu.filtered_count(),
mode: self.mode,
generation: self.generation,
}
}
/// Send the current view state to all subscribers.
fn broadcast_state(&self) {
let _ = self
.event_tx
.send(MenuEvent::StateChanged(self.build_view_state()));
fn broadcast_state(&mut self) {
let vs = self.build_view_state();
trace!(
generation = vs.generation,
filtered = vs.total_filtered,
"state broadcast"
);
let _ = self.event_tx.send(MenuEvent::StateChanged(vs));
}
/// Emit a hook event through the dispatcher, if one is set.
fn emit_hook(&mut self, event: HookEvent) {
if let Some(dispatcher) = &mut self.dispatcher {
debug!(hook_event = ?event.kind(), "hook dispatched");
dispatcher.dispatch(event);
}
}
@@ -165,6 +186,7 @@ impl<M: Menu> MenuRunner<M> {
if let Some(value) = self.menu.serialize_filtered(current).cloned()
&& let Some(orig_idx) = current_orig
{
trace!(cursor = current, original_index = orig_idx, "hover changed");
self.emit_hook(HookEvent::Hover {
item: value,
index: orig_idx,
@@ -178,6 +200,7 @@ impl<M: Menu> MenuRunner<M> {
pub fn apply_action(&mut self, action: Action) -> ActionOutcome {
match action {
Action::UpdateFilter(text) => {
debug!(filter = %text, "filter updated");
self.filter_text = Arc::from(text);
self.run_filter();
ActionOutcome::Broadcast
@@ -234,6 +257,7 @@ impl<M: Menu> MenuRunner<M> {
}
Action::Cancel => ActionOutcome::Cancelled,
Action::Resize { height } => {
trace!(height, "viewport resized");
self.viewport.set_height(height as usize);
ActionOutcome::Broadcast
}
@@ -246,15 +270,18 @@ impl<M: Menu> MenuRunner<M> {
ActionOutcome::Broadcast
}
Action::SetMode(m) => {
debug!(mode = ?m, "mode changed");
self.mode = m;
ActionOutcome::Broadcast
}
Action::AddItems(values) => {
debug!(count = values.len(), "items added");
self.menu.add_raw(values);
self.run_filter();
ActionOutcome::Broadcast
}
Action::ReplaceItems(values) => {
debug!(count = values.len(), "items replaced");
// Smart cursor: try to keep selection on the same original item.
let cursor = self.viewport.cursor();
let old_value = self.menu.serialize_filtered(cursor).cloned();
@@ -279,6 +306,7 @@ impl<M: Menu> MenuRunner<M> {
ActionOutcome::Broadcast
}
Action::RemoveItems(indices) => {
debug!(count = indices.len(), "items removed");
self.menu.remove_by_indices(indices);
self.run_filter();
self.viewport.clamp();
@@ -316,6 +344,10 @@ impl<M: Menu> MenuRunner<M> {
self.run_filter();
self.broadcast_state();
let total_items = self.menu.total();
let filtered = self.menu.filtered_count();
info!(total_items, filtered, "menu opened");
// Emit Open event
self.emit_hook(HookEvent::Open);
@@ -336,6 +368,7 @@ impl<M: Menu> MenuRunner<M> {
self.check_cursor_hover();
}
ActionOutcome::Selected { value, index } => {
info!(index, "item selected");
// Emit Select event
self.emit_hook(HookEvent::Select {
item: value.clone(),
@@ -351,6 +384,7 @@ impl<M: Menu> MenuRunner<M> {
let values: Vec<Value> =
items.iter().map(|(v, _)| v.clone()).collect();
let count = values.len();
info!(count, "quicklist returned");
self.emit_hook(HookEvent::Quicklist {
items: values.clone(),
count,
@@ -363,6 +397,7 @@ impl<M: Menu> MenuRunner<M> {
return Ok(MenuResult::Quicklist { items });
}
ActionOutcome::Cancelled => {
info!("menu cancelled");
self.emit_hook(HookEvent::Cancel);
self.emit_hook(HookEvent::Close);
@@ -370,6 +405,7 @@ impl<M: Menu> MenuRunner<M> {
return Ok(MenuResult::Cancelled);
}
ActionOutcome::Closed => {
info!("menu closed by hook");
self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Cancelled);
@@ -390,6 +426,7 @@ mod tests {
use super::*;
use crate::event::MenuEvent;
use crate::item::Item;
use crate::model::traits::Menu;
use crate::runtime::json_menu::JsonMenu;
fn test_menu() -> (MenuRunner<JsonMenu>, mpsc::Sender<Action>) {
@@ -1099,16 +1136,16 @@ mod tests {
#[tokio::test]
async fn hook_events_fire_on_lifecycle() {
use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
use crate::hook::{HookEvent, HookEventKind, HookHandler};
use std::sync::Mutex;
struct Recorder(Mutex<Vec<HookEventKind>>);
impl HookHandler for Recorder {
fn handle(&self, event: HookEvent) -> Result<Vec<HookResponse>, PiklError> {
fn handle(&self, event: HookEvent) -> Result<(), PiklError> {
if let Ok(mut v) = self.0.lock() {
v.push(event.kind());
}
Ok(vec![])
Ok(())
}
}

View File

@@ -14,3 +14,4 @@ ratatui = "0.30"
crossterm = { version = "0.29", features = ["event-stream"] }
tokio = { version = "1", features = ["sync", "macros", "rt"] }
futures = "0.3"
tracing = "0.1"

View File

@@ -82,6 +82,7 @@ async fn run_inner(
let mut event_stream = EventStream::new();
let mut mode = Mode::Insert;
let mut pending = PendingKey::None;
let mut last_generation: u64 = 0;
loop {
if let Some(ref vs) = view_state {
@@ -118,6 +119,11 @@ async fn run_inner(
menu_event = event_rx.recv() => {
match menu_event {
Ok(MenuEvent::StateChanged(vs)) => {
// Skip duplicate broadcasts
if vs.generation == last_generation {
continue;
}
last_generation = vs.generation;
// Sync filter text from core. Local keystrokes
// update filter_text immediately for responsiveness,
// but if core pushes a different value (e.g. IPC
@@ -135,7 +141,9 @@ async fn run_inner(
Ok(MenuEvent::Selected(_) | MenuEvent::Quicklist(_) | MenuEvent::Cancelled) => {
break;
}
Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(skipped = n, "TUI fell behind on state broadcasts");
}
Err(broadcast::error::RecvError::Closed) => {
break;
}
@@ -340,6 +348,7 @@ mod tests {
total_items: 5,
total_filtered: 3,
mode: Mode::Insert,
generation: 1,
}
}
@@ -811,6 +820,7 @@ mod tests {
total_items: 0,
total_filtered: 0,
mode: Mode::Insert,
generation: 1,
};
let backend = render_to_backend(30, 4, &vs, "");
let prompt = line_text(&backend, 0);

View File

@@ -13,7 +13,7 @@ use tokio::sync::mpsc;
use pikl_core::error::PiklError;
use pikl_core::event::Action;
use pikl_core::hook::{
parse_hook_response, HookEvent, HookEventKind, HookHandler, HookResponse,
parse_hook_response, HookEvent, HookEventKind, HookHandler,
};
/// A persistent handler hook process. Spawns a child process,
@@ -77,13 +77,13 @@ impl ShellHandlerHook {
}
impl HookHandler for ShellHandlerHook {
fn handle(&self, event: HookEvent) -> Result<Vec<HookResponse>, PiklError> {
fn handle(&self, event: HookEvent) -> Result<(), PiklError> {
let kind = event.kind();
if let Some(tx) = self.event_txs.get(&kind) {
// Non-blocking send. If the channel is full, drop the event.
let _ = tx.try_send(event);
}
Ok(vec![])
Ok(())
}
}
@@ -156,15 +156,49 @@ async fn run_handler_process(
// Close stdin to signal the child
drop(stdin_writer);
// Wait briefly for the reader to finish
// Wait up to 1s for child to exit after EOF
let exited = tokio::time::timeout(
std::time::Duration::from_secs(1),
child.wait(),
)
.await
.is_ok();
if !exited {
// Send SIGTERM on Unix, then wait another second
#[cfg(unix)]
{
if let Some(pid) = child.id() {
// SAFETY: pid is valid (child is still running), SIGTERM is
// a standard signal. kill() returns 0 on success or -1 on
// error, which we ignore (child may have exited between the
// check and the signal).
unsafe {
libc::kill(pid as libc::pid_t, libc::SIGTERM);
}
}
let termed = tokio::time::timeout(
std::time::Duration::from_secs(1),
child.wait(),
)
.await
.is_ok();
if !termed {
let _ = child.kill().await;
}
}
#[cfg(not(unix))]
{
let _ = child.kill().await;
}
}
// Wait for the reader to finish
let _ = tokio::time::timeout(
std::time::Duration::from_secs(2),
reader_handle,
)
.await;
// Kill child if still running
let _ = child.kill().await;
Ok(())
}

View File

@@ -9,7 +9,7 @@ use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use pikl_core::error::PiklError;
use pikl_core::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
use pikl_core::hook::{HookEvent, HookEventKind, HookHandler};
/// Duplicate stderr as a [`Stdio`] handle for use as a
/// child process's stdout. Keeps hook output on stderr
@@ -18,8 +18,12 @@ fn stderr_as_stdio() -> std::process::Stdio {
#[cfg(unix)]
{
use std::os::unix::io::FromRawFd;
// SAFETY: STDERR_FILENO is a valid open fd in any running process.
// dup() returns a new fd or -1, which we check below.
let fd = unsafe { libc::dup(libc::STDERR_FILENO) };
if fd >= 0 {
// SAFETY: fd is valid (checked >= 0 above) and we take exclusive
// ownership here (no aliasing). The File will close it on drop.
return unsafe { std::process::Stdio::from(std::fs::File::from_raw_fd(fd)) };
}
}
@@ -112,7 +116,7 @@ impl ShellExecHandler {
}
impl HookHandler for ShellExecHandler {
fn handle(&self, event: HookEvent) -> Result<Vec<HookResponse>, PiklError> {
fn handle(&self, event: HookEvent) -> Result<(), PiklError> {
let kind = event.kind();
if let Some(cmd) = self.commands.get(&kind) {
let cmd = cmd.clone();
@@ -125,7 +129,7 @@ impl HookHandler for ShellExecHandler {
}
});
}
Ok(vec![])
Ok(())
}
}

View File

@@ -123,7 +123,9 @@ struct Cli {
fn main() {
// Initialize tracing from RUST_LOG env var
tracing_subscriber::fmt::init();
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.init();
let cli = Cli::parse();
@@ -310,14 +312,14 @@ impl HookHandler for CompositeHookHandler {
fn handle(
&self,
event: pikl_core::hook::HookEvent,
) -> Result<Vec<pikl_core::hook::HookResponse>, PiklError> {
) -> Result<(), PiklError> {
// Both fire. Exec is fire-and-forget, handler may
// send responses through action_tx.
let _ = self.exec.handle(event.clone());
if let Some(ref h) = self.handler {
h.handle(event)?;
}
Ok(vec![])
Ok(())
}
}

View File

@@ -275,6 +275,36 @@ navigate directories without spawning new processes.
query, or the viewport to do a lookup after each filter
pass.
- **Confirm-with-arguments (Shift+Enter).** Select an item
and also pass free-text arguments alongside it. Primary
use case: app launcher where you select `ls` and want to
pass `-la` to it. The output would include both the
selected item and the user-supplied arguments.
Open questions:
- UX flow: does the filter text become the args on
Shift+Enter? Or does Shift+Enter open a second input
field for args after selection? The filter-as-args
approach is simpler but conflates filtering and
argument input. A two-step flow (select, then type
args) is cleaner but adds a mode.
- Output format: separate field in the JSON output
(`"args": "-la"`)? Second line on stdout? Appended to
the label? Needs to be unambiguous for scripts.
- Should regular Enter with a non-empty filter that
matches exactly one item just confirm that item (current
behaviour), or should it also treat any "extra" text
as args? Probably not, too implicit.
- Keybind: Shift+Enter is natural, but some terminals
don't distinguish it from Enter. May need a fallback
like Ctrl+Enter or a normal-mode keybind.
This is a core feature (new keybind, new output field),
not just a launcher script concern. Fits naturally after
phase 4 (multi-select) since it's another selection
mode variant. The launcher script would assemble
`{selected} {args}` for execution.
## Future Ideas (Unscheduled)
These are things we've talked about or thought of. No
@@ -306,3 +336,11 @@ commitment, no order.
(optionally into a tmux session). Needs GUI frontend
(phase 8) and frecency sorting.
See `docs/use-cases/app-launcher.md`.
Setup guides: `docs/guides/app-launcher.md`.
- App description indexing: a tool or subcommand that
builds a local cache of binary descriptions from man
pages (`whatis`), .desktop file Comment fields, and
macOS Info.plist data. Solves the "whatis is too slow
to run per-keystroke" problem for the app launcher.
Could be a `pikl index` subcommand or a standalone
helper script.

286
docs/guides/app-launcher.md Normal file
View File

@@ -0,0 +1,286 @@
# App Launcher Setup
Use pikl as a keyboard-driven application launcher. Bind
a hotkey, type a few characters, hit Enter, and your app
launches. This guide covers terminal, Hyprland, i3, and
macOS setups.
For the design rationale and feature roadmap, see
[the app launcher use case](../use-cases/app-launcher.md).
## Quick start: terminal
The simplest version. No GUI, no hotkeys, just an alias:
```sh
# ~/.bashrc or ~/.zshrc
alias launch='compgen -c | sort -u | pikl | xargs -I{} sh -c "{} &"'
```
Run `launch`, type to filter, Enter to run the selection
in the background. That's it.
### With descriptions (experimental)
You can pull one-line descriptions from man pages using
`whatis`. This is noticeably slow on systems with
thousands of binaries (it shells out per binary), so treat
it as a nice-to-have rather than the default:
```sh
launch-rich() {
compgen -c | sort -u | while IFS= read -r cmd; do
desc=$(whatis "$cmd" 2>/dev/null | head -1 | sed 's/.*- //')
printf '{"label":"%s","sublabel":"%s"}\n' "$cmd" "$desc"
done | pikl --format '{label} <dim>{sublabel}</dim>' \
| jq -r '.label' \
| xargs -I{} sh -c '{} &'
}
```
This works, but it's slow. Caching the output of the
`whatis` loop to a file and refreshing it periodically
would make it usable day-to-day. A built-in indexing
solution is on the roadmap but not built yet.
## Hyprland
Hyprland is a first-class target. pikl uses
`iced_layershell` to render as a Wayland layer-shell
overlay, so it floats above your desktop like rofi does.
### The launcher script
Save this somewhere in your PATH (e.g.
`~/.local/bin/pikl-launch`):
```sh
#!/bin/sh
# pikl-launch: open pikl as a GUI app launcher
compgen -c | sort -u \
| pikl --mode gui \
| xargs -I{} sh -c '{} &'
```
```sh
chmod +x ~/.local/bin/pikl-launch
```
### Keybinding
Add to `~/.config/hypr/hyprland.conf`:
```
bind = SUPER, SPACE, exec, pikl-launch
```
Reload with `hyprctl reload` or restart Hyprland.
### With .desktop files (future)
When pikl gains .desktop file parsing (or a helper
script emits structured JSON from XDG desktop entries),
you'll get proper app names, descriptions, and categories
instead of raw binary names. The keybinding and workflow
stay the same, only the input to pikl changes.
## i3
i3 runs on X11, so pikl opens as an override-redirect
window rather than a layer-shell overlay. Same launcher
script, different keybinding syntax.
### The launcher script
Same `pikl-launch` script as Hyprland above. pikl
auto-detects X11 vs Wayland, so no changes needed.
### Keybinding
Add to `~/.config/i3/config`:
```
bindsym $mod+space exec --no-startup-id pikl-launch
```
Reload with `$mod+Shift+r`.
### Notes
- `--no-startup-id` prevents the i3 startup notification
cursor from spinning while pikl is open.
- If pikl doesn't grab focus automatically, you may need
to add an i3 rule:
```
for_window [class="pikl"] focus
```
## macOS with Raycast
Raycast is the best way to bind a global hotkey to pikl
on macOS. You create a script command that Raycast can
trigger from its search bar or a direct hotkey.
### Prerequisites
- [Raycast](https://raycast.com) installed
- pikl installed (`cargo install pikl`)
- pikl in your PATH (cargo's bin directory is usually
`~/.cargo/bin`, make sure it's in your shell PATH)
### Create the script command
Raycast script commands are shell scripts with a special
header. Create this file in your Raycast script commands
directory (usually `~/.config/raycast/scripts/` or
wherever you've configured it):
**`pikl-launch.sh`:**
```bash
#!/bin/bash
# Required parameters:
# @raycast.schemaVersion 1
# @raycast.title App Launcher (pikl)
# @raycast.mode silent
# @raycast.packageName pikl
# Optional parameters:
# @raycast.icon :rocket:
# Scan /Applications for .app bundles, pipe to pikl, open the result
find /Applications ~/Applications -maxdepth 2 -name '*.app' 2>/dev/null \
| sed 's|.*/||; s|\.app$||' \
| sort -u \
| pikl --mode gui \
| xargs -I{} open -a "{}"
```
```sh
chmod +x pikl-launch.sh
```
The `silent` mode tells Raycast not to show any output
window. pikl handles its own GUI.
### Assign a hotkey
1. Open Raycast preferences (Cmd+,)
2. Go to Extensions
3. Find "App Launcher (pikl)" under Script Commands
4. Click the hotkey field and press your preferred combo
(e.g. Ctrl+Space, Opt+Space, etc.)
### With structured JSON input
For a richer experience with app metadata, you can parse
the `Info.plist` files inside .app bundles:
```bash
#!/bin/bash
# Required parameters:
# @raycast.schemaVersion 1
# @raycast.title App Launcher (pikl)
# @raycast.mode silent
# @raycast.packageName pikl
for app in /Applications/*.app ~/Applications/*.app; do
[ -d "$app" ] || continue
name=$(basename "$app" .app)
# Pull the bundle identifier for metadata
bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" \
"$app/Contents/Info.plist" 2>/dev/null)
printf '{"label":"%s","meta":{"bundle":"%s","path":"%s"}}\n' \
"$name" "$bundle_id" "$app"
done \
| pikl --mode gui \
| jq -r '.meta.path' \
| xargs -I{} open "{}"
```
This version gives pikl structured data to work with.
When pikl gains frecency sorting, your most-launched apps
will float to the top automatically.
### Alternative: skhd
If you don't use Raycast, [skhd](https://github.com/koekeishiya/skhd)
is a standalone hotkey daemon:
```
# ~/.config/skhd/skhdrc
ctrl + space : pikl-launch-mac.sh
```
Where `pikl-launch-mac.sh` is the same script as above,
minus the Raycast header comments.
## Customizing the launcher
These tips apply to all platforms.
### Filter fields
If your input includes structured JSON with metadata,
tell pikl which fields to search:
```sh
# Search both label and sublabel when filtering
pikl --filter-fields label,sublabel
```
### Hooks
Run something when the launcher opens or when an item
is selected:
```sh
pikl --mode gui \
--on-select-exec 'jq -r .label >> ~/.local/state/pikl-launch-history'
```
This logs every launch to a history file, which could
feed into frecency sorting later.
### Starting in normal mode
If you prefer to land in vim normal mode (navigate first,
then `/` to filter):
```sh
pikl --mode gui --start-mode normal
```
## What's not built yet
A few things mentioned in this guide depend on features
that are still in development:
- **GUI frontend** (phase 8): the `--mode gui` flag and
layer-shell/X11 rendering. Until this ships, you can use
the TUI versions in a drop-down terminal (like tdrop,
kitty's `--single-instance`, or a quake-mode terminal).
- **Frecency sorting:** tracking launch frequency and
boosting common picks. On the roadmap.
- **.desktop file parsing:** structured input from XDG
desktop entries with proper names, icons, and categories.
On the roadmap.
- **Description caching/indexing:** a way to build and
maintain a local index of binary descriptions so the
launcher can show what each app does without the slow
`whatis` loop. On the roadmap.
## GNOME, KDE, and other desktops
These aren't supported as app launcher environments right
now. Hyprland, i3, and macOS are the environments the dev
team uses daily, so that's where the effort goes. The
main blocker for GNOME on Wayland is the lack of
layer-shell support, which means pikl can't render as an
overlay the same way it does on wlroots-based compositors.
If you use one of these environments and want to help,
PRs and discussion are welcome.

60
docs/guides/install.md Normal file
View File

@@ -0,0 +1,60 @@
# Installing pikl
## From crates.io (recommended)
```sh
cargo install pikl
```
This builds the unified `pikl` binary with both TUI and
GUI frontends. You'll need a working Rust toolchain. If
you don't have one, [rustup](https://rustup.rs) is the
way to go.
### TUI only
If you only want the terminal interface and don't want to
pull in GUI dependencies:
```sh
cargo install pikl --no-default-features --features tui
```
## From source
```sh
git clone https://github.com/maplecool/pikl-menu.git
cd pikl-menu
cargo install --path .
```
This builds and installs the `pikl` binary into your
cargo bin directory (usually `~/.cargo/bin/`).
## Package managers
We'd like pikl to be available in package managers like
the AUR and Homebrew, but honestly haven't set that up
before and aren't sure when we'll get to it. TBD.
If you package pikl for a distro or package manager, open
an issue and we'll link it here.
## Verify it works
```sh
echo -e "hello\nworld\ngoodbye" | pikl
```
You should see a filterable list in insert mode. Type to
filter, use arrow keys to navigate, Enter to select,
Escape to quit. The selected item prints to stdout.
pikl starts in insert mode by default (type to filter
immediately). Press Ctrl+N to switch to normal mode for
vim-style navigation (j/k, gg, G, Ctrl+D/U). Ctrl+E
switches back to insert mode. You can also start in
normal mode with `--start-mode normal`.
Note: Ctrl+I is not the same as Tab. pikl treats these
as distinct inputs.