skim/
lib.rs

1#[macro_use]
2extern crate log;
3
4use std::any::Any;
5use std::borrow::Cow;
6use std::fmt::Display;
7use std::sync::mpsc::channel;
8use std::sync::Arc;
9use std::thread;
10
11use clap::ValueEnum;
12use crossbeam::channel::{Receiver, Sender};
13use tuikit::prelude::{Event as TermEvent, *};
14
15pub use crate::ansi::AnsiString;
16pub use crate::engine::fuzzy::FuzzyAlgorithm;
17use crate::event::{EventReceiver, EventSender};
18pub use crate::item::RankCriteria;
19use crate::model::Model;
20pub use crate::options::SkimOptions;
21pub use crate::output::SkimOutput;
22use crate::reader::Reader;
23
24mod ansi;
25mod engine;
26mod event;
27pub mod field;
28mod global;
29mod header;
30mod helper;
31mod input;
32pub mod item;
33mod matcher;
34mod model;
35pub mod options;
36mod orderedvec;
37mod output;
38pub mod prelude;
39mod previewer;
40mod query;
41pub mod reader;
42mod selection;
43mod spinlock;
44mod theme;
45pub mod tmux;
46mod util;
47
48//------------------------------------------------------------------------------
49pub trait AsAny {
50    fn as_any(&self) -> &dyn Any;
51    fn as_any_mut(&mut self) -> &mut dyn Any;
52}
53
54impl<T: Any> AsAny for T {
55    fn as_any(&self) -> &dyn Any {
56        self
57    }
58
59    fn as_any_mut(&mut self) -> &mut dyn Any {
60        self
61    }
62}
63
64/// A `SkimItem` defines what's been processed(fetched, matched, previewed and returned) by skim
65///
66/// # Downcast Example
67/// Skim will return the item back, but in `Arc<dyn SkimItem>` form. We might want a reference
68/// to the concrete type instead of trait object. Skim provide a somehow "complicated" way to
69/// `downcast` it back to the reference of the original concrete type.
70///
71/// ```rust
72/// use skim::prelude::*;
73///
74/// struct MyItem {}
75/// impl SkimItem for MyItem {
76///     fn text(&self) -> Cow<str> {
77///         unimplemented!()
78///     }
79/// }
80///
81/// impl MyItem {
82///     pub fn mutable(&mut self) -> i32 {
83///         1
84///     }
85///
86///     pub fn immutable(&self) -> i32 {
87///         0
88///     }
89/// }
90///
91/// let mut ret: Arc<dyn SkimItem> = Arc::new(MyItem{});
92/// let mutable: &mut MyItem = Arc::get_mut(&mut ret)
93///     .expect("item is referenced by others")
94///     .as_any_mut() // cast to Any
95///     .downcast_mut::<MyItem>() // downcast to (mut) concrete type
96///     .expect("something wrong with downcast");
97/// assert_eq!(mutable.mutable(), 1);
98///
99/// let immutable: &MyItem = (*ret).as_any() // cast to Any
100///     .downcast_ref::<MyItem>() // downcast to concrete type
101///     .expect("something wrong with downcast");
102/// assert_eq!(immutable.immutable(), 0)
103/// ```
104pub trait SkimItem: AsAny + Send + Sync + 'static {
105    /// The string to be used for matching (without color)
106    fn text(&self) -> Cow<str>;
107
108    /// The content to be displayed on the item list, could contain ANSI properties
109    fn display<'a>(&'a self, context: DisplayContext<'a>) -> AnsiString<'a> {
110        AnsiString::from(context)
111    }
112
113    /// Custom preview content, default to `ItemPreview::Global` which will use global preview
114    /// setting(i.e. the command set by `preview` option)
115    fn preview(&self, _context: PreviewContext) -> ItemPreview {
116        ItemPreview::Global
117    }
118
119    /// Get output text(after accept), default to `text()`
120    /// Note that this function is intended to be used by the caller of skim and will not be used by
121    /// skim. And since skim will return the item back in `SkimOutput`, if string is not what you
122    /// want, you could still use `downcast` to retain the pointer to the original struct.
123    fn output(&self) -> Cow<str> {
124        self.text()
125    }
126
127    /// we could limit the matching ranges of the `get_text` of the item.
128    /// providing (`start_byte`, `end_byte`) of the range
129    fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> {
130        None
131    }
132
133    /// Get index, for matching purposes
134    ///
135    /// Implemented as no-op for retro-compatibility purposes
136    fn get_index(&self) -> usize {
137        0
138    }
139    /// Set index, for matching purposes
140    ///
141    /// Implemented as no-op for retro-compatibility purposes
142    fn set_index(&mut self, _index: usize) {}
143}
144
145//------------------------------------------------------------------------------
146// Implement SkimItem for raw strings
147
148impl<T: AsRef<str> + Send + Sync + 'static> SkimItem for T {
149    fn text(&self) -> Cow<str> {
150        Cow::Borrowed(self.as_ref())
151    }
152}
153
154//------------------------------------------------------------------------------
155// Display Context
156pub enum Matches<'a> {
157    None,
158    CharIndices(&'a [usize]),
159    CharRange(usize, usize),
160    ByteRange(usize, usize),
161}
162
163pub struct DisplayContext<'a> {
164    pub text: &'a str,
165    pub score: i32,
166    pub matches: Matches<'a>,
167    pub container_width: usize,
168    pub highlight_attr: Attr,
169}
170
171impl<'a> From<DisplayContext<'a>> for AnsiString<'a> {
172    fn from(context: DisplayContext<'a>) -> Self {
173        match context.matches {
174            Matches::CharIndices(indices) => AnsiString::from((context.text, indices, context.highlight_attr)),
175            #[allow(clippy::cast_possible_truncation)]
176            Matches::CharRange(start, end) => {
177                AnsiString::new_str(context.text, vec![(context.highlight_attr, (start as u32, end as u32))])
178            }
179            Matches::ByteRange(start, end) => {
180                let ch_start = context.text[..start].chars().count();
181                let ch_end = ch_start + context.text[start..end].chars().count();
182                #[allow(clippy::cast_possible_truncation)]
183                AnsiString::new_str(
184                    context.text,
185                    vec![(context.highlight_attr, (ch_start as u32, ch_end as u32))],
186                )
187            }
188            Matches::None => AnsiString::new_str(context.text, vec![]),
189        }
190    }
191}
192
193//------------------------------------------------------------------------------
194// Preview Context
195
196pub struct PreviewContext<'a> {
197    pub query: &'a str,
198    pub cmd_query: &'a str,
199    pub width: usize,
200    pub height: usize,
201    pub current_index: usize,
202    pub current_selection: &'a str,
203    /// selected item indices (may or may not include current item)
204    pub selected_indices: &'a [usize],
205    /// selected item texts (may or may not include current item)
206    pub selections: &'a [&'a str],
207}
208
209//------------------------------------------------------------------------------
210// Preview
211#[derive(Default, Copy, Clone, Debug)]
212pub struct PreviewPosition {
213    pub h_scroll: Size,
214    pub h_offset: Size,
215    pub v_scroll: Size,
216    pub v_offset: Size,
217}
218
219pub enum ItemPreview {
220    /// execute the command and print the command's output
221    Command(String),
222    /// Display the prepared text(lines)
223    Text(String),
224    /// Display the colored text(lines)
225    AnsiText(String),
226    CommandWithPos(String, PreviewPosition),
227    TextWithPos(String, PreviewPosition),
228    AnsiWithPos(String, PreviewPosition),
229    /// Use global command settings to preview the item
230    Global,
231}
232
233//==============================================================================
234// A match engine will execute the matching algorithm
235
236#[derive(ValueEnum, Eq, PartialEq, Debug, Copy, Clone, Default)]
237#[clap(rename_all = "snake_case")]
238pub enum CaseMatching {
239    Respect,
240    Ignore,
241    #[default]
242    Smart,
243}
244
245#[derive(PartialEq, Eq, Clone, Debug)]
246#[allow(dead_code)]
247pub enum MatchRange {
248    ByteRange(usize, usize),
249    // range of bytes
250    Chars(Vec<usize>), // individual character indices matched
251}
252
253pub type Rank = [i32; 5];
254
255#[derive(Clone)]
256pub struct MatchResult {
257    pub rank: Rank,
258    pub matched_range: MatchRange,
259}
260
261impl MatchResult {
262    #[must_use]
263    pub fn range_char_indices(&self, text: &str) -> Vec<usize> {
264        match &self.matched_range {
265            &MatchRange::ByteRange(start, end) => {
266                let first = text[..start].chars().count();
267                let last = first + text[start..end].chars().count();
268                (first..last).collect()
269            }
270            MatchRange::Chars(vec) => vec.clone(),
271        }
272    }
273}
274
275pub trait MatchEngine: Sync + Send + Display {
276    fn match_item(&self, item: Arc<dyn SkimItem>) -> Option<MatchResult>;
277}
278
279pub trait MatchEngineFactory {
280    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine>;
281    fn create_engine(&self, query: &str) -> Box<dyn MatchEngine> {
282        self.create_engine_with_case(query, CaseMatching::default())
283    }
284}
285
286//------------------------------------------------------------------------------
287// Preselection
288
289/// A selector that determines whether an item should be "pre-selected" in multi-selection mode
290pub trait Selector {
291    fn should_select(&self, index: usize, item: &dyn SkimItem) -> bool;
292}
293
294//------------------------------------------------------------------------------
295pub type SkimItemSender = Sender<Arc<dyn SkimItem>>;
296pub type SkimItemReceiver = Receiver<Arc<dyn SkimItem>>;
297
298pub struct Skim {}
299
300impl Skim {
301    /// # Params
302    ///
303    /// - options: the "complex" options that control how skim behaves
304    /// - source: a stream of items to be passed to skim for filtering.
305    ///   If None is given, skim will invoke the command given to fetch the items.
306    ///
307    /// # Returns
308    ///
309    /// - None: on internal errors.
310    /// - `SkimOutput`: the collected key, event, query, selected items, etc.
311    ///
312    /// # Panics
313    ///
314    /// Panics if the tui fails to initilize
315    #[must_use]
316    pub fn run_with(options: &SkimOptions, source: Option<SkimItemReceiver>) -> Option<SkimOutput> {
317        let min_height = Skim::parse_height_string(&options.min_height);
318        let height = Skim::parse_height_string(&options.height);
319
320        let (tx, rx): (EventSender, EventReceiver) = channel();
321        let term = Arc::new(
322            Term::with_options(
323                TermOptions::default()
324                    .min_height(min_height)
325                    .height(height)
326                    .clear_on_exit(!options.no_clear)
327                    .disable_alternate_screen(options.no_clear_start)
328                    .clear_on_start(!options.no_clear_start)
329                    .hold(options.select_1 || options.exit_0 || options.sync),
330            )
331            .unwrap(),
332        );
333        if !options.no_mouse {
334            let _ = term.enable_mouse_support();
335        }
336
337        //------------------------------------------------------------------------------
338        // input
339        let mut input = input::Input::new();
340        input.parse_keymaps(options.bind.iter().map(String::as_str));
341        input.parse_expect_keys(options.expect.iter().map(String::as_str));
342
343        let tx_clone = tx.clone();
344        let term_clone = term.clone();
345        let input_thread = thread::spawn(move || loop {
346            if let Ok(key) = term_clone.poll_event() {
347                if key == TermEvent::User(()) {
348                    break;
349                }
350
351                let (key, action_chain) = input.translate_event(key);
352                for event in action_chain {
353                    let _ = tx_clone.send((key, event));
354                }
355            }
356        });
357
358        //------------------------------------------------------------------------------
359        // reader
360
361        let reader = Reader::with_options(options).source(source);
362
363        //------------------------------------------------------------------------------
364        // model + previewer
365        let mut model = Model::new(rx, tx, reader, term.clone(), options);
366        let ret = model.start();
367        let _ = term.send_event(TermEvent::User(())); // interrupt the input thread
368        let _ = input_thread.join();
369        ret
370    }
371
372    /// Converts a &str to a TermHeight, based on whether or not it ends with a percent sign
373    ///
374    /// Will clamp percentages into [0, 100] and fixed into [0, MAX_USIZE]
375    /// 10 -> TermHeight::Fixed(10)
376    /// 10% -> TermHeight::Percent(10)
377    fn parse_height_string(string: &str) -> TermHeight {
378        if string.ends_with('%') {
379            let inner = string[0..string.len() - 1].parse().unwrap_or(100);
380            TermHeight::Percent(inner.clamp(0, 100))
381        } else {
382            let inner = string.parse().unwrap_or(0);
383            TermHeight::Fixed(inner)
384        }
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    #[test]
392    fn parse_height_string_fixed() {
393        let TermHeight::Fixed(h) = Skim::parse_height_string("10") else {
394            panic!("Expected fixed, found percent");
395        };
396        assert_eq!(h, 10)
397    }
398    #[test]
399    fn parse_height_string_percent() {
400        let TermHeight::Percent(h) = Skim::parse_height_string("10%") else {
401            panic!("Expected percent, found fixed");
402        };
403        assert_eq!(h, 10)
404    }
405    #[test]
406    fn parse_height_string_percent_neg() {
407        let TermHeight::Percent(h) = Skim::parse_height_string("-20%") else {
408            panic!("Expected fixed, found percent");
409        };
410        assert_eq!(h, 100)
411    }
412    #[test]
413    fn parse_height_string_percent_too_large() {
414        let TermHeight::Percent(h) = Skim::parse_height_string("120%") else {
415            panic!("Expected percent, found fixed");
416        };
417        assert_eq!(h, 100)
418    }
419    #[test]
420    fn parse_height_string_fixed_neg() {
421        let TermHeight::Fixed(h) = Skim::parse_height_string("-20") else {
422            panic!("Expected fixed, found percent");
423        };
424        assert_eq!(h, 0)
425    }
426}