Compare commits
7 Commits
2729e7e1d2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
f6f1efdf8e
|
|||
|
c6d80a9650
|
|||
|
bb3f200141
|
|||
|
a27d529fa7
|
|||
|
e5c875389c
|
|||
|
e89c6cb16f
|
|||
|
0ebb5e79dd
|
@@ -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
1
Cargo.lock
generated
@@ -1117,6 +1117,7 @@ dependencies = [
|
||||
"pikl-core",
|
||||
"ratatui",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
26
README.md
26
README.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
286
docs/guides/app-launcher.md
Normal 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
60
docs/guides/install.md
Normal 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.
|
||||
Reference in New Issue
Block a user