diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 949f87f0..d333f564 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -2,4 +2,8 @@ set -xeuo pipefail cargo +nightly fmt --check --all cargo clippy --all-targets -- -Dwarnings -cargo check --no-default-features +cargo clippy --no-default-features -- -Dwarnings +cargo clippy --no-default-features --features cli -- -Dwarnings +cargo clippy --no-default-features --features image -- -Dwarnings +cargo clippy --no-default-features --features listen -- -Dwarnings +cargo clippy --no-default-features --features frizbee -- -Dwarnings diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c33436e9..4171c5fd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -138,6 +138,7 @@ jobs: path: target/llvm-cov/html deploy-coverage-page: + if: *if-master needs: coverage runs-on: ubuntu-latest permissions: @@ -149,7 +150,6 @@ jobs: steps: - name: "Deploy gh pages" id: deployment - if: *if-master uses: actions/deploy-pages@v4 clippy: @@ -174,7 +174,7 @@ jobs: run: | cargo fmt --all -- --check - build-no-default-features: + clippy-no-default-features: runs-on: ${{matrix.os}} strategy: matrix: *matrix @@ -182,9 +182,13 @@ jobs: - *checkout - *toolchain - *cache - - name: Build without any feature + - name: Run clippy with specific features run: | - cargo build --no-default-features + cargo clippy --no-default-features -- -Dwarnings + cargo clippy --no-default-features --features cli -- -Dwarnings + cargo clippy --no-default-features --features image -- -Dwarnings + cargo clippy --no-default-features --features listen -- -Dwarnings + cargo clippy --no-default-features --features frizbee -- -Dwarnings msrv: diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e504408a..c224d055 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -169,6 +169,17 @@ The single crate exports: The `cli` feature gates `clap`, `clap_complete`, `shlex`, `env_logger`, and `clap_mangen`. +The `image` feature (enabled by default) gates image preview support, including the +`image` and `ratatui-image` dependencies, the `ImageProtocol` enum, the +`SkimOptions::image` / `SkimOptions::image_picker` fields, and the +`PreviewContent::Image` rendering path. With the feature off, the `--image` flag and +its supporting code are compiled out entirely and neither image crate is pulled in. + +The `listen` feature (enabled by default) gates the IPC socket that lets other processes +drive skim via `--listen` / `--remote`, including the `interprocess`, `ron`, and `serde` +dependencies, the `SkimOptions::listen` / `SkimOptions::remote` fields, and the `serde` +derives on `Action`. See [IPC / Listen Socket](#ipc--listen-socket). + --- ## Entry Points @@ -799,7 +810,7 @@ Pre-selection is applied when items first appear: `DefaultSkimSelector::should_s **PTY mode** (`--preview-window pty`): creates a real pseudo-terminal pair via `portable_pty`. The child process sees a properly sized terminal (via `ROWS`/`COLUMNS` env and PTY dimensions). Output is parsed by a `vt100::Parser` with a scrollback buffer, stored as `PreviewContent::Terminal(Arc>)`. This enables interactive preview programs (e.g. `bat`, `delta`). -**Image mode** (`--image[=detect|halfblocks]`): treats the expanded preview command as an image path instead of executing it. A worker thread decodes the image with the `image` crate and stores `PreviewContent::Image { source, protocol, size }`. Rendering uses `ratatui_image`; `detect` builds an image protocol picker after entering the alternate screen, while `halfblocks` skips terminal capability detection and uses the portable half-block renderer. The protocol is rebuilt when the preview area changes so the image keeps its aspect ratio within the pane. +**Image mode** (`--image[=detect|halfblocks]`, requires the default `image` feature): treats the expanded preview command as an image path instead of executing it. A worker thread decodes the image with the `image` crate and stores `PreviewContent::Image { source, protocol, size }`. Rendering uses `ratatui_image`; `detect` builds an image protocol picker after entering the alternate screen, while `halfblocks` skips terminal capability detection and uses the portable half-block renderer. The protocol is rebuilt when the preview area changes so the image keeps its aspect ratio within the pane. `Preview::spawn()`: ``` @@ -996,6 +1007,14 @@ Exit codes: `0` = items selected, `1` = no items selected, `130` = abort, `135` ## IPC / Listen Socket +This subsystem is gated behind the default `listen` Cargo feature, which also pulls in the +`interprocess`, `ron`, and `serde` dependencies. With the feature off, the `--listen` / +`--remote` flags, the `Skim::listener` field, the `select!` listener branch, and the +`serde` derives on `Action` are all compiled out. The `select!` branch cannot carry a +`#[cfg]` attribute (tokio's macro rejects it), so it stays in place but its future becomes +a never-resolving `pending()` and its handler is unreachable (the stream type alias +`RemoteStream` is uninhabited). + When `--listen ` is set, `Skim::init_listener()` creates an `interprocess` local socket. The main event loop's `select!` accepts connections and spawns Tokio tasks to read RON-encoded `Action` values line by line: ``` diff --git a/Cargo.toml b/Cargo.toml index 6fbba45d..e23e6682 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,11 +24,15 @@ required-features = ["cli"] [features] # Default is destined to the CLI, not to library usage. -default = ["cli", "frizbee"] +default = ["cli", "frizbee", "image", "listen"] # Everything needed to use skim as a cli (argument parsing, shell integrations...). This should not be needed for most libraries. -cli = ["dep:clap", "dep:clap_complete", "dep:shlex", "dep:env_logger", "dep:clap_mangen"] +cli = ["dep:clap", "dep:clap_complete", "dep:clap_complete_nushell", "dep:shlex", "dep:env_logger", "dep:clap_mangen"] # Include frizbee as a matching algorithm frizbee = ["dep:frizbee"] +# Enable image previews (renders the preview argument as an image) +image = ["dep:image", "dep:ratatui-image"] +# Enable the IPC socket (--listen / --remote), driving skim from other processes +listen = ["dep:interprocess", "dep:ron", "dep:serde"] # Enable gungraun (Valgrind-based) benchmarks gungraun = ["dep:gungraun"] @@ -61,7 +65,7 @@ ansi-to-tui = "8.0.1" assert_enum_variants = "0.1.2" clap = { version = "4.6.1" , optional = true, features = ["cargo", "derive", "unstable-markdown"] } clap_complete = { version = "4.6.5", optional = true } -clap_complete_nushell = "4.6.0" +clap_complete_nushell = { version = "4.6.0", optional = true } clap_mangen = { version = "0.3.0", optional = true } color-eyre = "0.6.5" # Crossterm's version is selected by ratatui @@ -71,9 +75,9 @@ derive_more = { version = "2.1.1", features = ["debug", "eq"] } env_logger = { version = "0.11.10", optional = true, features = ["humantime"] } futures = "0.3.32" gungraun = { version = "0.19.1", optional = true } -image = "0.25.10" +image = { version = "0.25.10", optional = true } indexmap = "2.13.1" -interprocess = { version = "2.4.2", features = ["tokio"] } +interprocess = { version = "2.4.2", features = ["tokio"], optional = true } kanal = "0.1.1" log = "0.4.31" memchr = "2.8.1" @@ -81,11 +85,11 @@ mimalloc = { version = "0.1.48", features = ["v3"] } nix = { version = "0.31.3", features = ["fs", "poll"] } portable-pty = "0.9.0" ratatui = "0.30.0" -ratatui-image = { version = "11.0.4", features = ["image-defaults", "crossterm"], default-features = false } +ratatui-image = { version = "11.0.4", features = ["image-defaults", "crossterm"], default-features = false, optional = true } regex = "1.12.3" roff = "1.1.1" -ron = "0.12.1" -serde = { version = "1.0.228", features = ["derive"] } +ron = { version = "0.12.1", optional = true } +serde = { version = "1.0.228", features = ["derive"], optional = true } shell-quote = "0.7.2" shlex = { version = "2.0.1", optional = true } tempfile = "3.27.0" diff --git a/src/bin/main.rs b/src/bin/main.rs index 3a593b74..418cb524 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -11,10 +11,14 @@ extern crate skim; use color_eyre::Result; use color_eyre::eyre::eyre; +#[cfg(feature = "listen")] use interprocess::bound_util::RefWrite; +#[cfg(feature = "listen")] use interprocess::local_socket::ToNsName as _; +#[cfg(feature = "listen")] use interprocess::local_socket::traits::Stream as _; use log::trace; +#[cfg(feature = "listen")] use skim::binds::parse_action_chain; use skim::reader::CommandCollector; use std::fs::File; @@ -90,6 +94,7 @@ fn main() -> Result<()> { return Ok(()); } + #[cfg(feature = "listen")] if let Some(remote) = opts.remote { let ns_name = remote .to_ns_name::() diff --git a/src/manpage.rs b/src/manpage.rs index 24ecb381..0e4239ec 100644 --- a/src/manpage.rs +++ b/src/manpage.rs @@ -184,6 +184,7 @@ const ACTIONS_SS: &str = " * yank: ctrl-y "; +#[cfg(feature = "listen")] const REMOTE_SECTION: &str = " skim can be controlled from other processes, using the `--listen` (and optionally `--remote`) flags. @@ -356,6 +357,7 @@ Example: section(&mut custom, "THEME", THEME_SECTION); + #[cfg(feature = "listen")] section(&mut custom, "LISTEN/REMOTE", REMOTE_SECTION); section(&mut custom, "EXIT CODES", EXIT_CODES_SECTION); diff --git a/src/options.rs b/src/options.rs index 27ca865d..9adc601b 100644 --- a/src/options.rs +++ b/src/options.rs @@ -8,6 +8,7 @@ use std::rc::Rc; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use derive_builder::Builder; +#[cfg(feature = "image")] use ratatui_image::picker::Picker; use regex::Regex; @@ -62,6 +63,7 @@ pub enum MatchScheme { } /// Image rendering protocols +#[cfg(feature = "image")] #[derive(Default, Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "cli", derive(clap::ValueEnum))] pub enum ImageProtocol { @@ -709,6 +711,7 @@ pub struct SkimOptions { /// /// Note: the backend detection **will not** work when piping data into skim, use /// `SKIM_DEFAULT_COMMAND="find . -type f" sk --image` instead of `find . -type f | sk --image` + #[cfg(feature = "image")] #[cfg_attr( feature = "cli", arg(long, help_heading = "Preview", value_enum, default_missing_value = "detect", num_args=0..) @@ -717,6 +720,7 @@ pub struct SkimOptions { /// Terminal image protocol picker, queried after entering the alternate screen. /// Built from `options.image` and an stdio detection if needed + #[cfg(feature = "image")] #[cfg_attr(feature = "cli", clap(skip))] #[builder(setter(skip))] #[debug(skip)] @@ -842,6 +846,7 @@ pub struct SkimOptions { /// /// The socket expects Actions in Ron format (similar to Rust code), see `./src/tui/event.rs` for all possible Actions /// To write to it, see the `--remote` option or the man page + #[cfg(feature = "listen")] #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", default_missing_value = "sk", num_args=0..))] pub listen: Option, @@ -850,6 +855,7 @@ pub struct SkimOptions { /// The commands are read from stdin, one per line, in the same format as the actions in the /// bind flag. They can also be chained using `+` as a separator. /// All other arguments will be ignored + #[cfg(feature = "listen")] #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", default_missing_value = "sk", num_args=0..))] pub remote: Option, @@ -1056,7 +1062,9 @@ impl Default for SkimOptions { no_strip_ansi: false, wrap_items: false, multiline: None, + #[cfg(feature = "listen")] listen: None, + #[cfg(feature = "listen")] remote: None, print_header: false, print_current: false, @@ -1115,7 +1123,9 @@ impl Default for SkimOptions { cmd_history_size: 1000, preview: Default::default(), preview_window: PreviewLayout::default(), + #[cfg(feature = "image")] image: None, + #[cfg(feature = "image")] image_picker: None, query: Default::default(), cmd_query: Default::default(), diff --git a/src/skim.rs b/src/skim.rs index 3af68be3..692d6168 100644 --- a/src/skim.rs +++ b/src/skim.rs @@ -5,6 +5,7 @@ use std::time::Duration; use color_eyre::eyre::{self, OptionExt, Result}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +#[cfg(feature = "image")] use ratatui_image::picker::Picker; use tokio::runtime::Handle; use tokio::select; @@ -15,6 +16,13 @@ use crate::tui::event::Action; use crate::tui::{App, Event, Size, TICK_RATE, Tui}; use crate::{SkimItem, SkimItemReceiver, SkimOptions, SkimOutput}; +/// Stream type yielded by the IPC listener. With the `listen` feature disabled the +/// listener branch can never fire, so its payload type is uninhabited. +#[cfg(feature = "listen")] +type RemoteStream = interprocess::local_socket::tokio::Stream; +#[cfg(not(feature = "listen"))] +type RemoteStream = std::convert::Infallible; + /// Main entry point for running skim pub struct Skim>> where @@ -29,6 +37,7 @@ where initial_cmd: String, reader_control: Option, matcher_interval: Option, + #[cfg(feature = "listen")] listener: Option, final_event: Event, final_key: KeyEvent, @@ -175,6 +184,7 @@ where tui: None, reader_control: None, matcher_interval: None, + #[cfg(feature = "listen")] listener: None, final_event: Event::Quit, final_key: KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()), @@ -350,6 +360,7 @@ where .expect("TUI needs to be initialized using Skim::init_tui before entering"); tui.enter_terminal()?; + #[cfg(feature = "image")] if self.app.options.image == Some(crate::options::ImageProtocol::Detect) { if !tui.is_fullscreen { crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen)?; @@ -466,7 +477,9 @@ where /// Initialize the IPC socket listener /// This needs to be called from an async context despite being sync + #[cfg_attr(not(feature = "listen"), allow(clippy::unnecessary_wraps, clippy::unused_self))] fn init_listener(&mut self) -> Result<()> { + #[cfg(feature = "listen")] if let Some(socket_name) = &self.app.options.listen { self.listener = Some( interprocess::local_socket::ListenerOptions::new() @@ -628,26 +641,38 @@ where self.app.restart_matcher(false); self.try_flush_render(); } + // IPC listener branch. `tokio::select!` does not allow `#[cfg]` on a + // branch, so the branch stays but its body is gated: with the `listen` + // feature off the future is a never-resolving `pending()` and the handler + // is unreachable (`RemoteStream` is uninhabited). Ok(stream) = async { - match &self.listener { - Some(l) => interprocess::local_socket::traits::tokio::Listener::accept(l).await, - None => std::future::pending().await, + #[cfg(feature = "listen")] + if let Some(l) = self.listener.as_ref() { + return interprocess::local_socket::traits::tokio::Listener::accept(l).await; } + std::future::pending::>().await } => { - debug!("Listener accepted a connection"); - let event_tx_clone_ipc = self.tui.as_ref().expect("TUI should be initialized before listening").event_tx.clone(); - tokio::spawn(async move { - use tokio::io::AsyncBufReadExt; - let reader = tokio::io::BufReader::new(stream); - let mut lines = reader.lines(); - while let Ok(Some(line)) = lines.next_line().await { - debug!("listener: got {line}"); - if let Ok(act) = ron::from_str::(&line) { - debug!("listener: parsed into action {act:?}"); - _ = event_tx_clone_ipc.try_send(Event::Action(act)); + #[cfg(feature = "listen")] + { + debug!("Listener accepted a connection"); + let event_tx_clone_ipc = self.tui.as_ref().expect("TUI should be initialized before listening").event_tx.clone(); + tokio::spawn(async move { + use tokio::io::AsyncBufReadExt; + let reader = tokio::io::BufReader::new(stream); + let mut lines = reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + debug!("listener: got {line}"); + if let Ok(act) = ron::from_str::(&line) { + debug!("listener: parsed into action {act:?}"); + if let Err(e) = event_tx_clone_ipc.try_send(Event::Action(act)) { + warn!("listener: failed to send action to backend: {e:?}"); + } + } } - } - }); + }); + } + #[cfg(not(feature = "listen"))] + match stream {} } } diff --git a/src/tui/app.rs b/src/tui/app.rs index 6d8b3eda..449b8467 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -466,7 +466,10 @@ impl App { preview, ItemPreview::Global | ItemPreview::Command(_) | ItemPreview::CommandWithPos(_, _) ); + #[cfg(feature = "image")] let quote_cmd = self.options.image.is_none(); + #[cfg(not(feature = "image"))] + let quote_cmd = true; match preview { ItemPreview::Command(cmd) => self.preview.spawn(tui, &self.expand_cmd(&cmd, quote_cmd))?, ItemPreview::Text(t) | ItemPreview::AnsiText(t) => { diff --git a/src/tui/event.rs b/src/tui/event.rs index 021d1c8a..44cef358 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -151,7 +151,8 @@ pub enum Event { } /// Actions that can be performed in skim -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "listen", derive(serde::Serialize, serde::Deserialize))] pub enum Action { /// Abort and exit with error Abort, @@ -293,7 +294,7 @@ pub enum Action { #[debug("custom")] #[eq(skip)] #[partial_eq(skip)] - #[serde(skip)] + #[cfg_attr(feature = "listen", serde(skip))] Custom(ActionCallback), } diff --git a/src/tui/preview.rs b/src/tui/preview.rs index 9f0ffdfe..c9522e56 100644 --- a/src/tui/preview.rs +++ b/src/tui/preview.rs @@ -6,7 +6,9 @@ use ratatui::prelude::Backend; use ratatui::style::Stylize; use ratatui::text::{Line, Text}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget}; +#[cfg(feature = "image")] use ratatui_image::picker::Picker; +#[cfg(feature = "image")] use ratatui_image::protocol::Protocol as ImageProtocol; use tui_term::vt100; use tui_term::widget::PseudoTerminal; @@ -37,6 +39,7 @@ pub(crate) enum PreviewContent { /// Terminal screen (for PTY previews with cursor positioning) Terminal(Arc>), /// Image + #[cfg(feature = "image")] Image { source: image::DynamicImage, protocol: Option, @@ -90,7 +93,9 @@ pub struct Preview { pub wrap: bool, pty: Option, pty_child: Option>, + #[cfg(feature = "image")] image: bool, + #[cfg(feature = "image")] image_picker: Option, pub total_lines: u16, loading: bool, @@ -104,6 +109,7 @@ impl Default for Preview { } impl Preview { + #[cfg(feature = "image")] fn image_protocol( picker: Option<&Picker>, source: image::DynamicImage, @@ -138,6 +144,7 @@ impl Preview { picker.new_protocol(source, size, ratatui_image::Resize::Scale(None)) } + #[cfg(feature = "image")] pub(crate) fn set_image_picker(&mut self, picker: Option) { self.image_picker = picker; } @@ -331,6 +338,7 @@ impl Preview { let event_tx_clone = tui.event_tx.clone(); let content = self.content.clone(); + #[cfg(feature = "image")] if self.image { let cmd = self.cmd.clone(); @@ -606,6 +614,7 @@ impl Preview { } total_lines } + #[cfg(feature = "image")] fn render_image( &self, mut outer: Block, @@ -667,7 +676,9 @@ impl SkimWidget for Preview { interrupt_tx: None, pty: None, pty_child: None, + #[cfg(feature = "image")] image: options.image.is_some(), + #[cfg(feature = "image")] image_picker: options.image_picker.clone(), total_lines: 0, loading: false, @@ -717,6 +728,7 @@ impl SkimWidget for Preview { match &mut *content { PreviewContent::Text(text) => self.total_lines = self.render_text(block, area, buf, text), PreviewContent::Terminal(parser) => self.total_lines = self.render_pty(block, area, buf, parser.as_ref()), + #[cfg(feature = "image")] PreviewContent::Image { source, protocol, size } => { self.render_image(block, area, buf, source, protocol, size); } diff --git a/src/tui/preview_tests.rs b/src/tui/preview_tests.rs index 3a62cb12..cd1fa6ef 100644 --- a/src/tui/preview_tests.rs +++ b/src/tui/preview_tests.rs @@ -1,13 +1,18 @@ +#[cfg(feature = "image")] use image::{DynamicImage, RgbaImage}; +#[cfg(feature = "image")] use ratatui::layout::Size; +#[cfg(feature = "image")] use ratatui_image::picker::Picker; use super::Preview; +#[cfg(feature = "image")] fn image(width: u32, height: u32) -> DynamicImage { DynamicImage::ImageRgba8(RgbaImage::new(width, height)) } +#[cfg(feature = "image")] #[test] fn image_protocol_constrains_by_width() { let protocol = Preview::image_protocol(Some(&Picker::halfblocks()), image(400, 200), Size::new(20, 10)) @@ -16,6 +21,7 @@ fn image_protocol_constrains_by_width() { assert_eq!(protocol.size(), Size::new(20, 5)); } +#[cfg(feature = "image")] #[test] fn image_protocol_constrains_by_height() { let protocol = Preview::image_protocol(Some(&Picker::halfblocks()), image(200, 400), Size::new(20, 10)) @@ -24,6 +30,7 @@ fn image_protocol_constrains_by_height() { assert_eq!(protocol.size(), Size::new(10, 10)); } +#[cfg(feature = "image")] #[test] fn image_protocol_keeps_at_least_one_cell() { let protocol = Preview::image_protocol(Some(&Picker::halfblocks()), image(1000, 1), Size::new(1, 1)) @@ -32,6 +39,7 @@ fn image_protocol_keeps_at_least_one_cell() { assert_eq!(protocol.size(), Size::new(1, 1)); } +#[cfg(feature = "image")] #[test] fn image_protocol_uses_halfblocks_picker_when_none_is_provided() { let protocol = Preview::image_protocol(None, image(400, 200), Size::new(20, 10)) @@ -160,6 +168,7 @@ fn size_to_offset_resolves_each_variant() { assert_eq!(p.size_to_offset(super::super::Size::Neg(10), false), 70); } +#[cfg(feature = "image")] #[test] fn set_image_picker_sets_and_clears() { let mut p = Preview::default(); diff --git a/tests/listen.rs b/tests/listen.rs index b27924cf..e9ca97d4 100644 --- a/tests/listen.rs +++ b/tests/listen.rs @@ -1,7 +1,7 @@ // TODO: automate listen tests on windows // Maybe using smaller tests ? actions processing is already tested, only the IPC part needs testing #![allow(missing_docs, clippy::pedantic)] -#![cfg(unix)] +#![cfg(all(unix, feature = "listen"))] #[allow(dead_code)] #[macro_use] mod common;