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