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
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
lint:
|
||||||
name: Check
|
name: Lint
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: self-hosted
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest, macos-latest]
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
components: clippy, rustfmt
|
components: clippy, rustfmt
|
||||||
- uses: Swatinem/rust-cache@v2
|
- name: Format
|
||||||
- name: Check
|
run: cargo fmt --all -- --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: Clippy
|
- name: Clippy
|
||||||
run: |
|
run: |
|
||||||
cargo clippy --workspace --all-targets -- \
|
cargo clippy --workspace --all-targets -- \
|
||||||
@@ -47,26 +33,12 @@ jobs:
|
|||||||
-D clippy::print_stdout \
|
-D clippy::print_stdout \
|
||||||
-D clippy::print_stderr
|
-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:
|
test:
|
||||||
name: Test
|
name: Test
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: self-hosted
|
||||||
strategy:
|
needs: lint
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest, macos-latest]
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cargo test --workspace
|
run: cargo test --workspace
|
||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1117,6 +1117,7 @@ dependencies = [
|
|||||||
"pikl-core",
|
"pikl-core",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -1,4 +1,4 @@
|
|||||||
# pikl-menu
|
# pikl
|
||||||
|
|
||||||
Pipe stuff in, pick stuff out.
|
Pipe stuff in, pick stuff out.
|
||||||
|
|
||||||
@@ -95,6 +95,30 @@ or at all:
|
|||||||
overlay on Wayland (layer-shell) and X11. Auto-detects your
|
overlay on Wayland (layer-shell) and X11. Auto-detects your
|
||||||
environment.
|
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
|
## Building
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ pub struct ViewState {
|
|||||||
pub total_items: usize,
|
pub total_items: usize,
|
||||||
pub total_filtered: usize,
|
pub total_filtered: usize,
|
||||||
pub mode: Mode,
|
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
|
/// A single item in the current viewport window. Has the
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ pub trait Menu: Send + 'static {
|
|||||||
/// position in the filtered results.
|
/// position in the filtered results.
|
||||||
fn filtered_label(&self, filtered_index: usize) -> Option<&str>;
|
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.
|
/// Get the JSON value of a filtered item for output.
|
||||||
/// Returns a reference to the stored value.
|
/// Returns a reference to the stored value.
|
||||||
fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::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.
|
/// for output and hook events.
|
||||||
fn original_index(&self, filtered_index: usize) -> Option<usize>;
|
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,
|
/// Get the formatted display text for a filtered item,
|
||||||
/// if a format template is configured. Returns None if
|
/// if a format template is configured. Returns None if
|
||||||
/// no template is set, in which case the raw label is
|
/// no template is set, in which case the raw label is
|
||||||
@@ -73,3 +62,20 @@ pub trait Menu: Send + 'static {
|
|||||||
.collect()
|
.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.
|
//! caching: unchanged stages keep their results.
|
||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use tracing::{debug, trace};
|
||||||
|
|
||||||
use super::filter::{Filter, FuzzyFilter};
|
use super::filter::{Filter, FuzzyFilter};
|
||||||
use super::strategy::{self, FilterKind};
|
use super::strategy::{self, FilterKind};
|
||||||
@@ -135,11 +136,15 @@ impl FilterPipeline {
|
|||||||
/// Evaluate all dirty stages in order. Each stage filters
|
/// Evaluate all dirty stages in order. Each stage filters
|
||||||
/// against the previous stage's cached_indices.
|
/// against the previous stage's cached_indices.
|
||||||
fn evaluate(&mut self) {
|
fn evaluate(&mut self) {
|
||||||
|
let _span = tracing::trace_span!("pipeline_evaluate").entered();
|
||||||
for stage_idx in 0..self.stages.len() {
|
for stage_idx in 0..self.stages.len() {
|
||||||
if !self.stages[stage_idx].dirty {
|
if !self.stages[stage_idx].dirty {
|
||||||
|
trace!(stage_idx, dirty = false, "skipping clean stage");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trace!(stage_idx, dirty = true, kind = ?self.stages[stage_idx].kind, "evaluating stage");
|
||||||
|
|
||||||
let input_indices: Vec<usize> = if stage_idx == 0 {
|
let input_indices: Vec<usize> = if stage_idx == 0 {
|
||||||
self.items.iter().map(|(idx, _)| *idx).collect()
|
self.items.iter().map(|(idx, _)| *idx).collect()
|
||||||
} else {
|
} 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].cached_indices = result;
|
||||||
self.stages[stage_idx].dirty = false;
|
self.stages[stage_idx].dirty = false;
|
||||||
}
|
}
|
||||||
@@ -275,6 +281,7 @@ impl FilterPipeline {
|
|||||||
/// items. Used after replace_all or remove_by_indices
|
/// items. Used after replace_all or remove_by_indices
|
||||||
/// to rebuild the pipeline from scratch.
|
/// to rebuild the pipeline from scratch.
|
||||||
pub fn rebuild(&mut self, items: &[(usize, &str)]) {
|
pub fn rebuild(&mut self, items: &[(usize, &str)]) {
|
||||||
|
debug!(item_count = items.len(), "pipeline rebuilt");
|
||||||
self.items.clear();
|
self.items.clear();
|
||||||
self.item_values.clear();
|
self.item_values.clear();
|
||||||
self.stages.clear();
|
self.stages.clear();
|
||||||
@@ -288,6 +295,7 @@ impl FilterPipeline {
|
|||||||
/// Clear all items and stages, then re-push with values.
|
/// Clear all items and stages, then re-push with values.
|
||||||
/// Used when field filters need access to item JSON.
|
/// Used when field filters need access to item JSON.
|
||||||
pub fn rebuild_with_values(&mut self, items: &[(usize, &str, &Value)]) {
|
pub fn rebuild_with_values(&mut self, items: &[(usize, &str, &Value)]) {
|
||||||
|
debug!(item_count = items.len(), "pipeline rebuilt");
|
||||||
self.items.clear();
|
self.items.clear();
|
||||||
self.item_values.clear();
|
self.item_values.clear();
|
||||||
self.stages.clear();
|
self.stages.clear();
|
||||||
@@ -325,6 +333,7 @@ impl Filter for FilterPipeline {
|
|||||||
fn set_query(&mut self, query: &str) {
|
fn set_query(&mut self, query: &str) {
|
||||||
self.last_raw_query = query.to_string();
|
self.last_raw_query = query.to_string();
|
||||||
let segments = split_pipeline(query);
|
let segments = split_pipeline(query);
|
||||||
|
debug!(query = %query, stage_count = segments.len(), "pipeline query set");
|
||||||
|
|
||||||
// Reconcile stages with new segments
|
// Reconcile stages with new segments
|
||||||
let mut new_len = segments.len();
|
let mut new_len = segments.len();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
use tracing::Instrument;
|
||||||
|
|
||||||
use crate::event::Action;
|
use crate::event::Action;
|
||||||
use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
|
use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
|
||||||
@@ -29,7 +30,7 @@ pub enum DebounceMode {
|
|||||||
/// behavior. Each event kind can have its own mode.
|
/// behavior. Each event kind can have its own mode.
|
||||||
pub struct DebouncedDispatcher {
|
pub struct DebouncedDispatcher {
|
||||||
handler: Arc<dyn HookHandler>,
|
handler: Arc<dyn HookHandler>,
|
||||||
action_tx: mpsc::Sender<Action>,
|
_action_tx: mpsc::Sender<Action>,
|
||||||
modes: HashMap<HookEventKind, DebounceMode>,
|
modes: HashMap<HookEventKind, DebounceMode>,
|
||||||
in_flight: HashMap<HookEventKind, JoinHandle<()>>,
|
in_flight: HashMap<HookEventKind, JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
@@ -41,7 +42,7 @@ impl DebouncedDispatcher {
|
|||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
handler,
|
handler,
|
||||||
action_tx,
|
_action_tx: action_tx,
|
||||||
modes: HashMap::new(),
|
modes: HashMap::new(),
|
||||||
in_flight: HashMap::new(),
|
in_flight: HashMap::new(),
|
||||||
}
|
}
|
||||||
@@ -76,6 +77,8 @@ impl DebouncedDispatcher {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or(DebounceMode::None);
|
.unwrap_or(DebounceMode::None);
|
||||||
|
|
||||||
|
tracing::debug!(event_kind = ?kind, mode = ?mode, "dispatching hook event");
|
||||||
|
|
||||||
match mode {
|
match mode {
|
||||||
DebounceMode::None => {
|
DebounceMode::None => {
|
||||||
self.fire_now(event);
|
self.fire_now(event);
|
||||||
@@ -95,19 +98,15 @@ impl DebouncedDispatcher {
|
|||||||
|
|
||||||
fn fire_now(&self, event: HookEvent) {
|
fn fire_now(&self, event: HookEvent) {
|
||||||
let handler = Arc::clone(&self.handler);
|
let handler = Arc::clone(&self.handler);
|
||||||
let action_tx = self.action_tx.clone();
|
let span = tracing::debug_span!("hook_fire", event_kind = ?event.kind());
|
||||||
tokio::spawn(async move {
|
tokio::spawn(
|
||||||
match handler.handle(event) {
|
async move {
|
||||||
Ok(responses) => {
|
if let Err(e) = handler.handle(event) {
|
||||||
for resp in responses {
|
|
||||||
let _ = action_tx.send(Action::ProcessHookResponse(resp)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %e, "hook handler error");
|
tracing::warn!(error = %e, "hook handler error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
.instrument(span),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fire_debounced(&mut self, event: HookEvent, delay: Duration, cancel: bool) {
|
fn fire_debounced(&mut self, event: HookEvent, delay: Duration, cancel: bool) {
|
||||||
@@ -116,27 +115,27 @@ impl DebouncedDispatcher {
|
|||||||
self.cancel_in_flight(kind);
|
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 handler = Arc::clone(&self.handler);
|
||||||
let action_tx = self.action_tx.clone();
|
let span = tracing::debug_span!("hook_fire", event_kind = ?kind);
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(
|
||||||
|
async move {
|
||||||
tokio::time::sleep(delay).await;
|
tokio::time::sleep(delay).await;
|
||||||
match handler.handle(event) {
|
if let Err(e) = handler.handle(event) {
|
||||||
Ok(responses) => {
|
|
||||||
for resp in responses {
|
|
||||||
let _ = action_tx.send(Action::ProcessHookResponse(resp)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %e, "hook handler error");
|
tracing::warn!(error = %e, "hook handler error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
.instrument(span),
|
||||||
|
);
|
||||||
|
|
||||||
self.in_flight.insert(kind, handle);
|
self.in_flight.insert(kind, handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cancel_in_flight(&mut self, kind: HookEventKind) {
|
fn cancel_in_flight(&mut self, kind: HookEventKind) {
|
||||||
if let Some(handle) = self.in_flight.remove(&kind) {
|
if let Some(handle) = self.in_flight.remove(&kind) {
|
||||||
|
tracing::debug!(event_kind = ?kind, "cancelled in-flight hook");
|
||||||
handle.abort();
|
handle.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,11 +164,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl HookHandler for RecordingHandler {
|
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() {
|
if let Ok(mut events) = self.events.lock() {
|
||||||
events.push(event.kind());
|
events.push(event.kind());
|
||||||
}
|
}
|
||||||
Ok(vec![])
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,16 +65,15 @@ pub enum HookResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handler trait for lifecycle hooks. Implementations
|
/// Handler trait for lifecycle hooks. Implementations
|
||||||
/// receive events and optionally return responses.
|
/// receive events and may produce side effects (spawning
|
||||||
/// Exec hooks return empty vecs. Handler hooks send
|
/// processes, sending to channels). Responses flow through
|
||||||
/// responses back through the action channel asynchronously
|
/// the action channel, not the return value.
|
||||||
/// and also return empty vecs.
|
|
||||||
///
|
///
|
||||||
/// This is deliberately synchronous for dyn-compatibility.
|
/// This is deliberately synchronous for dyn-compatibility.
|
||||||
/// Implementations that need async work (spawning processes,
|
/// Implementations that need async work (spawning processes,
|
||||||
/// writing to channels) should use `tokio::spawn` internally.
|
/// writing to channels) should use `tokio::spawn` internally.
|
||||||
pub trait HookHandler: Send + Sync {
|
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`].
|
/// Parse a single line of JSON as a [`HookResponse`].
|
||||||
@@ -82,7 +81,10 @@ pub trait HookHandler: Send + Sync {
|
|||||||
/// tracing.
|
/// tracing.
|
||||||
pub fn parse_hook_response(line: &str) -> Option<HookResponse> {
|
pub fn parse_hook_response(line: &str) -> Option<HookResponse> {
|
||||||
match serde_json::from_str::<HookResponse>(line) {
|
match serde_json::from_str::<HookResponse>(line) {
|
||||||
Ok(resp) => Some(resp),
|
Ok(resp) => {
|
||||||
|
tracing::debug!(response = ?resp, "parsed hook response");
|
||||||
|
Some(resp)
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(line, error = %e, "failed to parse hook response");
|
tracing::warn!(line, error = %e, "failed to parse hook response");
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ pub fn read_items_sync(
|
|||||||
}
|
}
|
||||||
items.push(parse_line(&line, label_key));
|
items.push(parse_line(&line, label_key));
|
||||||
}
|
}
|
||||||
|
tracing::debug!(count = items.len(), "items read");
|
||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ pub async fn read_items(
|
|||||||
}
|
}
|
||||||
items.push(parse_line(&line, label_key));
|
items.push(parse_line(&line, label_key));
|
||||||
}
|
}
|
||||||
|
tracing::debug!(count = items.len(), "items read");
|
||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
use crate::filter::Filter;
|
use crate::filter::Filter;
|
||||||
use crate::format::FormatTemplate;
|
use crate::format::FormatTemplate;
|
||||||
use crate::item::Item;
|
use crate::item::Item;
|
||||||
use crate::model::traits::Menu;
|
use crate::model::traits::{Menu, MutableMenu};
|
||||||
use crate::pipeline::FilterPipeline;
|
use crate::pipeline::FilterPipeline;
|
||||||
|
|
||||||
/// A menu backed by a flat list of JSON items. Handles
|
/// A menu backed by a flat list of JSON items. Handles
|
||||||
@@ -24,6 +24,8 @@ pub struct JsonMenu {
|
|||||||
impl JsonMenu {
|
impl JsonMenu {
|
||||||
/// Create a new JSON menu with the given items and label key.
|
/// Create a new JSON menu with the given items and label key.
|
||||||
pub fn new(items: Vec<Item>, label_key: String) -> Self {
|
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();
|
let mut filter = FilterPipeline::new();
|
||||||
for (i, item) in items.iter().enumerate() {
|
for (i, item) in items.iter().enumerate() {
|
||||||
filter.push_with_value(i, item.label(), &item.value);
|
filter.push_with_value(i, item.label(), &item.value);
|
||||||
@@ -120,16 +122,6 @@ impl Menu for JsonMenu {
|
|||||||
.map(|idx| self.items[idx].label())
|
.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> {
|
fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::Value> {
|
||||||
self.filter
|
self.filter
|
||||||
.matched_index(filtered_index)
|
.matched_index(filtered_index)
|
||||||
@@ -140,7 +132,28 @@ impl Menu for JsonMenu {
|
|||||||
self.filter.matched_index(filtered_index)
|
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>) {
|
fn replace_all(&mut self, values: Vec<serde_json::Value>) {
|
||||||
|
tracing::debug!(count = values.len(), "replacing all menu items");
|
||||||
self.items = values
|
self.items = values
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|v| Item::new(v, &self.label_key))
|
.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>) {
|
fn remove_by_indices(&mut self, indices: Vec<usize>) {
|
||||||
|
let count = indices.len();
|
||||||
// Sort descending to remove from the end first,
|
// Sort descending to remove from the end first,
|
||||||
// preserving earlier indices.
|
// preserving earlier indices.
|
||||||
let mut sorted = indices;
|
let mut sorted = indices;
|
||||||
@@ -159,12 +173,7 @@ impl Menu for JsonMenu {
|
|||||||
self.items.remove(idx);
|
self.items.remove(idx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tracing::debug!(count, remaining = self.items.len(), "items removed from menu");
|
||||||
self.rebuild_pipeline();
|
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 std::sync::Arc;
|
||||||
|
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
use tracing::{debug, info, trace};
|
||||||
|
|
||||||
use crate::debounce::{hook_response_to_action, DebouncedDispatcher};
|
use crate::debounce::{hook_response_to_action, DebouncedDispatcher};
|
||||||
use crate::error::PiklError;
|
use crate::error::PiklError;
|
||||||
use crate::event::{Action, MenuEvent, MenuResult, Mode, ViewState, VisibleItem};
|
use crate::event::{Action, MenuEvent, MenuResult, Mode, ViewState, VisibleItem};
|
||||||
use crate::hook::{HookEvent, HookHandler};
|
use crate::hook::{HookEvent, HookHandler};
|
||||||
use crate::model::traits::Menu;
|
use crate::model::traits::MutableMenu;
|
||||||
use crate::navigation::Viewport;
|
use crate::navigation::Viewport;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ pub enum ActionOutcome {
|
|||||||
/// drives it with an action/event channel loop. Create one,
|
/// drives it with an action/event channel loop. Create one,
|
||||||
/// grab the action sender and event subscriber, then call
|
/// grab the action sender and event subscriber, then call
|
||||||
/// [`MenuRunner::run`] to start the event loop.
|
/// [`MenuRunner::run`] to start the event loop.
|
||||||
pub struct MenuRunner<M: Menu> {
|
pub struct MenuRunner<M: MutableMenu> {
|
||||||
menu: M,
|
menu: M,
|
||||||
viewport: Viewport,
|
viewport: Viewport,
|
||||||
filter_text: Arc<str>,
|
filter_text: Arc<str>,
|
||||||
@@ -46,9 +47,10 @@ pub struct MenuRunner<M: Menu> {
|
|||||||
event_tx: broadcast::Sender<MenuEvent>,
|
event_tx: broadcast::Sender<MenuEvent>,
|
||||||
dispatcher: Option<DebouncedDispatcher>,
|
dispatcher: Option<DebouncedDispatcher>,
|
||||||
previous_cursor: Option<usize>,
|
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.
|
/// Create a menu runner wrapping the given menu backend.
|
||||||
/// Returns the runner and an action sender. Call
|
/// Returns the runner and an action sender. Call
|
||||||
/// [`subscribe`](Self::subscribe) to get an event handle,
|
/// [`subscribe`](Self::subscribe) to get an event handle,
|
||||||
@@ -69,6 +71,7 @@ impl<M: Menu> MenuRunner<M> {
|
|||||||
event_tx,
|
event_tx,
|
||||||
dispatcher: None,
|
dispatcher: None,
|
||||||
previous_cursor: None,
|
previous_cursor: None,
|
||||||
|
generation: 0,
|
||||||
};
|
};
|
||||||
(runner, action_tx)
|
(runner, action_tx)
|
||||||
}
|
}
|
||||||
@@ -99,13 +102,25 @@ impl<M: Menu> MenuRunner<M> {
|
|||||||
/// Re-run the filter against all items with the current
|
/// Re-run the filter against all items with the current
|
||||||
/// filter text. Updates the viewport with the new count.
|
/// filter text. Updates the viewport with the new count.
|
||||||
fn run_filter(&mut self) {
|
fn run_filter(&mut self) {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
self.menu.apply_filter(&self.filter_text);
|
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
|
/// Build a [`ViewState`] snapshot from the current filter
|
||||||
/// results and viewport position.
|
/// 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 range = self.viewport.visible_range();
|
||||||
let visible_items: Vec<VisibleItem> = range
|
let visible_items: Vec<VisibleItem> = range
|
||||||
.clone()
|
.clone()
|
||||||
@@ -134,19 +149,25 @@ impl<M: Menu> MenuRunner<M> {
|
|||||||
total_items: self.menu.total(),
|
total_items: self.menu.total(),
|
||||||
total_filtered: self.menu.filtered_count(),
|
total_filtered: self.menu.filtered_count(),
|
||||||
mode: self.mode,
|
mode: self.mode,
|
||||||
|
generation: self.generation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send the current view state to all subscribers.
|
/// Send the current view state to all subscribers.
|
||||||
fn broadcast_state(&self) {
|
fn broadcast_state(&mut self) {
|
||||||
let _ = self
|
let vs = self.build_view_state();
|
||||||
.event_tx
|
trace!(
|
||||||
.send(MenuEvent::StateChanged(self.build_view_state()));
|
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.
|
/// Emit a hook event through the dispatcher, if one is set.
|
||||||
fn emit_hook(&mut self, event: HookEvent) {
|
fn emit_hook(&mut self, event: HookEvent) {
|
||||||
if let Some(dispatcher) = &mut self.dispatcher {
|
if let Some(dispatcher) = &mut self.dispatcher {
|
||||||
|
debug!(hook_event = ?event.kind(), "hook dispatched");
|
||||||
dispatcher.dispatch(event);
|
dispatcher.dispatch(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,6 +186,7 @@ impl<M: Menu> MenuRunner<M> {
|
|||||||
if let Some(value) = self.menu.serialize_filtered(current).cloned()
|
if let Some(value) = self.menu.serialize_filtered(current).cloned()
|
||||||
&& let Some(orig_idx) = current_orig
|
&& let Some(orig_idx) = current_orig
|
||||||
{
|
{
|
||||||
|
trace!(cursor = current, original_index = orig_idx, "hover changed");
|
||||||
self.emit_hook(HookEvent::Hover {
|
self.emit_hook(HookEvent::Hover {
|
||||||
item: value,
|
item: value,
|
||||||
index: orig_idx,
|
index: orig_idx,
|
||||||
@@ -178,6 +200,7 @@ impl<M: Menu> MenuRunner<M> {
|
|||||||
pub fn apply_action(&mut self, action: Action) -> ActionOutcome {
|
pub fn apply_action(&mut self, action: Action) -> ActionOutcome {
|
||||||
match action {
|
match action {
|
||||||
Action::UpdateFilter(text) => {
|
Action::UpdateFilter(text) => {
|
||||||
|
debug!(filter = %text, "filter updated");
|
||||||
self.filter_text = Arc::from(text);
|
self.filter_text = Arc::from(text);
|
||||||
self.run_filter();
|
self.run_filter();
|
||||||
ActionOutcome::Broadcast
|
ActionOutcome::Broadcast
|
||||||
@@ -234,6 +257,7 @@ impl<M: Menu> MenuRunner<M> {
|
|||||||
}
|
}
|
||||||
Action::Cancel => ActionOutcome::Cancelled,
|
Action::Cancel => ActionOutcome::Cancelled,
|
||||||
Action::Resize { height } => {
|
Action::Resize { height } => {
|
||||||
|
trace!(height, "viewport resized");
|
||||||
self.viewport.set_height(height as usize);
|
self.viewport.set_height(height as usize);
|
||||||
ActionOutcome::Broadcast
|
ActionOutcome::Broadcast
|
||||||
}
|
}
|
||||||
@@ -246,15 +270,18 @@ impl<M: Menu> MenuRunner<M> {
|
|||||||
ActionOutcome::Broadcast
|
ActionOutcome::Broadcast
|
||||||
}
|
}
|
||||||
Action::SetMode(m) => {
|
Action::SetMode(m) => {
|
||||||
|
debug!(mode = ?m, "mode changed");
|
||||||
self.mode = m;
|
self.mode = m;
|
||||||
ActionOutcome::Broadcast
|
ActionOutcome::Broadcast
|
||||||
}
|
}
|
||||||
Action::AddItems(values) => {
|
Action::AddItems(values) => {
|
||||||
|
debug!(count = values.len(), "items added");
|
||||||
self.menu.add_raw(values);
|
self.menu.add_raw(values);
|
||||||
self.run_filter();
|
self.run_filter();
|
||||||
ActionOutcome::Broadcast
|
ActionOutcome::Broadcast
|
||||||
}
|
}
|
||||||
Action::ReplaceItems(values) => {
|
Action::ReplaceItems(values) => {
|
||||||
|
debug!(count = values.len(), "items replaced");
|
||||||
// Smart cursor: try to keep selection on the same original item.
|
// Smart cursor: try to keep selection on the same original item.
|
||||||
let cursor = self.viewport.cursor();
|
let cursor = self.viewport.cursor();
|
||||||
let old_value = self.menu.serialize_filtered(cursor).cloned();
|
let old_value = self.menu.serialize_filtered(cursor).cloned();
|
||||||
@@ -279,6 +306,7 @@ impl<M: Menu> MenuRunner<M> {
|
|||||||
ActionOutcome::Broadcast
|
ActionOutcome::Broadcast
|
||||||
}
|
}
|
||||||
Action::RemoveItems(indices) => {
|
Action::RemoveItems(indices) => {
|
||||||
|
debug!(count = indices.len(), "items removed");
|
||||||
self.menu.remove_by_indices(indices);
|
self.menu.remove_by_indices(indices);
|
||||||
self.run_filter();
|
self.run_filter();
|
||||||
self.viewport.clamp();
|
self.viewport.clamp();
|
||||||
@@ -316,6 +344,10 @@ impl<M: Menu> MenuRunner<M> {
|
|||||||
self.run_filter();
|
self.run_filter();
|
||||||
self.broadcast_state();
|
self.broadcast_state();
|
||||||
|
|
||||||
|
let total_items = self.menu.total();
|
||||||
|
let filtered = self.menu.filtered_count();
|
||||||
|
info!(total_items, filtered, "menu opened");
|
||||||
|
|
||||||
// Emit Open event
|
// Emit Open event
|
||||||
self.emit_hook(HookEvent::Open);
|
self.emit_hook(HookEvent::Open);
|
||||||
|
|
||||||
@@ -336,6 +368,7 @@ impl<M: Menu> MenuRunner<M> {
|
|||||||
self.check_cursor_hover();
|
self.check_cursor_hover();
|
||||||
}
|
}
|
||||||
ActionOutcome::Selected { value, index } => {
|
ActionOutcome::Selected { value, index } => {
|
||||||
|
info!(index, "item selected");
|
||||||
// Emit Select event
|
// Emit Select event
|
||||||
self.emit_hook(HookEvent::Select {
|
self.emit_hook(HookEvent::Select {
|
||||||
item: value.clone(),
|
item: value.clone(),
|
||||||
@@ -351,6 +384,7 @@ impl<M: Menu> MenuRunner<M> {
|
|||||||
let values: Vec<Value> =
|
let values: Vec<Value> =
|
||||||
items.iter().map(|(v, _)| v.clone()).collect();
|
items.iter().map(|(v, _)| v.clone()).collect();
|
||||||
let count = values.len();
|
let count = values.len();
|
||||||
|
info!(count, "quicklist returned");
|
||||||
self.emit_hook(HookEvent::Quicklist {
|
self.emit_hook(HookEvent::Quicklist {
|
||||||
items: values.clone(),
|
items: values.clone(),
|
||||||
count,
|
count,
|
||||||
@@ -363,6 +397,7 @@ impl<M: Menu> MenuRunner<M> {
|
|||||||
return Ok(MenuResult::Quicklist { items });
|
return Ok(MenuResult::Quicklist { items });
|
||||||
}
|
}
|
||||||
ActionOutcome::Cancelled => {
|
ActionOutcome::Cancelled => {
|
||||||
|
info!("menu cancelled");
|
||||||
self.emit_hook(HookEvent::Cancel);
|
self.emit_hook(HookEvent::Cancel);
|
||||||
self.emit_hook(HookEvent::Close);
|
self.emit_hook(HookEvent::Close);
|
||||||
|
|
||||||
@@ -370,6 +405,7 @@ impl<M: Menu> MenuRunner<M> {
|
|||||||
return Ok(MenuResult::Cancelled);
|
return Ok(MenuResult::Cancelled);
|
||||||
}
|
}
|
||||||
ActionOutcome::Closed => {
|
ActionOutcome::Closed => {
|
||||||
|
info!("menu closed by hook");
|
||||||
self.emit_hook(HookEvent::Close);
|
self.emit_hook(HookEvent::Close);
|
||||||
|
|
||||||
let _ = self.event_tx.send(MenuEvent::Cancelled);
|
let _ = self.event_tx.send(MenuEvent::Cancelled);
|
||||||
@@ -390,6 +426,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::event::MenuEvent;
|
use crate::event::MenuEvent;
|
||||||
use crate::item::Item;
|
use crate::item::Item;
|
||||||
|
use crate::model::traits::Menu;
|
||||||
use crate::runtime::json_menu::JsonMenu;
|
use crate::runtime::json_menu::JsonMenu;
|
||||||
|
|
||||||
fn test_menu() -> (MenuRunner<JsonMenu>, mpsc::Sender<Action>) {
|
fn test_menu() -> (MenuRunner<JsonMenu>, mpsc::Sender<Action>) {
|
||||||
@@ -1099,16 +1136,16 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn hook_events_fire_on_lifecycle() {
|
async fn hook_events_fire_on_lifecycle() {
|
||||||
use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
|
use crate::hook::{HookEvent, HookEventKind, HookHandler};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
struct Recorder(Mutex<Vec<HookEventKind>>);
|
struct Recorder(Mutex<Vec<HookEventKind>>);
|
||||||
impl HookHandler for Recorder {
|
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() {
|
if let Ok(mut v) = self.0.lock() {
|
||||||
v.push(event.kind());
|
v.push(event.kind());
|
||||||
}
|
}
|
||||||
Ok(vec![])
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ ratatui = "0.30"
|
|||||||
crossterm = { version = "0.29", features = ["event-stream"] }
|
crossterm = { version = "0.29", features = ["event-stream"] }
|
||||||
tokio = { version = "1", features = ["sync", "macros", "rt"] }
|
tokio = { version = "1", features = ["sync", "macros", "rt"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
tracing = "0.1"
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ async fn run_inner(
|
|||||||
let mut event_stream = EventStream::new();
|
let mut event_stream = EventStream::new();
|
||||||
let mut mode = Mode::Insert;
|
let mut mode = Mode::Insert;
|
||||||
let mut pending = PendingKey::None;
|
let mut pending = PendingKey::None;
|
||||||
|
let mut last_generation: u64 = 0;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if let Some(ref vs) = view_state {
|
if let Some(ref vs) = view_state {
|
||||||
@@ -118,6 +119,11 @@ async fn run_inner(
|
|||||||
menu_event = event_rx.recv() => {
|
menu_event = event_rx.recv() => {
|
||||||
match menu_event {
|
match menu_event {
|
||||||
Ok(MenuEvent::StateChanged(vs)) => {
|
Ok(MenuEvent::StateChanged(vs)) => {
|
||||||
|
// Skip duplicate broadcasts
|
||||||
|
if vs.generation == last_generation {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
last_generation = vs.generation;
|
||||||
// Sync filter text from core. Local keystrokes
|
// Sync filter text from core. Local keystrokes
|
||||||
// update filter_text immediately for responsiveness,
|
// update filter_text immediately for responsiveness,
|
||||||
// but if core pushes a different value (e.g. IPC
|
// 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) => {
|
Ok(MenuEvent::Selected(_) | MenuEvent::Quicklist(_) | MenuEvent::Cancelled) => {
|
||||||
break;
|
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) => {
|
Err(broadcast::error::RecvError::Closed) => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -340,6 +348,7 @@ mod tests {
|
|||||||
total_items: 5,
|
total_items: 5,
|
||||||
total_filtered: 3,
|
total_filtered: 3,
|
||||||
mode: Mode::Insert,
|
mode: Mode::Insert,
|
||||||
|
generation: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -811,6 +820,7 @@ mod tests {
|
|||||||
total_items: 0,
|
total_items: 0,
|
||||||
total_filtered: 0,
|
total_filtered: 0,
|
||||||
mode: Mode::Insert,
|
mode: Mode::Insert,
|
||||||
|
generation: 1,
|
||||||
};
|
};
|
||||||
let backend = render_to_backend(30, 4, &vs, "");
|
let backend = render_to_backend(30, 4, &vs, "");
|
||||||
let prompt = line_text(&backend, 0);
|
let prompt = line_text(&backend, 0);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use tokio::sync::mpsc;
|
|||||||
use pikl_core::error::PiklError;
|
use pikl_core::error::PiklError;
|
||||||
use pikl_core::event::Action;
|
use pikl_core::event::Action;
|
||||||
use pikl_core::hook::{
|
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,
|
/// A persistent handler hook process. Spawns a child process,
|
||||||
@@ -77,13 +77,13 @@ impl ShellHandlerHook {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl HookHandler for 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();
|
let kind = event.kind();
|
||||||
if let Some(tx) = self.event_txs.get(&kind) {
|
if let Some(tx) = self.event_txs.get(&kind) {
|
||||||
// Non-blocking send. If the channel is full, drop the event.
|
// Non-blocking send. If the channel is full, drop the event.
|
||||||
let _ = tx.try_send(event);
|
let _ = tx.try_send(event);
|
||||||
}
|
}
|
||||||
Ok(vec![])
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,15 +156,49 @@ async fn run_handler_process(
|
|||||||
// Close stdin to signal the child
|
// Close stdin to signal the child
|
||||||
drop(stdin_writer);
|
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(
|
let _ = tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(2),
|
std::time::Duration::from_secs(2),
|
||||||
reader_handle,
|
reader_handle,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Kill child if still running
|
|
||||||
let _ = child.kill().await;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use tokio::io::AsyncWriteExt;
|
|||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
use pikl_core::error::PiklError;
|
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
|
/// Duplicate stderr as a [`Stdio`] handle for use as a
|
||||||
/// child process's stdout. Keeps hook output on stderr
|
/// child process's stdout. Keeps hook output on stderr
|
||||||
@@ -18,8 +18,12 @@ fn stderr_as_stdio() -> std::process::Stdio {
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::io::FromRawFd;
|
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) };
|
let fd = unsafe { libc::dup(libc::STDERR_FILENO) };
|
||||||
if fd >= 0 {
|
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)) };
|
return unsafe { std::process::Stdio::from(std::fs::File::from_raw_fd(fd)) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,7 +116,7 @@ impl ShellExecHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl HookHandler for 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();
|
let kind = event.kind();
|
||||||
if let Some(cmd) = self.commands.get(&kind) {
|
if let Some(cmd) = self.commands.get(&kind) {
|
||||||
let cmd = cmd.clone();
|
let cmd = cmd.clone();
|
||||||
@@ -125,7 +129,7 @@ impl HookHandler for ShellExecHandler {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(vec![])
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ struct Cli {
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Initialize tracing from RUST_LOG env var
|
// Initialize tracing from RUST_LOG env var
|
||||||
tracing_subscriber::fmt::init();
|
tracing_subscriber::fmt()
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.init();
|
||||||
|
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
@@ -310,14 +312,14 @@ impl HookHandler for CompositeHookHandler {
|
|||||||
fn handle(
|
fn handle(
|
||||||
&self,
|
&self,
|
||||||
event: pikl_core::hook::HookEvent,
|
event: pikl_core::hook::HookEvent,
|
||||||
) -> Result<Vec<pikl_core::hook::HookResponse>, PiklError> {
|
) -> Result<(), PiklError> {
|
||||||
// Both fire. Exec is fire-and-forget, handler may
|
// Both fire. Exec is fire-and-forget, handler may
|
||||||
// send responses through action_tx.
|
// send responses through action_tx.
|
||||||
let _ = self.exec.handle(event.clone());
|
let _ = self.exec.handle(event.clone());
|
||||||
if let Some(ref h) = self.handler {
|
if let Some(ref h) = self.handler {
|
||||||
h.handle(event)?;
|
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
|
query, or the viewport to do a lookup after each filter
|
||||||
pass.
|
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)
|
## Future Ideas (Unscheduled)
|
||||||
|
|
||||||
These are things we've talked about or thought of. No
|
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
|
(optionally into a tmux session). Needs GUI frontend
|
||||||
(phase 8) and frecency sorting.
|
(phase 8) and frecency sorting.
|
||||||
See `docs/use-cases/app-launcher.md`.
|
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