From eccf2ad2f86affa38ea01508a26273a305ee2c68 Mon Sep 17 00:00:00 2001 From: LoricAndre Date: Tue, 8 Sep 2026 14:37:57 +0000 Subject: [PATCH] deploy: dd3b09a687c6cad6e1789ecf2aa0f321285fdd5c --- coverage/coverage.svg | 8 +- .../work/skim/skim/src/bin/main.rs.html | 66 +- .../runner/work/skim/skim/src/binds.rs.html | 38 +- .../work/skim/skim/src/engine/all.rs.html | 2 +- .../work/skim/skim/src/engine/andor.rs.html | 4 +- .../work/skim/skim/src/engine/exact.rs.html | 10 +- .../work/skim/skim/src/engine/factory.rs.html | 42 +- .../work/skim/skim/src/engine/fuzzy.rs.html | 6 +- .../skim/skim/src/engine/normalized.rs.html | 2 +- .../work/skim/skim/src/engine/regexp.rs.html | 2 +- .../work/skim/skim/src/engine/split.rs.html | 6 +- .../work/skim/skim/src/engine/util.rs.html | 6 +- .../runner/work/skim/skim/src/field.rs.html | 30 +- .../src/fuzzy_matcher/arinae/algo.rs.html | 28 +- .../src/fuzzy_matcher/arinae/atom.rs.html | 4 +- .../src/fuzzy_matcher/arinae/banding.rs.html | 10 +- .../src/fuzzy_matcher/arinae/helpers.rs.html | 6 +- .../src/fuzzy_matcher/arinae/matrix.rs.html | 4 +- .../skim/src/fuzzy_matcher/arinae/mod.rs.html | 12 +- .../fuzzy_matcher/arinae/prefilter.rs.html | 2 +- .../skim/src/fuzzy_matcher/clangd.rs.html | 2 +- .../skim/src/fuzzy_matcher/frizbee.rs.html | 2 +- .../skim/skim/src/fuzzy_matcher/fzy.rs.html | 4 +- .../skim/skim/src/fuzzy_matcher/mod.rs.html | 2 +- .../skim/skim/src/fuzzy_matcher/skim.rs.html | 2 +- .../skim/skim/src/fuzzy_matcher/util.rs.html | 2 +- .../work/skim/skim/src/helper/item.rs.html | 56 +- .../skim/skim/src/helper/item_reader.rs.html | 348 +++++----- .../skim/skim/src/helper/selector.rs.html | 8 +- .../runner/work/skim/skim/src/item.rs.html | 32 +- .../runner/work/skim/skim/src/lib.rs.html | 2 +- .../runner/work/skim/skim/src/manpage.rs.html | 2 +- .../runner/work/skim/skim/src/matcher.rs.html | 50 +- .../runner/work/skim/skim/src/options.rs.html | 30 +- .../runner/work/skim/skim/src/output.rs.html | 36 +- .../work/skim/skim/src/popup/mod.rs.html | 2 +- .../work/skim/skim/src/popup/tmux.rs.html | 2 +- .../work/skim/skim/src/popup/zellij.rs.html | 2 +- .../runner/work/skim/skim/src/reader.rs.html | 18 +- .../runner/work/skim/skim/src/shell.rs.html | 8 +- .../runner/work/skim/skim/src/skim.rs.html | 488 +++++++------- .../work/skim/skim/src/skim_item.rs.html | 2 +- .../work/skim/skim/src/spinlock.rs.html | 8 +- .../runner/work/skim/skim/src/theme.rs.html | 36 +- .../work/skim/skim/src/thread_pool.rs.html | 34 +- .../work/skim/skim/src/tui/actions.rs.html | 4 +- .../runner/work/skim/skim/src/tui/app.rs.html | 636 +++++++++--------- .../work/skim/skim/src/tui/backend.rs.html | 154 ++--- .../work/skim/skim/src/tui/header.rs.html | 32 +- .../work/skim/skim/src/tui/input.rs.html | 56 +- .../work/skim/skim/src/tui/item_list.rs.html | 104 +-- .../skim/skim/src/tui/item_renderer.rs.html | 114 ++-- .../work/skim/skim/src/tui/layout.rs.html | 36 +- .../runner/work/skim/skim/src/tui/mod.rs.html | 14 +- .../work/skim/skim/src/tui/options.rs.html | 22 +- .../work/skim/skim/src/tui/preview.rs.html | 584 ++++++++-------- .../work/skim/skim/src/tui/statusline.rs.html | 6 +- .../work/skim/skim/src/tui/util.rs.html | 8 +- .../work/skim/skim/src/tui/widget.rs.html | 2 +- .../runner/work/skim/skim/src/util.rs.html | 100 +-- coverage/index.html | 2 +- 61 files changed, 1670 insertions(+), 1670 deletions(-) diff --git a/coverage/coverage.svg b/coverage/coverage.svg index dd39629c..74299f23 100644 --- a/coverage/coverage.svg +++ b/coverage/coverage.svg @@ -1,5 +1,5 @@ - - Coverage: 85.35% + + Coverage: 85.43% @@ -13,8 +13,8 @@ \ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/bin/main.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/bin/main.rs.html index c577cb3f..1be014eb 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/bin/main.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/bin/main.rs.html @@ -1,37 +1,37 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/bin/main.rs
Line
Count
Source
1
//! Command-line interface for skim fuzzy finder.
2
//!
3
//! This binary provides the `sk` command-line tool for fuzzy finding and filtering.
4
#![cfg_attr(coverage, allow(unused_features), feature(coverage_attribute))]
5
6
extern crate clap;
7
extern crate env_logger;
8
extern crate log;
9
extern crate shlex;
10
extern crate skim;
11
12
use eyre::{Result, eyre};
13
#[cfg(feature = "listen")]
14
use interprocess::bound_util::RefWrite;
15
#[cfg(feature = "listen")]
16
use interprocess::local_socket::ToNsName as _;
17
#[cfg(feature = "listen")]
18
use interprocess::local_socket::traits::Stream as _;
19
use log::trace;
20
#[cfg(feature = "listen")]
21
use skim::binds::parse_action_chain;
22
use skim::reader::CommandCollector;
23
use std::fs::File;
24
use std::io;
25
use std::io::{BufReader, BufWriter, IsTerminal, Write};
26
27
use skim::prelude::*;
28
29
71
fn init_logger(opts: &SkimOptions) {
30
71
    let target = if let Some(ref log_file) = opts.log_file.as_ref().or(std::env::var("SKIM_LOG_FILE").ok().as_ref()) {
  Branch (30:25): [Folded - Ignored]
-
  Branch (30:25): [True: 1, False: 70]
-
31
1
        env_logger::Target::Pipe(Box::new(File::create(log_file).expect("Failed to create log file")))
32
    } else {
33
70
        env_logger::Target::Stdout
34
    };
35
36
71
    let env_var = "SKIM_LOG";
37
38
47
    let format = |buf: &mut env_logger::fmt::Formatter, record: &log::Record<'_>| {
39
47
        writeln!(
40
47
            buf,
41
            "[{} {} {} ({}:{})] [{}/{:?}] {}",
42
47
            buf.timestamp_nanos(),
43
47
            record.level().as_str(),
44
47
            record.module_path().unwrap_or("sk"),
45
47
            record.file().unwrap_or_default(),
46
47
            record.line().unwrap_or_default(),
47
47
            std::thread::current().name().unwrap_or("?"),
48
47
            std::thread::current().id(),
49
47
            record.args()
50
        )
51
47
    };
52
53
71
    if let Some(level) = opts.log_level {
  Branch (53:12): [Folded - Ignored]
-
  Branch (53:12): [True: 0, False: 71]
-
54
0
        env_logger::builder()
55
0
            .filter_level(level)
56
0
            .parse_env(env_var)
57
0
            .target(target)
58
0
            .format(format)
59
0
            .init();
60
    } else {
61
71
        env_logger::builder()
62
71
            .parse_env(env_var)
63
71
            .target(target)
64
71
            .format(format)
65
71
            .init();
66
    }
67
71
}
68
69
//------------------------------------------------------------------------------
70
76
fn main() -> Result<()> {
71
76
    let mut opts = SkimOptions::from_env().unwrap_or_else(|e| 
{5
72
5
        e.exit();
73
    });
74
76
    init_logger(&opts);
75
76
    // Build the options after setting the log target
77
76
    opts = opts.build();
78
76
    trace!("Command line: {:?}", 
std::env::args()1
);
79
80
    // Shell completion scripts
81
76
    if let Some(shell) = opts.shell {
  Branch (81:12): [Folded - Ignored]
-
  Branch (81:12): [True: 7, False: 69]
-
82
        // Generate completion script directly to stdout
83
7
        skim::shell::generate_completions(&shell, &mut std::io::stdout());
84
7
        if opts.shell_bindings {
  Branch (84:12): [Folded - Ignored]
-
  Branch (84:12): [True: 1, False: 6]
-
85
1
            skim::shell::generate_key_bindings(&shell, &mut std::io::stdout())
?0
;
86
6
        }
87
7
        return Ok(());
88
69
    }
89
    // Man page
90
69
    if opts.man {
  Branch (90:8): [Folded - Ignored]
-
  Branch (90:8): [True: 1, False: 68]
-
91
1
        crate::manpage::generate(&mut std::io::stdout())
?0
;
92
1
        return Ok(());
93
68
    }
94
95
    #[cfg(feature = "listen")]
96
68
    if let Some(remote) = opts.remote {
  Branch (96:12): [Folded - Ignored]
-
  Branch (96:12): [True: 32, False: 36]
-
97
32
        let ns_name = remote
98
32
            .to_ns_name::<interprocess::local_socket::GenericNamespaced>()
99
32
            .unwrap();
100
32
        let stream = interprocess::local_socket::Stream::connect(ns_name)
?0
;
101
32
        let mut action_chain = String::new();
102
        loop {
103
71
            action_chain.clear();
104
71
            let len = std::io::stdin().read_line(&mut action_chain)
?0
;
105
71
            log::debug!("Got line {} from stdin", 
action_chain.trim()0
);
106
71
            if len == 0 {
  Branch (106:16): [Folded - Ignored]
-
  Branch (106:16): [True: 32, False: 39]
-
107
32
                break;
108
39
            }
109
39
            let actions = parse_action_chain(action_chain.trim())
?0
;
110
39
            for act in actions {
111
41
                stream
112
41
                    .as_write()
113
41
                    .write_all(format!("{}\n", ron::ser::to_string(&act)
?0
).as_bytes())
?0
;
114
41
                log::debug!("Sent action {
act0
:?} to listener");
115
            }
116
        }
117
32
        return Ok(());
118
36
    }
119
120
36
    match sk_main(opts) {
121
36
        Ok(exit_code) => std::process::exit(exit_code),
122
0
        Err(err) => match err.downcast_ref::<clap::error::Error>() {
123
0
            Some(e) => e.exit(),
124
0
            None => Err(eyre!(err)),
125
        },
126
    }
127
40
}
128
129
/// Returns `None` if the popup should not open, otherwise run the popup and return the result
130
#[cfg(unix)]
131
#[allow(clippy::option_option)]
132
36
fn check_and_run_popup(opts: &SkimOptions) -> Option<Option<SkimOutput>> {
133
36
    if opts.popup.is_some() && 
popup::check_env()5
{
  Branch (133:8): [Folded - Ignored]
-  Branch (133:32): [Folded - Ignored]
-
  Branch (133:8): [True: 5, False: 31]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/bin/main.rs
Line
Count
Source
1
//! Command-line interface for skim fuzzy finder.
2
//!
3
//! This binary provides the `sk` command-line tool for fuzzy finding and filtering.
4
#![cfg_attr(coverage, allow(unused_features), feature(coverage_attribute))]
5
6
extern crate clap;
7
extern crate env_logger;
8
extern crate log;
9
extern crate shlex;
10
extern crate skim;
11
12
use eyre::{Result, eyre};
13
#[cfg(feature = "listen")]
14
use interprocess::bound_util::RefWrite;
15
#[cfg(feature = "listen")]
16
use interprocess::local_socket::ToNsName as _;
17
#[cfg(feature = "listen")]
18
use interprocess::local_socket::traits::Stream as _;
19
use log::trace;
20
#[cfg(feature = "listen")]
21
use skim::binds::parse_action_chain;
22
use skim::reader::CommandCollector;
23
use std::fs::File;
24
use std::io;
25
use std::io::{BufReader, BufWriter, IsTerminal, Write};
26
27
use skim::prelude::*;
28
29
71
fn init_logger(opts: &SkimOptions) {
30
71
    let target = if let Some(ref log_file) = opts.log_file.as_ref().or(std::env::var("SKIM_LOG_FILE").ok().as_ref()) {
  Branch (30:25): [True: 1, False: 70]
+
  Branch (30:25): [Folded - Ignored]
+
31
1
        env_logger::Target::Pipe(Box::new(File::create(log_file).expect("Failed to create log file")))
32
    } else {
33
70
        env_logger::Target::Stdout
34
    };
35
36
71
    let env_var = "SKIM_LOG";
37
38
46
    let format = |buf: &mut env_logger::fmt::Formatter, record: &log::Record<'_>| {
39
46
        writeln!(
40
46
            buf,
41
            "[{} {} {} ({}:{})] [{}/{:?}] {}",
42
46
            buf.timestamp_nanos(),
43
46
            record.level().as_str(),
44
46
            record.module_path().unwrap_or("sk"),
45
46
            record.file().unwrap_or_default(),
46
46
            record.line().unwrap_or_default(),
47
46
            std::thread::current().name().unwrap_or("?"),
48
46
            std::thread::current().id(),
49
46
            record.args()
50
        )
51
46
    };
52
53
71
    if let Some(level) = opts.log_level {
  Branch (53:12): [True: 0, False: 71]
+
  Branch (53:12): [Folded - Ignored]
+
54
0
        env_logger::builder()
55
0
            .filter_level(level)
56
0
            .parse_env(env_var)
57
0
            .target(target)
58
0
            .format(format)
59
0
            .init();
60
    } else {
61
71
        env_logger::builder()
62
71
            .parse_env(env_var)
63
71
            .target(target)
64
71
            .format(format)
65
71
            .init();
66
    }
67
71
}
68
69
//------------------------------------------------------------------------------
70
76
fn main() -> Result<()> {
71
76
    let mut opts = SkimOptions::from_env().unwrap_or_else(|e| 
{5
72
5
        e.exit();
73
    });
74
76
    init_logger(&opts);
75
76
    // Build the options after setting the log target
77
76
    opts = opts.build();
78
76
    trace!("Command line: {:?}", 
std::env::args()1
);
79
80
    // Shell completion scripts
81
76
    if let Some(shell) = opts.shell {
  Branch (81:12): [True: 7, False: 69]
+
  Branch (81:12): [Folded - Ignored]
+
82
        // Generate completion script directly to stdout
83
7
        skim::shell::generate_completions(&shell, &mut std::io::stdout());
84
7
        if opts.shell_bindings {
  Branch (84:12): [True: 1, False: 6]
+
  Branch (84:12): [Folded - Ignored]
+
85
1
            skim::shell::generate_key_bindings(&shell, &mut std::io::stdout())
?0
;
86
6
        }
87
7
        return Ok(());
88
69
    }
89
    // Man page
90
69
    if opts.man {
  Branch (90:8): [True: 1, False: 68]
+
  Branch (90:8): [Folded - Ignored]
+
91
1
        crate::manpage::generate(&mut std::io::stdout())
?0
;
92
1
        return Ok(());
93
68
    }
94
95
    #[cfg(feature = "listen")]
96
68
    if let Some(remote) = opts.remote {
  Branch (96:12): [True: 32, False: 36]
+
  Branch (96:12): [Folded - Ignored]
+
97
32
        let ns_name = remote
98
32
            .to_ns_name::<interprocess::local_socket::GenericNamespaced>()
99
32
            .unwrap();
100
32
        let stream = interprocess::local_socket::Stream::connect(ns_name)
?0
;
101
32
        let mut action_chain = String::new();
102
        loop {
103
71
            action_chain.clear();
104
71
            let len = std::io::stdin().read_line(&mut action_chain)
?0
;
105
71
            log::debug!("Got line {} from stdin", 
action_chain.trim()0
);
106
71
            if len == 0 {
  Branch (106:16): [True: 32, False: 39]
+
  Branch (106:16): [Folded - Ignored]
+
107
32
                break;
108
39
            }
109
39
            let actions = parse_action_chain(action_chain.trim())
?0
;
110
39
            for act in actions {
111
41
                stream
112
41
                    .as_write()
113
41
                    .write_all(format!("{}\n", ron::ser::to_string(&act)
?0
).as_bytes())
?0
;
114
41
                log::debug!("Sent action {
act0
:?} to listener");
115
            }
116
        }
117
32
        return Ok(());
118
36
    }
119
120
36
    match sk_main(opts) {
121
36
        Ok(exit_code) => std::process::exit(exit_code),
122
0
        Err(err) => match err.downcast_ref::<clap::error::Error>() {
123
0
            Some(e) => e.exit(),
124
0
            None => Err(eyre!(err)),
125
        },
126
    }
127
40
}
128
129
/// Returns `None` if the popup should not open, otherwise run the popup and return the result
130
#[cfg(unix)]
131
#[allow(clippy::option_option)]
132
36
fn check_and_run_popup(opts: &SkimOptions) -> Option<Option<SkimOutput>> {
133
36
    if opts.popup.is_some() && 
popup::check_env()5
{
  Branch (133:8): [True: 5, False: 31]
   Branch (133:32): [True: 5, False: 0]
-
134
5
        Some(crate::popup::run_with(opts))
135
    } else {
136
31
        None
137
    }
138
36
}
139
#[cfg(not(unix))]
140
#[allow(clippy::option_option)]
141
fn check_and_run_popup(_opts: &SkimOptions) -> Option<Option<SkimOutput>> {
142
    None
143
}
144
145
36
fn sk_main(mut opts: SkimOptions) -> Result<i32> {
146
36
    let reader_opts = SkimItemReaderOption::from_options(&opts);
147
36
    let cmd_collector = Rc::new(RefCell::new(SkimItemReader::new(reader_opts)));
148
36
    opts.cmd_collector = cmd_collector.clone() as Rc<RefCell<dyn CommandCollector>>;
149
150
36
    let cmd_history = opts.cmd_history.clone();
151
36
    let cmd_history_size = opts.cmd_history_size;
152
36
    let cmd_history_file = opts.cmd_history_file.clone();
153
154
36
    let query_history = opts.query_history.clone();
155
36
    let history_size = opts.history_size;
156
36
    let history_file = opts.history_file.clone();
157
    //------------------------------------------------------------------------------
158
36
    let bin_options = BinOptions::from_opts(&opts);
159
160
    //------------------------------------------------------------------------------
161
    // output
162
163
36
    let Some(result) = check_and_run_popup(&opts).unwrap_or_else(|| 
{31
  Branch (163:9): [Folded - Ignored]
-
  Branch (163:9): [True: 36, False: 0]
-
164
        // read from pipe or command
165
31
        let rx_item = if io::stdin().is_terminal() || (opts.interactive && 
opts.cmd.is_some()0
) {
  Branch (165:26): [Folded - Ignored]
-  Branch (165:56): [Folded - Ignored]
-  Branch (165:76): [Folded - Ignored]
-
  Branch (165:26): [True: 0, False: 31]
+
  Branch (133:8): [Folded - Ignored]
+  Branch (133:32): [Folded - Ignored]
+
134
5
        Some(crate::popup::run_with(opts))
135
    } else {
136
31
        None
137
    }
138
36
}
139
#[cfg(not(unix))]
140
#[allow(clippy::option_option)]
141
fn check_and_run_popup(_opts: &SkimOptions) -> Option<Option<SkimOutput>> {
142
    None
143
}
144
145
36
fn sk_main(mut opts: SkimOptions) -> Result<i32> {
146
36
    let reader_opts = SkimItemReaderOption::from_options(&opts);
147
36
    let cmd_collector = Rc::new(RefCell::new(SkimItemReader::new(reader_opts)));
148
36
    opts.cmd_collector = cmd_collector.clone() as Rc<RefCell<dyn CommandCollector>>;
149
150
36
    let cmd_history = opts.cmd_history.clone();
151
36
    let cmd_history_size = opts.cmd_history_size;
152
36
    let cmd_history_file = opts.cmd_history_file.clone();
153
154
36
    let query_history = opts.query_history.clone();
155
36
    let history_size = opts.history_size;
156
36
    let history_file = opts.history_file.clone();
157
    //------------------------------------------------------------------------------
158
36
    let bin_options = BinOptions::from_opts(&opts);
159
160
    //------------------------------------------------------------------------------
161
    // output
162
163
36
    let Some(result) = check_and_run_popup(&opts).unwrap_or_else(|| 
{31
  Branch (163:9): [True: 36, False: 0]
+
  Branch (163:9): [Folded - Ignored]
+
164
        // read from pipe or command
165
31
        let rx_item = if io::stdin().is_terminal() || (opts.interactive && 
opts.cmd.is_some()0
) {
  Branch (165:26): [True: 0, False: 31]
   Branch (165:56): [True: 0, False: 31]
   Branch (165:76): [True: 0, False: 0]
-
166
0
            None
167
        } else {
168
31
            let rx_item = cmd_collector.borrow().of_bufread(BufReader::new(std::io::stdin()));
169
31
            Some(rx_item)
170
        };
171
31
        Skim::run_with(opts, rx_item).ok()
172
31
    }) else {
173
0
        return Ok(135);
174
    };
175
36
    log::debug!("result: {
result1
:?}");
176
177
36
    if result.is_abort {
  Branch (177:8): [Folded - Ignored]
-
  Branch (177:8): [True: 7, False: 29]
-
178
7
        return Ok(130);
179
29
    }
180
181
    // Output — use a large BufWriter to batch all writes into a few syscalls
182
    // instead of one syscall per item (Rust's default LineWriter flushes on \n).
183
    {
184
29
        let stdout = io::stdout();
185
29
        let mut out = BufWriter::with_capacity(1 << 20, stdout.lock());
186
29
        result.write_output(&mut out, &bin_options)
?0
;
187
29
        out.flush()
?0
;
188
    }
189
190
    //------------------------------------------------------------------------------
191
    // write the history with latest item
192
29
    if let Some(file) = history_file {
  Branch (192:12): [Folded - Ignored]
-
  Branch (192:12): [True: 1, False: 28]
-
193
1
        let limit = history_size;
194
1
        write_history_to_file(&query_history, &result.query, limit, &file)
?0
;
195
28
    }
196
197
29
    if let Some(file) = cmd_history_file {
  Branch (197:12): [Folded - Ignored]
-
  Branch (197:12): [True: 0, False: 29]
-
198
0
        let limit = cmd_history_size;
199
0
        write_history_to_file(&cmd_history, &result.cmd, limit, &file)?;
200
29
    }
201
202
29
    Ok(i32::from(result.selected_items.is_empty()))
203
36
}
204
205
5
fn write_history_to_file(
206
5
    orig_history: &[String],
207
5
    latest: &str,
208
5
    limit: usize,
209
5
    filename: &str,
210
5
) -> Result<(), std::io::Error> {
211
5
    if orig_history.last().map(String::as_str) == Some(latest) {
  Branch (211:8): [True: 1, False: 3]
-
  Branch (211:8): [True: 0, False: 1]
-
212
        // no point of having at the end of the history 5x the same command...
213
1
        return Ok(());
214
4
    }
215
4
    let additional_lines = usize::from(!latest.trim().is_empty());
216
4
    let start_index = if orig_history.len() + additional_lines > limit {
  Branch (216:26): [True: 1, False: 2]
-
  Branch (216:26): [True: 0, False: 1]
+
  Branch (165:26): [Folded - Ignored]
+  Branch (165:56): [Folded - Ignored]
+  Branch (165:76): [Folded - Ignored]
+
166
0
            None
167
        } else {
168
31
            let rx_item = cmd_collector.borrow().of_bufread(BufReader::new(std::io::stdin()));
169
31
            Some(rx_item)
170
        };
171
31
        Skim::run_with(opts, rx_item).ok()
172
31
    }) else {
173
0
        return Ok(135);
174
    };
175
36
    log::debug!("result: {
result1
:?}");
176
177
36
    if result.is_abort {
  Branch (177:8): [True: 7, False: 29]
+
  Branch (177:8): [Folded - Ignored]
+
178
7
        return Ok(130);
179
29
    }
180
181
    // Output — use a large BufWriter to batch all writes into a few syscalls
182
    // instead of one syscall per item (Rust's default LineWriter flushes on \n).
183
    {
184
29
        let stdout = io::stdout();
185
29
        let mut out = BufWriter::with_capacity(1 << 20, stdout.lock());
186
29
        result.write_output(&mut out, &bin_options)
?0
;
187
29
        out.flush()
?0
;
188
    }
189
190
    //------------------------------------------------------------------------------
191
    // write the history with latest item
192
29
    if let Some(file) = history_file {
  Branch (192:12): [True: 1, False: 28]
+
  Branch (192:12): [Folded - Ignored]
+
193
1
        let limit = history_size;
194
1
        write_history_to_file(&query_history, &result.query, limit, &file)
?0
;
195
28
    }
196
197
29
    if let Some(file) = cmd_history_file {
  Branch (197:12): [True: 0, False: 29]
+
  Branch (197:12): [Folded - Ignored]
+
198
0
        let limit = cmd_history_size;
199
0
        write_history_to_file(&cmd_history, &result.cmd, limit, &file)?;
200
29
    }
201
202
29
    Ok(i32::from(result.selected_items.is_empty()))
203
36
}
204
205
5
fn write_history_to_file(
206
5
    orig_history: &[String],
207
5
    latest: &str,
208
5
    limit: usize,
209
5
    filename: &str,
210
5
) -> Result<(), std::io::Error> {
211
5
    if orig_history.last().map(String::as_str) == Some(latest) {
  Branch (211:8): [True: 0, False: 1]
+
  Branch (211:8): [True: 1, False: 3]
+
212
        // no point of having at the end of the history 5x the same command...
213
1
        return Ok(());
214
4
    }
215
4
    let additional_lines = usize::from(!latest.trim().is_empty());
216
4
    let start_index = if orig_history.len() + additional_lines > limit {
  Branch (216:26): [True: 0, False: 1]
+
  Branch (216:26): [True: 1, False: 2]
 
217
1
        orig_history.len() + additional_lines - limit
218
    } else {
219
3
        0
220
    };
221
222
4
    let mut history = orig_history[start_index..].to_vec();
223
4
    history.push(latest.to_string());
224
225
4
    let file = File::create(filename)
?0
;
226
4
    let mut file = BufWriter::new(file);
227
4
    file.write_all(history.join("\n").as_bytes())
?0
;
228
4
    Ok(())
229
5
}
230
231
#[cfg(test)]
232
#[cfg_attr(coverage, coverage(off))]
233
mod tests {
234
    use super::*;
235
236
    fn read(path: &std::path::Path) -> String {
237
        std::fs::read_to_string(path).unwrap_or_default()
238
    }
239
240
    #[test]
241
    fn write_history_appends_latest_entry() {
242
        let dir = tempfile::tempdir().unwrap();
243
        let file = dir.path().join("hist");
244
        let file_str = file.to_str().unwrap();
245
        write_history_to_file(&["a".to_string(), "b".to_string()], "c", 10, file_str).unwrap();
246
        assert_eq!(read(&file), "a\nb\nc");
247
    }
248
249
    #[test]
250
    fn write_history_skips_duplicate_of_last() {
251
        let dir = tempfile::tempdir().unwrap();
252
        let file = dir.path().join("hist");
253
        let file_str = file.to_str().unwrap();
254
        // The latest equals the last entry → nothing is written, no file created.
255
        write_history_to_file(&["a".to_string(), "b".to_string()], "b", 10, file_str).unwrap();
256
        assert!(!file.exists());
257
    }
258
259
    #[test]
260
    fn write_history_truncates_to_limit() {
261
        let dir = tempfile::tempdir().unwrap();
262
        let file = dir.path().join("hist");
263
        let file_str = file.to_str().unwrap();
264
        // limit 2 with 3 existing + 1 new keeps only the newest entries.
265
        write_history_to_file(&["a".to_string(), "b".to_string(), "c".to_string()], "d", 2, file_str).unwrap();
266
        assert_eq!(read(&file), "c\nd");
267
    }
268
269
    #[test]
270
    fn write_history_empty_latest_does_not_count_towards_limit() {
271
        let dir = tempfile::tempdir().unwrap();
272
        let file = dir.path().join("hist");
273
        let file_str = file.to_str().unwrap();
274
        // An empty latest adds 0 to the length, so no truncation occurs at limit 3.
275
        write_history_to_file(&["a".to_string(), "b".to_string(), "c".to_string()], "", 3, file_str).unwrap();
276
        assert_eq!(read(&file), "a\nb\nc\n");
277
    }
278
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/binds.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/binds.rs.html index 84d553bd..691a0193 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/binds.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/binds.rs.html @@ -1,51 +1,51 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/binds.rs
Line
Count
Source
1
//! Key binding configuration and parsing.
2
//!
3
//! This module provides utilities for parsing and managing keyboard shortcuts
4
//! and their associated actions in skim.
5
6
use std::collections::HashMap;
7
use std::ops::{Deref, DerefMut};
8
9
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10
use eyre::{Result, eyre};
11
12
use crate::tui::actions::{self, Action};
13
14
/// Synthetic events that skim fires internally and that can be bound to actions
15
/// via the keymap, exactly like a real key press.
16
///
17
/// The keymap is keyed by crossterm's [`KeyEvent`], which cannot express
18
/// "the query changed" or "reading finished" directly. Each variant is
19
/// therefore represented *transparently* as a reserved function-key code in the
20
/// high-`F` range (`F(248)`–`F(255)`) that no real terminal ever emits.
21
/// Giving these reserved codes named variants keeps them in one place instead
22
/// of scattering magic function-key literals across the codebase, and lets
23
/// [`parse_key`] accept every friendly event name.
24
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
25
pub enum SkimEvent {
26
    /// Fired once, when skim has started up and entered its event loop.
27
    Start,
28
    /// Fired when the reader finishes producing items (once per read; a
29
    /// `reload` starts a new read and fires it again).
30
    Load,
31
    /// Fired whenever the query changes.
32
    Change,
33
    /// Fired when filtering for the current query completes and the result
34
    /// list is ready.
35
    Result,
36
    /// Fired when the focused item changes (cursor movement or a result update).
37
    Focus,
38
    /// Fired when a completed search yields no matches.
39
    Zero,
40
    /// Fired when a completed search yields exactly one match.
41
    One,
42
    /// Fired after two left mouse-button presses no more than 500 ms apart.
43
    DoubleClick,
44
}
45
46
impl SkimEvent {
47
    /// The reserved [`KeyCode`] used to route this event through the keymap.
48
    #[must_use]
49
4.60k
    pub const fn key_code(self) -> KeyCode {
50
4.60k
        match self {
51
359
            SkimEvent::Change => KeyCode::F(255),
52
396
            SkimEvent::Start => KeyCode::F(254),
53
399
            SkimEvent::Load => KeyCode::F(253),
54
744
            SkimEvent::Result => KeyCode::F(252),
55
498
            SkimEvent::Focus => KeyCode::F(251),
56
178
            SkimEvent::Zero => KeyCode::F(250),
57
225
            SkimEvent::One => KeyCode::F(249),
58
1.80k
            SkimEvent::DoubleClick => KeyCode::F(248),
59
        }
60
4.60k
    }
61
62
    /// The reserved [`KeyEvent`] used to route this event through the keymap.
63
    #[must_use]
64
4.60k
    pub const fn key_event(self) -> KeyEvent {
65
4.60k
        KeyEvent::new(self.key_code(), KeyModifiers::NONE)
66
4.60k
    }
67
68
    /// Parses an event name (`start`, `load`, `change`) into a [`SkimEvent`].
69
    ///
70
    /// Returns `None` if the name is not a recognised event.
71
    #[must_use]
72
187
    pub fn from_name(name: &str) -> Option<Self> {
73
187
        match name {
74
13
            "start" => Some(SkimEvent::Start),
75
7
            "load" => Some(SkimEvent::Load),
76
6
            "change" => Some(SkimEvent::Change),
77
4
            "result" => Some(SkimEvent::Result),
78
4
            "focus" => Some(SkimEvent::Focus),
79
4
            "zero" => Some(SkimEvent::Zero),
80
4
            "one" => Some(SkimEvent::One),
81
3
            "double-click" => Some(SkimEvent::DoubleClick),
82
142
            _ => None,
83
        }
84
187
    }
85
}
86
87
impl From<SkimEvent> for KeyEvent {
88
2.75k
    fn from(event: SkimEvent) -> Self {
89
2.75k
        event.key_event()
90
2.75k
    }
91
}
92
93
/// A map of key events to their associated actions
94
#[derive(Clone, Debug)]
95
pub struct KeyMap(pub HashMap<KeyEvent, Vec<Action>>);
96
97
impl Deref for KeyMap {
98
    type Target = HashMap<KeyEvent, Vec<Action>>;
99
100
3.20k
    fn deref(&self) -> &Self::Target {
101
3.20k
        &self.0
102
3.20k
    }
103
}
104
impl DerefMut for KeyMap {
105
112
    fn deref_mut(&mut self) -> &mut Self::Target {
106
112
        &mut self.0
107
112
    }
108
}
109
110
impl From<&str> for KeyMap {
111
3
    fn from(value: &str) -> Self {
112
3
        parse_keymaps(split_top_level(value, ',').into_iter())
113
3
    }
114
}
115
116
impl Default for KeyMap {
117
1.45k
    fn default() -> Self {
118
1.45k
        get_default_key_map()
119
1.45k
    }
120
}
121
122
impl KeyMap {
123
    /// Adds keymaps from a comma-separated string.
124
478
    pub(crate) fn add_keymaps_str(&mut self, source: &str) {
125
478
        self.add_keymaps(split_top_level(source, ',').into_iter());
126
478
    }
127
128
    /// Adds keymaps from the source, parsing them using `parse_keymap`
129
482
    pub fn add_keymaps<'a, T>(&mut self, source: T)
130
482
    where
131
482
        T: Iterator<Item = &'a str>,
132
    {
133
482
        for map in source {
134
493
            if let Ok((key, action_chain)) = parse_keymap(map) {
  Branch (134:20): [True: 35, False: 439]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/binds.rs
Line
Count
Source
1
//! Key binding configuration and parsing.
2
//!
3
//! This module provides utilities for parsing and managing keyboard shortcuts
4
//! and their associated actions in skim.
5
6
use std::collections::HashMap;
7
use std::ops::{Deref, DerefMut};
8
9
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10
use eyre::{Result, eyre};
11
12
use crate::tui::actions::{self, Action};
13
14
/// Synthetic events that skim fires internally and that can be bound to actions
15
/// via the keymap, exactly like a real key press.
16
///
17
/// The keymap is keyed by crossterm's [`KeyEvent`], which cannot express
18
/// "the query changed" or "reading finished" directly. Each variant is
19
/// therefore represented *transparently* as a reserved function-key code in the
20
/// high-`F` range (`F(248)`–`F(255)`) that no real terminal ever emits.
21
/// Giving these reserved codes named variants keeps them in one place instead
22
/// of scattering magic function-key literals across the codebase, and lets
23
/// [`parse_key`] accept every friendly event name.
24
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
25
pub enum SkimEvent {
26
    /// Fired once, when skim has started up and entered its event loop.
27
    Start,
28
    /// Fired when the reader finishes producing items (once per read; a
29
    /// `reload` starts a new read and fires it again).
30
    Load,
31
    /// Fired whenever the query changes.
32
    Change,
33
    /// Fired when filtering for the current query completes and the result
34
    /// list is ready.
35
    Result,
36
    /// Fired when the focused item changes (cursor movement or a result update).
37
    Focus,
38
    /// Fired when a completed search yields no matches.
39
    Zero,
40
    /// Fired when a completed search yields exactly one match.
41
    One,
42
    /// Fired after two left mouse-button presses no more than 500 ms apart.
43
    DoubleClick,
44
}
45
46
impl SkimEvent {
47
    /// The reserved [`KeyCode`] used to route this event through the keymap.
48
    #[must_use]
49
4.60k
    pub const fn key_code(self) -> KeyCode {
50
4.60k
        match self {
51
362
            SkimEvent::Change => KeyCode::F(255),
52
395
            SkimEvent::Start => KeyCode::F(254),
53
400
            SkimEvent::Load => KeyCode::F(253),
54
746
            SkimEvent::Result => KeyCode::F(252),
55
500
            SkimEvent::Focus => KeyCode::F(251),
56
177
            SkimEvent::Zero => KeyCode::F(250),
57
227
            SkimEvent::One => KeyCode::F(249),
58
1.80k
            SkimEvent::DoubleClick => KeyCode::F(248),
59
        }
60
4.60k
    }
61
62
    /// The reserved [`KeyEvent`] used to route this event through the keymap.
63
    #[must_use]
64
4.60k
    pub const fn key_event(self) -> KeyEvent {
65
4.60k
        KeyEvent::new(self.key_code(), KeyModifiers::NONE)
66
4.60k
    }
67
68
    /// Parses an event name (`start`, `load`, `change`) into a [`SkimEvent`].
69
    ///
70
    /// Returns `None` if the name is not a recognised event.
71
    #[must_use]
72
191
    pub fn from_name(name: &str) -> Option<Self> {
73
191
        match name {
74
13
            "start" => Some(SkimEvent::Start),
75
7
            "load" => Some(SkimEvent::Load),
76
6
            "change" => Some(SkimEvent::Change),
77
4
            "result" => Some(SkimEvent::Result),
78
4
            "focus" => Some(SkimEvent::Focus),
79
4
            "zero" => Some(SkimEvent::Zero),
80
4
            "one" => Some(SkimEvent::One),
81
3
            "double-click" => Some(SkimEvent::DoubleClick),
82
146
            _ => None,
83
        }
84
191
    }
85
}
86
87
impl From<SkimEvent> for KeyEvent {
88
2.76k
    fn from(event: SkimEvent) -> Self {
89
2.76k
        event.key_event()
90
2.76k
    }
91
}
92
93
/// A map of key events to their associated actions
94
#[derive(Clone, Debug)]
95
pub struct KeyMap(pub HashMap<KeyEvent, Vec<Action>>);
96
97
impl Deref for KeyMap {
98
    type Target = HashMap<KeyEvent, Vec<Action>>;
99
100
3.21k
    fn deref(&self) -> &Self::Target {
101
3.21k
        &self.0
102
3.21k
    }
103
}
104
impl DerefMut for KeyMap {
105
116
    fn deref_mut(&mut self) -> &mut Self::Target {
106
116
        &mut self.0
107
116
    }
108
}
109
110
impl From<&str> for KeyMap {
111
3
    fn from(value: &str) -> Self {
112
3
        parse_keymaps(split_top_level(value, ',').into_iter())
113
3
    }
114
}
115
116
impl Default for KeyMap {
117
1.45k
    fn default() -> Self {
118
1.45k
        get_default_key_map()
119
1.45k
    }
120
}
121
122
impl KeyMap {
123
    /// Adds keymaps from a comma-separated string.
124
477
    pub(crate) fn add_keymaps_str(&mut self, source: &str) {
125
477
        self.add_keymaps(split_top_level(source, ',').into_iter());
126
477
    }
127
128
    /// Adds keymaps from the source, parsing them using `parse_keymap`
129
481
    pub fn add_keymaps<'a, T>(&mut self, source: T)
130
481
    where
131
481
        T: Iterator<Item = &'a str>,
132
    {
133
481
        for map in source {
134
493
            if let Ok((key, action_chain)) = parse_keymap(map) {
  Branch (134:20): [True: 37, False: 437]
 
  Branch (134:20): [True: 17, False: 0]
   Branch (134:20): [True: 2, False: 0]
-
135
54
                self.bind(key, action_chain)
136
5
                    .unwrap_or_else(|err| debug!("Failed to bind key {
map0
}: {
err0
}"));
137
            } else {
138
439
                debug!("Failed to parse key: {
map1
}");
139
            }
140
        }
141
482
    }
142
54
    fn bind(&mut self, key: &str, action_chain: Vec<Action>) -> Result<()> {
143
54
        let key = parse_key(key)
?5
;
144
145
        // remove the key for existing keymap;
146
49
        let _ = self.remove(&key);
147
49
        self.entry(key).or_insert(action_chain);
148
49
        Ok(())
149
54
    }
150
}
151
152
/// Returns the default key bindings for skim
153
#[rustfmt::skip]
154
#[must_use]
155
1.45k
pub fn get_default_key_map() -> KeyMap {
156
1.45k
    let mut ret = HashMap::new();
157
158
1.45k
    ret.insert(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), vec![Action::Down(1)]);
159
1.45k
    ret.insert(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), vec![Action::Up(1)]);
160
1.45k
    ret.insert(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE), vec![Action::PageUp(1)]);
161
1.45k
    ret.insert(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE), vec![Action::PageDown(1)]);
162
1.45k
    ret.insert(KeyEvent::new(KeyCode::End, KeyModifiers::NONE), vec![Action::EndOfLine]);
163
1.45k
    ret.insert(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE), vec![Action::BeginningOfLine]);
164
1.45k
    ret.insert(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE), vec![Action::DeleteChar]);
165
1.45k
    ret.insert(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), vec![Action::Toggle, Action::Down(1)]);
166
1.45k
    ret.insert(KeyEvent::new(KeyCode::BackTab, KeyModifiers::all()), vec![Action::Toggle, Action::Up(1)]);
167
1.45k
    ret.insert(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), vec![Action::Abort]);
168
1.45k
    ret.insert(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), vec![Action::Accept(None)]);
169
1.45k
    ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), vec![Action::BackwardChar]);
170
1.45k
    ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), vec![Action::ForwardChar]);
171
1.45k
    ret.insert(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), vec![Action::BackwardDeleteChar]);
172
1.45k
    ret.insert(SkimEvent::DoubleClick.key_event(), vec![Action::Accept(None)]);
173
174
175
1.45k
    ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT), vec![Action::BackwardWord]);
176
1.45k
    ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT), vec![Action::ForwardWord]);
177
1.45k
    ret.insert(KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT), vec![Action::PreviewUp(1)]);
178
1.45k
    ret.insert(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT), vec![Action::PreviewDown(1)]);
179
1.45k
    ret.insert(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT), vec![Action::Toggle, Action::Up(1)]);
180
1.45k
    ret.insert(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT), vec![Action::Toggle, Action::Up(1)]);
181
1.45k
    ret.insert(KeyEvent::new(KeyCode::Home, KeyModifiers::SHIFT), vec![Action::BeginningOfLine]);
182
183
184
1.45k
    ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL), vec![Action::BackwardWord]);
185
1.45k
    ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL), vec![Action::ForwardWord]);
186
187
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), vec![Action::BeginningOfLine]);
188
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL), vec![Action::BackwardChar]);
189
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), vec![Action::Abort]);
190
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), vec![Action::Abort]);
191
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), vec![Action::EndOfLine]);
192
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), vec![Action::ForwardChar]);
193
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL), vec![Action::Abort]);
194
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), vec![Action::BackwardDeleteChar]);
195
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), vec![Action::Down(1)]);
196
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), vec![Action::Up(1)]);
197
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL), vec![Action::ClearScreen]);
198
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), vec![Action::Down(1)]);
199
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), vec![Action::Up(1)]);
200
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL), vec![Action::ToggleInteractive]);
201
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), vec![Action::RotateMode]);
202
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), vec![Action::UnixLineDiscard]);
203
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), vec![Action::UnixWordRubout]);
204
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), vec![Action::Yank]);
205
206
207
1.45k
    ret.insert(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT), vec![Action::BackwardKillWord]);
208
209
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT), vec![Action::BackwardWord]);
210
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT), vec![Action::KillWord]);
211
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT), vec![Action::ForwardWord]);
212
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::ALT), vec![Action::ScrollLeft(1)]);
213
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::ALT), vec![Action::ScrollRight(1)]);
214
215
1.45k
    KeyMap(ret)
216
1.45k
}
217
218
/// Parses a key str into a crossterm `KeyEvent`.
219
///
220
/// In addition to keyboard names, accepts all names recognized by
221
/// [`SkimEvent::from_name`], including `change`, `start`, and `double-click`.
222
///
223
/// # Errors
224
/// Returns an error if the key string is empty, contains an unknown modifier,
225
/// or does not correspond to a recognised key name.
226
169
pub fn parse_key(key: &str) -> Result<KeyEvent> {
227
169
    if key.is_empty() {
  Branch (227:8): [True: 0, False: 70]
+
135
56
                self.bind(key, action_chain)
136
5
                    .unwrap_or_else(|err| debug!("Failed to bind key {
map0
}: {
err0
}"));
137
            } else {
138
437
                debug!("Failed to parse key: {
map1
}");
139
            }
140
        }
141
481
    }
142
56
    fn bind(&mut self, key: &str, action_chain: Vec<Action>) -> Result<()> {
143
56
        let key = parse_key(key)
?5
;
144
145
        // remove the key for existing keymap;
146
51
        let _ = self.remove(&key);
147
51
        self.entry(key).or_insert(action_chain);
148
51
        Ok(())
149
56
    }
150
}
151
152
/// Returns the default key bindings for skim
153
#[rustfmt::skip]
154
#[must_use]
155
1.45k
pub fn get_default_key_map() -> KeyMap {
156
1.45k
    let mut ret = HashMap::new();
157
158
1.45k
    ret.insert(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), vec![Action::Down(1)]);
159
1.45k
    ret.insert(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), vec![Action::Up(1)]);
160
1.45k
    ret.insert(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE), vec![Action::PageUp(1)]);
161
1.45k
    ret.insert(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE), vec![Action::PageDown(1)]);
162
1.45k
    ret.insert(KeyEvent::new(KeyCode::End, KeyModifiers::NONE), vec![Action::EndOfLine]);
163
1.45k
    ret.insert(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE), vec![Action::BeginningOfLine]);
164
1.45k
    ret.insert(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE), vec![Action::DeleteChar]);
165
1.45k
    ret.insert(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), vec![Action::Toggle, Action::Down(1)]);
166
1.45k
    ret.insert(KeyEvent::new(KeyCode::BackTab, KeyModifiers::all()), vec![Action::Toggle, Action::Up(1)]);
167
1.45k
    ret.insert(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), vec![Action::Abort]);
168
1.45k
    ret.insert(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), vec![Action::Accept(None)]);
169
1.45k
    ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), vec![Action::BackwardChar]);
170
1.45k
    ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), vec![Action::ForwardChar]);
171
1.45k
    ret.insert(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), vec![Action::BackwardDeleteChar]);
172
1.45k
    ret.insert(SkimEvent::DoubleClick.key_event(), vec![Action::Accept(None)]);
173
174
175
1.45k
    ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT), vec![Action::BackwardWord]);
176
1.45k
    ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT), vec![Action::ForwardWord]);
177
1.45k
    ret.insert(KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT), vec![Action::PreviewUp(1)]);
178
1.45k
    ret.insert(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT), vec![Action::PreviewDown(1)]);
179
1.45k
    ret.insert(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT), vec![Action::Toggle, Action::Up(1)]);
180
1.45k
    ret.insert(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT), vec![Action::Toggle, Action::Up(1)]);
181
1.45k
    ret.insert(KeyEvent::new(KeyCode::Home, KeyModifiers::SHIFT), vec![Action::BeginningOfLine]);
182
183
184
1.45k
    ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL), vec![Action::BackwardWord]);
185
1.45k
    ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL), vec![Action::ForwardWord]);
186
187
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), vec![Action::BeginningOfLine]);
188
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL), vec![Action::BackwardChar]);
189
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), vec![Action::Abort]);
190
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), vec![Action::Abort]);
191
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), vec![Action::EndOfLine]);
192
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), vec![Action::ForwardChar]);
193
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL), vec![Action::Abort]);
194
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), vec![Action::BackwardDeleteChar]);
195
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), vec![Action::Down(1)]);
196
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), vec![Action::Up(1)]);
197
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL), vec![Action::ClearScreen]);
198
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), vec![Action::Down(1)]);
199
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), vec![Action::Up(1)]);
200
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL), vec![Action::ToggleInteractive]);
201
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), vec![Action::RotateMode]);
202
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), vec![Action::UnixLineDiscard]);
203
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), vec![Action::UnixWordRubout]);
204
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), vec![Action::Yank]);
205
206
207
1.45k
    ret.insert(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT), vec![Action::BackwardKillWord]);
208
209
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT), vec![Action::BackwardWord]);
210
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT), vec![Action::KillWord]);
211
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT), vec![Action::ForwardWord]);
212
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::ALT), vec![Action::ScrollLeft(1)]);
213
1.45k
    ret.insert(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::ALT), vec![Action::ScrollRight(1)]);
214
215
1.45k
    KeyMap(ret)
216
1.45k
}
217
218
/// Parses a key str into a crossterm `KeyEvent`.
219
///
220
/// In addition to keyboard names, accepts all names recognized by
221
/// [`SkimEvent::from_name`], including `change`, `start`, and `double-click`.
222
///
223
/// # Errors
224
/// Returns an error if the key string is empty, contains an unknown modifier,
225
/// or does not correspond to a recognised key name.
226
173
pub fn parse_key(key: &str) -> Result<KeyEvent> {
227
173
    if key.is_empty() {
  Branch (227:8): [True: 0, False: 74]
 
  Branch (227:8): [True: 1, False: 98]
-
228
1
        return Err(eyre!("Cannot parse empty key"));
229
168
    }
230
168
    if let Some(event) = SkimEvent::from_name(key) {
  Branch (230:12): [True: 20, False: 50]
+
228
1
        return Err(eyre!("Cannot parse empty key"));
229
172
    }
230
172
    if let Some(event) = SkimEvent::from_name(key) {
  Branch (230:12): [True: 20, False: 54]
 
  Branch (230:12): [True: 17, False: 81]
-
231
37
        return Ok(event.key_event());
232
131
    }
233
131
    let parts = key.split('-').collect::<Vec<&str>>();
234
131
    let mut mods = KeyModifiers::NONE;
235
236
131
    if parts.len() > 1 {
  Branch (236:8): [True: 46, False: 4]
+
231
37
        return Ok(event.key_event());
232
135
    }
233
135
    let parts = key.split('-').collect::<Vec<&str>>();
234
135
    let mut mods = KeyModifiers::NONE;
235
236
135
    if parts.len() > 1 {
  Branch (236:8): [True: 50, False: 4]
 
  Branch (236:8): [True: 38, False: 43]
-
237
84
        let mod_strs = &parts[..parts.len() - 1];
238
84
        for mod_str in mod_strs {
239
86
            mods |= match *mod_str {
240
63
                "ctrl" => KeyModifiers::CONTROL,
241
3
                "alt" => KeyModifiers::ALT,
242
2
                "shift" => KeyModifiers::SHIFT,
243
18
                s => return Err(eyre!("Failed to parse {} as key modifier", s)),
244
            }
245
        }
246
47
    }
247
113
    let key = parts.last().unwrap_or(&"").to_string();
248
249
    let keycode: KeyCode;
250
113
    if key.len() == 1 {
  Branch (250:8): [True: 42, False: 4]
+
237
88
        let mod_strs = &parts[..parts.len() - 1];
238
88
        for mod_str in mod_strs {
239
90
            mods |= match *mod_str {
240
67
                "ctrl" => KeyModifiers::CONTROL,
241
3
                "alt" => KeyModifiers::ALT,
242
2
                "shift" => KeyModifiers::SHIFT,
243
18
                s => return Err(eyre!("Failed to parse {} as key modifier", s)),
244
            }
245
        }
246
47
    }
247
117
    let key = parts.last().unwrap_or(&"").to_string();
248
249
    let keycode: KeyCode;
250
117
    if key.len() == 1 {
  Branch (250:8): [True: 46, False: 4]
 
  Branch (250:8): [True: 26, False: 41]
-
251
68
        let char = key.chars().next().unwrap_or_default();
252
68
        if char.is_uppercase() {
  Branch (252:12): [True: 0, False: 42]
+
251
72
        let char = key.chars().next().unwrap_or_default();
252
72
        if char.is_uppercase() {
  Branch (252:12): [True: 0, False: 46]
 
  Branch (252:12): [True: 3, False: 23]
-
253
3
            mods |= KeyModifiers::SHIFT;
254
3
            keycode = KeyCode::Char(char.to_ascii_lowercase());
255
        } else {
256
65
            keycode = KeyCode::Char(char);
257
        }
258
45
    } else if let Some(f) = key.strip_prefix('f')
  Branch (258:19): [True: 2, False: 2]
+
253
3
            mods |= KeyModifiers::SHIFT;
254
3
            keycode = KeyCode::Char(char.to_ascii_lowercase());
255
        } else {
256
69
            keycode = KeyCode::Char(char);
257
        }
258
45
    } else if let Some(f) = key.strip_prefix('f')
  Branch (258:19): [True: 2, False: 2]
 
  Branch (258:19): [True: 7, False: 34]
 
259
9
        && let Ok(f_index) = f.parse::<u8>()
  Branch (259:16): [True: 0, False: 2]
 
  Branch (259:16): [True: 1, False: 6]
-
260
    {
261
        // A function key like `f10`. If the suffix isn't numeric (e.g. `focus`,
262
        // `first`), fall through to the named-key / event matching below.
263
1
        keycode = KeyCode::F(f_index);
264
    } else {
265
44
        
keycode34
= match key.as_str() {
266
1
            "space" => KeyCode::Char(' '),
267
12
            "enter" => KeyCode::Enter,
268
2
            "bspace" | "bs" => KeyCode::Backspace,
269
7
            "up" => KeyCode::Up,
270
3
            "down" => KeyCode::Down,
271
1
            "left" => KeyCode::Left,
272
1
            "right" => KeyCode::Right,
273
1
            "tab" => KeyCode::Tab,
274
1
            "btab" => KeyCode::BackTab,
275
1
            "esc" => KeyCode::Esc,
276
1
            "home" => KeyCode::Home,
277
1
            "end" => KeyCode::End,
278
1
            "pgup" => KeyCode::PageUp,
279
1
            "pgdown" => KeyCode::PageDown,
280
10
            s => match SkimEvent::from_name(s) {
281
0
                Some(event) => event.key_code(),
282
10
                None => return Err(eyre!("Unknown key {}", s)),
283
            },
284
        }
285
    }
286
287
103
    debug!("parsed key {
keycode0
:?} and mods {
mods0
:?}");
288
289
103
    Ok(KeyEvent::new(keycode, mods))
290
169
}
291
292
/// Parse an iterator of keymaps into a `KeyMap`
293
4
pub fn parse_keymaps<'a, T>(maps: T) -> KeyMap
294
4
where
295
4
    T: Iterator<Item = &'a str>,
296
{
297
4
    let mut res = KeyMap::default();
298
4
    res.add_keymaps(maps);
299
4
    res
300
4
}
301
302
1.09k
pub(crate) fn split_top_level(value: &str, separator: char) -> Vec<&str> {
303
1.09k
    let mut depth = 0_u32;
304
1.09k
    let mut start = 0;
305
1.09k
    let mut parts = Vec::new();
306
307
1.09k
    for (index, ch) in value.char_indices() {
308
3.23k
        match ch {
309
66
            '(' => depth += 1,
310
67
            ')' => depth = depth.saturating_sub(1),
311
3.10k
            _ if ch == separator && 
depth == 039
=> {
  Branch (311:18): [True: 12, False: 2.13k]
-  Branch (311:37): [True: 12, False: 0]
+
260
    {
261
        // A function key like `f10`. If the suffix isn't numeric (e.g. `focus`,
262
        // `first`), fall through to the named-key / event matching below.
263
1
        keycode = KeyCode::F(f_index);
264
    } else {
265
44
        
keycode34
= match key.as_str() {
266
1
            "space" => KeyCode::Char(' '),
267
12
            "enter" => KeyCode::Enter,
268
2
            "bspace" | "bs" => KeyCode::Backspace,
269
7
            "up" => KeyCode::Up,
270
3
            "down" => KeyCode::Down,
271
1
            "left" => KeyCode::Left,
272
1
            "right" => KeyCode::Right,
273
1
            "tab" => KeyCode::Tab,
274
1
            "btab" => KeyCode::BackTab,
275
1
            "esc" => KeyCode::Esc,
276
1
            "home" => KeyCode::Home,
277
1
            "end" => KeyCode::End,
278
1
            "pgup" => KeyCode::PageUp,
279
1
            "pgdown" => KeyCode::PageDown,
280
10
            s => match SkimEvent::from_name(s) {
281
0
                Some(event) => event.key_code(),
282
10
                None => return Err(eyre!("Unknown key {}", s)),
283
            },
284
        }
285
    }
286
287
107
    debug!("parsed key {
keycode0
:?} and mods {
mods0
:?}");
288
289
107
    Ok(KeyEvent::new(keycode, mods))
290
173
}
291
292
/// Parse an iterator of keymaps into a `KeyMap`
293
4
pub fn parse_keymaps<'a, T>(maps: T) -> KeyMap
294
4
where
295
4
    T: Iterator<Item = &'a str>,
296
{
297
4
    let mut res = KeyMap::default();
298
4
    res.add_keymaps(maps);
299
4
    res
300
4
}
301
302
1.09k
pub(crate) fn split_top_level(value: &str, separator: char) -> Vec<&str> {
303
1.09k
    let mut depth = 0_u32;
304
1.09k
    let mut start = 0;
305
1.09k
    let mut parts = Vec::new();
306
307
1.09k
    for (index, ch) in value.char_indices() {
308
3.29k
        match ch {
309
66
            '(' => depth += 1,
310
67
            ')' => depth = depth.saturating_sub(1),
311
3.16k
            _ if ch == separator && 
depth == 041
=> {
  Branch (311:18): [True: 14, False: 2.19k]
+  Branch (311:37): [True: 14, False: 0]
 
  Branch (311:18): [True: 27, False: 928]
   Branch (311:37): [True: 23, False: 4]
-
312
35
                parts.push(&value[start..index]);
313
35
                start = index + ch.len_utf8();
314
            }
315
3.07k
            _ => {}
316
        }
317
    }
318
1.09k
    parts.push(&value[start..]);
319
1.09k
    parts
320
1.09k
}
321
322
/// Parses follow-up action bindings from raw `--bind` specs.
323
///
324
/// Any action can be bound as if it were an event: when the "key" of a bind is
325
/// not a real key but is a known action name, the bound chain becomes a
326
/// *follow-up* that runs right after that action. For example `reload:first`
327
/// queues `first` immediately after a `reload`. The returned map is keyed by the
328
/// action's canonical name (see [`Action::name`](crate::tui::actions::Action::name)),
329
/// so it can be looked up directly from the action that just ran.
330
///
331
/// Keys take precedence: if the "key" resolves to a real key it is left to the
332
/// key map, so a name shared by a key and an action (e.g. `up`) always binds the
333
/// key. To target the action in that case, prefix it with `act-` (`act-up`).
334
#[must_use]
335
484
pub fn parse_action_binds<'a, T>(maps: T) -> HashMap<String, Vec<Action>>
336
484
where
337
484
    T: Iterator<Item = &'a str>,
338
{
339
484
    let mut res = HashMap::new();
340
484
    for map in maps {
341
496
        let Some((key, chain)) = map.split_once(':') else {
  Branch (341:13): [True: 35, False: 439]
+
312
37
                parts.push(&value[start..index]);
313
37
                start = index + ch.len_utf8();
314
            }
315
3.12k
            _ => {}
316
        }
317
    }
318
1.09k
    parts.push(&value[start..]);
319
1.09k
    parts
320
1.09k
}
321
322
/// Parses follow-up action bindings from raw `--bind` specs.
323
///
324
/// Any action can be bound as if it were an event: when the "key" of a bind is
325
/// not a real key but is a known action name, the bound chain becomes a
326
/// *follow-up* that runs right after that action. For example `reload:first`
327
/// queues `first` immediately after a `reload`. The returned map is keyed by the
328
/// action's canonical name (see [`Action::name`](crate::tui::actions::Action::name)),
329
/// so it can be looked up directly from the action that just ran.
330
///
331
/// Keys take precedence: if the "key" resolves to a real key it is left to the
332
/// key map, so a name shared by a key and an action (e.g. `up`) always binds the
333
/// key. To target the action in that case, prefix it with `act-` (`act-up`).
334
#[must_use]
335
483
pub fn parse_action_binds<'a, T>(maps: T) -> HashMap<String, Vec<Action>>
336
483
where
337
483
    T: Iterator<Item = &'a str>,
338
{
339
483
    let mut res = HashMap::new();
340
483
    for map in maps {
341
496
        let Some((key, chain)) = map.split_once(':') else {
  Branch (341:13): [True: 37, False: 437]
 
  Branch (341:13): [True: 10, False: 0]
 
  Branch (341:13): [True: 4, False: 0]
 
  Branch (341:13): [True: 3, False: 0]
 
  Branch (341:13): [True: 5, False: 0]
-
342
439
            continue;
343
        };
344
        // Keys win: anything that parses as a real key is not an action trigger.
345
57
        if parse_key(key).is_ok() {
  Branch (345:12): [True: 32, False: 3]
+
342
437
            continue;
343
        };
344
        // Keys win: anything that parses as a real key is not an action trigger.
345
59
        if parse_key(key).is_ok() {
  Branch (345:12): [True: 34, False: 3]
 
  Branch (345:12): [True: 6, False: 4]
 
  Branch (345:12): [True: 1, False: 3]
 
  Branch (345:12): [True: 0, False: 3]
 
  Branch (345:12): [True: 0, False: 5]
-
346
39
            continue;
347
18
        }
348
18
        let Some(name) = action_trigger_name(key) else {
  Branch (348:13): [True: 3, False: 0]
+
346
41
            continue;
347
18
        }
348
18
        let Some(name) = action_trigger_name(key) else {
  Branch (348:13): [True: 3, False: 0]
 
  Branch (348:13): [True: 4, False: 0]
 
  Branch (348:13): [True: 3, False: 0]
 
  Branch (348:13): [True: 2, False: 1]
 
  Branch (348:13): [True: 5, False: 0]
-
349
1
            debug!("Ignoring bind `{
map0
}`: `{
key0
}` is neither a key nor an action");
350
1
            continue;
351
        };
352
17
        match parse_action_chain(chain) {
353
            Ok(actions) => {
354
16
                res.insert(name.to_string(), actions);
355
            }
356
1
            Err(err) => debug!("Ignoring bind `{
map0
}`: invalid action chain `{
chain0
}`: {
err0
}"),
357
        }
358
    }
359
484
    res
360
484
}
361
362
/// Resolves a bind trigger to the canonical name of the action it targets
363
/// (see [`Action::name`](crate::tui::actions::Action::name)). `act-<name>`
364
/// explicitly targets the action `<name>`, even when `<name>` is also a key;
365
/// without the prefix, a bare action name works too. Returns `None` if the
366
/// name is not a known action.
367
///
368
/// This performs pure name resolution: callers that want "keys win" semantics
369
/// (e.g. [`parse_action_binds`]) must check [`parse_key`] first.
370
25
pub(crate) fn action_trigger_name(trigger: &str) -> Option<&'static str> {
371
25
    let action_name = trigger.strip_prefix("act-").unwrap_or(trigger);
372
    // Some actions require an argument when executed, but their canonical
373
    // name is still valid as a trigger. `()` supplies the parser's empty
374
    // placeholder solely for name validation.
375
25
    let action = actions::parse_action(action_name).or_else(|| 
actions::parse_action10
(
&format!("{action_name}()")10
))
?4
;
376
21
    Some(action.name())
377
25
}
378
379
/// Parses an action chain, separated by '+'s into the corresponding actions
380
///
381
/// # Errors
382
/// Returns an error if the action chain is empty or contains only unknown actions.
383
127
pub fn parse_action_chain(action_chain: &str) -> Result<Vec<Action>> {
384
127
    let mut actions: Vec<Action> = vec![];
385
127
    let mut split = split_top_level(action_chain, '+').into_iter();
386
387
268
    while let Some(mut s) = split.next().map(String::from) {
  Branch (387:15): [True: 84, False: 78]
+
349
1
            debug!("Ignoring bind `{
map0
}`: `{
key0
}` is neither a key nor an action");
350
1
            continue;
351
        };
352
17
        match parse_action_chain(chain) {
353
            Ok(actions) => {
354
16
                res.insert(name.to_string(), actions);
355
            }
356
1
            Err(err) => debug!("Ignoring bind `{
map0
}`: invalid action chain `{
chain0
}`: {
err0
}"),
357
        }
358
    }
359
483
    res
360
483
}
361
362
/// Resolves a bind trigger to the canonical name of the action it targets
363
/// (see [`Action::name`](crate::tui::actions::Action::name)). `act-<name>`
364
/// explicitly targets the action `<name>`, even when `<name>` is also a key;
365
/// without the prefix, a bare action name works too. Returns `None` if the
366
/// name is not a known action.
367
///
368
/// This performs pure name resolution: callers that want "keys win" semantics
369
/// (e.g. [`parse_action_binds`]) must check [`parse_key`] first.
370
25
pub(crate) fn action_trigger_name(trigger: &str) -> Option<&'static str> {
371
25
    let action_name = trigger.strip_prefix("act-").unwrap_or(trigger);
372
    // Some actions require an argument when executed, but their canonical
373
    // name is still valid as a trigger. `()` supplies the parser's empty
374
    // placeholder solely for name validation.
375
25
    let action = actions::parse_action(action_name).or_else(|| 
actions::parse_action10
(
&format!("{action_name}()")10
))
?4
;
376
21
    Some(action.name())
377
25
}
378
379
/// Parses an action chain, separated by '+'s into the corresponding actions
380
///
381
/// # Errors
382
/// Returns an error if the action chain is empty or contains only unknown actions.
383
129
pub fn parse_action_chain(action_chain: &str) -> Result<Vec<Action>> {
384
129
    let mut actions: Vec<Action> = vec![];
385
129
    let mut split = split_top_level(action_chain, '+').into_iter();
386
387
272
    while let Some(mut s) = split.next().map(String::from) {
  Branch (387:15): [True: 86, False: 80]
 
  Branch (387:15): [True: 57, False: 49]
-
388
141
        if (s.starts_with("if-") || 
s.ends_with('{')138
)
  Branch (388:13): [True: 1, False: 83]
-  Branch (388:37): [True: 0, False: 83]
+
388
143
        if (s.starts_with("if-") || 
s.ends_with('{')140
)
  Branch (388:13): [True: 1, False: 85]
+  Branch (388:37): [True: 0, False: 85]
 
  Branch (388:13): [True: 2, False: 55]
   Branch (388:37): [True: 2, False: 53]
 
389
5
            && let Some(otherwise) = split.next()
  Branch (389:20): [True: 0, False: 1]
 
  Branch (389:20): [True: 3, False: 1]
-
390
        {
391
3
            s += &(String::from("+") + otherwise);
392
138
        }
393
141
        if let Some(act) = actions::parse_action(&s) {
  Branch (393:16): [True: 84, False: 0]
+
390
        {
391
3
            s += &(String::from("+") + otherwise);
392
140
        }
393
143
        if let Some(act) = actions::parse_action(&s) {
  Branch (393:16): [True: 86, False: 0]
 
  Branch (393:16): [True: 54, False: 3]
-
394
138
            actions.push(act);
395
3
        }
396
    }
397
127
    if actions.is_empty() {
  Branch (397:8): [True: 0, False: 78]
+
394
140
            actions.push(act);
395
3
        }
396
    }
397
129
    if actions.is_empty() {
  Branch (397:8): [True: 0, False: 80]
 
  Branch (397:8): [True: 3, False: 46]
-
398
3
        Err(eyre!("Empty action chain or unknown action `{}`", action_chain))
399
    } else {
400
124
        Ok(actions)
401
    }
402
127
}
403
404
/// Parse a single keymap and return the key and action(s)
405
///
406
/// # Errors
407
/// Returns an error if the string is empty, missing a colon separator, or the
408
/// action chain cannot be parsed.
409
493
pub fn parse_keymap(key_action: &str) -> Result<(&str, Vec<Action>)> {
410
493
    if key_action.is_empty() {
  Branch (410:8): [True: 439, False: 35]
+
398
3
        Err(eyre!("Empty action chain or unknown action `{}`", action_chain))
399
    } else {
400
126
        Ok(actions)
401
    }
402
129
}
403
404
/// Parse a single keymap and return the key and action(s)
405
///
406
/// # Errors
407
/// Returns an error if the string is empty, missing a colon separator, or the
408
/// action chain cannot be parsed.
409
493
pub fn parse_keymap(key_action: &str) -> Result<(&str, Vec<Action>)> {
410
493
    if key_action.is_empty() {
  Branch (410:8): [True: 437, False: 37]
 
  Branch (410:8): [True: 0, False: 19]
-
411
439
        return Err(eyre!("Got an empty keybind, skipping"));
412
54
    }
413
54
    debug!("got key_action: {
key_action0
:?}");
414
54
    let (key, action_chain) = key_action
415
54
        .split_once(':')
416
54
        .ok_or(eyre!("Failed to parse {} as key and action", key_action))
?0
;
417
54
    debug!("parsed key_action: {
key0
:?}: {
action_chain0
:?}");
418
54
    Ok((key, parse_action_chain(action_chain)
?0
))
419
493
}
420
421
#[cfg(test)]
422
#[path = "binds_tests.rs"]
423
mod tests;
\ No newline at end of file +
411
437
        return Err(eyre!("Got an empty keybind, skipping"));
412
56
    }
413
56
    debug!("got key_action: {
key_action0
:?}");
414
56
    let (key, action_chain) = key_action
415
56
        .split_once(':')
416
56
        .ok_or(eyre!("Failed to parse {} as key and action", key_action))
?0
;
417
56
    debug!("parsed key_action: {
key0
:?}: {
action_chain0
:?}");
418
56
    Ok((key, parse_action_chain(action_chain)
?0
))
419
493
}
420
421
#[cfg(test)]
422
#[path = "binds_tests.rs"]
423
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/engine/all.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/engine/all.rs.html index 32901472..848d4ac7 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/engine/all.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/engine/all.rs.html @@ -1 +1 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/all.rs
Line
Count
Source
1
use std::fmt::{Display, Error, Formatter};
2
use std::sync::Arc;
3
4
use crate::item::RankBuilder;
5
use crate::{MatchEngine, MatchRange, MatchResult, SkimItem};
6
7
//------------------------------------------------------------------------------
8
#[derive(Debug)]
9
pub struct MatchAllEngine {
10
    rank_builder: Arc<RankBuilder>,
11
}
12
13
impl MatchAllEngine {
14
668
    pub fn builder() -> Self {
15
668
        Self {
16
668
            rank_builder: Default::default(),
17
668
        }
18
668
    }
19
20
666
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
21
666
        self.rank_builder = rank_builder;
22
666
        self
23
666
    }
24
25
668
    pub fn build(self) -> Self {
26
668
        self
27
668
    }
28
}
29
30
impl MatchEngine for MatchAllEngine {
31
1.34k
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
32
1.34k
        let item_text = item.text();
33
1.34k
        Some(MatchResult {
34
1.34k
            rank: self.rank_builder.build_rank(0, 0, 0, &item_text),
35
1.34k
            matched_range: MatchRange::ByteRange(0, 0),
36
1.34k
        })
37
1.34k
    }
38
}
39
40
impl Display for MatchAllEngine {
41
1
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
42
1
        write!(f, "Noop")
43
1
    }
44
}
45
46
#[cfg(test)]
47
#[cfg_attr(coverage, coverage(off))]
48
mod tests {
49
    use super::*;
50
51
    #[test]
52
    fn matches_every_item_with_empty_range() {
53
        let engine = MatchAllEngine::builder().build();
54
        let result = engine.match_item(&"anything".to_string()).unwrap();
55
        assert_eq!(result.matched_range, MatchRange::ByteRange(0, 0));
56
    }
57
58
    #[test]
59
    fn rank_builder_override_is_used() {
60
        let engine = MatchAllEngine::builder()
61
            .rank_builder(Arc::new(RankBuilder::default()))
62
            .build();
63
        assert!(engine.match_item(&"x".to_string()).is_some());
64
    }
65
66
    #[test]
67
    fn display_is_noop() {
68
        let engine = MatchAllEngine::builder().build();
69
        assert_eq!(format!("{engine}"), "Noop");
70
    }
71
}
\ No newline at end of file +

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/all.rs
Line
Count
Source
1
use std::fmt::{Display, Error, Formatter};
2
use std::sync::Arc;
3
4
use crate::item::RankBuilder;
5
use crate::{MatchEngine, MatchRange, MatchResult, SkimItem};
6
7
//------------------------------------------------------------------------------
8
#[derive(Debug)]
9
pub struct MatchAllEngine {
10
    rank_builder: Arc<RankBuilder>,
11
}
12
13
impl MatchAllEngine {
14
670
    pub fn builder() -> Self {
15
670
        Self {
16
670
            rank_builder: Default::default(),
17
670
        }
18
670
    }
19
20
668
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
21
668
        self.rank_builder = rank_builder;
22
668
        self
23
668
    }
24
25
670
    pub fn build(self) -> Self {
26
670
        self
27
670
    }
28
}
29
30
impl MatchEngine for MatchAllEngine {
31
1.34k
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
32
1.34k
        let item_text = item.text();
33
1.34k
        Some(MatchResult {
34
1.34k
            rank: self.rank_builder.build_rank(0, 0, 0, &item_text),
35
1.34k
            matched_range: MatchRange::ByteRange(0, 0),
36
1.34k
        })
37
1.34k
    }
38
}
39
40
impl Display for MatchAllEngine {
41
1
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
42
1
        write!(f, "Noop")
43
1
    }
44
}
45
46
#[cfg(test)]
47
#[cfg_attr(coverage, coverage(off))]
48
mod tests {
49
    use super::*;
50
51
    #[test]
52
    fn matches_every_item_with_empty_range() {
53
        let engine = MatchAllEngine::builder().build();
54
        let result = engine.match_item(&"anything".to_string()).unwrap();
55
        assert_eq!(result.matched_range, MatchRange::ByteRange(0, 0));
56
    }
57
58
    #[test]
59
    fn rank_builder_override_is_used() {
60
        let engine = MatchAllEngine::builder()
61
            .rank_builder(Arc::new(RankBuilder::default()))
62
            .build();
63
        assert!(engine.match_item(&"x".to_string()).is_some());
64
    }
65
66
    #[test]
67
    fn display_is_noop() {
68
        let engine = MatchAllEngine::builder().build();
69
        assert_eq!(format!("{engine}"), "Noop");
70
    }
71
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/engine/andor.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/engine/andor.rs.html index 68e6241f..0fb45069 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/engine/andor.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/engine/andor.rs.html @@ -1,5 +1,5 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/andor.rs
Line
Count
Source
1
use std::fmt::{Display, Error, Formatter};
2
3
use crate::fuzzy_matcher::MatchIndices;
4
use crate::{MatchEngine, MatchRange, MatchResult, SkimItem};
5
6
//------------------------------------------------------------------------------
7
// OrEngine, a combinator
8
pub struct OrEngine {
9
    engines: Vec<Box<dyn MatchEngine>>,
10
}
11
12
impl OrEngine {
13
30
    pub fn builder() -> Self {
14
30
        Self { engines: vec![] }
15
30
    }
16
17
29
    pub fn engines(mut self, mut engines: Vec<Box<dyn MatchEngine>>) -> Self {
18
29
        self.engines.append(&mut engines);
19
29
        self
20
29
    }
21
22
30
    pub fn build(self) -> Self {
23
30
        self
24
30
    }
25
}
26
27
impl MatchEngine for OrEngine {
28
18
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
29
18
        let result = self
30
18
            .engines
31
18
            .iter()
32
32
            .map(|e| e.match_item(item))
33
32
            .max_by_key(|res| res.as_ref().map(|matched| 
matched.rank7
.score));
34
35
18
        result
?2
36
18
    }
37
}
38
39
impl Display for OrEngine {
40
4
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
41
4
        write!(
42
4
            f,
43
            "(Or: {})",
44
4
            self.engines
45
4
                .iter()
46
7
                .map(|e| format!("{e}"))
47
                .collect::<Vec<_>>()
48
4
                .join(", ")
49
        )
50
4
    }
51
}
52
53
//------------------------------------------------------------------------------
54
// AndEngine, a combinator
55
pub struct AndEngine {
56
    engines: Vec<Box<dyn MatchEngine>>,
57
}
58
59
impl AndEngine {
60
443
    pub fn builder() -> Self {
61
443
        Self { engines: vec![] }
62
443
    }
63
64
442
    pub fn engines(mut self, mut engines: Vec<Box<dyn MatchEngine>>) -> Self {
65
442
        self.engines.append(&mut engines);
66
442
        self
67
442
    }
68
69
443
    pub fn build(self) -> Self {
70
443
        self
71
443
    }
72
73
3
    fn merge_matched_items(items: Vec<MatchResult>, text: &str) -> MatchResult {
74
3
        let mut ranges = MatchIndices::new();
75
3
        let mut rank = crate::Rank {
76
3
            score: 0,
77
3
            begin: i32::MAX,
78
3
            end: i32::MIN,
79
3
            ..items[0].rank
80
3
        };
81
3
        for item in items {
82
6
            match item.matched_range {
83
                MatchRange::ByteRange(..) => {
84
2
                    ranges.extend(item.range_char_indices(text));
85
                }
86
                MatchRange::CharRange(start, end) => {
87
2
                    ranges.extend(start..end);
88
                }
89
                MatchRange::Chars(vec) => {
90
2
                    ranges.extend(vec.iter().copied());
91
                }
92
            }
93
6
            rank.score = rank.score.saturating_add(item.rank.score);
94
6
            rank.begin = rank.begin.min(item.rank.begin);
95
6
            rank.end = rank.end.max(item.rank.end);
96
        }
97
98
3
        ranges.sort_unstable();
99
3
        ranges.dedup();
100
3
        MatchResult {
101
3
            rank,
102
3
            matched_range: MatchRange::Chars(ranges),
103
3
        }
104
3
    }
105
}
106
107
impl MatchEngine for AndEngine {
108
51.5k
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
109
        // Fast path: single sub-engine — skip merge entirely.
110
51.5k
        if self.engines.len() == 1 {
  Branch (110:12): [True: 51.5k, False: 48]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/andor.rs
Line
Count
Source
1
use std::fmt::{Display, Error, Formatter};
2
3
use crate::fuzzy_matcher::MatchIndices;
4
use crate::{MatchEngine, MatchRange, MatchResult, SkimItem};
5
6
//------------------------------------------------------------------------------
7
// OrEngine, a combinator
8
pub struct OrEngine {
9
    engines: Vec<Box<dyn MatchEngine>>,
10
}
11
12
impl OrEngine {
13
30
    pub fn builder() -> Self {
14
30
        Self { engines: vec![] }
15
30
    }
16
17
29
    pub fn engines(mut self, mut engines: Vec<Box<dyn MatchEngine>>) -> Self {
18
29
        self.engines.append(&mut engines);
19
29
        self
20
29
    }
21
22
30
    pub fn build(self) -> Self {
23
30
        self
24
30
    }
25
}
26
27
impl MatchEngine for OrEngine {
28
18
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
29
18
        let result = self
30
18
            .engines
31
18
            .iter()
32
32
            .map(|e| e.match_item(item))
33
32
            .max_by_key(|res| res.as_ref().map(|matched| 
matched.rank7
.score));
34
35
18
        result
?2
36
18
    }
37
}
38
39
impl Display for OrEngine {
40
4
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
41
4
        write!(
42
4
            f,
43
            "(Or: {})",
44
4
            self.engines
45
4
                .iter()
46
7
                .map(|e| format!("{e}"))
47
                .collect::<Vec<_>>()
48
4
                .join(", ")
49
        )
50
4
    }
51
}
52
53
//------------------------------------------------------------------------------
54
// AndEngine, a combinator
55
pub struct AndEngine {
56
    engines: Vec<Box<dyn MatchEngine>>,
57
}
58
59
impl AndEngine {
60
441
    pub fn builder() -> Self {
61
441
        Self { engines: vec![] }
62
441
    }
63
64
440
    pub fn engines(mut self, mut engines: Vec<Box<dyn MatchEngine>>) -> Self {
65
440
        self.engines.append(&mut engines);
66
440
        self
67
440
    }
68
69
441
    pub fn build(self) -> Self {
70
441
        self
71
441
    }
72
73
3
    fn merge_matched_items(items: Vec<MatchResult>, text: &str) -> MatchResult {
74
3
        let mut ranges = MatchIndices::new();
75
3
        let mut rank = crate::Rank {
76
3
            score: 0,
77
3
            begin: i32::MAX,
78
3
            end: i32::MIN,
79
3
            ..items[0].rank
80
3
        };
81
3
        for item in items {
82
6
            match item.matched_range {
83
                MatchRange::ByteRange(..) => {
84
2
                    ranges.extend(item.range_char_indices(text));
85
                }
86
                MatchRange::CharRange(start, end) => {
87
2
                    ranges.extend(start..end);
88
                }
89
                MatchRange::Chars(vec) => {
90
2
                    ranges.extend(vec.iter().copied());
91
                }
92
            }
93
6
            rank.score = rank.score.saturating_add(item.rank.score);
94
6
            rank.begin = rank.begin.min(item.rank.begin);
95
6
            rank.end = rank.end.max(item.rank.end);
96
        }
97
98
3
        ranges.sort_unstable();
99
3
        ranges.dedup();
100
3
        MatchResult {
101
3
            rank,
102
3
            matched_range: MatchRange::Chars(ranges),
103
3
        }
104
3
    }
105
}
106
107
impl MatchEngine for AndEngine {
108
51.6k
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
109
        // Fast path: single sub-engine — skip merge entirely.
110
51.6k
        if self.engines.len() == 1 {
  Branch (110:12): [True: 51.5k, False: 48]
 
  Branch (110:12): [True: 8, False: 3]
 
111
51.5k
            return self.engines[0].match_item(item);
112
51
        }
113
114
51
        let mut results = vec![];
115
51
        for engine in &self.engines {
116
52
            let result = engine.match_item(item)
?49
;
117
3
            results.push(result);
118
        }
119
120
2
        if results.is_empty() {
  Branch (120:12): [True: 0, False: 0]
 
  Branch (120:12): [True: 1, False: 1]
-
121
1
            None
122
        } else {
123
1
            Some(Self::merge_matched_items(results, &item.text()))
124
        }
125
51.5k
    }
126
}
127
128
impl Display for AndEngine {
129
7
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
130
7
        write!(
131
7
            f,
132
            "(And: {})",
133
7
            self.engines
134
7
                .iter()
135
12
                .map(|e| format!("{e}"))
136
                .collect::<Vec<_>>()
137
7
                .join(", ")
138
        )
139
7
    }
140
}
141
142
#[cfg(test)]
143
#[path = "andor_tests.rs"]
144
mod tests;
\ No newline at end of file +
121
1
            None
122
        } else {
123
1
            Some(Self::merge_matched_items(results, &item.text()))
124
        }
125
51.6k
    }
126
}
127
128
impl Display for AndEngine {
129
7
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
130
7
        write!(
131
7
            f,
132
            "(And: {})",
133
7
            self.engines
134
7
                .iter()
135
12
                .map(|e| format!("{e}"))
136
                .collect::<Vec<_>>()
137
7
                .join(", ")
138
        )
139
7
    }
140
}
141
142
#[cfg(test)]
143
#[path = "andor_tests.rs"]
144
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/engine/exact.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/engine/exact.rs.html index c7b383dc..751f18c4 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/engine/exact.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/engine/exact.rs.html @@ -1,12 +1,12 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/exact.rs
Line
Count
Source
1
use crate::engine::util::{contains_upper, regex_match};
2
use crate::item::RankBuilder;
3
use crate::{CaseMatching, MatchEngine, MatchRange, MatchResult, SkimItem};
4
use regex::{Regex, escape};
5
use std::cmp::min;
6
use std::fmt::{Display, Error, Formatter};
7
use std::sync::Arc;
8
9
//------------------------------------------------------------------------------
10
// Exact engine
11
#[derive(Debug, Copy, Clone, Default)]
12
#[allow(clippy::struct_excessive_bools)]
13
pub struct ExactMatchingParam {
14
    pub prefix: bool,
15
    pub postfix: bool,
16
    pub inverse: bool,
17
    pub case: CaseMatching,
18
    __non_exhaustive: bool,
19
}
20
21
#[derive(Debug)]
22
pub struct ExactEngine {
23
    #[allow(dead_code)]
24
    query: String,
25
    query_regex: Option<Regex>,
26
    rank_builder: Arc<RankBuilder>,
27
    inverse: bool,
28
}
29
30
impl ExactEngine {
31
61
    pub fn builder(query: &str, param: ExactMatchingParam) -> Self {
32
61
        let case_sensitive = match param.case {
33
3
            CaseMatching::Respect => true,
34
6
            CaseMatching::Ignore => false,
35
52
            CaseMatching::Smart => contains_upper(query),
36
        };
37
38
61
        let mut query_builder = String::new();
39
61
        if !case_sensitive {
  Branch (39:12): [True: 18, False: 0]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/exact.rs
Line
Count
Source
1
use crate::engine::util::{contains_upper, regex_match};
2
use crate::item::RankBuilder;
3
use crate::{CaseMatching, MatchEngine, MatchRange, MatchResult, SkimItem};
4
use regex::{Regex, escape};
5
use std::cmp::min;
6
use std::fmt::{Display, Error, Formatter};
7
use std::sync::Arc;
8
9
//------------------------------------------------------------------------------
10
// Exact engine
11
#[derive(Debug, Copy, Clone, Default)]
12
#[allow(clippy::struct_excessive_bools)]
13
pub struct ExactMatchingParam {
14
    pub prefix: bool,
15
    pub postfix: bool,
16
    pub inverse: bool,
17
    pub case: CaseMatching,
18
    __non_exhaustive: bool,
19
}
20
21
#[derive(Debug)]
22
pub struct ExactEngine {
23
    #[allow(dead_code)]
24
    query: String,
25
    query_regex: Option<Regex>,
26
    rank_builder: Arc<RankBuilder>,
27
    inverse: bool,
28
}
29
30
impl ExactEngine {
31
60
    pub fn builder(query: &str, param: ExactMatchingParam) -> Self {
32
60
        let case_sensitive = match param.case {
33
3
            CaseMatching::Respect => true,
34
6
            CaseMatching::Ignore => false,
35
51
            CaseMatching::Smart => contains_upper(query),
36
        };
37
38
60
        let mut query_builder = String::new();
39
60
        if !case_sensitive {
  Branch (39:12): [True: 17, False: 0]
 
  Branch (39:12): [True: 39, False: 4]
-
40
57
            query_builder.push_str("(?i)");
41
4
        }
42
43
61
        if param.prefix {
  Branch (43:12): [True: 5, False: 13]
+
40
56
            query_builder.push_str("(?i)");
41
4
        }
42
43
60
        if param.prefix {
  Branch (43:12): [True: 5, False: 12]
 
  Branch (43:12): [True: 6, False: 37]
-
44
11
            query_builder.push('^');
45
50
        }
46
47
61
        query_builder.push_str(&escape(query));
48
49
61
        if param.postfix {
  Branch (49:12): [True: 1, False: 17]
+
44
11
            query_builder.push('^');
45
49
        }
46
47
60
        query_builder.push_str(&escape(query));
48
49
60
        if param.postfix {
  Branch (49:12): [True: 1, False: 16]
 
  Branch (49:12): [True: 7, False: 36]
-
50
8
            query_builder.push('$');
51
53
        }
52
53
61
        let query_regex = if query.is_empty() {
  Branch (53:30): [True: 1, False: 17]
+
50
8
            query_builder.push('$');
51
52
        }
52
53
60
        let query_regex = if query.is_empty() {
  Branch (53:30): [True: 1, False: 16]
 
  Branch (53:30): [True: 1, False: 42]
-
54
2
            None
55
        } else {
56
59
            Regex::new(&query_builder).ok()
57
        };
58
59
61
        ExactEngine {
60
61
            query: query.to_string(),
61
61
            query_regex,
62
61
            rank_builder: Default::default(),
63
61
            inverse: param.inverse,
64
61
        }
65
61
    }
66
67
30
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
68
30
        self.rank_builder = rank_builder;
69
30
        self
70
30
    }
71
72
61
    pub fn build(self) -> Self {
73
61
        self
74
61
    }
75
}
76
77
impl MatchEngine for ExactEngine {
78
81
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
79
81
        let mut matched_result = None;
80
81
        let item_text = item.text();
81
81
        let default_range = [(0, item_text.len())];
82
81
        let ranges = item.get_matching_ranges().unwrap_or(&default_range);
83
84
81
        if ranges.is_empty() {
  Branch (84:12): [True: 0, False: 48]
+
54
2
            None
55
        } else {
56
58
            Regex::new(&query_builder).ok()
57
        };
58
59
60
        ExactEngine {
60
60
            query: query.to_string(),
61
60
            query_regex,
62
60
            rank_builder: Default::default(),
63
60
            inverse: param.inverse,
64
60
        }
65
60
    }
66
67
29
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
68
29
        self.rank_builder = rank_builder;
69
29
        self
70
29
    }
71
72
60
    pub fn build(self) -> Self {
73
60
        self
74
60
    }
75
}
76
77
impl MatchEngine for ExactEngine {
78
81
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
79
81
        let mut matched_result = None;
80
81
        let item_text = item.text();
81
81
        let default_range = [(0, item_text.len())];
82
81
        let ranges = item.get_matching_ranges().unwrap_or(&default_range);
83
84
81
        if ranges.is_empty() {
  Branch (84:12): [True: 0, False: 48]
 
  Branch (84:12): [True: 1, False: 32]
 
85
1
            // Nothing to match against (e.g. every `--nth` index is out of range): the item
86
1
            // stays unmatched, inverse or not.
87
80
        } else if self.query_regex.is_none() {
  Branch (87:19): [True: 3, False: 45]
 
  Branch (87:19): [True: 1, False: 31]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/engine/factory.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/engine/factory.rs.html
index 9965a3b3..4cb1df1a 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/engine/factory.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/engine/factory.rs.html
@@ -1,21 +1,21 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/factory.rs
Line
Count
Source
1
use regex::Regex;
2
3
use crate::engine::all::MatchAllEngine;
4
use crate::engine::andor::{AndEngine, OrEngine};
5
use crate::engine::exact::{ExactEngine, ExactMatchingParam};
6
use crate::engine::fuzzy::{FuzzyAlgorithm, FuzzyEngine};
7
use crate::engine::regexp::RegexEngine;
8
use crate::item::RankBuilder;
9
use crate::{CaseMatching, MatchEngine, MatchEngineFactory, Typos};
10
use std::sync::{Arc, LazyLock};
11
12
185
static RE_OR_WITH_SPACES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r" *\|+ *").unwrap());
13
14
//------------------------------------------------------------------------------
15
// Exact engine factory
16
/// Factory for creating exact or fuzzy match engines based on configuration
17
pub struct ExactOrFuzzyEngineFactory {
18
    exact_mode: bool,
19
    fuzzy_algorithm: FuzzyAlgorithm,
20
    rank_builder: Arc<RankBuilder>,
21
    typos: Typos,
22
    filter_mode: bool,
23
    last_match: bool,
24
}
25
26
impl ExactOrFuzzyEngineFactory {
27
    /// Creates a new builder with default settings
28
    #[must_use]
29
551
    pub fn builder() -> Self {
30
551
        Self {
31
551
            exact_mode: false,
32
551
            fuzzy_algorithm: FuzzyAlgorithm::SkimV2,
33
551
            rank_builder: Default::default(),
34
551
            typos: Typos::Disabled,
35
551
            filter_mode: false,
36
551
            last_match: false,
37
551
        }
38
551
    }
39
40
    /// Sets whether to use exact matching mode
41
    #[must_use]
42
408
    pub fn exact_mode(mut self, exact_mode: bool) -> Self {
43
408
        self.exact_mode = exact_mode;
44
408
        self
45
408
    }
46
47
    /// Sets the fuzzy matching algorithm to use
48
    #[must_use]
49
408
    pub fn fuzzy_algorithm(mut self, fuzzy_algorithm: FuzzyAlgorithm) -> Self {
50
408
        self.fuzzy_algorithm = fuzzy_algorithm;
51
408
        self
52
408
    }
53
54
    /// Sets the rank builder for scoring matches
55
    #[must_use]
56
408
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
57
408
        self.rank_builder = rank_builder;
58
408
        self
59
408
    }
60
61
    /// Sets the typo tolerance configuration
62
    ///
63
    /// - `Typos::Disabled`: no typo tolerance
64
    /// - `Typos::Smart`: adaptive typo tolerance (`pattern_length` / 4)
65
    /// - `Typos::Fixed(n)`: exactly n typos allowed
66
    #[must_use]
67
408
    pub fn typos(mut self, typos: Typos) -> Self {
68
408
        self.typos = typos;
69
408
        self
70
408
    }
71
72
    /// Sets filter mode (skips per-character match indices for faster matching)
73
    #[must_use]
74
408
    pub fn filter_mode(mut self, filter_mode: bool) -> Self {
75
408
        self.filter_mode = filter_mode;
76
408
        self
77
408
    }
78
79
    /// When true, prefer the last (rightmost) occurrence on tied scores
80
    #[must_use]
81
408
    pub fn last_match(mut self, last_match: bool) -> Self {
82
408
        self.last_match = last_match;
83
408
        self
84
408
    }
85
86
    /// Builds the factory (currently a no-op, returns self)
87
    #[must_use]
88
551
    pub fn build(self) -> Self {
89
551
        self
90
551
    }
91
}
92
93
impl MatchEngineFactory for ExactOrFuzzyEngineFactory {
94
1.26k
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
95
        // 'abc => match exact "abc"
96
        // ^abc => starts with "abc"
97
        // abc$ => ends with "abc"
98
        // ^abc$ => match exact "abc"
99
        // !^abc => items not starting with "abc"
100
        // !abc$ => items not ending with "abc"
101
        // !^abc$ => not "abc"
102
103
1.26k
        let mut query = query;
104
1.26k
        let mut exact = self.exact_mode;
105
1.26k
        let mut param = ExactMatchingParam::default();
106
1.26k
        param.case = case;
107
108
1.26k
        if query.starts_with('\'') {
  Branch (108:12): [True: 5, False: 1.20k]
-
  Branch (108:12): [True: 2, False: 53]
-
109
7
            exact = !exact;
110
7
            query = &query[1..];
111
1.25k
        }
112
113
1.26k
        if query.starts_with('!') {
  Branch (113:12): [True: 9, False: 1.19k]
-
  Branch (113:12): [True: 4, False: 51]
-
114
13
            query = &query[1..];
115
13
            exact = true;
116
13
            param.inverse = true;
117
1.25k
        }
118
119
1.26k
        if query.is_empty() {
  Branch (119:12): [True: 637, False: 571]
-
  Branch (119:12): [True: 28, False: 27]
-
120
            // if only "!" was provided, will still show all items
121
665
            return Box::new(
122
665
                MatchAllEngine::builder()
123
665
                    .rank_builder(self.rank_builder.clone())
124
665
                    .build(),
125
665
            );
126
598
        }
127
128
598
        if query.starts_with('^') {
  Branch (128:12): [True: 5, False: 566]
-
  Branch (128:12): [True: 5, False: 22]
-
129
10
            query = &query[1..];
130
10
            exact = true;
131
10
            param.prefix = true;
132
588
        }
133
134
598
        if query.ends_with('$') {
  Branch (134:12): [True: 1, False: 570]
-
  Branch (134:12): [True: 6, False: 21]
-
135
7
            query = &query[..(query.len() - 1)];
136
7
            exact = true;
137
7
            param.postfix = true;
138
591
        }
139
140
598
        if exact {
  Branch (140:12): [True: 18, False: 553]
-
  Branch (140:12): [True: 12, False: 15]
-
141
30
            Box::new(
142
30
                ExactEngine::builder(query, param)
143
30
                    .rank_builder(self.rank_builder.clone())
144
30
                    .build(),
145
30
            )
146
        } else {
147
568
            Box::new(
148
568
                FuzzyEngine::builder()
149
568
                    .query(query)
150
568
                    .algorithm(self.fuzzy_algorithm)
151
568
                    .case(case)
152
568
                    .typos(self.typos)
153
568
                    .filter_mode(self.filter_mode)
154
568
                    .last_match(self.last_match)
155
568
                    .rank_builder(self.rank_builder.clone())
156
568
                    .build(),
157
568
            )
158
        }
159
1.26k
    }
160
}
161
162
//------------------------------------------------------------------------------
163
/// Factory for creating AND/OR composite match engines
164
pub struct AndOrEngineFactory {
165
    inner: Box<dyn MatchEngineFactory>,
166
}
167
168
impl AndOrEngineFactory {
169
    /// Creates a new AND/OR engine factory wrapping another factory
170
411
    pub fn new(factory: impl MatchEngineFactory + 'static) -> Self {
171
411
        Self {
172
411
            inner: Box::new(factory),
173
411
        }
174
411
    }
175
176
1.08k
    fn parse_andor(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
177
1.08k
        if query.trim().is_empty() {
  Branch (177:12): [True: 623, False: 432]
-
  Branch (177:12): [True: 22, False: 7]
-
178
645
            return self.inner.create_engine_with_case(query, case);
179
439
        }
180
439
        let and_engines = RE_OR_WITH_SPACES
181
439
            .replace_all(&Self::mask_escape_space(query), "|")
182
439
            .split(' ')
183
543
            .filter_map(|and_term| {
184
543
                if and_term.is_empty() {
  Branch (184:20): [True: 2, False: 528]
-
  Branch (184:20): [True: 1, False: 12]
-
185
3
                    return None;
186
540
                }
187
540
                let or_engines = and_term
188
540
                    .split('|')
189
596
                    .filter_map(|term| {
190
596
                        if term.is_empty() {
  Branch (190:28): [True: 26, False: 554]
-
  Branch (190:28): [True: 1, False: 15]
-
191
27
                            return None;
192
569
                        }
193
569
                        debug!("Creating Or engine for {
term2
}");
194
569
                        Some(
195
569
                            self.inner
196
569
                                .create_engine_with_case(&Self::unmask_escape_space(term), case),
197
569
                        )
198
596
                    })
199
                    .collect::<Vec<_>>();
200
540
                debug!(
"Building or matcher engine from Ors"2
);
201
540
                if or_engines.len() == 1 {
  Branch (201:20): [True: 505, False: 23]
-
  Branch (201:20): [True: 9, False: 3]
-
202
514
                    return Some(or_engines.into_iter().next().unwrap());
203
26
                }
204
26
                Some(Box::new(OrEngine::builder().engines(or_engines).build()) as Box<dyn MatchEngine>)
205
543
            })
206
            .collect();
207
439
        debug!(
"Creating and matcher engine from Ors"2
);
208
439
        Box::new(AndEngine::builder().engines(and_engines).build())
209
1.08k
    }
210
211
439
    fn mask_escape_space(string: &str) -> String {
212
439
        string.replace("\\ ", "\0")
213
439
    }
214
215
569
    fn unmask_escape_space(string: &str) -> String {
216
569
        string.replace('\0', " ")
217
569
    }
218
}
219
220
impl MatchEngineFactory for AndOrEngineFactory {
221
1.08k
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
222
1.08k
        self.parse_andor(query, case)
223
1.08k
    }
224
}
225
226
//------------------------------------------------------------------------------
227
/// Factory for creating regex-based match engines
228
pub struct RegexEngineFactory {
229
    rank_builder: Arc<RankBuilder>,
230
}
231
232
impl RegexEngineFactory {
233
    /// Creates a new builder with default settings
234
    #[must_use]
235
6
    pub fn builder() -> Self {
236
6
        Self {
237
6
            rank_builder: Default::default(),
238
6
        }
239
6
    }
240
241
    /// Sets the rank builder for scoring matches
242
    #[must_use]
243
5
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
244
5
        self.rank_builder = rank_builder;
245
5
        self
246
5
    }
247
248
    /// Builds the factory (currently a no-op, returns self)
249
    #[must_use]
250
1
    pub fn build(self) -> Self {
251
1
        self
252
1
    }
253
}
254
255
impl MatchEngineFactory for RegexEngineFactory {
256
7
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
257
7
        Box::new(
258
7
            RegexEngine::builder(query, case)
259
7
                .rank_builder(self.rank_builder.clone())
260
7
                .build(),
261
7
        )
262
7
    }
263
}
264
265
#[cfg(test)]
266
#[cfg_attr(coverage, coverage(off))]
267
mod test {
268
    #[test]
269
    fn test_engine_factory() {
270
        use super::*;
271
        let exact_or_fuzzy = ExactOrFuzzyEngineFactory::builder().build();
272
        let x = exact_or_fuzzy.create_engine("'abc");
273
        assert_eq!(format!("{x}"), "(Exact|(?i)abc)");
274
275
        let x = exact_or_fuzzy.create_engine("^abc");
276
        assert_eq!(format!("{x}"), "(Exact|(?i)^abc)");
277
278
        let x = exact_or_fuzzy.create_engine("abc$");
279
        assert_eq!(format!("{x}"), "(Exact|(?i)abc$)");
280
281
        let x = exact_or_fuzzy.create_engine("^abc$");
282
        assert_eq!(format!("{x}"), "(Exact|(?i)^abc$)");
283
284
        let x = exact_or_fuzzy.create_engine("!abc");
285
        assert_eq!(format!("{x}"), "(Exact|!(?i)abc)");
286
287
        let x = exact_or_fuzzy.create_engine("!^abc");
288
        assert_eq!(format!("{x}"), "(Exact|!(?i)^abc)");
289
290
        let x = exact_or_fuzzy.create_engine("!abc$");
291
        assert_eq!(format!("{x}"), "(Exact|!(?i)abc$)");
292
293
        let x = exact_or_fuzzy.create_engine("!^abc$");
294
        assert_eq!(format!("{x}"), "(Exact|!(?i)^abc$)");
295
296
        let regex_factory = RegexEngineFactory::builder();
297
        let and_or_factory = AndOrEngineFactory::new(exact_or_fuzzy);
298
299
        let x = and_or_factory.create_engine("'abc | def ^gh ij | kl mn");
300
        assert_eq!(
301
            format!("{x}"),
302
            "(And: (Or: (Exact|(?i)abc), (Fuzzy: def)), (Exact|(?i)^gh), (Or: (Fuzzy: ij), (Fuzzy: kl)), (Fuzzy: mn))"
303
        );
304
305
        let x = regex_factory.create_engine("'abc | def ^gh ij | kl mn");
306
        assert_eq!(format!("{x}"), "(Regex: 'abc | def ^gh ij | kl mn)");
307
308
        let x = and_or_factory.create_engine("readme .md$ | .markdown$");
309
        assert_eq!(
310
            format!("{x}"),
311
            "(And: (Fuzzy: readme), (Or: (Exact|(?i)\\.md$), (Exact|(?i)\\.markdown$)))"
312
        );
313
    }
314
315
    #[test]
316
    fn andor_skips_empty_and_terms() {
317
        use super::*;
318
        // Two consecutive spaces produce an empty "and" term between `a` and `b`,
319
        // which must be filtered out, leaving a plain two-clause AND.
320
        let factory = AndOrEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build());
321
        let engine = factory.create_engine("a  b");
322
        assert_eq!(format!("{engine}"), "(And: (Fuzzy: a), (Fuzzy: b))");
323
    }
324
325
    #[test]
326
    fn andor_skips_empty_or_terms() {
327
        use super::*;
328
        // A leading `|` splits into an empty "or" term and `abc`; the empty term
329
        // must be dropped, collapsing to a single fuzzy clause.
330
        let factory = AndOrEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build());
331
        let engine = factory.create_engine("|abc");
332
        assert_eq!(format!("{engine}"), "(And: (Fuzzy: abc))");
333
        // It still behaves like a plain `abc` fuzzy match.
334
        assert!(engine.match_item(&"xabcy".to_string()).is_some());
335
        assert!(engine.match_item(&"zzz".to_string()).is_none());
336
    }
337
338
    #[test]
339
    fn regex_factory_with_rank_builder() {
340
        use super::*;
341
        // Exercise the `rank_builder` and `build` chaining on RegexEngineFactory.
342
        let factory = RegexEngineFactory::builder()
343
            .rank_builder(Arc::new(RankBuilder::default()))
344
            .build();
345
        let engine = factory.create_engine("ab.");
346
        assert_eq!(format!("{engine}"), "(Regex: ab.)");
347
    }
348
}
\ No newline at end of file +

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/factory.rs
Line
Count
Source
1
use regex::Regex;
2
3
use crate::engine::all::MatchAllEngine;
4
use crate::engine::andor::{AndEngine, OrEngine};
5
use crate::engine::exact::{ExactEngine, ExactMatchingParam};
6
use crate::engine::fuzzy::{FuzzyAlgorithm, FuzzyEngine};
7
use crate::engine::regexp::RegexEngine;
8
use crate::item::RankBuilder;
9
use crate::{CaseMatching, MatchEngine, MatchEngineFactory, Typos};
10
use std::sync::{Arc, LazyLock};
11
12
188
static RE_OR_WITH_SPACES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r" *\|+ *").unwrap());
13
14
//------------------------------------------------------------------------------
15
// Exact engine factory
16
/// Factory for creating exact or fuzzy match engines based on configuration
17
pub struct ExactOrFuzzyEngineFactory {
18
    exact_mode: bool,
19
    fuzzy_algorithm: FuzzyAlgorithm,
20
    rank_builder: Arc<RankBuilder>,
21
    typos: Typos,
22
    filter_mode: bool,
23
    last_match: bool,
24
}
25
26
impl ExactOrFuzzyEngineFactory {
27
    /// Creates a new builder with default settings
28
    #[must_use]
29
550
    pub fn builder() -> Self {
30
550
        Self {
31
550
            exact_mode: false,
32
550
            fuzzy_algorithm: FuzzyAlgorithm::SkimV2,
33
550
            rank_builder: Default::default(),
34
550
            typos: Typos::Disabled,
35
550
            filter_mode: false,
36
550
            last_match: false,
37
550
        }
38
550
    }
39
40
    /// Sets whether to use exact matching mode
41
    #[must_use]
42
407
    pub fn exact_mode(mut self, exact_mode: bool) -> Self {
43
407
        self.exact_mode = exact_mode;
44
407
        self
45
407
    }
46
47
    /// Sets the fuzzy matching algorithm to use
48
    #[must_use]
49
407
    pub fn fuzzy_algorithm(mut self, fuzzy_algorithm: FuzzyAlgorithm) -> Self {
50
407
        self.fuzzy_algorithm = fuzzy_algorithm;
51
407
        self
52
407
    }
53
54
    /// Sets the rank builder for scoring matches
55
    #[must_use]
56
407
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
57
407
        self.rank_builder = rank_builder;
58
407
        self
59
407
    }
60
61
    /// Sets the typo tolerance configuration
62
    ///
63
    /// - `Typos::Disabled`: no typo tolerance
64
    /// - `Typos::Smart`: adaptive typo tolerance (`pattern_length` / 4)
65
    /// - `Typos::Fixed(n)`: exactly n typos allowed
66
    #[must_use]
67
407
    pub fn typos(mut self, typos: Typos) -> Self {
68
407
        self.typos = typos;
69
407
        self
70
407
    }
71
72
    /// Sets filter mode (skips per-character match indices for faster matching)
73
    #[must_use]
74
407
    pub fn filter_mode(mut self, filter_mode: bool) -> Self {
75
407
        self.filter_mode = filter_mode;
76
407
        self
77
407
    }
78
79
    /// When true, prefer the last (rightmost) occurrence on tied scores
80
    #[must_use]
81
407
    pub fn last_match(mut self, last_match: bool) -> Self {
82
407
        self.last_match = last_match;
83
407
        self
84
407
    }
85
86
    /// Builds the factory (currently a no-op, returns self)
87
    #[must_use]
88
550
    pub fn build(self) -> Self {
89
550
        self
90
550
    }
91
}
92
93
impl MatchEngineFactory for ExactOrFuzzyEngineFactory {
94
1.26k
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
95
        // 'abc => match exact "abc"
96
        // ^abc => starts with "abc"
97
        // abc$ => ends with "abc"
98
        // ^abc$ => match exact "abc"
99
        // !^abc => items not starting with "abc"
100
        // !abc$ => items not ending with "abc"
101
        // !^abc$ => not "abc"
102
103
1.26k
        let mut query = query;
104
1.26k
        let mut exact = self.exact_mode;
105
1.26k
        let mut param = ExactMatchingParam::default();
106
1.26k
        param.case = case;
107
108
1.26k
        if query.starts_with('\'') {
  Branch (108:12): [True: 5, False: 1.20k]
+
  Branch (108:12): [True: 2, False: 56]
+
109
7
            exact = !exact;
110
7
            query = &query[1..];
111
1.26k
        }
112
113
1.26k
        if query.starts_with('!') {
  Branch (113:12): [True: 8, False: 1.20k]
+
  Branch (113:12): [True: 4, False: 54]
+
114
12
            query = &query[1..];
115
12
            exact = true;
116
12
            param.inverse = true;
117
1.25k
        }
118
119
1.26k
        if query.is_empty() {
  Branch (119:12): [True: 637, False: 572]
+
  Branch (119:12): [True: 30, False: 28]
+
120
            // if only "!" was provided, will still show all items
121
667
            return Box::new(
122
667
                MatchAllEngine::builder()
123
667
                    .rank_builder(self.rank_builder.clone())
124
667
                    .build(),
125
667
            );
126
600
        }
127
128
600
        if query.starts_with('^') {
  Branch (128:12): [True: 5, False: 567]
+
  Branch (128:12): [True: 5, False: 23]
+
129
10
            query = &query[1..];
130
10
            exact = true;
131
10
            param.prefix = true;
132
590
        }
133
134
600
        if query.ends_with('$') {
  Branch (134:12): [True: 1, False: 571]
+
  Branch (134:12): [True: 6, False: 22]
+
135
7
            query = &query[..(query.len() - 1)];
136
7
            exact = true;
137
7
            param.postfix = true;
138
593
        }
139
140
600
        if exact {
  Branch (140:12): [True: 17, False: 555]
+
  Branch (140:12): [True: 12, False: 16]
+
141
29
            Box::new(
142
29
                ExactEngine::builder(query, param)
143
29
                    .rank_builder(self.rank_builder.clone())
144
29
                    .build(),
145
29
            )
146
        } else {
147
571
            Box::new(
148
571
                FuzzyEngine::builder()
149
571
                    .query(query)
150
571
                    .algorithm(self.fuzzy_algorithm)
151
571
                    .case(case)
152
571
                    .typos(self.typos)
153
571
                    .filter_mode(self.filter_mode)
154
571
                    .last_match(self.last_match)
155
571
                    .rank_builder(self.rank_builder.clone())
156
571
                    .build(),
157
571
            )
158
        }
159
1.26k
    }
160
}
161
162
//------------------------------------------------------------------------------
163
/// Factory for creating AND/OR composite match engines
164
pub struct AndOrEngineFactory {
165
    inner: Box<dyn MatchEngineFactory>,
166
}
167
168
impl AndOrEngineFactory {
169
    /// Creates a new AND/OR engine factory wrapping another factory
170
410
    pub fn new(factory: impl MatchEngineFactory + 'static) -> Self {
171
410
        Self {
172
410
            inner: Box::new(factory),
173
410
        }
174
410
    }
175
176
1.08k
    fn parse_andor(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
177
1.08k
        if query.trim().is_empty() {
  Branch (177:12): [True: 623, False: 429]
+
  Branch (177:12): [True: 24, False: 8]
+
178
647
            return self.inner.create_engine_with_case(query, case);
179
437
        }
180
437
        let and_engines = RE_OR_WITH_SPACES
181
437
            .replace_all(&Self::mask_escape_space(query), "|")
182
437
            .split(' ')
183
545
            .filter_map(|and_term| {
184
545
                if and_term.is_empty() {
  Branch (184:20): [True: 2, False: 529]
+
  Branch (184:20): [True: 1, False: 13]
+
185
3
                    return None;
186
542
                }
187
542
                let or_engines = and_term
188
542
                    .split('|')
189
598
                    .filter_map(|term| {
190
598
                        if term.is_empty() {
  Branch (190:28): [True: 26, False: 555]
+
  Branch (190:28): [True: 1, False: 16]
+
191
27
                            return None;
192
571
                        }
193
571
                        debug!("Creating Or engine for {
term2
}");
194
571
                        Some(
195
571
                            self.inner
196
571
                                .create_engine_with_case(&Self::unmask_escape_space(term), case),
197
571
                        )
198
598
                    })
199
                    .collect::<Vec<_>>();
200
542
                debug!(
"Building or matcher engine from Ors"2
);
201
542
                if or_engines.len() == 1 {
  Branch (201:20): [True: 506, False: 23]
+
  Branch (201:20): [True: 10, False: 3]
+
202
516
                    return Some(or_engines.into_iter().next().unwrap());
203
26
                }
204
26
                Some(Box::new(OrEngine::builder().engines(or_engines).build()) as Box<dyn MatchEngine>)
205
545
            })
206
            .collect();
207
437
        debug!(
"Creating and matcher engine from Ors"2
);
208
437
        Box::new(AndEngine::builder().engines(and_engines).build())
209
1.08k
    }
210
211
437
    fn mask_escape_space(string: &str) -> String {
212
437
        string.replace("\\ ", "\0")
213
437
    }
214
215
571
    fn unmask_escape_space(string: &str) -> String {
216
571
        string.replace('\0', " ")
217
571
    }
218
}
219
220
impl MatchEngineFactory for AndOrEngineFactory {
221
1.08k
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
222
1.08k
        self.parse_andor(query, case)
223
1.08k
    }
224
}
225
226
//------------------------------------------------------------------------------
227
/// Factory for creating regex-based match engines
228
pub struct RegexEngineFactory {
229
    rank_builder: Arc<RankBuilder>,
230
}
231
232
impl RegexEngineFactory {
233
    /// Creates a new builder with default settings
234
    #[must_use]
235
6
    pub fn builder() -> Self {
236
6
        Self {
237
6
            rank_builder: Default::default(),
238
6
        }
239
6
    }
240
241
    /// Sets the rank builder for scoring matches
242
    #[must_use]
243
5
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
244
5
        self.rank_builder = rank_builder;
245
5
        self
246
5
    }
247
248
    /// Builds the factory (currently a no-op, returns self)
249
    #[must_use]
250
1
    pub fn build(self) -> Self {
251
1
        self
252
1
    }
253
}
254
255
impl MatchEngineFactory for RegexEngineFactory {
256
7
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
257
7
        Box::new(
258
7
            RegexEngine::builder(query, case)
259
7
                .rank_builder(self.rank_builder.clone())
260
7
                .build(),
261
7
        )
262
7
    }
263
}
264
265
#[cfg(test)]
266
#[cfg_attr(coverage, coverage(off))]
267
mod test {
268
    #[test]
269
    fn test_engine_factory() {
270
        use super::*;
271
        let exact_or_fuzzy = ExactOrFuzzyEngineFactory::builder().build();
272
        let x = exact_or_fuzzy.create_engine("'abc");
273
        assert_eq!(format!("{x}"), "(Exact|(?i)abc)");
274
275
        let x = exact_or_fuzzy.create_engine("^abc");
276
        assert_eq!(format!("{x}"), "(Exact|(?i)^abc)");
277
278
        let x = exact_or_fuzzy.create_engine("abc$");
279
        assert_eq!(format!("{x}"), "(Exact|(?i)abc$)");
280
281
        let x = exact_or_fuzzy.create_engine("^abc$");
282
        assert_eq!(format!("{x}"), "(Exact|(?i)^abc$)");
283
284
        let x = exact_or_fuzzy.create_engine("!abc");
285
        assert_eq!(format!("{x}"), "(Exact|!(?i)abc)");
286
287
        let x = exact_or_fuzzy.create_engine("!^abc");
288
        assert_eq!(format!("{x}"), "(Exact|!(?i)^abc)");
289
290
        let x = exact_or_fuzzy.create_engine("!abc$");
291
        assert_eq!(format!("{x}"), "(Exact|!(?i)abc$)");
292
293
        let x = exact_or_fuzzy.create_engine("!^abc$");
294
        assert_eq!(format!("{x}"), "(Exact|!(?i)^abc$)");
295
296
        let regex_factory = RegexEngineFactory::builder();
297
        let and_or_factory = AndOrEngineFactory::new(exact_or_fuzzy);
298
299
        let x = and_or_factory.create_engine("'abc | def ^gh ij | kl mn");
300
        assert_eq!(
301
            format!("{x}"),
302
            "(And: (Or: (Exact|(?i)abc), (Fuzzy: def)), (Exact|(?i)^gh), (Or: (Fuzzy: ij), (Fuzzy: kl)), (Fuzzy: mn))"
303
        );
304
305
        let x = regex_factory.create_engine("'abc | def ^gh ij | kl mn");
306
        assert_eq!(format!("{x}"), "(Regex: 'abc | def ^gh ij | kl mn)");
307
308
        let x = and_or_factory.create_engine("readme .md$ | .markdown$");
309
        assert_eq!(
310
            format!("{x}"),
311
            "(And: (Fuzzy: readme), (Or: (Exact|(?i)\\.md$), (Exact|(?i)\\.markdown$)))"
312
        );
313
    }
314
315
    #[test]
316
    fn andor_skips_empty_and_terms() {
317
        use super::*;
318
        // Two consecutive spaces produce an empty "and" term between `a` and `b`,
319
        // which must be filtered out, leaving a plain two-clause AND.
320
        let factory = AndOrEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build());
321
        let engine = factory.create_engine("a  b");
322
        assert_eq!(format!("{engine}"), "(And: (Fuzzy: a), (Fuzzy: b))");
323
    }
324
325
    #[test]
326
    fn andor_skips_empty_or_terms() {
327
        use super::*;
328
        // A leading `|` splits into an empty "or" term and `abc`; the empty term
329
        // must be dropped, collapsing to a single fuzzy clause.
330
        let factory = AndOrEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build());
331
        let engine = factory.create_engine("|abc");
332
        assert_eq!(format!("{engine}"), "(And: (Fuzzy: abc))");
333
        // It still behaves like a plain `abc` fuzzy match.
334
        assert!(engine.match_item(&"xabcy".to_string()).is_some());
335
        assert!(engine.match_item(&"zzz".to_string()).is_none());
336
    }
337
338
    #[test]
339
    fn regex_factory_with_rank_builder() {
340
        use super::*;
341
        // Exercise the `rank_builder` and `build` chaining on RegexEngineFactory.
342
        let factory = RegexEngineFactory::builder()
343
            .rank_builder(Arc::new(RankBuilder::default()))
344
            .build();
345
        let engine = factory.create_engine("ab.");
346
        assert_eq!(format!("{engine}"), "(Regex: ab.)");
347
    }
348
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/engine/fuzzy.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/engine/fuzzy.rs.html index 010042f5..5ef33482 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/engine/fuzzy.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/engine/fuzzy.rs.html @@ -1,11 +1,11 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/fuzzy.rs
Line
Count
Source
1
use std::cmp::min;
2
use std::fmt::{Display, Error, Formatter};
3
use std::sync::Arc;
4
5
use crate::fuzzy_matcher::FuzzyMatcher;
6
use crate::fuzzy_matcher::arinae::ArinaeMatcher;
7
use crate::fuzzy_matcher::clangd::ClangdMatcher;
8
#[cfg(feature = "frizbee")]
9
use crate::fuzzy_matcher::frizbee::FrizbeeMatcher;
10
use crate::fuzzy_matcher::fzy::FzyMatcher;
11
use crate::fuzzy_matcher::skim::SkimMatcherV2;
12
13
use crate::item::RankBuilder;
14
use crate::{CaseMatching, MatchEngine, MatchRange, MatchResult, SkimItem, Typos};
15
16
//------------------------------------------------------------------------------
17
/// Fuzzy matching algorithm to use
18
#[derive(Debug, Copy, Clone, Default, PartialEq)]
19
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
20
#[cfg_attr(feature = "cli", clap(rename_all = "snake_case"))]
21
pub enum FuzzyAlgorithm {
22
    /// Arinae: typo-resistant & natural algorithm, default
23
    #[cfg_attr(feature = "cli", clap(alias = "ari"))]
24
    #[default]
25
    Arinae,
26
    /// Clangd fuzzy matching algorithm
27
    Clangd,
28
    /// Fzy matching algorithm (<https://github.com/jhawthorn/fzy>)
29
    Fzy,
30
    /// Frizbee matching algorithm, typo resistant
31
    #[cfg(feature = "frizbee")]
32
    Frizbee,
33
    /// Previous skim fuzzy matching algorithm (v2)
34
    SkimV2,
35
}
36
37
const BYTES_1M: usize = 1024 * 1024 * 1024;
38
39
//------------------------------------------------------------------------------
40
// Fuzzy engine
41
#[derive(Default)]
42
pub struct FuzzyEngineBuilder {
43
    query: String,
44
    case: CaseMatching,
45
    algorithm: FuzzyAlgorithm,
46
    rank_builder: Arc<RankBuilder>,
47
    /// Typo tolerance configuration:
48
    /// - `Typos::Disabled`: no typo tolerance
49
    /// - `Typos::Smart`: adaptive (`pattern_length` / 4)
50
    /// - `Typos::Fixed(n)`: exactly n typos allowed
51
    typos: Typos,
52
    /// When true, prefer the last (rightmost) occurrence on tied scores.
53
    last_match: bool,
54
}
55
56
impl FuzzyEngineBuilder {
57
593
    pub fn query(mut self, query: &str) -> Self {
58
593
        self.query = query.to_string();
59
593
        self
60
593
    }
61
62
580
    pub fn case(mut self, case: CaseMatching) -> Self {
63
580
        self.case = case;
64
580
        self
65
580
    }
66
67
583
    pub fn algorithm(mut self, algorithm: FuzzyAlgorithm) -> Self {
68
583
        self.algorithm = algorithm;
69
583
        self
70
583
    }
71
72
568
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
73
568
        self.rank_builder = rank_builder;
74
568
        self
75
568
    }
76
77
573
    pub fn typos(mut self, typos: Typos) -> Self {
78
573
        self.typos = typos;
79
573
        self
80
573
    }
81
82
    /// No-op: `fuzzy_match_range` is now always used (`ByteRange`).
83
    /// Kept for API backward compatibility.
84
568
    pub fn filter_mode(self, _filter_mode: bool) -> Self {
85
568
        self
86
568
    }
87
88
568
    pub fn last_match(mut self, last_match: bool) -> Self {
89
568
        self.last_match = last_match;
90
568
        self
91
568
    }
92
93
    /// Compute the effective `max_typos` for the given query.
94
    ///
95
    /// - `Typos::Disabled` → `None` (no typo tolerance)
96
    /// - `Typos::Smart` → adaptive: `Some(query.chars().count() / 4)`
97
    /// - `Typos::Fixed(n)` → `Some(n)`
98
593
    fn effective_max_typos(&self) -> Option<usize> {
99
593
        match self.typos {
100
585
            Typos::Disabled => None,
101
6
            Typos::Smart => Some(self.query.chars().count().saturating_div(4)),
102
2
            Typos::Fixed(n) => Some(n),
103
        }
104
593
    }
105
106
    #[allow(deprecated)]
107
590
    pub fn build(self) -> FuzzyEngine {
108
        #[allow(unused_mut)]
109
590
        let mut algorithm = self.algorithm;
110
590
        let max_typos = self.effective_max_typos();
111
590
        let matcher: Box<dyn FuzzyMatcher> = match algorithm {
112
            FuzzyAlgorithm::SkimV2 => {
113
17
                let matcher = SkimMatcherV2::default().element_limit(BYTES_1M);
114
17
                let matcher = match self.case {
115
1
                    CaseMatching::Respect => matcher.respect_case(),
116
1
                    CaseMatching::Ignore => matcher.ignore_case(),
117
15
                    CaseMatching::Smart => matcher.smart_case(),
118
                };
119
17
                debug!(
"Initialized SkimV2 algorithm"0
);
120
17
                Box::new(matcher)
121
            }
122
            FuzzyAlgorithm::Clangd => {
123
5
                let matcher = ClangdMatcher::default();
124
5
                let matcher = match self.case {
125
1
                    CaseMatching::Respect => matcher.respect_case(),
126
1
                    CaseMatching::Ignore => matcher.ignore_case(),
127
3
                    CaseMatching::Smart => matcher.smart_case(),
128
                };
129
5
                debug!(
"Initialized Clangd algorithm"0
);
130
5
                Box::new(matcher)
131
            }
132
            #[cfg(feature = "frizbee")]
133
5
            FuzzyAlgorithm::Frizbee => Box::new(FrizbeeMatcher::default().case(self.case).max_typos(max_typos)),
134
            FuzzyAlgorithm::Fzy => {
135
7
                let matcher = FzyMatcher::default().max_typos(max_typos);
136
7
                let matcher = match self.case {
137
1
                    CaseMatching::Respect => matcher.respect_case(),
138
1
                    CaseMatching::Ignore => matcher.ignore_case(),
139
5
                    CaseMatching::Smart => matcher.smart_case(),
140
                };
141
7
                debug!("Initialized Fzy algorithm (max_typos: {
max_typos0
:?})");
142
7
                Box::new(matcher)
143
            }
144
            FuzzyAlgorithm::Arinae => {
145
556
                let matcher = ArinaeMatcher::new(self.case, !matches!(self.typos, Typos::Disabled), self.last_match);
146
556
                debug!(
"Initialized Arinae algorithm"2
);
147
556
                Box::new(matcher)
148
            }
149
        };
150
151
590
        FuzzyEngine {
152
590
            matcher,
153
590
            query: self.query,
154
590
            rank_builder: self.rank_builder,
155
590
        }
156
590
    }
157
}
158
159
/// The fuzzy matching engine
160
pub struct FuzzyEngine {
161
    query: String,
162
    matcher: Box<dyn FuzzyMatcher>,
163
    rank_builder: Arc<RankBuilder>,
164
}
165
166
impl FuzzyEngine {
167
    /// Returns a default builder for chaining
168
    #[must_use]
169
593
    pub fn builder() -> FuzzyEngineBuilder {
170
593
        FuzzyEngineBuilder::default()
171
593
    }
172
}
173
174
impl MatchEngine for FuzzyEngine {
175
51.5k
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
176
51.5k
        let item_text = item.text();
177
51.5k
        let default_range = [(0, item_text.len())];
178
179
51.5k
        let mut best: Option<(i64, Vec<usize>)> = None;
180
51.5k
        for &(start, end) in item.get_matching_ranges().unwrap_or(&default_range) {
181
51.5k
            let start = min(start, item_text.len());
182
51.5k
            let end = min(end, item_text.len());
183
184
51.5k
            let result = if self.query.is_empty() {
  Branch (184:29): [True: 0, False: 51.5k]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/fuzzy.rs
Line
Count
Source
1
use std::cmp::min;
2
use std::fmt::{Display, Error, Formatter};
3
use std::sync::Arc;
4
5
use crate::fuzzy_matcher::FuzzyMatcher;
6
use crate::fuzzy_matcher::arinae::ArinaeMatcher;
7
use crate::fuzzy_matcher::clangd::ClangdMatcher;
8
#[cfg(feature = "frizbee")]
9
use crate::fuzzy_matcher::frizbee::FrizbeeMatcher;
10
use crate::fuzzy_matcher::fzy::FzyMatcher;
11
use crate::fuzzy_matcher::skim::SkimMatcherV2;
12
13
use crate::item::RankBuilder;
14
use crate::{CaseMatching, MatchEngine, MatchRange, MatchResult, SkimItem, Typos};
15
16
//------------------------------------------------------------------------------
17
/// Fuzzy matching algorithm to use
18
#[derive(Debug, Copy, Clone, Default, PartialEq)]
19
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
20
#[cfg_attr(feature = "cli", clap(rename_all = "snake_case"))]
21
pub enum FuzzyAlgorithm {
22
    /// Arinae: typo-resistant & natural algorithm, default
23
    #[cfg_attr(feature = "cli", clap(alias = "ari"))]
24
    #[default]
25
    Arinae,
26
    /// Clangd fuzzy matching algorithm
27
    Clangd,
28
    /// Fzy matching algorithm (<https://github.com/jhawthorn/fzy>)
29
    Fzy,
30
    /// Frizbee matching algorithm, typo resistant
31
    #[cfg(feature = "frizbee")]
32
    Frizbee,
33
    /// Previous skim fuzzy matching algorithm (v2)
34
    SkimV2,
35
}
36
37
const BYTES_1M: usize = 1024 * 1024 * 1024;
38
39
//------------------------------------------------------------------------------
40
// Fuzzy engine
41
#[derive(Default)]
42
pub struct FuzzyEngineBuilder {
43
    query: String,
44
    case: CaseMatching,
45
    algorithm: FuzzyAlgorithm,
46
    rank_builder: Arc<RankBuilder>,
47
    /// Typo tolerance configuration:
48
    /// - `Typos::Disabled`: no typo tolerance
49
    /// - `Typos::Smart`: adaptive (`pattern_length` / 4)
50
    /// - `Typos::Fixed(n)`: exactly n typos allowed
51
    typos: Typos,
52
    /// When true, prefer the last (rightmost) occurrence on tied scores.
53
    last_match: bool,
54
}
55
56
impl FuzzyEngineBuilder {
57
596
    pub fn query(mut self, query: &str) -> Self {
58
596
        self.query = query.to_string();
59
596
        self
60
596
    }
61
62
583
    pub fn case(mut self, case: CaseMatching) -> Self {
63
583
        self.case = case;
64
583
        self
65
583
    }
66
67
586
    pub fn algorithm(mut self, algorithm: FuzzyAlgorithm) -> Self {
68
586
        self.algorithm = algorithm;
69
586
        self
70
586
    }
71
72
571
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
73
571
        self.rank_builder = rank_builder;
74
571
        self
75
571
    }
76
77
576
    pub fn typos(mut self, typos: Typos) -> Self {
78
576
        self.typos = typos;
79
576
        self
80
576
    }
81
82
    /// No-op: `fuzzy_match_range` is now always used (`ByteRange`).
83
    /// Kept for API backward compatibility.
84
571
    pub fn filter_mode(self, _filter_mode: bool) -> Self {
85
571
        self
86
571
    }
87
88
571
    pub fn last_match(mut self, last_match: bool) -> Self {
89
571
        self.last_match = last_match;
90
571
        self
91
571
    }
92
93
    /// Compute the effective `max_typos` for the given query.
94
    ///
95
    /// - `Typos::Disabled` → `None` (no typo tolerance)
96
    /// - `Typos::Smart` → adaptive: `Some(query.chars().count() / 4)`
97
    /// - `Typos::Fixed(n)` → `Some(n)`
98
596
    fn effective_max_typos(&self) -> Option<usize> {
99
596
        match self.typos {
100
587
            Typos::Disabled => None,
101
7
            Typos::Smart => Some(self.query.chars().count().saturating_div(4)),
102
2
            Typos::Fixed(n) => Some(n),
103
        }
104
596
    }
105
106
    #[allow(deprecated)]
107
593
    pub fn build(self) -> FuzzyEngine {
108
        #[allow(unused_mut)]
109
593
        let mut algorithm = self.algorithm;
110
593
        let max_typos = self.effective_max_typos();
111
593
        let matcher: Box<dyn FuzzyMatcher> = match algorithm {
112
            FuzzyAlgorithm::SkimV2 => {
113
17
                let matcher = SkimMatcherV2::default().element_limit(BYTES_1M);
114
17
                let matcher = match self.case {
115
1
                    CaseMatching::Respect => matcher.respect_case(),
116
1
                    CaseMatching::Ignore => matcher.ignore_case(),
117
15
                    CaseMatching::Smart => matcher.smart_case(),
118
                };
119
17
                debug!(
"Initialized SkimV2 algorithm"0
);
120
17
                Box::new(matcher)
121
            }
122
            FuzzyAlgorithm::Clangd => {
123
5
                let matcher = ClangdMatcher::default();
124
5
                let matcher = match self.case {
125
1
                    CaseMatching::Respect => matcher.respect_case(),
126
1
                    CaseMatching::Ignore => matcher.ignore_case(),
127
3
                    CaseMatching::Smart => matcher.smart_case(),
128
                };
129
5
                debug!(
"Initialized Clangd algorithm"0
);
130
5
                Box::new(matcher)
131
            }
132
            #[cfg(feature = "frizbee")]
133
6
            FuzzyAlgorithm::Frizbee => Box::new(FrizbeeMatcher::default().case(self.case).max_typos(max_typos)),
134
            FuzzyAlgorithm::Fzy => {
135
6
                let matcher = FzyMatcher::default().max_typos(max_typos);
136
6
                let matcher = match self.case {
137
1
                    CaseMatching::Respect => matcher.respect_case(),
138
1
                    CaseMatching::Ignore => matcher.ignore_case(),
139
4
                    CaseMatching::Smart => matcher.smart_case(),
140
                };
141
6
                debug!("Initialized Fzy algorithm (max_typos: {
max_typos0
:?})");
142
6
                Box::new(matcher)
143
            }
144
            FuzzyAlgorithm::Arinae => {
145
559
                let matcher = ArinaeMatcher::new(self.case, !matches!(self.typos, Typos::Disabled), self.last_match);
146
559
                debug!(
"Initialized Arinae algorithm"2
);
147
559
                Box::new(matcher)
148
            }
149
        };
150
151
593
        FuzzyEngine {
152
593
            matcher,
153
593
            query: self.query,
154
593
            rank_builder: self.rank_builder,
155
593
        }
156
593
    }
157
}
158
159
/// The fuzzy matching engine
160
pub struct FuzzyEngine {
161
    query: String,
162
    matcher: Box<dyn FuzzyMatcher>,
163
    rank_builder: Arc<RankBuilder>,
164
}
165
166
impl FuzzyEngine {
167
    /// Returns a default builder for chaining
168
    #[must_use]
169
596
    pub fn builder() -> FuzzyEngineBuilder {
170
596
        FuzzyEngineBuilder::default()
171
596
    }
172
}
173
174
impl MatchEngine for FuzzyEngine {
175
51.5k
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
176
51.5k
        let item_text = item.text();
177
51.5k
        let default_range = [(0, item_text.len())];
178
179
51.5k
        let mut best: Option<(i64, Vec<usize>)> = None;
180
51.5k
        for &(start, end) in item.get_matching_ranges().unwrap_or(&default_range) {
181
51.5k
            let start = min(start, item_text.len());
182
51.5k
            let end = min(end, item_text.len());
183
184
51.5k
            let result = if self.query.is_empty() {
  Branch (184:29): [True: 0, False: 51.5k]
 
  Branch (184:29): [True: 1, False: 34]
 
185
1
                Some((0i64, vec![]))
186
51.5k
            } else if item_text[start..end].is_empty() {
  Branch (186:23): [True: 64, False: 51.4k]
 
  Branch (186:23): [True: 3, False: 31]
 
187
67
                None
188
            } else {
189
51.5k
                self.matcher
190
51.5k
                    .fuzzy_indices(&item_text[start..end], &self.query)
191
50.6k
                    .map(|(s, indices)| {
192
50.6k
                        let offset = if start != 0 {
  Branch (192:41): [True: 10, False: 50.5k]
 
  Branch (192:41): [True: 1, False: 26]
-
193
11
                            item_text[..start].chars().count()
194
                        } else {
195
50.6k
                            0
196
                        };
197
51.3k
                        let indices = 
indices.into_iter()50.6k
.map(|i| i + offset).collect();
198
50.6k
                        (s, indices)
199
50.6k
                    })
200
            };
201
202
51.5k
            if result.is_some() {
  Branch (202:16): [True: 50.5k, False: 954]
+
193
11
                            item_text[..start].chars().count()
194
                        } else {
195
50.6k
                            0
196
                        };
197
51.3k
                        let indices = 
indices.into_iter()50.6k
.map(|i| i + offset).collect();
198
50.6k
                        (s, indices)
199
50.6k
                    })
200
            };
201
202
51.5k
            if result.is_some() {
  Branch (202:16): [True: 50.6k, False: 958]
 
  Branch (202:16): [True: 28, False: 7]
-
203
50.6k
                best = result;
204
50.6k
                break;
205
961
            }
206
        }
207
208
51.5k
        let (score, indices) = best
?962
;
209
50.6k
        let begin = indices.first().copied().unwrap_or(0);
210
50.6k
        let end_excl = indices.last().map_or(0, |&i| 
i50.6k
+
150.6k
);
211
212
50.6k
        let matched_range = if indices.is_empty() {
  Branch (212:32): [True: 0, False: 50.5k]
+
203
50.6k
                best = result;
204
50.6k
                break;
205
965
            }
206
        }
207
208
51.5k
        let (score, indices) = best
?966
;
209
50.6k
        let begin = indices.first().copied().unwrap_or(0);
210
50.6k
        let end_excl = indices.last().map_or(0, |&i| 
i50.6k
+
150.6k
);
211
212
50.6k
        let matched_range = if indices.is_empty() {
  Branch (212:32): [True: 0, False: 50.6k]
 
  Branch (212:32): [True: 1, False: 27]
 
213
1
            MatchRange::CharRange(0, 0)
214
        } else {
215
50.6k
            MatchRange::Chars(indices)
216
        };
217
218
50.6k
        Some(MatchResult {
219
50.6k
            rank: self
220
50.6k
                .rank_builder
221
50.6k
                .build_rank(i32::try_from(score).unwrap_or(i32::MAX), begin, end_excl, &item_text),
222
50.6k
            matched_range,
223
50.6k
        })
224
51.5k
    }
225
}
226
227
impl Display for FuzzyEngine {
228
11
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
229
11
        write!(f, "(Fuzzy: {})", self.query)
230
11
    }
231
}
232
233
#[cfg(test)]
234
#[cfg_attr(coverage, coverage(off))]
235
mod tests {
236
    use super::*;
237
    use std::borrow::Cow;
238
239
    /// A test item that exposes explicit `get_matching_ranges`, letting us drive
240
    /// the per-range loop in `match_item` (empty ranges, non-zero offsets, …).
241
    struct RangedItem {
242
        text: String,
243
        ranges: Vec<(usize, usize)>,
244
    }
245
246
    impl SkimItem for RangedItem {
247
        fn text(&self) -> Cow<'_, str> {
248
            Cow::Borrowed(&self.text)
249
        }
250
251
        fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> {
252
            Some(&self.ranges)
253
        }
254
    }
255
256
    #[test]
257
    fn effective_max_typos_per_variant() {
258
        let disabled = FuzzyEngine::builder().query("hello").typos(Typos::Disabled);
259
        assert_eq!(disabled.effective_max_typos(), None);
260
261
        let smart = FuzzyEngine::builder().query("hello").typos(Typos::Smart);
262
        assert_eq!(smart.effective_max_typos(), Some(1)); // 5 / 4 = 1
263
264
        let fixed = FuzzyEngine::builder().query("hello").typos(Typos::Fixed(3));
265
        assert_eq!(fixed.effective_max_typos(), Some(3));
266
    }
267
268
    /// Every algorithm × case combination should build and match a basic query.
269
    #[test]
270
    fn builds_every_algorithm_and_case() {
271
        let algorithms = [
272
            FuzzyAlgorithm::SkimV2,
273
            FuzzyAlgorithm::Clangd,
274
            FuzzyAlgorithm::Fzy,
275
            FuzzyAlgorithm::Arinae,
276
        ];
277
        let cases = [CaseMatching::Respect, CaseMatching::Ignore, CaseMatching::Smart];
278
        for algo in algorithms {
279
            for case in cases {
280
                let engine = FuzzyEngine::builder().query("foo").algorithm(algo).case(case).build();
281
                assert!(
282
                    engine.match_item(&"foobar".to_string()).is_some(),
283
                    "algo {algo:?} case {case:?} should match"
284
                );
285
            }
286
        }
287
    }
288
289
    #[test]
290
    fn empty_query_yields_empty_char_range() {
291
        let engine = FuzzyEngine::builder().query("").build();
292
        let result = engine.match_item(&"anything".to_string()).unwrap();
293
        assert_eq!(result.matched_range, MatchRange::CharRange(0, 0));
294
    }
295
296
    #[test]
297
    fn matching_query_yields_char_indices() {
298
        let engine = FuzzyEngine::builder().query("fb").build();
299
        let result = engine.match_item(&"foobar".to_string()).unwrap();
300
        assert!(matches!(result.matched_range, MatchRange::Chars(_)));
301
    }
302
303
    #[test]
304
    fn no_match_returns_none() {
305
        let engine = FuzzyEngine::builder().query("zzz").build();
306
        assert!(engine.match_item(&"foobar".to_string()).is_none());
307
    }
308
309
    /// An empty matching range (start == end) with a non-empty query must be
310
    /// skipped; a subsequent non-empty range can still match.
311
    #[test]
312
    fn empty_matching_range_is_skipped_then_later_range_matches() {
313
        let item = RangedItem {
314
            text: "foobar".to_string(),
315
            ranges: vec![(0, 0), (0, 6)],
316
        };
317
        let engine = FuzzyEngine::builder().query("fb").build();
318
        let result = engine.match_item(&item).expect("second range should match");
319
        assert!(matches!(result.matched_range, MatchRange::Chars(_)));
320
    }
321
322
    /// When every matching range is empty, the query cannot match anywhere.
323
    #[test]
324
    fn only_empty_matching_ranges_yields_no_match() {
325
        let item = RangedItem {
326
            text: "foobar".to_string(),
327
            ranges: vec![(0, 0), (3, 3)],
328
        };
329
        let engine = FuzzyEngine::builder().query("f").build();
330
        assert!(engine.match_item(&item).is_none());
331
    }
332
333
    /// A matching range that starts after byte 0 must offset the reported
334
    /// character indices by the number of characters skipped before it.
335
    #[test]
336
    fn nonzero_start_offsets_char_indices() {
337
        // Bytes 2..8 of "xxfoobar" are "foobar"; the leading "xx" is two chars.
338
        let item = RangedItem {
339
            text: "xxfoobar".to_string(),
340
            ranges: vec![(2, 8)],
341
        };
342
        let engine = FuzzyEngine::builder().query("fb").build();
343
        let result = engine.match_item(&item).expect("should match within range");
344
        let MatchRange::Chars(indices) = result.matched_range else {
345
            panic!("expected Chars range, got {:?}", result.matched_range);
346
        };
347
        // 'f' sits at char index 2 in the full text; nothing before the range.
348
        assert!(indices.iter().all(|&i| i >= 2), "indices not offset: {indices:?}");
349
        assert!(indices.contains(&2), "expected 'f' at char index 2: {indices:?}");
350
    }
351
352
    /// Building the Arinae matcher exercises the `matches!(typos, Disabled)`
353
    /// branch in both directions (typos on and off).
354
    #[test]
355
    fn builds_arinae_with_and_without_typos() {
356
        for typos in [Typos::Disabled, Typos::Fixed(1)] {
357
            let engine = FuzzyEngine::builder()
358
                .query("foo")
359
                .algorithm(FuzzyAlgorithm::Arinae)
360
                .typos(typos)
361
                .build();
362
            assert!(
363
                engine.match_item(&"foobar".to_string()).is_some(),
364
                "Arinae with {typos:?} should match"
365
            );
366
        }
367
    }
368
369
    #[cfg(feature = "frizbee")]
370
    #[test]
371
    fn builds_frizbee_algorithm() {
372
        let engine = FuzzyEngine::builder()
373
            .query("foo")
374
            .algorithm(FuzzyAlgorithm::Frizbee)
375
            .build();
376
        assert!(engine.match_item(&"foobar".to_string()).is_some());
377
    }
378
379
    #[test]
380
    fn display_shows_query() {
381
        let engine = FuzzyEngine::builder().query("foo").build();
382
        assert_eq!(format!("{engine}"), "(Fuzzy: foo)");
383
    }
384
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/engine/normalized.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/engine/normalized.rs.html index de190c54..3e464c67 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/engine/normalized.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/engine/normalized.rs.html @@ -1,3 +1,3 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/normalized.rs
Line
Count
Source
1
//! Normalized match engine for matching with Unicode normalization (removing diacritics).
2
//!
3
//! This engine wraps another engine and normalizes both the query and item text before matching,
4
//! then maps the results back to the original text.
5
6
use std::borrow::Cow;
7
use std::fmt::{Display, Error, Formatter};
8
9
use crate::engine::util::{
10
    map_byte_range_to_original, map_char_indices_to_original, normalize_with_byte_mapping, normalize_with_char_mapping,
11
};
12
use crate::{CaseMatching, MatchEngine, MatchEngineFactory, MatchRange, MatchResult, SkimItem};
13
14
/// Engine that normalizes text before matching
15
pub struct NormalizedEngine {
16
    /// The underlying engine to match normalized text
17
    inner: Box<dyn MatchEngine>,
18
}
19
20
impl NormalizedEngine {
21
    /// Creates a new normalized match engine
22
102
    pub fn new(inner: Box<dyn MatchEngine>) -> Self {
23
102
        Self { inner }
24
102
    }
25
}
26
27
impl MatchEngine for NormalizedEngine {
28
293
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
29
293
        let item_text = item.text();
30
31
        // Normalize the item text
32
293
        let (normalized_text, char_mapping) = normalize_with_char_mapping(&item_text);
33
293
        let (_, byte_mapping) = normalize_with_byte_mapping(&item_text);
34
35
        // Create a wrapper item with normalized text
36
293
        let normalized_item: &dyn SkimItem = &NormalizedItem(normalized_text);
37
38
        // Match using the inner engine
39
293
        let mut result = self.inner.match_item(normalized_item)
?93
;
40
41
        // Map the matched range back to the original text
42
200
        result.matched_range = match result.matched_range {
43
113
            MatchRange::Chars(indices) => MatchRange::Chars(map_char_indices_to_original(&indices, &char_mapping)),
44
            MatchRange::CharRange(start, end) => {
45
2
                let orig_start = char_mapping.get(start).copied().unwrap_or(start);
46
2
                let orig_end = if end > 0 {
  Branch (46:35): [True: 0, False: 0]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/normalized.rs
Line
Count
Source
1
//! Normalized match engine for matching with Unicode normalization (removing diacritics).
2
//!
3
//! This engine wraps another engine and normalizes both the query and item text before matching,
4
//! then maps the results back to the original text.
5
6
use std::borrow::Cow;
7
use std::fmt::{Display, Error, Formatter};
8
9
use crate::engine::util::{
10
    map_byte_range_to_original, map_char_indices_to_original, normalize_with_byte_mapping, normalize_with_char_mapping,
11
};
12
use crate::{CaseMatching, MatchEngine, MatchEngineFactory, MatchRange, MatchResult, SkimItem};
13
14
/// Engine that normalizes text before matching
15
pub struct NormalizedEngine {
16
    /// The underlying engine to match normalized text
17
    inner: Box<dyn MatchEngine>,
18
}
19
20
impl NormalizedEngine {
21
    /// Creates a new normalized match engine
22
102
    pub fn new(inner: Box<dyn MatchEngine>) -> Self {
23
102
        Self { inner }
24
102
    }
25
}
26
27
impl MatchEngine for NormalizedEngine {
28
293
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
29
293
        let item_text = item.text();
30
31
        // Normalize the item text
32
293
        let (normalized_text, char_mapping) = normalize_with_char_mapping(&item_text);
33
293
        let (_, byte_mapping) = normalize_with_byte_mapping(&item_text);
34
35
        // Create a wrapper item with normalized text
36
293
        let normalized_item: &dyn SkimItem = &NormalizedItem(normalized_text);
37
38
        // Match using the inner engine
39
293
        let mut result = self.inner.match_item(normalized_item)
?93
;
40
41
        // Map the matched range back to the original text
42
200
        result.matched_range = match result.matched_range {
43
113
            MatchRange::Chars(indices) => MatchRange::Chars(map_char_indices_to_original(&indices, &char_mapping)),
44
            MatchRange::CharRange(start, end) => {
45
2
                let orig_start = char_mapping.get(start).copied().unwrap_or(start);
46
2
                let orig_end = if end > 0 {
  Branch (46:35): [True: 0, False: 0]
   Branch (46:35): [True: 1, False: 1]
 
47
1
                    char_mapping.get(end - 1).copied().map_or(end, |e| e + 1)
48
                } else {
49
1
                    0
50
                };
51
2
                MatchRange::CharRange(orig_start, orig_end)
52
            }
53
            MatchRange::ByteRange(start, end) => {
54
85
                let (orig_start, orig_end) = map_byte_range_to_original(start, end, &byte_mapping, &item_text);
55
85
                MatchRange::ByteRange(orig_start, orig_end)
56
            }
57
        };
58
59
200
        Some(result)
60
293
    }
61
}
62
63
impl Display for NormalizedEngine {
64
1
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
65
1
        write!(f, "(Normalized: {})", self.inner)
66
1
    }
67
}
68
69
/// Simple string wrapper implementing `SkimItem` for normalized matching
70
struct NormalizedItem(String);
71
72
impl SkimItem for NormalizedItem {
73
290
    fn text(&self) -> Cow<'_, str> {
74
290
        Cow::Borrowed(&self.0)
75
290
    }
76
}
77
78
//------------------------------------------------------------------------------
79
// NormalizedEngineFactory - wraps another factory and handles normalization
80
81
/// Factory that handles normalization by wrapping another engine factory
82
pub struct NormalizedEngineFactory {
83
    inner: Box<dyn MatchEngineFactory>,
84
}
85
86
impl NormalizedEngineFactory {
87
    /// Creates a new normalized engine factory
88
13
    pub fn new(inner: impl MatchEngineFactory + 'static) -> Self {
89
13
        Self { inner: Box::new(inner) }
90
13
    }
91
}
92
93
impl MatchEngineFactory for NormalizedEngineFactory {
94
96
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
95
        // Normalize the query
96
96
        let (normalized_query, _) = normalize_with_char_mapping(query);
97
98
        // Create the inner engine with the normalized query
99
96
        let inner_engine = self.inner.create_engine_with_case(&normalized_query, case);
100
101
        // Wrap it in a NormalizedEngine
102
96
        Box::new(NormalizedEngine::new(inner_engine))
103
96
    }
104
}
105
106
#[cfg(test)]
107
#[cfg_attr(coverage, coverage(off))]
108
mod tests {
109
    use super::*;
110
    use crate::engine::exact::{ExactEngine, ExactMatchingParam};
111
    use crate::prelude::ExactOrFuzzyEngineFactory;
112
113
    #[test]
114
    fn matches_through_diacritics() {
115
        // Inner exact engine searches for the ASCII form; the normalized engine
116
        // strips the accent from the item text before matching.
117
        let inner = Box::new(ExactEngine::builder("cafe", ExactMatchingParam::default()).build());
118
        let engine = NormalizedEngine::new(inner);
119
        let result = engine.match_item(&"café".to_string());
120
        assert!(result.is_some());
121
    }
122
123
    #[test]
124
    fn no_match_returns_none() {
125
        let inner = Box::new(ExactEngine::builder("zzz", ExactMatchingParam::default()).build());
126
        let engine = NormalizedEngine::new(inner);
127
        assert!(engine.match_item(&"café".to_string()).is_none());
128
    }
129
130
    #[test]
131
    fn display_includes_inner_engine() {
132
        let inner = Box::new(ExactEngine::builder("x", ExactMatchingParam::default()).build());
133
        let engine = NormalizedEngine::new(inner);
134
        assert!(format!("{engine}").starts_with("(Normalized:"));
135
    }
136
137
    #[test]
138
    fn factory_creates_normalized_engine() {
139
        let factory = NormalizedEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build());
140
        let engine = factory.create_engine_with_case("cafe", CaseMatching::Smart);
141
        // The accented item should match the normalized query.
142
        assert!(engine.match_item(&"café".to_string()).is_some());
143
    }
144
145
    /// Inner engine that always returns a fixed `CharRange`, so the normalized
146
    /// engine's `CharRange` remapping branch is exercised.
147
    struct CharRangeStub(usize, usize);
148
149
    impl MatchEngine for CharRangeStub {
150
        fn match_item(&self, _item: &dyn SkimItem) -> Option<MatchResult> {
151
            Some(MatchResult {
152
                rank: crate::Rank::default(),
153
                matched_range: MatchRange::CharRange(self.0, self.1),
154
            })
155
        }
156
    }
157
158
    impl Display for CharRangeStub {
159
        fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
160
            write!(f, "CharRangeStub")
161
        }
162
    }
163
164
    #[test]
165
    fn char_range_is_mapped_back_to_original() {
166
        // café → cafe is a 1:1 normalization, so the char range is unchanged.
167
        let engine = NormalizedEngine::new(Box::new(CharRangeStub(1, 3)));
168
        let result = engine.match_item(&"café".to_string()).unwrap();
169
        assert_eq!(result.matched_range, MatchRange::CharRange(1, 3));
170
    }
171
172
    #[test]
173
    fn empty_char_range_maps_to_zero() {
174
        // An empty range (end == 0) maps straight back to (0, 0).
175
        let engine = NormalizedEngine::new(Box::new(CharRangeStub(0, 0)));
176
        let result = engine.match_item(&"café".to_string()).unwrap();
177
        assert_eq!(result.matched_range, MatchRange::CharRange(0, 0));
178
    }
179
180
    /// Inner engine returning fixed `Chars` indices, exercising the
181
    /// `map_char_indices_to_original` remapping branch.
182
    struct CharsStub(Vec<usize>);
183
184
    impl MatchEngine for CharsStub {
185
        fn match_item(&self, _item: &dyn SkimItem) -> Option<MatchResult> {
186
            Some(MatchResult {
187
                rank: crate::Rank::default(),
188
                matched_range: MatchRange::Chars(self.0.clone()),
189
            })
190
        }
191
    }
192
193
    impl Display for CharsStub {
194
        fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
195
            write!(f, "CharsStub")
196
        }
197
    }
198
199
    #[test]
200
    fn chars_indices_are_mapped_back_to_original() {
201
        // 1:1 normalization (café → cafe) keeps the char indices unchanged.
202
        let engine = NormalizedEngine::new(Box::new(CharsStub(vec![0, 2])));
203
        let result = engine.match_item(&"café".to_string()).unwrap();
204
        assert_eq!(result.matched_range, MatchRange::Chars(vec![0, 2]));
205
    }
206
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/engine/regexp.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/engine/regexp.rs.html index b39b5064..b9a3446f 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/engine/regexp.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/engine/regexp.rs.html @@ -1,4 +1,4 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/regexp.rs
Line
Count
Source
1
use std::fmt::{Display, Error, Formatter};
2
use std::sync::Arc;
3
4
use regex::Regex;
5
6
use crate::engine::util::regex_match;
7
use crate::item::RankBuilder;
8
use crate::{CaseMatching, MatchEngine, MatchRange, MatchResult, SkimItem};
9
use std::cmp::min;
10
11
//------------------------------------------------------------------------------
12
// Regular Expression engine
13
#[derive(Debug)]
14
pub struct RegexEngine {
15
    query_regex: Option<Regex>,
16
    rank_builder: Arc<RankBuilder>,
17
}
18
19
impl RegexEngine {
20
14
    pub fn builder(query: &str, case: CaseMatching) -> Self {
21
14
        let mut query_builder = String::new();
22
23
14
        match case {
24
1
            CaseMatching::Ignore => query_builder.push_str("(?i)"),
25
13
            CaseMatching::Respect | CaseMatching::Smart => {}
26
        }
27
28
14
        query_builder.push_str(query);
29
30
14
        RegexEngine {
31
14
            query_regex: Regex::new(&query_builder).ok(),
32
14
            rank_builder: Default::default(),
33
14
        }
34
14
    }
35
36
7
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
37
7
        self.rank_builder = rank_builder;
38
7
        self
39
7
    }
40
41
14
    pub fn build(self) -> Self {
42
14
        self
43
14
    }
44
}
45
46
impl MatchEngine for RegexEngine {
47
12
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
48
12
        let mut matched_result = None;
49
12
        let item_text = item.text();
50
12
        let default_range = [(0, item_text.len())];
51
12
        for &(start, end) in item.get_matching_ranges().unwrap_or(&default_range) {
52
12
            let start = min(start, item_text.len());
53
12
            let end = min(end, item_text.len());
54
12
            if self.query_regex.is_none() {
  Branch (54:16): [True: 0, False: 1]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/regexp.rs
Line
Count
Source
1
use std::fmt::{Display, Error, Formatter};
2
use std::sync::Arc;
3
4
use regex::Regex;
5
6
use crate::engine::util::regex_match;
7
use crate::item::RankBuilder;
8
use crate::{CaseMatching, MatchEngine, MatchRange, MatchResult, SkimItem};
9
use std::cmp::min;
10
11
//------------------------------------------------------------------------------
12
// Regular Expression engine
13
#[derive(Debug)]
14
pub struct RegexEngine {
15
    query_regex: Option<Regex>,
16
    rank_builder: Arc<RankBuilder>,
17
}
18
19
impl RegexEngine {
20
14
    pub fn builder(query: &str, case: CaseMatching) -> Self {
21
14
        let mut query_builder = String::new();
22
23
14
        match case {
24
1
            CaseMatching::Ignore => query_builder.push_str("(?i)"),
25
13
            CaseMatching::Respect | CaseMatching::Smart => {}
26
        }
27
28
14
        query_builder.push_str(query);
29
30
14
        RegexEngine {
31
14
            query_regex: Regex::new(&query_builder).ok(),
32
14
            rank_builder: Default::default(),
33
14
        }
34
14
    }
35
36
7
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
37
7
        self.rank_builder = rank_builder;
38
7
        self
39
7
    }
40
41
14
    pub fn build(self) -> Self {
42
14
        self
43
14
    }
44
}
45
46
impl MatchEngine for RegexEngine {
47
12
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
48
12
        let mut matched_result = None;
49
12
        let item_text = item.text();
50
12
        let default_range = [(0, item_text.len())];
51
12
        for &(start, end) in item.get_matching_ranges().unwrap_or(&default_range) {
52
12
            let start = min(start, item_text.len());
53
12
            let end = min(end, item_text.len());
54
12
            if self.query_regex.is_none() {
  Branch (54:16): [True: 0, False: 1]
 
  Branch (54:16): [True: 1, False: 10]
 
55
1
                matched_result = Some((0, 0));
56
1
                break;
57
11
            }
58
59
11
            matched_result =
60
11
                regex_match(&item_text[start..end], self.query_regex.as_ref()).map(|(s, e)| (
s + start9
,
e + start9
));
61
62
11
            if matched_result.is_some() {
  Branch (62:16): [True: 1, False: 0]
 
  Branch (62:16): [True: 8, False: 2]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/engine/split.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/engine/split.rs.html
index 552675bb..7c703f8a 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/engine/split.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/engine/split.rs.html
@@ -1,7 +1,7 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/split.rs
Line
Count
Source
1
//! Split match engine for matching against different parts of items based on a delimiter.
2
//!
3
//! This engine splits both the query and item text on a delimiter character, then matches
4
//! the query parts against the corresponding item parts.
5
6
use crate::fuzzy_matcher::MatchIndices;
7
use crate::{MatchEngine, MatchEngineFactory, MatchRange, MatchResult, SkimItem};
8
use std::fmt::{Display, Error, Formatter};
9
10
/// Engine that matches by splitting query and item on a delimiter
11
pub struct SplitMatchEngine {
12
    /// The engine to match the "before delimiter" part
13
    before_engine: Box<dyn MatchEngine>,
14
    /// The engine to match the "after delimiter" part  
15
    after_engine: Box<dyn MatchEngine>,
16
    /// The delimiter character used for splitting
17
    delimiter: char,
18
}
19
20
impl SplitMatchEngine {
21
    /// Creates a new split match engine
22
39
    pub fn new(before_engine: Box<dyn MatchEngine>, after_engine: Box<dyn MatchEngine>, delimiter: char) -> Self {
23
39
        Self {
24
39
            before_engine,
25
39
            after_engine,
26
39
            delimiter,
27
39
        }
28
39
    }
29
}
30
31
impl MatchEngine for SplitMatchEngine {
32
88
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
33
88
        let text = item.text();
34
35
        // Find the delimiter in the item text (by char position)
36
291
        let delimiter_char_idx = 
text.chars()88
.position(|c| c == self.delimiter)
?9
;
37
38
        // Get byte position for slicing
39
79
        let delimiter_byte_pos = text.char_indices().nth(delimiter_char_idx).map(|(i, _)| i)
?0
;
40
41
79
        let text_before = &text[..delimiter_byte_pos];
42
79
        let text_after = &text[delimiter_byte_pos + self.delimiter.len_utf8()..];
43
44
        // Create wrapper items for each part
45
79
        let before_item: &dyn SkimItem = &StringItem(text_before.to_string());
46
79
        let after_item: &dyn SkimItem = &StringItem(text_after.to_string());
47
48
        // Match both parts
49
79
        let before_result = self.before_engine.match_item(before_item)
?32
;
50
47
        let after_result = self.after_engine.match_item(after_item)
?5
;
51
52
        // Combine the results - use rank from first result (like AndEngine does)
53
42
        let rank = before_result.rank;
54
55
42
        let mut combined_indices: MatchIndices = match before_result.matched_range {
56
30
            MatchRange::Chars(indices) => indices,
57
1
            MatchRange::CharRange(start, end) => (start..end).collect(),
58
            MatchRange::ByteRange(start, end) => {
59
                // Convert byte range to char indices for the before part
60
11
                text_before
61
11
                    .char_indices()
62
11
                    .enumerate()
63
31
                    .filter(|(_, (byte_idx, _))| *byte_idx >= start && 
*byte_idx < end30
)
  Branch (63:50): [True: 24, False: 0]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/split.rs
Line
Count
Source
1
//! Split match engine for matching against different parts of items based on a delimiter.
2
//!
3
//! This engine splits both the query and item text on a delimiter character, then matches
4
//! the query parts against the corresponding item parts.
5
6
use crate::fuzzy_matcher::MatchIndices;
7
use crate::{MatchEngine, MatchEngineFactory, MatchRange, MatchResult, SkimItem};
8
use std::fmt::{Display, Error, Formatter};
9
10
/// Engine that matches by splitting query and item on a delimiter
11
pub struct SplitMatchEngine {
12
    /// The engine to match the "before delimiter" part
13
    before_engine: Box<dyn MatchEngine>,
14
    /// The engine to match the "after delimiter" part  
15
    after_engine: Box<dyn MatchEngine>,
16
    /// The delimiter character used for splitting
17
    delimiter: char,
18
}
19
20
impl SplitMatchEngine {
21
    /// Creates a new split match engine
22
39
    pub fn new(before_engine: Box<dyn MatchEngine>, after_engine: Box<dyn MatchEngine>, delimiter: char) -> Self {
23
39
        Self {
24
39
            before_engine,
25
39
            after_engine,
26
39
            delimiter,
27
39
        }
28
39
    }
29
}
30
31
impl MatchEngine for SplitMatchEngine {
32
88
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
33
88
        let text = item.text();
34
35
        // Find the delimiter in the item text (by char position)
36
291
        let delimiter_char_idx = 
text.chars()88
.position(|c| c == self.delimiter)
?9
;
37
38
        // Get byte position for slicing
39
79
        let delimiter_byte_pos = text.char_indices().nth(delimiter_char_idx).map(|(i, _)| i)
?0
;
40
41
79
        let text_before = &text[..delimiter_byte_pos];
42
79
        let text_after = &text[delimiter_byte_pos + self.delimiter.len_utf8()..];
43
44
        // Create wrapper items for each part
45
79
        let before_item: &dyn SkimItem = &StringItem(text_before.to_string());
46
79
        let after_item: &dyn SkimItem = &StringItem(text_after.to_string());
47
48
        // Match both parts
49
79
        let before_result = self.before_engine.match_item(before_item)
?32
;
50
47
        let after_result = self.after_engine.match_item(after_item)
?5
;
51
52
        // Combine the results - use rank from first result (like AndEngine does)
53
42
        let rank = before_result.rank;
54
55
42
        let mut combined_indices: MatchIndices = match before_result.matched_range {
56
30
            MatchRange::Chars(indices) => indices,
57
1
            MatchRange::CharRange(start, end) => (start..end).collect(),
58
            MatchRange::ByteRange(start, end) => {
59
                // Convert byte range to char indices for the before part
60
11
                text_before
61
11
                    .char_indices()
62
11
                    .enumerate()
63
31
                    .filter(|(_, (byte_idx, _))| *byte_idx >= start && 
*byte_idx < end30
)
  Branch (63:50): [True: 24, False: 0]
 
  Branch (63:50): [True: 6, False: 1]
 
64
                    .map(|(char_idx, _)| char_idx)
65
                    .collect()
66
            }
67
        };
68
69
        // Offset for the "after" part: delimiter_char_idx + 1 (to skip the delimiter)
70
42
        let offset = delimiter_char_idx + 1;
71
72
42
        let after_indices: MatchIndices = match after_result.matched_range {
73
46
            MatchRange::Chars(indices) => 
indices.into_iter()27
.map(|i| i + offset).collect(),
74
2
            MatchRange::CharRange(start, end) => (
start..end1
).map(|i| i + offset).collect(),
75
            MatchRange::ByteRange(start, end) => {
76
                // Convert byte range to char indices for the after part
77
14
                text_after
78
14
                    .char_indices()
79
14
                    .enumerate()
80
40
                    .filter(|(_, (byte_idx, _))| *byte_idx >= start && 
*byte_idx < end39
)
  Branch (80:50): [True: 33, False: 0]
 
  Branch (80:50): [True: 6, False: 1]
-
81
5
                    .map(|(char_idx, _)| char_idx + offset)
82
                    .collect()
83
            }
84
        };
85
86
42
        combined_indices.extend(after_indices);
87
42
        combined_indices.sort_unstable();
88
42
        combined_indices.dedup();
89
90
42
        Some(MatchResult {
91
42
            rank,
92
42
            matched_range: MatchRange::Chars(combined_indices),
93
42
        })
94
88
    }
95
}
96
97
impl Display for SplitMatchEngine {
98
1
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
99
1
        write!(
100
1
            f,
101
            "(Split[{}]: {} | {})",
102
1
            self.delimiter, self.before_engine, self.after_engine
103
        )
104
1
    }
105
}
106
107
/// Simple string wrapper implementing `SkimItem` for split matching
108
struct StringItem(String);
109
110
impl SkimItem for StringItem {
111
120
    fn text(&self) -> std::borrow::Cow<'_, str> {
112
120
        std::borrow::Cow::Borrowed(&self.0)
113
120
    }
114
}
115
116
//------------------------------------------------------------------------------
117
// SplitMatchEngineFactory - wraps another factory and handles split matching
118
119
/// Factory that handles split matching by wrapping another engine factory
120
pub struct SplitMatchEngineFactory {
121
    inner: Box<dyn MatchEngineFactory>,
122
    delimiter: char,
123
}
124
125
impl SplitMatchEngineFactory {
126
    /// Creates a new split match engine factory
127
13
    pub fn new(inner: impl MatchEngineFactory + 'static, delimiter: char) -> Self {
128
13
        Self {
129
13
            inner: Box::new(inner),
130
13
            delimiter,
131
13
        }
132
13
    }
133
}
134
135
impl MatchEngineFactory for SplitMatchEngineFactory {
136
74
    fn create_engine_with_case(&self, query: &str, case: crate::CaseMatching) -> Box<dyn MatchEngine> {
137
        // Check if the query contains the delimiter
138
74
        if let Some(delimiter_pos) = query.find(self.delimiter) {
  Branch (138:16): [True: 31, False: 41]
+
81
5
                    .map(|(char_idx, _)| char_idx + offset)
82
                    .collect()
83
            }
84
        };
85
86
42
        combined_indices.extend(after_indices);
87
42
        combined_indices.sort_unstable();
88
42
        combined_indices.dedup();
89
90
42
        Some(MatchResult {
91
42
            rank,
92
42
            matched_range: MatchRange::Chars(combined_indices),
93
42
        })
94
88
    }
95
}
96
97
impl Display for SplitMatchEngine {
98
1
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
99
1
        write!(
100
1
            f,
101
            "(Split[{}]: {} | {})",
102
1
            self.delimiter, self.before_engine, self.after_engine
103
        )
104
1
    }
105
}
106
107
/// Simple string wrapper implementing `SkimItem` for split matching
108
struct StringItem(String);
109
110
impl SkimItem for StringItem {
111
120
    fn text(&self) -> std::borrow::Cow<'_, str> {
112
120
        std::borrow::Cow::Borrowed(&self.0)
113
120
    }
114
}
115
116
//------------------------------------------------------------------------------
117
// SplitMatchEngineFactory - wraps another factory and handles split matching
118
119
/// Factory that handles split matching by wrapping another engine factory
120
pub struct SplitMatchEngineFactory {
121
    inner: Box<dyn MatchEngineFactory>,
122
    delimiter: char,
123
}
124
125
impl SplitMatchEngineFactory {
126
    /// Creates a new split match engine factory
127
11
    pub fn new(inner: impl MatchEngineFactory + 'static, delimiter: char) -> Self {
128
11
        Self {
129
11
            inner: Box::new(inner),
130
11
            delimiter,
131
11
        }
132
11
    }
133
}
134
135
impl MatchEngineFactory for SplitMatchEngineFactory {
136
71
    fn create_engine_with_case(&self, query: &str, case: crate::CaseMatching) -> Box<dyn MatchEngine> {
137
        // Check if the query contains the delimiter
138
71
        if let Some(delimiter_pos) = query.find(self.delimiter) {
  Branch (138:16): [True: 31, False: 38]
 
  Branch (138:16): [True: 1, False: 1]
-
139
32
            let query_before = &query[..delimiter_pos];
140
32
            let query_after = &query[delimiter_pos + self.delimiter.len_utf8()..];
141
142
            // Create engines for each part using the inner factory
143
32
            let before_engine = self.inner.create_engine_with_case(query_before, case);
144
32
            let after_engine = self.inner.create_engine_with_case(query_after, case);
145
146
32
            Box::new(SplitMatchEngine::new(before_engine, after_engine, self.delimiter))
147
        } else {
148
            // No delimiter in query, pass through to inner factory
149
42
            self.inner.create_engine_with_case(query, case)
150
        }
151
74
    }
152
}
153
154
#[cfg(test)]
155
#[cfg_attr(coverage, coverage(off))]
156
mod tests {
157
    use super::*;
158
    use crate::engine::exact::{ExactEngine, ExactMatchingParam};
159
    use crate::prelude::ExactOrFuzzyEngineFactory;
160
161
    /// A stub engine that returns a fixed match range, regardless of the item.
162
    struct StubEngine(MatchRange);
163
164
    impl MatchEngine for StubEngine {
165
        fn match_item(&self, _item: &dyn SkimItem) -> Option<MatchResult> {
166
            Some(MatchResult {
167
                rank: crate::Rank::default(),
168
                matched_range: self.0.clone(),
169
            })
170
        }
171
    }
172
173
    impl Display for StubEngine {
174
        fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
175
            write!(f, "Stub")
176
        }
177
    }
178
179
    fn exact(query: &str) -> Box<dyn MatchEngine> {
180
        Box::new(ExactEngine::builder(query, ExactMatchingParam::default()).build())
181
    }
182
183
    #[test]
184
    fn no_delimiter_in_item_returns_none() {
185
        let engine = SplitMatchEngine::new(exact("a"), exact("b"), ':');
186
        assert!(engine.match_item(&"no delimiter here".to_string()).is_none());
187
    }
188
189
    #[test]
190
    fn matches_both_sides_of_delimiter() {
191
        let engine = SplitMatchEngine::new(exact("ab"), exact("cd"), ':');
192
        let result = engine.match_item(&"ab:cd".to_string());
193
        assert!(result.is_some());
194
    }
195
196
    #[test]
197
    fn char_range_results_are_offset_and_combined() {
198
        // Before matches chars 0..2 of "ab"; after matches chars 0..2 of "cd"
199
        // which become 3..5 after the delimiter offset.
200
        let engine = SplitMatchEngine::new(
201
            Box::new(StubEngine(MatchRange::CharRange(0, 2))),
202
            Box::new(StubEngine(MatchRange::CharRange(0, 2))),
203
            ':',
204
        );
205
        let result = engine.match_item(&"ab:cd".to_string()).unwrap();
206
        assert_eq!(result.matched_range, MatchRange::Chars(vec![0, 1, 3, 4]));
207
    }
208
209
    #[test]
210
    fn after_engine_failure_returns_none() {
211
        let engine = SplitMatchEngine::new(exact("ab"), exact("zzz"), ':');
212
        assert!(engine.match_item(&"ab:cd".to_string()).is_none());
213
    }
214
215
    #[test]
216
    fn byte_range_results_drop_chars_outside_range() {
217
        // ByteRange(1, 2) over the three-char "abc"/"def" parts covers only the
218
        // middle byte: 'a'/'d' (byte 0) fail the `>= start` check, 'c'/'f'
219
        // (byte 2) fail the `< end` check, so only 'b'/'e' survive.
220
        let engine = SplitMatchEngine::new(
221
            Box::new(StubEngine(MatchRange::ByteRange(1, 2))),
222
            Box::new(StubEngine(MatchRange::ByteRange(1, 2))),
223
            ':',
224
        );
225
        let result = engine.match_item(&"abc:def".to_string()).unwrap();
226
        // before: char 1 ('b'); after: char 1 of "def" offset past "abc:" → char 5 ('e').
227
        assert_eq!(result.matched_range, MatchRange::Chars(vec![1, 5]));
228
    }
229
230
    #[test]
231
    fn byte_range_results_include_chars_within_range() {
232
        // ByteRange(0, 2) covers both chars of each part, so nothing is dropped.
233
        let engine = SplitMatchEngine::new(
234
            Box::new(StubEngine(MatchRange::ByteRange(0, 2))),
235
            Box::new(StubEngine(MatchRange::ByteRange(0, 2))),
236
            ':',
237
        );
238
        let result = engine.match_item(&"ab:cd".to_string()).unwrap();
239
        assert_eq!(result.matched_range, MatchRange::Chars(vec![0, 1, 3, 4]));
240
    }
241
242
    #[test]
243
    fn display_shows_both_engines_and_delimiter() {
244
        let engine = SplitMatchEngine::new(exact("a"), exact("b"), ':');
245
        let s = format!("{engine}");
246
        assert!(s.starts_with("(Split[:]:"));
247
    }
248
249
    #[test]
250
    fn factory_without_delimiter_passes_through() {
251
        let factory = SplitMatchEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build(), ':');
252
        let engine = factory.create_engine_with_case("foo", crate::CaseMatching::Smart);
253
        // Plain query, no delimiter → behaves like the inner engine.
254
        assert!(engine.match_item(&"foobar".to_string()).is_some());
255
    }
256
257
    #[test]
258
    fn factory_with_delimiter_builds_split_engine() {
259
        let factory = SplitMatchEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build(), ':');
260
        let engine = factory.create_engine_with_case("ab:cd", crate::CaseMatching::Smart);
261
        assert!(engine.match_item(&"ab:cd".to_string()).is_some());
262
        assert!(engine.match_item(&"ab:xy".to_string()).is_none());
263
    }
264
}
\ No newline at end of file +
139
32
            let query_before = &query[..delimiter_pos];
140
32
            let query_after = &query[delimiter_pos + self.delimiter.len_utf8()..];
141
142
            // Create engines for each part using the inner factory
143
32
            let before_engine = self.inner.create_engine_with_case(query_before, case);
144
32
            let after_engine = self.inner.create_engine_with_case(query_after, case);
145
146
32
            Box::new(SplitMatchEngine::new(before_engine, after_engine, self.delimiter))
147
        } else {
148
            // No delimiter in query, pass through to inner factory
149
39
            self.inner.create_engine_with_case(query, case)
150
        }
151
71
    }
152
}
153
154
#[cfg(test)]
155
#[cfg_attr(coverage, coverage(off))]
156
mod tests {
157
    use super::*;
158
    use crate::engine::exact::{ExactEngine, ExactMatchingParam};
159
    use crate::prelude::ExactOrFuzzyEngineFactory;
160
161
    /// A stub engine that returns a fixed match range, regardless of the item.
162
    struct StubEngine(MatchRange);
163
164
    impl MatchEngine for StubEngine {
165
        fn match_item(&self, _item: &dyn SkimItem) -> Option<MatchResult> {
166
            Some(MatchResult {
167
                rank: crate::Rank::default(),
168
                matched_range: self.0.clone(),
169
            })
170
        }
171
    }
172
173
    impl Display for StubEngine {
174
        fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
175
            write!(f, "Stub")
176
        }
177
    }
178
179
    fn exact(query: &str) -> Box<dyn MatchEngine> {
180
        Box::new(ExactEngine::builder(query, ExactMatchingParam::default()).build())
181
    }
182
183
    #[test]
184
    fn no_delimiter_in_item_returns_none() {
185
        let engine = SplitMatchEngine::new(exact("a"), exact("b"), ':');
186
        assert!(engine.match_item(&"no delimiter here".to_string()).is_none());
187
    }
188
189
    #[test]
190
    fn matches_both_sides_of_delimiter() {
191
        let engine = SplitMatchEngine::new(exact("ab"), exact("cd"), ':');
192
        let result = engine.match_item(&"ab:cd".to_string());
193
        assert!(result.is_some());
194
    }
195
196
    #[test]
197
    fn char_range_results_are_offset_and_combined() {
198
        // Before matches chars 0..2 of "ab"; after matches chars 0..2 of "cd"
199
        // which become 3..5 after the delimiter offset.
200
        let engine = SplitMatchEngine::new(
201
            Box::new(StubEngine(MatchRange::CharRange(0, 2))),
202
            Box::new(StubEngine(MatchRange::CharRange(0, 2))),
203
            ':',
204
        );
205
        let result = engine.match_item(&"ab:cd".to_string()).unwrap();
206
        assert_eq!(result.matched_range, MatchRange::Chars(vec![0, 1, 3, 4]));
207
    }
208
209
    #[test]
210
    fn after_engine_failure_returns_none() {
211
        let engine = SplitMatchEngine::new(exact("ab"), exact("zzz"), ':');
212
        assert!(engine.match_item(&"ab:cd".to_string()).is_none());
213
    }
214
215
    #[test]
216
    fn byte_range_results_drop_chars_outside_range() {
217
        // ByteRange(1, 2) over the three-char "abc"/"def" parts covers only the
218
        // middle byte: 'a'/'d' (byte 0) fail the `>= start` check, 'c'/'f'
219
        // (byte 2) fail the `< end` check, so only 'b'/'e' survive.
220
        let engine = SplitMatchEngine::new(
221
            Box::new(StubEngine(MatchRange::ByteRange(1, 2))),
222
            Box::new(StubEngine(MatchRange::ByteRange(1, 2))),
223
            ':',
224
        );
225
        let result = engine.match_item(&"abc:def".to_string()).unwrap();
226
        // before: char 1 ('b'); after: char 1 of "def" offset past "abc:" → char 5 ('e').
227
        assert_eq!(result.matched_range, MatchRange::Chars(vec![1, 5]));
228
    }
229
230
    #[test]
231
    fn byte_range_results_include_chars_within_range() {
232
        // ByteRange(0, 2) covers both chars of each part, so nothing is dropped.
233
        let engine = SplitMatchEngine::new(
234
            Box::new(StubEngine(MatchRange::ByteRange(0, 2))),
235
            Box::new(StubEngine(MatchRange::ByteRange(0, 2))),
236
            ':',
237
        );
238
        let result = engine.match_item(&"ab:cd".to_string()).unwrap();
239
        assert_eq!(result.matched_range, MatchRange::Chars(vec![0, 1, 3, 4]));
240
    }
241
242
    #[test]
243
    fn display_shows_both_engines_and_delimiter() {
244
        let engine = SplitMatchEngine::new(exact("a"), exact("b"), ':');
245
        let s = format!("{engine}");
246
        assert!(s.starts_with("(Split[:]:"));
247
    }
248
249
    #[test]
250
    fn factory_without_delimiter_passes_through() {
251
        let factory = SplitMatchEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build(), ':');
252
        let engine = factory.create_engine_with_case("foo", crate::CaseMatching::Smart);
253
        // Plain query, no delimiter → behaves like the inner engine.
254
        assert!(engine.match_item(&"foobar".to_string()).is_some());
255
    }
256
257
    #[test]
258
    fn factory_with_delimiter_builds_split_engine() {
259
        let factory = SplitMatchEngineFactory::new(ExactOrFuzzyEngineFactory::builder().build(), ':');
260
        let engine = factory.create_engine_with_case("ab:cd", crate::CaseMatching::Smart);
261
        assert!(engine.match_item(&"ab:cd".to_string()).is_some());
262
        assert!(engine.match_item(&"ab:xy".to_string()).is_none());
263
    }
264
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/engine/util.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/engine/util.rs.html index 9d2f0e80..30545074 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/engine/util.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/engine/util.rs.html @@ -1,4 +1,4 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/util.rs
Line
Count
Source
1
use crate::fuzzy_matcher::MatchIndices;
2
use regex::Regex;
3
use unicode_normalization::UnicodeNormalization;
4
5
/// Normalize a string and return a mapping from normalized char indices to original char indices.
6
///
7
/// Returns (`normalized_string`, mapping) where mapping[i] gives the original char index
8
/// for the i-th character in the normalized string.
9
389
pub fn normalize_with_char_mapping(s: &str) -> (String, Vec<usize>) {
10
389
    let mut normalized = String::new();
11
389
    let mut mapping = Vec::new();
12
13
389
    for (orig_char_idx, orig_char) in s.chars().enumerate() {
14
        // Decompose this character into NFD form
15
1.78k
        for decomposed_char in orig_char.nfd() {
16
1.93k
            if !unicode_normalization::char::is_combining_mark(decomposed_char) {
  Branch (16:16): [True: 1.74k, False: 146]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/engine/util.rs
Line
Count
Source
1
use crate::fuzzy_matcher::MatchIndices;
2
use regex::Regex;
3
use unicode_normalization::UnicodeNormalization;
4
5
/// Normalize a string and return a mapping from normalized char indices to original char indices.
6
///
7
/// Returns (`normalized_string`, mapping) where mapping[i] gives the original char index
8
/// for the i-th character in the normalized string.
9
389
pub fn normalize_with_char_mapping(s: &str) -> (String, Vec<usize>) {
10
389
    let mut normalized = String::new();
11
389
    let mut mapping = Vec::new();
12
13
389
    for (orig_char_idx, orig_char) in s.chars().enumerate() {
14
        // Decompose this character into NFD form
15
1.78k
        for decomposed_char in orig_char.nfd() {
16
1.93k
            if !unicode_normalization::char::is_combining_mark(decomposed_char) {
  Branch (16:16): [True: 1.74k, False: 146]
 
  Branch (16:16): [True: 37, False: 6]
 
17
1.78k
                normalized.push(decomposed_char);
18
1.78k
                mapping.push(orig_char_idx);
19
152
            }
20
        }
21
    }
22
23
389
    (normalized, mapping)
24
389
}
25
26
/// Map character indices from normalized string back to original string.
27
///
28
/// Given indices into a normalized string and the char mapping from `normalize_with_char_mapping`,
29
/// returns the corresponding indices in the original string.
30
113
pub fn map_char_indices_to_original(normalized_indices: &[usize], char_mapping: &[usize]) -> MatchIndices {
31
113
    normalized_indices
32
113
        .iter()
33
327
        .filter_map(|&idx| char_mapping.get(idx).copied())
34
        .collect()
35
113
}
36
37
/// Normalize a string and return a mapping from normalized byte positions to original byte positions.
38
///
39
/// Returns (`normalized_string`, `byte_mapping`) where `byte_mapping`[i] gives the original byte position
40
/// for the i-th byte in the normalized string.
41
293
pub fn normalize_with_byte_mapping(s: &str) -> (String, Vec<usize>) {
42
293
    let mut normalized = String::new();
43
293
    let mut byte_mapping = Vec::new();
44
45
293
    for (orig_byte_pos, orig_char) in s.char_indices() {
46
        // Decompose this character into NFD form
47
1.56k
        for decomposed_char in orig_char.nfd() {
48
1.71k
            if !unicode_normalization::char::is_combining_mark(decomposed_char) {
  Branch (48:16): [True: 1.53k, False: 145]
 
  Branch (48:16): [True: 30, False: 6]
@@ -12,6 +12,6 @@
   Branch (80:45): [True: 3, False: 1]
 
81
25
        let last_byte_orig_pos = byte_mapping[normalized_end - 1];
82
        // Find the end of the character at this position in the original string
83
25
        original_str[last_byte_orig_pos..]
84
25
            .chars()
85
25
            .next()
86
25
            .map_or(original_str.len(), |c| last_byte_orig_pos + c.len_utf8())
87
63
    } else if normalized_end >= byte_mapping.len() {
  Branch (87:15): [True: 0, False: 61]
 
  Branch (87:15): [True: 1, False: 1]
-
88
1
        original_str.len()
89
    } else {
90
62
        orig_start
91
    };
92
93
88
    (orig_start, orig_end)
94
90
}
95
96
95
pub fn regex_match(choice: &str, pattern: Option<&Regex>) -> Option<(usize, usize)> {
97
95
    let pat = pattern
?1
;
98
94
    let mat = pat.find(choice)
?33
;
99
61
    Some((mat.start(), mat.end()))
100
95
}
101
102
56
pub fn contains_upper(string: &str) -> bool {
103
56
    for ch in string.chars() {
104
151
        if ch.is_uppercase() {
  Branch (104:12): [True: 0, False: 50]
+
88
1
        original_str.len()
89
    } else {
90
62
        orig_start
91
    };
92
93
88
    (orig_start, orig_end)
94
90
}
95
96
95
pub fn regex_match(choice: &str, pattern: Option<&Regex>) -> Option<(usize, usize)> {
97
95
    let pat = pattern
?1
;
98
94
    let mat = pat.find(choice)
?33
;
99
61
    Some((mat.start(), mat.end()))
100
95
}
101
102
55
pub fn contains_upper(string: &str) -> bool {
103
55
    for ch in string.chars() {
104
148
        if ch.is_uppercase() {
  Branch (104:12): [True: 0, False: 47]
 
  Branch (104:12): [True: 3, False: 98]
-
105
3
            return true;
106
148
        }
107
    }
108
53
    false
109
56
}
110
111
#[cfg(test)]
112
#[cfg_attr(coverage, coverage(off))]
113
mod tests {
114
    use super::*;
115
116
    #[test]
117
    fn contains_upper_detects_case() {
118
        assert!(contains_upper("heLlo"));
119
        assert!(contains_upper("ABC"));
120
        assert!(!contains_upper("hello"));
121
        assert!(!contains_upper("123 -_"));
122
    }
123
124
    #[test]
125
    fn regex_match_finds_and_misses() {
126
        let re = Regex::new("ba.").unwrap();
127
        assert_eq!(regex_match("foobar", Some(&re)), Some((3, 6)));
128
        assert_eq!(regex_match("nope", Some(&re)), None);
129
        assert_eq!(regex_match("foobar", None), None);
130
    }
131
132
    #[test]
133
    fn byte_range_empty_mapping_returns_zero() {
134
        // Empty mapping or out-of-range start short-circuits to (0, 0).
135
        assert_eq!(map_byte_range_to_original(0, 2, &[], "abc"), (0, 0));
136
        assert_eq!(map_byte_range_to_original(5, 6, &[0, 1, 2], "abc"), (0, 0));
137
    }
138
139
    #[test]
140
    fn byte_range_end_past_mapping_uses_string_len() {
141
        // normalized_end beyond the mapping length clamps to the original length.
142
        assert_eq!(map_byte_range_to_original(0, 5, &[0, 1, 2], "abc"), (0, 3));
143
    }
144
145
    #[test]
146
    fn byte_range_zero_end_falls_back_to_start() {
147
        // normalized_end == 0 yields an empty range anchored at orig_start.
148
        assert_eq!(map_byte_range_to_original(1, 0, &[0, 1, 2], "abc"), (1, 1));
149
    }
150
151
    #[test]
152
    fn byte_range_maps_normal_range() {
153
        // A normal in-bounds range maps to the byte span of the original char.
154
        assert_eq!(map_byte_range_to_original(0, 2, &[0, 1, 2], "abc"), (0, 2));
155
    }
156
}
\ No newline at end of file +
105
3
            return true;
106
145
        }
107
    }
108
52
    false
109
55
}
110
111
#[cfg(test)]
112
#[cfg_attr(coverage, coverage(off))]
113
mod tests {
114
    use super::*;
115
116
    #[test]
117
    fn contains_upper_detects_case() {
118
        assert!(contains_upper("heLlo"));
119
        assert!(contains_upper("ABC"));
120
        assert!(!contains_upper("hello"));
121
        assert!(!contains_upper("123 -_"));
122
    }
123
124
    #[test]
125
    fn regex_match_finds_and_misses() {
126
        let re = Regex::new("ba.").unwrap();
127
        assert_eq!(regex_match("foobar", Some(&re)), Some((3, 6)));
128
        assert_eq!(regex_match("nope", Some(&re)), None);
129
        assert_eq!(regex_match("foobar", None), None);
130
    }
131
132
    #[test]
133
    fn byte_range_empty_mapping_returns_zero() {
134
        // Empty mapping or out-of-range start short-circuits to (0, 0).
135
        assert_eq!(map_byte_range_to_original(0, 2, &[], "abc"), (0, 0));
136
        assert_eq!(map_byte_range_to_original(5, 6, &[0, 1, 2], "abc"), (0, 0));
137
    }
138
139
    #[test]
140
    fn byte_range_end_past_mapping_uses_string_len() {
141
        // normalized_end beyond the mapping length clamps to the original length.
142
        assert_eq!(map_byte_range_to_original(0, 5, &[0, 1, 2], "abc"), (0, 3));
143
    }
144
145
    #[test]
146
    fn byte_range_zero_end_falls_back_to_start() {
147
        // normalized_end == 0 yields an empty range anchored at orig_start.
148
        assert_eq!(map_byte_range_to_original(1, 0, &[0, 1, 2], "abc"), (1, 1));
149
    }
150
151
    #[test]
152
    fn byte_range_maps_normal_range() {
153
        // A normal in-bounds range maps to the byte span of the original char.
154
        assert_eq!(map_byte_range_to_original(0, 2, &[0, 1, 2], "abc"), (0, 2));
155
    }
156
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/field.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/field.rs.html index 518fa751..8a48a628 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/field.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/field.rs.html @@ -1,33 +1,33 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/field.rs
Line
Count
Source
1
//! Field extraction and parsing utilities.
2
//!
3
//! This module provides utilities for parsing field ranges and extracting
4
//! fields from text based on delimiters.
5
6
use regex::Regex;
7
use std::cmp::{max, min};
8
use std::sync::LazyLock;
9
10
static FIELD_RANGE: LazyLock<Regex> =
11
69
    LazyLock::new(|| Regex::new(r"^(?P<left>-?\d+)?(?P<sep>\.\.)?(?P<right>-?\d+)?$").unwrap());
12
13
/// Represents a range of fields to extract from text
14
#[derive(PartialEq, Eq, Clone, Debug)]
15
pub enum FieldRange {
16
    /// A single field at the given index
17
    Single(i32),
18
    /// All fields from the start up to and including the given index
19
    LeftInf(i32),
20
    /// All fields from the given index to the end
21
    RightInf(i32),
22
    /// Fields between two indices (inclusive)
23
    Both(i32, i32),
24
}
25
26
/// Parses one side of a field range. The regex only ever hands us `-?\d+`, so the
27
/// single failure mode is overflowing `i32`; saturate instead of silently falling
28
/// back to a different field.
29
100
fn parse_index(s: &str) -> i32 {
30
100
    s.parse()
31
100
        .unwrap_or(if s.starts_with('-') { 
i32::MIN25
} else {
i32::MAX75
})
  Branch (31:23): [True: 15, False: 54]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/field.rs
Line
Count
Source
1
//! Field extraction and parsing utilities.
2
//!
3
//! This module provides utilities for parsing field ranges and extracting
4
//! fields from text based on delimiters.
5
6
use regex::Regex;
7
use std::cmp::{max, min};
8
use std::sync::LazyLock;
9
10
static FIELD_RANGE: LazyLock<Regex> =
11
67
    LazyLock::new(|| Regex::new(r"^(?P<left>-?\d+)?(?P<sep>\.\.)?(?P<right>-?\d+)?$").unwrap());
12
13
/// Represents a range of fields to extract from text
14
#[derive(PartialEq, Eq, Clone, Debug)]
15
pub enum FieldRange {
16
    /// A single field at the given index
17
    Single(i32),
18
    /// All fields from the start up to and including the given index
19
    LeftInf(i32),
20
    /// All fields from the given index to the end
21
    RightInf(i32),
22
    /// Fields between two indices (inclusive)
23
    Both(i32, i32),
24
}
25
26
/// Parses one side of a field range. The regex only ever hands us `-?\d+`, so the
27
/// single failure mode is overflowing `i32`; saturate instead of silently falling
28
/// back to a different field.
29
99
fn parse_index(s: &str) -> i32 {
30
99
    s.parse()
31
99
        .unwrap_or(if s.starts_with('-') { 
i32::MIN22
} else {
i32::MAX77
})
  Branch (31:23): [True: 12, False: 56]
 
  Branch (31:23): [True: 10, False: 21]
-
32
100
}
33
34
impl FieldRange {
35
    /// Parses a field range from a string (e.g., "1", "1..", "..10", "1..10")
36
    #[allow(clippy::should_implement_trait)]
37
100
    pub fn from_str(range: &str) -> Option<FieldRange> {
38
        use self::FieldRange::{Both, LeftInf, RightInf, Single};
39
40
        // "1", "1..", "..10", "1..10", etc.
41
100
        let opt_caps = FIELD_RANGE.captures(range);
42
100
        if let Some(caps) = opt_caps {
  Branch (42:16): [True: 64, False: 0]
+
32
99
}
33
34
impl FieldRange {
35
    /// Parses a field range from a string (e.g., "1", "1..", "..10", "1..10")
36
    #[allow(clippy::should_implement_trait)]
37
98
    pub fn from_str(range: &str) -> Option<FieldRange> {
38
        use self::FieldRange::{Both, LeftInf, RightInf, Single};
39
40
        // "1", "1..", "..10", "1..10", etc.
41
98
        let opt_caps = FIELD_RANGE.captures(range);
42
98
        if let Some(caps) = opt_caps {
  Branch (42:16): [True: 62, False: 0]
 
  Branch (42:16): [True: 29, False: 7]
-
43
93
            let opt_left = caps.name("left").map(|s| 
parse_index85
(
s.as_str()85
));
44
93
            let opt_right = caps.name("right").map(|s| 
parse_index15
(
s.as_str()15
));
45
93
            let opt_sep = caps.name("sep").map(|s| 
s.as_str()26
.to_string());
46
47
93
            match (opt_left, opt_right) {
48
1
                (None, None) => Some(RightInf(0)),
49
                (Some(left), None) => {
50
77
                    match opt_sep {
51
67
                        None => Some(Single(left)),      // 1
52
10
                        Some(_) => Some(RightInf(left)), // 1..
53
                    }
54
                }
55
                (None, Some(right)) => {
56
7
                    match opt_sep {
57
0
                        None => Some(Single(right)),     // 1 (should not happen)
58
7
                        Some(_) => Some(LeftInf(right)), // ..1 (should not happen)
59
                    }
60
                }
61
8
                (Some(left), Some(right)) => Some(Both(left, right)), // 1..3
62
            }
63
        } else {
64
7
            None
65
        }
66
100
    }
67
68
    /// Converts a field range to an index pair (left, right).
69
    ///
70
    /// For example, 1..3 => (0, 4). Note that field range is inclusive while
71
    /// the output index will exclude the right end.
72
    #[must_use]
73
231
    pub fn to_index_pair(&self, length: usize) -> Option<(usize, usize)> {
74
        use self::FieldRange::{Both, LeftInf, RightInf, Single};
75
231
        match *self {
76
            Single(num) => {
77
112
                let num = FieldRange::translate_neg(num, length);
78
112
                if num == 0 || 
num > length101
{
  Branch (78:20): [True: 3, False: 58]
-  Branch (78:32): [True: 6, False: 52]
+
43
91
            let opt_left = caps.name("left").map(|s| 
parse_index83
(
s.as_str()83
));
44
91
            let opt_right = caps.name("right").map(|s| 
parse_index16
(
s.as_str()16
));
45
91
            let opt_sep = caps.name("sep").map(|s| 
s.as_str()26
.to_string());
46
47
91
            match (opt_left, opt_right) {
48
1
                (None, None) => Some(RightInf(0)),
49
                (Some(left), None) => {
50
74
                    match opt_sep {
51
65
                        None => Some(Single(left)),      // 1
52
9
                        Some(_) => Some(RightInf(left)), // 1..
53
                    }
54
                }
55
                (None, Some(right)) => {
56
7
                    match opt_sep {
57
0
                        None => Some(Single(right)),     // 1 (should not happen)
58
7
                        Some(_) => Some(LeftInf(right)), // ..1 (should not happen)
59
                    }
60
                }
61
9
                (Some(left), Some(right)) => Some(Both(left, right)), // 1..3
62
            }
63
        } else {
64
7
            None
65
        }
66
98
    }
67
68
    /// Converts a field range to an index pair (left, right).
69
    ///
70
    /// For example, 1..3 => (0, 4). Note that field range is inclusive while
71
    /// the output index will exclude the right end.
72
    #[must_use]
73
229
    pub fn to_index_pair(&self, length: usize) -> Option<(usize, usize)> {
74
        use self::FieldRange::{Both, LeftInf, RightInf, Single};
75
229
        match *self {
76
            Single(num) => {
77
110
                let num = FieldRange::translate_neg(num, length);
78
110
                if num == 0 || 
num > length99
{
  Branch (78:20): [True: 3, False: 56]
+  Branch (78:32): [True: 5, False: 51]
 
  Branch (78:20): [True: 8, False: 43]
   Branch (78:32): [True: 7, False: 36]
-
79
24
                    None
80
                } else {
81
88
                    Some((num - 1, num))
82
                }
83
            }
84
            LeftInf(right) => {
85
32
                let right = FieldRange::translate_neg(right, length);
86
32
                if length == 0 || 
right == 031
{
  Branch (86:20): [True: 0, False: 2]
+
79
23
                    None
80
                } else {
81
87
                    Some((num - 1, num))
82
                }
83
            }
84
            LeftInf(right) => {
85
32
                let right = FieldRange::translate_neg(right, length);
86
32
                if length == 0 || 
right == 031
{
  Branch (86:20): [True: 0, False: 2]
   Branch (86:35): [True: 0, False: 2]
 
  Branch (86:20): [True: 1, False: 29]
   Branch (86:35): [True: 6, False: 23]
-
87
7
                    None
88
                } else {
89
25
                    let right = min(right, length);
90
25
                    Some((0, right))
91
                }
92
            }
93
            RightInf(left) => {
94
38
                let left = FieldRange::translate_neg(left, length);
95
38
                if length == 0 || 
left > length37
{
  Branch (95:20): [True: 0, False: 6]
-  Branch (95:35): [True: 0, False: 6]
+
87
7
                    None
88
                } else {
89
25
                    let right = min(right, length);
90
25
                    Some((0, right))
91
                }
92
            }
93
            RightInf(left) => {
94
37
                let left = FieldRange::translate_neg(left, length);
95
37
                if length == 0 || 
left > length36
{
  Branch (95:20): [True: 0, False: 5]
+  Branch (95:35): [True: 0, False: 5]
 
  Branch (95:20): [True: 1, False: 31]
   Branch (95:35): [True: 4, False: 27]
-
96
5
                    None
97
                } else {
98
33
                    let left = max(left, 1);
99
33
                    Some((left - 1, length))
100
                }
101
            }
102
            Both(left, right) => {
103
49
                let left = FieldRange::translate_neg(left, length);
104
49
                let right = FieldRange::translate_neg(right, length);
105
49
                if length == 0 || 
right == 048
||
left > right42
||
left > length37
{
  Branch (105:20): [True: 0, False: 5]
-  Branch (105:35): [True: 0, False: 5]
-  Branch (105:49): [True: 2, False: 3]
-  Branch (105:65): [True: 0, False: 3]
+
96
5
                    None
97
                } else {
98
32
                    let left = max(left, 1);
99
32
                    Some((left - 1, length))
100
                }
101
            }
102
            Both(left, right) => {
103
50
                let left = FieldRange::translate_neg(left, length);
104
50
                let right = FieldRange::translate_neg(right, length);
105
50
                if length == 0 || 
right == 049
||
left > right43
||
left > length38
{
  Branch (105:20): [True: 0, False: 6]
+  Branch (105:35): [True: 0, False: 6]
+  Branch (105:49): [True: 2, False: 4]
+  Branch (105:65): [True: 0, False: 4]
 
  Branch (105:20): [True: 1, False: 43]
   Branch (105:35): [True: 6, False: 37]
   Branch (105:49): [True: 3, False: 34]
   Branch (105:65): [True: 2, False: 32]
-
106
14
                    None
107
                } else {
108
35
                    Some((max(left, 1) - 1, min(right, length)))
109
                }
110
            }
111
        }
112
231
    }
113
114
280
    fn translate_neg(idx: i32, length: usize) -> usize {
115
280
        let len = i32::try_from(length).unwrap_or(i32::MAX);
116
280
        let idx = if idx < 0 { 
idx + len + 177
} else {
idx203
};
  Branch (116:22): [True: 15, False: 64]
+
106
14
                    None
107
                } else {
108
36
                    Some((max(left, 1) - 1, min(right, length)))
109
                }
110
            }
111
        }
112
229
    }
113
114
279
    fn translate_neg(idx: i32, length: usize) -> usize {
115
279
        let len = i32::try_from(length).unwrap_or(i32::MAX);
116
279
        let idx = if idx < 0 { 
idx + len + 174
} else {
idx205
};
  Branch (116:22): [True: 12, False: 66]
 
  Branch (116:22): [True: 62, False: 139]
-
117
280
        max(0, idx).unsigned_abs() as usize
118
280
    }
119
}
120
121
// ("|", "a|b||c") -> [(0, 2), (2, 4), (4, 5), (5, 6)]
122
// explain: split to ["a|", "b|", "|", "c"]
123
148
fn get_ranges_by_delimiter(delimiter: &Regex, text: &str) -> Vec<(usize, usize)> {
124
148
    let mut ranges = Vec::new();
125
148
    let mut last = 0;
126
148
    for mat in delimiter.find_iter(text) {
127
394
        ranges.push((last, mat.start()));
128
394
        last = mat.end();
129
    }
130
148
    ranges.push((last, text.len()));
131
148
    ranges
132
148
}
133
134
/// Extracts a substring from text based on a field range and delimiter.
135
///
136
/// For example, with delimiter = `Regex::new(",").unwrap()`, text "a,b,c", and field Single(2),
137
/// this returns "b". Note that this is different from `to_index_pair`, it uses delimiters.
138
#[must_use]
139
74
pub fn get_string_by_field<'a>(delimiter: &Regex, text: &'a str, field: &FieldRange) -> Option<&'a str> {
140
74
    let ranges = get_ranges_by_delimiter(delimiter, text);
141
142
74
    if let Some((start, stop)) = field.to_index_pair(ranges.len()) {
  Branch (142:12): [True: 7, False: 2]
+
117
279
        max(0, idx).unsigned_abs() as usize
118
279
    }
119
}
120
121
// ("|", "a|b||c") -> [(0, 2), (2, 4), (4, 5), (5, 6)]
122
// explain: split to ["a|", "b|", "|", "c"]
123
146
fn get_ranges_by_delimiter(delimiter: &Regex, text: &str) -> Vec<(usize, usize)> {
124
146
    let mut ranges = Vec::new();
125
146
    let mut last = 0;
126
146
    for mat in delimiter.find_iter(text) {
127
388
        ranges.push((last, mat.start()));
128
388
        last = mat.end();
129
    }
130
146
    ranges.push((last, text.len()));
131
146
    ranges
132
146
}
133
134
/// Extracts a substring from text based on a field range and delimiter.
135
///
136
/// For example, with delimiter = `Regex::new(",").unwrap()`, text "a,b,c", and field Single(2),
137
/// this returns "b". Note that this is different from `to_index_pair`, it uses delimiters.
138
#[must_use]
139
74
pub fn get_string_by_field<'a>(delimiter: &Regex, text: &'a str, field: &FieldRange) -> Option<&'a str> {
140
74
    let ranges = get_ranges_by_delimiter(delimiter, text);
141
142
74
    if let Some((start, stop)) = field.to_index_pair(ranges.len()) {
  Branch (142:12): [True: 7, False: 2]
 
  Branch (142:12): [True: 53, False: 12]
-
143
60
        let &(begin, _) = &ranges[start];
144
60
        let &(_, end) = ranges.get(stop - 1).unwrap_or(&(text.len(), 0));
145
60
        Some(&text[begin..end])
146
    } else {
147
14
        None
148
    }
149
74
}
150
151
/// Extracts a substring from text by parsing a range string and using a delimiter
152
#[must_use]
153
4
pub fn get_string_by_range<'a>(delimiter: &Regex, text: &'a str, range: &str) -> Option<&'a str> {
154
4
    FieldRange::from_str(range).and_then(|field| 
get_string_by_field3
(
delimiter3
,
text3
,
&field3
))
155
4
}
156
157
/// Parses matching fields and returns a vector of byte ranges.
158
///
159
/// Given delimiter `,`, text: "a,b,c", and fields &[Single(2), LeftInf(2)],
160
/// this returns [(2, 4), (0, 4)].
161
#[must_use]
162
47
pub fn parse_matching_fields(delimiter: &Regex, text: &str, fields: &[FieldRange]) -> Vec<(usize, usize)> {
163
47
    let ranges = get_ranges_by_delimiter(delimiter, text);
164
165
47
    let mut ret = Vec::new();
166
47
    for field in fields {
167
66
        if let Some((start, stop)) = field.to_index_pair(ranges.len()) {
  Branch (167:16): [True: 36, False: 6]
+
143
60
        let &(begin, _) = &ranges[start];
144
60
        let &(_, end) = ranges.get(stop - 1).unwrap_or(&(text.len(), 0));
145
60
        Some(&text[begin..end])
146
    } else {
147
14
        None
148
    }
149
74
}
150
151
/// Extracts a substring from text by parsing a range string and using a delimiter
152
#[must_use]
153
4
pub fn get_string_by_range<'a>(delimiter: &Regex, text: &'a str, range: &str) -> Option<&'a str> {
154
4
    FieldRange::from_str(range).and_then(|field| 
get_string_by_field3
(
delimiter3
,
text3
,
&field3
))
155
4
}
156
157
/// Parses matching fields and returns a vector of byte ranges.
158
///
159
/// Given delimiter `,`, text: "a,b,c", and fields &[Single(2), LeftInf(2)],
160
/// this returns [(2, 4), (0, 4)].
161
#[must_use]
162
45
pub fn parse_matching_fields(delimiter: &Regex, text: &str, fields: &[FieldRange]) -> Vec<(usize, usize)> {
163
45
    let ranges = get_ranges_by_delimiter(delimiter, text);
164
165
45
    let mut ret = Vec::new();
166
45
    for field in fields {
167
64
        if let Some((start, stop)) = field.to_index_pair(ranges.len()) {
  Branch (167:16): [True: 35, False: 5]
 
  Branch (167:16): [True: 21, False: 3]
-
168
57
            let &(begin, _) = &ranges[start];
169
57
            let &(end, _) = ranges.get(stop).unwrap_or(&(text.len(), 0));
170
57
            ret.push((begin, end));
171
9
        }
172
    }
173
47
    ret
174
47
}
175
176
/// Extracts the specified fields from text using the delimiter
177
#[must_use]
178
27
pub fn parse_transform_fields(delimiter: &Regex, text: &str, fields: &[FieldRange]) -> String {
179
27
    let ranges = get_ranges_by_delimiter(delimiter, text);
180
181
27
    let mut ret = String::new();
182
27
    for field in fields {
183
40
        if let Some((start, stop)) = field.to_index_pair(ranges.len()) {
  Branch (183:16): [True: 20, False: 3]
+
168
56
            let &(begin, _) = &ranges[start];
169
56
            let &(end, _) = ranges.get(stop).unwrap_or(&(text.len(), 0));
170
56
            ret.push((begin, end));
171
8
        }
172
    }
173
45
    ret
174
45
}
175
176
/// Extracts the specified fields from text using the delimiter
177
#[must_use]
178
27
pub fn parse_transform_fields(delimiter: &Regex, text: &str, fields: &[FieldRange]) -> String {
179
27
    let ranges = get_ranges_by_delimiter(delimiter, text);
180
181
27
    let mut ret = String::new();
182
27
    for field in fields {
183
40
        if let Some((start, stop)) = field.to_index_pair(ranges.len()) {
  Branch (183:16): [True: 20, False: 3]
 
  Branch (183:16): [True: 14, False: 3]
 
184
34
            let &(begin, _) = &ranges[start];
185
34
            let &(end, _) = ranges.get(stop).unwrap_or(&(text.len(), 0));
186
34
            ret.push_str(&text[begin..end]);
187
6
        }
188
    }
189
27
    ret
190
27
}
191
192
#[cfg(test)]
193
#[path = "field_tests.rs"]
194
mod test;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/algo.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/algo.rs.html index 7022c3ec..5afb37a8 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/algo.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/algo.rs.html @@ -1,4 +1,4 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/algo.rs
Line
Count
Source
1
//! Arinae's algo itself
2
3
use std::cell::RefCell;
4
5
use thread_local::ThreadLocal;
6
7
use crate::fuzzy_matcher::{IndexType, MatchIndices};
8
9
use super::banding::{BandingInfo, typo_vband_row};
10
use super::constants::{
11
    CONSECUTIVE_BONUS, GAP_EXTEND, GAP_OPEN, MATCH_BONUS, MAX_PAT_LEN, MISMATCH_PENALTY, TYPO_PENALTY,
12
};
13
use super::{Atom, CELL_ZERO, Cell, Dir, SWMatrix, Score};
14
15
/// Core cell scoring kernel shared by both score-only and full DP.
16
///
17
/// Computes the best score and direction for a single DP cell from its
18
/// three neighbours (diagonal, up, left). The caller is responsible for
19
/// fetching the neighbour values from whatever storage layout it uses.
20
///
21
/// Returns `(best_score, direction)`. The direction is `Dir::None` when
22
/// `best_score <= 0`.
23
///
24
/// This function is written in a branchless style: all scoring arithmetic
25
/// uses `bool as Score` multipliers and `max` instead of if/else, and the
26
/// final direction is selected via a branchless cascade of conditional moves.
27
#[inline(always)]
28
#[allow(clippy::too_many_arguments)]
29
#[allow(clippy::fn_params_excessive_bools)]
30
63.5k
fn compute_cell<const ALLOW_TYPOS: bool>(
31
63.5k
    is_match: bool,
32
63.5k
    is_first: bool,
33
63.5k
    bonus_j: Score,
34
63.5k
    diag_score: Score,
35
63.5k
    diag_was_diag: bool,
36
63.5k
    up_score: Score,
37
63.5k
    left_score: Score,
38
63.5k
    left_was_diag: bool,
39
63.5k
) -> (Score, Dir) {
40
    // --- Bonus (branchless) ---
41
    // consecutive bonus added when diag_was_diag, first-char multiplier doubles the bonus.
42
    // `bool as Score` is 0 or 1 — no branch.
43
63.5k
    let bonus = (bonus_j + CONSECUTIVE_BONUS * Score::from(diag_was_diag)) * (1 + Score::from(is_first));
44
45
    // --- DIAGONAL (branchless) ---
46
    // Match path: diag_score + MATCH_BONUS + bonus, masked by is_match.
47
    // Mismatch path (typos only): diag_score - MISMATCH_PENALTY, masked by !is_match.
48
63.5k
    let match_val = (diag_score + MATCH_BONUS + bonus) * Score::from(is_match);
49
63.5k
    let mismatch_val = if ALLOW_TYPOS {
  Branch (49:27): [True: 0, False: 55.8k]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/algo.rs
Line
Count
Source
1
//! Arinae's algo itself
2
3
use std::cell::RefCell;
4
5
use thread_local::ThreadLocal;
6
7
use crate::fuzzy_matcher::{IndexType, MatchIndices};
8
9
use super::banding::{BandingInfo, typo_vband_row};
10
use super::constants::{
11
    CONSECUTIVE_BONUS, GAP_EXTEND, GAP_OPEN, MATCH_BONUS, MAX_PAT_LEN, MISMATCH_PENALTY, TYPO_PENALTY,
12
};
13
use super::{Atom, CELL_ZERO, Cell, Dir, SWMatrix, Score};
14
15
/// Core cell scoring kernel shared by both score-only and full DP.
16
///
17
/// Computes the best score and direction for a single DP cell from its
18
/// three neighbours (diagonal, up, left). The caller is responsible for
19
/// fetching the neighbour values from whatever storage layout it uses.
20
///
21
/// Returns `(best_score, direction)`. The direction is `Dir::None` when
22
/// `best_score <= 0`.
23
///
24
/// This function is written in a branchless style: all scoring arithmetic
25
/// uses `bool as Score` multipliers and `max` instead of if/else, and the
26
/// final direction is selected via a branchless cascade of conditional moves.
27
#[inline(always)]
28
#[allow(clippy::too_many_arguments)]
29
#[allow(clippy::fn_params_excessive_bools)]
30
63.5k
fn compute_cell<const ALLOW_TYPOS: bool>(
31
63.5k
    is_match: bool,
32
63.5k
    is_first: bool,
33
63.5k
    bonus_j: Score,
34
63.5k
    diag_score: Score,
35
63.5k
    diag_was_diag: bool,
36
63.5k
    up_score: Score,
37
63.5k
    left_score: Score,
38
63.5k
    left_was_diag: bool,
39
63.5k
) -> (Score, Dir) {
40
    // --- Bonus (branchless) ---
41
    // consecutive bonus added when diag_was_diag, first-char multiplier doubles the bonus.
42
    // `bool as Score` is 0 or 1 — no branch.
43
63.5k
    let bonus = (bonus_j + CONSECUTIVE_BONUS * Score::from(diag_was_diag)) * (1 + Score::from(is_first));
44
45
    // --- DIAGONAL (branchless) ---
46
    // Match path: diag_score + MATCH_BONUS + bonus, masked by is_match.
47
    // Mismatch path (typos only): diag_score - MISMATCH_PENALTY, masked by !is_match.
48
63.5k
    let match_val = (diag_score + MATCH_BONUS + bonus) * Score::from(is_match);
49
63.5k
    let mismatch_val = if ALLOW_TYPOS {
  Branch (49:27): [True: 0, False: 55.8k]
 
  Branch (49:27): [True: 2.94k, False: 0]
 
  Branch (49:27): [True: 0, False: 3.18k]
 
  Branch (49:27): [True: 1.58k, False: 0]
@@ -14,7 +14,7 @@
 
  Branch (75:9): [True: 290, False: 2.65k]
 
  Branch (75:9): [True: 0, False: 0]
 
  Branch (75:9): [True: 313, False: 1.26k]
-
76
    } else {
77
59.0k
        is_match && 
diag_val >= left_val51.6k
  Branch (77:9): [True: 51.2k, False: 4.65k]
+
76
    } else {
77
59.0k
        is_match && 
diag_val >= left_val51.6k
  Branch (77:9): [True: 51.2k, False: 4.67k]
 
  Branch (77:9): [True: 0, False: 0]
 
  Branch (77:9): [True: 403, False: 2.77k]
 
  Branch (77:9): [True: 0, False: 0]
@@ -26,7 +26,7 @@
   Branch (79:34): [True: 0, False: 0]
 
  Branch (79:19): [True: 1.58k, False: 0]
   Branch (79:34): [True: 1.36k, False: 216]
-
80
81
    // Branchless cascade: select dir as integer.
82
    // Dir encoding: None=0, Diag=1, Up=2, Left=3.
83
    // Base is Left(3); subtract 1 if Up wins, subtract 2 if Diag wins.
84
63.5k
    let dir_bits: u8 = Dir::Left as u8 - u8::from(up_wins) - u8::from(diag_wins) * 2;
85
    // If best <= 0, force Dir::None (0) — achieved by ANDing with all-zeros.
86
63.5k
    let positive = best > 0;
87
    // When positive: dir_bits; when not: 0 (Dir::None).
88
63.5k
    let dir_val = dir_bits & u8::from(positive).wrapping_neg();
89
90
    // SAFETY: dir_val is in 0..=3 because of the construction above.
91
63.5k
    let dir: Dir = unsafe { std::mem::transmute(dir_val) };
92
93
63.5k
    (best, dir)
94
63.5k
}
95
96
// ---------------------------------------------------------------------------
97
// Full DP with traceback — packed Cell (u32 = score + dir)
98
// ---------------------------------------------------------------------------
99
100
/// Full DP for byte slices using packed cells.
101
///
102
/// Implements two pruning strategies:
103
///
104
/// 1. **Row-range banding** – for each row `i` only compute columns
105
///    `j_lo..=j_hi` that can participate in a valid alignment.
106
///    - Exact mode: bounded by precomputed first/last match columns.
107
///    - Typo mode: bounded by diagonal ± bandwidth.
108
///
109
/// 2. **Interpair max-score pruning** – after processing a row, if no
110
///    column produced a non-zero score, all active alignments for this
111
///    and subsequent rows are dead (since UP/LEFT can only propagate
112
///    existing scores). We track this and allow early termination.
113
#[allow(clippy::too_many_lines)]
114
#[allow(clippy::too_many_arguments)]
115
50.6k
pub(super) fn full_dp<const ALLOW_TYPOS: bool, const COMPUTE_INDICES: bool, C: Atom>(
116
50.6k
    cho: &[C],
117
50.6k
    pat: &[C],
118
50.6k
    bonuses: &[Score],
119
50.6k
    respect_case: bool,
120
50.6k
    full_buf: &ThreadLocal<RefCell<SWMatrix>>,
121
50.6k
    indices_buf: &ThreadLocal<RefCell<MatchIndices>>,
122
50.6k
    use_last_match: bool,
123
50.6k
    banding: &BandingInfo,
124
50.6k
) -> Option<(Score, MatchIndices)> {
125
50.6k
    let n = pat.len();
126
50.6k
    let m = cho.len();
127
128
50.6k
    let j_start = banding.j_first; // earliest match — skip columns before this
129
130
    // Column offset: the matrix stores only columns from j_start onward.
131
    // Matrix column 0 is the left wall (all zeros); matrix column `jm`
132
    // corresponds to original 1-indexed column `j = jm + j_start - 1`.
133
50.6k
    let col_off = j_start - 1; // subtract from original j to get matrix col
134
50.6k
    let mcols = m - col_off + 1; // matrix columns: 0 ..= (m - col_off)
135
136
50.6k
    let mut buf = full_buf
137
353
        .get_or(|| RefCell::new(SWMatrix::zero(n + 1, mcols)))
138
        .borrow_mut();
139
50.6k
    buf.resize(n + 1, mcols);
140
141
    // Hoist pointer and stride before initialization to use raw access.
142
50.6k
    let base_ptr = buf.data.as_mut_ptr();
143
50.6k
    let cols = buf.cols;
144
145
    // Initialize row 0 to CELL_ZERO (all-zero bytes: score=0, dir=None=0).
146
    // Column 0 of each subsequent row is also CELL_ZERO.
147
    // SAFETY: base_ptr points to a valid allocation of (n+1)*cols Cells.
148
    unsafe {
149
        // Row 0: mcols contiguous Cells starting at base_ptr.
150
50.6k
        std::ptr::write_bytes(base_ptr, 0, mcols);
151
        // Column 0 of rows 1..=n: one Cell per row, stride = cols.
152
50.6k
        for i in 1..=n {
153
51.5k
            *base_ptr.add(i * cols) = CELL_ZERO;
154
        }
155
    }
156
157
    // base_ptr and cols already set above
158
159
    // Pre-extract row bounds once (avoids repeated unwrap inside the loop).
160
    // For exact mode we copy the arrays out; for typo mode these are unused.
161
50.6k
    let (row_lo_arr, row_hi_arr) = if ALLOW_TYPOS {
  Branch (161:39): [True: 0, False: 0]
+
80
81
    // Branchless cascade: select dir as integer.
82
    // Dir encoding: None=0, Diag=1, Up=2, Left=3.
83
    // Base is Left(3); subtract 1 if Up wins, subtract 2 if Diag wins.
84
63.5k
    let dir_bits: u8 = Dir::Left as u8 - u8::from(up_wins) - u8::from(diag_wins) * 2;
85
    // If best <= 0, force Dir::None (0) — achieved by ANDing with all-zeros.
86
63.5k
    let positive = best > 0;
87
    // When positive: dir_bits; when not: 0 (Dir::None).
88
63.5k
    let dir_val = dir_bits & u8::from(positive).wrapping_neg();
89
90
    // SAFETY: dir_val is in 0..=3 because of the construction above.
91
63.5k
    let dir: Dir = unsafe { std::mem::transmute(dir_val) };
92
93
63.5k
    (best, dir)
94
63.5k
}
95
96
// ---------------------------------------------------------------------------
97
// Full DP with traceback — packed Cell (u32 = score + dir)
98
// ---------------------------------------------------------------------------
99
100
/// Full DP for byte slices using packed cells.
101
///
102
/// Implements two pruning strategies:
103
///
104
/// 1. **Row-range banding** – for each row `i` only compute columns
105
///    `j_lo..=j_hi` that can participate in a valid alignment.
106
///    - Exact mode: bounded by precomputed first/last match columns.
107
///    - Typo mode: bounded by diagonal ± bandwidth.
108
///
109
/// 2. **Interpair max-score pruning** – after processing a row, if no
110
///    column produced a non-zero score, all active alignments for this
111
///    and subsequent rows are dead (since UP/LEFT can only propagate
112
///    existing scores). We track this and allow early termination.
113
#[allow(clippy::too_many_lines)]
114
#[allow(clippy::too_many_arguments)]
115
50.6k
pub(super) fn full_dp<const ALLOW_TYPOS: bool, const COMPUTE_INDICES: bool, C: Atom>(
116
50.6k
    cho: &[C],
117
50.6k
    pat: &[C],
118
50.6k
    bonuses: &[Score],
119
50.6k
    respect_case: bool,
120
50.6k
    full_buf: &ThreadLocal<RefCell<SWMatrix>>,
121
50.6k
    indices_buf: &ThreadLocal<RefCell<MatchIndices>>,
122
50.6k
    use_last_match: bool,
123
50.6k
    banding: &BandingInfo,
124
50.6k
) -> Option<(Score, MatchIndices)> {
125
50.6k
    let n = pat.len();
126
50.6k
    let m = cho.len();
127
128
50.6k
    let j_start = banding.j_first; // earliest match — skip columns before this
129
130
    // Column offset: the matrix stores only columns from j_start onward.
131
    // Matrix column 0 is the left wall (all zeros); matrix column `jm`
132
    // corresponds to original 1-indexed column `j = jm + j_start - 1`.
133
50.6k
    let col_off = j_start - 1; // subtract from original j to get matrix col
134
50.6k
    let mcols = m - col_off + 1; // matrix columns: 0 ..= (m - col_off)
135
136
50.6k
    let mut buf = full_buf
137
356
        .get_or(|| RefCell::new(SWMatrix::zero(n + 1, mcols)))
138
        .borrow_mut();
139
50.6k
    buf.resize(n + 1, mcols);
140
141
    // Hoist pointer and stride before initialization to use raw access.
142
50.6k
    let base_ptr = buf.data.as_mut_ptr();
143
50.6k
    let cols = buf.cols;
144
145
    // Initialize row 0 to CELL_ZERO (all-zero bytes: score=0, dir=None=0).
146
    // Column 0 of each subsequent row is also CELL_ZERO.
147
    // SAFETY: base_ptr points to a valid allocation of (n+1)*cols Cells.
148
    unsafe {
149
        // Row 0: mcols contiguous Cells starting at base_ptr.
150
50.6k
        std::ptr::write_bytes(base_ptr, 0, mcols);
151
        // Column 0 of rows 1..=n: one Cell per row, stride = cols.
152
50.6k
        for i in 1..=n {
153
51.5k
            *base_ptr.add(i * cols) = CELL_ZERO;
154
        }
155
    }
156
157
    // base_ptr and cols already set above
158
159
    // Pre-extract row bounds once (avoids repeated unwrap inside the loop).
160
    // For exact mode we copy the arrays out; for typo mode these are unused.
161
50.6k
    let (row_lo_arr, row_hi_arr) = if ALLOW_TYPOS {
  Branch (161:39): [True: 0, False: 0]
 
  Branch (161:39): [True: 0, False: 0]
 
  Branch (161:39): [True: 0, False: 38]
 
  Branch (161:39): [True: 0, False: 50.4k]
@@ -141,7 +141,7 @@
 
191
2
                    let next_mat_lo = nj_lo - col_off;
192
2
                    let next_mat_hi = (nj_hi - col_off).min(mcols - 1);
193
                    // Diag reads jm-1, Up reads jm → need [next_mat_lo-1 .. next_mat_hi].
194
2
                    let zero_lo = next_mat_lo.saturating_sub(1);
195
2
                    let zero_hi = next_mat_hi.min(mcols - 1);
196
                    // SAFETY: row i is within the allocated matrix.
197
                    unsafe {
198
2
                        let row_ptr = base_ptr.add(i * cols);
199
2
                        for k in zero_lo..=zero_hi {
200
4
                            *row_ptr.add(k) = CELL_ZERO;
201
                        }
202
                    }
203
2
                }
204
3
            }
205
7
            continue;
206
51.5k
        }
207
208
        // Convert to matrix-local column indices (safe: j_lo >= j_start here).
209
51.5k
        let mat_col_lo = j_lo - col_off;
210
51.5k
        let mat_col_hi = j_hi - col_off;
211
51.5k
        let jm_max = mcols - 1; // last valid matrix column
212
213
        // Zero only the boundary cells that Diag/Left/Up moves will read:
214
        // - Cell at mat_col_lo-1: read by Left at mat_col_lo and Diag from next row.
215
        // - Cell at mat_col_hi+1: read by Up from next row at mat_col_hi+1 (if in next band).
216
        // SAFETY: indices are within the row's allocation.
217
        unsafe {
218
51.5k
            let row_ptr = base_ptr.add(i * cols);
219
51.5k
            if mat_col_lo > 1 {
  Branch (219:16): [True: 0, False: 0]
 
  Branch (219:16): [True: 0, False: 0]
 
  Branch (219:16): [True: 66, False: 38]
-
  Branch (219:16): [True: 407, False: 50.4k]
+
  Branch (219:16): [True: 410, False: 50.4k]
 
  Branch (219:16): [True: 0, False: 0]
 
  Branch (219:16): [True: 0, False: 164]
 
  Branch (219:16): [True: 0, False: 0]
@@ -154,7 +154,7 @@
 
  Branch (219:16): [True: 0, False: 55]
 
  Branch (219:16): [True: 0, False: 0]
 
  Branch (219:16): [True: 1, False: 59]
-
220
638
                *row_ptr.add(mat_col_lo - 1) = CELL_ZERO;
221
50.8k
            }
222
51.5k
            if mat_col_hi < jm_max {
  Branch (222:16): [True: 0, False: 0]
+
220
641
                *row_ptr.add(mat_col_lo - 1) = CELL_ZERO;
221
50.8k
            }
222
51.5k
            if mat_col_hi < jm_max {
  Branch (222:16): [True: 0, False: 0]
 
  Branch (222:16): [True: 0, False: 0]
 
  Branch (222:16): [True: 0, False: 104]
 
  Branch (222:16): [True: 0, False: 50.8k]
@@ -170,10 +170,10 @@
 
  Branch (222:16): [True: 0, False: 55]
 
  Branch (222:16): [True: 0, False: 0]
 
  Branch (222:16): [True: 0, False: 60]
-
223
0
                *row_ptr.add(mat_col_hi + 1) = CELL_ZERO;
224
51.5k
            }
225
        }
226
227
        // Get prev_row as immutable slice, cur_row as mutable slice.
228
        // SAFETY: i >= 1 so rows i-1 and i are distinct; each row is
229
        // cols-aligned inside the contiguous data vec. base_ptr/cols are
230
        // hoisted outside the loop.
231
        let (prev_row, cur_row) = unsafe {
232
51.5k
            let pr = std::slice::from_raw_parts(base_ptr.add((i - 1) * cols), cols);
233
51.5k
            let cr = std::slice::from_raw_parts_mut(base_ptr.add(i * cols), cols);
234
51.5k
            (pr, cr)
235
        };
236
237
        // Hoist raw pointers for unchecked access inside the hot loop.
238
51.5k
        let prev_ptr = prev_row.as_ptr();
239
51.5k
        let cur_ptr = cur_row.as_mut_ptr();
240
241
51.5k
        for j in j_lo..=j_hi {
242
63.0k
            let jm = j - col_off; // matrix column
243
            // SAFETY: j and jm are inside the band and within array bounds.
244
63.0k
            let cj = unsafe { *cho_ptr.add(j - 1) };
245
63.0k
            let is_match = pi.eq(cj, respect_case);
246
247
            // Fetch neighbour values from the matrix.
248
63.0k
            let diag_cell = unsafe { *prev_ptr.add(jm - 1) };
249
63.0k
            let up_score = if ALLOW_TYPOS {
  Branch (249:31): [True: 0, False: 0]
+
223
0
                *row_ptr.add(mat_col_hi + 1) = CELL_ZERO;
224
51.5k
            }
225
        }
226
227
        // Get prev_row as immutable slice, cur_row as mutable slice.
228
        // SAFETY: i >= 1 so rows i-1 and i are distinct; each row is
229
        // cols-aligned inside the contiguous data vec. base_ptr/cols are
230
        // hoisted outside the loop.
231
        let (prev_row, cur_row) = unsafe {
232
51.5k
            let pr = std::slice::from_raw_parts(base_ptr.add((i - 1) * cols), cols);
233
51.5k
            let cr = std::slice::from_raw_parts_mut(base_ptr.add(i * cols), cols);
234
51.5k
            (pr, cr)
235
        };
236
237
        // Hoist raw pointers for unchecked access inside the hot loop.
238
51.5k
        let prev_ptr = prev_row.as_ptr();
239
51.5k
        let cur_ptr = cur_row.as_mut_ptr();
240
241
51.5k
        for j in j_lo..=j_hi {
242
63.1k
            let jm = j - col_off; // matrix column
243
            // SAFETY: j and jm are inside the band and within array bounds.
244
63.1k
            let cj = unsafe { *cho_ptr.add(j - 1) };
245
63.1k
            let is_match = pi.eq(cj, respect_case);
246
247
            // Fetch neighbour values from the matrix.
248
63.1k
            let diag_cell = unsafe { *prev_ptr.add(jm - 1) };
249
63.1k
            let up_score = if ALLOW_TYPOS {
  Branch (249:31): [True: 0, False: 0]
 
  Branch (249:31): [True: 0, False: 0]
 
  Branch (249:31): [True: 0, False: 382]
-
  Branch (249:31): [True: 0, False: 55.4k]
+
  Branch (249:31): [True: 0, False: 55.5k]
 
  Branch (249:31): [True: 0, False: 0]
 
  Branch (249:31): [True: 2.94k, False: 0]
 
  Branch (249:31): [True: 0, False: 0]
@@ -186,7 +186,7 @@
 
  Branch (249:31): [True: 442, False: 0]
 
  Branch (249:31): [True: 0, False: 0]
 
  Branch (249:31): [True: 657, False: 0]
-
250
4.16k
                let up_cell = unsafe { *prev_ptr.add(jm) };
251
4.16k
                up_cell.score()
252
            } else {
253
58.9k
                0
254
            };
255
63.0k
            let left_cell = unsafe { *cur_ptr.add(jm - 1) };
256
257
63.0k
            let (best, dir) = compute_cell::<ALLOW_TYPOS>(
258
63.0k
                is_match,
259
63.0k
                is_first,
260
63.0k
                unsafe { *bonuses_ptr.add(j - 1) },
261
63.0k
                diag_cell.score(),
262
63.0k
                diag_cell.is_diag(),
263
63.0k
                up_score,
264
63.0k
                left_cell.score(),
265
63.0k
                left_cell.is_diag(),
266
63.0k
            );
267
268
            unsafe {
269
63.0k
                *cur_ptr.add(jm) = Cell::new(best, dir);
270
            }
271
        }
272
    }
273
274
    // --- Find best score in the last row (row n) ---
275
    // Moved out of the inner loop to eliminate the `i == n` branch per cell.
276
50.6k
    let mut best_score: Score = 0;
277
50.6k
    let mut best_j = 0usize; // stored in original 1-indexed space
278
    {
279
50.6k
        let (last_j_lo_raw, last_j_hi) = if ALLOW_TYPOS {
  Branch (279:45): [True: 0, False: 0]
+
250
4.16k
                let up_cell = unsafe { *prev_ptr.add(jm) };
251
4.16k
                up_cell.score()
252
            } else {
253
58.9k
                0
254
            };
255
63.1k
            let left_cell = unsafe { *cur_ptr.add(jm - 1) };
256
257
63.1k
            let (best, dir) = compute_cell::<ALLOW_TYPOS>(
258
63.1k
                is_match,
259
63.1k
                is_first,
260
63.1k
                unsafe { *bonuses_ptr.add(j - 1) },
261
63.1k
                diag_cell.score(),
262
63.1k
                diag_cell.is_diag(),
263
63.1k
                up_score,
264
63.1k
                left_cell.score(),
265
63.1k
                left_cell.is_diag(),
266
63.1k
            );
267
268
            unsafe {
269
63.1k
                *cur_ptr.add(jm) = Cell::new(best, dir);
270
            }
271
        }
272
    }
273
274
    // --- Find best score in the last row (row n) ---
275
    // Moved out of the inner loop to eliminate the `i == n` branch per cell.
276
50.6k
    let mut best_score: Score = 0;
277
50.6k
    let mut best_j = 0usize; // stored in original 1-indexed space
278
    {
279
50.6k
        let (last_j_lo_raw, last_j_hi) = if ALLOW_TYPOS {
  Branch (279:45): [True: 0, False: 0]
 
  Branch (279:45): [True: 0, False: 0]
 
  Branch (279:45): [True: 0, False: 38]
 
  Branch (279:45): [True: 0, False: 50.4k]
@@ -266,10 +266,10 @@
 
  Branch (294:29): [True: 0, False: 0]
 
  Branch (294:29): [True: 0, False: 0]
 
  Branch (294:29): [True: 0, False: 0]
-
295
            }
296
        } else {
297
50.6k
            for j in last_j_lo..=last_j_hi {
298
51.8k
                let jm = j - col_off;
299
51.8k
                let s = unsafe { (*last_row_ptr.add(jm)).score() };
300
                // Branchless max: update best_score and best_j together.
301
51.8k
                let better = s > best_score;
302
                // Use conditional moves instead of a branch.
303
51.8k
                best_score = if better { 
s50.7k
} else {
best_score1.03k
};
  Branch (303:33): [True: 0, False: 0]
+
295
            }
296
        } else {
297
50.6k
            for j in last_j_lo..=last_j_hi {
298
51.8k
                let jm = j - col_off;
299
51.8k
                let s = unsafe { (*last_row_ptr.add(jm)).score() };
300
                // Branchless max: update best_score and best_j together.
301
51.8k
                let better = s > best_score;
302
                // Use conditional moves instead of a branch.
303
51.8k
                best_score = if better { 
s50.7k
} else {
best_score1.04k
};
  Branch (303:33): [True: 0, False: 0]
 
  Branch (303:33): [True: 0, False: 0]
 
  Branch (303:33): [True: 37, False: 12]
-
  Branch (303:33): [True: 50.4k, False: 162]
+
  Branch (303:33): [True: 50.4k, False: 168]
 
  Branch (303:33): [True: 0, False: 0]
 
  Branch (303:33): [True: 101, False: 634]
 
  Branch (303:33): [True: 0, False: 0]
@@ -282,10 +282,10 @@
 
  Branch (303:33): [True: 52, False: 66]
 
  Branch (303:33): [True: 0, False: 0]
 
  Branch (303:33): [True: 50, False: 76]
-
304
51.8k
                best_j = if better { 
j50.7k
} else {
best_j1.03k
};
  Branch (304:29): [True: 0, False: 0]
+
304
51.8k
                best_j = if better { 
j50.7k
} else {
best_j1.04k
};
  Branch (304:29): [True: 0, False: 0]
 
  Branch (304:29): [True: 0, False: 0]
 
  Branch (304:29): [True: 37, False: 12]
-
  Branch (304:29): [True: 50.4k, False: 162]
+
  Branch (304:29): [True: 50.4k, False: 168]
 
  Branch (304:29): [True: 0, False: 0]
 
  Branch (304:29): [True: 101, False: 634]
 
  Branch (304:29): [True: 0, False: 0]
@@ -330,7 +330,7 @@
 
  Branch (313:8): [True: 15, False: 0]
 
  Branch (313:8): [True: 0, False: 0]
 
  Branch (313:8): [True: 0, False: 13]
-
314
        // Traceback — j walks in original 1-indexed space, convert to matrix
315
        // column for buf access; output indices in original 0-indexed space.
316
        // Reuse a thread-local Vec to avoid per-call allocation.
317
50.6k
        let indices_ref_cell = indices_buf.get_or(|| 
RefCell::new321
(
Vec::new()321
));
318
50.6k
        let mut indices_ref = indices_ref_cell.borrow_mut();
319
50.6k
        indices_ref.clear();
320
50.6k
        let mut i = n;
321
50.6k
        let mut j = best_j;
322
50.6k
        let mut true_matches = 0usize;
323
324
102k
        while i > 0 && 
j >= j_start51.5k
{
  Branch (324:15): [True: 0, False: 0]
+
314
        // Traceback — j walks in original 1-indexed space, convert to matrix
315
        // column for buf access; output indices in original 0-indexed space.
316
        // Reuse a thread-local Vec to avoid per-call allocation.
317
50.6k
        let indices_ref_cell = indices_buf.get_or(|| 
RefCell::new324
(
Vec::new()324
));
318
50.6k
        let mut indices_ref = indices_ref_cell.borrow_mut();
319
50.6k
        indices_ref.clear();
320
50.6k
        let mut i = n;
321
50.6k
        let mut j = best_j;
322
50.6k
        let mut true_matches = 0usize;
323
324
102k
        while i > 0 && 
j >= j_start51.5k
{
  Branch (324:15): [True: 0, False: 0]
   Branch (324:24): [True: 0, False: 0]
 
  Branch (324:15): [True: 0, False: 0]
   Branch (324:24): [True: 0, False: 0]
@@ -378,7 +378,7 @@
 
  Branch (330:24): [True: 48, False: 0]
 
  Branch (330:24): [True: 0, False: 0]
 
  Branch (330:24): [True: 0, False: 0]
-
331
51.2k
                        indices_ref.push((j - 1) as IndexType);
332
51.2k
                        true_matches += 1;
333
0
                    }
334
51.2k
                    i -= 1;
335
51.2k
                    j -= 1;
336
                }
337
                Dir::Up => {
338
57
                    i -= 1;
339
                }
340
                Dir::Left => {
341
273
                    j -= 1;
342
                }
343
11
                Dir::None => break,
344
            }
345
        }
346
347
50.6k
        if true_matches < banding.min_true_matches {
  Branch (347:12): [True: 0, False: 0]
+
331
51.2k
                        indices_ref.push((j - 1) as IndexType);
332
51.2k
                        true_matches += 1;
333
0
                    }
334
51.2k
                    i -= 1;
335
51.2k
                    j -= 1;
336
                }
337
                Dir::Up => {
338
57
                    i -= 1;
339
                }
340
                Dir::Left => {
341
276
                    j -= 1;
342
                }
343
11
                Dir::None => break,
344
            }
345
        }
346
347
50.6k
        if true_matches < banding.min_true_matches {
  Branch (347:12): [True: 0, False: 0]
 
  Branch (347:12): [True: 0, False: 0]
 
  Branch (347:12): [True: 0, False: 38]
 
  Branch (347:12): [True: 0, False: 50.4k]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/atom.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/atom.rs.html
index c6195ab3..812cdda3 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/atom.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/atom.rs.html
@@ -1,6 +1,6 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/atom.rs
Line
Count
Source
1
//! Byte/Char helpers
2
use super::Score;
3
use super::constants::SEPARATOR_TABLE;
4
use crate::fuzzy_matcher::util::char_equal;
5
use memchr::{memchr, memrchr};
6
7
pub(super) trait Atom: PartialEq + Into<char> + Copy {
8
    #[inline(always)]
9
725k
    fn eq(self, other: Self, respect_case: bool) -> bool
10
725k
    where
11
725k
        Self: PartialEq + Sized,
12
    {
13
725k
        if respect_case {
  Branch (13:12): [True: 1.07k, False: 718k]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/atom.rs
Line
Count
Source
1
//! Byte/Char helpers
2
use super::Score;
3
use super::constants::SEPARATOR_TABLE;
4
use crate::fuzzy_matcher::util::char_equal;
5
use memchr::{memchr, memrchr};
6
7
pub(super) trait Atom: PartialEq + Into<char> + Copy {
8
    #[inline(always)]
9
725k
    fn eq(self, other: Self, respect_case: bool) -> bool
10
725k
    where
11
725k
        Self: PartialEq + Sized,
12
    {
13
725k
        if respect_case {
  Branch (13:12): [True: 1.07k, False: 718k]
 
  Branch (13:12): [True: 122, False: 6.23k]
-
14
1.19k
            self == other
15
        } else {
16
724k
            self.eq_ignore_case(other)
17
        }
18
725k
    }
19
    fn eq_ignore_case(self, other: Self) -> bool;
20
    fn is_lowercase(self) -> bool;
21
22
    /// Return the index of the first occurrence of `self` in `haystack`,
23
    /// or `None` if not found.
24
    ///
25
    /// Implementations may override this with a SIMD-backed search (e.g.
26
    /// `memchr` for `u8` in case-sensitive mode).
27
    #[inline(always)]
28
18
    fn find_first_in(self, haystack: &[Self], respect_case: bool) -> Option<usize> {
29
26
        
haystack.iter()18
.position(|&c| self.eq(c, respect_case))
30
18
    }
31
32
    /// Return the index of the last occurrence of `self` in `haystack`,
33
    /// or `None` if not found.
34
    ///
35
    /// Implementations may override this with a SIMD-backed search (e.g.
36
    /// `memrchr` for `u8` in case-sensitive mode).
37
    #[inline(always)]
38
131
    fn find_last_in(self, haystack: &[Self], respect_case: bool) -> Option<usize> {
39
275
        
haystack.iter()131
.rposition(|&c| self.eq(c, respect_case))
40
131
    }
41
42
    /// Return the word-separator bonus for this character, or `0` if it is not
43
    /// a separator.  Uses a table lookup — a single bounds check replaces
44
    /// several branches and the returned value encodes both *whether* the
45
    /// character is a separator and *how much* bonus it carries.
46
    #[inline(always)]
47
558k
    fn separator_bonus(self) -> Score {
48
558k
        let ch = self.into() as usize;
49
        // For ch < 128 we do a table lookup; for ch >= 128 we return 0.
50
        // The `get` returns None for out-of-range, and `copied().unwrap_or(0)` is
51
        // typically compiled as a conditional move (branchless).
52
558k
        SEPARATOR_TABLE.get(ch).copied().unwrap_or(0)
53
558k
    }
54
}
55
56
impl Atom for u8 {
57
    #[inline(always)]
58
724k
    fn eq_ignore_case(self, b: Self) -> bool {
59
724k
        self.eq_ignore_ascii_case(&b)
60
724k
    }
61
    #[inline(always)]
62
819k
    fn is_lowercase(self) -> bool {
63
819k
        self.is_ascii_lowercase()
64
819k
    }
65
66
    /// Case-sensitive search uses SIMD-backed `memchr`; case-insensitive
67
    /// falls back to the generic scalar loop.
68
    #[inline(always)]
69
170
    fn find_first_in(self, haystack: &[Self], respect_case: bool) -> Option<usize> {
70
170
        if respect_case {
  Branch (70:12): [True: 0, False: 90]
+
14
1.19k
            self == other
15
        } else {
16
724k
            self.eq_ignore_case(other)
17
        }
18
725k
    }
19
    fn eq_ignore_case(self, other: Self) -> bool;
20
    fn is_lowercase(self) -> bool;
21
22
    /// Return the index of the first occurrence of `self` in `haystack`,
23
    /// or `None` if not found.
24
    ///
25
    /// Implementations may override this with a SIMD-backed search (e.g.
26
    /// `memchr` for `u8` in case-sensitive mode).
27
    #[inline(always)]
28
18
    fn find_first_in(self, haystack: &[Self], respect_case: bool) -> Option<usize> {
29
26
        
haystack.iter()18
.position(|&c| self.eq(c, respect_case))
30
18
    }
31
32
    /// Return the index of the last occurrence of `self` in `haystack`,
33
    /// or `None` if not found.
34
    ///
35
    /// Implementations may override this with a SIMD-backed search (e.g.
36
    /// `memrchr` for `u8` in case-sensitive mode).
37
    #[inline(always)]
38
131
    fn find_last_in(self, haystack: &[Self], respect_case: bool) -> Option<usize> {
39
275
        
haystack.iter()131
.rposition(|&c| self.eq(c, respect_case))
40
131
    }
41
42
    /// Return the word-separator bonus for this character, or `0` if it is not
43
    /// a separator.  Uses a table lookup — a single bounds check replaces
44
    /// several branches and the returned value encodes both *whether* the
45
    /// character is a separator and *how much* bonus it carries.
46
    #[inline(always)]
47
559k
    fn separator_bonus(self) -> Score {
48
559k
        let ch = self.into() as usize;
49
        // For ch < 128 we do a table lookup; for ch >= 128 we return 0.
50
        // The `get` returns None for out-of-range, and `copied().unwrap_or(0)` is
51
        // typically compiled as a conditional move (branchless).
52
559k
        SEPARATOR_TABLE.get(ch).copied().unwrap_or(0)
53
559k
    }
54
}
55
56
impl Atom for u8 {
57
    #[inline(always)]
58
724k
    fn eq_ignore_case(self, b: Self) -> bool {
59
724k
        self.eq_ignore_ascii_case(&b)
60
724k
    }
61
    #[inline(always)]
62
819k
    fn is_lowercase(self) -> bool {
63
819k
        self.is_ascii_lowercase()
64
819k
    }
65
66
    /// Case-sensitive search uses SIMD-backed `memchr`; case-insensitive
67
    /// falls back to the generic scalar loop.
68
    #[inline(always)]
69
170
    fn find_first_in(self, haystack: &[Self], respect_case: bool) -> Option<usize> {
70
170
        if respect_case {
  Branch (70:12): [True: 0, False: 90]
 
  Branch (70:12): [True: 2, False: 78]
 
71
2
            memchr(self, haystack)
72
        } else {
73
            // Case-insensitive: compare lowercase. Also try the uppercase variant
74
            // so a single `memchr` can be used for each case variant.
75
168
            let lo = self.to_ascii_lowercase();
76
168
            let hi = self.to_ascii_uppercase();
77
168
            if lo == hi {
  Branch (77:16): [True: 0, False: 90]
 
  Branch (77:16): [True: 2, False: 76]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/banding.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/banding.rs.html
index d166adcb..1784435f 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/banding.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/banding.rs.html
@@ -1,12 +1,12 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/banding.rs
Line
Count
Source
1
//! Banding utils
2
//! Banding is the process of calculating the pertinent parts of the matrix to our specific
3
//! computation to avoid computing every cell
4
5
use super::atom::Atom;
6
use super::constants::{MAX_PAT_LEN, TYPO_BAND_SLACK};
7
use super::helpers::{compute_last_match_cols, compute_row_col_bounds, find_first_char};
8
9
/// Precomputed banding information shared by both score-only and full DP.
10
#[derive(Clone)]
11
pub(super) struct BandingInfo {
12
    /// Per-row column bounds (only present in exact mode).
13
    pub(super) row_bounds: Option<([usize; MAX_PAT_LEN], [usize; MAX_PAT_LEN])>,
14
    /// 1-indexed column of the first match of `pat[0]` in `cho`.
15
    pub(super) j_first: usize,
16
    /// Bandwidth for typo-mode diagonal banding (0 in exact mode).
17
    pub(super) bandwidth: usize,
18
    /// Minimum number of true (non-substitution) matches to accept.
19
    pub(super) min_true_matches: usize,
20
}
21
22
/// Compute banding information for the DP. Returns `None` if the pattern
23
/// cannot possibly match (e.g. a pattern character has no occurrence).
24
51.3k
pub(super) fn compute_banding<const ALLOW_TYPOS: bool, C: Atom>(
25
51.3k
    pat: &[C],
26
51.3k
    cho: &[C],
27
51.3k
    respect_case: bool,
28
51.3k
) -> Option<BandingInfo> {
29
51.3k
    let n = pat.len();
30
51.3k
    let m = cho.len();
31
32
51.3k
    let (j_first, row_bounds) = if ALLOW_TYPOS {
  Branch (32:36): [True: 0, False: 55]
-
  Branch (32:36): [True: 0, False: 51.0k]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/banding.rs
Line
Count
Source
1
//! Banding utils
2
//! Banding is the process of calculating the pertinent parts of the matrix to our specific
3
//! computation to avoid computing every cell
4
5
use super::atom::Atom;
6
use super::constants::{MAX_PAT_LEN, TYPO_BAND_SLACK};
7
use super::helpers::{compute_last_match_cols, compute_row_col_bounds, find_first_char};
8
9
/// Precomputed banding information shared by both score-only and full DP.
10
#[derive(Clone)]
11
pub(super) struct BandingInfo {
12
    /// Per-row column bounds (only present in exact mode).
13
    pub(super) row_bounds: Option<([usize; MAX_PAT_LEN], [usize; MAX_PAT_LEN])>,
14
    /// 1-indexed column of the first match of `pat[0]` in `cho`.
15
    pub(super) j_first: usize,
16
    /// Bandwidth for typo-mode diagonal banding (0 in exact mode).
17
    pub(super) bandwidth: usize,
18
    /// Minimum number of true (non-substitution) matches to accept.
19
    pub(super) min_true_matches: usize,
20
}
21
22
/// Compute banding information for the DP. Returns `None` if the pattern
23
/// cannot possibly match (e.g. a pattern character has no occurrence).
24
51.3k
pub(super) fn compute_banding<const ALLOW_TYPOS: bool, C: Atom>(
25
51.3k
    pat: &[C],
26
51.3k
    cho: &[C],
27
51.3k
    respect_case: bool,
28
51.3k
) -> Option<BandingInfo> {
29
51.3k
    let n = pat.len();
30
51.3k
    let m = cho.len();
31
32
51.3k
    let (j_first, row_bounds) = if ALLOW_TYPOS {
  Branch (32:36): [True: 0, False: 55]
+
  Branch (32:36): [True: 0, False: 51.1k]
 
  Branch (32:36): [True: 0, False: 0]
 
  Branch (32:36): [True: 41, False: 0]
 
  Branch (32:36): [True: 0, False: 15]
 
  Branch (32:36): [True: 0, False: 75]
 
  Branch (32:36): [True: 8, False: 0]
 
  Branch (32:36): [True: 35, False: 0]
-
33
84
        (find_first_char(pat, cho, respect_case)
?0
, None)
34
    } else {
35
51.2k
        let fm = compute_first_match_cols(pat, cho, respect_case)
?648
;
36
50.5k
        let lm = compute_last_match_cols(pat, cho, respect_case)
?0
;
37
50.5k
        (fm[0], Some(compute_row_col_bounds(n, m, &fm, &lm)))
38
    };
39
40
50.6k
    let bandwidth = if ALLOW_TYPOS { 
n + TYPO_BAND_SLACK84
} else {
050.5k
};
  Branch (40:24): [True: 0, False: 38]
+
33
84
        (find_first_char(pat, cho, respect_case)
?0
, None)
34
    } else {
35
51.2k
        let fm = compute_first_match_cols(pat, cho, respect_case)
?652
;
36
50.5k
        let lm = compute_last_match_cols(pat, cho, respect_case)
?0
;
37
50.5k
        (fm[0], Some(compute_row_col_bounds(n, m, &fm, &lm)))
38
    };
39
40
50.6k
    let bandwidth = if ALLOW_TYPOS { 
n + TYPO_BAND_SLACK84
} else {
050.5k
};
  Branch (40:24): [True: 0, False: 38]
 
  Branch (40:24): [True: 0, False: 50.4k]
 
  Branch (40:24): [True: 0, False: 0]
 
  Branch (40:24): [True: 41, False: 0]
@@ -23,7 +23,7 @@
 
  Branch (41:31): [True: 8, False: 0]
 
  Branch (41:31): [True: 35, False: 0]
 
42
43
50.6k
    Some(BandingInfo {
44
50.6k
        row_bounds,
45
50.6k
        j_first,
46
50.6k
        bandwidth,
47
50.6k
        min_true_matches,
48
50.6k
    })
49
51.3k
}
50
51
/// Row-major V-shaped band: compute column bounds at row `i`.
52
///
53
/// The result is an upper triangle starting at the diagonal (j ~ i + `j_first` - 1)
54
#[inline(always)]
55
51.6k
pub(super) fn typo_vband_row(i: usize, m: usize, bandwidth: usize, j_first: usize) -> (usize, usize) {
56
51.6k
    let j = i + j_first - 1;
57
51.6k
    let lo = j.saturating_sub(bandwidth).max(j_first);
58
59
51.6k
    (lo, m)
60
51.6k
}
61
62
/// For exact (non-typo) mode, compute the earliest column (1-indexed) at which
63
/// each pattern character can first be matched. This tightens the diagonal
64
/// lower bound so we never compute cells that cannot participate in a valid
65
/// alignment.
66
///
67
/// Returns `None` if any pattern character has no match in the choice (the
68
/// subsequence check should have caught this, but we guard anyway).
69
51.2k
fn compute_first_match_cols<C: Atom>(pat: &[C], cho: &[C], respect_case: bool) -> Option<[usize; MAX_PAT_LEN]> {
70
51.2k
    let n = pat.len();
71
    // Patterns longer than MAX_PAT_LEN cannot be handled by the stack-allocated
72
    // banding arrays.  Return None so the caller skips this choice gracefully.
73
51.2k
    if n > MAX_PAT_LEN {
  Branch (73:8): [True: 0, False: 55]
-  Branch (73:8): [True: 0, False: 51.0k]
+  Branch (73:8): [True: 0, False: 51.1k]
 
  Branch (73:8): [True: 1, False: 14]
   Branch (73:8): [True: 2, False: 73]
-
74
3
        return None;
75
51.2k
    }
76
51.2k
    let mut first = [0usize; MAX_PAT_LEN];
77
51.2k
    let mut start = 0usize; // search from this choice index onward
78
51.2k
    for i in 0..n {
79
608k
        let found = 
cho[start..].iter()52.1k
.position(|&c| pat[i].eq(c, respect_case));
80
        {
81
52.1k
            let pos = found
?645
;
82
51.4k
            first[i] = start + pos + 1; // 1-indexed column
83
51.4k
            start = start + pos + 1; // next char must be strictly after
84
        }
85
    }
86
50.5k
    Some(first)
87
51.2k
}
\ No newline at end of file +
74
3
        return None;
75
51.2k
    }
76
51.2k
    let mut first = [0usize; MAX_PAT_LEN];
77
51.2k
    let mut start = 0usize; // search from this choice index onward
78
51.2k
    for i in 0..n {
79
608k
        let found = 
cho[start..].iter()52.1k
.position(|&c| pat[i].eq(c, respect_case));
80
        {
81
52.1k
            let pos = found
?649
;
82
51.4k
            first[i] = start + pos + 1; // 1-indexed column
83
51.4k
            start = start + pos + 1; // next char must be strictly after
84
        }
85
    }
86
50.5k
    Some(first)
87
51.2k
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/helpers.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/helpers.rs.html index cc496056..b769e5a2 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/helpers.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/helpers.rs.html @@ -1,7 +1,7 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/helpers.rs
Line
Count
Source
1
//! &[dyn Atom] manipulation helpers
2
3
use super::Atom;
4
use super::constants::MAX_PAT_LEN;
5
6
/// Find the 1-indexed column of the first occurrence of `pat[0]` in `cho`.
7
///
8
/// Returns `None` if `pat[0]` is not found anywhere (caller should return
9
/// `None`). The position defines the start of the V-shaped banding envelope.
10
/// Uses SIMD-backed `find_first_in` for `u8` slices.
11
#[inline(always)]
12
84
pub(super) fn find_first_char<C: Atom>(pat: &[C], cho: &[C], respect_case: bool) -> Option<usize> {
13
84
    pat[0].find_first_in(cho, respect_case).map(|idx| idx + 1) // 1-indexed
14
84
}
15
16
/// Compute the last column (1-indexed) at which each pattern character can be
17
/// matched, scanning from the end. Used to tighten the diagonal upper bound.
18
50.5k
pub(super) fn compute_last_match_cols<C: Atom>(
19
50.5k
    pat: &[C],
20
50.5k
    cho: &[C],
21
50.5k
    respect_case: bool,
22
50.5k
) -> Option<[usize; MAX_PAT_LEN]> {
23
50.5k
    let n = pat.len();
24
    // Patterns longer than MAX_PAT_LEN cannot be handled by the stack-allocated
25
    // banding arrays.  Return None so the caller skips this choice gracefully.
26
50.5k
    if n > MAX_PAT_LEN {
  Branch (26:8): [True: 0, False: 38]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/helpers.rs
Line
Count
Source
1
//! &[dyn Atom] manipulation helpers
2
3
use super::Atom;
4
use super::constants::MAX_PAT_LEN;
5
6
/// Find the 1-indexed column of the first occurrence of `pat[0]` in `cho`.
7
///
8
/// Returns `None` if `pat[0]` is not found anywhere (caller should return
9
/// `None`). The position defines the start of the V-shaped banding envelope.
10
/// Uses SIMD-backed `find_first_in` for `u8` slices.
11
#[inline(always)]
12
84
pub(super) fn find_first_char<C: Atom>(pat: &[C], cho: &[C], respect_case: bool) -> Option<usize> {
13
84
    pat[0].find_first_in(cho, respect_case).map(|idx| idx + 1) // 1-indexed
14
84
}
15
16
/// Compute the last column (1-indexed) at which each pattern character can be
17
/// matched, scanning from the end. Used to tighten the diagonal upper bound.
18
50.5k
pub(super) fn compute_last_match_cols<C: Atom>(
19
50.5k
    pat: &[C],
20
50.5k
    cho: &[C],
21
50.5k
    respect_case: bool,
22
50.5k
) -> Option<[usize; MAX_PAT_LEN]> {
23
50.5k
    let n = pat.len();
24
    // Patterns longer than MAX_PAT_LEN cannot be handled by the stack-allocated
25
    // banding arrays.  Return None so the caller skips this choice gracefully.
26
50.5k
    if n > MAX_PAT_LEN {
  Branch (26:8): [True: 0, False: 38]
   Branch (26:8): [True: 0, False: 50.4k]
 
  Branch (26:8): [True: 0, False: 9]
   Branch (26:8): [True: 1, False: 67]
-
27
1
        return None;
28
50.5k
    }
29
50.5k
    let m = cho.len();
30
50.5k
    let mut last = [0usize; MAX_PAT_LEN];
31
50.5k
    let mut end = m; // search up to this choice index (exclusive)
32
50.5k
    for i in (0..n).rev() {
33
51.3k
        let found = pat[i].find_last_in(&cho[..end], respect_case);
34
        {
35
51.3k
            let pos = found
?0
;
36
51.3k
            last[i] = pos + 1; // 1-indexed column
37
51.3k
            end = pos; // previous char must be strictly before
38
        }
39
    }
40
50.5k
    Some(last)
41
50.5k
}
42
43
/// For the **row-major** full DP (outer loop over rows), compute per-row
44
/// column bounds `(j_lo, j_hi)` accounting for cross-row Diag reads.
45
///
46
/// Row `i` (1-indexed) matches pattern char `i-1`. The Diag move at
47
/// `(i, j)` reads `buf[i-1][j-1]`, so row `i-1` must have computed
48
/// column `j-1`. We expand each row's upper bound to satisfy the next
49
/// row's lower-bound Diag dependency, and each row's lower bound to
50
/// satisfy the previous row's upper-bound Diag dependency.
51
50.5k
pub(super) fn compute_row_col_bounds(
52
50.5k
    n: usize,
53
50.5k
    m: usize,
54
50.5k
    first_match: &[usize; MAX_PAT_LEN],
55
50.5k
    last_match: &[usize; MAX_PAT_LEN],
56
50.5k
) -> ([usize; MAX_PAT_LEN], [usize; MAX_PAT_LEN]) {
57
50.5k
    let mut lo = [0usize; MAX_PAT_LEN];
58
50.5k
    let mut hi = [0usize; MAX_PAT_LEN];
59
60
    // Start with the raw first/last match bounds.
61
50.5k
    lo[..n].copy_from_slice(&first_match[..n]);
62
50.5k
    hi[..n].copy_from_slice(&last_match[..n]);
63
64
    // Forward pass: row i's upper bound must extend so that row i+1 can
65
    // read Diag at (i+1, j_lo[i+1]) → needs buf[i][j_lo[i+1]-1].
66
    // Also, LEFT propagation within row i+1 starts at j_lo[i+1], but
67
    // score flows from row i via Diag, so row i must reach j_lo[i+1]-1.
68
50.5k
    for i in 0..n.saturating_sub(1) {
69
660
        let next_lo = lo[i + 1];
70
660
        if next_lo > 1 {
  Branch (70:12): [True: 473, False: 0]
+
27
1
        return None;
28
50.5k
    }
29
50.5k
    let m = cho.len();
30
50.5k
    let mut last = [0usize; MAX_PAT_LEN];
31
50.5k
    let mut end = m; // search up to this choice index (exclusive)
32
50.5k
    for i in (0..n).rev() {
33
51.3k
        let found = pat[i].find_last_in(&cho[..end], respect_case);
34
        {
35
51.3k
            let pos = found
?0
;
36
51.3k
            last[i] = pos + 1; // 1-indexed column
37
51.3k
            end = pos; // previous char must be strictly before
38
        }
39
    }
40
50.5k
    Some(last)
41
50.5k
}
42
43
/// For the **row-major** full DP (outer loop over rows), compute per-row
44
/// column bounds `(j_lo, j_hi)` accounting for cross-row Diag reads.
45
///
46
/// Row `i` (1-indexed) matches pattern char `i-1`. The Diag move at
47
/// `(i, j)` reads `buf[i-1][j-1]`, so row `i-1` must have computed
48
/// column `j-1`. We expand each row's upper bound to satisfy the next
49
/// row's lower-bound Diag dependency, and each row's lower bound to
50
/// satisfy the previous row's upper-bound Diag dependency.
51
50.5k
pub(super) fn compute_row_col_bounds(
52
50.5k
    n: usize,
53
50.5k
    m: usize,
54
50.5k
    first_match: &[usize; MAX_PAT_LEN],
55
50.5k
    last_match: &[usize; MAX_PAT_LEN],
56
50.5k
) -> ([usize; MAX_PAT_LEN], [usize; MAX_PAT_LEN]) {
57
50.5k
    let mut lo = [0usize; MAX_PAT_LEN];
58
50.5k
    let mut hi = [0usize; MAX_PAT_LEN];
59
60
    // Start with the raw first/last match bounds.
61
50.5k
    lo[..n].copy_from_slice(&first_match[..n]);
62
50.5k
    hi[..n].copy_from_slice(&last_match[..n]);
63
64
    // Forward pass: row i's upper bound must extend so that row i+1 can
65
    // read Diag at (i+1, j_lo[i+1]) → needs buf[i][j_lo[i+1]-1].
66
    // Also, LEFT propagation within row i+1 starts at j_lo[i+1], but
67
    // score flows from row i via Diag, so row i must reach j_lo[i+1]-1.
68
50.5k
    for i in 0..n.saturating_sub(1) {
69
663
        let next_lo = lo[i + 1];
70
663
        if next_lo > 1 {
  Branch (70:12): [True: 476, False: 0]
 
  Branch (70:12): [True: 186, False: 1]
-
71
659
            hi[i] = hi[i].max(next_lo - 1);
72
1
        }
73
    }
74
75
    // Backward pass: row i's lower bound can't be later than row i-1's
76
    // upper bound + 1 (Diag from (i-1, hi[i-1]) can reach (i, hi[i-1]+1)).
77
    // This is rarely binding but ensures consistency.
78
50.5k
    for i in 1..n {
79
660
        lo[i] = lo[i].min(hi[i - 1] + 1);
80
    }
81
82
    // Clamp to valid range.
83
50.5k
    for i in 0..n {
84
51.2k
        lo[i] = lo[i].max(1).min(m);
85
51.2k
        hi[i] = hi[i].max(lo[i]).min(m);
86
    }
87
88
50.5k
    (lo, hi)
89
50.5k
}
\ No newline at end of file +
71
662
            hi[i] = hi[i].max(next_lo - 1);
72
1
        }
73
    }
74
75
    // Backward pass: row i's lower bound can't be later than row i-1's
76
    // upper bound + 1 (Diag from (i-1, hi[i-1]) can reach (i, hi[i-1]+1)).
77
    // This is rarely binding but ensures consistency.
78
50.5k
    for i in 1..n {
79
663
        lo[i] = lo[i].min(hi[i - 1] + 1);
80
    }
81
82
    // Clamp to valid range.
83
50.5k
    for i in 0..n {
84
51.2k
        lo[i] = lo[i].max(1).min(m);
85
51.2k
        hi[i] = hi[i].max(lo[i]).min(m);
86
    }
87
88
50.5k
    (lo, hi)
89
50.5k
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/matrix.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/matrix.rs.html index 80090bff..ea4a743a 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/matrix.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/matrix.rs.html @@ -1,3 +1,3 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/matrix.rs
Line
Count
Source
1
//! Base structs for the matching algorithm: Cell & `SWMatrix`
2
3
use super::Score;
4
5
/// Direction the optimal path took to reach a cell.
6
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7
#[repr(u8)]
8
#[allow(dead_code)] // variants are constructed via transmute from bits
9
pub(super) enum Dir {
10
    /// No valid path (score == 0).
11
    ///
12
    /// Assigned tag 0 so that `Cell::new(0, Dir::None)` encodes as all-zero
13
    /// bits, allowing boundary rows/columns to be bulk-zeroed with
14
    /// `write_bytes(0)` instead of a scalar loop.
15
    None = 0,
16
    /// Diagonal: match or mismatch (came from [i-1][j-1])
17
    Diag = 1,
18
    /// Up: gap in choice (came from [i-1][j], skip pattern char)
19
    Up = 2,
20
    /// Left: gap in pattern (came from [i][j-1], skip choice char)
21
    Left = 3,
22
}
23
24
/// Packed cell stored as a `u32`: bits [15:0] = score (as u16 bitcast from
25
/// i16), bits [17:16] = direction tag.  This gives 4 bytes per cell with no
26
/// padding and enables branchless direction extraction via bitmask.
27
#[derive(Copy, Clone)]
28
pub(super) struct Cell(u32);
29
30
pub(super) const CELL_ZERO: Cell = Cell::new(0, Dir::None);
31
32
impl std::fmt::Debug for Cell {
33
1
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34
1
        f.debug_struct("Cell")
35
1
            .field("score", &self.score())
36
1
            .field("dir", &self.dir())
37
1
            .finish()
38
1
    }
39
}
40
41
impl Cell {
42
    #[inline(always)]
43
63.5k
    pub(super) const fn new(score: Score, dir: Dir) -> Cell {
44
        // Store score as u16 bits in low 16 bits, dir in bits 16-17.
45
63.5k
        Cell((score.cast_unsigned() as u32) | ((dir as u32) << 16))
46
63.5k
    }
47
    #[inline(always)]
48
183k
    pub(super) fn score(self) -> Score {
49
        // Truncation is intentional: low 16 bits store the score as a bitcast i16.
50
        #[allow(clippy::cast_possible_truncation)]
51
183k
        let low16 = self.0 as u16;
52
183k
        low16.cast_signed()
53
183k
    }
54
    #[inline(always)]
55
51.6k
    pub(super) fn dir(self) -> Dir {
56
        // SAFETY: Dir has repr(u8) with values 0..=3 and we only ever store
57
        // valid Dir values in bits 16-17. Truncation from u32 to u8 is intentional.
58
        #[allow(clippy::cast_possible_truncation)]
59
51.6k
        let tag = (self.0 >> 16) as u8 & 0x3;
60
51.6k
        unsafe { std::mem::transmute(tag) }
61
51.6k
    }
62
    /// Branchless check: true when dir == Diag (tag 1).
63
    #[inline(always)]
64
127k
    pub(super) fn is_diag(self) -> bool {
65
127k
        (self.0 >> 16) & 0x3 == 1
66
127k
    }
67
}
68
69
#[derive(Default, Debug)]
70
pub(super) struct SWMatrix {
71
    pub(super) data: Vec<Cell>,
72
    pub(super) cols: usize,
73
    pub(super) rows: usize,
74
}
75
76
impl SWMatrix {
77
367
    pub fn zero(rows: usize, cols: usize) -> Self {
78
367
        let mut res = SWMatrix::default();
79
367
        res.resize(rows, cols);
80
367
        res
81
367
    }
82
51.0k
    pub fn resize(&mut self, rows: usize, cols: usize) {
83
51.0k
        let needed = rows * cols;
84
51.0k
        if needed > self.data.len() {
  Branch (84:12): [True: 341, False: 50.5k]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/matrix.rs
Line
Count
Source
1
//! Base structs for the matching algorithm: Cell & `SWMatrix`
2
3
use super::Score;
4
5
/// Direction the optimal path took to reach a cell.
6
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7
#[repr(u8)]
8
#[allow(dead_code)] // variants are constructed via transmute from bits
9
pub(super) enum Dir {
10
    /// No valid path (score == 0).
11
    ///
12
    /// Assigned tag 0 so that `Cell::new(0, Dir::None)` encodes as all-zero
13
    /// bits, allowing boundary rows/columns to be bulk-zeroed with
14
    /// `write_bytes(0)` instead of a scalar loop.
15
    None = 0,
16
    /// Diagonal: match or mismatch (came from [i-1][j-1])
17
    Diag = 1,
18
    /// Up: gap in choice (came from [i-1][j], skip pattern char)
19
    Up = 2,
20
    /// Left: gap in pattern (came from [i][j-1], skip choice char)
21
    Left = 3,
22
}
23
24
/// Packed cell stored as a `u32`: bits [15:0] = score (as u16 bitcast from
25
/// i16), bits [17:16] = direction tag.  This gives 4 bytes per cell with no
26
/// padding and enables branchless direction extraction via bitmask.
27
#[derive(Copy, Clone)]
28
pub(super) struct Cell(u32);
29
30
pub(super) const CELL_ZERO: Cell = Cell::new(0, Dir::None);
31
32
impl std::fmt::Debug for Cell {
33
1
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34
1
        f.debug_struct("Cell")
35
1
            .field("score", &self.score())
36
1
            .field("dir", &self.dir())
37
1
            .finish()
38
1
    }
39
}
40
41
impl Cell {
42
    #[inline(always)]
43
63.6k
    pub(super) const fn new(score: Score, dir: Dir) -> Cell {
44
        // Store score as u16 bits in low 16 bits, dir in bits 16-17.
45
63.6k
        Cell((score.cast_unsigned() as u32) | ((dir as u32) << 16))
46
63.6k
    }
47
    #[inline(always)]
48
183k
    pub(super) fn score(self) -> Score {
49
        // Truncation is intentional: low 16 bits store the score as a bitcast i16.
50
        #[allow(clippy::cast_possible_truncation)]
51
183k
        let low16 = self.0 as u16;
52
183k
        low16.cast_signed()
53
183k
    }
54
    #[inline(always)]
55
51.7k
    pub(super) fn dir(self) -> Dir {
56
        // SAFETY: Dir has repr(u8) with values 0..=3 and we only ever store
57
        // valid Dir values in bits 16-17. Truncation from u32 to u8 is intentional.
58
        #[allow(clippy::cast_possible_truncation)]
59
51.7k
        let tag = (self.0 >> 16) as u8 & 0x3;
60
51.7k
        unsafe { std::mem::transmute(tag) }
61
51.7k
    }
62
    /// Branchless check: true when dir == Diag (tag 1).
63
    #[inline(always)]
64
127k
    pub(super) fn is_diag(self) -> bool {
65
127k
        (self.0 >> 16) & 0x3 == 1
66
127k
    }
67
}
68
69
#[derive(Default, Debug)]
70
pub(super) struct SWMatrix {
71
    pub(super) data: Vec<Cell>,
72
    pub(super) cols: usize,
73
    pub(super) rows: usize,
74
}
75
76
impl SWMatrix {
77
370
    pub fn zero(rows: usize, cols: usize) -> Self {
78
370
        let mut res = SWMatrix::default();
79
370
        res.resize(rows, cols);
80
370
        res
81
370
    }
82
51.0k
    pub fn resize(&mut self, rows: usize, cols: usize) {
83
51.0k
        let needed = rows * cols;
84
51.0k
        if needed > self.data.len() {
  Branch (84:12): [True: 344, False: 50.5k]
   Branch (84:12): [True: 82, False: 122]
-
85
423
            self.data.resize(needed, CELL_ZERO);
86
50.6k
        }
87
51.0k
        self.rows = rows;
88
51.0k
        self.cols = cols;
89
51.0k
    }
90
}
91
92
#[cfg(test)]
93
#[cfg_attr(coverage, coverage(off))]
94
mod tests {
95
    use super::*;
96
97
    #[test]
98
    fn cell_packs_score_and_direction() {
99
        let cell = Cell::new(42, Dir::Diag);
100
        assert_eq!(cell.score(), 42);
101
        assert_eq!(cell.dir(), Dir::Diag);
102
        assert!(cell.is_diag());
103
104
        // Negative scores round-trip through the i16 bitcast.
105
        let neg = Cell::new(-7, Dir::Up);
106
        assert_eq!(neg.score(), -7);
107
        assert_eq!(neg.dir(), Dir::Up);
108
        assert!(!neg.is_diag());
109
    }
110
111
    #[test]
112
    fn cell_zero_is_none_direction() {
113
        assert_eq!(CELL_ZERO.score(), 0);
114
        assert_eq!(CELL_ZERO.dir(), Dir::None);
115
    }
116
117
    #[test]
118
    fn cell_debug_shows_score_and_dir() {
119
        let s = format!("{:?}", Cell::new(5, Dir::Left));
120
        assert!(s.contains("Cell"));
121
        assert!(s.contains("score"));
122
        assert!(s.contains("Left"));
123
    }
124
125
    #[test]
126
    fn matrix_zero_and_resize_grow() {
127
        let mut m = SWMatrix::zero(2, 3);
128
        assert_eq!(m.rows, 2);
129
        assert_eq!(m.cols, 3);
130
        assert!(m.data.len() >= 6);
131
132
        // Growing increases the backing storage.
133
        m.resize(4, 4);
134
        assert_eq!(m.rows, 4);
135
        assert_eq!(m.cols, 4);
136
        assert!(m.data.len() >= 16);
137
138
        // Shrinking keeps the (larger) allocation but updates dims.
139
        m.resize(1, 1);
140
        assert_eq!(m.rows, 1);
141
        assert_eq!(m.cols, 1);
142
    }
143
}
\ No newline at end of file +
85
426
            self.data.resize(needed, CELL_ZERO);
86
50.6k
        }
87
51.0k
        self.rows = rows;
88
51.0k
        self.cols = cols;
89
51.0k
    }
90
}
91
92
#[cfg(test)]
93
#[cfg_attr(coverage, coverage(off))]
94
mod tests {
95
    use super::*;
96
97
    #[test]
98
    fn cell_packs_score_and_direction() {
99
        let cell = Cell::new(42, Dir::Diag);
100
        assert_eq!(cell.score(), 42);
101
        assert_eq!(cell.dir(), Dir::Diag);
102
        assert!(cell.is_diag());
103
104
        // Negative scores round-trip through the i16 bitcast.
105
        let neg = Cell::new(-7, Dir::Up);
106
        assert_eq!(neg.score(), -7);
107
        assert_eq!(neg.dir(), Dir::Up);
108
        assert!(!neg.is_diag());
109
    }
110
111
    #[test]
112
    fn cell_zero_is_none_direction() {
113
        assert_eq!(CELL_ZERO.score(), 0);
114
        assert_eq!(CELL_ZERO.dir(), Dir::None);
115
    }
116
117
    #[test]
118
    fn cell_debug_shows_score_and_dir() {
119
        let s = format!("{:?}", Cell::new(5, Dir::Left));
120
        assert!(s.contains("Cell"));
121
        assert!(s.contains("score"));
122
        assert!(s.contains("Left"));
123
    }
124
125
    #[test]
126
    fn matrix_zero_and_resize_grow() {
127
        let mut m = SWMatrix::zero(2, 3);
128
        assert_eq!(m.rows, 2);
129
        assert_eq!(m.cols, 3);
130
        assert!(m.data.len() >= 6);
131
132
        // Growing increases the backing storage.
133
        m.resize(4, 4);
134
        assert_eq!(m.rows, 4);
135
        assert_eq!(m.cols, 4);
136
        assert!(m.data.len() >= 16);
137
138
        // Shrinking keeps the (larger) allocation but updates dims.
139
        m.resize(1, 1);
140
        assert_eq!(m.rows, 1);
141
        assert_eq!(m.cols, 1);
142
    }
143
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/mod.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/mod.rs.html index 05ef4972..050a13ae 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/mod.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/mod.rs.html @@ -1,8 +1,8 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/mod.rs
Line
Count
Source
1
//! Arinae fuzzy matching algorithm.
2
//!
3
//! Uses a Smith-Waterman local alignment approach with affine gap penalties
4
//! and context-sensitive bonuses.
5
//!
6
//! ## Key design choices
7
//!
8
//! - **Single score per cell** (u16 saturating) plus a 2-bit direction tag
9
//!   for traceback. Gap open vs extend is tracked via the direction tag.
10
//! - **Semi-global alignment**: the pattern must be fully consumed, but
11
//!   alignment can start/end at any position in the choice string.
12
//!
13
//!
14
//! ## Pruning strategies
15
//!
16
//! - **Row-range banding**: each DP cell is only computed when the row/column
17
//!   pair falls within the feasible alignment band. In exact mode the band is
18
//!   derived from precomputed first/last match columns for each pattern
19
//!   character; in typo mode a diagonal ± bandwidth envelope is used.
20
//! - **Interpair max-score pruning**: after processing a column (score-only)
21
//!   or row (full DP), if all cells are zero for several consecutive
22
//!   iterations, the alignment is dead and we terminate early.
23
24
#![allow(clippy::inline_always)]
25
26
mod algo;
27
mod atom;
28
mod banding;
29
mod constants;
30
mod helpers;
31
mod matrix;
32
mod prefilter;
33
#[cfg(test)]
34
mod tests;
35
36
use std::cell::RefCell;
37
38
use thread_local::ThreadLocal;
39
40
use self::algo::{full_dp, range_dp};
41
use self::atom::Atom;
42
use self::banding::{BandingInfo, compute_banding};
43
use self::constants::{CAMEL_CASE_BONUS, START_OF_STRING_BONUS};
44
use self::prefilter::cheap_typo_prefilter;
45
46
use self::matrix::{CELL_ZERO, Cell, Dir, SWMatrix};
47
use crate::CaseMatching;
48
use crate::fuzzy_matcher::{FuzzyMatcher, MatchIndices, ScoreType};
49
50
type Score = i16;
51
52
50.6k
fn precompute_bonuses<C: Atom>(cho: &[C], buf: &mut Vec<Score>) {
53
    // Reset length (O(1), no deallocation) then fill with fresh values.
54
50.6k
    buf.clear();
55
    // The first character always gets START_OF_STRING_BONUS.
56
    // Subsequent characters get a bonus based on the previous character:
57
    //   - separator_bonus() when the previous char is a separator (the exact
58
    //     bonus depends on the separator — see SEPARATOR_TABLE in constants.rs),
59
    //   - CAMEL_CASE_BONUS when transitioning from lowercase to non-lowercase.
60
    // Using a safe iterator lets the compiler auto-vectorise the loop.
61
558k
    let bonus_iter = 
std::iter::once(START_OF_STRING_BONUS)50.6k
.chain(
cho.windows(2)50.6k
.map(|w| {
62
558k
        let prev = w[0];
63
558k
        let cur = w[1];
64
558k
        prev.separator_bonus() + CAMEL_CASE_BONUS * Score::from(prev.is_lowercase() && 
!cur.is_lowercase()208k
)
  Branch (64:65): [True: 111, False: 40]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/mod.rs
Line
Count
Source
1
//! Arinae fuzzy matching algorithm.
2
//!
3
//! Uses a Smith-Waterman local alignment approach with affine gap penalties
4
//! and context-sensitive bonuses.
5
//!
6
//! ## Key design choices
7
//!
8
//! - **Single score per cell** (u16 saturating) plus a 2-bit direction tag
9
//!   for traceback. Gap open vs extend is tracked via the direction tag.
10
//! - **Semi-global alignment**: the pattern must be fully consumed, but
11
//!   alignment can start/end at any position in the choice string.
12
//!
13
//!
14
//! ## Pruning strategies
15
//!
16
//! - **Row-range banding**: each DP cell is only computed when the row/column
17
//!   pair falls within the feasible alignment band. In exact mode the band is
18
//!   derived from precomputed first/last match columns for each pattern
19
//!   character; in typo mode a diagonal ± bandwidth envelope is used.
20
//! - **Interpair max-score pruning**: after processing a column (score-only)
21
//!   or row (full DP), if all cells are zero for several consecutive
22
//!   iterations, the alignment is dead and we terminate early.
23
24
#![allow(clippy::inline_always)]
25
26
mod algo;
27
mod atom;
28
mod banding;
29
mod constants;
30
mod helpers;
31
mod matrix;
32
mod prefilter;
33
#[cfg(test)]
34
mod tests;
35
36
use std::cell::RefCell;
37
38
use thread_local::ThreadLocal;
39
40
use self::algo::{full_dp, range_dp};
41
use self::atom::Atom;
42
use self::banding::{BandingInfo, compute_banding};
43
use self::constants::{CAMEL_CASE_BONUS, START_OF_STRING_BONUS};
44
use self::prefilter::cheap_typo_prefilter;
45
46
use self::matrix::{CELL_ZERO, Cell, Dir, SWMatrix};
47
use crate::CaseMatching;
48
use crate::fuzzy_matcher::{FuzzyMatcher, MatchIndices, ScoreType};
49
50
type Score = i16;
51
52
50.6k
fn precompute_bonuses<C: Atom>(cho: &[C], buf: &mut Vec<Score>) {
53
    // Reset length (O(1), no deallocation) then fill with fresh values.
54
50.6k
    buf.clear();
55
    // The first character always gets START_OF_STRING_BONUS.
56
    // Subsequent characters get a bonus based on the previous character:
57
    //   - separator_bonus() when the previous char is a separator (the exact
58
    //     bonus depends on the separator — see SEPARATOR_TABLE in constants.rs),
59
    //   - CAMEL_CASE_BONUS when transitioning from lowercase to non-lowercase.
60
    // Using a safe iterator lets the compiler auto-vectorise the loop.
61
559k
    let bonus_iter = 
std::iter::once(START_OF_STRING_BONUS)50.6k
.chain(
cho.windows(2)50.6k
.map(|w| {
62
559k
        let prev = w[0];
63
559k
        let cur = w[1];
64
559k
        prev.separator_bonus() + CAMEL_CASE_BONUS * Score::from(prev.is_lowercase() && 
!cur.is_lowercase()208k
)
  Branch (64:65): [True: 111, False: 40]
   Branch (64:65): [True: 206k, False: 350k]
 
  Branch (64:65): [True: 127, False: 36]
   Branch (64:65): [True: 1.07k, False: 252]
-
65
558k
    }));
66
50.6k
    buf.extend(bonus_iter);
67
50.6k
}
68
69
/// Arinae fuzzy matcher: Smith-Waterman local alignment with affine gap
70
/// penalties and context-sensitive bonuses.
71
#[derive(Debug, Default)]
72
pub struct ArinaeMatcher {
73
    pub(crate) case: CaseMatching,
74
    pub(crate) allow_typos: bool,
75
    pub(crate) use_last_match: bool,
76
77
    full_buf: ThreadLocal<RefCell<SWMatrix>>,
78
    indices_buf: ThreadLocal<RefCell<MatchIndices>>,
79
    #[allow(clippy::type_complexity)]
80
    char_buf: ThreadLocal<RefCell<(Vec<char>, Vec<char>)>>,
81
    bonus_buf: ThreadLocal<RefCell<Vec<Score>>>,
82
}
83
84
impl ArinaeMatcher {
85
    /// Create a new `ArinaeMatcher` with the given settings.
86
    #[must_use]
87
557
    pub fn new(case: CaseMatching, allow_typos: bool, use_last_match: bool) -> Self {
88
557
        Self {
89
557
            case,
90
557
            allow_typos,
91
557
            use_last_match,
92
557
            ..Default::default()
93
557
        }
94
557
    }
95
96
    #[inline(always)]
97
51.3k
    fn respect_case<C: Atom>(&self, pattern: &[C]) -> bool {
98
51.3k
        self.case == CaseMatching::Respect
  Branch (98:9): [True: 0, False: 55]
+
65
559k
    }));
66
50.6k
    buf.extend(bonus_iter);
67
50.6k
}
68
69
/// Arinae fuzzy matcher: Smith-Waterman local alignment with affine gap
70
/// penalties and context-sensitive bonuses.
71
#[derive(Debug, Default)]
72
pub struct ArinaeMatcher {
73
    pub(crate) case: CaseMatching,
74
    pub(crate) allow_typos: bool,
75
    pub(crate) use_last_match: bool,
76
77
    full_buf: ThreadLocal<RefCell<SWMatrix>>,
78
    indices_buf: ThreadLocal<RefCell<MatchIndices>>,
79
    #[allow(clippy::type_complexity)]
80
    char_buf: ThreadLocal<RefCell<(Vec<char>, Vec<char>)>>,
81
    bonus_buf: ThreadLocal<RefCell<Vec<Score>>>,
82
}
83
84
impl ArinaeMatcher {
85
    /// Create a new `ArinaeMatcher` with the given settings.
86
    #[must_use]
87
560
    pub fn new(case: CaseMatching, allow_typos: bool, use_last_match: bool) -> Self {
88
560
        Self {
89
560
            case,
90
560
            allow_typos,
91
560
            use_last_match,
92
560
            ..Default::default()
93
560
        }
94
560
    }
95
96
    #[inline(always)]
97
51.3k
    fn respect_case<C: Atom>(&self, pattern: &[C]) -> bool {
98
51.3k
        self.case == CaseMatching::Respect
  Branch (98:9): [True: 0, False: 55]
 
  Branch (98:9): [True: 9, False: 51.1k]
 
  Branch (98:9): [True: 0, False: 26]
 
  Branch (98:9): [True: 3, False: 111]
@@ -14,15 +14,15 @@
 
  Branch (127:12): [True: 1, False: 99]
 
128
1
            return Some((0, MatchIndices::new()));
129
51.2k
        }
130
51.2k
        if cho.is_empty() {
  Branch (130:12): [True: 0, False: 51.1k]
 
  Branch (130:12): [True: 1, False: 98]
-
131
1
            return None;
132
51.2k
        }
133
134
51.2k
        let respect_case = self.respect_case(pat);
135
136
        // Prefilter for typo mode.
137
51.2k
        if self.allow_typos && 
!79
cheap_typo_prefilter79
(
pat79
, cho, respect_case) {
  Branch (137:12): [True: 49, False: 51.0k]
+
131
1
            return None;
132
51.2k
        }
133
134
51.2k
        let respect_case = self.respect_case(pat);
135
136
        // Prefilter for typo mode.
137
51.2k
        if self.allow_typos && 
!79
cheap_typo_prefilter79
(
pat79
, cho, respect_case) {
  Branch (137:12): [True: 49, False: 51.1k]
   Branch (137:32): [True: 8, False: 41]
 
  Branch (137:12): [True: 30, False: 68]
   Branch (137:32): [True: 2, False: 28]
-
138
10
            return None;
139
51.2k
        }
140
141
        // Compute banding BEFORE bonuses: the banding check (subsequence scan) is
142
        // a fast SIMD operation that rejects ~70% of items early.  For those items
143
        // we never allocate or fill the bonus buffer, saving an O(m) write pass.
144
51.2k
        let banding = if self.allow_typos {
  Branch (144:26): [True: 41, False: 51.0k]
+
138
10
            return None;
139
51.2k
        }
140
141
        // Compute banding BEFORE bonuses: the banding check (subsequence scan) is
142
        // a fast SIMD operation that rejects ~70% of items early.  For those items
143
        // we never allocate or fill the bonus buffer, saving an O(m) write pass.
144
51.2k
        let banding = if self.allow_typos {
  Branch (144:26): [True: 41, False: 51.1k]
 
  Branch (144:26): [True: 28, False: 68]
-
145
69
            compute_banding::<true, C>(pat, cho, respect_case)
?0
146
        } else {
147
51.1k
            compute_banding::<false, C>(pat, cho, respect_case)
?625
148
        };
149
150
        // Only compute bonuses for items that survive the banding check.
151
50.6k
        let mut bonus_buf = self.bonus_buf.get_or(|| 
RefCell::new326
(
Vec::new()326
)).borrow_mut();
152
50.6k
        precompute_bonuses(cho, &mut bonus_buf);
153
154
50.6k
        self.dispatch_dp(cho, pat, &bonus_buf, respect_case, compute_indices, &banding)
155
51.2k
    }
156
157
51.3k
    fn run(&self, choice: &str, pattern: &str, compute_indices: bool) -> Option<(ScoreType, MatchIndices)> {
158
51.3k
        if pattern.is_empty() {
  Branch (158:12): [True: 0, False: 51.1k]
+
145
69
            compute_banding::<true, C>(pat, cho, respect_case)
?0
146
        } else {
147
51.1k
            compute_banding::<false, C>(pat, cho, respect_case)
?629
148
        };
149
150
        // Only compute bonuses for items that survive the banding check.
151
50.6k
        let mut bonus_buf = self.bonus_buf.get_or(|| 
RefCell::new329
(
Vec::new()329
)).borrow_mut();
152
50.6k
        precompute_bonuses(cho, &mut bonus_buf);
153
154
50.6k
        self.dispatch_dp(cho, pat, &bonus_buf, respect_case, compute_indices, &banding)
155
51.2k
    }
156
157
51.3k
    fn run(&self, choice: &str, pattern: &str, compute_indices: bool) -> Option<(ScoreType, MatchIndices)> {
158
51.3k
        if pattern.is_empty() {
  Branch (158:12): [True: 0, False: 51.2k]
 
  Branch (158:12): [True: 2, False: 114]
-
159
2
            return Some((0, MatchIndices::new()));
160
51.3k
        }
161
51.3k
        if choice.is_empty() {
  Branch (161:12): [True: 0, False: 51.1k]
+
159
2
            return Some((0, MatchIndices::new()));
160
51.3k
        }
161
51.3k
        if choice.is_empty() {
  Branch (161:12): [True: 0, False: 51.2k]
 
  Branch (161:12): [True: 1, False: 113]
 
162
1
            return None;
163
51.3k
        }
164
165
        // Fast path for ASCII matching
166
51.3k
        if choice.is_ascii() && 
pattern.is_ascii()51.2k
{
  Branch (166:12): [True: 51.1k, False: 55]
   Branch (166:33): [True: 51.1k, False: 0]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/prefilter.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/prefilter.rs.html
index 5732c63e..c68adc45 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/prefilter.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/prefilter.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/prefilter.rs
Line
Count
Source
1
//! Prefilters running before the algo to optimize performance on unmatchable items
2
3
use super::Atom;
4
use super::constants::MAX_PAT_LEN;
5
6
/// Cheap prefilter for typo-tolerant matching.
7
///
8
/// Rejects choices that clearly cannot produce a positive score in the DP.
9
/// The prefilter is intentionally lenient — false positives are fine (the DP
10
/// will reject them), but false negatives lose valid matches.
11
///
12
/// Strategy:
13
///   1. The first pattern character must appear somewhere in the choice at
14
///      position `j_first` (anchoring the alignment).
15
///   2. Of the remaining `n - 1` pattern characters, at least
16
///      `floor((n - 1) / 2)` must also appear (unordered, as a multiset) in
17
///      `choice[j_first..]` — the window the DP actually examines.
18
///
19
/// Scoping the tail check to `choice[j_first..]` is strictly correct: the
20
/// typo-mode DP band starts at `j_first` for every row (bandwidth = n + 4
21
/// always exceeds n - 1, so the left clamp always hits `j_first`). Any tail
22
/// character that only exists before `j_first` can never contribute a true
23
/// diagonal match in the DP; counting it would be a false positive.
24
///
25
/// We use a multiset frequency check rather than an ordered greedy scan.
26
/// An ordered scan causes false negatives when a greedily-consumed character
27
/// advances the cursor past positions where later characters could still match.
28
///
29
/// For the ASCII (`u8`) path the tail frequency table is built in a single
30
/// O(m) sequential pass over the window, then queried in O(n). For the `char`
31
/// path we fall back to a small O(n) linear-search table seeded from the
32
/// tail, queried via a scalar scan of the window — still a single O(m) pass.
33
99
pub(super) fn cheap_typo_prefilter<C: Atom>(pattern: &[C], choice: &[C], respect_case: bool) -> bool {
34
99
    let n = pattern.len();
35
99
    let m = choice.len();
36
37
    // A pattern much longer than the choice cannot match.
38
99
    if n > m + 2 {
  Branch (38:8): [True: 0, False: 0]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/arinae/prefilter.rs
Line
Count
Source
1
//! Prefilters running before the algo to optimize performance on unmatchable items
2
3
use super::Atom;
4
use super::constants::MAX_PAT_LEN;
5
6
/// Cheap prefilter for typo-tolerant matching.
7
///
8
/// Rejects choices that clearly cannot produce a positive score in the DP.
9
/// The prefilter is intentionally lenient — false positives are fine (the DP
10
/// will reject them), but false negatives lose valid matches.
11
///
12
/// Strategy:
13
///   1. The first pattern character must appear somewhere in the choice at
14
///      position `j_first` (anchoring the alignment).
15
///   2. Of the remaining `n - 1` pattern characters, at least
16
///      `floor((n - 1) / 2)` must also appear (unordered, as a multiset) in
17
///      `choice[j_first..]` — the window the DP actually examines.
18
///
19
/// Scoping the tail check to `choice[j_first..]` is strictly correct: the
20
/// typo-mode DP band starts at `j_first` for every row (bandwidth = n + 4
21
/// always exceeds n - 1, so the left clamp always hits `j_first`). Any tail
22
/// character that only exists before `j_first` can never contribute a true
23
/// diagonal match in the DP; counting it would be a false positive.
24
///
25
/// We use a multiset frequency check rather than an ordered greedy scan.
26
/// An ordered scan causes false negatives when a greedily-consumed character
27
/// advances the cursor past positions where later characters could still match.
28
///
29
/// For the ASCII (`u8`) path the tail frequency table is built in a single
30
/// O(m) sequential pass over the window, then queried in O(n). For the `char`
31
/// path we fall back to a small O(n) linear-search table seeded from the
32
/// tail, queried via a scalar scan of the window — still a single O(m) pass.
33
99
pub(super) fn cheap_typo_prefilter<C: Atom>(pattern: &[C], choice: &[C], respect_case: bool) -> bool {
34
99
    let n = pattern.len();
35
99
    let m = choice.len();
36
37
    // A pattern much longer than the choice cannot match.
38
99
    if n > m + 2 {
  Branch (38:8): [True: 0, False: 0]
 
  Branch (38:8): [True: 0, False: 49]
 
  Branch (38:8): [True: 2, False: 9]
 
  Branch (38:8): [True: 1, False: 38]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/clangd.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/clangd.rs.html
index 783be26c..ca6e4328 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/clangd.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/clangd.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/clangd.rs
Line
Count
Source
1
//! The fuzzy matching algorithm used in clangd.
2
//! https://github.com/llvm-mirror/clang-tools-extra/blob/master/clangd/FuzzyMatch.cpp
3
//!
4
//! # Example:
5
//! ```
6
//! use skim::fuzzy_matcher::FuzzyMatcher;
7
//! use skim::fuzzy_matcher::clangd::ClangdMatcher;
8
//!
9
//! let matcher = ClangdMatcher::default();
10
//!
11
//! assert_eq!(None, matcher.fuzzy_match("abc", "abx"));
12
//! assert!(matcher.fuzzy_match("axbycz", "abc").is_some());
13
//! assert!(matcher.fuzzy_match("axbycz", "xyz").is_some());
14
//!
15
//! let (score, indices) = matcher.fuzzy_indices("axbycz", "abc").unwrap();
16
//! assert_eq!(indices, [0, 2, 4]);
17
//!
18
//! ```
19
//!
20
//! Algorithm modified from
21
//! https://github.com/llvm-mirror/clang-tools-extra/blob/master/clangd/FuzzyMatch.cpp
22
//! Also check: https://github.com/lewang/flx/issues/98
23
use crate::fuzzy_matcher::util::{CharRole, CharType, char_equal, char_role, char_type_of, cheap_matches};
24
use crate::fuzzy_matcher::{FuzzyMatcher, IndexType, MatchIndices, ScoreType};
25
use std::cell::RefCell;
26
use std::cmp::max;
27
use thread_local::ThreadLocal;
28
29
#[derive(Eq, PartialEq, Debug, Copy, Clone)]
30
enum CaseMatching {
31
    Respect,
32
    Ignore,
33
    Smart,
34
}
35
36
#[derive(Debug)]
37
/// Fuzzy matcher using the clangd algorithm
38
pub struct ClangdMatcher {
39
    case: CaseMatching,
40
41
    use_cache: bool,
42
43
    c_cache: ThreadLocal<RefCell<Vec<char>>>, // vector to store the characters of choice
44
    p_cache: ThreadLocal<RefCell<Vec<char>>>, // vector to store the characters of pattern
45
}
46
47
impl Default for ClangdMatcher {
48
20
    fn default() -> Self {
49
20
        Self {
50
20
            case: CaseMatching::Ignore,
51
20
            use_cache: true,
52
20
            c_cache: ThreadLocal::new(),
53
20
            p_cache: ThreadLocal::new(),
54
20
        }
55
20
    }
56
}
57
58
impl ClangdMatcher {
59
    /// Sets the matcher to ignore case when matching
60
    #[must_use]
61
11
    pub fn ignore_case(mut self) -> Self {
62
11
        self.case = CaseMatching::Ignore;
63
11
        self
64
11
    }
65
66
    /// Sets the matcher to use smart case (case insensitive unless pattern contains uppercase)
67
    #[must_use]
68
4
    pub fn smart_case(mut self) -> Self {
69
4
        self.case = CaseMatching::Smart;
70
4
        self
71
4
    }
72
73
    /// Sets the matcher to respect case when matching
74
    #[must_use]
75
2
    pub fn respect_case(mut self) -> Self {
76
2
        self.case = CaseMatching::Respect;
77
2
        self
78
2
    }
79
80
    /// Enables or disables caching for improved performance
81
    #[must_use]
82
2
    pub fn use_cache(mut self, use_cache: bool) -> Self {
83
2
        self.use_cache = use_cache;
84
2
        self
85
2
    }
86
87
53
    fn contains_upper(string: &str) -> bool {
88
53
        for ch in string.chars() {
89
203
            if ch.is_uppercase() {
  Branch (89:16): [True: 0, False: 196]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/clangd.rs
Line
Count
Source
1
//! The fuzzy matching algorithm used in clangd.
2
//! https://github.com/llvm-mirror/clang-tools-extra/blob/master/clangd/FuzzyMatch.cpp
3
//!
4
//! # Example:
5
//! ```
6
//! use skim::fuzzy_matcher::FuzzyMatcher;
7
//! use skim::fuzzy_matcher::clangd::ClangdMatcher;
8
//!
9
//! let matcher = ClangdMatcher::default();
10
//!
11
//! assert_eq!(None, matcher.fuzzy_match("abc", "abx"));
12
//! assert!(matcher.fuzzy_match("axbycz", "abc").is_some());
13
//! assert!(matcher.fuzzy_match("axbycz", "xyz").is_some());
14
//!
15
//! let (score, indices) = matcher.fuzzy_indices("axbycz", "abc").unwrap();
16
//! assert_eq!(indices, [0, 2, 4]);
17
//!
18
//! ```
19
//!
20
//! Algorithm modified from
21
//! https://github.com/llvm-mirror/clang-tools-extra/blob/master/clangd/FuzzyMatch.cpp
22
//! Also check: https://github.com/lewang/flx/issues/98
23
use crate::fuzzy_matcher::util::{CharRole, CharType, char_equal, char_role, char_type_of, cheap_matches};
24
use crate::fuzzy_matcher::{FuzzyMatcher, IndexType, MatchIndices, ScoreType};
25
use std::cell::RefCell;
26
use std::cmp::max;
27
use thread_local::ThreadLocal;
28
29
#[derive(Eq, PartialEq, Debug, Copy, Clone)]
30
enum CaseMatching {
31
    Respect,
32
    Ignore,
33
    Smart,
34
}
35
36
#[derive(Debug)]
37
/// Fuzzy matcher using the clangd algorithm
38
pub struct ClangdMatcher {
39
    case: CaseMatching,
40
41
    use_cache: bool,
42
43
    c_cache: ThreadLocal<RefCell<Vec<char>>>, // vector to store the characters of choice
44
    p_cache: ThreadLocal<RefCell<Vec<char>>>, // vector to store the characters of pattern
45
}
46
47
impl Default for ClangdMatcher {
48
20
    fn default() -> Self {
49
20
        Self {
50
20
            case: CaseMatching::Ignore,
51
20
            use_cache: true,
52
20
            c_cache: ThreadLocal::new(),
53
20
            p_cache: ThreadLocal::new(),
54
20
        }
55
20
    }
56
}
57
58
impl ClangdMatcher {
59
    /// Sets the matcher to ignore case when matching
60
    #[must_use]
61
11
    pub fn ignore_case(mut self) -> Self {
62
11
        self.case = CaseMatching::Ignore;
63
11
        self
64
11
    }
65
66
    /// Sets the matcher to use smart case (case insensitive unless pattern contains uppercase)
67
    #[must_use]
68
4
    pub fn smart_case(mut self) -> Self {
69
4
        self.case = CaseMatching::Smart;
70
4
        self
71
4
    }
72
73
    /// Sets the matcher to respect case when matching
74
    #[must_use]
75
2
    pub fn respect_case(mut self) -> Self {
76
2
        self.case = CaseMatching::Respect;
77
2
        self
78
2
    }
79
80
    /// Enables or disables caching for improved performance
81
    #[must_use]
82
2
    pub fn use_cache(mut self, use_cache: bool) -> Self {
83
2
        self.use_cache = use_cache;
84
2
        self
85
2
    }
86
87
53
    fn contains_upper(string: &str) -> bool {
88
53
        for ch in string.chars() {
89
203
            if ch.is_uppercase() {
  Branch (89:16): [True: 0, False: 196]
 
  Branch (89:16): [True: 2, False: 5]
 
90
2
                return true;
91
201
            }
92
        }
93
94
51
        false
95
53
    }
96
97
115
    fn is_case_sensitive(&self, pattern: &str) -> bool {
98
115
        match self.case {
99
3
            CaseMatching::Respect => true,
100
59
            CaseMatching::Ignore => false,
101
53
            CaseMatching::Smart => Self::contains_upper(pattern),
102
        }
103
115
    }
104
}
105
106
impl FuzzyMatcher for ClangdMatcher {
107
68
    fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(ScoreType, MatchIndices)> {
108
68
        let case_sensitive = self.is_case_sensitive(pattern);
109
110
68
        let mut choice_chars = self.c_cache.get_or(|| 
RefCell::new11
(
Vec::new()11
)).borrow_mut();
111
68
        let mut pattern_chars = self.p_cache.get_or(|| 
RefCell::new11
(
Vec::new()11
)).borrow_mut();
112
113
68
        choice_chars.clear();
114
68
        for char in choice.chars() {
115
943
            choice_chars.push(char);
116
        }
117
118
68
        pattern_chars.clear();
119
68
        for char in pattern.chars() {
120
245
            pattern_chars.push(char);
121
        }
122
123
68
        cheap_matches(&choice_chars, &pattern_chars, case_sensitive)
?55
;
124
125
13
        let num_pattern_chars = pattern_chars.len();
126
13
        let num_choice_chars = choice_chars.len();
127
128
13
        let dp = build_graph(&choice_chars, &pattern_chars, false, case_sensitive);
129
130
        // search backwards for the matched indices
131
13
        let mut indices_reverse = Vec::with_capacity(num_pattern_chars);
132
13
        let cell = dp[num_pattern_chars][num_choice_chars];
133
134
13
        let (mut last_action, score) = if cell.hit > cell.missed {
  Branch (134:43): [True: 0, False: 2]
 
  Branch (134:43): [True: 3, False: 8]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/frizbee.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/frizbee.rs.html
index f60aae6e..fbe06109 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/frizbee.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/frizbee.rs.html
@@ -1 +1 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/frizbee.rs
Line
Count
Source
1
//! Matcher using <https://crates.io/crates/frizbee>
2
use std::cell::RefCell;
3
4
use frizbee::{Config, Matcher};
5
6
use crate::CaseMatching;
7
use crate::fuzzy_matcher::{FuzzyMatcher, MatchIndices, ScoreType};
8
9
thread_local! {
10
    /// One reusable frizbee Matcher per thread
11
    static LOCAL_MATCHER: RefCell<Option<Matcher>> = const { RefCell::new(None) };
12
}
13
14
/// Matcher using frizbee,
15
/// the same one that `blink.cmp` uses in neovim
16
/// credits to @saghen
17
pub struct FrizbeeMatcher {
18
    config: Config,
19
}
20
impl Default for FrizbeeMatcher {
21
14
    fn default() -> Self {
22
14
        Self {
23
14
            config: Config {
24
14
                casing: CaseMatching::Respect.into(),
25
14
                ..Config::default()
26
14
            },
27
14
        }
28
14
    }
29
}
30
31
impl FrizbeeMatcher {
32
    /// Set the max typos to use
33
    #[must_use]
34
6
    pub fn max_typos(mut self, typos: Option<usize>) -> Self {
35
6
        self.config.max_typos = Some(typos.map_or(0, |x| 
u16::try_from(x)3
.unwrap_or(
u16::MAX3
)));
36
6
        self
37
6
    }
38
39
    /// Set the case matching strategy
40
    #[must_use]
41
11
    pub fn case(mut self, case: CaseMatching) -> Self {
42
11
        self.config.casing = case.into();
43
11
        self
44
11
    }
45
46
    /// Run `f` with a thread-local matcher configured with this matcher's config
47
    /// and the given needle.
48
111
    fn with_matcher<R>(&self, pattern: &str, f: impl FnOnce(&mut Matcher) -> R) -> R {
49
111
        LOCAL_MATCHER.with(|cell| {
50
111
            let mut slot = cell.borrow_mut();
51
52
111
            let matcher = slot.get_or_insert_with(|| 
Matcher::new11
(
""11
,
&self.config11
));
53
111
            matcher.set_config(self.config.clone());
54
111
            matcher.set_pattern(pattern);
55
111
            f(matcher)
56
111
        })
57
111
    }
58
}
59
60
impl FuzzyMatcher for FrizbeeMatcher {
61
105
    fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(ScoreType, MatchIndices)> {
62
105
        self.with_matcher(pattern, |m| {
63
105
            m.match_one_indices(choice, 0).map(|mut hit| 
{32
64
32
                hit.indices.reverse();
65
                (
66
32
                    hit.score.into(),
67
100
                    
hit.indices.into_iter()32
.map(|index| index as usize).collect(),
68
                )
69
32
            })
70
105
        })
71
105
    }
72
73
6
    fn fuzzy_match(&self, choice: &str, pattern: &str) -> Option<i64> {
74
6
        self.with_matcher(pattern, |m| m.match_one(choice, 0).map(|hit| hit.score.into()))
75
6
    }
76
}
77
78
impl From<CaseMatching> for frizbee::CaseMatching {
79
25
    fn from(case: CaseMatching) -> Self {
80
25
        match case {
81
16
            CaseMatching::Respect => frizbee::CaseMatching::Respect,
82
2
            CaseMatching::Ignore => frizbee::CaseMatching::Ignore,
83
7
            CaseMatching::Smart => frizbee::CaseMatching::Smart,
84
        }
85
25
    }
86
}
87
88
#[cfg(test)]
89
#[cfg_attr(coverage, coverage(off))]
90
mod tests {
91
    use super::*;
92
    use crate::fuzzy_matcher::FuzzyMatcher;
93
94
    #[test]
95
    fn matches_subsequence() {
96
        let m = FrizbeeMatcher::default();
97
        assert!(m.fuzzy_match("foobar", "foo").is_some());
98
        assert!(m.fuzzy_indices("foobar", "foo").is_some());
99
    }
100
101
    #[test]
102
    fn respect_case_variant() {
103
        let m = FrizbeeMatcher::default().case(CaseMatching::Respect);
104
        assert!(m.fuzzy_indices("FooBar", "Foo").is_some());
105
    }
106
107
    #[test]
108
    fn smart_case_variant() {
109
        let m = FrizbeeMatcher::default().case(CaseMatching::Smart);
110
        // Uppercase pattern triggers the case bonus branch.
111
        assert!(m.fuzzy_indices("FooBar", "Foo").is_some());
112
        // Lowercase pattern -> no bonus.
113
        assert!(m.fuzzy_indices("foobar", "foo").is_some());
114
    }
115
116
    #[test]
117
    fn ignore_case_variant() {
118
        let m = FrizbeeMatcher::default().case(CaseMatching::Ignore);
119
        assert!(m.fuzzy_match("FOOBAR", "foo").is_some());
120
    }
121
122
    #[test]
123
    fn max_typos_tolerates_mismatch() {
124
        let m = FrizbeeMatcher::default().max_typos(Some(1));
125
        assert!(m.fuzzy_match("foobar", "fxo").is_some());
126
    }
127
128
    #[test]
129
    fn fuzzy_indices_ignore_case() {
130
        // Ignore case → matching_case_bonus is 0 in fuzzy_indices.
131
        let m = FrizbeeMatcher::default().case(CaseMatching::Ignore);
132
        assert!(m.fuzzy_indices("FOOBAR", "foo").is_some());
133
    }
134
135
    #[test]
136
    fn fuzzy_indices_no_match_returns_none() {
137
        // A non-subsequence pattern exercises the None branch.
138
        let m = FrizbeeMatcher::default();
139
        assert!(m.fuzzy_indices("foobar", "zzz").is_none());
140
    }
141
142
    #[test]
143
    fn fuzzy_match_respect_and_smart_case() {
144
        // fuzzy_match (score-only) across the Respect and Smart case arms.
145
        let respect = FrizbeeMatcher::default().case(CaseMatching::Respect);
146
        assert!(respect.fuzzy_match("FooBar", "Foo").is_some());
147
148
        let smart = FrizbeeMatcher::default().case(CaseMatching::Smart);
149
        assert!(smart.fuzzy_match("FooBar", "Foo").is_some());
150
        assert!(smart.fuzzy_match("foobar", "foo").is_some());
151
    }
152
}
\ No newline at end of file +

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/frizbee.rs
Line
Count
Source
1
//! Matcher using <https://crates.io/crates/frizbee>
2
use std::cell::RefCell;
3
4
use frizbee::{Config, Matcher};
5
6
use crate::CaseMatching;
7
use crate::fuzzy_matcher::{FuzzyMatcher, MatchIndices, ScoreType};
8
9
thread_local! {
10
    /// One reusable frizbee Matcher per thread
11
    static LOCAL_MATCHER: RefCell<Option<Matcher>> = const { RefCell::new(None) };
12
}
13
14
/// Matcher using frizbee,
15
/// the same one that `blink.cmp` uses in neovim
16
/// credits to @saghen
17
pub struct FrizbeeMatcher {
18
    config: Config,
19
}
20
impl Default for FrizbeeMatcher {
21
15
    fn default() -> Self {
22
15
        Self {
23
15
            config: Config {
24
15
                casing: CaseMatching::Respect.into(),
25
15
                ..Config::default()
26
15
            },
27
15
        }
28
15
    }
29
}
30
31
impl FrizbeeMatcher {
32
    /// Set the max typos to use
33
    #[must_use]
34
7
    pub fn max_typos(mut self, typos: Option<usize>) -> Self {
35
7
        self.config.max_typos = Some(typos.map_or(0, |x| 
u16::try_from(x)4
.unwrap_or(
u16::MAX4
)));
36
7
        self
37
7
    }
38
39
    /// Set the case matching strategy
40
    #[must_use]
41
12
    pub fn case(mut self, case: CaseMatching) -> Self {
42
12
        self.config.casing = case.into();
43
12
        self
44
12
    }
45
46
    /// Run `f` with a thread-local matcher configured with this matcher's config
47
    /// and the given needle.
48
111
    fn with_matcher<R>(&self, pattern: &str, f: impl FnOnce(&mut Matcher) -> R) -> R {
49
111
        LOCAL_MATCHER.with(|cell| {
50
111
            let mut slot = cell.borrow_mut();
51
52
111
            let matcher = slot.get_or_insert_with(|| 
Matcher::new11
(
""11
,
&self.config11
));
53
111
            matcher.set_config(self.config.clone());
54
111
            matcher.set_pattern(pattern);
55
111
            f(matcher)
56
111
        })
57
111
    }
58
}
59
60
impl FuzzyMatcher for FrizbeeMatcher {
61
105
    fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(ScoreType, MatchIndices)> {
62
105
        self.with_matcher(pattern, |m| {
63
105
            m.match_one_indices(choice, 0).map(|mut hit| 
{32
64
32
                hit.indices.reverse();
65
                (
66
32
                    hit.score.into(),
67
100
                    
hit.indices.into_iter()32
.map(|index| index as usize).collect(),
68
                )
69
32
            })
70
105
        })
71
105
    }
72
73
6
    fn fuzzy_match(&self, choice: &str, pattern: &str) -> Option<i64> {
74
6
        self.with_matcher(pattern, |m| m.match_one(choice, 0).map(|hit| hit.score.into()))
75
6
    }
76
}
77
78
impl From<CaseMatching> for frizbee::CaseMatching {
79
27
    fn from(case: CaseMatching) -> Self {
80
27
        match case {
81
17
            CaseMatching::Respect => frizbee::CaseMatching::Respect,
82
2
            CaseMatching::Ignore => frizbee::CaseMatching::Ignore,
83
8
            CaseMatching::Smart => frizbee::CaseMatching::Smart,
84
        }
85
27
    }
86
}
87
88
#[cfg(test)]
89
#[cfg_attr(coverage, coverage(off))]
90
mod tests {
91
    use super::*;
92
    use crate::fuzzy_matcher::FuzzyMatcher;
93
94
    #[test]
95
    fn matches_subsequence() {
96
        let m = FrizbeeMatcher::default();
97
        assert!(m.fuzzy_match("foobar", "foo").is_some());
98
        assert!(m.fuzzy_indices("foobar", "foo").is_some());
99
    }
100
101
    #[test]
102
    fn respect_case_variant() {
103
        let m = FrizbeeMatcher::default().case(CaseMatching::Respect);
104
        assert!(m.fuzzy_indices("FooBar", "Foo").is_some());
105
    }
106
107
    #[test]
108
    fn smart_case_variant() {
109
        let m = FrizbeeMatcher::default().case(CaseMatching::Smart);
110
        // Uppercase pattern triggers the case bonus branch.
111
        assert!(m.fuzzy_indices("FooBar", "Foo").is_some());
112
        // Lowercase pattern -> no bonus.
113
        assert!(m.fuzzy_indices("foobar", "foo").is_some());
114
    }
115
116
    #[test]
117
    fn ignore_case_variant() {
118
        let m = FrizbeeMatcher::default().case(CaseMatching::Ignore);
119
        assert!(m.fuzzy_match("FOOBAR", "foo").is_some());
120
    }
121
122
    #[test]
123
    fn max_typos_tolerates_mismatch() {
124
        let m = FrizbeeMatcher::default().max_typos(Some(1));
125
        assert!(m.fuzzy_match("foobar", "fxo").is_some());
126
    }
127
128
    #[test]
129
    fn fuzzy_indices_ignore_case() {
130
        // Ignore case → matching_case_bonus is 0 in fuzzy_indices.
131
        let m = FrizbeeMatcher::default().case(CaseMatching::Ignore);
132
        assert!(m.fuzzy_indices("FOOBAR", "foo").is_some());
133
    }
134
135
    #[test]
136
    fn fuzzy_indices_no_match_returns_none() {
137
        // A non-subsequence pattern exercises the None branch.
138
        let m = FrizbeeMatcher::default();
139
        assert!(m.fuzzy_indices("foobar", "zzz").is_none());
140
    }
141
142
    #[test]
143
    fn fuzzy_match_respect_and_smart_case() {
144
        // fuzzy_match (score-only) across the Respect and Smart case arms.
145
        let respect = FrizbeeMatcher::default().case(CaseMatching::Respect);
146
        assert!(respect.fuzzy_match("FooBar", "Foo").is_some());
147
148
        let smart = FrizbeeMatcher::default().case(CaseMatching::Smart);
149
        assert!(smart.fuzzy_match("FooBar", "Foo").is_some());
150
        assert!(smart.fuzzy_match("foobar", "foo").is_some());
151
    }
152
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/fzy.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/fzy.rs.html index fd3cfeb2..217cdf79 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/fzy.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/fzy.rs.html @@ -1,4 +1,4 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/fzy.rs
Line
Count
Source
1
//! Fuzzy matching algorithm based on fzy by John Hawthorn.
2
//! https://github.com/jhawthorn/fzy
3
//!
4
//! This implements fzy's scoring algorithm which treats fuzzy matching as a
5
//! modified edit-distance problem using dynamic programming (Needleman-Wunsch style).
6
//! It uses two DP matrices:
7
//! - `M[i][j]`: the best possible score using the first `i` chars of the needle
8
//!   and the first `j` chars of the haystack.
9
//! - `D[i][j]`: the best score that *ends with a match* at position `(i, j)`.
10
//!
11
//! This separation enables affine gap penalties: a constant cost to open a gap
12
//! and a linear cost for extending it, plus a bonus for consecutive matches.
13
//!
14
//! All scoring uses integer arithmetic with a ×200 scaling factor for performance.
15
//! The original fzy float constants map as follows:
16
//! - -0.005 → -1
17
//! - -0.01  → -2
18
//! - 0.6    → 120
19
//! - 0.7    → 140
20
//! - 0.8    → 160
21
//! - 0.9    → 180
22
//! - 1.0    → 200
23
//! - -1.5   → -300
24
//!
25
//! # Example:
26
//! ```
27
//! use skim::fuzzy_matcher::FuzzyMatcher;
28
//! use skim::fuzzy_matcher::fzy::FzyMatcher;
29
//!
30
//! let matcher = FzyMatcher::default();
31
//!
32
//! assert_eq!(None, matcher.fuzzy_match("abc", "abx"));
33
//! assert!(matcher.fuzzy_match("axbycz", "abc").is_some());
34
//!
35
//! let (score, indices) = matcher.fuzzy_indices("axbycz", "abc").unwrap();
36
//! assert_eq!(indices, [0, 2, 4]);
37
//! ```
38
39
use std::cell::RefCell;
40
41
use thread_local::ThreadLocal;
42
43
use crate::fuzzy_matcher::util::{char_equal, cheap_matches};
44
use crate::fuzzy_matcher::{FuzzyMatcher, IndexType, MatchIndices, ScoreType};
45
46
// ---------------------------------------------------------------------------
47
// Score constants (from fzy's config.def.h, scaled ×200 to integer)
48
// ---------------------------------------------------------------------------
49
50
/// Sentinel for "impossible" / uninitialized DP cells.
51
/// Uses `i64::MIN / 2` so that adding a penalty never overflows.
52
const SCORE_MIN: i64 = i64::MIN / 2;
53
54
/// Score for an exact-length match (`needle.len()` == `haystack.len()`).
55
const SCORE_MAX: i64 = i64::MAX / 2;
56
57
const SCORE_GAP_LEADING: i64 = -1; // -0.005 × 200
58
const SCORE_GAP_TRAILING: i64 = -1; // -0.005 × 200
59
const SCORE_GAP_INNER: i64 = -2; // -0.01  × 200
60
61
const SCORE_MATCH_CONSECUTIVE: i64 = 200; // 1.0 × 200
62
const SCORE_MATCH_SLASH: i64 = 180; // 0.9 × 200
63
const SCORE_MATCH_WORD: i64 = 160; // 0.8 × 200
64
const SCORE_MATCH_CAPITAL: i64 = 140; // 0.7 × 200
65
const SCORE_MATCH_DOT: i64 = 120; // 0.6 × 200
66
67
/// Penalty applied when a typo is used (substitution or needle-char deletion).
68
const SCORE_TYPO: i64 = -300; // -1.5 × 200
69
70
/// Maximum haystack length we will score.
71
const MATCH_MAX_LEN: usize = 1024;
72
73
/// Conversion factor from internal ×200 scores to skim's ×1000 convention.
74
/// `internal_score` × `SCORE_TO_SKIM` = `skim_score`
75
const SCORE_TO_SKIM: i64 = 5; // 1000 / 200
76
77
// ---------------------------------------------------------------------------
78
// Bonus computation
79
// ---------------------------------------------------------------------------
80
81
#[inline]
82
923
fn bonus_index(ch: char) -> usize {
83
923
    if ch.is_ascii_uppercase() {
  Branch (83:8): [True: 0, False: 418]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/fzy.rs
Line
Count
Source
1
//! Fuzzy matching algorithm based on fzy by John Hawthorn.
2
//! https://github.com/jhawthorn/fzy
3
//!
4
//! This implements fzy's scoring algorithm which treats fuzzy matching as a
5
//! modified edit-distance problem using dynamic programming (Needleman-Wunsch style).
6
//! It uses two DP matrices:
7
//! - `M[i][j]`: the best possible score using the first `i` chars of the needle
8
//!   and the first `j` chars of the haystack.
9
//! - `D[i][j]`: the best score that *ends with a match* at position `(i, j)`.
10
//!
11
//! This separation enables affine gap penalties: a constant cost to open a gap
12
//! and a linear cost for extending it, plus a bonus for consecutive matches.
13
//!
14
//! All scoring uses integer arithmetic with a ×200 scaling factor for performance.
15
//! The original fzy float constants map as follows:
16
//! - -0.005 → -1
17
//! - -0.01  → -2
18
//! - 0.6    → 120
19
//! - 0.7    → 140
20
//! - 0.8    → 160
21
//! - 0.9    → 180
22
//! - 1.0    → 200
23
//! - -1.5   → -300
24
//!
25
//! # Example:
26
//! ```
27
//! use skim::fuzzy_matcher::FuzzyMatcher;
28
//! use skim::fuzzy_matcher::fzy::FzyMatcher;
29
//!
30
//! let matcher = FzyMatcher::default();
31
//!
32
//! assert_eq!(None, matcher.fuzzy_match("abc", "abx"));
33
//! assert!(matcher.fuzzy_match("axbycz", "abc").is_some());
34
//!
35
//! let (score, indices) = matcher.fuzzy_indices("axbycz", "abc").unwrap();
36
//! assert_eq!(indices, [0, 2, 4]);
37
//! ```
38
39
use std::cell::RefCell;
40
41
use thread_local::ThreadLocal;
42
43
use crate::fuzzy_matcher::util::{char_equal, cheap_matches};
44
use crate::fuzzy_matcher::{FuzzyMatcher, IndexType, MatchIndices, ScoreType};
45
46
// ---------------------------------------------------------------------------
47
// Score constants (from fzy's config.def.h, scaled ×200 to integer)
48
// ---------------------------------------------------------------------------
49
50
/// Sentinel for "impossible" / uninitialized DP cells.
51
/// Uses `i64::MIN / 2` so that adding a penalty never overflows.
52
const SCORE_MIN: i64 = i64::MIN / 2;
53
54
/// Score for an exact-length match (`needle.len()` == `haystack.len()`).
55
const SCORE_MAX: i64 = i64::MAX / 2;
56
57
const SCORE_GAP_LEADING: i64 = -1; // -0.005 × 200
58
const SCORE_GAP_TRAILING: i64 = -1; // -0.005 × 200
59
const SCORE_GAP_INNER: i64 = -2; // -0.01  × 200
60
61
const SCORE_MATCH_CONSECUTIVE: i64 = 200; // 1.0 × 200
62
const SCORE_MATCH_SLASH: i64 = 180; // 0.9 × 200
63
const SCORE_MATCH_WORD: i64 = 160; // 0.8 × 200
64
const SCORE_MATCH_CAPITAL: i64 = 140; // 0.7 × 200
65
const SCORE_MATCH_DOT: i64 = 120; // 0.6 × 200
66
67
/// Penalty applied when a typo is used (substitution or needle-char deletion).
68
const SCORE_TYPO: i64 = -300; // -1.5 × 200
69
70
/// Maximum haystack length we will score.
71
const MATCH_MAX_LEN: usize = 1024;
72
73
/// Conversion factor from internal ×200 scores to skim's ×1000 convention.
74
/// `internal_score` × `SCORE_TO_SKIM` = `skim_score`
75
const SCORE_TO_SKIM: i64 = 5; // 1000 / 200
76
77
// ---------------------------------------------------------------------------
78
// Bonus computation
79
// ---------------------------------------------------------------------------
80
81
#[inline]
82
923
fn bonus_index(ch: char) -> usize {
83
923
    if ch.is_ascii_uppercase() {
  Branch (83:8): [True: 0, False: 418]
 
  Branch (83:8): [True: 46, False: 459]
 
84
46
        2
85
    } else {
86
877
        usize::from(ch.is_ascii_lowercase() || 
ch.is_ascii_digit()104
)
  Branch (86:21): [True: 344, False: 74]
 
  Branch (86:21): [True: 429, False: 30]
@@ -156,7 +156,7 @@
 
  Branch (671:8): [True: 13, False: 85]
 
672
13
        ScoreType::MAX / 2
673
109
    } else if score == SCORE_MIN {
  Branch (673:15): [True: 0, False: 24]
 
  Branch (673:15): [True: 1, False: 84]
-
674
1
        ScoreType::MIN / 2
675
    } else {
676
        // Saturate rather than panic: the DP sentinel (SCORE_MIN) can end up
677
        // offset by a few accumulated bonuses/penalties along a path that's
678
        // still effectively "no match" (see the fuzz-found case-folding bug
679
        // this fixed), landing close to but not exactly on SCORE_MIN/SCORE_MAX.
680
108
        score.saturating_mul(SCORE_TO_SKIM)
681
    }
682
122
}
683
684
// ---------------------------------------------------------------------------
685
// Public matcher struct
686
// ---------------------------------------------------------------------------
687
688
#[derive(Eq, PartialEq, Debug, Copy, Clone)]
689
enum CaseMatching {
690
    Respect,
691
    Ignore,
692
    Smart,
693
}
694
695
/// Fuzzy matcher using the fzy algorithm.
696
///
697
/// This is a clean reimplementation of the scoring algorithm from
698
/// [fzy](https://github.com/jhawthorn/fzy) by John Hawthorn.
699
///
700
/// Supports optional typo tolerance via [`max_typos`](Self::max_typos).
701
#[derive(Debug)]
702
pub struct FzyMatcher {
703
    case: CaseMatching,
704
    use_cache: bool,
705
    max_typos: Option<usize>,
706
    c_cache: ThreadLocal<RefCell<Vec<char>>>,
707
    p_cache: ThreadLocal<RefCell<Vec<char>>>,
708
    lc_cache: ThreadLocal<RefCell<Vec<char>>>,
709
    lp_cache: ThreadLocal<RefCell<Vec<char>>>,
710
    typo_bufs: ThreadLocal<RefCell<TypoDpBuffers>>,
711
}
712
713
impl Default for FzyMatcher {
714
71
    fn default() -> Self {
715
71
        Self {
716
71
            case: CaseMatching::Ignore,
717
71
            use_cache: true,
718
71
            max_typos: None,
719
71
            c_cache: ThreadLocal::new(),
720
71
            p_cache: ThreadLocal::new(),
721
71
            lc_cache: ThreadLocal::new(),
722
71
            lp_cache: ThreadLocal::new(),
723
71
            typo_bufs: ThreadLocal::new(),
724
71
        }
725
71
    }
726
}
727
728
impl FzyMatcher {
729
    /// Sets the matcher to ignore case when matching.
730
    #[must_use]
731
57
    pub fn ignore_case(mut self) -> Self {
732
57
        self.case = CaseMatching::Ignore;
733
57
        self
734
57
    }
735
736
    /// Sets the matcher to use smart case.
737
    #[must_use]
738
6
    pub fn smart_case(mut self) -> Self {
739
6
        self.case = CaseMatching::Smart;
740
6
        self
741
6
    }
742
743
    /// Sets the matcher to respect case exactly.
744
    #[must_use]
745
6
    pub fn respect_case(mut self) -> Self {
746
6
        self.case = CaseMatching::Respect;
747
6
        self
748
6
    }
749
750
    /// Enables or disables thread-local caching.
751
    #[must_use]
752
2
    pub fn use_cache(mut self, use_cache: bool) -> Self {
753
2
        self.use_cache = use_cache;
754
2
        self
755
2
    }
756
757
    /// Sets the maximum number of typos allowed during matching.
758
    ///
759
    /// - `None` (default): strict subsequence matching with no typos.
760
    /// - `Some(n)`: allows up to `n` typos.
761
    #[must_use]
762
37
    pub fn max_typos(mut self, max_typos: Option<usize>) -> Self {
763
37
        self.max_typos = max_typos;
764
37
        self
765
37
    }
766
767
102
    fn contains_upper(string: &str) -> bool {
768
102
        string.chars().any(char::is_uppercase)
769
102
    }
770
771
231
    fn is_case_sensitive(&self, pattern: &str) -> bool {
772
231
        match self.case {
773
15
            CaseMatching::Respect => true,
774
114
            CaseMatching::Ignore => false,
775
102
            CaseMatching::Smart => Self::contains_upper(pattern),
776
        }
777
231
    }
778
}
779
780
impl FuzzyMatcher for FzyMatcher {
781
148
    fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(ScoreType, MatchIndices)> {
782
148
        let case_sensitive = self.is_case_sensitive(pattern);
783
784
148
        let mut choice_chars = self.c_cache.get_or(|| 
RefCell::new24
(
Vec::new()24
)).borrow_mut();
785
148
        let mut pattern_chars = self.p_cache.get_or(|| 
RefCell::new24
(
Vec::new()24
)).borrow_mut();
786
787
148
        choice_chars.clear();
788
148
        choice_chars.extend(choice.chars());
789
148
        pattern_chars.clear();
790
148
        pattern_chars.extend(pattern.chars());
791
792
148
        match self.max_typos {
793
            None => {
794
76
                cheap_matches(&choice_chars, &pattern_chars, case_sensitive)
?54
;
795
22
                let mut positions = Vec::with_capacity(pattern_chars.len());
796
22
                let s = fzy_score(&pattern_chars, &choice_chars, case_sensitive, Some(&mut positions))
?0
;
797
22
                Some((internal_to_skim_score(s), MatchIndices::from(positions)))
798
            }
799
            Some(max_t) => {
800
                // Fast path: try exact subsequence match first
801
72
                if cheap_matches(&choice_chars, &pattern_chars, case_sensitive).is_some() {
  Branch (801:20): [True: 2, False: 47]
+
674
1
        ScoreType::MIN / 2
675
    } else {
676
        // Saturate rather than panic: the DP sentinel (SCORE_MIN) can end up
677
        // offset by a few accumulated bonuses/penalties along a path that's
678
        // still effectively "no match" (see the fuzz-found case-folding bug
679
        // this fixed), landing close to but not exactly on SCORE_MIN/SCORE_MAX.
680
108
        score.saturating_mul(SCORE_TO_SKIM)
681
    }
682
122
}
683
684
// ---------------------------------------------------------------------------
685
// Public matcher struct
686
// ---------------------------------------------------------------------------
687
688
#[derive(Eq, PartialEq, Debug, Copy, Clone)]
689
enum CaseMatching {
690
    Respect,
691
    Ignore,
692
    Smart,
693
}
694
695
/// Fuzzy matcher using the fzy algorithm.
696
///
697
/// This is a clean reimplementation of the scoring algorithm from
698
/// [fzy](https://github.com/jhawthorn/fzy) by John Hawthorn.
699
///
700
/// Supports optional typo tolerance via [`max_typos`](Self::max_typos).
701
#[derive(Debug)]
702
pub struct FzyMatcher {
703
    case: CaseMatching,
704
    use_cache: bool,
705
    max_typos: Option<usize>,
706
    c_cache: ThreadLocal<RefCell<Vec<char>>>,
707
    p_cache: ThreadLocal<RefCell<Vec<char>>>,
708
    lc_cache: ThreadLocal<RefCell<Vec<char>>>,
709
    lp_cache: ThreadLocal<RefCell<Vec<char>>>,
710
    typo_bufs: ThreadLocal<RefCell<TypoDpBuffers>>,
711
}
712
713
impl Default for FzyMatcher {
714
70
    fn default() -> Self {
715
70
        Self {
716
70
            case: CaseMatching::Ignore,
717
70
            use_cache: true,
718
70
            max_typos: None,
719
70
            c_cache: ThreadLocal::new(),
720
70
            p_cache: ThreadLocal::new(),
721
70
            lc_cache: ThreadLocal::new(),
722
70
            lp_cache: ThreadLocal::new(),
723
70
            typo_bufs: ThreadLocal::new(),
724
70
        }
725
70
    }
726
}
727
728
impl FzyMatcher {
729
    /// Sets the matcher to ignore case when matching.
730
    #[must_use]
731
57
    pub fn ignore_case(mut self) -> Self {
732
57
        self.case = CaseMatching::Ignore;
733
57
        self
734
57
    }
735
736
    /// Sets the matcher to use smart case.
737
    #[must_use]
738
5
    pub fn smart_case(mut self) -> Self {
739
5
        self.case = CaseMatching::Smart;
740
5
        self
741
5
    }
742
743
    /// Sets the matcher to respect case exactly.
744
    #[must_use]
745
6
    pub fn respect_case(mut self) -> Self {
746
6
        self.case = CaseMatching::Respect;
747
6
        self
748
6
    }
749
750
    /// Enables or disables thread-local caching.
751
    #[must_use]
752
2
    pub fn use_cache(mut self, use_cache: bool) -> Self {
753
2
        self.use_cache = use_cache;
754
2
        self
755
2
    }
756
757
    /// Sets the maximum number of typos allowed during matching.
758
    ///
759
    /// - `None` (default): strict subsequence matching with no typos.
760
    /// - `Some(n)`: allows up to `n` typos.
761
    #[must_use]
762
36
    pub fn max_typos(mut self, max_typos: Option<usize>) -> Self {
763
36
        self.max_typos = max_typos;
764
36
        self
765
36
    }
766
767
102
    fn contains_upper(string: &str) -> bool {
768
102
        string.chars().any(char::is_uppercase)
769
102
    }
770
771
231
    fn is_case_sensitive(&self, pattern: &str) -> bool {
772
231
        match self.case {
773
15
            CaseMatching::Respect => true,
774
114
            CaseMatching::Ignore => false,
775
102
            CaseMatching::Smart => Self::contains_upper(pattern),
776
        }
777
231
    }
778
}
779
780
impl FuzzyMatcher for FzyMatcher {
781
148
    fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(ScoreType, MatchIndices)> {
782
148
        let case_sensitive = self.is_case_sensitive(pattern);
783
784
148
        let mut choice_chars = self.c_cache.get_or(|| 
RefCell::new24
(
Vec::new()24
)).borrow_mut();
785
148
        let mut pattern_chars = self.p_cache.get_or(|| 
RefCell::new24
(
Vec::new()24
)).borrow_mut();
786
787
148
        choice_chars.clear();
788
148
        choice_chars.extend(choice.chars());
789
148
        pattern_chars.clear();
790
148
        pattern_chars.extend(pattern.chars());
791
792
148
        match self.max_typos {
793
            None => {
794
76
                cheap_matches(&choice_chars, &pattern_chars, case_sensitive)
?54
;
795
22
                let mut positions = Vec::with_capacity(pattern_chars.len());
796
22
                let s = fzy_score(&pattern_chars, &choice_chars, case_sensitive, Some(&mut positions))
?0
;
797
22
                Some((internal_to_skim_score(s), MatchIndices::from(positions)))
798
            }
799
            Some(max_t) => {
800
                // Fast path: try exact subsequence match first
801
72
                if cheap_matches(&choice_chars, &pattern_chars, case_sensitive).is_some() {
  Branch (801:20): [True: 2, False: 47]
 
  Branch (801:20): [True: 3, False: 20]
 
802
5
                    let mut positions = Vec::with_capacity(pattern_chars.len());
803
5
                    if let Some(s) = fzy_score(&pattern_chars, &choice_chars, case_sensitive, Some(&mut positions)) {
  Branch (803:28): [True: 2, False: 0]
 
  Branch (803:28): [True: 1, False: 2]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/mod.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/mod.rs.html
index 7a5a2285..a1474800 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/mod.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/mod.rs.html
@@ -1 +1 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/mod.rs
Line
Count
Source
1
//! Fuzzy matching algorithms and implementations.
2
//!
3
//! This module provides different fuzzy matching algorithms including
4
//! skim's own algorithm and clangd's algorithm for matching text patterns.
5
6
/// Arinae fuzzy matching algorithm (Smith-Waterman with affine gaps)
7
pub mod arinae;
8
/// Clangd fuzzy matching algorithm
9
pub mod clangd;
10
#[cfg(feature = "frizbee")]
11
pub mod frizbee;
12
/// Fzy fuzzy matching algorithm
13
pub mod fzy;
14
/// Skim fuzzy matching algorithm
15
pub mod skim;
16
mod util;
17
18
pub(crate) type IndexType = usize;
19
pub(crate) type ScoreType = i64;
20
21
pub(crate) type MatchIndices = Vec<IndexType>;
22
23
/// Trait for fuzzy matching text patterns against choices
24
pub trait FuzzyMatcher: Send + Sync {
25
    /// fuzzy match choice with pattern, and return the score & matched indices of characters
26
    fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(i64, MatchIndices)>;
27
28
    /// fuzzy match choice with pattern, and return the score of matching
29
2
    fn fuzzy_match(&self, choice: &str, pattern: &str) -> Option<i64> {
30
2
        self.fuzzy_indices(choice, pattern).map(|(score, _)| score)
31
2
    }
32
33
    /// Fuzzy match and return (score, `begin_char_index`, `end_char_index`) without
34
    /// computing per-character match indices. This avoids the Vec allocation and
35
    /// traceback that `fuzzy_indices` requires, making it much faster for ranking.
36
    ///
37
    /// `begin` is the character index of the first matched pattern character,
38
    /// `end` is the character index of the last matched pattern character.
39
    ///
40
    /// Default implementation falls back to `fuzzy_indices`.
41
5
    fn fuzzy_match_range(&self, choice: &str, pattern: &str) -> Option<(i64, usize, usize)> {
42
5
        self.fuzzy_indices(choice, pattern).map(|(score, indices)| 
{3
43
3
            let begin = indices.first().copied().unwrap_or(0);
44
3
            let end = indices.last().copied().unwrap_or(0);
45
3
            (score, begin, end)
46
3
        })
47
5
    }
48
}
49
50
#[cfg(test)]
51
#[cfg_attr(coverage, coverage(off))]
52
mod tests {
53
    use super::*;
54
55
    /// A matcher that only implements `fuzzy_indices`, so it exercises the
56
    /// default `fuzzy_match` / `fuzzy_match_range` implementations.
57
    struct StubMatcher;
58
59
    impl FuzzyMatcher for StubMatcher {
60
        fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(i64, MatchIndices)> {
61
            if pattern.is_empty() {
62
                return Some((0, vec![]));
63
            }
64
            // Match only when the pattern is a prefix of the choice.
65
            choice
66
                .starts_with(pattern)
67
                .then(|| (10, (0..pattern.chars().count()).collect()))
68
        }
69
    }
70
71
    #[test]
72
    fn default_fuzzy_match_uses_indices_score() {
73
        assert_eq!(StubMatcher.fuzzy_match("hello", "he"), Some(10));
74
        assert_eq!(StubMatcher.fuzzy_match("hello", "xy"), None);
75
    }
76
77
    #[test]
78
    fn default_fuzzy_match_range_spans_first_to_last() {
79
        assert_eq!(StubMatcher.fuzzy_match_range("hello", "hel"), Some((10, 0, 2)));
80
        assert_eq!(StubMatcher.fuzzy_match_range("hello", "zz"), None);
81
    }
82
83
    #[test]
84
    fn default_fuzzy_match_range_empty_indices_default_to_zero() {
85
        // Empty pattern yields an empty index list, so begin/end fall back to 0.
86
        assert_eq!(StubMatcher.fuzzy_match_range("hello", ""), Some((0, 0, 0)));
87
    }
88
89
    /// Regression test for a fuzzer-found panic (fuzz target `fuzzy_match`).
90
    ///
91
    /// 'İ' (U+0130) lowercases to two chars, and `char_equal` used to be
92
    /// asymmetric for such characters. `cheap_matches` compares
93
    /// `(choice, pattern)` while the matchers' `allow_match` helpers compare
94
    /// `(pattern, choice)`, so the cheap pre-filter accepted a candidate the DP
95
    /// then refused to match. The clangd matcher's backtracking loop walked off
96
    /// the start of its matrix, panicking with "attempt to subtract with
97
    /// overflow" in debug and an out-of-bounds index in release.
98
    #[test]
99
    fn multichar_lowercase_does_not_panic() {
100
        use crate::fuzzy_matcher::clangd::ClangdMatcher;
101
        use crate::fuzzy_matcher::fzy::FzyMatcher;
102
        use crate::fuzzy_matcher::skim::SkimMatcherV2;
103
104
        let skim = SkimMatcherV2::default();
105
        let fzy = FzyMatcher::default();
106
        let clangd = ClangdMatcher::default();
107
        let matchers: [(&str, &dyn FuzzyMatcher); 3] = [("skim", &skim), ("fzy", &fzy), ("clangd", &clangd)];
108
109
        // The exact crashing input from the fuzz artifact, plus related shapes.
110
        let cases = [
111
            ("Jİ:I", "İ:İ"),
112
            ("I", "İ"),
113
            ("İ", "I"),
114
            ("i", "İ"),
115
            ("İ", "i"),
116
            ("Jİ:Iİ", "İİ"),
117
            ("straße", "STRASSE"),
118
            ("ffly", "ffl"),
119
        ];
120
121
        for (choice, pattern) in cases {
122
            let num_chars = choice.chars().count();
123
            for (name, matcher) in matchers {
124
                // Must not panic, and any returned index must be a valid char
125
                // index into `choice` (the invariant asserted by the fuzzer).
126
                if let Some((_score, indices)) = matcher.fuzzy_indices(choice, pattern) {
127
                    for idx in indices {
128
                        assert!(
129
                            idx < num_chars,
130
                            "{name}: match index {idx} out of bounds for {choice:?} ({num_chars} chars)"
131
                        );
132
                    }
133
                }
134
            }
135
        }
136
    }
137
}
\ No newline at end of file +

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/mod.rs
Line
Count
Source
1
//! Fuzzy matching algorithms and implementations.
2
//!
3
//! This module provides different fuzzy matching algorithms including
4
//! skim's own algorithm and clangd's algorithm for matching text patterns.
5
6
/// Arinae fuzzy matching algorithm (Smith-Waterman with affine gaps)
7
pub mod arinae;
8
/// Clangd fuzzy matching algorithm
9
pub mod clangd;
10
#[cfg(feature = "frizbee")]
11
pub mod frizbee;
12
/// Fzy fuzzy matching algorithm
13
pub mod fzy;
14
/// Skim fuzzy matching algorithm
15
pub mod skim;
16
mod util;
17
18
pub(crate) type IndexType = usize;
19
pub(crate) type ScoreType = i64;
20
21
pub(crate) type MatchIndices = Vec<IndexType>;
22
23
/// Trait for fuzzy matching text patterns against choices
24
pub trait FuzzyMatcher: Send + Sync {
25
    /// fuzzy match choice with pattern, and return the score & matched indices of characters
26
    fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(i64, MatchIndices)>;
27
28
    /// fuzzy match choice with pattern, and return the score of matching
29
2
    fn fuzzy_match(&self, choice: &str, pattern: &str) -> Option<i64> {
30
2
        self.fuzzy_indices(choice, pattern).map(|(score, _)| score)
31
2
    }
32
33
    /// Fuzzy match and return (score, `begin_char_index`, `end_char_index`) without
34
    /// computing per-character match indices. This avoids the Vec allocation and
35
    /// traceback that `fuzzy_indices` requires, making it much faster for ranking.
36
    ///
37
    /// `begin` is the character index of the first matched pattern character,
38
    /// `end` is the character index of the last matched pattern character.
39
    ///
40
    /// Default implementation falls back to `fuzzy_indices`.
41
5
    fn fuzzy_match_range(&self, choice: &str, pattern: &str) -> Option<(i64, usize, usize)> {
42
5
        self.fuzzy_indices(choice, pattern).map(|(score, indices)| 
{3
43
3
            let begin = indices.first().copied().unwrap_or(0);
44
3
            let end = indices.last().copied().unwrap_or(0);
45
3
            (score, begin, end)
46
3
        })
47
5
    }
48
}
49
50
#[cfg(test)]
51
#[cfg_attr(coverage, coverage(off))]
52
mod tests {
53
    use super::*;
54
55
    /// A matcher that only implements `fuzzy_indices`, so it exercises the
56
    /// default `fuzzy_match` / `fuzzy_match_range` implementations.
57
    struct StubMatcher;
58
59
    impl FuzzyMatcher for StubMatcher {
60
        fn fuzzy_indices(&self, choice: &str, pattern: &str) -> Option<(i64, MatchIndices)> {
61
            if pattern.is_empty() {
62
                return Some((0, vec![]));
63
            }
64
            // Match only when the pattern is a prefix of the choice.
65
            choice
66
                .starts_with(pattern)
67
                .then(|| (10, (0..pattern.chars().count()).collect()))
68
        }
69
    }
70
71
    #[test]
72
    fn default_fuzzy_match_uses_indices_score() {
73
        assert_eq!(StubMatcher.fuzzy_match("hello", "he"), Some(10));
74
        assert_eq!(StubMatcher.fuzzy_match("hello", "xy"), None);
75
    }
76
77
    #[test]
78
    fn default_fuzzy_match_range_spans_first_to_last() {
79
        assert_eq!(StubMatcher.fuzzy_match_range("hello", "hel"), Some((10, 0, 2)));
80
        assert_eq!(StubMatcher.fuzzy_match_range("hello", "zz"), None);
81
    }
82
83
    #[test]
84
    fn default_fuzzy_match_range_empty_indices_default_to_zero() {
85
        // Empty pattern yields an empty index list, so begin/end fall back to 0.
86
        assert_eq!(StubMatcher.fuzzy_match_range("hello", ""), Some((0, 0, 0)));
87
    }
88
89
    /// Regression test for a fuzzer-found panic (fuzz target `fuzzy_match`).
90
    ///
91
    /// 'İ' (U+0130) lowercases to two chars, and `char_equal` used to be
92
    /// asymmetric for such characters. `cheap_matches` compares
93
    /// `(choice, pattern)` while the matchers' `allow_match` helpers compare
94
    /// `(pattern, choice)`, so the cheap pre-filter accepted a candidate the DP
95
    /// then refused to match. The clangd matcher's backtracking loop walked off
96
    /// the start of its matrix, panicking with "attempt to subtract with
97
    /// overflow" in debug and an out-of-bounds index in release.
98
    #[test]
99
    fn multichar_lowercase_does_not_panic() {
100
        use crate::fuzzy_matcher::clangd::ClangdMatcher;
101
        use crate::fuzzy_matcher::fzy::FzyMatcher;
102
        use crate::fuzzy_matcher::skim::SkimMatcherV2;
103
104
        let skim = SkimMatcherV2::default();
105
        let fzy = FzyMatcher::default();
106
        let clangd = ClangdMatcher::default();
107
        let matchers: [(&str, &dyn FuzzyMatcher); 3] = [("skim", &skim), ("fzy", &fzy), ("clangd", &clangd)];
108
109
        // The exact crashing input from the fuzz artifact, plus related shapes.
110
        let cases = [
111
            ("Jİ:I", "İ:İ"),
112
            ("I", "İ"),
113
            ("İ", "I"),
114
            ("i", "İ"),
115
            ("İ", "i"),
116
            ("Jİ:Iİ", "İİ"),
117
            ("straße", "STRASSE"),
118
            ("ffly", "ffl"),
119
        ];
120
121
        for (choice, pattern) in cases {
122
            let num_chars = choice.chars().count();
123
            for (name, matcher) in matchers {
124
                // Must not panic, and any returned index must be a valid char
125
                // index into `choice` (the invariant asserted by the fuzzer).
126
                if let Some((_score, indices)) = matcher.fuzzy_indices(choice, pattern) {
127
                    for idx in indices {
128
                        assert!(
129
                            idx < num_chars,
130
                            "{name}: match index {idx} out of bounds for {choice:?} ({num_chars} chars)"
131
                        );
132
                    }
133
                }
134
            }
135
        }
136
    }
137
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/skim.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/skim.rs.html index 77e245a6..55f3016a 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/skim.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/skim.rs.html @@ -1,4 +1,4 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/skim.rs
Line
Count
Source
1
//! The fuzzy matching algorithm used by skim
2
//!
3
//! # Example:
4
//! ```
5
//! use skim::fuzzy_matcher::FuzzyMatcher;
6
//! use skim::fuzzy_matcher::skim::SkimMatcherV2;
7
//!
8
//! let matcher = SkimMatcherV2::default();
9
//! assert_eq!(None, matcher.fuzzy_match("abc", "abx"));
10
//! assert!(matcher.fuzzy_match("axbycz", "abc").is_some());
11
//! assert!(matcher.fuzzy_match("axbycz", "xyz").is_some());
12
//!
13
//! let (score, indices) = matcher.fuzzy_indices("axbycz", "abc").unwrap();
14
//! assert_eq!(indices, [0, 2, 4]);
15
//! ```
16
17
#![allow(deprecated)]
18
19
use std::cell::RefCell;
20
use std::cmp::max;
21
use std::fmt::Formatter;
22
23
use thread_local::ThreadLocal;
24
25
use super::skim::Movement::{Match, Skip};
26
use super::util::{char_equal, cheap_matches};
27
use super::{FuzzyMatcher, IndexType, MatchIndices, ScoreType};
28
29
#[derive(Copy, Clone, Debug)]
30
/// Configuration for skim's scoring algorithm
31
pub struct SkimScoreConfig {
32
    /// Score for each matched character
33
    pub score_match: i32,
34
    /// Penalty for starting a gap (unmatched characters)
35
    pub gap_start: i32,
36
    /// Penalty for extending a gap
37
    pub gap_extension: i32,
38
39
    /// The first character in the typed pattern usually has more significance
40
    /// than the rest so it's important that it appears at special positions where
41
    /// bonus points are given. e.g. "to-go" vs. "ongoing" on "og" or on "ogo".
42
    /// The amount of the extra bonus should be limited so that the gap penalty is
43
    /// still respected.
44
    pub bonus_first_char_multiplier: i32,
45
46
    /// We prefer matches at the beginning of a word, but the bonus should not be
47
    /// too great to prevent the longer acronym matches from always winning over
48
    /// shorter fuzzy matches. The bonus point here was specifically chosen that
49
    /// the bonus is cancelled when the gap between the acronyms grows over
50
    /// 8 characters, which is approximately the average length of the words found
51
    /// in web2 dictionary and my file system.
52
    pub bonus_head: i32,
53
54
    /// Just like `bonus_head`, but its breakage of word is not that strong, so it should
55
    /// be slighter less then `bonus_head`
56
    pub bonus_break: i32,
57
58
    /// Edge-triggered bonus for matches in camelCase words.
59
    /// Compared to word-boundary case, they don't accompany single-character gaps
60
    /// (e.g. `FooBar` vs. foo-bar), so we deduct bonus point accordingly.
61
    pub bonus_camel: i32,
62
63
    /// Minimum bonus point given to characters in consecutive chunks.
64
    /// Note that bonus points for consecutive matches shouldn't have needed if we
65
    /// used fixed match score as in the original algorithm.
66
    pub bonus_consecutive: i32,
67
68
    /// Skim will match case-sensitively if the pattern contains ASCII upper case,
69
    /// If case of case insensitive match, the penalty will be given to case mismatch
70
    pub penalty_case_mismatch: i32,
71
}
72
73
impl Default for SkimScoreConfig {
74
39
    fn default() -> Self {
75
39
        let score_match = 16;
76
39
        let gap_start = -3;
77
39
        let gap_extension = -1;
78
39
        let bonus_first_char_multiplier = 2;
79
80
39
        Self {
81
39
            score_match,
82
39
            gap_start,
83
39
            gap_extension,
84
39
            bonus_first_char_multiplier,
85
39
            bonus_head: score_match / 2,
86
39
            bonus_break: score_match / 2 + gap_extension,
87
39
            bonus_camel: score_match / 2 + 2 * gap_extension,
88
39
            bonus_consecutive: -(gap_start + gap_extension),
89
39
            penalty_case_mismatch: gap_extension * 2,
90
39
        }
91
39
    }
92
}
93
94
#[derive(Debug, Copy, Clone, PartialEq)]
95
enum Movement {
96
    Match,
97
    Skip,
98
}
99
100
/// Inner state of the score matrix
101
// Implementation detail: tried to pad to 16B
102
// will store the m and p matrix together
103
#[derive(Clone, Debug)]
104
struct MatrixCell {
105
    pub m_move: Movement,
106
    pub m_score: i32,
107
    pub p_move: Movement,
108
    pub p_score: i32, // The max score of align pattern[..i] & choice[..j]
109
110
    // temporary fields (make use the rest of the padding)
111
    pub matched: bool,
112
    pub bonus: i32,
113
}
114
115
const MATRIX_CELL_NEG_INFINITY: i32 = i16::MIN as i32;
116
117
impl Default for MatrixCell {
118
129
    fn default() -> Self {
119
129
        Self {
120
129
            m_move: Skip,
121
129
            m_score: MATRIX_CELL_NEG_INFINITY,
122
129
            p_move: Skip,
123
129
            p_score: MATRIX_CELL_NEG_INFINITY,
124
129
            matched: false,
125
129
            bonus: 0,
126
129
        }
127
129
    }
128
}
129
130
impl MatrixCell {
131
1.28k
    pub fn reset(&mut self) {
132
1.28k
        self.m_move = Skip;
133
1.28k
        self.m_score = MATRIX_CELL_NEG_INFINITY;
134
1.28k
        self.p_move = Skip;
135
1.28k
        self.p_score = MATRIX_CELL_NEG_INFINITY;
136
1.28k
        self.bonus = 0;
137
1.28k
        self.matched = false;
138
1.28k
    }
139
}
140
141
/// Simulate a 1-D vector as 2-D matrix
142
struct ScoreMatrix<'a> {
143
    matrix: &'a mut [MatrixCell],
144
    pub rows: usize,
145
    pub cols: usize,
146
}
147
148
impl<'a> ScoreMatrix<'a> {
149
    /// given a matrix, extend it to be (rows x cols) and fill in as `init_val`
150
129
    pub fn new(matrix: &'a mut Vec<MatrixCell>, rows: usize, cols: usize) -> Self {
151
129
        matrix.resize(rows * cols, MatrixCell::default());
152
129
        ScoreMatrix { matrix, rows, cols }
153
129
    }
154
155
    #[inline]
156
2.68k
    fn get_index(&self, row: usize, col: usize) -> usize {
157
2.68k
        row * self.cols + col
158
2.68k
    }
159
160
129
    fn get_row(&self, row: usize) -> &[MatrixCell] {
161
129
        let start = row * self.cols;
162
129
        &self.matrix[start..start + self.cols]
163
129
    }
164
}
165
166
impl std::ops::Index<(usize, usize)> for ScoreMatrix<'_> {
167
    type Output = MatrixCell;
168
169
490
    fn index(&self, index: (usize, usize)) -> &Self::Output {
170
490
        &self.matrix[self.get_index(index.0, index.1)]
171
490
    }
172
}
173
174
impl std::ops::IndexMut<(usize, usize)> for ScoreMatrix<'_> {
175
2.19k
    fn index_mut(&mut self, index: (usize, usize)) -> &mut Self::Output {
176
2.19k
        &mut self.matrix[self.get_index(index.0, index.1)]
177
2.19k
    }
178
}
179
180
impl std::fmt::Debug for ScoreMatrix<'_> {
181
5
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
182
5
        let _ = writeln!(f, "M score:");
183
5
        for row in 0..self.rows {
184
15
            for col in 0..self.cols {
185
114
                let cell = &self[(row, col)];
186
114
                write!(
187
114
                    f,
188
                    "{:4}/{}  ",
189
114
                    if cell.m_score == MATRIX_CELL_NEG_INFINITY {
  Branch (189:24): [True: 0, False: 0]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/skim.rs
Line
Count
Source
1
//! The fuzzy matching algorithm used by skim
2
//!
3
//! # Example:
4
//! ```
5
//! use skim::fuzzy_matcher::FuzzyMatcher;
6
//! use skim::fuzzy_matcher::skim::SkimMatcherV2;
7
//!
8
//! let matcher = SkimMatcherV2::default();
9
//! assert_eq!(None, matcher.fuzzy_match("abc", "abx"));
10
//! assert!(matcher.fuzzy_match("axbycz", "abc").is_some());
11
//! assert!(matcher.fuzzy_match("axbycz", "xyz").is_some());
12
//!
13
//! let (score, indices) = matcher.fuzzy_indices("axbycz", "abc").unwrap();
14
//! assert_eq!(indices, [0, 2, 4]);
15
//! ```
16
17
#![allow(deprecated)]
18
19
use std::cell::RefCell;
20
use std::cmp::max;
21
use std::fmt::Formatter;
22
23
use thread_local::ThreadLocal;
24
25
use super::skim::Movement::{Match, Skip};
26
use super::util::{char_equal, cheap_matches};
27
use super::{FuzzyMatcher, IndexType, MatchIndices, ScoreType};
28
29
#[derive(Copy, Clone, Debug)]
30
/// Configuration for skim's scoring algorithm
31
pub struct SkimScoreConfig {
32
    /// Score for each matched character
33
    pub score_match: i32,
34
    /// Penalty for starting a gap (unmatched characters)
35
    pub gap_start: i32,
36
    /// Penalty for extending a gap
37
    pub gap_extension: i32,
38
39
    /// The first character in the typed pattern usually has more significance
40
    /// than the rest so it's important that it appears at special positions where
41
    /// bonus points are given. e.g. "to-go" vs. "ongoing" on "og" or on "ogo".
42
    /// The amount of the extra bonus should be limited so that the gap penalty is
43
    /// still respected.
44
    pub bonus_first_char_multiplier: i32,
45
46
    /// We prefer matches at the beginning of a word, but the bonus should not be
47
    /// too great to prevent the longer acronym matches from always winning over
48
    /// shorter fuzzy matches. The bonus point here was specifically chosen that
49
    /// the bonus is cancelled when the gap between the acronyms grows over
50
    /// 8 characters, which is approximately the average length of the words found
51
    /// in web2 dictionary and my file system.
52
    pub bonus_head: i32,
53
54
    /// Just like `bonus_head`, but its breakage of word is not that strong, so it should
55
    /// be slighter less then `bonus_head`
56
    pub bonus_break: i32,
57
58
    /// Edge-triggered bonus for matches in camelCase words.
59
    /// Compared to word-boundary case, they don't accompany single-character gaps
60
    /// (e.g. `FooBar` vs. foo-bar), so we deduct bonus point accordingly.
61
    pub bonus_camel: i32,
62
63
    /// Minimum bonus point given to characters in consecutive chunks.
64
    /// Note that bonus points for consecutive matches shouldn't have needed if we
65
    /// used fixed match score as in the original algorithm.
66
    pub bonus_consecutive: i32,
67
68
    /// Skim will match case-sensitively if the pattern contains ASCII upper case,
69
    /// If case of case insensitive match, the penalty will be given to case mismatch
70
    pub penalty_case_mismatch: i32,
71
}
72
73
impl Default for SkimScoreConfig {
74
39
    fn default() -> Self {
75
39
        let score_match = 16;
76
39
        let gap_start = -3;
77
39
        let gap_extension = -1;
78
39
        let bonus_first_char_multiplier = 2;
79
80
39
        Self {
81
39
            score_match,
82
39
            gap_start,
83
39
            gap_extension,
84
39
            bonus_first_char_multiplier,
85
39
            bonus_head: score_match / 2,
86
39
            bonus_break: score_match / 2 + gap_extension,
87
39
            bonus_camel: score_match / 2 + 2 * gap_extension,
88
39
            bonus_consecutive: -(gap_start + gap_extension),
89
39
            penalty_case_mismatch: gap_extension * 2,
90
39
        }
91
39
    }
92
}
93
94
#[derive(Debug, Copy, Clone, PartialEq)]
95
enum Movement {
96
    Match,
97
    Skip,
98
}
99
100
/// Inner state of the score matrix
101
// Implementation detail: tried to pad to 16B
102
// will store the m and p matrix together
103
#[derive(Clone, Debug)]
104
struct MatrixCell {
105
    pub m_move: Movement,
106
    pub m_score: i32,
107
    pub p_move: Movement,
108
    pub p_score: i32, // The max score of align pattern[..i] & choice[..j]
109
110
    // temporary fields (make use the rest of the padding)
111
    pub matched: bool,
112
    pub bonus: i32,
113
}
114
115
const MATRIX_CELL_NEG_INFINITY: i32 = i16::MIN as i32;
116
117
impl Default for MatrixCell {
118
129
    fn default() -> Self {
119
129
        Self {
120
129
            m_move: Skip,
121
129
            m_score: MATRIX_CELL_NEG_INFINITY,
122
129
            p_move: Skip,
123
129
            p_score: MATRIX_CELL_NEG_INFINITY,
124
129
            matched: false,
125
129
            bonus: 0,
126
129
        }
127
129
    }
128
}
129
130
impl MatrixCell {
131
1.28k
    pub fn reset(&mut self) {
132
1.28k
        self.m_move = Skip;
133
1.28k
        self.m_score = MATRIX_CELL_NEG_INFINITY;
134
1.28k
        self.p_move = Skip;
135
1.28k
        self.p_score = MATRIX_CELL_NEG_INFINITY;
136
1.28k
        self.bonus = 0;
137
1.28k
        self.matched = false;
138
1.28k
    }
139
}
140
141
/// Simulate a 1-D vector as 2-D matrix
142
struct ScoreMatrix<'a> {
143
    matrix: &'a mut [MatrixCell],
144
    pub rows: usize,
145
    pub cols: usize,
146
}
147
148
impl<'a> ScoreMatrix<'a> {
149
    /// given a matrix, extend it to be (rows x cols) and fill in as `init_val`
150
129
    pub fn new(matrix: &'a mut Vec<MatrixCell>, rows: usize, cols: usize) -> Self {
151
129
        matrix.resize(rows * cols, MatrixCell::default());
152
129
        ScoreMatrix { matrix, rows, cols }
153
129
    }
154
155
    #[inline]
156
2.68k
    fn get_index(&self, row: usize, col: usize) -> usize {
157
2.68k
        row * self.cols + col
158
2.68k
    }
159
160
129
    fn get_row(&self, row: usize) -> &[MatrixCell] {
161
129
        let start = row * self.cols;
162
129
        &self.matrix[start..start + self.cols]
163
129
    }
164
}
165
166
impl std::ops::Index<(usize, usize)> for ScoreMatrix<'_> {
167
    type Output = MatrixCell;
168
169
490
    fn index(&self, index: (usize, usize)) -> &Self::Output {
170
490
        &self.matrix[self.get_index(index.0, index.1)]
171
490
    }
172
}
173
174
impl std::ops::IndexMut<(usize, usize)> for ScoreMatrix<'_> {
175
2.19k
    fn index_mut(&mut self, index: (usize, usize)) -> &mut Self::Output {
176
2.19k
        &mut self.matrix[self.get_index(index.0, index.1)]
177
2.19k
    }
178
}
179
180
impl std::fmt::Debug for ScoreMatrix<'_> {
181
5
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
182
5
        let _ = writeln!(f, "M score:");
183
5
        for row in 0..self.rows {
184
15
            for col in 0..self.cols {
185
114
                let cell = &self[(row, col)];
186
114
                write!(
187
114
                    f,
188
                    "{:4}/{}  ",
189
114
                    if cell.m_score == MATRIX_CELL_NEG_INFINITY {
  Branch (189:24): [True: 0, False: 0]
 
  Branch (189:24): [True: 100, False: 14]
 
190
100
                        -999
191
                    } else {
192
14
                        cell.m_score
193
                    },
194
114
                    match cell.m_move {
195
0
                        Match => 'M',
196
114
                        Skip => 'S',
197
                    }
198
0
                )?;
199
            }
200
15
            writeln!(f)
?0
;
201
        }
202
203
5
        let _ = writeln!(f, "P score:");
204
5
        for row in 0..self.rows {
205
15
            for col in 0..self.cols {
206
114
                let cell = &self[(row, col)];
207
114
                write!(
208
114
                    f,
209
                    "{:4}/{}  ",
210
114
                    if cell.p_score == MATRIX_CELL_NEG_INFINITY {
  Branch (210:24): [True: 0, False: 0]
 
  Branch (210:24): [True: 32, False: 82]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/util.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/util.rs.html
index dfe18d1a..747b2b83 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/util.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/fuzzy_matcher/util.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/util.rs
Line
Count
Source
1
use super::{FuzzyMatcher, IndexType, ScoreType};
2
3
10.5k
pub fn cheap_matches(choice: &[char], pattern: &[char], case_sensitive: bool) -> Option<Vec<usize>> {
4
10.5k
    let mut first_match_indices = vec![];
5
10.5k
    let mut pattern_iter = pattern.iter().peekable();
6
10.5k
    for (idx, &c) in choice.iter().enumerate() {
7
47.8k
        match pattern_iter.peek() {
8
            Some(&&p) => {
9
47.6k
                if char_equal(c, p, case_sensitive) {
  Branch (9:20): [True: 396, False: 2.96k]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/fuzzy_matcher/util.rs
Line
Count
Source
1
use super::{FuzzyMatcher, IndexType, ScoreType};
2
3
10.5k
pub fn cheap_matches(choice: &[char], pattern: &[char], case_sensitive: bool) -> Option<Vec<usize>> {
4
10.5k
    let mut first_match_indices = vec![];
5
10.5k
    let mut pattern_iter = pattern.iter().peekable();
6
10.5k
    for (idx, &c) in choice.iter().enumerate() {
7
47.8k
        match pattern_iter.peek() {
8
            Some(&&p) => {
9
47.6k
                if char_equal(c, p, case_sensitive) {
  Branch (9:20): [True: 396, False: 2.96k]
 
  Branch (9:20): [True: 4.72k, False: 39.5k]
 
10
5.12k
                    first_match_indices.push(idx);
11
5.12k
                    let _ = pattern_iter.next();
12
42.5k
                }
13
            }
14
160
            None => break,
15
        }
16
    }
17
18
10.5k
    if pattern_iter.peek().is_none() {
  Branch (18:8): [True: 8, False: 188]
 
  Branch (18:8): [True: 266, False: 10.0k]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/helper/item.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/helper/item.rs.html
index 1f189d41..a7724a0a 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/helper/item.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/helper/item.rs.html
@@ -1,52 +1,52 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/helper/item.rs
Line
Count
Source
1
//! Skim item helpers
2
//! Including the `DefaultSkimItem`
3
use crate::field::{FieldRange, parse_matching_fields, parse_transform_fields};
4
use crate::tui::util::merge_styles;
5
use crate::{DisplayContext, Matches, SkimItem};
6
use ansi_to_tui::IntoText;
7
use ratatui::text::{Line, Span};
8
use regex::Regex;
9
use std::borrow::Cow;
10
11
//------------------------------------------------------------------------------
12
/// An item will store everything that one line input will need to be operated and displayed.
13
///
14
/// What's special about an item?
15
/// The simplest version of an item is a line of string, but things are getting more complex:
16
/// - The conversion of lower/upper case is slow in rust, because it involds unicode.
17
/// - We may need to interpret the ANSI codes in the text.
18
/// - The text can be transformed and limited while searching.
19
///
20
/// About the ANSI, we made assumption that it is linewise, that means no ANSI codes will affect
21
/// more than one line.
22
#[derive(Debug)]
23
pub struct DefaultSkimItem {
24
    /// The text that will be shown on screen.
25
    text: Box<str>,
26
27
    /// Metadata containing miscellaneous fields when special options are used
28
    metadata: Option<Box<DefaultSkimItemMetadata>>,
29
}
30
31
/// Additional metadata for a `SkimItem`
32
#[derive(Debug, Default)]
33
pub struct DefaultSkimItemMetadata {
34
    /// The text that will be output when user press `enter`
35
    /// `Some(..)` => the original input is transformed, could not output `text` directly
36
    /// `None` => that it is safe to output `text` directly
37
    orig_text: Option<Box<str>>,
38
39
    /// The text stripped of all ansi sequences, used for matching
40
    /// Will be Some when ANSI is enabled, None otherwise
41
    stripped_text: Option<Box<str>>,
42
43
    /// A mapping of positions from stripped text to original text.
44
    /// Each element is (`byte_position`, `char_position`) in the original raw text.
45
    /// Will be empty if ansi is disabled.
46
    ansi_info: Option<Vec<(usize, usize)>>,
47
48
    /// The ranges on which to perform matching
49
    matching_ranges: Option<Vec<(usize, usize)>>,
50
51
    /// Byte ranges (in the display/matching text) of fields hidden via `--hide-nth`.
52
    /// Characters inside these ranges are removed from the rendered line and ignored
53
    /// for match highlighting and horizontal scrolling, but remain part of the text
54
    /// used for matching so they stay searchable.
55
    hidden_ranges: Option<Vec<(usize, usize)>>,
56
57
    /// Whether the item should be disabled or not
58
    disabled: bool,
59
}
60
61
impl DefaultSkimItem {
62
    /// Create a new `DefaultSkimItem` from text
63
    #[must_use]
64
52.0k
    pub fn new(
65
52.0k
        orig_text: &str,
66
52.0k
        ansi_enabled: bool,
67
52.0k
        trans_fields: &[FieldRange],
68
52.0k
        matching_fields: &[FieldRange],
69
52.0k
        delimiter: &Regex,
70
52.0k
    ) -> Self {
71
52.0k
        let using_transform_fields = !trans_fields.is_empty();
72
52.0k
        let contains_ansi = Self::contains_ansi_escape(orig_text);
73
74
        //        transformed | ANSI             | output
75
        //------------------------------------------------------
76
        //                    +- T -> trans+ANSI | ANSI
77
        //                    |                  |
78
        //      +- T -> trans +- F -> trans      | orig
79
        // orig |                                |
80
        //      +- F -> orig  +- T -> ANSI     ==| ANSI
81
        //                    |                  |
82
        //                    +- F -> orig       | orig
83
84
52.0k
        let (mut orig_text, mut temp_text): (Option<String>, Box<str>) = match (using_transform_fields, ansi_enabled) {
85
            (true, true) => {
86
1
                let transformed = parse_transform_fields(delimiter, orig_text, trans_fields);
87
1
                (Some(orig_text.into()), Box::from(transformed))
88
            }
89
            (true, false) => {
90
22
                let transformed = parse_transform_fields(delimiter, &escape_ansi(orig_text), trans_fields);
91
22
                (Some(orig_text.into()), Box::from(transformed))
92
            }
93
51.9k
            (false, false) if contains_ansi => 
(3
None3
, escape_ansi(orig_text).into()),
  Branch (93:31): [True: 2, False: 51.9k]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/helper/item.rs
Line
Count
Source
1
//! Skim item helpers
2
//! Including the `DefaultSkimItem`
3
use crate::field::{FieldRange, parse_matching_fields, parse_transform_fields};
4
use crate::tui::util::merge_styles;
5
use crate::{DisplayContext, Matches, SkimItem};
6
use ansi_to_tui::IntoText;
7
use ratatui::text::{Line, Span};
8
use regex::Regex;
9
use std::borrow::Cow;
10
11
//------------------------------------------------------------------------------
12
/// An item will store everything that one line input will need to be operated and displayed.
13
///
14
/// What's special about an item?
15
/// The simplest version of an item is a line of string, but things are getting more complex:
16
/// - The conversion of lower/upper case is slow in rust, because it involds unicode.
17
/// - We may need to interpret the ANSI codes in the text.
18
/// - The text can be transformed and limited while searching.
19
///
20
/// About the ANSI, we made assumption that it is linewise, that means no ANSI codes will affect
21
/// more than one line.
22
#[derive(Debug)]
23
pub struct DefaultSkimItem {
24
    /// The text that will be shown on screen.
25
    text: Box<str>,
26
27
    /// Metadata containing miscellaneous fields when special options are used
28
    metadata: Option<Box<DefaultSkimItemMetadata>>,
29
}
30
31
/// Additional metadata for a `SkimItem`
32
#[derive(Debug, Default)]
33
pub struct DefaultSkimItemMetadata {
34
    /// The text that will be output when user press `enter`
35
    /// `Some(..)` => the original input is transformed, could not output `text` directly
36
    /// `None` => that it is safe to output `text` directly
37
    orig_text: Option<Box<str>>,
38
39
    /// The text stripped of all ansi sequences, used for matching
40
    /// Will be Some when ANSI is enabled, None otherwise
41
    stripped_text: Option<Box<str>>,
42
43
    /// A mapping of positions from stripped text to original text.
44
    /// Each element is (`byte_position`, `char_position`) in the original raw text.
45
    /// Will be empty if ansi is disabled.
46
    ansi_info: Option<Vec<(usize, usize)>>,
47
48
    /// The ranges on which to perform matching
49
    matching_ranges: Option<Vec<(usize, usize)>>,
50
51
    /// Byte ranges (in the display/matching text) of fields hidden via `--hide-nth`.
52
    /// Characters inside these ranges are removed from the rendered line and ignored
53
    /// for match highlighting and horizontal scrolling, but remain part of the text
54
    /// used for matching so they stay searchable.
55
    hidden_ranges: Option<Vec<(usize, usize)>>,
56
57
    /// Whether the item should be disabled or not
58
    disabled: bool,
59
}
60
61
impl DefaultSkimItem {
62
    /// Create a new `DefaultSkimItem` from text
63
    #[must_use]
64
52.1k
    pub fn new(
65
52.1k
        orig_text: &str,
66
52.1k
        ansi_enabled: bool,
67
52.1k
        trans_fields: &[FieldRange],
68
52.1k
        matching_fields: &[FieldRange],
69
52.1k
        delimiter: &Regex,
70
52.1k
    ) -> Self {
71
52.1k
        let using_transform_fields = !trans_fields.is_empty();
72
52.1k
        let contains_ansi = Self::contains_ansi_escape(orig_text);
73
74
        //        transformed | ANSI             | output
75
        //------------------------------------------------------
76
        //                    +- T -> trans+ANSI | ANSI
77
        //                    |                  |
78
        //      +- T -> trans +- F -> trans      | orig
79
        // orig |                                |
80
        //      +- F -> orig  +- T -> ANSI     ==| ANSI
81
        //                    |                  |
82
        //                    +- F -> orig       | orig
83
84
52.1k
        let (mut orig_text, mut temp_text): (Option<String>, Box<str>) = match (using_transform_fields, ansi_enabled) {
85
            (true, true) => {
86
1
                let transformed = parse_transform_fields(delimiter, orig_text, trans_fields);
87
1
                (Some(orig_text.into()), Box::from(transformed))
88
            }
89
            (true, false) => {
90
22
                let transformed = parse_transform_fields(delimiter, &escape_ansi(orig_text), trans_fields);
91
22
                (Some(orig_text.into()), Box::from(transformed))
92
            }
93
52.1k
            (false, false) if contains_ansi => 
(3
None3
, escape_ansi(orig_text).into()),
  Branch (93:31): [True: 2, False: 52.0k]
 
  Branch (93:31): [True: 1, False: 39]
-
94
51.9k
            (false, true | false) => (None, Box::from(orig_text)),
95
        };
96
97
        // Keep track of whether we have null bytes for special handling
98
52.0k
        let has_null_bytes = memchr::memchr(b'\0', temp_text.as_bytes()).is_some();
99
100
        // Preserve original text with null bytes for output if needed
101
52.0k
        if has_null_bytes && 
orig_text.is_none()7
{
  Branch (101:12): [True: 6, False: 51.9k]
-  Branch (101:30): [True: 5, False: 1]
+
94
52.1k
            (false, true | false) => (None, Box::from(orig_text)),
95
        };
96
97
        // Keep track of whether we have null bytes for special handling
98
52.1k
        let has_null_bytes = memchr::memchr(b'\0', temp_text.as_bytes()).is_some();
99
100
        // Preserve original text with null bytes for output if needed
101
52.1k
        if has_null_bytes && 
orig_text.is_none()6
{
  Branch (101:12): [True: 5, False: 52.1k]
+  Branch (101:30): [True: 4, False: 1]
 
  Branch (101:12): [True: 1, False: 53]
   Branch (101:30): [True: 1, False: 0]
-
102
6
            orig_text = Some(temp_text.to_string());
103
52.0k
        }
104
105
        // Strip null bytes from text used for display and matching
106
        // Null bytes are control characters that cause rendering issues (zero-width)
107
        // They are preserved in orig_text for output
108
52.0k
        if has_null_bytes {
  Branch (108:12): [True: 6, False: 51.9k]
+
102
5
            orig_text = Some(temp_text.to_string());
103
52.1k
        }
104
105
        // Strip null bytes from text used for display and matching
106
        // Null bytes are control characters that cause rendering issues (zero-width)
107
        // They are preserved in orig_text for output
108
52.1k
        if has_null_bytes {
  Branch (108:12): [True: 5, False: 52.1k]
 
  Branch (108:12): [True: 1, False: 53]
-
109
7
            temp_text = temp_text.to_string().replace('\0', "").into_boxed_str();
110
52.0k
        }
111
112
52.0k
        let (stripped_text, ansi_info) = if ansi_enabled && 
contains_ansi24
{
  Branch (112:45): [True: 11, False: 51.9k]
+
109
6
            temp_text = temp_text.to_string().replace('\0', "").into_boxed_str();
110
52.1k
        }
111
112
52.1k
        let (stripped_text, ansi_info) = if ansi_enabled && 
contains_ansi24
{
  Branch (112:45): [True: 11, False: 52.1k]
   Branch (112:61): [True: 8, False: 3]
 
  Branch (112:45): [True: 13, False: 41]
   Branch (112:61): [True: 13, False: 0]
-
113
21
            let (stripped, info) = strip_ansi(&temp_text);
114
21
            (Some(stripped), Some(info))
115
        } else {
116
51.9k
            (None, None)
117
        };
118
119
        // Calculate matching ranges on text WITHOUT null bytes (after stripping)
120
        // This ensures the byte positions match the actual text used for matching
121
52.0k
        let matching_ranges = if matching_fields.is_empty() {
  Branch (121:34): [True: 51.9k, False: 27]
+
113
21
            let (stripped, info) = strip_ansi(&temp_text);
114
21
            (Some(stripped), Some(info))
115
        } else {
116
52.1k
            (None, None)
117
        };
118
119
        // Calculate matching ranges on text WITHOUT null bytes (after stripping)
120
        // This ensures the byte positions match the actual text used for matching
121
52.1k
        let matching_ranges = if matching_fields.is_empty() {
  Branch (121:34): [True: 52.1k, False: 24]
 
  Branch (121:34): [True: 52, False: 2]
-
122
51.9k
            None
123
        } else {
124
            // Use stripped text for matching ranges when ANSI is enabled
125
29
            let text_for_matching = if let Some(stripped) = stripped_text.as_ref() {
  Branch (125:44): [True: 0, False: 27]
+
122
52.1k
            None
123
        } else {
124
            // Use stripped text for matching ranges when ANSI is enabled
125
26
            let text_for_matching = if let Some(stripped) = stripped_text.as_ref() {
  Branch (125:44): [True: 0, False: 24]
 
  Branch (125:44): [True: 1, False: 1]
-
126
1
                stripped
127
            } else {
128
28
                temp_text.as_ref()
129
            };
130
131
            // Parse the original text with null bytes to determine field boundaries
132
            // Then extract those fields, strip null bytes, and recalculate positions
133
            // When has_null_bytes is true, orig_text was set to Some above, so unwrap is safe.
134
29
            let orig_text_for_fields = if has_null_bytes {
  Branch (134:43): [True: 1, False: 26]
+
126
1
                stripped
127
            } else {
128
25
                temp_text.as_ref()
129
            };
130
131
            // Parse the original text with null bytes to determine field boundaries
132
            // Then extract those fields, strip null bytes, and recalculate positions
133
            // When has_null_bytes is true, orig_text was set to Some above, so unwrap is safe.
134
26
            let orig_text_for_fields = if has_null_bytes {
  Branch (134:43): [True: 1, False: 23]
 
  Branch (134:43): [True: 1, False: 1]
-
135
2
                orig_text.as_deref().unwrap_or(text_for_matching)
136
            } else {
137
27
                text_for_matching
138
            };
139
140
29
            if has_null_bytes {
  Branch (140:16): [True: 1, False: 26]
+
135
2
                orig_text.as_deref().unwrap_or(text_for_matching)
136
            } else {
137
24
                text_for_matching
138
            };
139
140
26
            if has_null_bytes {
  Branch (140:16): [True: 1, False: 23]
 
  Branch (140:16): [True: 1, False: 1]
 
141
                // Extract each field from the original text (with null bytes)
142
                // then strip null bytes and build new ranges in the cleaned text
143
2
                let mut adjusted_ranges = Vec::new();
144
145
2
                for field in matching_fields {
146
                    // Get the field text from original (with null bytes)
147
2
                    if let Some(field_text) = crate::field::get_string_by_field(delimiter, orig_text_for_fields, field)
  Branch (147:28): [True: 1, False: 0]
 
  Branch (147:28): [True: 1, False: 0]
 
148
                    {
149
                        // Strip null bytes from this field
150
2
                        let cleaned_field = field_text.replace('\0', "");
151
152
                        // Find this cleaned field in the cleaned full text
153
2
                        if let Some(pos) = text_for_matching.find(&cleaned_field) {
  Branch (153:32): [True: 1, False: 0]
 
  Branch (153:32): [True: 1, False: 0]
-
154
2
                            adjusted_ranges.push((pos, pos + cleaned_field.len()));
155
0
                        }
156
0
                    }
157
                }
158
2
                Some(adjusted_ranges)
159
            } else {
160
27
                Some(parse_matching_fields(delimiter, text_for_matching, matching_fields))
161
            }
162
        };
163
164
        let metadata =
165
52.0k
            if orig_text.is_some() || 
stripped_text.is_some()51.9k
||
ansi_info.is_some()51.9k
||
matching_ranges.is_some()51.9k
{
  Branch (165:16): [True: 26, False: 51.9k]
-  Branch (165:39): [True: 8, False: 51.9k]
-  Branch (165:66): [True: 0, False: 51.9k]
-  Branch (165:89): [True: 26, False: 51.9k]
+
154
2
                            adjusted_ranges.push((pos, pos + cleaned_field.len()));
155
0
                        }
156
0
                    }
157
                }
158
2
                Some(adjusted_ranges)
159
            } else {
160
24
                Some(parse_matching_fields(delimiter, text_for_matching, matching_fields))
161
            }
162
        };
163
164
        let metadata =
165
52.1k
            if orig_text.is_some() || 
stripped_text.is_some()52.1k
||
ansi_info.is_some()52.1k
||
matching_ranges.is_some()52.1k
{
  Branch (165:16): [True: 25, False: 52.1k]
+  Branch (165:39): [True: 8, False: 52.0k]
+  Branch (165:66): [True: 0, False: 52.0k]
+  Branch (165:89): [True: 23, False: 52.0k]
 
  Branch (165:16): [True: 3, False: 51]
   Branch (165:39): [True: 12, False: 39]
   Branch (165:66): [True: 0, False: 39]
   Branch (165:89): [True: 0, False: 39]
-
166
75
                Some(Box::new(DefaultSkimItemMetadata {
167
75
                    orig_text: orig_text.map(std::string::String::into_boxed_str),
168
75
                    stripped_text: stripped_text.map(std::string::String::into_boxed_str),
169
75
                    ansi_info,
170
75
                    matching_ranges,
171
75
                    hidden_ranges: None,
172
75
                    disabled: false,
173
75
                }))
174
            } else {
175
51.9k
                None
176
            };
177
178
52.0k
        DefaultSkimItem {
179
52.0k
            text: temp_text,
180
52.0k
            metadata,
181
52.0k
        }
182
52.0k
    }
183
184
    /// Builder-style setter for the fields hidden from display (via `--hide-nth`).
185
    ///
186
    /// The fields are resolved against the item's display/matching text — which is
187
    /// exactly what [`text()`](Self::text) returns (the ANSI-stripped text under
188
    /// `--ansi`, otherwise the raw text) — so this must be called after construction.
189
    /// The requested fields stay part of `text()` (and therefore searchable); they are
190
    /// only removed from the rendered line and ignored for highlighting and hscroll.
191
    ///
192
    /// A no-op when `hidden_fields` is empty or resolves to no ranges.
193
    #[must_use]
194
52.0k
    pub fn hidden_fields(mut self, hidden_fields: &[FieldRange], delimiter: &Regex) -> Self {
195
52.0k
        if hidden_fields.is_empty() {
  Branch (195:12): [True: 51.9k, False: 9]
+
166
71
                Some(Box::new(DefaultSkimItemMetadata {
167
71
                    orig_text: orig_text.map(std::string::String::into_boxed_str),
168
71
                    stripped_text: stripped_text.map(std::string::String::into_boxed_str),
169
71
                    ansi_info,
170
71
                    matching_ranges,
171
71
                    hidden_ranges: None,
172
71
                    disabled: false,
173
71
                }))
174
            } else {
175
52.1k
                None
176
            };
177
178
52.1k
        DefaultSkimItem {
179
52.1k
            text: temp_text,
180
52.1k
            metadata,
181
52.1k
        }
182
52.1k
    }
183
184
    /// Builder-style setter for the fields hidden from display (via `--hide-nth`).
185
    ///
186
    /// The fields are resolved against the item's display/matching text — which is
187
    /// exactly what [`text()`](Self::text) returns (the ANSI-stripped text under
188
    /// `--ansi`, otherwise the raw text) — so this must be called after construction.
189
    /// The requested fields stay part of `text()` (and therefore searchable); they are
190
    /// only removed from the rendered line and ignored for highlighting and hscroll.
191
    ///
192
    /// A no-op when `hidden_fields` is empty or resolves to no ranges.
193
    #[must_use]
194
52.1k
    pub fn hidden_fields(mut self, hidden_fields: &[FieldRange], delimiter: &Regex) -> Self {
195
52.1k
        if hidden_fields.is_empty() {
  Branch (195:12): [True: 52.1k, False: 10]
 
  Branch (195:12): [True: 36, False: 5]
-
196
51.9k
            return self;
197
14
        }
198
        // Resolve the ranges before touching `self.metadata`; the `text()` borrow must
199
        // end before the mutable borrow below.
200
        let ranges = {
201
14
            let text = self.text();
202
14
            normalize_ranges(&parse_matching_fields(delimiter, text.as_ref(), hidden_fields))
203
        };
204
14
        if !ranges.is_empty() {
  Branch (204:12): [True: 9, False: 0]
+
196
52.1k
            return self;
197
15
        }
198
        // Resolve the ranges before touching `self.metadata`; the `text()` borrow must
199
        // end before the mutable borrow below.
200
        let ranges = {
201
15
            let text = self.text();
202
15
            normalize_ranges(&parse_matching_fields(delimiter, text.as_ref(), hidden_fields))
203
        };
204
15
        if !ranges.is_empty() {
  Branch (204:12): [True: 10, False: 0]
 
  Branch (204:12): [True: 5, False: 0]
-
205
14
            self.metadata.get_or_insert_default().hidden_ranges = Some(ranges);
206
0
        }
207
14
        self
208
52.0k
    }
209
210
52.0k
    fn contains_ansi_escape(s: &str) -> bool {
211
52.0k
        memchr::memchr(b'\x1b', s.as_bytes()).is_some()
212
52.0k
    }
213
214
    /// Mark the item as disabled
215
2
    pub fn disable(&mut self) {
216
2
        self.metadata.get_or_insert_default().disabled = true;
217
2
    }
218
219
    /// Getter for `stripped_text` stored in the metadata
220
    #[must_use]
221
64.9k
    pub fn stripped_text(&self) -> Option<&str> {
222
64.9k
        if let Some(meta) = &self.metadata
  Branch (222:16): [True: 1.22k, False: 63.6k]
+
205
15
            self.metadata.get_or_insert_default().hidden_ranges = Some(ranges);
206
0
        }
207
15
        self
208
52.1k
    }
209
210
52.1k
    fn contains_ansi_escape(s: &str) -> bool {
211
52.1k
        memchr::memchr(b'\x1b', s.as_bytes()).is_some()
212
52.1k
    }
213
214
    /// Mark the item as disabled
215
2
    pub fn disable(&mut self) {
216
2
        self.metadata.get_or_insert_default().disabled = true;
217
2
    }
218
219
    /// Getter for `stripped_text` stored in the metadata
220
    #[must_use]
221
65.0k
    pub fn stripped_text(&self) -> Option<&str> {
222
65.0k
        if let Some(meta) = &self.metadata
  Branch (222:16): [True: 1.22k, False: 63.7k]
 
  Branch (222:16): [True: 20, False: 24]
 
223
1.24k
            && let Some(stripped_text) = &meta.stripped_text
  Branch (223:20): [True: 300, False: 926]
 
  Branch (223:20): [True: 15, False: 5]
-
224
        {
225
315
            Some(stripped_text.as_ref())
226
        } else {
227
64.6k
            None
228
        }
229
64.9k
    }
230
231
    /// Getter for `orig_text` stored in metadata
232
    #[must_use]
233
50.1k
    pub fn orig_text(&self) -> Option<&str> {
234
50.1k
        if let Some(meta) = &self.metadata
  Branch (234:16): [True: 7, False: 50.1k]
+
224
        {
225
315
            Some(stripped_text.as_ref())
226
        } else {
227
64.6k
            None
228
        }
229
65.0k
    }
230
231
    /// Getter for `orig_text` stored in metadata
232
    #[must_use]
233
50.1k
    pub fn orig_text(&self) -> Option<&str> {
234
50.1k
        if let Some(meta) = &self.metadata
  Branch (234:16): [True: 7, False: 50.1k]
 
  Branch (234:16): [True: 0, False: 0]
 
235
7
            && let Some(orig) = &meta.orig_text
  Branch (235:20): [True: 5, False: 2]
 
  Branch (235:20): [True: 0, False: 0]
-
236
        {
237
5
            Some(orig.as_ref())
238
        } else {
239
50.1k
            None
240
        }
241
50.1k
    }
242
243
    /// Getter for `ansi_info` stored in metadata
244
    #[must_use]
245
6.45k
    pub fn ansi_info(&self) -> Option<&Vec<(usize, usize)>> {
246
6.45k
        if let Some(meta) = &self.metadata
  Branch (246:16): [True: 376, False: 6.07k]
+
236
        {
237
5
            Some(orig.as_ref())
238
        } else {
239
50.1k
            None
240
        }
241
50.1k
    }
242
243
    /// Getter for `ansi_info` stored in metadata
244
    #[must_use]
245
6.48k
    pub fn ansi_info(&self) -> Option<&Vec<(usize, usize)>> {
246
6.48k
        if let Some(meta) = &self.metadata
  Branch (246:16): [True: 376, False: 6.09k]
 
  Branch (246:16): [True: 12, False: 0]
 
247
388
            && let Some(info) = &meta.ansi_info
  Branch (247:20): [True: 92, False: 284]
 
  Branch (247:20): [True: 10, False: 2]
-
248
        {
249
102
            Some(info)
250
        } else {
251
6.35k
            None
252
        }
253
6.45k
    }
254
255
    /// Getter for `matching_ranges` stored in metadata
256
    #[must_use]
257
51.2k
    pub fn matching_ranges(&self) -> Option<&[(usize, usize)]> {
258
51.2k
        if let Some(meta) = &self.metadata {
  Branch (258:16): [True: 70, False: 51.2k]
+
248
        {
249
102
            Some(info)
250
        } else {
251
6.38k
            None
252
        }
253
6.48k
    }
254
255
    /// Getter for `matching_ranges` stored in metadata
256
    #[must_use]
257
51.2k
    pub fn matching_ranges(&self) -> Option<&[(usize, usize)]> {
258
51.2k
        if let Some(meta) = &self.metadata {
  Branch (258:16): [True: 70, False: 51.2k]
 
  Branch (258:16): [True: 3, False: 0]
 
259
73
            meta.matching_ranges.as_ref().map(|v| 
v.as_ref()39
as &[(usize, usize)])
260
        } else {
261
51.2k
            None
262
        }
263
51.2k
    }
264
265
    /// Getter for `hidden_ranges` stored in metadata
266
    #[must_use]
267
12.8k
    pub fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
268
12.8k
        if let Some(meta) = &self.metadata {
  Branch (268:16): [True: 752, False: 12.0k]
 
  Branch (268:16): [True: 11, False: 0]
@@ -54,7 +54,7 @@
 
  Branch (299:16): [True: 12, False: 29]
 
300
288
            Cow::Borrowed(stripped)
301
        } else {
302
64.6k
            Cow::Borrowed(&self.text)
303
        }
304
64.9k
    }
305
306
50.1k
    fn output(&self) -> Cow<'_, str> {
307
50.1k
        if let Some(orig) = self.orig_text() {
  Branch (307:16): [True: 5, False: 50.1k]
 
  Branch (307:16): [True: 0, False: 0]
-
308
5
            Cow::Borrowed(orig)
309
        } else {
310
50.1k
            Cow::Borrowed(&self.text)
311
        }
312
50.1k
    }
313
314
51.2k
    fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> {
315
        // Return matching ranges if present in metadata
316
51.2k
        self.matching_ranges()
317
51.2k
    }
318
319
6.35k
    fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
320
6.35k
        self.hidden_ranges()
321
6.35k
    }
322
323
    // The display function handles ANSI stripping, field highlighting, and match
324
    // rendering in a single pass; splitting it would require duplicating context handling.
325
    #[allow(clippy::too_many_lines)]
326
6.45k
    fn display(&self, context: DisplayContext) -> Line<'_> {
327
        // If we have ANSI info, we need to handle ANSI codes properly and map matches
328
6.45k
        if self.ansi_info().is_some() {
  Branch (328:12): [True: 92, False: 6.35k]
+
308
5
            Cow::Borrowed(orig)
309
        } else {
310
50.1k
            Cow::Borrowed(&self.text)
311
        }
312
50.1k
    }
313
314
51.2k
    fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> {
315
        // Return matching ranges if present in metadata
316
51.2k
        self.matching_ranges()
317
51.2k
    }
318
319
6.38k
    fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
320
6.38k
        self.hidden_ranges()
321
6.38k
    }
322
323
    // The display function handles ANSI stripping, field highlighting, and match
324
    // rendering in a single pass; splitting it would require duplicating context handling.
325
    #[allow(clippy::too_many_lines)]
326
6.48k
    fn display(&self, context: DisplayContext) -> Line<'_> {
327
        // If we have ANSI info, we need to handle ANSI codes properly and map matches
328
6.48k
        if self.ansi_info().is_some() {
  Branch (328:12): [True: 92, False: 6.37k]
 
  Branch (328:12): [True: 8, False: 2]
 
329
            // Parse the ANSI text using ansi-to-tui to get proper styled spans
330
100
            let text_bytes = self.text.as_bytes().to_vec();
331
100
            let Ok(parsed_text) = text_bytes.into_text() else {
  Branch (331:17): [True: 92, False: 0]
 
  Branch (331:17): [True: 8, False: 0]
@@ -90,12 +90,12 @@
 
  Branch (493:28): [True: 1, False: 0]
 
494
                            // Combine ANSI style with context matched_style
495
1
                            new_spans.push(Span::styled(
496
1
                                highlighted,
497
1
                                merge_styles(base_style, context.matched_style),
498
1
                            ));
499
36
                        }
500
37
                        if !after.is_empty() {
  Branch (500:28): [True: 36, False: 0]
 
  Branch (500:28): [True: 1, False: 0]
-
501
                            // Combine ANSI style with context base_style
502
37
                            new_spans.push(Span::styled(after, merge_styles(context.base_style, base_style)));
503
0
                        }
504
                    }
505
506
25
                    Line::from(new_spans)
507
                }
508
1
                crate::Matches::None => Line::from(all_spans),
509
            }
510
6.35k
        } else if let Some(hidden) = self.hidden_ranges() {
  Branch (510:23): [True: 28, False: 6.32k]
+
501
                            // Combine ANSI style with context base_style
502
37
                            new_spans.push(Span::styled(after, merge_styles(context.base_style, base_style)));
503
0
                        }
504
                    }
505
506
25
                    Line::from(new_spans)
507
                }
508
1
                crate::Matches::None => Line::from(all_spans),
509
            }
510
6.38k
        } else if let Some(hidden) = self.hidden_ranges() {
  Branch (510:23): [True: 28, False: 6.35k]
 
  Branch (510:23): [True: 2, False: 0]
-
511
            // Non-ANSI hidden path: remove the hidden characters and remap the match
512
            // highlight positions into the visible coordinate space.
513
30
            let (visible, map) = project_visible_text(&self.text, hidden);
514
30
            let indices = project_match_indices(&self.text, &context.matches, &map);
515
30
            DisplayContext {
516
30
                score: context.score,
517
30
                matches: Matches::CharIndices(indices),
518
30
                container_width: context.container_width,
519
30
                base_style: context.base_style,
520
30
                matched_style: context.matched_style,
521
30
            }
522
30
            .to_line(Cow::Owned(visible))
523
        } else {
524
            // No ANSI mapping needed, use text as-is
525
6.32k
            context.to_line(Cow::Borrowed(&self.text))
526
        }
527
6.45k
    }
528
529
63.5k
    fn disabled(&self) -> bool {
530
63.5k
        self.metadata.as_ref().is_some_and(|x| 
x1.41k
.disabled)
531
63.5k
    }
532
}
533
534
/// Strip ANSI escape sequences from a string
535
///
536
/// This function removes all ANSI escape codes (CSI sequences, OSC sequences, etc.)
537
/// from the input string, leaving only the visible text.
538
///
539
/// Returns the stripped string as well as a mapping of positions. Each element in the
540
/// mapping vector is a tuple `(byte_position, char_position)` where:
541
/// - `byte_position`: The byte offset in the original raw string
542
/// - `char_position`: The character index in the original raw string
543
///
544
/// For the character at position `i` in the stripped string:
545
/// - `mapping[i].0` gives its byte position in the original string
546
/// - `mapping[i].1` gives its character index in the original string
547
///
548
/// Examples of ANSI codes that are stripped:
549
/// - `\x1b[31m` (set foreground color to red)
550
/// - `\x1b[01;32m` (bold green)
551
/// - `\x1b[0m` (reset)
552
/// - `\x1b]0;title\x07` (OSC sequences)
553
#[must_use]
554
2.91k
pub fn strip_ansi(text: &str) -> (String, Vec<(usize, usize)>) {
555
2.91k
    let mut result = String::with_capacity(text.len());
556
2.91k
    let mut index_mapping = Vec::new();
557
2.91k
    let mut chars = text.char_indices().peekable();
558
2.91k
    let mut char_idx = 0;
559
560
9.66k
    while let Some((byte_pos, ch)) = chars.next() {
  Branch (560:15): [True: 6.36k, False: 2.82k]
-
  Branch (560:15): [True: 388, False: 89]
-
561
6.74k
        if ch == '\x1b' {
  Branch (561:12): [True: 28, False: 6.33k]
-
  Branch (561:12): [True: 54, False: 334]
+
511
            // Non-ANSI hidden path: remove the hidden characters and remap the match
512
            // highlight positions into the visible coordinate space.
513
30
            let (visible, map) = project_visible_text(&self.text, hidden);
514
30
            let indices = project_match_indices(&self.text, &context.matches, &map);
515
30
            DisplayContext {
516
30
                score: context.score,
517
30
                matches: Matches::CharIndices(indices),
518
30
                container_width: context.container_width,
519
30
                base_style: context.base_style,
520
30
                matched_style: context.matched_style,
521
30
            }
522
30
            .to_line(Cow::Owned(visible))
523
        } else {
524
            // No ANSI mapping needed, use text as-is
525
6.35k
            context.to_line(Cow::Borrowed(&self.text))
526
        }
527
6.48k
    }
528
529
63.6k
    fn disabled(&self) -> bool {
530
63.6k
        self.metadata.as_ref().is_some_and(|x| 
x1.41k
.disabled)
531
63.6k
    }
532
}
533
534
/// Strip ANSI escape sequences from a string
535
///
536
/// This function removes all ANSI escape codes (CSI sequences, OSC sequences, etc.)
537
/// from the input string, leaving only the visible text.
538
///
539
/// Returns the stripped string as well as a mapping of positions. Each element in the
540
/// mapping vector is a tuple `(byte_position, char_position)` where:
541
/// - `byte_position`: The byte offset in the original raw string
542
/// - `char_position`: The character index in the original raw string
543
///
544
/// For the character at position `i` in the stripped string:
545
/// - `mapping[i].0` gives its byte position in the original string
546
/// - `mapping[i].1` gives its character index in the original string
547
///
548
/// Examples of ANSI codes that are stripped:
549
/// - `\x1b[31m` (set foreground color to red)
550
/// - `\x1b[01;32m` (bold green)
551
/// - `\x1b[0m` (reset)
552
/// - `\x1b]0;title\x07` (OSC sequences)
553
#[must_use]
554
2.92k
pub fn strip_ansi(text: &str) -> (String, Vec<(usize, usize)>) {
555
2.92k
    let mut result = String::with_capacity(text.len());
556
2.92k
    let mut index_mapping = Vec::new();
557
2.92k
    let mut chars = text.char_indices().peekable();
558
2.92k
    let mut char_idx = 0;
559
560
9.68k
    while let Some((byte_pos, ch)) = chars.next() {
  Branch (560:15): [True: 6.37k, False: 2.83k]
+
  Branch (560:15): [True: 390, False: 90]
+
561
6.76k
        if ch == '\x1b' {
  Branch (561:12): [True: 28, False: 6.34k]
+
  Branch (561:12): [True: 54, False: 336]
 
562
            // ESC sequence detected
563
82
            if let Some(&(_, next_ch)) = chars.peek() {
  Branch (563:20): [True: 28, False: 0]
 
  Branch (563:20): [True: 53, False: 1]
 
564
81
                match next_ch {
565
                    '[' => {
566
                        // CSI sequence: ESC [ ... (ending with a letter)
567
76
                        chars.next(); // consume '['
568
76
                        char_idx += 1;
569
213
                        while let Some(&(_, c)) = chars.peek() {
  Branch (569:35): [True: 78, False: 0]
@@ -110,13 +110,13 @@
 
  Branch (587:32): [True: 1, False: 28]
 
588
1
                                && let Some(&(_, '\\')) = chars.peek()
  Branch (588:40): [True: 0, False: 0]
 
  Branch (588:40): [True: 1, False: 0]
-
589
                            {
590
1
                                chars.next(); // consume '\'
591
1
                                char_idx += 1;
592
1
                                break;
593
28
                            }
594
                        }
595
                    }
596
                    '(' | ')' | '#' | '%' => {
597
                        // Other escape sequences
598
2
                        chars.next(); // consume the next char
599
2
                        char_idx += 1;
600
2
                        chars.next(); // and one more
601
2
                        char_idx += 1;
602
                    }
603
                    _ => {
604
                        // Unknown escape sequence, consume next char
605
1
                        chars.next();
606
1
                        char_idx += 1;
607
                    }
608
                }
609
1
            }
610
        } else {
611
6.66k
            result.push(ch);
612
6.66k
            index_mapping.push((byte_pos, char_idx));
613
        }
614
6.74k
        char_idx += 1;
615
    }
616
617
2.91k
    (result, index_mapping)
618
2.91k
}
619
620
/// Replace the ANSI ESC code by a ?
621
///
622
/// Unsafe: bytes are parsed back from the original string or b'?'
623
/// No risk associated
624
25
fn escape_ansi(raw: &str) -> String {
625
265
    unsafe { 
String::from_utf8_unchecked25
(
raw.bytes()25
.map(|b| if b == 27 {
b'?'6
} else {
b259
}).collect()) }
  Branch (625:65): [True: 4, False: 233]
+
589
                            {
590
1
                                chars.next(); // consume '\'
591
1
                                char_idx += 1;
592
1
                                break;
593
28
                            }
594
                        }
595
                    }
596
                    '(' | ')' | '#' | '%' => {
597
                        // Other escape sequences
598
2
                        chars.next(); // consume the next char
599
2
                        char_idx += 1;
600
2
                        chars.next(); // and one more
601
2
                        char_idx += 1;
602
                    }
603
                    _ => {
604
                        // Unknown escape sequence, consume next char
605
1
                        chars.next();
606
1
                        char_idx += 1;
607
                    }
608
                }
609
1
            }
610
        } else {
611
6.68k
            result.push(ch);
612
6.68k
            index_mapping.push((byte_pos, char_idx));
613
        }
614
6.76k
        char_idx += 1;
615
    }
616
617
2.92k
    (result, index_mapping)
618
2.92k
}
619
620
/// Replace the ANSI ESC code by a ?
621
///
622
/// Unsafe: bytes are parsed back from the original string or b'?'
623
/// No risk associated
624
25
fn escape_ansi(raw: &str) -> String {
625
265
    unsafe { 
String::from_utf8_unchecked25
(
raw.bytes()25
.map(|b| if b == 27 {
b'?'6
} else {
b259
}).collect()) }
  Branch (625:65): [True: 4, False: 233]
 
  Branch (625:65): [True: 2, False: 26]
-
626
25
}
627
628
/// Sort and merge a list of byte ranges into a canonical, non-overlapping form.
629
///
630
/// Empty ranges are dropped. Overlapping or touching ranges are merged so callers
631
/// can iterate the result assuming disjoint, ascending ranges.
632
#[must_use]
633
19
pub(crate) fn normalize_ranges(ranges: &[(usize, usize)]) -> Vec<(usize, usize)> {
634
23
    let mut sorted: Vec<(usize, usize)> = 
ranges.iter().copied()19
.filter(|(s, e)| e > s).collect();
635
19
    sorted.sort_unstable();
636
637
19
    let mut merged: Vec<(usize, usize)> = Vec::with_capacity(sorted.len());
638
19
    for (start, end) in sorted {
639
22
        if let Some(last) = merged.last_mut()
  Branch (639:16): [True: 1, False: 9]
+
626
25
}
627
628
/// Sort and merge a list of byte ranges into a canonical, non-overlapping form.
629
///
630
/// Empty ranges are dropped. Overlapping or touching ranges are merged so callers
631
/// can iterate the result assuming disjoint, ascending ranges.
632
#[must_use]
633
20
pub(crate) fn normalize_ranges(ranges: &[(usize, usize)]) -> Vec<(usize, usize)> {
634
24
    let mut sorted: Vec<(usize, usize)> = 
ranges.iter().copied()20
.filter(|(s, e)| e > s).collect();
635
20
    sorted.sort_unstable();
636
637
20
    let mut merged: Vec<(usize, usize)> = Vec::with_capacity(sorted.len());
638
20
    for (start, end) in sorted {
639
23
        if let Some(last) = merged.last_mut()
  Branch (639:16): [True: 1, False: 10]
 
  Branch (639:16): [True: 3, False: 9]
 
640
4
            && start <= last.1
  Branch (640:16): [True: 0, False: 1]
 
  Branch (640:16): [True: 2, False: 1]
-
641
        {
642
2
            last.1 = last.1.max(end);
643
        } else {
644
20
            merged.push((start, end));
645
        }
646
    }
647
19
    merged
648
19
}
649
650
/// Remove the hidden byte ranges from `text`, returning the visible string and a
651
/// map from each original char index to its `Some(visible char index)`, or `None`
652
/// when that char falls inside a hidden range.
653
///
654
/// `hidden` must be normalized (see [`normalize_ranges`]): sorted, disjoint byte ranges.
655
#[must_use]
656
96
pub(crate) fn project_visible_text(text: &str, hidden: &[(usize, usize)]) -> (String, Vec<Option<usize>>) {
657
96
    let mut visible = String::with_capacity(text.len());
658
96
    let mut map = Vec::new();
659
96
    let mut vis_idx = 0usize;
660
96
    let mut hi = 0usize;
661
662
96
    for (byte_pos, ch) in text.char_indices() {
663
7.26k
        while hi < hidden.len() && 
byte_pos6.83k
>= hidden[hi].1 {
  Branch (663:15): [True: 6.78k, False: 408]
+
641
        {
642
2
            last.1 = last.1.max(end);
643
        } else {
644
21
            merged.push((start, end));
645
        }
646
    }
647
20
    merged
648
20
}
649
650
/// Remove the hidden byte ranges from `text`, returning the visible string and a
651
/// map from each original char index to its `Some(visible char index)`, or `None`
652
/// when that char falls inside a hidden range.
653
///
654
/// `hidden` must be normalized (see [`normalize_ranges`]): sorted, disjoint byte ranges.
655
#[must_use]
656
96
pub(crate) fn project_visible_text(text: &str, hidden: &[(usize, usize)]) -> (String, Vec<Option<usize>>) {
657
96
    let mut visible = String::with_capacity(text.len());
658
96
    let mut map = Vec::new();
659
96
    let mut vis_idx = 0usize;
660
96
    let mut hi = 0usize;
661
662
96
    for (byte_pos, ch) in text.char_indices() {
663
7.26k
        while hi < hidden.len() && 
byte_pos6.83k
>= hidden[hi].1 {
  Branch (663:15): [True: 6.78k, False: 408]
   Branch (663:36): [True: 90, False: 6.69k]
 
  Branch (663:15): [True: 54, False: 18]
   Branch (663:36): [True: 6, False: 48]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/helper/item_reader.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/helper/item_reader.rs.html
index 3936eec7..92cffff8 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/helper/item_reader.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/helper/item_reader.rs.html
@@ -1,91 +1,91 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/helper/item_reader.rs
Line
Count
Source
1
//! Helper utilities for converting input sources into skim item streams.
2
3
use std::collections::BTreeMap;
4
use std::error::Error;
5
use std::io::{BufRead, BufReader};
6
use std::process::{Child, Stdio};
7
use std::sync::Arc;
8
use std::sync::atomic::{AtomicUsize, Ordering};
9
use std::thread;
10
11
use crate::thread_pool::ThreadPool;
12
13
/// Size of the read buffer used by the parallel I/O reader thread.
14
const PARALLEL_READ_BUF_SIZE: usize = 256 * 1024;
15
16
use regex::Regex;
17
18
use crate::field::FieldRange;
19
use crate::helper::item::DefaultSkimItem;
20
use crate::reader::CommandCollector;
21
use crate::{SkimItem, SkimItemReceiver, SkimItemSender, SkimOptions};
22
23
const DELIMITER_STR: &str = r"[\t\n ]+";
24
const READ_BUFFER_SIZE: usize = 1024;
25
26
/// Options for configuring how items are read and parsed
27
#[derive(Debug)]
28
pub struct SkimItemReaderOption {
29
    buf_size: usize,
30
    use_ansi_color: bool,
31
    transform_fields: Vec<FieldRange>,
32
    matching_fields: Vec<FieldRange>,
33
    hidden_fields: Vec<FieldRange>,
34
    delimiter: Regex,
35
    line_ending: u8,
36
    show_error: bool,
37
    disable_pattern: Option<Regex>,
38
}
39
40
impl Default for SkimItemReaderOption {
41
907
    fn default() -> Self {
42
907
        Self {
43
907
            buf_size: READ_BUFFER_SIZE,
44
907
            line_ending: b'\n',
45
907
            use_ansi_color: false,
46
907
            transform_fields: Vec::new(),
47
907
            matching_fields: Vec::new(),
48
907
            hidden_fields: Vec::new(),
49
907
            delimiter: Regex::new(DELIMITER_STR).unwrap(),
50
907
            show_error: false,
51
907
            disable_pattern: None,
52
907
        }
53
907
    }
54
}
55
56
impl SkimItemReaderOption {
57
    /// Creates reader options from skim options
58
    #[must_use]
59
369
    pub fn from_options(options: &SkimOptions) -> Self {
60
        Self {
61
369
            buf_size: READ_BUFFER_SIZE,
62
369
            line_ending: if options.read0 { 
b'\0'4
} else {
b'\n'365
},
  Branch (62:29): [True: 3, False: 364]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/helper/item_reader.rs
Line
Count
Source
1
//! Helper utilities for converting input sources into skim item streams.
2
3
use std::collections::BTreeMap;
4
use std::error::Error;
5
use std::io::{BufRead, BufReader};
6
use std::process::{Child, Stdio};
7
use std::sync::Arc;
8
use std::sync::atomic::{AtomicUsize, Ordering};
9
use std::thread;
10
11
use crate::thread_pool::ThreadPool;
12
13
/// Size of the read buffer used by the parallel I/O reader thread.
14
const PARALLEL_READ_BUF_SIZE: usize = 256 * 1024;
15
16
use regex::Regex;
17
18
use crate::field::FieldRange;
19
use crate::helper::item::DefaultSkimItem;
20
use crate::reader::CommandCollector;
21
use crate::{SkimItem, SkimItemReceiver, SkimItemSender, SkimOptions};
22
23
const DELIMITER_STR: &str = r"[\t\n ]+";
24
const READ_BUFFER_SIZE: usize = 1024;
25
26
/// Options for configuring how items are read and parsed
27
#[derive(Debug)]
28
pub struct SkimItemReaderOption {
29
    buf_size: usize,
30
    use_ansi_color: bool,
31
    transform_fields: Vec<FieldRange>,
32
    matching_fields: Vec<FieldRange>,
33
    hidden_fields: Vec<FieldRange>,
34
    delimiter: Regex,
35
    line_ending: u8,
36
    show_error: bool,
37
    disable_pattern: Option<Regex>,
38
}
39
40
impl Default for SkimItemReaderOption {
41
907
    fn default() -> Self {
42
907
        Self {
43
907
            buf_size: READ_BUFFER_SIZE,
44
907
            line_ending: b'\n',
45
907
            use_ansi_color: false,
46
907
            transform_fields: Vec::new(),
47
907
            matching_fields: Vec::new(),
48
907
            hidden_fields: Vec::new(),
49
907
            delimiter: Regex::new(DELIMITER_STR).unwrap(),
50
907
            show_error: false,
51
907
            disable_pattern: None,
52
907
        }
53
907
    }
54
}
55
56
impl SkimItemReaderOption {
57
    /// Creates reader options from skim options
58
    #[must_use]
59
367
    pub fn from_options(options: &SkimOptions) -> Self {
60
        Self {
61
367
            buf_size: READ_BUFFER_SIZE,
62
367
            line_ending: if options.read0 { 
b'\0'5
} else {
b'\n'362
},
  Branch (62:29): [True: 4, False: 361]
 
  Branch (62:29): [True: 1, False: 1]
-
63
369
            use_ansi_color: options.ansi,
64
369
            transform_fields: options
65
369
                .with_nth
66
369
                .iter()
67
368
                .filter_map(|f| if f.is_empty() { 
None348
} else {
FieldRange::from_str(f)20
})
  Branch (67:36): [True: 348, False: 20]
+
63
367
            use_ansi_color: options.ansi,
64
367
            transform_fields: options
65
367
                .with_nth
66
367
                .iter()
67
366
                .filter_map(|f| if f.is_empty() { 
None346
} else {
FieldRange::from_str(f)20
})
  Branch (67:36): [True: 346, False: 20]
 
  Branch (67:36): [True: 0, False: 0]
-
68
                .collect(),
69
369
            matching_fields: options
70
369
                .nth
71
369
                .iter()
72
370
                .filter_map(|f| if f.is_empty() { 
None344
} else {
FieldRange::from_str(f)26
})
  Branch (72:36): [True: 344, False: 26]
+
68
                .collect(),
69
367
            matching_fields: options
70
367
                .nth
71
367
                .iter()
72
368
                .filter_map(|f| if f.is_empty() { 
None345
} else {
FieldRange::from_str(f)23
})
  Branch (72:36): [True: 345, False: 23]
 
  Branch (72:36): [True: 0, False: 0]
-
73
                .collect(),
74
369
            hidden_fields: options
75
369
                .hide_nth
76
369
                .iter()
77
368
                .filter_map(|f| if f.is_empty() { 
None358
} else {
FieldRange::from_str(f)10
})
  Branch (77:36): [True: 358, False: 10]
+
73
                .collect(),
74
367
            hidden_fields: options
75
367
                .hide_nth
76
367
                .iter()
77
366
                .filter_map(|f| if f.is_empty() { 
None355
} else {
FieldRange::from_str(f)11
})
  Branch (77:36): [True: 355, False: 11]
 
  Branch (77:36): [True: 0, False: 0]
-
78
                .collect(),
79
369
            delimiter: options.delimiter.clone(),
80
369
            show_error: options.show_cmd_error,
81
369
            disable_pattern: options.disable_pattern.clone(),
82
        }
83
369
    }
84
85
    /// Sets the buffer size for reading
86
    #[must_use]
87
1
    pub fn buf_size(mut self, buf_size: usize) -> Self {
88
1
        self.buf_size = buf_size;
89
1
        self
90
1
    }
91
92
    /// Sets the line ending character (default: '\n')
93
    #[must_use]
94
1
    pub fn line_ending(mut self, line_ending: u8) -> Self {
95
1
        self.line_ending = line_ending;
96
1
        self
97
1
    }
98
99
    /// Enables or disables ANSI color code parsing
100
    #[must_use]
101
2
    pub fn ansi(mut self, enable: bool) -> Self {
102
2
        self.use_ansi_color = enable;
103
2
        self
104
2
    }
105
106
    /// Sets the field delimiter regex
107
    #[must_use]
108
1
    pub fn delimiter(mut self, delimiter: Regex) -> Self {
109
1
        self.delimiter = delimiter;
110
1
        self
111
1
    }
112
113
    /// Sets the fields to display (transform) from the input
114
    #[must_use]
115
2
    pub fn with_nth<'a, T>(mut self, with_nth: T) -> Self
116
2
    where
117
2
        T: Iterator<Item = &'a str>,
118
    {
119
2
        self.transform_fields = with_nth.filter_map(FieldRange::from_str).collect();
120
2
        self
121
2
    }
122
123
    /// Sets the transform fields directly
124
    #[must_use]
125
1
    pub fn transform_fields(mut self, transform_fields: Vec<FieldRange>) -> Self {
126
1
        self.transform_fields = transform_fields;
127
1
        self
128
1
    }
129
130
    /// Sets the fields to use for matching
131
    #[must_use]
132
1
    pub fn nth<'a, T>(mut self, nth: T) -> Self
133
1
    where
134
1
        T: Iterator<Item = &'a str>,
135
    {
136
1
        self.matching_fields = nth.filter_map(FieldRange::from_str).collect();
137
1
        self
138
1
    }
139
140
    /// Sets the matching fields directly
141
    #[must_use]
142
1
    pub fn matching_fields(mut self, matching_fields: Vec<FieldRange>) -> Self {
143
1
        self.matching_fields = matching_fields;
144
1
        self
145
1
    }
146
147
    /// Sets the fields to hide from display (while keeping them searchable)
148
    #[must_use]
149
0
    pub fn hide_nth<'a, T>(mut self, hide_nth: T) -> Self
150
0
    where
151
0
        T: Iterator<Item = &'a str>,
152
    {
153
0
        self.hidden_fields = hide_nth.filter_map(FieldRange::from_str).collect();
154
0
        self
155
0
    }
156
157
    /// Sets the hidden fields directly
158
    #[must_use]
159
0
    pub fn hidden_fields(mut self, hidden_fields: Vec<FieldRange>) -> Self {
160
0
        self.hidden_fields = hidden_fields;
161
0
        self
162
0
    }
163
164
    /// Enables reading null-terminated lines instead of newline-terminated
165
    #[must_use]
166
4
    pub fn read0(mut self, enable: bool) -> Self {
167
4
        if enable {
  Branch (167:12): [Folded - Ignored]
+
78
                .collect(),
79
367
            delimiter: options.delimiter.clone(),
80
367
            show_error: options.show_cmd_error,
81
367
            disable_pattern: options.disable_pattern.clone(),
82
        }
83
367
    }
84
85
    /// Sets the buffer size for reading
86
    #[must_use]
87
1
    pub fn buf_size(mut self, buf_size: usize) -> Self {
88
1
        self.buf_size = buf_size;
89
1
        self
90
1
    }
91
92
    /// Sets the line ending character (default: '\n')
93
    #[must_use]
94
1
    pub fn line_ending(mut self, line_ending: u8) -> Self {
95
1
        self.line_ending = line_ending;
96
1
        self
97
1
    }
98
99
    /// Enables or disables ANSI color code parsing
100
    #[must_use]
101
2
    pub fn ansi(mut self, enable: bool) -> Self {
102
2
        self.use_ansi_color = enable;
103
2
        self
104
2
    }
105
106
    /// Sets the field delimiter regex
107
    #[must_use]
108
1
    pub fn delimiter(mut self, delimiter: Regex) -> Self {
109
1
        self.delimiter = delimiter;
110
1
        self
111
1
    }
112
113
    /// Sets the fields to display (transform) from the input
114
    #[must_use]
115
2
    pub fn with_nth<'a, T>(mut self, with_nth: T) -> Self
116
2
    where
117
2
        T: Iterator<Item = &'a str>,
118
    {
119
2
        self.transform_fields = with_nth.filter_map(FieldRange::from_str).collect();
120
2
        self
121
2
    }
122
123
    /// Sets the transform fields directly
124
    #[must_use]
125
1
    pub fn transform_fields(mut self, transform_fields: Vec<FieldRange>) -> Self {
126
1
        self.transform_fields = transform_fields;
127
1
        self
128
1
    }
129
130
    /// Sets the fields to use for matching
131
    #[must_use]
132
1
    pub fn nth<'a, T>(mut self, nth: T) -> Self
133
1
    where
134
1
        T: Iterator<Item = &'a str>,
135
    {
136
1
        self.matching_fields = nth.filter_map(FieldRange::from_str).collect();
137
1
        self
138
1
    }
139
140
    /// Sets the matching fields directly
141
    #[must_use]
142
1
    pub fn matching_fields(mut self, matching_fields: Vec<FieldRange>) -> Self {
143
1
        self.matching_fields = matching_fields;
144
1
        self
145
1
    }
146
147
    /// Sets the fields to hide from display (while keeping them searchable)
148
    #[must_use]
149
0
    pub fn hide_nth<'a, T>(mut self, hide_nth: T) -> Self
150
0
    where
151
0
        T: Iterator<Item = &'a str>,
152
    {
153
0
        self.hidden_fields = hide_nth.filter_map(FieldRange::from_str).collect();
154
0
        self
155
0
    }
156
157
    /// Sets the hidden fields directly
158
    #[must_use]
159
0
    pub fn hidden_fields(mut self, hidden_fields: Vec<FieldRange>) -> Self {
160
0
        self.hidden_fields = hidden_fields;
161
0
        self
162
0
    }
163
164
    /// Enables reading null-terminated lines instead of newline-terminated
165
    #[must_use]
166
4
    pub fn read0(mut self, enable: bool) -> Self {
167
4
        if enable {
  Branch (167:12): [Folded - Ignored]
 
  Branch (167:12): [True: 3, False: 1]
-
168
3
            self.line_ending = b'\0';
169
        } else {
170
1
            self.line_ending = b'\n';
171
        }
172
4
        self
173
4
    }
174
175
    /// Sets whether to show command errors
176
    #[must_use]
177
2
    pub fn show_error(mut self, show_error: bool) -> Self {
178
2
        self.show_error = show_error;
179
2
        self
180
2
    }
181
182
    /// Builds the options (currently a no-op, returns self)
183
    #[must_use]
184
8
    pub fn build(self) -> Self {
185
8
        self
186
8
    }
187
}
188
189
/// Reader for converting various input sources into streams of skim items
190
pub struct SkimItemReader {
191
    option: Arc<SkimItemReaderOption>,
192
    /// Thread pool used for chunk-processing jobs.  Reader and matcher share
193
    /// this pool so they compete for the same thread budget rather than each
194
    /// spawning their own OS threads.  Defaults to a private pool sized to the
195
    /// number of logical CPUs; callers can replace it with a shared pool via
196
    /// [`with_thread_pool`](Self::with_thread_pool) or
197
    /// [`set_thread_pool`](Self::set_thread_pool).
198
    thread_pool: Arc<ThreadPool>,
199
}
200
201
1.27k
fn default_thread_pool() -> Arc<ThreadPool> {
202
1.27k
    let n = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
203
1.27k
    let (reader_threads, _) = crate::thread_pool::partition_threads(n);
204
1.27k
    Arc::new(ThreadPool::new(reader_threads))
205
1.27k
}
206
207
impl Default for SkimItemReader {
208
899
    fn default() -> Self {
209
899
        Self {
210
899
            option: Arc::new(Default::default()),
211
899
            thread_pool: default_thread_pool(),
212
899
        }
213
899
    }
214
}
215
216
impl SkimItemReader {
217
    /// Creates a new item reader with the given options
218
    #[must_use]
219
373
    pub fn new(option: SkimItemReaderOption) -> Self {
220
373
        Self {
221
373
            option: Arc::new(option),
222
373
            thread_pool: default_thread_pool(),
223
373
        }
224
373
    }
225
226
    /// Sets the reader options
227
    #[must_use]
228
1
    pub fn option(mut self, option: SkimItemReaderOption) -> Self {
229
1
        self.option = Arc::new(option);
230
1
        self
231
1
    }
232
233
    /// Replaces the thread pool used for chunk-processing.  Pass the matcher's
234
    /// pool here so that reader and matcher share the same thread budget.
235
    #[must_use]
236
1
    pub fn with_thread_pool(mut self, pool: Arc<ThreadPool>) -> Self {
237
1
        self.thread_pool = pool;
238
1
        self
239
1
    }
240
241
    /// Like [`with_thread_pool`] but takes `&mut self` — useful when the pool
242
    /// is only available after construction (e.g. injected from the app).
243
1
    pub fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
244
1
        self.thread_pool = pool;
245
1
    }
246
}
247
248
impl SkimItemReader {
249
    /// Converts a `BufRead` source into a stream of skim items using the
250
    /// parallel pipeline.
251
377
    pub fn of_bufread(&self, source: impl BufRead + Send + 'static) -> SkimItemReceiver {
252
377
        self.parallel_bufread(source, None, &Arc::new(AtomicUsize::new(0))).0
253
377
    }
254
255
    /// Core parallel reader pipeline.
256
    ///
257
    /// All input — whether a plain pipe, a `--ansi`-decorated stream, or one
258
    /// with `--nth`/`--with-nth` field transforms — goes through the same four
259
    /// stages.  Every per-line operation inside `DefaultSkimItem::new` is
260
    /// stateless and purely functional, so chunks can be processed concurrently
261
    /// without any coordination beyond sequence reordering.
262
    ///
263
    /// Pipeline:
264
    ///
265
    /// 1. **I/O thread** (dedicated) — reads large byte chunks (~256 KB) from
266
    ///    `source`, splitting on line boundaries, and sends them tagged with
267
    ///    monotonic sequence numbers into a bounded channel.
268
    /// 2. **Bounded dispatcher** — submits chunk jobs to the pool while a token
269
    ///    limit bounds queued and running work. It stops draining the input
270
    ///    channel when that limit is reached, which applies back-pressure to I/O.
271
    /// 3. **Pool jobs** — parse lines, validate UTF-8, apply ANSI stripping and
272
    ///    field transforms, and create `DefaultSkimItem` + `Arc` per line.
273
    /// 4. **Reorder thread** (dedicated) — collects `(seq, items)` from workers
274
    ///    and emits them in sequence order so downstream index assignment
275
    ///    and `--tac` behaviour are correct.
276
    ///
277
    /// When `child` is `Some`, a **killer thread** is also spawned.  It waits
278
    /// on `rx_interrupt` and kills the child process on request (or when the
279
    /// reader is dropped).  This thread participates in `components_to_stop`
280
    /// accounting so that [`ReaderControl::kill`] waits for it to finish.
281
    ///
282
    /// Returns `(rx_item, tx_interrupt)`.  The caller must send on `tx_interrupt`
283
    /// to signal shutdown; the killer thread (if any) will then kill the child.
284
449
    fn parallel_bufread(
285
449
        &self,
286
449
        source: impl BufRead + Send + 'static,
287
449
        child: Option<Child>,
288
449
        components_to_stop: &Arc<AtomicUsize>,
289
449
    ) -> (SkimItemReceiver, crate::prelude::Sender<i32>) {
290
449
        let (tx_item, rx_item): (SkimItemSender, SkimItemReceiver) = kanal::bounded(1024 * 1024);
291
449
        let option = self.option.clone();
292
449
        let pool = Arc::clone(&self.thread_pool);
293
294
449
        let num_threads = pool.num_threads();
295
449
        let (tx_chunks, rx_chunks) = kanal::bounded::<(usize, Vec<u8>)>(num_threads * 4);
296
449
        let (tx_results, rx_results) = kanal::bounded::<(usize, Vec<Arc<dyn SkimItem>>)>(num_threads * 4);
297
298
449
        let line_ending = option.line_ending;
299
300
        // Stage 1: I/O thread.
301
449
        Self::spawn_io_reader(source, tx_chunks, line_ending);
302
303
        // Stage 2: dispatch at most a fixed number of queued or running jobs.
304
        // A worker returns its token only after it sends the parsed result.
305
449
        let max_in_flight = num_threads * 4;
306
449
        let (tx_permits, rx_permits) = std::sync::mpsc::sync_channel(max_in_flight);
307
449
        for _ in 0..max_in_flight {
308
3.59k
            tx_permits.send(()).expect("permit receiver is alive");
309
        }
310
449
        thread::spawn(move || {
311
874
            while let Ok((seq, chunk)) = rx_chunks.recv() {
  Branch (311:23): [True: 11, False: 11]
-
  Branch (311:23): [True: 19, False: 68]
+
168
3
            self.line_ending = b'\0';
169
        } else {
170
1
            self.line_ending = b'\n';
171
        }
172
4
        self
173
4
    }
174
175
    /// Sets whether to show command errors
176
    #[must_use]
177
2
    pub fn show_error(mut self, show_error: bool) -> Self {
178
2
        self.show_error = show_error;
179
2
        self
180
2
    }
181
182
    /// Builds the options (currently a no-op, returns self)
183
    #[must_use]
184
8
    pub fn build(self) -> Self {
185
8
        self
186
8
    }
187
}
188
189
/// Reader for converting various input sources into streams of skim items
190
pub struct SkimItemReader {
191
    option: Arc<SkimItemReaderOption>,
192
    /// Thread pool used for chunk-processing jobs.  Reader and matcher share
193
    /// this pool so they compete for the same thread budget rather than each
194
    /// spawning their own OS threads.  Defaults to a private pool sized to the
195
    /// number of logical CPUs; callers can replace it with a shared pool via
196
    /// [`with_thread_pool`](Self::with_thread_pool) or
197
    /// [`set_thread_pool`](Self::set_thread_pool).
198
    thread_pool: Arc<ThreadPool>,
199
}
200
201
1.27k
fn default_thread_pool() -> Arc<ThreadPool> {
202
1.27k
    let n = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
203
1.27k
    let (reader_threads, _) = crate::thread_pool::partition_threads(n);
204
1.27k
    Arc::new(ThreadPool::new(reader_threads))
205
1.27k
}
206
207
impl Default for SkimItemReader {
208
899
    fn default() -> Self {
209
899
        Self {
210
899
            option: Arc::new(Default::default()),
211
899
            thread_pool: default_thread_pool(),
212
899
        }
213
899
    }
214
}
215
216
impl SkimItemReader {
217
    /// Creates a new item reader with the given options
218
    #[must_use]
219
371
    pub fn new(option: SkimItemReaderOption) -> Self {
220
371
        Self {
221
371
            option: Arc::new(option),
222
371
            thread_pool: default_thread_pool(),
223
371
        }
224
371
    }
225
226
    /// Sets the reader options
227
    #[must_use]
228
1
    pub fn option(mut self, option: SkimItemReaderOption) -> Self {
229
1
        self.option = Arc::new(option);
230
1
        self
231
1
    }
232
233
    /// Replaces the thread pool used for chunk-processing.  Pass the matcher's
234
    /// pool here so that reader and matcher share the same thread budget.
235
    #[must_use]
236
1
    pub fn with_thread_pool(mut self, pool: Arc<ThreadPool>) -> Self {
237
1
        self.thread_pool = pool;
238
1
        self
239
1
    }
240
241
    /// Like [`with_thread_pool`] but takes `&mut self` — useful when the pool
242
    /// is only available after construction (e.g. injected from the app).
243
1
    pub fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
244
1
        self.thread_pool = pool;
245
1
    }
246
}
247
248
impl SkimItemReader {
249
    /// Converts a `BufRead` source into a stream of skim items using the
250
    /// parallel pipeline.
251
375
    pub fn of_bufread(&self, source: impl BufRead + Send + 'static) -> SkimItemReceiver {
252
375
        self.parallel_bufread(source, None, &Arc::new(AtomicUsize::new(0))).0
253
375
    }
254
255
    /// Core parallel reader pipeline.
256
    ///
257
    /// All input — whether a plain pipe, a `--ansi`-decorated stream, or one
258
    /// with `--nth`/`--with-nth` field transforms — goes through the same four
259
    /// stages.  Every per-line operation inside `DefaultSkimItem::new` is
260
    /// stateless and purely functional, so chunks can be processed concurrently
261
    /// without any coordination beyond sequence reordering.
262
    ///
263
    /// Pipeline:
264
    ///
265
    /// 1. **I/O thread** (dedicated) — reads large byte chunks (~256 KB) from
266
    ///    `source`, splitting on line boundaries, and sends them tagged with
267
    ///    monotonic sequence numbers into a bounded channel.
268
    /// 2. **Bounded dispatcher** — submits chunk jobs to the pool while a token
269
    ///    limit bounds queued and running work. It stops draining the input
270
    ///    channel when that limit is reached, which applies back-pressure to I/O.
271
    /// 3. **Pool jobs** — parse lines, validate UTF-8, apply ANSI stripping and
272
    ///    field transforms, and create `DefaultSkimItem` + `Arc` per line.
273
    /// 4. **Reorder thread** (dedicated) — collects `(seq, items)` from workers
274
    ///    and emits them in sequence order so downstream index assignment
275
    ///    and `--tac` behaviour are correct.
276
    ///
277
    /// When `child` is `Some`, a **killer thread** is also spawned.  It waits
278
    /// on `rx_interrupt` and kills the child process on request (or when the
279
    /// reader is dropped).  This thread participates in `components_to_stop`
280
    /// accounting so that [`ReaderControl::kill`] waits for it to finish.
281
    ///
282
    /// Returns `(rx_item, tx_interrupt)`.  The caller must send on `tx_interrupt`
283
    /// to signal shutdown; the killer thread (if any) will then kill the child.
284
448
    fn parallel_bufread(
285
448
        &self,
286
448
        source: impl BufRead + Send + 'static,
287
448
        child: Option<Child>,
288
448
        components_to_stop: &Arc<AtomicUsize>,
289
448
    ) -> (SkimItemReceiver, crate::prelude::Sender<i32>) {
290
448
        let (tx_item, rx_item): (SkimItemSender, SkimItemReceiver) = kanal::bounded(1024 * 1024);
291
448
        let option = self.option.clone();
292
448
        let pool = Arc::clone(&self.thread_pool);
293
294
448
        let num_threads = pool.num_threads();
295
448
        let (tx_chunks, rx_chunks) = kanal::bounded::<(usize, Vec<u8>)>(num_threads * 4);
296
448
        let (tx_results, rx_results) = kanal::bounded::<(usize, Vec<Arc<dyn SkimItem>>)>(num_threads * 4);
297
298
448
        let line_ending = option.line_ending;
299
300
        // Stage 1: I/O thread.
301
448
        Self::spawn_io_reader(source, tx_chunks, line_ending);
302
303
        // Stage 2: dispatch at most a fixed number of queued or running jobs.
304
        // A worker returns its token only after it sends the parsed result.
305
448
        let max_in_flight = num_threads * 4;
306
448
        let (tx_permits, rx_permits) = std::sync::mpsc::sync_channel(max_in_flight);
307
448
        for _ in 0..max_in_flight {
308
3.58k
            tx_permits.send(()).expect("permit receiver is alive");
309
        }
310
448
        thread::spawn(move || {
311
872
            while let Ok((seq, chunk)) = rx_chunks.recv() {
  Branch (311:23): [True: 54, False: 31]
+
  Branch (311:23): [True: 20, False: 69]
+
  Branch (311:23): [True: 36, False: 36]
+
  Branch (311:23): [True: 5, False: 5]
 
  Branch (311:23): [True: 12, False: 12]
-
  Branch (311:23): [True: 9, False: 9]
+
  Branch (311:23): [True: 5, False: 5]
 
  Branch (311:23): [True: 3, False: 3]
 
  Branch (311:23): [True: 1, False: 1]
-
  Branch (311:23): [True: 6, False: 6]
-
  Branch (311:23): [True: 9, False: 9]
-
  Branch (311:23): [True: 24, False: 24]
-
  Branch (311:23): [True: 39, False: 39]
-
  Branch (311:23): [True: 31, False: 31]
-
  Branch (311:23): [True: 2, False: 2]
-
  Branch (311:23): [True: 54, False: 31]
-
  Branch (311:23): [True: 5, False: 5]
-
  Branch (311:23): [True: 122, False: 122]
-
  Branch (311:23): [True: 4, False: 3]
-
  Branch (311:23): [True: 2, False: 1]
-
  Branch (311:23): [True: 11, False: 11]
+
  Branch (311:23): [True: 3, False: 3]
+
  Branch (311:23): [True: 23, False: 23]
+
  Branch (311:23): [True: 12, False: 12]
 
  Branch (311:23): [True: 4, False: 4]
 
  Branch (311:23): [True: 13, False: 13]
 
  Branch (311:23): [True: 1, False: 1]
+
  Branch (311:23): [True: 2, False: 1]
+
  Branch (311:23): [True: 32, False: 32]
+
  Branch (311:23): [True: 3, False: 3]
+
  Branch (311:23): [True: 9, False: 9]
+
  Branch (311:23): [True: 118, False: 118]
+
  Branch (311:23): [True: 4, False: 3]
+
  Branch (311:23): [True: 25, False: 25]
 
  Branch (311:23): [True: 1, False: 1]
-
  Branch (311:23): [True: 2, False: 2]
-
  Branch (311:23): [True: 22, False: 22]
-
  Branch (311:23): [True: 5, False: 5]
-
  Branch (311:23): [True: 13, False: 13]
-
312
425
                if rx_permits.recv().is_err() {
  Branch (312:20): [True: 0, False: 11]
-
  Branch (312:20): [True: 0, False: 19]
+
  Branch (311:23): [True: 6, False: 6]
+
  Branch (311:23): [True: 11, False: 11]
+
  Branch (311:23): [True: 12, False: 12]
+
  Branch (311:23): [True: 9, False: 9]
+
312
424
                if rx_permits.recv().is_err() {
  Branch (312:20): [True: 0, False: 54]
+
  Branch (312:20): [True: 0, False: 20]
+
  Branch (312:20): [True: 0, False: 36]
+
  Branch (312:20): [True: 0, False: 5]
 
  Branch (312:20): [True: 0, False: 12]
-
  Branch (312:20): [True: 0, False: 9]
+
  Branch (312:20): [True: 0, False: 5]
 
  Branch (312:20): [True: 0, False: 3]
 
  Branch (312:20): [True: 0, False: 1]
-
  Branch (312:20): [True: 0, False: 6]
+
  Branch (312:20): [True: 0, False: 3]
+
  Branch (312:20): [True: 0, False: 23]
+
  Branch (312:20): [True: 0, False: 12]
+
  Branch (312:20): [True: 0, False: 4]
+
  Branch (312:20): [True: 0, False: 13]
+
  Branch (312:20): [True: 0, False: 1]
+
  Branch (312:20): [True: 0, False: 2]
+
  Branch (312:20): [True: 0, False: 32]
+
  Branch (312:20): [True: 0, False: 3]
 
  Branch (312:20): [True: 0, False: 9]
-
  Branch (312:20): [True: 0, False: 24]
-
  Branch (312:20): [True: 0, False: 39]
-
  Branch (312:20): [True: 0, False: 31]
-
  Branch (312:20): [True: 0, False: 2]
-
  Branch (312:20): [True: 0, False: 54]
-
  Branch (312:20): [True: 0, False: 5]
-
  Branch (312:20): [True: 0, False: 122]
+
  Branch (312:20): [True: 0, False: 118]
 
  Branch (312:20): [True: 0, False: 4]
-
  Branch (312:20): [True: 0, False: 2]
+
  Branch (312:20): [True: 0, False: 25]
+
  Branch (312:20): [True: 0, False: 1]
+
  Branch (312:20): [True: 0, False: 6]
 
  Branch (312:20): [True: 0, False: 11]
-
  Branch (312:20): [True: 0, False: 4]
-
  Branch (312:20): [True: 0, False: 13]
-
  Branch (312:20): [True: 0, False: 1]
-
  Branch (312:20): [True: 0, False: 1]
-
  Branch (312:20): [True: 0, False: 2]
-
  Branch (312:20): [True: 0, False: 22]
-
  Branch (312:20): [True: 0, False: 5]
-
  Branch (312:20): [True: 0, False: 13]
-
313
0
                    break;
314
425
                }
315
425
                let tx = tx_results.clone();
316
425
                let return_permit = tx_permits.clone();
317
425
                let opt = option.clone();
318
425
                pool.spawn(move || {
319
425
                    let result = Self::process_chunk(seq, &chunk, &opt);
320
425
                    let _ = tx.send(result);
321
425
                    let _ = return_permit.send(());
322
425
                });
323
            }
324
449
        });
325
326
        // A zero-capacity channel used as a completion signal: the reorder
327
        // thread drops its sender when it exits, closing the channel.  The
328
        // killer thread waits on either this signal (natural EOF) or on the
329
        // external interrupt (early termination request).
330
449
        let (tx_pipeline_done, rx_pipeline_done) = kanal::bounded::<()>(0);
331
332
        // Stage 4: reorder thread.
333
449
        Self::spawn_reorder_thread(rx_results, tx_item.clone(), tx_pipeline_done);
334
335
        // Killer thread: exits when the pipeline drains naturally (child process
336
        // reached EOF) OR when it receives an explicit interrupt signal.
337
        //
338
        // This thread participates in `components_to_stop` accounting so that
339
        // [`ReaderControl::is_done`] correctly waits for cleanup to complete.
340
449
        let (tx_interrupt, rx_interrupt) = crate::prelude::bounded::<i32>(8);
341
449
        let components_to_stop_killer = components_to_stop.clone();
342
449
        components_to_stop.fetch_add(1, Ordering::SeqCst);
343
449
        thread::spawn(move || {
344
449
            debug!(
"parallel reader: killer thread start"1
);
345
346
            // Wait for either a kill request or the pipeline finishing naturally.
347
            // kanal doesn't have a multi-channel select, so we poll with a short
348
            // timeout.  Both channels are bounded so this never busy-spins in
349
            // practice: the kill path is rare, and the done path fires quickly.
350
            loop {
351
562
                if rx_interrupt.try_recv().is_ok_and(|v| 
v99
.is_some()) {
  Branch (351:20): [True: 0, False: 15]
-
  Branch (351:20): [True: 0, False: 83]
-
  Branch (351:20): [True: 0, False: 20]
-
  Branch (351:20): [True: 0, False: 9]
-
  Branch (351:20): [True: 0, False: 4]
+
  Branch (312:20): [True: 0, False: 12]
+
  Branch (312:20): [True: 0, False: 9]
+
313
0
                    break;
314
424
                }
315
424
                let tx = tx_results.clone();
316
424
                let return_permit = tx_permits.clone();
317
424
                let opt = option.clone();
318
424
                pool.spawn(move || {
319
424
                    let result = Self::process_chunk(seq, &chunk, &opt);
320
424
                    let _ = tx.send(result);
321
424
                    let _ = return_permit.send(());
322
424
                });
323
            }
324
448
        });
325
326
        // A zero-capacity channel used as a completion signal: the reorder
327
        // thread drops its sender when it exits, closing the channel.  The
328
        // killer thread waits on either this signal (natural EOF) or on the
329
        // external interrupt (early termination request).
330
448
        let (tx_pipeline_done, rx_pipeline_done) = kanal::bounded::<()>(0);
331
332
        // Stage 4: reorder thread.
333
448
        Self::spawn_reorder_thread(rx_results, tx_item.clone(), tx_pipeline_done);
334
335
        // Killer thread: exits when the pipeline drains naturally (child process
336
        // reached EOF) OR when it receives an explicit interrupt signal.
337
        //
338
        // This thread participates in `components_to_stop` accounting so that
339
        // [`ReaderControl::is_done`] correctly waits for cleanup to complete.
340
448
        let (tx_interrupt, rx_interrupt) = crate::prelude::bounded::<i32>(8);
341
448
        let components_to_stop_killer = components_to_stop.clone();
342
448
        components_to_stop.fetch_add(1, Ordering::SeqCst);
343
448
        thread::spawn(move || {
344
448
            debug!(
"parallel reader: killer thread start"1
);
345
346
            // Wait for either a kill request or the pipeline finishing naturally.
347
            // kanal doesn't have a multi-channel select, so we poll with a short
348
            // timeout.  Both channels are bounded so this never busy-spins in
349
            // practice: the kill path is rare, and the done path fires quickly.
350
            loop {
351
561
                if rx_interrupt.try_recv().is_ok_and(|v| 
v109
.is_some()) {
  Branch (351:20): [True: 0, False: 41]
+
  Branch (351:20): [True: 0, False: 87]
+
  Branch (351:20): [True: 0, False: 50]
+
  Branch (351:20): [True: 0, False: 5]
+
  Branch (351:20): [True: 0, False: 13]
+
  Branch (351:20): [True: 0, False: 5]
+
  Branch (351:20): [True: 0, False: 3]
 
  Branch (351:20): [True: 0, False: 1]
-
  Branch (351:20): [True: 0, False: 9]
-
  Branch (351:20): [True: 0, False: 11]
+
  Branch (351:20): [True: 0, False: 3]
 
  Branch (351:20): [True: 0, False: 24]
-
  Branch (351:20): [True: 0, False: 52]
-
  Branch (351:20): [True: 0, False: 42]
-
  Branch (351:20): [True: 0, False: 2]
-
  Branch (351:20): [True: 0, False: 37]
-
  Branch (351:20): [True: 0, False: 5]
-
  Branch (351:20): [True: 0, False: 150]
-
  Branch (351:20): [True: 0, False: 5]
-
  Branch (351:20): [True: 0, False: 1]
-
  Branch (351:20): [True: 0, False: 11]
-
  Branch (351:20): [True: 0, False: 8]
-
  Branch (351:20): [True: 0, False: 19]
-
  Branch (351:20): [True: 0, False: 1]
-
  Branch (351:20): [True: 0, False: 1]
-
  Branch (351:20): [True: 0, False: 2]
-
  Branch (351:20): [True: 0, False: 25]
-
  Branch (351:20): [True: 0, False: 9]
 
  Branch (351:20): [True: 0, False: 16]
+
  Branch (351:20): [True: 0, False: 9]
+
  Branch (351:20): [True: 0, False: 15]
+
  Branch (351:20): [True: 0, False: 1]
+
  Branch (351:20): [True: 0, False: 1]
+
  Branch (351:20): [True: 0, False: 37]
+
  Branch (351:20): [True: 0, False: 3]
+
  Branch (351:20): [True: 0, False: 9]
+
  Branch (351:20): [True: 0, False: 161]
+
  Branch (351:20): [True: 0, False: 6]
+
  Branch (351:20): [True: 0, False: 25]
+
  Branch (351:20): [True: 0, False: 1]
+
  Branch (351:20): [True: 0, False: 6]
+
  Branch (351:20): [True: 0, False: 11]
+
  Branch (351:20): [True: 0, False: 16]
+
  Branch (351:20): [True: 0, False: 12]
 
352
                    // Explicit kill: terminate the child immediately.
353
0
                    if let Some(mut c) = child {
  Branch (353:28): [True: 0, False: 0]
 
  Branch (353:28): [True: 0, False: 0]
 
  Branch (353:28): [True: 0, False: 0]
@@ -112,59 +112,59 @@
 
  Branch (353:28): [True: 0, False: 0]
 
  Branch (353:28): [True: 0, False: 0]
 
  Branch (353:28): [True: 0, False: 0]
-
354
0
                        let _ = c.kill();
355
0
                        let _ = c.wait();
356
0
                    }
357
0
                    break;
358
562
                }
359
                // Channel closed = reorder thread exited = pipeline drained.
360
562
                match rx_pipeline_done.recv_timeout(std::time::Duration::from_millis(1)) {
361
0
                    Ok(()) => break,
362
                    Err(kanal::ReceiveErrorTimeout::Closed | kanal::ReceiveErrorTimeout::SendClosed) => {
363
                        // Natural EOF: child already exited; just reap if present.
364
449
                        if let Some(mut c) = child {
  Branch (364:32): [True: 0, False: 11]
-
  Branch (364:32): [True: 68, False: 0]
+
354
0
                        let _ = c.kill();
355
0
                        let _ = c.wait();
356
0
                    }
357
0
                    break;
358
561
                }
359
                // Channel closed = reorder thread exited = pipeline drained.
360
561
                match rx_pipeline_done.recv_timeout(std::time::Duration::from_millis(1)) {
361
3
                    Ok(()) => break,
362
                    Err(kanal::ReceiveErrorTimeout::Closed | kanal::ReceiveErrorTimeout::SendClosed) => {
363
                        // Natural EOF: child already exited; just reap if present.
364
445
                        if let Some(mut c) = child {
  Branch (364:32): [True: 0, False: 31]
+
  Branch (364:32): [True: 69, False: 0]
+
  Branch (364:32): [True: 0, False: 36]
+
  Branch (364:32): [True: 0, False: 5]
 
  Branch (364:32): [True: 0, False: 12]
-
  Branch (364:32): [True: 0, False: 9]
+
  Branch (364:32): [True: 0, False: 5]
 
  Branch (364:32): [True: 0, False: 3]
 
  Branch (364:32): [True: 0, False: 1]
+
  Branch (364:32): [True: 0, False: 3]
+
  Branch (364:32): [True: 0, False: 23]
+
  Branch (364:32): [True: 0, False: 12]
+
  Branch (364:32): [True: 4, False: 0]
+
  Branch (364:32): [True: 0, False: 10]
+
  Branch (364:32): [True: 0, False: 1]
+
  Branch (364:32): [True: 0, False: 1]
+
  Branch (364:32): [True: 0, False: 32]
+
  Branch (364:32): [True: 0, False: 3]
+
  Branch (364:32): [True: 0, False: 9]
+
  Branch (364:32): [True: 0, False: 118]
+
  Branch (364:32): [True: 0, False: 3]
+
  Branch (364:32): [True: 0, False: 25]
+
  Branch (364:32): [True: 0, False: 1]
 
  Branch (364:32): [True: 0, False: 6]
-
  Branch (364:32): [True: 0, False: 9]
-
  Branch (364:32): [True: 0, False: 24]
-
  Branch (364:32): [True: 0, False: 39]
-
  Branch (364:32): [True: 0, False: 31]
-
  Branch (364:32): [True: 0, False: 2]
-
  Branch (364:32): [True: 0, False: 31]
-
  Branch (364:32): [True: 0, False: 5]
-
  Branch (364:32): [True: 0, False: 122]
-
  Branch (364:32): [True: 0, False: 3]
-
  Branch (364:32): [True: 0, False: 1]
 
  Branch (364:32): [True: 0, False: 11]
-
  Branch (364:32): [True: 4, False: 0]
-
  Branch (364:32): [True: 0, False: 13]
-
  Branch (364:32): [True: 0, False: 1]
-
  Branch (364:32): [True: 0, False: 1]
-
  Branch (364:32): [True: 0, False: 2]
-
  Branch (364:32): [True: 0, False: 22]
-
  Branch (364:32): [True: 0, False: 5]
-
  Branch (364:32): [True: 0, False: 13]
-
365
72
                            let _ = c.wait();
366
377
                        }
367
449
                        break;
368
                    }
369
113
                    Err(kanal::ReceiveErrorTimeout::Timeout) => {
370
113
                        // Neither signal yet — loop.
371
113
                    }
372
                }
373
            }
374
375
449
            components_to_stop_killer.fetch_sub(1, Ordering::SeqCst);
376
449
            debug!(
"parallel reader: killer thread stop"1
);
377
449
        });
378
379
449
        (rx_item, tx_interrupt)
380
449
    }
381
382
    /// Stage 1 of the parallel reader: reads large byte chunks from `source`,
383
    /// splitting on line boundaries, and sends them to workers.
384
449
    fn spawn_io_reader(
385
449
        source: impl BufRead + Send + 'static,
386
449
        tx_chunks: kanal::Sender<(usize, Vec<u8>)>,
387
449
        line_ending: u8,
388
449
    ) {
389
449
        thread::spawn(move || {
390
449
            debug!(
"parallel reader: I/O thread start"1
);
391
392
449
            let mut source = source;
393
449
            let mut leftover: Vec<u8> = Vec::new();
394
449
            let mut seq = 0usize;
395
449
            let mut read_buf = vec![0u8; PARALLEL_READ_BUF_SIZE];
396
397
            loop {
398
854
                let n = match std::io::Read::read(&mut source, &mut read_buf) {
399
                    Ok(0) => {
400
                        // EOF — flush any remaining leftover as the final chunk.
401
449
                        if !leftover.is_empty() {
  Branch (401:28): [True: 0, False: 11]
-
  Branch (401:28): [True: 5, False: 63]
+
  Branch (364:32): [True: 0, False: 12]
+
  Branch (364:32): [True: 0, False: 9]
+
365
73
                            let _ = c.wait();
366
372
                        }
367
445
                        break;
368
                    }
369
113
                    Err(kanal::ReceiveErrorTimeout::Timeout) => {
370
113
                        // Neither signal yet — loop.
371
113
                    }
372
                }
373
            }
374
375
448
            components_to_stop_killer.fetch_sub(1, Ordering::SeqCst);
376
448
            debug!(
"parallel reader: killer thread stop"1
);
377
448
        });
378
379
448
        (rx_item, tx_interrupt)
380
448
    }
381
382
    /// Stage 1 of the parallel reader: reads large byte chunks from `source`,
383
    /// splitting on line boundaries, and sends them to workers.
384
448
    fn spawn_io_reader(
385
448
        source: impl BufRead + Send + 'static,
386
448
        tx_chunks: kanal::Sender<(usize, Vec<u8>)>,
387
448
        line_ending: u8,
388
448
    ) {
389
448
        thread::spawn(move || {
390
448
            debug!(
"parallel reader: I/O thread start"1
);
391
392
448
            let mut source = source;
393
448
            let mut leftover: Vec<u8> = Vec::new();
394
448
            let mut seq = 0usize;
395
448
            let mut read_buf = vec![0u8; PARALLEL_READ_BUF_SIZE];
396
397
            loop {
398
852
                let n = match std::io::Read::read(&mut source, &mut read_buf) {
399
                    Ok(0) => {
400
                        // EOF — flush any remaining leftover as the final chunk.
401
448
                        if !leftover.is_empty() {
  Branch (401:28): [True: 20, False: 11]
+
  Branch (401:28): [True: 5, False: 64]
+
  Branch (401:28): [True: 0, False: 36]
+
  Branch (401:28): [True: 0, False: 5]
 
  Branch (401:28): [True: 0, False: 12]
-
  Branch (401:28): [True: 0, False: 9]
+
  Branch (401:28): [True: 0, False: 5]
 
  Branch (401:28): [True: 0, False: 3]
 
  Branch (401:28): [True: 0, False: 1]
-
  Branch (401:28): [True: 0, False: 6]
-
  Branch (401:28): [True: 0, False: 9]
-
  Branch (401:28): [True: 0, False: 24]
-
  Branch (401:28): [True: 0, False: 39]
-
  Branch (401:28): [True: 0, False: 31]
-
  Branch (401:28): [True: 0, False: 2]
-
  Branch (401:28): [True: 20, False: 11]
-
  Branch (401:28): [True: 0, False: 5]
-
  Branch (401:28): [True: 0, False: 122]
-
  Branch (401:28): [True: 3, False: 0]
-
  Branch (401:28): [True: 1, False: 0]
-
  Branch (401:28): [True: 0, False: 11]
+
  Branch (401:28): [True: 0, False: 3]
+
  Branch (401:28): [True: 0, False: 23]
+
  Branch (401:28): [True: 0, False: 12]
 
  Branch (401:28): [True: 0, False: 4]
 
  Branch (401:28): [True: 0, False: 13]
 
  Branch (401:28): [True: 0, False: 1]
+
  Branch (401:28): [True: 1, False: 0]
+
  Branch (401:28): [True: 0, False: 32]
+
  Branch (401:28): [True: 0, False: 3]
+
  Branch (401:28): [True: 0, False: 9]
+
  Branch (401:28): [True: 0, False: 118]
+
  Branch (401:28): [True: 3, False: 0]
+
  Branch (401:28): [True: 0, False: 25]
 
  Branch (401:28): [True: 0, False: 1]
-
  Branch (401:28): [True: 0, False: 2]
-
  Branch (401:28): [True: 0, False: 22]
-
  Branch (401:28): [True: 0, False: 5]
-
  Branch (401:28): [True: 0, False: 13]
-
402
29
                            let _ = tx_chunks.send((seq, std::mem::take(&mut leftover)));
403
420
                        }
404
449
                        break;
405
                    }
406
405
                    Ok(n) => n,
407
0
                    Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
  Branch (407:35): [True: 0, False: 0]
+
  Branch (401:28): [True: 0, False: 6]
+
  Branch (401:28): [True: 0, False: 11]
+
  Branch (401:28): [True: 0, False: 12]
+
  Branch (401:28): [True: 0, False: 9]
+
402
29
                            let _ = tx_chunks.send((seq, std::mem::take(&mut leftover)));
403
419
                        }
404
448
                        break;
405
                    }
406
404
                    Ok(n) => n,
407
0
                    Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
  Branch (407:35): [True: 0, False: 0]
 
  Branch (407:35): [True: 0, False: 0]
 
  Branch (407:35): [True: 0, False: 0]
 
  Branch (407:35): [True: 0, False: 0]
@@ -216,100 +216,100 @@
 
  Branch (410:28): [True: 0, False: 0]
 
  Branch (410:28): [True: 0, False: 0]
 
  Branch (410:28): [True: 0, False: 0]
-
411
0
                            let _ = tx_chunks.send((seq, std::mem::take(&mut leftover)));
412
0
                        }
413
0
                        break;
414
                    }
415
                };
416
417
                // Combine leftover from previous iteration with fresh data.
418
405
                let data = if leftover.is_empty() {
  Branch (418:31): [True: 11, False: 0]
-
  Branch (418:31): [True: 15, False: 0]
-
  Branch (418:31): [True: 12, False: 0]
-
  Branch (418:31): [True: 9, False: 0]
-
  Branch (418:31): [True: 3, False: 0]
-
  Branch (418:31): [True: 1, False: 0]
-
  Branch (418:31): [True: 6, False: 0]
-
  Branch (418:31): [True: 9, False: 0]
-
  Branch (418:31): [True: 24, False: 0]
-
  Branch (418:31): [True: 39, False: 0]
-
  Branch (418:31): [True: 31, False: 0]
-
  Branch (418:31): [True: 2, False: 0]
-
  Branch (418:31): [True: 31, False: 9]
+
411
0
                            let _ = tx_chunks.send((seq, std::mem::take(&mut leftover)));
412
0
                        }
413
0
                        break;
414
                    }
415
                };
416
417
                // Combine leftover from previous iteration with fresh data.
418
404
                let data = if leftover.is_empty() {
  Branch (418:31): [True: 31, False: 9]
+
  Branch (418:31): [True: 16, False: 0]
+
  Branch (418:31): [True: 36, False: 0]
+
  Branch (418:31): [True: 5, False: 0]
+
  Branch (418:31): [True: 12, False: 0]
 
  Branch (418:31): [True: 5, False: 0]
-
  Branch (418:31): [True: 122, False: 0]
 
  Branch (418:31): [True: 3, False: 0]
 
  Branch (418:31): [True: 1, False: 0]
-
  Branch (418:31): [True: 11, False: 0]
+
  Branch (418:31): [True: 3, False: 0]
+
  Branch (418:31): [True: 23, False: 0]
+
  Branch (418:31): [True: 12, False: 0]
 
  Branch (418:31): [True: 4, False: 0]
 
  Branch (418:31): [True: 13, False: 0]
 
  Branch (418:31): [True: 1, False: 0]
 
  Branch (418:31): [True: 1, False: 0]
-
  Branch (418:31): [True: 2, False: 0]
-
  Branch (418:31): [True: 22, False: 0]
-
  Branch (418:31): [True: 5, False: 0]
-
  Branch (418:31): [True: 13, False: 0]
-
419
396
                    read_buf[..n].to_vec()
420
                } else {
421
9
                    let mut combined = std::mem::take(&mut leftover);
422
9
                    combined.extend_from_slice(&read_buf[..n]);
423
9
                    combined
424
                };
425
426
                // Split at the last newline: everything up to it forms a
427
                // complete-line chunk; the remainder carries over.
428
405
                if let Some(last_nl) = memchr::memrchr(line_ending, &data) {
  Branch (428:24): [True: 11, False: 0]
-
  Branch (428:24): [True: 14, False: 1]
+
  Branch (418:31): [True: 32, False: 0]
+
  Branch (418:31): [True: 3, False: 0]
+
  Branch (418:31): [True: 9, False: 0]
+
  Branch (418:31): [True: 118, False: 0]
+
  Branch (418:31): [True: 3, False: 0]
+
  Branch (418:31): [True: 25, False: 0]
+
  Branch (418:31): [True: 1, False: 0]
+
  Branch (418:31): [True: 6, False: 0]
+
  Branch (418:31): [True: 11, False: 0]
+
  Branch (418:31): [True: 12, False: 0]
+
  Branch (418:31): [True: 9, False: 0]
+
419
395
                    read_buf[..n].to_vec()
420
                } else {
421
9
                    let mut combined = std::mem::take(&mut leftover);
422
9
                    combined.extend_from_slice(&read_buf[..n]);
423
9
                    combined
424
                };
425
426
                // Split at the last newline: everything up to it forms a
427
                // complete-line chunk; the remainder carries over.
428
404
                if let Some(last_nl) = memchr::memrchr(line_ending, &data) {
  Branch (428:24): [True: 34, False: 6]
+
  Branch (428:24): [True: 15, False: 1]
+
  Branch (428:24): [True: 36, False: 0]
+
  Branch (428:24): [True: 5, False: 0]
 
  Branch (428:24): [True: 12, False: 0]
-
  Branch (428:24): [True: 9, False: 0]
+
  Branch (428:24): [True: 5, False: 0]
 
  Branch (428:24): [True: 3, False: 0]
 
  Branch (428:24): [True: 1, False: 0]
-
  Branch (428:24): [True: 6, False: 0]
-
  Branch (428:24): [True: 9, False: 0]
-
  Branch (428:24): [True: 24, False: 0]
-
  Branch (428:24): [True: 39, False: 0]
-
  Branch (428:24): [True: 31, False: 0]
-
  Branch (428:24): [True: 2, False: 0]
-
  Branch (428:24): [True: 34, False: 6]
-
  Branch (428:24): [True: 5, False: 0]
-
  Branch (428:24): [True: 122, False: 0]
-
  Branch (428:24): [True: 1, False: 2]
-
  Branch (428:24): [True: 1, False: 0]
-
  Branch (428:24): [True: 11, False: 0]
+
  Branch (428:24): [True: 3, False: 0]
+
  Branch (428:24): [True: 23, False: 0]
+
  Branch (428:24): [True: 12, False: 0]
 
  Branch (428:24): [True: 4, False: 0]
 
  Branch (428:24): [True: 13, False: 0]
 
  Branch (428:24): [True: 1, False: 0]
 
  Branch (428:24): [True: 1, False: 0]
-
  Branch (428:24): [True: 2, False: 0]
-
  Branch (428:24): [True: 22, False: 0]
-
  Branch (428:24): [True: 5, False: 0]
-
  Branch (428:24): [True: 13, False: 0]
-
429
396
                    leftover = data[last_nl + 1..].to_vec();
430
396
                    let mut chunk = data;
431
396
                    chunk.truncate(last_nl + 1);
432
396
                    if tx_chunks.send((seq, chunk)).is_err() {
  Branch (432:24): [True: 0, False: 11]
-
  Branch (432:24): [True: 0, False: 14]
+
  Branch (428:24): [True: 32, False: 0]
+
  Branch (428:24): [True: 3, False: 0]
+
  Branch (428:24): [True: 9, False: 0]
+
  Branch (428:24): [True: 118, False: 0]
+
  Branch (428:24): [True: 1, False: 2]
+
  Branch (428:24): [True: 25, False: 0]
+
  Branch (428:24): [True: 1, False: 0]
+
  Branch (428:24): [True: 6, False: 0]
+
  Branch (428:24): [True: 11, False: 0]
+
  Branch (428:24): [True: 12, False: 0]
+
  Branch (428:24): [True: 9, False: 0]
+
429
395
                    leftover = data[last_nl + 1..].to_vec();
430
395
                    let mut chunk = data;
431
395
                    chunk.truncate(last_nl + 1);
432
395
                    if tx_chunks.send((seq, chunk)).is_err() {
  Branch (432:24): [True: 0, False: 34]
+
  Branch (432:24): [True: 0, False: 15]
+
  Branch (432:24): [True: 0, False: 36]
+
  Branch (432:24): [True: 0, False: 5]
 
  Branch (432:24): [True: 0, False: 12]
-
  Branch (432:24): [True: 0, False: 9]
+
  Branch (432:24): [True: 0, False: 5]
 
  Branch (432:24): [True: 0, False: 3]
 
  Branch (432:24): [True: 0, False: 1]
-
  Branch (432:24): [True: 0, False: 6]
-
  Branch (432:24): [True: 0, False: 9]
-
  Branch (432:24): [True: 0, False: 24]
-
  Branch (432:24): [True: 0, False: 39]
-
  Branch (432:24): [True: 0, False: 31]
-
  Branch (432:24): [True: 0, False: 2]
-
  Branch (432:24): [True: 0, False: 34]
-
  Branch (432:24): [True: 0, False: 5]
-
  Branch (432:24): [True: 0, False: 122]
-
  Branch (432:24): [True: 0, False: 1]
-
  Branch (432:24): [True: 0, False: 1]
-
  Branch (432:24): [True: 0, False: 11]
+
  Branch (432:24): [True: 0, False: 3]
+
  Branch (432:24): [True: 0, False: 23]
+
  Branch (432:24): [True: 0, False: 12]
 
  Branch (432:24): [True: 0, False: 4]
 
  Branch (432:24): [True: 0, False: 13]
 
  Branch (432:24): [True: 0, False: 1]
 
  Branch (432:24): [True: 0, False: 1]
-
  Branch (432:24): [True: 0, False: 2]
-
  Branch (432:24): [True: 0, False: 22]
-
  Branch (432:24): [True: 0, False: 5]
-
  Branch (432:24): [True: 0, False: 13]
-
433
0
                        break;
434
396
                    }
435
396
                    seq += 1;
436
                } else {
437
                    // No newline at all — accumulate for the next read.
438
9
                    leftover = data;
439
                }
440
            }
441
442
449
            debug!("parallel reader: I/O thread stop (sent {
seq1
} chunks)");
443
449
        });
444
449
    }
445
446
    /// Parses a raw byte chunk into a tagged batch of items.
447
425
    fn process_chunk(seq: usize, chunk: &[u8], opt: &SkimItemReaderOption) -> (usize, Vec<Arc<dyn SkimItem>>) {
448
425
        let mut items = Vec::new();
449
425
        let line_ending = opt.line_ending;
450
451
        // Chunks produced by the I/O thread end with the line-ending delimiter
452
        // (except possibly the final leftover at EOF).  `split()` would produce
453
        // a spurious trailing empty segment in that case, so we trim the
454
        // trailing delimiter first.  After trimming, every segment — including
455
        // empty ones — maps 1:1 to an input line.
456
425
        let chunk_trimmed: &[u8] = if chunk.last() == Some(&line_ending) {
  Branch (456:39): [True: 378, False: 29]
+
  Branch (432:24): [True: 0, False: 32]
+
  Branch (432:24): [True: 0, False: 3]
+
  Branch (432:24): [True: 0, False: 9]
+
  Branch (432:24): [True: 0, False: 118]
+
  Branch (432:24): [True: 0, False: 1]
+
  Branch (432:24): [True: 0, False: 25]
+
  Branch (432:24): [True: 0, False: 1]
+
  Branch (432:24): [True: 0, False: 6]
+
  Branch (432:24): [True: 0, False: 11]
+
  Branch (432:24): [True: 0, False: 12]
+
  Branch (432:24): [True: 0, False: 9]
+
433
0
                        break;
434
395
                    }
435
395
                    seq += 1;
436
                } else {
437
                    // No newline at all — accumulate for the next read.
438
9
                    leftover = data;
439
                }
440
            }
441
442
448
            debug!("parallel reader: I/O thread stop (sent {
seq1
} chunks)");
443
448
        });
444
448
    }
445
446
    /// Parses a raw byte chunk into a tagged batch of items.
447
424
    fn process_chunk(seq: usize, chunk: &[u8], opt: &SkimItemReaderOption) -> (usize, Vec<Arc<dyn SkimItem>>) {
448
424
        let mut items = Vec::new();
449
424
        let line_ending = opt.line_ending;
450
451
        // Chunks produced by the I/O thread end with the line-ending delimiter
452
        // (except possibly the final leftover at EOF).  `split()` would produce
453
        // a spurious trailing empty segment in that case, so we trim the
454
        // trailing delimiter first.  After trimming, every segment — including
455
        // empty ones — maps 1:1 to an input line.
456
424
        let chunk_trimmed: &[u8] = if chunk.last() == Some(&line_ending) {
  Branch (456:39): [True: 377, False: 29]
 
  Branch (456:39): [True: 18, False: 0]
-
457
396
            &chunk[..chunk.len() - 1]
458
        } else {
459
29
            chunk
460
        };
461
462
674k
        for line_bytes in 
chunk_trimmed425
.split(|&b: &u8| b == line_ending) {
463
            // Strip optional \r for \r\n endings.
464
51.9k
            let line_bytes: &[u8] = line_bytes.strip_suffix(b"\r").unwrap_or(line_bytes);
465
51.9k
            let Ok(line) = std::str::from_utf8(line_bytes) else {
  Branch (465:17): [True: 51.9k, False: 0]
+
457
395
            &chunk[..chunk.len() - 1]
458
        } else {
459
29
            chunk
460
        };
461
462
675k
        for line_bytes in 
chunk_trimmed424
.split(|&b: &u8| b == line_ending) {
463
            // Strip optional \r for \r\n endings.
464
52.1k
            let line_bytes: &[u8] = line_bytes.strip_suffix(b"\r").unwrap_or(line_bytes);
465
52.1k
            let Ok(line) = std::str::from_utf8(line_bytes) else {
  Branch (465:17): [True: 52.1k, False: 0]
 
  Branch (465:17): [True: 36, False: 1]
-
466
1
                continue;
467
            };
468
51.9k
            let mut item = DefaultSkimItem::new(
469
51.9k
                line,
470
51.9k
                opt.use_ansi_color,
471
51.9k
                &opt.transform_fields,
472
51.9k
                &opt.matching_fields,
473
51.9k
                &opt.delimiter,
474
51.9k
            )
475
51.9k
            .hidden_fields(&opt.hidden_fields, &opt.delimiter);
476
51.9k
            if opt.disable_pattern.as_ref().is_some_and(|re| 
re2
.is_match(
line2
)) {
  Branch (476:16): [True: 0, False: 51.9k]
+
466
1
                continue;
467
            };
468
52.1k
            let mut item = DefaultSkimItem::new(
469
52.1k
                line,
470
52.1k
                opt.use_ansi_color,
471
52.1k
                &opt.transform_fields,
472
52.1k
                &opt.matching_fields,
473
52.1k
                &opt.delimiter,
474
52.1k
            )
475
52.1k
            .hidden_fields(&opt.hidden_fields, &opt.delimiter);
476
52.1k
            if opt.disable_pattern.as_ref().is_some_and(|re| 
re2
.is_match(
line2
)) {
  Branch (476:16): [True: 0, False: 52.1k]
 
  Branch (476:16): [True: 1, False: 35]
-
477
1
                item.disable();
478
51.9k
            }
479
51.9k
            items.push(Arc::new(item) as Arc<dyn SkimItem>);
480
        }
481
482
425
        (seq, items)
483
425
    }
484
485
    /// Stage 4: receives item batches from workers and emits them through the
486
    /// downstream channel in the original sequence order.  Drops
487
    /// `tx_pipeline_done` on exit to signal the killer thread that the
488
    /// pipeline has drained naturally.
489
449
    fn spawn_reorder_thread(
490
449
        rx_results: kanal::Receiver<(usize, Vec<Arc<dyn SkimItem>>)>,
491
449
        tx_item: SkimItemSender,
492
449
        tx_pipeline_done: kanal::Sender<()>,
493
449
    ) {
494
449
        thread::spawn(move || {
495
449
            debug!(
"parallel reader: reorder thread start"1
);
496
449
            let mut expected = 0usize;
497
449
            let mut pending: BTreeMap<usize, Vec<Arc<dyn SkimItem>>> = BTreeMap::new();
498
499
873
            while let Ok((seq, items)) = rx_results.recv() {
  Branch (499:23): [True: 407, False: 431]
+
477
1
                item.disable();
478
52.1k
            }
479
52.1k
            items.push(Arc::new(item) as Arc<dyn SkimItem>);
480
        }
481
482
424
        (seq, items)
483
424
    }
484
485
    /// Stage 4: receives item batches from workers and emits them through the
486
    /// downstream channel in the original sequence order.  Drops
487
    /// `tx_pipeline_done` on exit to signal the killer thread that the
488
    /// pipeline has drained naturally.
489
448
    fn spawn_reorder_thread(
490
448
        rx_results: kanal::Receiver<(usize, Vec<Arc<dyn SkimItem>>)>,
491
448
        tx_item: SkimItemSender,
492
448
        tx_pipeline_done: kanal::Sender<()>,
493
448
    ) {
494
448
        thread::spawn(move || {
495
448
            debug!(
"parallel reader: reorder thread start"1
);
496
448
            let mut expected = 0usize;
497
448
            let mut pending: BTreeMap<usize, Vec<Arc<dyn SkimItem>>> = BTreeMap::new();
498
499
871
            while let Ok((seq, items)) = rx_results.recv() {
  Branch (499:23): [True: 406, False: 430]
 
  Branch (499:23): [True: 18, False: 17]
-
500
425
                pending.insert(seq, items);
501
                // Flush consecutive batches starting from the expected seq.
502
849
                while let Some(batch) = pending.remove(&expected) {
  Branch (502:27): [True: 407, False: 407]
+
500
424
                pending.insert(seq, items);
501
                // Flush consecutive batches starting from the expected seq.
502
847
                while let Some(batch) = pending.remove(&expected) {
  Branch (502:27): [True: 406, False: 406]
 
  Branch (502:27): [True: 18, False: 17]
-
503
425
                    if tx_item.send(batch).is_err() {
  Branch (503:24): [True: 0, False: 407]
+
503
424
                    if tx_item.send(batch).is_err() {
  Branch (503:24): [True: 0, False: 406]
 
  Branch (503:24): [True: 1, False: 17]
-
504
1
                        return;
505
424
                    }
506
424
                    expected += 1;
507
                }
508
            }
509
            // Drain anything left (shouldn't normally happen).
510
448
            while let Some((&seq, _)) = pending.first_key_value() {
  Branch (510:23): [True: 0, False: 431]
+
504
1
                        return;
505
423
                    }
506
423
                    expected += 1;
507
                }
508
            }
509
            // Drain anything left (shouldn't normally happen).
510
447
            while let Some((&seq, _)) = pending.first_key_value() {
  Branch (510:23): [True: 0, False: 430]
 
  Branch (510:23): [True: 0, False: 17]
 
511
0
                if pending.remove(&seq).is_some_and(|batch| tx_item.send(batch).is_err()) {
  Branch (511:20): [True: 0, False: 0]
 
  Branch (511:20): [True: 0, False: 0]
-
512
0
                    return;
513
0
                }
514
            }
515
            // Dropping tx_pipeline_done closes the channel, waking the killer
516
            // thread so it can decrement components_to_stop.
517
448
            drop(tx_pipeline_done);
518
448
            debug!(
"parallel reader: reorder thread stop"1
);
519
449
        });
520
449
    }
521
}
522
523
impl CommandCollector for SkimItemReader {
524
72
    fn invoke(
525
72
        &mut self,
526
72
        cmd: &str,
527
72
        components_to_stop: Arc<AtomicUsize>,
528
72
    ) -> (SkimItemReceiver, crate::prelude::Sender<i32>) {
529
72
        let send_error = self.option.show_error;
530
72
        let (child, source) = get_command_output(cmd, send_error).expect("command not found");
531
72
        self.parallel_bufread(source, child, &components_to_stop)
532
72
    }
533
534
404
    fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
535
404
        self.thread_pool = pool;
536
404
    }
537
}
538
539
type CommandOutput = (Option<Child>, Box<dyn BufRead + Send>);
540
541
72
fn get_command_output(cmd: &str, send_error: bool) -> Result<CommandOutput, Box<dyn Error>> {
542
72
    let (reader, writer) = std::io::pipe()
?0
;
543
72
    let mut command = crate::shell_cmd(cmd);
544
72
    command.stdout(writer.try_clone()
?0
);
545
72
    if send_error {
  Branch (545:8): [True: 0, False: 68]
+
512
0
                    return;
513
0
                }
514
            }
515
            // Dropping tx_pipeline_done closes the channel, waking the killer
516
            // thread so it can decrement components_to_stop.
517
447
            drop(tx_pipeline_done);
518
447
            debug!(
"parallel reader: reorder thread stop"1
);
519
448
        });
520
448
    }
521
}
522
523
impl CommandCollector for SkimItemReader {
524
73
    fn invoke(
525
73
        &mut self,
526
73
        cmd: &str,
527
73
        components_to_stop: Arc<AtomicUsize>,
528
73
    ) -> (SkimItemReceiver, crate::prelude::Sender<i32>) {
529
73
        let send_error = self.option.show_error;
530
73
        let (child, source) = get_command_output(cmd, send_error).expect("command not found");
531
73
        self.parallel_bufread(source, child, &components_to_stop)
532
73
    }
533
534
403
    fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
535
403
        self.thread_pool = pool;
536
403
    }
537
}
538
539
type CommandOutput = (Option<Child>, Box<dyn BufRead + Send>);
540
541
73
fn get_command_output(cmd: &str, send_error: bool) -> Result<CommandOutput, Box<dyn Error>> {
542
73
    let (reader, writer) = std::io::pipe()
?0
;
543
73
    let mut command = crate::shell_cmd(cmd);
544
73
    command.stdout(writer.try_clone()
?0
);
545
73
    if send_error {
  Branch (545:8): [True: 0, False: 69]
 
  Branch (545:8): [True: 1, False: 3]
-
546
1
        trace!(
"redirecting stderr to the output"0
);
547
1
        command.stderr(writer);
548
    } else {
549
71
        command.stderr(Stdio::null());
550
    }
551
552
72
    Ok((command.spawn().ok(), Box::new(BufReader::new(reader))))
553
72
}
554
555
#[cfg(test)]
556
#[path = "item_reader_tests.rs"]
557
mod tests;
\ No newline at end of file +
546
1
        trace!(
"redirecting stderr to the output"0
);
547
1
        command.stderr(writer);
548
    } else {
549
72
        command.stderr(Stdio::null());
550
    }
551
552
73
    Ok((command.spawn().ok(), Box::new(BufReader::new(reader))))
553
73
}
554
555
#[cfg(test)]
556
#[path = "item_reader_tests.rs"]
557
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/helper/selector.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/helper/selector.rs.html index 7a6c6425..01f9b207 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/helper/selector.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/helper/selector.rs.html @@ -1,10 +1,10 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/helper/selector.rs
Line
Count
Source
1
use std::collections::HashSet;
2
3
use regex::Regex;
4
5
use crate::{Selector, SkimItem};
6
7
/// Default implementation of the selector trait for pre-selecting items
8
#[derive(Debug, Default)]
9
pub struct DefaultSkimSelector {
10
    first_n: usize,
11
    regex: Option<Regex>,
12
    preset: Option<HashSet<String>>,
13
}
14
15
impl DefaultSkimSelector {
16
    /// Selects the first N items
17
    #[must_use]
18
19
    pub fn first_n(mut self, first_n: usize) -> Self {
19
19
        trace!("select first_n: {
first_n0
}");
20
19
        self.first_n = first_n;
21
19
        self
22
19
    }
23
24
    /// Selects items whose text matches any of the preset strings
25
    #[must_use]
26
18
    pub fn preset(mut self, preset: impl IntoIterator<Item = String>) -> Self {
27
18
        if self.preset.is_none() {
  Branch (27:12): [True: 16, False: 0]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/helper/selector.rs
Line
Count
Source
1
use std::collections::HashSet;
2
3
use regex::Regex;
4
5
use crate::{Selector, SkimItem};
6
7
/// Default implementation of the selector trait for pre-selecting items
8
#[derive(Debug, Default)]
9
pub struct DefaultSkimSelector {
10
    first_n: usize,
11
    regex: Option<Regex>,
12
    preset: Option<HashSet<String>>,
13
}
14
15
impl DefaultSkimSelector {
16
    /// Selects the first N items
17
    #[must_use]
18
18
    pub fn first_n(mut self, first_n: usize) -> Self {
19
18
        trace!("select first_n: {
first_n0
}");
20
18
        self.first_n = first_n;
21
18
        self
22
18
    }
23
24
    /// Selects items whose text matches any of the preset strings
25
    #[must_use]
26
17
    pub fn preset(mut self, preset: impl IntoIterator<Item = String>) -> Self {
27
17
        if self.preset.is_none() {
  Branch (27:12): [True: 15, False: 0]
 
  Branch (27:12): [True: 2, False: 0]
-
28
18
            self.preset = Some(HashSet::new());
29
0
        }
30
31
18
        if let Some(set) = self.preset.as_mut() {
  Branch (31:16): [True: 16, False: 0]
+
28
17
            self.preset = Some(HashSet::new());
29
0
        }
30
31
17
        if let Some(set) = self.preset.as_mut() {
  Branch (31:16): [True: 15, False: 0]
 
  Branch (31:16): [True: 2, False: 0]
-
32
18
            set.extend(preset);
33
0
        }
34
18
        self
35
18
    }
36
37
    /// Selects items whose text matches the given regex pattern
38
    #[must_use]
39
18
    pub fn regex(mut self, regex: &str) -> Self {
40
18
        trace!("select regex: {
regex0
}");
41
18
        if !regex.is_empty() {
  Branch (41:12): [True: 2, False: 14]
+
32
17
            set.extend(preset);
33
0
        }
34
17
        self
35
17
    }
36
37
    /// Selects items whose text matches the given regex pattern
38
    #[must_use]
39
17
    pub fn regex(mut self, regex: &str) -> Self {
40
17
        trace!("select regex: {
regex0
}");
41
17
        if !regex.is_empty() {
  Branch (41:12): [True: 2, False: 13]
 
  Branch (41:12): [True: 2, False: 0]
-
42
4
            self.regex = Regex::new(regex).ok();
43
14
        }
44
18
        self
45
18
    }
46
}
47
48
impl Selector for DefaultSkimSelector {
49
53
    fn should_select(&self, index: usize, item: &dyn SkimItem) -> bool {
50
53
        if item.disabled() {
  Branch (50:12): [True: 0, False: 34]
+
42
4
            self.regex = Regex::new(regex).ok();
43
13
        }
44
17
        self
45
17
    }
46
}
47
48
impl Selector for DefaultSkimSelector {
49
53
    fn should_select(&self, index: usize, item: &dyn SkimItem) -> bool {
50
53
        if item.disabled() {
  Branch (50:12): [True: 0, False: 34]
 
  Branch (50:12): [True: 1, False: 18]
 
51
1
            return false;
52
52
        }
53
52
        if self.first_n > index {
  Branch (53:12): [True: 22, False: 12]
 
  Branch (53:12): [True: 5, False: 13]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/item.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/item.rs.html
index 951b51ad..81f0a6bd 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/item.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/item.rs.html
@@ -1,14 +1,14 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/item.rs
Line
Count
Source
1
//! Item representation and management.
2
//!
3
//! This module provides the core item types used by skim, including ranked items,
4
//! item pools for efficient storage, and ranking criteria for sorting matches.
5
use std::cmp::min;
6
use std::default::Default;
7
use std::hash::Hash;
8
use std::ops::Deref;
9
use std::sync::Arc;
10
use std::sync::atomic::{AtomicUsize, Ordering};
11
12
#[cfg(feature = "cli")]
13
use clap::ValueEnum;
14
#[cfg(feature = "cli")]
15
use clap::builder::PossibleValue;
16
17
use crate::spinlock::{SpinLock, SpinLockGuard};
18
use crate::{MatchRange, Rank, SkimItem};
19
use tokio::sync::Notify;
20
21
//------------------------------------------------------------------------------
22
23
/// Builder for creating rank values based on configurable criteria
24
#[derive(Debug)]
25
pub struct RankBuilder {
26
    criterion: Vec<RankCriteria>,
27
    tac: bool,
28
}
29
30
impl Default for RankBuilder {
31
4.15k
    fn default() -> Self {
32
4.15k
        Self {
33
4.15k
            criterion: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End],
34
4.15k
            tac: false,
35
4.15k
        }
36
4.15k
    }
37
}
38
39
impl RankBuilder {
40
    /// Creates a new rank builder with the given criteria
41
    #[must_use]
42
417
    pub fn new(mut criterion: Vec<RankCriteria>) -> Self {
43
417
        if !criterion.contains(&RankCriteria::Score) && 
!criterion.contains(&RankCriteria::NegScore)7
{
  Branch (43:12): [True: 1, False: 387]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/item.rs
Line
Count
Source
1
//! Item representation and management.
2
//!
3
//! This module provides the core item types used by skim, including ranked items,
4
//! item pools for efficient storage, and ranking criteria for sorting matches.
5
use std::cmp::min;
6
use std::default::Default;
7
use std::hash::Hash;
8
use std::ops::Deref;
9
use std::sync::Arc;
10
use std::sync::atomic::{AtomicUsize, Ordering};
11
12
#[cfg(feature = "cli")]
13
use clap::ValueEnum;
14
#[cfg(feature = "cli")]
15
use clap::builder::PossibleValue;
16
17
use crate::spinlock::{SpinLock, SpinLockGuard};
18
use crate::{MatchRange, Rank, SkimItem};
19
use tokio::sync::Notify;
20
21
//------------------------------------------------------------------------------
22
23
/// Builder for creating rank values based on configurable criteria
24
#[derive(Debug)]
25
pub struct RankBuilder {
26
    criterion: Vec<RankCriteria>,
27
    tac: bool,
28
}
29
30
impl Default for RankBuilder {
31
4.15k
    fn default() -> Self {
32
4.15k
        Self {
33
4.15k
            criterion: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End],
34
4.15k
            tac: false,
35
4.15k
        }
36
4.15k
    }
37
}
38
39
impl RankBuilder {
40
    /// Creates a new rank builder with the given criteria
41
    #[must_use]
42
416
    pub fn new(mut criterion: Vec<RankCriteria>) -> Self {
43
416
        if !criterion.contains(&RankCriteria::Score) && 
!criterion.contains(&RankCriteria::NegScore)7
{
  Branch (43:12): [True: 1, False: 386]
   Branch (43:57): [True: 0, False: 1]
 
  Branch (43:12): [True: 6, False: 23]
   Branch (43:57): [True: 5, False: 1]
-
44
5
            criterion.insert(0, RankCriteria::Score);
45
412
        }
46
47
417
        criterion.dedup();
48
417
        Self { criterion, tac: false }
49
417
    }
50
51
    #[must_use]
52
415
    pub(crate) fn tac(mut self, tac: bool) -> Self {
53
415
        self.tac = tac;
54
415
        self
55
415
    }
56
57
    /// Returns the tiebreak criteria slice.
58
    #[must_use]
59
3
    pub fn criteria(&self) -> &[RankCriteria] {
60
3
        &self.criterion
61
3
    }
62
63
53.6k
    fn sort_key(&self, rank: &Rank) -> [i32; 6] {
64
53.6k
        let configured = rank.sort_key(&self.criterion);
65
53.6k
        let mut key = [0; 6];
66
53.6k
        key[..5].copy_from_slice(&configured);
67
68
53.6k
        if self.tac {
  Branch (68:12): [True: 8, False: 51.9k]
+
44
5
            criterion.insert(0, RankCriteria::Score);
45
411
        }
46
47
416
        criterion.dedup();
48
416
        Self { criterion, tac: false }
49
416
    }
50
51
    #[must_use]
52
414
    pub(crate) fn tac(mut self, tac: bool) -> Self {
53
414
        self.tac = tac;
54
414
        self
55
414
    }
56
57
    /// Returns the tiebreak criteria slice.
58
    #[must_use]
59
3
    pub fn criteria(&self) -> &[RankCriteria] {
60
3
        &self.criterion
61
3
    }
62
63
53.6k
    fn sort_key(&self, rank: &Rank) -> [i32; 6] {
64
53.6k
        let configured = rank.sort_key(&self.criterion);
65
53.6k
        let mut key = [0; 6];
66
53.6k
        key[..5].copy_from_slice(&configured);
67
68
53.6k
        if self.tac {
  Branch (68:12): [True: 8, False: 51.9k]
 
  Branch (68:12): [True: 9, False: 1.73k]
 
69
17
            for (priority, criterion) in self.criterion.iter().take(5).enumerate() {
70
49
                key[priority] = match criterion {
71
2
                    RankCriteria::Index => rank.index.saturating_neg(),
72
0
                    RankCriteria::NegIndex => rank.index,
73
47
                    _ => key[priority],
74
                };
75
            }
76
53.6k
        }
77
53.6k
        key[5] = if self.tac {
  Branch (77:21): [True: 8, False: 51.9k]
 
  Branch (77:21): [True: 9, False: 1.73k]
-
78
17
            rank.index.saturating_neg()
79
        } else {
80
53.6k
            rank.index
81
        };
82
53.6k
        key
83
53.6k
    }
84
85
    /// Computes the **character** index of the first character after the last path
86
    /// separator (`/` or `\`) in `text`.  Returns `0` when no separator is present.
87
    ///
88
    /// This must be a char index, not a byte offset: the `PathName` tiebreak
89
    /// subtracts it from [`Rank::begin`], which is a char index, so counting bytes
90
    /// here would mix units and mis-rank any path with a non-ASCII component.
91
52.0k
    fn path_name_offset(text: &str) -> i32 {
92
52.0k
        text.rfind(['/', '\\']).map_or(0, |pos| 
{197
93
197
            i32::try_from(text[..pos].chars().count())
94
197
                .unwrap_or(i32::MAX)
95
197
                .saturating_add(1)
96
197
        })
97
52.0k
    }
98
99
    /// Builds a `Rank` from raw match measurements.
100
    ///
101
    /// The values are stored as-is; the tiebreak ordering and sign-flipping are
102
    /// applied lazily by [`Rank::sort_key`] at comparison time.
103
    /// The `index` will be overridden later
104
    #[must_use]
105
52.0k
    pub fn build_rank(&self, score: i32, begin: usize, end: usize, item_text: &str) -> Rank {
106
52.0k
        Rank {
107
52.0k
            score,
108
52.0k
            begin: i32::try_from(begin).unwrap_or(i32::MAX),
109
52.0k
            end: i32::try_from(end).unwrap_or(i32::MAX),
110
52.0k
            length: i32::try_from(item_text.len()).unwrap_or(i32::MAX),
111
52.0k
            index: Default::default(),
112
52.0k
            path_name_offset: Self::path_name_offset(item_text),
113
52.0k
        }
114
52.0k
    }
115
}
116
117
impl Rank {
118
    /// Computes the ordered sort key for this rank given a slice of tiebreak criteria.
119
    ///
120
    /// Each criterion maps to one slot in the returned `[i32; 5]` array. Values are
121
    /// sign-flipped where necessary so that the array compares lexicographically with
122
    /// the "best" match sorting first (ascending order).
123
    #[must_use]
124
53.6k
    pub fn sort_key(&self, criteria: &[RankCriteria]) -> [i32; 5] {
125
53.6k
        let mut key = [0i32; 5];
126
53.6k
        for (priority, criterion) in criteria.iter().take(5).enumerate() {
127
160k
            key[priority] = match criterion {
128
53.6k
                RankCriteria::Score => -self.score,
129
7
                RankCriteria::NegScore => self.score,
130
53.5k
                RankCriteria::Begin => self.begin,
131
8
                RankCriteria::NegBegin => -self.begin,
132
53.5k
                RankCriteria::End => self.end,
133
10
                RankCriteria::NegEnd => -self.end,
134
13
                RankCriteria::Length => self.length,
135
7
                RankCriteria::NegLength => -self.length,
136
13
                RankCriteria::Index => self.index,
137
7
                RankCriteria::NegIndex => -self.index,
138
                // PathName: prefer matches that fall within the filename portion (i.e. at or
139
                // after the last path separator).  `path_name_offset - begin` is <= 0 when the
140
                // match starts inside the filename, and positive when it starts in a directory
141
                // component.  Lower values sort first, so filename matches rank higher.
142
19
                RankCriteria::PathName => self.path_name_offset - self.begin,
143
12
                RankCriteria::NegPathName => self.begin - self.path_name_offset,
144
            };
145
        }
146
53.6k
        key
147
53.6k
    }
148
}
149
150
//------------------------------------------------------------------------------
151
/// An item that has been matched against a query
152
#[derive(Clone)]
153
pub struct MatchedItem {
154
    /// The underlying skim item
155
    pub item: Arc<dyn SkimItem>,
156
    /// Raw match measurements
157
    pub rank: Rank,
158
    /// Range of characters that matched the pattern
159
    pub matched_range: Option<MatchRange>,
160
    /// Sort key precomputed at construction time from `rank` and the tiebreak
161
    /// criteria. The sixth slot is the tac-aware implicit index tiebreak.
162
    /// Caching avoids recomputing it on every comparison during sort.
163
    sort_key: [i32; 6],
164
}
165
166
impl std::fmt::Debug for MatchedItem {
167
6
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168
6
        f.debug_struct("MatchedItem")
169
6
            .field("item", &self.item.text())
170
6
            .field("rank", &self.rank)
171
6
            .field("matched_range", &self.matched_range)
172
6
            .finish_non_exhaustive()
173
6
    }
174
}
175
176
impl Hash for MatchedItem {
177
277
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
178
277
        state.write_i32(self.rank.index);
179
277
        self.text().hash(state);
180
277
    }
181
}
182
183
impl Deref for MatchedItem {
184
    type Target = Arc<dyn SkimItem>;
185
186
54.8k
    fn deref(&self) -> &Self::Target {
187
54.8k
        &self.item
188
54.8k
    }
189
}
190
191
impl MatchedItem {
192
    /// Create a new `MatchedItem`, building the `sort_key` from the Rank and `RankBuilder`
193
53.6k
    pub fn new(
194
53.6k
        item: Arc<dyn SkimItem>,
195
53.6k
        rank: Rank,
196
53.6k
        matched_range: Option<MatchRange>,
197
53.6k
        rank_builder: &RankBuilder,
198
53.6k
    ) -> Self {
199
53.6k
        Self {
200
53.6k
            item,
201
53.6k
            rank,
202
53.6k
            matched_range,
203
53.6k
            sort_key: rank_builder.sort_key(&rank),
204
53.6k
        }
205
53.6k
    }
206
    /// Merge two sorted `Vec<MatchedItem>` lists into one, preserving sort order by rank.
207
    ///
208
    /// Both input lists must already be sorted by the same tiebreak criteria (ascending).
209
    /// The merge is O(n+m).
210
    #[must_use]
211
25
    pub fn sorted_merge(existing: Vec<MatchedItem>, incoming: Vec<MatchedItem>) -> Vec<MatchedItem> {
212
25
        if existing.is_empty() {
  Branch (212:12): [True: 16, False: 0]
+
78
17
            rank.index.saturating_neg()
79
        } else {
80
53.6k
            rank.index
81
        };
82
53.6k
        key
83
53.6k
    }
84
85
    /// Computes the **character** index of the first character after the last path
86
    /// separator (`/` or `\`) in `text`.  Returns `0` when no separator is present.
87
    ///
88
    /// This must be a char index, not a byte offset: the `PathName` tiebreak
89
    /// subtracts it from [`Rank::begin`], which is a char index, so counting bytes
90
    /// here would mix units and mis-rank any path with a non-ASCII component.
91
52.0k
    fn path_name_offset(text: &str) -> i32 {
92
52.0k
        text.rfind(['/', '\\']).map_or(0, |pos| 
{197
93
197
            i32::try_from(text[..pos].chars().count())
94
197
                .unwrap_or(i32::MAX)
95
197
                .saturating_add(1)
96
197
        })
97
52.0k
    }
98
99
    /// Builds a `Rank` from raw match measurements.
100
    ///
101
    /// The values are stored as-is; the tiebreak ordering and sign-flipping are
102
    /// applied lazily by [`Rank::sort_key`] at comparison time.
103
    /// The `index` will be overridden later
104
    #[must_use]
105
52.0k
    pub fn build_rank(&self, score: i32, begin: usize, end: usize, item_text: &str) -> Rank {
106
52.0k
        Rank {
107
52.0k
            score,
108
52.0k
            begin: i32::try_from(begin).unwrap_or(i32::MAX),
109
52.0k
            end: i32::try_from(end).unwrap_or(i32::MAX),
110
52.0k
            length: i32::try_from(item_text.len()).unwrap_or(i32::MAX),
111
52.0k
            index: Default::default(),
112
52.0k
            path_name_offset: Self::path_name_offset(item_text),
113
52.0k
        }
114
52.0k
    }
115
}
116
117
impl Rank {
118
    /// Computes the ordered sort key for this rank given a slice of tiebreak criteria.
119
    ///
120
    /// Each criterion maps to one slot in the returned `[i32; 5]` array. Values are
121
    /// sign-flipped where necessary so that the array compares lexicographically with
122
    /// the "best" match sorting first (ascending order).
123
    #[must_use]
124
53.6k
    pub fn sort_key(&self, criteria: &[RankCriteria]) -> [i32; 5] {
125
53.6k
        let mut key = [0i32; 5];
126
53.6k
        for (priority, criterion) in criteria.iter().take(5).enumerate() {
127
160k
            key[priority] = match criterion {
128
53.6k
                RankCriteria::Score => -self.score,
129
7
                RankCriteria::NegScore => self.score,
130
53.5k
                RankCriteria::Begin => self.begin,
131
8
                RankCriteria::NegBegin => -self.begin,
132
53.5k
                RankCriteria::End => self.end,
133
10
                RankCriteria::NegEnd => -self.end,
134
13
                RankCriteria::Length => self.length,
135
7
                RankCriteria::NegLength => -self.length,
136
13
                RankCriteria::Index => self.index,
137
7
                RankCriteria::NegIndex => -self.index,
138
                // PathName: prefer matches that fall within the filename portion (i.e. at or
139
                // after the last path separator).  `path_name_offset - begin` is <= 0 when the
140
                // match starts inside the filename, and positive when it starts in a directory
141
                // component.  Lower values sort first, so filename matches rank higher.
142
19
                RankCriteria::PathName => self.path_name_offset - self.begin,
143
12
                RankCriteria::NegPathName => self.begin - self.path_name_offset,
144
            };
145
        }
146
53.6k
        key
147
53.6k
    }
148
}
149
150
//------------------------------------------------------------------------------
151
/// An item that has been matched against a query
152
#[derive(Clone)]
153
pub struct MatchedItem {
154
    /// The underlying skim item
155
    pub item: Arc<dyn SkimItem>,
156
    /// Raw match measurements
157
    pub rank: Rank,
158
    /// Range of characters that matched the pattern
159
    pub matched_range: Option<MatchRange>,
160
    /// Sort key precomputed at construction time from `rank` and the tiebreak
161
    /// criteria. The sixth slot is the tac-aware implicit index tiebreak.
162
    /// Caching avoids recomputing it on every comparison during sort.
163
    sort_key: [i32; 6],
164
}
165
166
impl std::fmt::Debug for MatchedItem {
167
6
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168
6
        f.debug_struct("MatchedItem")
169
6
            .field("item", &self.item.text())
170
6
            .field("rank", &self.rank)
171
6
            .field("matched_range", &self.matched_range)
172
6
            .finish_non_exhaustive()
173
6
    }
174
}
175
176
impl Hash for MatchedItem {
177
277
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
178
277
        state.write_i32(self.rank.index);
179
277
        self.text().hash(state);
180
277
    }
181
}
182
183
impl Deref for MatchedItem {
184
    type Target = Arc<dyn SkimItem>;
185
186
54.9k
    fn deref(&self) -> &Self::Target {
187
54.9k
        &self.item
188
54.9k
    }
189
}
190
191
impl MatchedItem {
192
    /// Create a new `MatchedItem`, building the `sort_key` from the Rank and `RankBuilder`
193
53.6k
    pub fn new(
194
53.6k
        item: Arc<dyn SkimItem>,
195
53.6k
        rank: Rank,
196
53.6k
        matched_range: Option<MatchRange>,
197
53.6k
        rank_builder: &RankBuilder,
198
53.6k
    ) -> Self {
199
53.6k
        Self {
200
53.6k
            item,
201
53.6k
            rank,
202
53.6k
            matched_range,
203
53.6k
            sort_key: rank_builder.sort_key(&rank),
204
53.6k
        }
205
53.6k
    }
206
    /// Merge two sorted `Vec<MatchedItem>` lists into one, preserving sort order by rank.
207
    ///
208
    /// Both input lists must already be sorted by the same tiebreak criteria (ascending).
209
    /// The merge is O(n+m).
210
    #[must_use]
211
23
    pub fn sorted_merge(existing: Vec<MatchedItem>, incoming: Vec<MatchedItem>) -> Vec<MatchedItem> {
212
23
        if existing.is_empty() {
  Branch (212:12): [True: 14, False: 0]
 
  Branch (212:12): [True: 1, False: 8]
-
213
17
            return incoming;
214
8
        }
215
8
        if incoming.is_empty() {
  Branch (215:12): [True: 0, False: 0]
+
213
15
            return incoming;
214
8
        }
215
8
        if incoming.is_empty() {
  Branch (215:12): [True: 0, False: 0]
 
  Branch (215:12): [True: 1, False: 7]
 
216
1
            return existing;
217
7
        }
218
219
        // Fast path: if all existing <= all incoming, we can append without merging.
220
        #[allow(clippy::missing_panics_doc)]
221
7
        if existing.last().unwrap() <= incoming.first().unwrap() {
  Branch (221:12): [True: 0, False: 0]
 
  Branch (221:12): [True: 2, False: 5]
@@ -16,11 +16,11 @@
 
  Branch (229:12): [True: 3, False: 2]
 
230
3
            let mut out = incoming;
231
3
            out.extend(existing);
232
3
            return out;
233
2
        }
234
235
2
        let mut merged = Vec::with_capacity(existing.len() + incoming.len());
236
2
        let mut a = existing.into_iter().peekable();
237
2
        let mut b = incoming.into_iter().peekable();
238
239
        loop {
240
7
            match (a.peek(), b.peek()) {
241
                (Some(av), Some(bv)) => {
242
5
                    if av <= bv {
  Branch (242:24): [True: 0, False: 0]
 
  Branch (242:24): [True: 3, False: 2]
-
243
                        #[allow(clippy::missing_panics_doc)]
244
3
                        merged.push(a.next().unwrap());
245
                    } else {
246
                        #[allow(clippy::missing_panics_doc)]
247
2
                        merged.push(b.next().unwrap());
248
                    }
249
                }
250
                (Some(_), None) => {
251
1
                    merged.extend(a);
252
1
                    break;
253
                }
254
                (None, _) => {
255
1
                    merged.extend(b);
256
1
                    break;
257
                }
258
            }
259
        }
260
261
2
        merged
262
25
    }
263
264
    /// Merge `incoming` into an already-sorted `existing` vector in-place.
265
    ///
266
    /// This function chooses between two strategies:
267
    /// - If `incoming` is small (≤ 256 items), insert them one-by-one using
268
    ///   binary search to find the insertion point.  Each insert is O(n) due
269
    ///   to element shifting, giving O(m·n) overall, but the constant factor
270
    ///   is small for tiny m and avoids any extra allocation.
271
    /// - Otherwise, perform a backwards in-place merge that writes the result
272
    ///   directly into `existing`'s buffer (after a single `reserve`).  This
273
    ///   is O(n+m) time with **zero additional heap allocation** beyond the
274
    ///   amortised `Vec::reserve`.
275
    ///
276
    /// `existing` must be sorted according to the same ordering used by
277
    /// `MatchedItem::cmp`.
278
284
    pub fn merge_into_sorted(existing: &mut Vec<MatchedItem>, incoming: Vec<MatchedItem>) {
279
        const SMALL_INSERT_THRESHOLD: usize = 256;
280
281
284
        if incoming.is_empty() {
  Branch (281:12): [True: 5, False: 272]
-
  Branch (281:12): [True: 0, False: 7]
-
282
5
            return;
283
279
        }
284
285
        // When existing is empty, extend preserves any pre-allocated capacity
286
        // (e.g. a caller that did `Vec::with_capacity(total)` before a fold).
287
279
        if existing.is_empty() {
  Branch (287:12): [True: 272, False: 0]
-
  Branch (287:12): [True: 4, False: 3]
-
288
276
            existing.extend(incoming);
289
276
            return;
290
3
        }
291
292
        // Fast path: all existing ≤ first incoming — just append.
293
        #[allow(clippy::missing_panics_doc)]
294
3
        if existing.last().unwrap() <= incoming.first().unwrap() {
  Branch (294:12): [True: 0, False: 0]
+
243
                        #[allow(clippy::missing_panics_doc)]
244
3
                        merged.push(a.next().unwrap());
245
                    } else {
246
                        #[allow(clippy::missing_panics_doc)]
247
2
                        merged.push(b.next().unwrap());
248
                    }
249
                }
250
                (Some(_), None) => {
251
1
                    merged.extend(a);
252
1
                    break;
253
                }
254
                (None, _) => {
255
1
                    merged.extend(b);
256
1
                    break;
257
                }
258
            }
259
        }
260
261
2
        merged
262
23
    }
263
264
    /// Merge `incoming` into an already-sorted `existing` vector in-place.
265
    ///
266
    /// This function chooses between two strategies:
267
    /// - If `incoming` is small (≤ 256 items), insert them one-by-one using
268
    ///   binary search to find the insertion point.  Each insert is O(n) due
269
    ///   to element shifting, giving O(m·n) overall, but the constant factor
270
    ///   is small for tiny m and avoids any extra allocation.
271
    /// - Otherwise, perform a backwards in-place merge that writes the result
272
    ///   directly into `existing`'s buffer (after a single `reserve`).  This
273
    ///   is O(n+m) time with **zero additional heap allocation** beyond the
274
    ///   amortised `Vec::reserve`.
275
    ///
276
    /// `existing` must be sorted according to the same ordering used by
277
    /// `MatchedItem::cmp`.
278
284
    pub fn merge_into_sorted(existing: &mut Vec<MatchedItem>, incoming: Vec<MatchedItem>) {
279
        const SMALL_INSERT_THRESHOLD: usize = 256;
280
281
284
        if incoming.is_empty() {
  Branch (281:12): [True: 6, False: 269]
+
  Branch (281:12): [True: 0, False: 9]
+
282
6
            return;
283
278
        }
284
285
        // When existing is empty, extend preserves any pre-allocated capacity
286
        // (e.g. a caller that did `Vec::with_capacity(total)` before a fold).
287
278
        if existing.is_empty() {
  Branch (287:12): [True: 269, False: 0]
+
  Branch (287:12): [True: 6, False: 3]
+
288
275
            existing.extend(incoming);
289
275
            return;
290
3
        }
291
292
        // Fast path: all existing ≤ first incoming — just append.
293
        #[allow(clippy::missing_panics_doc)]
294
3
        if existing.last().unwrap() <= incoming.first().unwrap() {
  Branch (294:12): [True: 0, False: 0]
 
  Branch (294:12): [True: 0, False: 3]
 
295
0
            existing.extend(incoming);
296
0
            return;
297
3
        }
298
299
3
        if incoming.len() <= SMALL_INSERT_THRESHOLD {
  Branch (299:12): [True: 0, False: 0]
 
  Branch (299:12): [True: 1, False: 2]
@@ -32,10 +32,10 @@
 
  Branch (351:20): [True: 599, False: 300]
 
352
599
                    ai -= 1;
353
599
                    std::ptr::copy_nonoverlapping(a_ptr.add(ai), a_ptr.add(write), 1);
354
                } else {
355
300
                    bi -= 1;
356
300
                    std::ptr::copy_nonoverlapping(b_ptr.add(bi), a_ptr.add(write), 1);
357
                }
358
            }
359
360
            // Remaining B elements go to the front of existing.
361
            // (Remaining A elements at 0..ai are already in their final positions.)
362
2
            if bi > 0 {
  Branch (362:16): [True: 0, False: 0]
 
  Branch (362:16): [True: 1, False: 1]
-
363
1
                std::ptr::copy_nonoverlapping(b_ptr, a_ptr, bi);
364
1
            }
365
366
2
            existing.set_len(new_len);
367
368
            // Free incoming's backing allocation; all elements were moved out.
369
2
            drop(Vec::from_raw_parts(b_ptr, 0, b_cap));
370
        }
371
2
    }
372
}
373
374
impl MatchedItem {
375
    /// Downcast the `MatchedItem` to the corresponding `SkimItem` struct
376
    #[must_use]
377
1
    pub fn downcast_item<T: SkimItem>(&self) -> Option<&T> {
378
1
        (*self.item).as_any().downcast_ref::<T>()
379
1
    }
380
}
381
382
use std::cmp::Ordering as CmpOrd;
383
384
impl PartialEq for MatchedItem {
385
307
    fn eq(&self, other: &Self) -> bool {
386
307
        self.text().eq(&other.text()) && 
self.rank.index.eq(&other.rank.index)181
  Branch (386:9): [True: 163, False: 120]
-
  Branch (386:9): [True: 18, False: 6]
-
387
307
    }
388
}
389
390
impl std::cmp::Eq for MatchedItem {}
391
392
impl PartialOrd for MatchedItem {
393
5.20k
    fn partial_cmp(&self, other: &Self) -> Option<CmpOrd> {
394
5.20k
        Some(self.cmp(other))
395
5.20k
    }
396
}
397
398
impl Ord for MatchedItem {
399
5.20k
    fn cmp(&self, other: &Self) -> CmpOrd {
400
5.20k
        self.sort_key.cmp(&other.sort_key)
401
5.20k
    }
402
}
403
404
//------------------------------------------------------------------------------
405
const ITEM_POOL_CAPACITY: usize = 16384;
406
407
/// Thread-safe pool for storing and managing items efficiently
408
pub struct ItemPool {
409
    /// Total number of items in the pool
410
    length: AtomicUsize,
411
    /// The main pool of items
412
    pool: SpinLock<Vec<Arc<dyn SkimItem>>>,
413
    /// Number of items that were taken
414
    taken: AtomicUsize,
415
416
    /// Reserved first N lines as header
417
    reserved_items: SpinLock<Vec<Arc<dyn SkimItem>>>,
418
    /// Number of lines to reserve as header
419
    lines_to_reserve: usize,
420
    /// Reverse the order of items (--tac flag)
421
    tac: bool,
422
423
    /// Notified whenever new items are appended to the pool (async path).
424
    ///
425
    /// Listeners (e.g. the TUI event loop) can `await` this to wake up
426
    /// immediately when items arrive instead of waiting for the next
427
    /// periodic tick.
428
    pub items_available: Arc<Notify>,
429
}
430
431
impl Default for ItemPool {
432
145
    fn default() -> Self {
433
145
        Self {
434
145
            length: AtomicUsize::new(0),
435
145
            pool: SpinLock::new(Vec::with_capacity(ITEM_POOL_CAPACITY)),
436
145
            taken: AtomicUsize::new(0),
437
145
            reserved_items: SpinLock::new(Vec::new()),
438
145
            lines_to_reserve: 0,
439
145
            tac: false,
440
145
            items_available: Arc::new(Notify::new()),
441
145
        }
442
145
    }
443
}
444
445
impl ItemPool {
446
    /// Creates a new empty item pool
447
    #[must_use]
448
8
    pub fn new() -> Self {
449
8
        Self::default()
450
8
    }
451
452
    /// Creates a new item pool from skim options
453
    #[must_use]
454
407
    pub fn from_options(options: &crate::SkimOptions) -> Self {
455
407
        Self {
456
407
            length: AtomicUsize::new(0),
457
407
            pool: SpinLock::new(Vec::with_capacity(ITEM_POOL_CAPACITY)),
458
407
            taken: AtomicUsize::new(0),
459
407
            reserved_items: SpinLock::new(Vec::new()),
460
407
            lines_to_reserve: options.header_lines,
461
407
            tac: options.tac,
462
407
            items_available: Arc::new(Notify::new()),
463
407
        }
464
407
    }
465
466
    /// Returns the total number of items in the pool
467
2.68k
    pub fn len(&self) -> usize {
468
2.68k
        self.length.load(Ordering::SeqCst)
469
2.68k
    }
470
471
    /// Returns true if the pool contains no items
472
3
    pub fn is_empty(&self) -> bool {
473
3
        self.len() == 0
474
3
    }
475
476
    /// Returns the number of items that have not been taken yet
477
2.79k
    pub fn num_not_taken(&self) -> usize {
478
2.79k
        self.length.load(Ordering::SeqCst) - self.taken.load(Ordering::SeqCst)
479
2.79k
    }
480
481
    /// Returns the number of items that have been taken
482
1.09k
    pub fn num_taken(&self) -> usize {
483
1.09k
        self.taken.load(Ordering::SeqCst)
484
1.09k
    }
485
486
    /// Clears all items from the pool and resets counters
487
45
    pub fn clear(&self) {
488
45
        let mut items = self.pool.lock();
489
45
        items.clear();
490
45
        let mut header_items = self.reserved_items.lock();
491
45
        header_items.clear();
492
45
        self.taken.store(0, Ordering::SeqCst);
493
45
        self.length.store(0, Ordering::SeqCst);
494
45
    }
495
496
    /// Resets the taken counter without clearing items
497
784
    pub fn reset(&self) {
498
        // lock to ensure consistency
499
784
        let _items = self.pool.lock();
500
501
784
        self.taken.store(0, Ordering::SeqCst);
502
784
    }
503
504
    /// append the items and return the `new_size` of the pool
505
440
    pub fn append(&self, mut items: Vec<Arc<dyn SkimItem>>) -> usize {
506
440
        let len = items.len();
507
440
        trace!("item pool, append {
len2
} items");
508
440
        let mut pool = self.pool.lock();
509
440
        let mut header_items = self.reserved_items.lock();
510
511
440
        let to_reserve = self.lines_to_reserve - header_items.len();
512
440
        if to_reserve > 0 {
  Branch (512:12): [True: 17, False: 391]
-
  Branch (512:12): [True: 1, False: 31]
-
513
18
            let to_reserve = min(to_reserve, items.len());
514
            // Split items: first part goes to header, rest to main pool
515
18
            let remaining = items.split_off(to_reserve);
516
517
            // Header items are always in input order, regardless of tac
518
18
            header_items.extend(items);
519
520
18
            pool.extend(remaining);
521
        } else {
522
422
            pool.extend(items);
523
        }
524
440
        self.length.store(pool.len(), Ordering::SeqCst);
525
440
        trace!("item pool, done append {
len2
} items, total: {}",
pool.len()2
);
526
440
        let new_len = pool.len();
527
440
        drop(pool);
528
440
        drop(header_items);
529
        // Wake any listener that is waiting for new items (e.g. the event loop
530
        // or the filter-mode loop) so it can restart the matcher immediately
531
        // instead of waiting for the next periodic tick.
532
440
        self.items_available.notify_one();
533
534
440
        new_len
535
440
    }
536
537
    /// Takes items from the pool, copying new items since last take and releasing lock immediately
538
1.09k
    pub fn take(&self) -> Vec<Arc<dyn SkimItem>> {
539
1.09k
        let guard = self.pool.lock();
540
1.09k
        let taken = self.taken.swap(guard.len(), Ordering::SeqCst);
541
        // Copy the new items out so we can release the lock immediately
542
1.09k
        let mut items = guard[taken..].to_vec();
543
1.09k
        if self.tac {
  Branch (543:12): [True: 6, False: 1.05k]
-
  Branch (543:12): [True: 1, False: 35]
-
544
7
            items.reverse();
545
1.08k
        }
546
1.09k
        drop(guard); // Explicitly release lock
547
1.09k
        items
548
1.09k
    }
549
550
    /// Returns a copy of the reserved header items
551
2.73k
    pub fn reserved(&self) -> Vec<Arc<dyn SkimItem>> {
552
2.73k
        let guard = self.reserved_items.lock();
553
2.73k
        guard.clone()
554
2.73k
    }
555
}
556
557
/// Guard for accessing a slice of items from the pool
558
pub struct ItemPoolGuard<'a, T: Sized + 'a> {
559
    guard: SpinLockGuard<'a, Vec<T>>,
560
    start: usize,
561
}
562
563
impl<T: Sized> Deref for ItemPoolGuard<'_, T> {
564
    type Target = [T];
565
566
0
    fn deref(&self) -> &[T] {
567
0
        &self.guard[self.start..]
568
0
    }
569
}
570
571
//------------------------------------------------------------------------------
572
/// Criteria for ranking and sorting matched items
573
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
574
pub enum RankCriteria {
575
    /// Sort by match score (lower is better)
576
    Score,
577
    /// Sort by match score (higher is better)
578
    NegScore,
579
    /// Sort by beginning position of match
580
    Begin,
581
    /// Sort by beginning position of match (reversed)
582
    NegBegin,
583
    /// Sort by ending position of match
584
    End,
585
    /// Sort by ending position of match (reversed)
586
    NegEnd,
587
    /// Sort by item length
588
    Length,
589
    /// Sort by item length (reversed)
590
    NegLength,
591
    /// Sort by item index
592
    Index,
593
    /// Sort by item index (reversed)
594
    NegIndex,
595
    /// Give a bonus to matches that are after the last path separator (`/` or `\`)
596
    PathName,
597
    /// Give a bonus to matches that are after the last path separator (reversed)
598
    NegPathName,
599
}
600
601
#[cfg(feature = "cli")]
602
impl ValueEnum for RankCriteria {
603
1.44k
    fn value_variants<'a>() -> &'a [Self] {
604
        use RankCriteria::{
605
            Begin, End, Index, Length, NegBegin, NegEnd, NegIndex, NegLength, NegPathName, NegScore, PathName, Score,
606
        };
607
1.44k
        &[
608
1.44k
            Score,
609
1.44k
            NegScore,
610
1.44k
            Begin,
611
1.44k
            NegBegin,
612
1.44k
            End,
613
1.44k
            NegEnd,
614
1.44k
            Length,
615
1.44k
            NegLength,
616
1.44k
            Index,
617
1.44k
            NegIndex,
618
1.44k
            PathName,
619
1.44k
            NegPathName,
620
1.44k
        ]
621
1.44k
    }
622
623
4.52k
    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
624
        use RankCriteria::{
625
            Begin, End, Index, Length, NegBegin, NegEnd, NegIndex, NegLength, NegPathName, NegScore, PathName, Score,
626
        };
627
4.52k
        Some(match self {
628
1.44k
            Score => PossibleValue::new("score"),
629
963
            Begin => PossibleValue::new("begin"),
630
494
            End => PossibleValue::new("end"),
631
964
            NegScore => PossibleValue::new("-score"),
632
495
            NegBegin => PossibleValue::new("-begin"),
633
27
            NegEnd => PossibleValue::new("-end"),
634
26
            Length => PossibleValue::new("length"),
635
24
            NegLength => PossibleValue::new("-length"),
636
23
            Index => PossibleValue::new("index"),
637
22
            NegIndex => PossibleValue::new("-index"),
638
21
            PathName => PossibleValue::new("pathname"),
639
20
            NegPathName => PossibleValue::new("-pathname"),
640
        })
641
4.52k
    }
642
}
643
644
#[cfg(test)]
645
#[allow(clippy::field_reassign_with_default)]
646
#[path = "item_tests.rs"]
647
mod tests;
\ No newline at end of file +
363
1
                std::ptr::copy_nonoverlapping(b_ptr, a_ptr, bi);
364
1
            }
365
366
2
            existing.set_len(new_len);
367
368
            // Free incoming's backing allocation; all elements were moved out.
369
2
            drop(Vec::from_raw_parts(b_ptr, 0, b_cap));
370
        }
371
2
    }
372
}
373
374
impl MatchedItem {
375
    /// Downcast the `MatchedItem` to the corresponding `SkimItem` struct
376
    #[must_use]
377
1
    pub fn downcast_item<T: SkimItem>(&self) -> Option<&T> {
378
1
        (*self.item).as_any().downcast_ref::<T>()
379
1
    }
380
}
381
382
use std::cmp::Ordering as CmpOrd;
383
384
impl PartialEq for MatchedItem {
385
309
    fn eq(&self, other: &Self) -> bool {
386
309
        self.text().eq(&other.text()) && 
self.rank.index.eq(&other.rank.index)181
  Branch (386:9): [True: 163, False: 120]
+
  Branch (386:9): [True: 18, False: 8]
+
387
309
    }
388
}
389
390
impl std::cmp::Eq for MatchedItem {}
391
392
impl PartialOrd for MatchedItem {
393
5.20k
    fn partial_cmp(&self, other: &Self) -> Option<CmpOrd> {
394
5.20k
        Some(self.cmp(other))
395
5.20k
    }
396
}
397
398
impl Ord for MatchedItem {
399
5.21k
    fn cmp(&self, other: &Self) -> CmpOrd {
400
5.21k
        self.sort_key.cmp(&other.sort_key)
401
5.21k
    }
402
}
403
404
//------------------------------------------------------------------------------
405
const ITEM_POOL_CAPACITY: usize = 16384;
406
407
/// Thread-safe pool for storing and managing items efficiently
408
pub struct ItemPool {
409
    /// Total number of items in the pool
410
    length: AtomicUsize,
411
    /// The main pool of items
412
    pool: SpinLock<Vec<Arc<dyn SkimItem>>>,
413
    /// Number of items that were taken
414
    taken: AtomicUsize,
415
416
    /// Reserved first N lines as header
417
    reserved_items: SpinLock<Vec<Arc<dyn SkimItem>>>,
418
    /// Number of lines to reserve as header
419
    lines_to_reserve: usize,
420
    /// Reverse the order of items (--tac flag)
421
    tac: bool,
422
423
    /// Notified whenever new items are appended to the pool (async path).
424
    ///
425
    /// Listeners (e.g. the TUI event loop) can `await` this to wake up
426
    /// immediately when items arrive instead of waiting for the next
427
    /// periodic tick.
428
    pub items_available: Arc<Notify>,
429
}
430
431
impl Default for ItemPool {
432
145
    fn default() -> Self {
433
145
        Self {
434
145
            length: AtomicUsize::new(0),
435
145
            pool: SpinLock::new(Vec::with_capacity(ITEM_POOL_CAPACITY)),
436
145
            taken: AtomicUsize::new(0),
437
145
            reserved_items: SpinLock::new(Vec::new()),
438
145
            lines_to_reserve: 0,
439
145
            tac: false,
440
145
            items_available: Arc::new(Notify::new()),
441
145
        }
442
145
    }
443
}
444
445
impl ItemPool {
446
    /// Creates a new empty item pool
447
    #[must_use]
448
8
    pub fn new() -> Self {
449
8
        Self::default()
450
8
    }
451
452
    /// Creates a new item pool from skim options
453
    #[must_use]
454
406
    pub fn from_options(options: &crate::SkimOptions) -> Self {
455
406
        Self {
456
406
            length: AtomicUsize::new(0),
457
406
            pool: SpinLock::new(Vec::with_capacity(ITEM_POOL_CAPACITY)),
458
406
            taken: AtomicUsize::new(0),
459
406
            reserved_items: SpinLock::new(Vec::new()),
460
406
            lines_to_reserve: options.header_lines,
461
406
            tac: options.tac,
462
406
            items_available: Arc::new(Notify::new()),
463
406
        }
464
406
    }
465
466
    /// Returns the total number of items in the pool
467
2.69k
    pub fn len(&self) -> usize {
468
2.69k
        self.length.load(Ordering::SeqCst)
469
2.69k
    }
470
471
    /// Returns true if the pool contains no items
472
3
    pub fn is_empty(&self) -> bool {
473
3
        self.len() == 0
474
3
    }
475
476
    /// Returns the number of items that have not been taken yet
477
2.77k
    pub fn num_not_taken(&self) -> usize {
478
2.77k
        self.length.load(Ordering::SeqCst) - self.taken.load(Ordering::SeqCst)
479
2.77k
    }
480
481
    /// Returns the number of items that have been taken
482
1.09k
    pub fn num_taken(&self) -> usize {
483
1.09k
        self.taken.load(Ordering::SeqCst)
484
1.09k
    }
485
486
    /// Clears all items from the pool and resets counters
487
45
    pub fn clear(&self) {
488
45
        let mut items = self.pool.lock();
489
45
        items.clear();
490
45
        let mut header_items = self.reserved_items.lock();
491
45
        header_items.clear();
492
45
        self.taken.store(0, Ordering::SeqCst);
493
45
        self.length.store(0, Ordering::SeqCst);
494
45
    }
495
496
    /// Resets the taken counter without clearing items
497
786
    pub fn reset(&self) {
498
        // lock to ensure consistency
499
786
        let _items = self.pool.lock();
500
501
786
        self.taken.store(0, Ordering::SeqCst);
502
786
    }
503
504
    /// append the items and return the `new_size` of the pool
505
438
    pub fn append(&self, mut items: Vec<Arc<dyn SkimItem>>) -> usize {
506
438
        let len = items.len();
507
438
        trace!("item pool, append {
len2
} items");
508
438
        let mut pool = self.pool.lock();
509
438
        let mut header_items = self.reserved_items.lock();
510
511
438
        let to_reserve = self.lines_to_reserve - header_items.len();
512
438
        if to_reserve > 0 {
  Branch (512:12): [True: 17, False: 390]
+
  Branch (512:12): [True: 1, False: 30]
+
513
18
            let to_reserve = min(to_reserve, items.len());
514
            // Split items: first part goes to header, rest to main pool
515
18
            let remaining = items.split_off(to_reserve);
516
517
            // Header items are always in input order, regardless of tac
518
18
            header_items.extend(items);
519
520
18
            pool.extend(remaining);
521
        } else {
522
420
            pool.extend(items);
523
        }
524
438
        self.length.store(pool.len(), Ordering::SeqCst);
525
438
        trace!("item pool, done append {
len2
} items, total: {}",
pool.len()2
);
526
438
        let new_len = pool.len();
527
438
        drop(pool);
528
438
        drop(header_items);
529
        // Wake any listener that is waiting for new items (e.g. the event loop
530
        // or the filter-mode loop) so it can restart the matcher immediately
531
        // instead of waiting for the next periodic tick.
532
438
        self.items_available.notify_one();
533
534
438
        new_len
535
438
    }
536
537
    /// Takes items from the pool, copying new items since last take and releasing lock immediately
538
1.09k
    pub fn take(&self) -> Vec<Arc<dyn SkimItem>> {
539
1.09k
        let guard = self.pool.lock();
540
1.09k
        let taken = self.taken.swap(guard.len(), Ordering::SeqCst);
541
        // Copy the new items out so we can release the lock immediately
542
1.09k
        let mut items = guard[taken..].to_vec();
543
1.09k
        if self.tac {
  Branch (543:12): [True: 6, False: 1.04k]
+
  Branch (543:12): [True: 1, False: 38]
+
544
7
            items.reverse();
545
1.08k
        }
546
1.09k
        drop(guard); // Explicitly release lock
547
1.09k
        items
548
1.09k
    }
549
550
    /// Returns a copy of the reserved header items
551
2.73k
    pub fn reserved(&self) -> Vec<Arc<dyn SkimItem>> {
552
2.73k
        let guard = self.reserved_items.lock();
553
2.73k
        guard.clone()
554
2.73k
    }
555
}
556
557
/// Guard for accessing a slice of items from the pool
558
pub struct ItemPoolGuard<'a, T: Sized + 'a> {
559
    guard: SpinLockGuard<'a, Vec<T>>,
560
    start: usize,
561
}
562
563
impl<T: Sized> Deref for ItemPoolGuard<'_, T> {
564
    type Target = [T];
565
566
0
    fn deref(&self) -> &[T] {
567
0
        &self.guard[self.start..]
568
0
    }
569
}
570
571
//------------------------------------------------------------------------------
572
/// Criteria for ranking and sorting matched items
573
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
574
pub enum RankCriteria {
575
    /// Sort by match score (lower is better)
576
    Score,
577
    /// Sort by match score (higher is better)
578
    NegScore,
579
    /// Sort by beginning position of match
580
    Begin,
581
    /// Sort by beginning position of match (reversed)
582
    NegBegin,
583
    /// Sort by ending position of match
584
    End,
585
    /// Sort by ending position of match (reversed)
586
    NegEnd,
587
    /// Sort by item length
588
    Length,
589
    /// Sort by item length (reversed)
590
    NegLength,
591
    /// Sort by item index
592
    Index,
593
    /// Sort by item index (reversed)
594
    NegIndex,
595
    /// Give a bonus to matches that are after the last path separator (`/` or `\`)
596
    PathName,
597
    /// Give a bonus to matches that are after the last path separator (reversed)
598
    NegPathName,
599
}
600
601
#[cfg(feature = "cli")]
602
impl ValueEnum for RankCriteria {
603
1.44k
    fn value_variants<'a>() -> &'a [Self] {
604
        use RankCriteria::{
605
            Begin, End, Index, Length, NegBegin, NegEnd, NegIndex, NegLength, NegPathName, NegScore, PathName, Score,
606
        };
607
1.44k
        &[
608
1.44k
            Score,
609
1.44k
            NegScore,
610
1.44k
            Begin,
611
1.44k
            NegBegin,
612
1.44k
            End,
613
1.44k
            NegEnd,
614
1.44k
            Length,
615
1.44k
            NegLength,
616
1.44k
            Index,
617
1.44k
            NegIndex,
618
1.44k
            PathName,
619
1.44k
            NegPathName,
620
1.44k
        ]
621
1.44k
    }
622
623
4.51k
    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
624
        use RankCriteria::{
625
            Begin, End, Index, Length, NegBegin, NegEnd, NegIndex, NegLength, NegPathName, NegScore, PathName, Score,
626
        };
627
4.51k
        Some(match self {
628
1.44k
            Score => PossibleValue::new("score"),
629
962
            Begin => PossibleValue::new("begin"),
630
493
            End => PossibleValue::new("end"),
631
963
            NegScore => PossibleValue::new("-score"),
632
494
            NegBegin => PossibleValue::new("-begin"),
633
26
            NegEnd => PossibleValue::new("-end"),
634
25
            Length => PossibleValue::new("length"),
635
24
            NegLength => PossibleValue::new("-length"),
636
23
            Index => PossibleValue::new("index"),
637
22
            NegIndex => PossibleValue::new("-index"),
638
21
            PathName => PossibleValue::new("pathname"),
639
20
            NegPathName => PossibleValue::new("-pathname"),
640
        })
641
4.51k
    }
642
}
643
644
#[cfg(test)]
645
#[allow(clippy::field_reassign_with_default)]
646
#[path = "item_tests.rs"]
647
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/lib.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/lib.rs.html index 54aeb538..b82f07e0 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/lib.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/lib.rs.html @@ -1 +1 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/lib.rs
Line
Count
Source
1
//! Skim is a fuzzy finder library for Rust.
2
//!
3
//! It provides a fast and customizable way to filter and select items interactively,
4
//! similar to fzf. Skim can be used as a library or as a command-line tool.
5
//!
6
//! # Examples
7
//!
8
//! ```no_run
9
//! use skim::prelude::*;
10
//!
11
//! let options = SkimOptionsBuilder::default()
12
//!     .height("50%")
13
//!     .multi(true)
14
//!     .build()
15
//!     .unwrap();
16
//!
17
//! let output = Skim::run_items(
18
//!         options,
19
//!         ["awk", "bash", "csh", "dash", "fish", "ksh", "zsh"]
20
//!     ).unwrap();
21
//! ```
22
#![cfg_attr(coverage, feature(coverage_attribute))]
23
24
#[macro_use]
25
extern crate log;
26
27
#[global_allocator]
28
static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc;
29
30
use std::any::Any;
31
use std::borrow::Cow;
32
use std::fmt::Display;
33
use std::process::Command;
34
use std::sync::Arc;
35
36
use crate::fuzzy_matcher::MatchIndices;
37
use ratatui::style::Style;
38
use ratatui::text::{Line, Span};
39
40
pub use crate::engine::fuzzy::FuzzyAlgorithm;
41
pub use crate::item::RankCriteria;
42
pub use crate::options::SkimOptions;
43
pub use crate::output::{BinOptions, SkimOutput};
44
pub use crate::skim::*;
45
pub use crate::skim_item::SkimItem;
46
use crate::tui::Size;
47
pub use util::printf;
48
49
pub mod binds;
50
mod engine;
51
pub mod field;
52
pub mod fuzzy_matcher;
53
pub mod helper;
54
pub mod item;
55
pub mod matcher;
56
pub mod options;
57
mod output;
58
#[cfg(unix)]
59
pub mod popup;
60
pub mod prelude;
61
pub mod reader;
62
mod skim;
63
mod skim_item;
64
pub mod spinlock;
65
pub mod theme;
66
pub mod thread_pool;
67
pub mod tui;
68
mod util;
69
70
#[cfg(feature = "cli")]
71
pub mod manpage;
72
#[cfg(feature = "cli")]
73
pub mod shell;
74
75
/// Skim's default command when no `--cmd` flag is passed and `$SKIM_DEFAULT_COMMAND` is unset
76
#[cfg(unix)]
77
pub const SKIM_DEFAULT_COMMAND: &str = "find .";
78
/// Skim's default command when no `--cmd` flag is passed and `$SKIM_DEFAULT_COMMAND` is unset
79
#[cfg(windows)]
80
pub const SKIM_DEFAULT_COMMAND: &str = "dir /s /b /A:-D";
81
82
#[cfg(unix)]
83
120
fn shell_cmd(cmd: &str) -> Command {
84
120
    let mut c = Command::new("sh");
85
120
    c.arg("-c").arg(cmd);
86
120
    c
87
120
}
88
#[cfg(windows)]
89
fn shell_cmd(cmd: &str) -> Command {
90
    use std::os::windows::process::CommandExt as _;
91
    // `cmd.exe` does not parse its command line using MSVC rules, so the default
92
    // `Command::arg` escaping (quoting/backslash-escaping) corrupts shell
93
    // metacharacters like `|`, `&`, `>` and embedded quotes. Pass the command
94
    // string verbatim via `raw_arg` so cmd.exe sees exactly what the user wrote.
95
    let mut c = Command::new("cmd");
96
    c.arg("/c").raw_arg(cmd);
97
    c
98
}
99
100
//------------------------------------------------------------------------------
101
/// Trait for downcasting to concrete types from trait objects
102
pub trait AsAny {
103
    /// Returns a reference to the value as `Any`
104
    fn as_any(&self) -> &dyn Any;
105
    /// Returns a mutable reference to the value as `Any`
106
    fn as_any_mut(&mut self) -> &mut dyn Any;
107
}
108
109
impl<T: Any> AsAny for T {
110
2
    fn as_any(&self) -> &dyn Any {
111
2
        self
112
2
    }
113
114
1
    fn as_any_mut(&mut self) -> &mut dyn Any {
115
1
        self
116
1
    }
117
}
118
119
//------------------------------------------------------------------------------
120
// Display Context
121
#[derive(Default, Debug, Clone)]
122
/// Represents how a query matches an item
123
pub enum Matches {
124
    /// No matches
125
    #[default]
126
    None,
127
    /// Matches at specific character indices
128
    CharIndices(MatchIndices),
129
    /// Matches in a character range (start, end)
130
    CharRange(usize, usize),
131
    /// Matches in a byte range (start, end)
132
    ByteRange(usize, usize),
133
}
134
135
#[derive(Default, Clone)]
136
/// Context information for displaying an item
137
pub struct DisplayContext {
138
    /// The match score for this item
139
    pub score: i32,
140
    /// Where the query matched in the item
141
    pub matches: Matches,
142
    /// The width of the container to display in
143
    pub container_width: usize,
144
    /// The base style to apply to non-matched portions
145
    pub base_style: Style,
146
    /// The style to apply to matched portions
147
    pub matched_style: Style,
148
}
149
150
impl DisplayContext {
151
    /// Converts the context and text into a styled `Line` with highlighted matches
152
    ///
153
    /// # Panics
154
    ///
155
    /// Panics if the byte ranges in `Matches::ByteRange` do not align with valid UTF-8 boundaries.
156
    #[must_use]
157
6.47k
    pub fn to_line(self, cow: Cow<str>) -> Line {
158
6.47k
        let text: String = cow.into_owned();
159
160
        // Combine base_style with match style for highlighted text
161
        // Match style takes precedence for fg, but inherits bg from base if not set
162
6.47k
        match &self.matches {
163
            Matches::CharIndices(indices) => {
164
1.63k
                let mut res = Line::default();
165
1.63k
                let mut chars = text.chars();
166
1.63k
                let mut prev_index = 0;
167
1.63k
                for &index in indices {
168
3.58k
                    let span_content = chars.by_ref().take(index - prev_index);
169
3.58k
                    res.push_span(Span::styled(span_content.collect::<String>(), self.base_style));
170
3.58k
                    let highlighted_char = chars.next().unwrap_or_default().to_string();
171
172
3.58k
                    res.push_span(Span::styled(
173
3.58k
                        highlighted_char,
174
3.58k
                        self.base_style.patch(self.matched_style),
175
3.58k
                    ));
176
3.58k
                    prev_index = index + 1;
177
                }
178
1.63k
                res.push_span(Span::styled(chars.collect::<String>(), self.base_style));
179
1.63k
                res
180
            }
181
            // AnsiString::from((context.text, indices, context.highlight_attr)),
182
            #[allow(clippy::cast_possible_truncation)]
183
            Matches::CharRange(start, end) => {
184
1
                let mut chars = text.chars();
185
1
                let mut res = Line::default();
186
1
                res.push_span(Span::styled(
187
1
                    chars.by_ref().take(*start).collect::<String>(),
188
1
                    self.base_style,
189
1
                ));
190
1
                let highlighted_text = chars.by_ref().take(*end - *start).collect::<String>();
191
192
1
                res.push_span(Span::styled(
193
1
                    highlighted_text,
194
1
                    self.base_style.patch(self.matched_style),
195
1
                ));
196
1
                res.push_span(Span::styled(chars.collect::<String>(), self.base_style));
197
1
                res
198
            }
199
            Matches::ByteRange(start, end) => {
200
4.63k
                let mut bytes = text.bytes();
201
4.63k
                let mut res = Line::default();
202
4.63k
                res.push_span(Span::styled(
203
4.63k
                    String::from_utf8(bytes.by_ref().take(*start).collect()).unwrap(),
204
4.63k
                    self.base_style,
205
4.63k
                ));
206
4.63k
                let highlighted_bytes = bytes.by_ref().take(*end - *start).collect();
207
4.63k
                let highlighted_text = String::from_utf8(highlighted_bytes).unwrap();
208
209
4.63k
                res.push_span(Span::styled(
210
4.63k
                    highlighted_text,
211
4.63k
                    self.base_style.patch(self.matched_style),
212
4.63k
                ));
213
4.63k
                res.push_span(Span::styled(
214
4.63k
                    String::from_utf8(bytes.collect()).unwrap(),
215
4.63k
                    self.base_style,
216
4.63k
                ));
217
4.63k
                res
218
            }
219
200
            Matches::None => Line::from(vec![Span::styled(text, self.base_style)]),
220
        }
221
6.47k
    }
222
}
223
224
//------------------------------------------------------------------------------
225
// Preview Context
226
227
/// Context information for generating item previews
228
pub struct PreviewContext<'a> {
229
    /// The current search query
230
    pub query: &'a str,
231
    /// The current command query (for interactive mode)
232
    pub cmd_query: &'a str,
233
    /// Width of the preview window
234
    pub width: usize,
235
    /// Height of the preview window
236
    pub height: usize,
237
    /// Index of the current item
238
    pub current_index: usize,
239
    /// Text of the current selection
240
    pub current_selection: &'a str,
241
    /// selected item indices (may or may not include current item)
242
    pub selected_indices: &'a [usize],
243
    /// selected item texts (may or may not include current item)
244
    pub selections: &'a [&'a str],
245
}
246
247
//------------------------------------------------------------------------------
248
// Preview
249
250
/// Position and scroll information for preview display
251
#[derive(Default, Copy, Clone, Debug)]
252
pub struct PreviewPosition {
253
    /// Horizontal scroll position
254
    pub h_scroll: Size,
255
    /// Horizontal offset
256
    pub h_offset: Size,
257
    /// Vertical scroll position
258
    pub v_scroll: Size,
259
    /// Vertical offset
260
    pub v_offset: Size,
261
}
262
263
/// Defines how an item should be previewed
264
pub enum ItemPreview {
265
    /// execute the command and print the command's output
266
    Command(String),
267
    /// Display the prepared text(lines)
268
    Text(String),
269
    /// Display the colored text(lines)
270
    AnsiText(String),
271
    /// Execute a command and display output with position
272
    CommandWithPos(String, PreviewPosition),
273
    /// Display text with position
274
    TextWithPos(String, PreviewPosition),
275
    /// Display ANSI-colored text with position
276
    AnsiWithPos(String, PreviewPosition),
277
    /// Use global command settings to preview the item
278
    Global,
279
}
280
281
//==============================================================================
282
// A match engine will execute the matching algorithm
283
284
/// Case sensitivity mode for matching
285
#[derive(Eq, PartialEq, Debug, Copy, Clone, Default)]
286
#[cfg_attr(feature = "cli", derive(clap::ValueEnum), clap(rename_all = "snake_case"))]
287
pub enum CaseMatching {
288
    /// Case-sensitive matching
289
    Respect,
290
    /// Case-insensitive matching
291
    Ignore,
292
    /// Smart case: case-insensitive unless query contains uppercase
293
    #[default]
294
    Smart,
295
}
296
297
/// Typo tolerance configuration for fuzzy matching
298
///
299
/// Controls how many character mismatches (typos) are allowed when matching.
300
#[derive(Eq, PartialEq, Debug, Copy, Clone, Default)]
301
pub enum Typos {
302
    /// No typo tolerance — query must match exactly
303
    #[default]
304
    Disabled,
305
    /// Adaptive typo tolerance — allows `pattern_length / 4` typos
306
    Smart,
307
    /// Fixed typo tolerance — allows exactly `n` typos
308
    Fixed(usize),
309
}
310
311
impl From<usize> for Typos {
312
2
    fn from(n: usize) -> Self {
313
2
        match n {
314
1
            0 => Typos::Disabled,
315
1
            n => Typos::Fixed(n),
316
        }
317
2
    }
318
}
319
320
/// Represents the range of a match in an item
321
#[derive(PartialEq, Eq, Clone, Debug)]
322
pub enum MatchRange {
323
    /// Range of bytes (start, end)
324
    ByteRange(usize, usize),
325
    /// Range of character indices (start, end) — used by fuzzy matchers that
326
    /// operate on `char` arrays rather than raw bytes.
327
    CharRange(usize, usize),
328
    /// Individual character indices that matched
329
    Chars(MatchIndices),
330
}
331
332
/// Rank stores the raw match measurements used for sorting results.
333
///
334
/// Named fields preserve the semantic meaning of each value. The actual
335
/// sort key (taking into account the user-configured tiebreak criteria and
336
/// their direction) is computed lazily via [`Rank::sort_key`] rather than
337
/// being baked in at construction time.
338
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
339
pub struct Rank {
340
    /// Raw fuzzy/exact match score (higher is a better match)
341
    pub score: i32,
342
    /// Index of the first matched character (0-based)
343
    pub begin: i32,
344
    /// Index of the last matched character (0-based)
345
    pub end: i32,
346
    /// Length of the item text in bytes
347
    pub length: i32,
348
    /// Ordinal position of the item in the input stream
349
    pub index: i32,
350
    /// Byte offset of the first character after the last path separator (`/` or `\`).
351
    /// Equal to `0` when the item text contains no path separator.
352
    pub path_name_offset: i32,
353
}
354
355
/// Result of matching a query against an item
356
#[derive(Clone, Debug)]
357
pub struct MatchResult {
358
    /// The rank/score of this match
359
    pub rank: Rank,
360
    /// The range where the match occurred
361
    pub matched_range: MatchRange,
362
}
363
364
impl MatchResult {
365
    #[must_use]
366
    /// Converts the match range to character indices
367
5
    pub fn range_char_indices(&self, text: &str) -> MatchIndices {
368
5
        match &self.matched_range {
369
            &MatchRange::ByteRange(start, end) => {
370
3
                let first = text[..start].chars().count();
371
3
                let last = first + text[start..end].chars().count();
372
3
                (first..last).collect()
373
            }
374
1
            &MatchRange::CharRange(start, end) => (start..end).collect(),
375
1
            MatchRange::Chars(vec) => vec.clone(),
376
        }
377
5
    }
378
}
379
380
/// A matching engine that can match queries against items
381
pub trait MatchEngine: Sync + Send + Display {
382
    /// Matches an item against the query, returning a result if matched
383
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult>;
384
}
385
386
/// Factory for creating match engines
387
pub trait MatchEngineFactory {
388
    /// Creates a match engine with explicit case sensitivity
389
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine>;
390
    /// Creates a match engine with default case sensitivity
391
18
    fn create_engine(&self, query: &str) -> Box<dyn MatchEngine> {
392
18
        self.create_engine_with_case(query, CaseMatching::default())
393
18
    }
394
}
395
396
impl MatchEngineFactory for Box<dyn MatchEngineFactory> {
397
3.89k
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
398
3.89k
        (**self).create_engine_with_case(query, case)
399
3.89k
    }
400
}
401
402
//------------------------------------------------------------------------------
403
// Preselection
404
405
/// A selector that determines whether an item should be "pre-selected" in multi-selection mode
406
pub trait Selector {
407
    /// Returns true if the item at the given index should be pre-selected
408
    fn should_select(&self, index: usize, item: &dyn SkimItem) -> bool;
409
}
410
411
//------------------------------------------------------------------------------
412
/// Sender for streaming items to skim
413
pub type SkimItemSender = kanal::Sender<Vec<Arc<dyn SkimItem>>>;
414
/// Receiver for streaming items to skim
415
pub type SkimItemReceiver = kanal::Receiver<Vec<Arc<dyn SkimItem>>>;
416
417
#[cfg(test)]
418
#[path = "lib_tests.rs"]
419
mod tests;
\ No newline at end of file +

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/lib.rs
Line
Count
Source
1
//! Skim is a fuzzy finder library for Rust.
2
//!
3
//! It provides a fast and customizable way to filter and select items interactively,
4
//! similar to fzf. Skim can be used as a library or as a command-line tool.
5
//!
6
//! # Examples
7
//!
8
//! ```no_run
9
//! use skim::prelude::*;
10
//!
11
//! let options = SkimOptionsBuilder::default()
12
//!     .height("50%")
13
//!     .multi(true)
14
//!     .build()
15
//!     .unwrap();
16
//!
17
//! let output = Skim::run_items(
18
//!         options,
19
//!         ["awk", "bash", "csh", "dash", "fish", "ksh", "zsh"]
20
//!     ).unwrap();
21
//! ```
22
#![cfg_attr(coverage, feature(coverage_attribute))]
23
24
#[macro_use]
25
extern crate log;
26
27
#[global_allocator]
28
static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc;
29
30
use std::any::Any;
31
use std::borrow::Cow;
32
use std::fmt::Display;
33
use std::process::Command;
34
use std::sync::Arc;
35
36
use crate::fuzzy_matcher::MatchIndices;
37
use ratatui::style::Style;
38
use ratatui::text::{Line, Span};
39
40
pub use crate::engine::fuzzy::FuzzyAlgorithm;
41
pub use crate::item::RankCriteria;
42
pub use crate::options::SkimOptions;
43
pub use crate::output::{BinOptions, SkimOutput};
44
pub use crate::skim::*;
45
pub use crate::skim_item::SkimItem;
46
use crate::tui::Size;
47
pub use util::printf;
48
49
pub mod binds;
50
mod engine;
51
pub mod field;
52
pub mod fuzzy_matcher;
53
pub mod helper;
54
pub mod item;
55
pub mod matcher;
56
pub mod options;
57
mod output;
58
#[cfg(unix)]
59
pub mod popup;
60
pub mod prelude;
61
pub mod reader;
62
mod skim;
63
mod skim_item;
64
pub mod spinlock;
65
pub mod theme;
66
pub mod thread_pool;
67
pub mod tui;
68
mod util;
69
70
#[cfg(feature = "cli")]
71
pub mod manpage;
72
#[cfg(feature = "cli")]
73
pub mod shell;
74
75
/// Skim's default command when no `--cmd` flag is passed and `$SKIM_DEFAULT_COMMAND` is unset
76
#[cfg(unix)]
77
pub const SKIM_DEFAULT_COMMAND: &str = "find .";
78
/// Skim's default command when no `--cmd` flag is passed and `$SKIM_DEFAULT_COMMAND` is unset
79
#[cfg(windows)]
80
pub const SKIM_DEFAULT_COMMAND: &str = "dir /s /b /A:-D";
81
82
#[cfg(unix)]
83
121
fn shell_cmd(cmd: &str) -> Command {
84
121
    let mut c = Command::new("sh");
85
121
    c.arg("-c").arg(cmd);
86
121
    c
87
121
}
88
#[cfg(windows)]
89
fn shell_cmd(cmd: &str) -> Command {
90
    use std::os::windows::process::CommandExt as _;
91
    // `cmd.exe` does not parse its command line using MSVC rules, so the default
92
    // `Command::arg` escaping (quoting/backslash-escaping) corrupts shell
93
    // metacharacters like `|`, `&`, `>` and embedded quotes. Pass the command
94
    // string verbatim via `raw_arg` so cmd.exe sees exactly what the user wrote.
95
    let mut c = Command::new("cmd");
96
    c.arg("/c").raw_arg(cmd);
97
    c
98
}
99
100
//------------------------------------------------------------------------------
101
/// Trait for downcasting to concrete types from trait objects
102
pub trait AsAny {
103
    /// Returns a reference to the value as `Any`
104
    fn as_any(&self) -> &dyn Any;
105
    /// Returns a mutable reference to the value as `Any`
106
    fn as_any_mut(&mut self) -> &mut dyn Any;
107
}
108
109
impl<T: Any> AsAny for T {
110
2
    fn as_any(&self) -> &dyn Any {
111
2
        self
112
2
    }
113
114
1
    fn as_any_mut(&mut self) -> &mut dyn Any {
115
1
        self
116
1
    }
117
}
118
119
//------------------------------------------------------------------------------
120
// Display Context
121
#[derive(Default, Debug, Clone)]
122
/// Represents how a query matches an item
123
pub enum Matches {
124
    /// No matches
125
    #[default]
126
    None,
127
    /// Matches at specific character indices
128
    CharIndices(MatchIndices),
129
    /// Matches in a character range (start, end)
130
    CharRange(usize, usize),
131
    /// Matches in a byte range (start, end)
132
    ByteRange(usize, usize),
133
}
134
135
#[derive(Default, Clone)]
136
/// Context information for displaying an item
137
pub struct DisplayContext {
138
    /// The match score for this item
139
    pub score: i32,
140
    /// Where the query matched in the item
141
    pub matches: Matches,
142
    /// The width of the container to display in
143
    pub container_width: usize,
144
    /// The base style to apply to non-matched portions
145
    pub base_style: Style,
146
    /// The style to apply to matched portions
147
    pub matched_style: Style,
148
}
149
150
impl DisplayContext {
151
    /// Converts the context and text into a styled `Line` with highlighted matches
152
    ///
153
    /// # Panics
154
    ///
155
    /// Panics if the byte ranges in `Matches::ByteRange` do not align with valid UTF-8 boundaries.
156
    #[must_use]
157
6.49k
    pub fn to_line(self, cow: Cow<str>) -> Line {
158
6.49k
        let text: String = cow.into_owned();
159
160
        // Combine base_style with match style for highlighted text
161
        // Match style takes precedence for fg, but inherits bg from base if not set
162
6.49k
        match &self.matches {
163
            Matches::CharIndices(indices) => {
164
1.65k
                let mut res = Line::default();
165
1.65k
                let mut chars = text.chars();
166
1.65k
                let mut prev_index = 0;
167
1.65k
                for &index in indices {
168
3.60k
                    let span_content = chars.by_ref().take(index - prev_index);
169
3.60k
                    res.push_span(Span::styled(span_content.collect::<String>(), self.base_style));
170
3.60k
                    let highlighted_char = chars.next().unwrap_or_default().to_string();
171
172
3.60k
                    res.push_span(Span::styled(
173
3.60k
                        highlighted_char,
174
3.60k
                        self.base_style.patch(self.matched_style),
175
3.60k
                    ));
176
3.60k
                    prev_index = index + 1;
177
                }
178
1.65k
                res.push_span(Span::styled(chars.collect::<String>(), self.base_style));
179
1.65k
                res
180
            }
181
            // AnsiString::from((context.text, indices, context.highlight_attr)),
182
            #[allow(clippy::cast_possible_truncation)]
183
            Matches::CharRange(start, end) => {
184
1
                let mut chars = text.chars();
185
1
                let mut res = Line::default();
186
1
                res.push_span(Span::styled(
187
1
                    chars.by_ref().take(*start).collect::<String>(),
188
1
                    self.base_style,
189
1
                ));
190
1
                let highlighted_text = chars.by_ref().take(*end - *start).collect::<String>();
191
192
1
                res.push_span(Span::styled(
193
1
                    highlighted_text,
194
1
                    self.base_style.patch(self.matched_style),
195
1
                ));
196
1
                res.push_span(Span::styled(chars.collect::<String>(), self.base_style));
197
1
                res
198
            }
199
            Matches::ByteRange(start, end) => {
200
4.64k
                let mut bytes = text.bytes();
201
4.64k
                let mut res = Line::default();
202
4.64k
                res.push_span(Span::styled(
203
4.64k
                    String::from_utf8(bytes.by_ref().take(*start).collect()).unwrap(),
204
4.64k
                    self.base_style,
205
4.64k
                ));
206
4.64k
                let highlighted_bytes = bytes.by_ref().take(*end - *start).collect();
207
4.64k
                let highlighted_text = String::from_utf8(highlighted_bytes).unwrap();
208
209
4.64k
                res.push_span(Span::styled(
210
4.64k
                    highlighted_text,
211
4.64k
                    self.base_style.patch(self.matched_style),
212
4.64k
                ));
213
4.64k
                res.push_span(Span::styled(
214
4.64k
                    String::from_utf8(bytes.collect()).unwrap(),
215
4.64k
                    self.base_style,
216
4.64k
                ));
217
4.64k
                res
218
            }
219
200
            Matches::None => Line::from(vec![Span::styled(text, self.base_style)]),
220
        }
221
6.49k
    }
222
}
223
224
//------------------------------------------------------------------------------
225
// Preview Context
226
227
/// Context information for generating item previews
228
pub struct PreviewContext<'a> {
229
    /// The current search query
230
    pub query: &'a str,
231
    /// The current command query (for interactive mode)
232
    pub cmd_query: &'a str,
233
    /// Width of the preview window
234
    pub width: usize,
235
    /// Height of the preview window
236
    pub height: usize,
237
    /// Index of the current item
238
    pub current_index: usize,
239
    /// Text of the current selection
240
    pub current_selection: &'a str,
241
    /// selected item indices (may or may not include current item)
242
    pub selected_indices: &'a [usize],
243
    /// selected item texts (may or may not include current item)
244
    pub selections: &'a [&'a str],
245
}
246
247
//------------------------------------------------------------------------------
248
// Preview
249
250
/// Position and scroll information for preview display
251
#[derive(Default, Copy, Clone, Debug)]
252
pub struct PreviewPosition {
253
    /// Horizontal scroll position
254
    pub h_scroll: Size,
255
    /// Horizontal offset
256
    pub h_offset: Size,
257
    /// Vertical scroll position
258
    pub v_scroll: Size,
259
    /// Vertical offset
260
    pub v_offset: Size,
261
}
262
263
/// Defines how an item should be previewed
264
pub enum ItemPreview {
265
    /// execute the command and print the command's output
266
    Command(String),
267
    /// Display the prepared text(lines)
268
    Text(String),
269
    /// Display the colored text(lines)
270
    AnsiText(String),
271
    /// Execute a command and display output with position
272
    CommandWithPos(String, PreviewPosition),
273
    /// Display text with position
274
    TextWithPos(String, PreviewPosition),
275
    /// Display ANSI-colored text with position
276
    AnsiWithPos(String, PreviewPosition),
277
    /// Use global command settings to preview the item
278
    Global,
279
}
280
281
//==============================================================================
282
// A match engine will execute the matching algorithm
283
284
/// Case sensitivity mode for matching
285
#[derive(Eq, PartialEq, Debug, Copy, Clone, Default)]
286
#[cfg_attr(feature = "cli", derive(clap::ValueEnum), clap(rename_all = "snake_case"))]
287
pub enum CaseMatching {
288
    /// Case-sensitive matching
289
    Respect,
290
    /// Case-insensitive matching
291
    Ignore,
292
    /// Smart case: case-insensitive unless query contains uppercase
293
    #[default]
294
    Smart,
295
}
296
297
/// Typo tolerance configuration for fuzzy matching
298
///
299
/// Controls how many character mismatches (typos) are allowed when matching.
300
#[derive(Eq, PartialEq, Debug, Copy, Clone, Default)]
301
pub enum Typos {
302
    /// No typo tolerance — query must match exactly
303
    #[default]
304
    Disabled,
305
    /// Adaptive typo tolerance — allows `pattern_length / 4` typos
306
    Smart,
307
    /// Fixed typo tolerance — allows exactly `n` typos
308
    Fixed(usize),
309
}
310
311
impl From<usize> for Typos {
312
2
    fn from(n: usize) -> Self {
313
2
        match n {
314
1
            0 => Typos::Disabled,
315
1
            n => Typos::Fixed(n),
316
        }
317
2
    }
318
}
319
320
/// Represents the range of a match in an item
321
#[derive(PartialEq, Eq, Clone, Debug)]
322
pub enum MatchRange {
323
    /// Range of bytes (start, end)
324
    ByteRange(usize, usize),
325
    /// Range of character indices (start, end) — used by fuzzy matchers that
326
    /// operate on `char` arrays rather than raw bytes.
327
    CharRange(usize, usize),
328
    /// Individual character indices that matched
329
    Chars(MatchIndices),
330
}
331
332
/// Rank stores the raw match measurements used for sorting results.
333
///
334
/// Named fields preserve the semantic meaning of each value. The actual
335
/// sort key (taking into account the user-configured tiebreak criteria and
336
/// their direction) is computed lazily via [`Rank::sort_key`] rather than
337
/// being baked in at construction time.
338
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
339
pub struct Rank {
340
    /// Raw fuzzy/exact match score (higher is a better match)
341
    pub score: i32,
342
    /// Index of the first matched character (0-based)
343
    pub begin: i32,
344
    /// Index of the last matched character (0-based)
345
    pub end: i32,
346
    /// Length of the item text in bytes
347
    pub length: i32,
348
    /// Ordinal position of the item in the input stream
349
    pub index: i32,
350
    /// Byte offset of the first character after the last path separator (`/` or `\`).
351
    /// Equal to `0` when the item text contains no path separator.
352
    pub path_name_offset: i32,
353
}
354
355
/// Result of matching a query against an item
356
#[derive(Clone, Debug)]
357
pub struct MatchResult {
358
    /// The rank/score of this match
359
    pub rank: Rank,
360
    /// The range where the match occurred
361
    pub matched_range: MatchRange,
362
}
363
364
impl MatchResult {
365
    #[must_use]
366
    /// Converts the match range to character indices
367
5
    pub fn range_char_indices(&self, text: &str) -> MatchIndices {
368
5
        match &self.matched_range {
369
            &MatchRange::ByteRange(start, end) => {
370
3
                let first = text[..start].chars().count();
371
3
                let last = first + text[start..end].chars().count();
372
3
                (first..last).collect()
373
            }
374
1
            &MatchRange::CharRange(start, end) => (start..end).collect(),
375
1
            MatchRange::Chars(vec) => vec.clone(),
376
        }
377
5
    }
378
}
379
380
/// A matching engine that can match queries against items
381
pub trait MatchEngine: Sync + Send + Display {
382
    /// Matches an item against the query, returning a result if matched
383
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult>;
384
}
385
386
/// Factory for creating match engines
387
pub trait MatchEngineFactory {
388
    /// Creates a match engine with explicit case sensitivity
389
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine>;
390
    /// Creates a match engine with default case sensitivity
391
18
    fn create_engine(&self, query: &str) -> Box<dyn MatchEngine> {
392
18
        self.create_engine_with_case(query, CaseMatching::default())
393
18
    }
394
}
395
396
impl MatchEngineFactory for Box<dyn MatchEngineFactory> {
397
3.89k
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
398
3.89k
        (**self).create_engine_with_case(query, case)
399
3.89k
    }
400
}
401
402
//------------------------------------------------------------------------------
403
// Preselection
404
405
/// A selector that determines whether an item should be "pre-selected" in multi-selection mode
406
pub trait Selector {
407
    /// Returns true if the item at the given index should be pre-selected
408
    fn should_select(&self, index: usize, item: &dyn SkimItem) -> bool;
409
}
410
411
//------------------------------------------------------------------------------
412
/// Sender for streaming items to skim
413
pub type SkimItemSender = kanal::Sender<Vec<Arc<dyn SkimItem>>>;
414
/// Receiver for streaming items to skim
415
pub type SkimItemReceiver = kanal::Receiver<Vec<Arc<dyn SkimItem>>>;
416
417
#[cfg(test)]
418
#[path = "lib_tests.rs"]
419
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/manpage.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/manpage.rs.html index 11c983bc..e7addf54 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/manpage.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/manpage.rs.html @@ -1,4 +1,4 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/manpage.rs
Line
Count
Source
1
//! Provides what's needed to generate skim's man page
2
use std::fmt::Write as _;
3
use std::io::Write;
4
5
use clap::CommandFactory;
6
use clap_mangen::Man;
7
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8
use eyre::Result;
9
use roff::{Inline, Roff};
10
11
use crate::SkimOptions;
12
use crate::binds::{SkimEvent, get_default_key_map};
13
use crate::tui::actions::{ACTION_CATALOG, Action};
14
15
const THEME_SECTION: &str = "
16
Available themes:
17
    * none: base color scheme
18
    * molokai: molokai 256color
19
    * light: light 256color
20
    * 16: dark base16 theme
21
    * bw: black & white theme
22
    * dark | default: dark 256color, default value
23
    * all 4 catppuccin variants:
24
        * catppuccin-latte
25
        * catppuccin-macchiato
26
        * catppuccin-frappe
27
        * catppuccin-mocha
28
29
Available color names:
30
    * normal (or empty string): normal text
31
    * matched (or hl): matched text
32
    * current (or fg+): current line foreground
33
    * bg+: current line background (special case, always sets background)
34
    * current_match (or hl+): matched text in current line
35
    * query: query text
36
    * spinner: spinner character
37
    * info: info text (match count)
38
    * prompt: prompt text
39
    * cursor (or pointer): cursor/pointer
40
    * selected (or marker): selected item marker
41
    * header: header text
42
    * border: border lines
43
    * scrollbar: item list scrollbar thumb
44
45
Adding `-fg`, `_fg`, `-bg`, `_bg`, `-underline`, `_underline` sets the corresponding part of
46
the color. For instance, `normal-fg` (or simply `fg`) will set the foreground normal color.
47
48
Color formats:
49
    * 0-255: ANSI terminal color
50
    * #rrggbb: 24-bit color
51
52
Available attrs:
53
    * x | regular: resets the modifiers, use it before the others
54
    * b | bold
55
    * u | underline
56
    * c | crossed-out
57
    * d | dim
58
    * i | italic
59
    * r | reverse
60
61
Example: `--color '16,normal-fg:0+bold,matched-fg:#ffffff+u,cursor-bg:#deadbe'` will start with the
62
 base 16 theme and override it with a bold ANSI color 0 foreground (black), a hex ffffff (full
63
 white) underlined foreground for matched parts and a #deadbe (pale rose, apparently) cursor background.
64
";
65
66
const EXIT_CODES_SECTION: &str = "
67
* 0: success
68
* 1: no match
69
* 130: interrupt (ctrl-c or esc)
70
* others: error
71
";
72
73
const NORMAL_MODE_SS: &str = "
74
In normal mode, sk reads the input from stdin and displays the results interactively,
75
and the query is then used to fuzzily filter among the input lines.
76
";
77
78
const INTERACTIVE_MODE_SS: &str = "
79
Interactive mode is a special mode that allows you to run a command interactively and display
80
the results. It is enabled by the `--interactive` (or `-i`) option or by binding the
81
`toggle-interactive` action (default: <ctrl-q>).
82
The command is specified with the `--cmd` option.
83
84
Example: `sk --cmd 'rg {} --color=always' --interactive` will use `rg` to search for the query
85
in the current directory and display the results interactively.
86
";
87
88
const KEYS_SS: &str = "
89
* ctrl-[a-z]
90
* ctrl-space
91
* ctrl-alt-[a-z]
92
* alt-[a-zA-Z]
93
* alt-[0-9]
94
* f[1-12]
95
* enter
96
* space
97
* bspace      (bs)
98
* alt-up
99
* alt-down
100
* alt-left
101
* alt-right
102
* alt-enter
103
* alt-space
104
* alt-bspace  (alt-bs)
105
* alt-/
106
* tab
107
* btab        (shift-tab)
108
* esc
109
* del
110
* up
111
* down
112
* left
113
* right
114
* home
115
* end
116
* pgup
117
* pgdn
118
* shift-up
119
* shift-down
120
* shift-left
121
* shift-right
122
* alt-shift-up
123
* alt-shift-down
124
* alt-shift-left
125
* alt-shift-right
126
* double-click
127
* any single character
128
";
129
const BINDABLE_EVENTS_SS: &str = concat!(
130
    "\n",
131
    "* change: the query changes\n",
132
    "* start: skim enters its event loop; fired once\n",
133
    "* load: the reader and matcher finish consuming the current input; ",
134
    "fired once per read, including reloads\n",
135
    "* result: filtering for the current query completes\n",
136
    "* focus: the focused item changes because of cursor movement or a result update\n",
137
    "* zero: the input stream is complete and the final search has no matches\n",
138
    "* one: the input stream is complete and the final search has exactly one match\n",
139
);
140
141
const ACTION_BINDINGS_SS: &str = concat!(
142
    "\n",
143
    "Actions can also be used as binding triggers. A follow-up chain bound to an action name runs immediately ",
144
    "after that action. Use the `act-` prefix for action triggers; it is recommended to avoid ambiguity and ",
145
    "required when the action name is also a key, for example `act-up:last`.\n\n",
146
    "Follow-up chains use non-recursive (`noremap`) semantics: their actions do not trigger further action ",
147
    "bindings. Add `suppress` to skip the triggering action's default behavior, for example ",
148
    "`act-up:suppress+down`.\n",
149
);
150
151
#[cfg(feature = "listen")]
152
const REMOTE_SECTION: &str = "
153
skim can be controlled from other processes, using the `--listen` (and optionally `--remote`) flags.
154
155
To achieve this, run the server instance using `sk --listen optional_address` (the address defaults to `sk`).
156
It will then start listening on a named socket for instructions.
157
158
To send instructions, you can use `sk --remote optional_address` or any other tool that allows us to interact with such sockets,
159
such as `socat` on linux: `echo 'ToggleIn' | socat -u STDIN ABSTRACT-CONNECT:optional_address`. Instructions correspond to skim's Actions and need to be sent in Ron format.
160
When using `sk --remote`, pipe in action chains (see the KEYBINDS section), for instance `echo 'up+select-row' | sk --remote optional_address`
161
";
162
163
/// Renders the list of bindable actions from the action catalog.
164
///
165
/// The list is generated from `define_action_catalog!` in `src/tui/actions.rs`,
166
/// so a new action shows up here (with its doc comment) automatically.
167
7
fn actions_ss() -> String {
168
7
    let mut res = String::from("\n");
169
511
    for action in 
ACTION_CATALOG.iter()7
.filter(|action| action.is_bindable()) {
170
504
        let _ = writeln!(res, "* {}: {}", action.display_name(), action.summary());
171
    }
172
7
    res
173
7
}
174
175
/// Renders the runtime default keymap, keeping the manpage in sync with
176
/// [`get_default_key_map`].
177
7
fn default_keys_ss() -> String {
178
7
    let mut bindings = get_default_key_map()
179
7
        .iter()
180
336
        .map(|(key, actions)| {
181
336
            let actions = actions.iter().map(Action::name).collect::<Vec<_>>().join("+");
182
336
            (key_name(key), actions)
183
336
        })
184
        .collect::<Vec<_>>();
185
1.82k
    
bindings7
.sort_unstable_by(|left, right| left.0.cmp(&right.0));
186
187
7
    let mut res = String::from("\n");
188
7
    for (key, actions) in bindings {
189
336
        let _ = writeln!(res, "* {key}: {actions}");
190
    }
191
7
    res
192
7
}
193
194
336
fn key_name(key: &KeyEvent) -> String {
195
336
    if *key == SkimEvent::DoubleClick.key_event() {
  Branch (195:8): [True: 1, False: 47]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/manpage.rs
Line
Count
Source
1
//! Provides what's needed to generate skim's man page
2
use std::fmt::Write as _;
3
use std::io::Write;
4
5
use clap::CommandFactory;
6
use clap_mangen::Man;
7
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8
use eyre::Result;
9
use roff::{Inline, Roff};
10
11
use crate::SkimOptions;
12
use crate::binds::{SkimEvent, get_default_key_map};
13
use crate::tui::actions::{ACTION_CATALOG, Action};
14
15
const THEME_SECTION: &str = "
16
Available themes:
17
    * none: base color scheme
18
    * molokai: molokai 256color
19
    * light: light 256color
20
    * 16: dark base16 theme
21
    * bw: black & white theme
22
    * dark | default: dark 256color, default value
23
    * all 4 catppuccin variants:
24
        * catppuccin-latte
25
        * catppuccin-macchiato
26
        * catppuccin-frappe
27
        * catppuccin-mocha
28
29
Available color names:
30
    * normal (or empty string): normal text
31
    * matched (or hl): matched text
32
    * current (or fg+): current line foreground
33
    * bg+: current line background (special case, always sets background)
34
    * current_match (or hl+): matched text in current line
35
    * query: query text
36
    * spinner: spinner character
37
    * info: info text (match count)
38
    * prompt: prompt text
39
    * cursor (or pointer): cursor/pointer
40
    * selected (or marker): selected item marker
41
    * header: header text
42
    * border: border lines
43
    * scrollbar: item list scrollbar thumb
44
45
Adding `-fg`, `_fg`, `-bg`, `_bg`, `-underline`, `_underline` sets the corresponding part of
46
the color. For instance, `normal-fg` (or simply `fg`) will set the foreground normal color.
47
48
Color formats:
49
    * 0-255: ANSI terminal color
50
    * #rrggbb: 24-bit color
51
52
Available attrs:
53
    * x | regular: resets the modifiers, use it before the others
54
    * b | bold
55
    * u | underline
56
    * c | crossed-out
57
    * d | dim
58
    * i | italic
59
    * r | reverse
60
61
Example: `--color '16,normal-fg:0+bold,matched-fg:#ffffff+u,cursor-bg:#deadbe'` will start with the
62
 base 16 theme and override it with a bold ANSI color 0 foreground (black), a hex ffffff (full
63
 white) underlined foreground for matched parts and a #deadbe (pale rose, apparently) cursor background.
64
";
65
66
const EXIT_CODES_SECTION: &str = "
67
* 0: success
68
* 1: no match
69
* 130: interrupt (ctrl-c or esc)
70
* others: error
71
";
72
73
const NORMAL_MODE_SS: &str = "
74
In normal mode, sk reads the input from stdin and displays the results interactively,
75
and the query is then used to fuzzily filter among the input lines.
76
";
77
78
const INTERACTIVE_MODE_SS: &str = "
79
Interactive mode is a special mode that allows you to run a command interactively and display
80
the results. It is enabled by the `--interactive` (or `-i`) option or by binding the
81
`toggle-interactive` action (default: <ctrl-q>).
82
The command is specified with the `--cmd` option.
83
84
Example: `sk --cmd 'rg {} --color=always' --interactive` will use `rg` to search for the query
85
in the current directory and display the results interactively.
86
";
87
88
const KEYS_SS: &str = "
89
* ctrl-[a-z]
90
* ctrl-space
91
* ctrl-alt-[a-z]
92
* alt-[a-zA-Z]
93
* alt-[0-9]
94
* f[1-12]
95
* enter
96
* space
97
* bspace      (bs)
98
* alt-up
99
* alt-down
100
* alt-left
101
* alt-right
102
* alt-enter
103
* alt-space
104
* alt-bspace  (alt-bs)
105
* alt-/
106
* tab
107
* btab        (shift-tab)
108
* esc
109
* del
110
* up
111
* down
112
* left
113
* right
114
* home
115
* end
116
* pgup
117
* pgdn
118
* shift-up
119
* shift-down
120
* shift-left
121
* shift-right
122
* alt-shift-up
123
* alt-shift-down
124
* alt-shift-left
125
* alt-shift-right
126
* double-click
127
* any single character
128
";
129
const BINDABLE_EVENTS_SS: &str = concat!(
130
    "\n",
131
    "* change: the query changes\n",
132
    "* start: skim enters its event loop; fired once\n",
133
    "* load: the reader and matcher finish consuming the current input; ",
134
    "fired once per read, including reloads\n",
135
    "* result: filtering for the current query completes\n",
136
    "* focus: the focused item changes because of cursor movement or a result update\n",
137
    "* zero: the input stream is complete and the final search has no matches\n",
138
    "* one: the input stream is complete and the final search has exactly one match\n",
139
);
140
141
const ACTION_BINDINGS_SS: &str = concat!(
142
    "\n",
143
    "Actions can also be used as binding triggers. A follow-up chain bound to an action name runs immediately ",
144
    "after that action. Use the `act-` prefix for action triggers; it is recommended to avoid ambiguity and ",
145
    "required when the action name is also a key, for example `act-up:last`.\n\n",
146
    "Follow-up chains use non-recursive (`noremap`) semantics: their actions do not trigger further action ",
147
    "bindings. Add `suppress` to skip the triggering action's default behavior, for example ",
148
    "`act-up:suppress+down`.\n",
149
);
150
151
#[cfg(feature = "listen")]
152
const REMOTE_SECTION: &str = "
153
skim can be controlled from other processes, using the `--listen` (and optionally `--remote`) flags.
154
155
To achieve this, run the server instance using `sk --listen optional_address` (the address defaults to `sk`).
156
It will then start listening on a named socket for instructions.
157
158
To send instructions, you can use `sk --remote optional_address` or any other tool that allows us to interact with such sockets,
159
such as `socat` on linux: `echo 'ToggleIn' | socat -u STDIN ABSTRACT-CONNECT:optional_address`. Instructions correspond to skim's Actions and need to be sent in Ron format.
160
When using `sk --remote`, pipe in action chains (see the KEYBINDS section), for instance `echo 'up+select-row' | sk --remote optional_address`
161
";
162
163
/// Renders the list of bindable actions from the action catalog.
164
///
165
/// The list is generated from `define_action_catalog!` in `src/tui/actions.rs`,
166
/// so a new action shows up here (with its doc comment) automatically.
167
7
fn actions_ss() -> String {
168
7
    let mut res = String::from("\n");
169
511
    for action in 
ACTION_CATALOG.iter()7
.filter(|action| action.is_bindable()) {
170
504
        let _ = writeln!(res, "* {}: {}", action.display_name(), action.summary());
171
    }
172
7
    res
173
7
}
174
175
/// Renders the runtime default keymap, keeping the manpage in sync with
176
/// [`get_default_key_map`].
177
7
fn default_keys_ss() -> String {
178
7
    let mut bindings = get_default_key_map()
179
7
        .iter()
180
336
        .map(|(key, actions)| {
181
336
            let actions = actions.iter().map(Action::name).collect::<Vec<_>>().join("+");
182
336
            (key_name(key), actions)
183
336
        })
184
        .collect::<Vec<_>>();
185
1.79k
    
bindings7
.sort_unstable_by(|left, right| left.0.cmp(&right.0));
186
187
7
    let mut res = String::from("\n");
188
7
    for (key, actions) in bindings {
189
336
        let _ = writeln!(res, "* {key}: {actions}");
190
    }
191
7
    res
192
7
}
193
194
336
fn key_name(key: &KeyEvent) -> String {
195
336
    if *key == SkimEvent::DoubleClick.key_event() {
  Branch (195:8): [True: 1, False: 47]
 
  Branch (195:8): [True: 6, False: 282]
 
196
7
        return "double-click".to_string();
197
329
    }
198
199
    // Crossterm can report back-tab with every modifier set. Keep the familiar
200
    // binding spelling instead of exposing that terminal representation.
201
329
    if key.code == KeyCode::BackTab && 
key.modifiers == KeyModifiers::all()14
{
  Branch (201:8): [True: 2, False: 45]
   Branch (201:40): [True: 1, False: 1]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/matcher.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/matcher.rs.html
index 0608bd18..57a5339c 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/matcher.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/matcher.rs.html
@@ -1,31 +1,31 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/matcher.rs
Line
Count
Source
1
//! This module contains the matching coordinator
2
use crate::thread_pool::{self, ThreadPool};
3
use crate::tui::item_list::{MergeStrategy, ProcessedItems};
4
use std::rc::Rc;
5
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
6
use std::sync::{Arc, Mutex};
7
8
use crate::engine::normalized::NormalizedEngineFactory;
9
use crate::engine::split::SplitMatchEngineFactory;
10
use crate::item::{ItemPool, MatchedItem, RankBuilder};
11
use crate::prelude::{AndOrEngineFactory, ExactOrFuzzyEngineFactory, RegexEngineFactory};
12
use crate::{CaseMatching, MatchEngineFactory, SkimItem, SkimOptions};
13
14
/// Merges per-worker match results and writes them into `processed_items`.
15
///
16
/// When `no_sort` is false, concatenates the pre-sorted worker results into a
17
/// single contiguous `Vec` and calls `sort()`.  Rust's stable sort (driftsort
18
/// since 1.81, a `TimSort` variant before that) detects the k pre-sorted runs
19
/// and merges them in O(n log k) on contiguous memory — benchmarking shows
20
/// this consistently outperforms tree-based or fold-based merge strategies
21
/// due to driftsort's cache-friendly single-buffer merge passes.
22
///
23
/// When `no_sort` is true, worker results arrive in chunk-index order and are
24
/// flattened without sorting.
25
///
26
/// Signals `needs_render` after writing so the UI picks up the new data.
27
51.9k
fn input_index(tac: bool, start: usize, batch_len: usize, batch_index: usize) -> usize {
28
51.9k
    debug_assert!(
batch_index < batch_len0
);
29
51.9k
    if tac {
  Branch (29:8): [True: 8, False: 51.9k]
-
  Branch (29:8): [True: 5, False: 19]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/matcher.rs
Line
Count
Source
1
//! This module contains the matching coordinator
2
use crate::thread_pool::{self, ThreadPool};
3
use crate::tui::item_list::{MergeStrategy, ProcessedItems};
4
use std::rc::Rc;
5
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
6
use std::sync::{Arc, Mutex};
7
8
use crate::engine::normalized::NormalizedEngineFactory;
9
use crate::engine::split::SplitMatchEngineFactory;
10
use crate::item::{ItemPool, MatchedItem, RankBuilder};
11
use crate::prelude::{AndOrEngineFactory, ExactOrFuzzyEngineFactory, RegexEngineFactory};
12
use crate::{CaseMatching, MatchEngineFactory, SkimItem, SkimOptions};
13
14
/// Merges per-worker match results and writes them into `processed_items`.
15
///
16
/// When `no_sort` is false, concatenates the pre-sorted worker results into a
17
/// single contiguous `Vec` and calls `sort()`.  Rust's stable sort (driftsort
18
/// since 1.81, a `TimSort` variant before that) detects the k pre-sorted runs
19
/// and merges them in O(n log k) on contiguous memory — benchmarking shows
20
/// this consistently outperforms tree-based or fold-based merge strategies
21
/// due to driftsort's cache-friendly single-buffer merge passes.
22
///
23
/// When `no_sort` is true, worker results arrive in chunk-index order and are
24
/// flattened without sorting.
25
///
26
/// Signals `needs_render` after writing so the UI picks up the new data.
27
51.9k
fn input_index(tac: bool, start: usize, batch_len: usize, batch_index: usize) -> usize {
28
51.9k
    debug_assert!(
batch_index < batch_len0
);
29
51.9k
    if tac {
  Branch (29:8): [True: 8, False: 51.9k]
+
  Branch (29:8): [True: 5, False: 21]
 
30
13
        start + batch_len - 1 - batch_index
31
    } else {
32
51.9k
        start + batch_index
33
    }
34
51.9k
}
35
36
1.08k
fn merge_worker_results(
37
1.08k
    worker_results: Vec<Vec<MatchedItem>>,
38
1.08k
    no_sort: bool,
39
1.08k
    processed_items: &Mutex<Option<ProcessedItems>>,
40
1.08k
    merge_strategy: MergeStrategy,
41
1.08k
    generation: usize,
42
1.08k
    current_generation: &AtomicUsize,
43
1.08k
    needs_render: &AtomicBool,
44
1.08k
) {
45
1.08k
    if current_generation.load(Ordering::Acquire) != generation {
  Branch (45:8): [True: 0, False: 1.05k]
-
  Branch (45:8): [True: 1, False: 26]
-
46
1
        return;
47
1.08k
    }
48
49
1.08k
    let total_len: usize = worker_results.iter().map(Vec::len).sum();
50
1.08k
    let mut items = Vec::with_capacity(total_len);
51
1.08k
    for chunk in worker_results {
52
1.39k
        items.extend(chunk);
53
    }
54
55
1.08k
    if !no_sort {
  Branch (55:8): [True: 1.04k, False: 9]
-
  Branch (55:8): [True: 20, False: 6]
-
56
        // Each worker's sub-list is already sorted by `prepare`, so stable
57
        // sort detects the pre-existing runs and merges them efficiently.
58
1.06k
        items.sort();
59
15
    }
60
61
1.08k
    trace!("matcher stop, total matched: {}", 
items.len()2
);
62
63
    // Validate while holding the result lock so an old matcher cannot overwrite
64
    // results that belong to a newer query generation.
65
1.08k
    let mut guard = processed_items
66
1.08k
        .lock()
67
1.08k
        .unwrap_or_else(std::sync::PoisonError::into_inner);
68
1.08k
    if current_generation.load(Ordering::Acquire) != generation {
  Branch (68:8): [True: 0, False: 1.05k]
-
  Branch (68:8): [True: 0, False: 26]
-
69
0
        return;
70
1.08k
    }
71
1.08k
    if matches!(merge_strategy, MergeStrategy::Replace) {
72
776
        *guard = Some(ProcessedItems {
73
776
            items,
74
776
            merge: MergeStrategy::Replace,
75
776
            generation,
76
776
        });
77
776
        drop(guard);
78
776
        needs_render.store(true, Ordering::Relaxed);
79
776
        return;
80
307
    }
81
307
    match &mut *guard {
82
288
        Some(existing) if existing.generation != generation => {
  Branch (82:27): [True: 0, False: 281]
-
  Branch (82:27): [True: 1, False: 6]
-
83
1
            *guard = Some(ProcessedItems {
84
1
                items,
85
1
                merge: MergeStrategy::Replace,
86
1
                generation,
87
1
            });
88
        }
89
        Some(existing) => {
90
287
            if no_sort {
  Branch (90:16): [True: 4, False: 277]
-
  Branch (90:16): [True: 2, False: 4]
-
91
6
                if matches!(merge_strategy, MergeStrategy::Prepend) {
92
1
                    items.append(&mut existing.items);
93
1
                    existing.items = items;
94
                } else {
95
5
                    existing.items.extend(items);
96
                }
97
            } else {
98
                // Both sides are fully sorted — one O(n+m) merge.
99
281
                MatchedItem::merge_into_sorted(&mut existing.items, items);
100
            }
101
        }
102
        None => {
103
19
            *guard = Some(ProcessedItems {
104
19
                items,
105
19
                merge: merge_strategy,
106
19
                generation,
107
19
            });
108
        }
109
    }
110
    // Guard is dropped here, releasing the lock before we signal the render flag.
111
112
307
    needs_render.store(true, Ordering::Relaxed);
113
1.08k
}
114
115
//==============================================================================
116
/// Control handle for a running matcher operation.
117
///
118
/// Provides methods to check status, retrieve results, and stop the matcher.
119
pub struct MatcherControl {
120
    stopped: Arc<AtomicBool>,
121
    interrupt: Arc<AtomicBool>,
122
    processed: Arc<AtomicUsize>,
123
    matched: Arc<AtomicUsize>,
124
}
125
126
impl Default for MatcherControl {
127
542
    fn default() -> Self {
128
542
        Self {
129
542
            stopped: Arc::new(AtomicBool::new(true)),
130
542
            interrupt: Arc::new(AtomicBool::new(false)),
131
542
            processed: Default::default(),
132
542
            matched: Default::default(),
133
542
        }
134
542
    }
135
}
136
137
impl MatcherControl {
138
    /// Returns the number of items that have been processed so far.
139
    #[must_use]
140
2.67k
    pub fn get_num_processed(&self) -> usize {
141
2.67k
        self.processed.load(Ordering::Relaxed)
142
2.67k
    }
143
144
    /// Returns the number of items that have matched so far.
145
    #[must_use]
146
785
    pub fn get_num_matched(&self) -> usize {
147
785
        self.matched.load(Ordering::Relaxed)
148
785
    }
149
150
    /// Signals the matcher to stop processing.
151
2.72k
    pub fn kill(&mut self) {
152
2.72k
        self.interrupt.store(true, Ordering::Relaxed);
153
2.72k
    }
154
155
    /// Returns true if the matcher has stopped (either completed or killed).
156
    #[must_use]
157
5.99k
    pub fn stopped(&self) -> bool {
158
5.99k
        self.stopped.load(Ordering::Relaxed)
159
5.99k
    }
160
}
161
162
impl Drop for MatcherControl {
163
1.62k
    fn drop(&mut self) {
164
1.62k
        self.kill();
165
1.62k
    }
166
}
167
168
//==============================================================================
169
/// The main matcher that coordinates fuzzy/exact matching of items against a query.
170
pub struct Matcher {
171
    engine_factory: Rc<dyn MatchEngineFactory>,
172
    case_matching: CaseMatching,
173
    /// The rank builder shared with all engines; used to attach criteria to `MatchedItem`s.
174
    pub rank_builder: Arc<RankBuilder>,
175
}
176
177
impl Matcher {
178
    /// Creates a new Matcher builder with the given engine factory.
179
546
    pub fn builder(engine_factory: Rc<dyn MatchEngineFactory>) -> Self {
180
546
        Self {
181
546
            engine_factory,
182
546
            case_matching: CaseMatching::default(),
183
546
            rank_builder: Arc::new(RankBuilder::default()),
184
546
        }
185
546
    }
186
187
    /// Sets the case matching mode (smart, ignore, or respect).
188
    #[must_use]
189
546
    pub fn case(mut self, case_matching: CaseMatching) -> Self {
190
546
        self.case_matching = case_matching;
191
546
        self
192
546
    }
193
194
    /// Sets the rank builder (carries tiebreak criteria).
195
    #[must_use]
196
409
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
197
409
        self.rank_builder = rank_builder;
198
409
        self
199
409
    }
200
201
    /// Finalizes the builder and returns the configured Matcher.
202
    #[must_use]
203
546
    pub fn build(self) -> Self {
204
546
        self
205
546
    }
206
207
    /// Creates a `MatchEngineFactory` from the given options.
208
    ///
209
    /// This is useful when you need the factory directly (e.g., for filter mode)
210
    /// without creating a full Matcher instance.
211
    #[must_use]
212
1
    pub fn create_engine_factory(options: &SkimOptions) -> Rc<dyn MatchEngineFactory> {
213
1
        Self::create_engine_factory_with_builder(options).0
214
1
    }
215
216
    /// Creates a `MatchEngineFactory` and the associated `RankBuilder` from the given options.
217
    ///
218
    /// Returns both so callers can attach the builder to `MatchedItem`s for lazy sort-key
219
    /// computation.
220
    #[must_use]
221
412
    pub fn create_engine_factory_with_builder(options: &SkimOptions) -> (Rc<dyn MatchEngineFactory>, Arc<RankBuilder>) {
222
412
        if options.regex {
  Branch (222:12): [True: 1, False: 387]
+
  Branch (45:8): [True: 1, False: 28]
+
46
1
        return;
47
1.08k
    }
48
49
1.08k
    let total_len: usize = worker_results.iter().map(Vec::len).sum();
50
1.08k
    let mut items = Vec::with_capacity(total_len);
51
1.08k
    for chunk in worker_results {
52
1.40k
        items.extend(chunk);
53
    }
54
55
1.08k
    if !no_sort {
  Branch (55:8): [True: 1.04k, False: 8]
+
  Branch (55:8): [True: 23, False: 5]
+
56
        // Each worker's sub-list is already sorted by `prepare`, so stable
57
        // sort detects the pre-existing runs and merges them efficiently.
58
1.06k
        items.sort();
59
13
    }
60
61
1.08k
    trace!("matcher stop, total matched: {}", 
items.len()2
);
62
63
    // Validate while holding the result lock so an old matcher cannot overwrite
64
    // results that belong to a newer query generation.
65
1.08k
    let mut guard = processed_items
66
1.08k
        .lock()
67
1.08k
        .unwrap_or_else(std::sync::PoisonError::into_inner);
68
1.08k
    if current_generation.load(Ordering::Acquire) != generation {
  Branch (68:8): [True: 0, False: 1.05k]
+
  Branch (68:8): [True: 0, False: 28]
+
69
0
        return;
70
1.08k
    }
71
1.08k
    if matches!(merge_strategy, MergeStrategy::Replace) {
72
778
        *guard = Some(ProcessedItems {
73
778
            items,
74
778
            merge: MergeStrategy::Replace,
75
778
            generation,
76
778
        });
77
778
        drop(guard);
78
778
        needs_render.store(true, Ordering::Relaxed);
79
778
        return;
80
304
    }
81
304
    match &mut *guard {
82
287
        Some(existing) if existing.generation != generation => {
  Branch (82:27): [True: 0, False: 278]
+
  Branch (82:27): [True: 1, False: 8]
+
83
1
            *guard = Some(ProcessedItems {
84
1
                items,
85
1
                merge: MergeStrategy::Replace,
86
1
                generation,
87
1
            });
88
        }
89
        Some(existing) => {
90
286
            if no_sort {
  Branch (90:16): [True: 3, False: 275]
+
  Branch (90:16): [True: 2, False: 6]
+
91
5
                if matches!(merge_strategy, MergeStrategy::Prepend) {
92
1
                    items.append(&mut existing.items);
93
1
                    existing.items = items;
94
                } else {
95
4
                    existing.items.extend(items);
96
                }
97
            } else {
98
                // Both sides are fully sorted — one O(n+m) merge.
99
281
                MatchedItem::merge_into_sorted(&mut existing.items, items);
100
            }
101
        }
102
        None => {
103
17
            *guard = Some(ProcessedItems {
104
17
                items,
105
17
                merge: merge_strategy,
106
17
                generation,
107
17
            });
108
        }
109
    }
110
    // Guard is dropped here, releasing the lock before we signal the render flag.
111
112
304
    needs_render.store(true, Ordering::Relaxed);
113
1.08k
}
114
115
//==============================================================================
116
/// Control handle for a running matcher operation.
117
///
118
/// Provides methods to check status, retrieve results, and stop the matcher.
119
pub struct MatcherControl {
120
    stopped: Arc<AtomicBool>,
121
    interrupt: Arc<AtomicBool>,
122
    processed: Arc<AtomicUsize>,
123
    matched: Arc<AtomicUsize>,
124
}
125
126
impl Default for MatcherControl {
127
541
    fn default() -> Self {
128
541
        Self {
129
541
            stopped: Arc::new(AtomicBool::new(true)),
130
541
            interrupt: Arc::new(AtomicBool::new(false)),
131
541
            processed: Default::default(),
132
541
            matched: Default::default(),
133
541
        }
134
541
    }
135
}
136
137
impl MatcherControl {
138
    /// Returns the number of items that have been processed so far.
139
    #[must_use]
140
2.68k
    pub fn get_num_processed(&self) -> usize {
141
2.68k
        self.processed.load(Ordering::Relaxed)
142
2.68k
    }
143
144
    /// Returns the number of items that have matched so far.
145
    #[must_use]
146
781
    pub fn get_num_matched(&self) -> usize {
147
781
        self.matched.load(Ordering::Relaxed)
148
781
    }
149
150
    /// Signals the matcher to stop processing.
151
2.72k
    pub fn kill(&mut self) {
152
2.72k
        self.interrupt.store(true, Ordering::Relaxed);
153
2.72k
    }
154
155
    /// Returns true if the matcher has stopped (either completed or killed).
156
    #[must_use]
157
5.94k
    pub fn stopped(&self) -> bool {
158
5.94k
        self.stopped.load(Ordering::Relaxed)
159
5.94k
    }
160
}
161
162
impl Drop for MatcherControl {
163
1.62k
    fn drop(&mut self) {
164
1.62k
        self.kill();
165
1.62k
    }
166
}
167
168
//==============================================================================
169
/// The main matcher that coordinates fuzzy/exact matching of items against a query.
170
pub struct Matcher {
171
    engine_factory: Rc<dyn MatchEngineFactory>,
172
    case_matching: CaseMatching,
173
    /// The rank builder shared with all engines; used to attach criteria to `MatchedItem`s.
174
    pub rank_builder: Arc<RankBuilder>,
175
}
176
177
impl Matcher {
178
    /// Creates a new Matcher builder with the given engine factory.
179
545
    pub fn builder(engine_factory: Rc<dyn MatchEngineFactory>) -> Self {
180
545
        Self {
181
545
            engine_factory,
182
545
            case_matching: CaseMatching::default(),
183
545
            rank_builder: Arc::new(RankBuilder::default()),
184
545
        }
185
545
    }
186
187
    /// Sets the case matching mode (smart, ignore, or respect).
188
    #[must_use]
189
545
    pub fn case(mut self, case_matching: CaseMatching) -> Self {
190
545
        self.case_matching = case_matching;
191
545
        self
192
545
    }
193
194
    /// Sets the rank builder (carries tiebreak criteria).
195
    #[must_use]
196
408
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
197
408
        self.rank_builder = rank_builder;
198
408
        self
199
408
    }
200
201
    /// Finalizes the builder and returns the configured Matcher.
202
    #[must_use]
203
545
    pub fn build(self) -> Self {
204
545
        self
205
545
    }
206
207
    /// Creates a `MatchEngineFactory` from the given options.
208
    ///
209
    /// This is useful when you need the factory directly (e.g., for filter mode)
210
    /// without creating a full Matcher instance.
211
    #[must_use]
212
1
    pub fn create_engine_factory(options: &SkimOptions) -> Rc<dyn MatchEngineFactory> {
213
1
        Self::create_engine_factory_with_builder(options).0
214
1
    }
215
216
    /// Creates a `MatchEngineFactory` and the associated `RankBuilder` from the given options.
217
    ///
218
    /// Returns both so callers can attach the builder to `MatchedItem`s for lazy sort-key
219
    /// computation.
220
    #[must_use]
221
411
    pub fn create_engine_factory_with_builder(options: &SkimOptions) -> (Rc<dyn MatchEngineFactory>, Arc<RankBuilder>) {
222
411
        if options.regex {
  Branch (222:12): [True: 1, False: 386]
 
  Branch (222:12): [True: 3, False: 21]
 
223
4
            let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()).tac(options.tac));
224
4
            let regex_factory = RegexEngineFactory::builder().rank_builder(rank_builder.clone());
225
4
            let factory: Rc<dyn MatchEngineFactory> = if options.normalize {
  Branch (225:58): [True: 0, False: 1]
 
  Branch (225:58): [True: 1, False: 2]
-
226
1
                Rc::new(NormalizedEngineFactory::new(regex_factory))
227
            } else {
228
3
                Rc::new(regex_factory)
229
            };
230
4
            (factory, rank_builder)
231
        } else {
232
408
            let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()).tac(options.tac));
233
408
            log::debug!("Creating matcher for algo {:?}", 
options.algorithm1
);
234
408
            let fuzzy_engine_factory = ExactOrFuzzyEngineFactory::builder()
235
408
                .fuzzy_algorithm(options.algorithm)
236
408
                .exact_mode(options.exact)
237
408
                .typos(options.typos)
238
408
                .filter_mode(options.filter.is_some())
239
408
                .last_match(options.last_match)
240
408
                .rank_builder(rank_builder.clone())
241
408
                .build();
242
243
408
            let mut factory: Box<dyn MatchEngineFactory> = Box::new(fuzzy_engine_factory);
244
245
            // If split_match is enabled, wrap the fuzzy factory with SplitMatchEngineFactory
246
408
            if let Some(delimiter) = options.split_match {
  Branch (246:20): [True: 11, False: 376]
+
226
1
                Rc::new(NormalizedEngineFactory::new(regex_factory))
227
            } else {
228
3
                Rc::new(regex_factory)
229
            };
230
4
            (factory, rank_builder)
231
        } else {
232
407
            let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()).tac(options.tac));
233
407
            log::debug!("Creating matcher for algo {:?}", 
options.algorithm1
);
234
407
            let fuzzy_engine_factory = ExactOrFuzzyEngineFactory::builder()
235
407
                .fuzzy_algorithm(options.algorithm)
236
407
                .exact_mode(options.exact)
237
407
                .typos(options.typos)
238
407
                .filter_mode(options.filter.is_some())
239
407
                .last_match(options.last_match)
240
407
                .rank_builder(rank_builder.clone())
241
407
                .build();
242
243
407
            let mut factory: Box<dyn MatchEngineFactory> = Box::new(fuzzy_engine_factory);
244
245
            // If split_match is enabled, wrap the fuzzy factory with SplitMatchEngineFactory
246
407
            if let Some(delimiter) = options.split_match {
  Branch (246:20): [True: 9, False: 377]
 
  Branch (246:20): [True: 0, False: 21]
-
247
11
                factory = Box::new(SplitMatchEngineFactory::new(factory, delimiter));
248
397
            }
249
250
            // Wrap with AndOrEngineFactory so that queries like "foo:bar baz:qux" work
251
408
            factory = Box::new(AndOrEngineFactory::new(factory));
252
253
            // Wrap with NormalizedEngineFactory if normalization is requested
254
408
            if options.normalize {
  Branch (254:16): [True: 11, False: 376]
+
247
9
                factory = Box::new(SplitMatchEngineFactory::new(factory, delimiter));
248
398
            }
249
250
            // Wrap with AndOrEngineFactory so that queries like "foo:bar baz:qux" work
251
407
            factory = Box::new(AndOrEngineFactory::new(factory));
252
253
            // Wrap with NormalizedEngineFactory if normalization is requested
254
407
            if options.normalize {
  Branch (254:16): [True: 11, False: 375]
 
  Branch (254:16): [True: 0, False: 21]
-
255
11
                factory = Box::new(NormalizedEngineFactory::new(factory));
256
397
            }
257
258
408
            let factory: Rc<dyn MatchEngineFactory> = Rc::new(factory);
259
408
            (factory, rank_builder)
260
        }
261
412
    }
262
263
    /// Creates a Matcher configured from the given `SkimOptions`.
264
    #[must_use]
265
409
    pub fn from_options(options: &SkimOptions) -> Self {
266
409
        let (engine_factory, rank_builder) = Self::create_engine_factory_with_builder(options);
267
409
        Matcher::builder(engine_factory)
268
409
            .case(options.case)
269
409
            .rank_builder(rank_builder)
270
409
            .build()
271
409
    }
272
273
    /// Returns the case matching setting for this matcher.
274
    #[must_use]
275
1
    pub fn case_matching(&self) -> CaseMatching {
276
1
        self.case_matching
277
1
    }
278
279
    /// Returns a reference to the engine factory.
280
    #[must_use]
281
1
    pub fn engine_factory(&self) -> &Rc<dyn MatchEngineFactory> {
282
1
        &self.engine_factory
283
1
    }
284
285
    /// Runs the matcher on items from the pool in a background thread.
286
    ///
287
    /// When matching completes, the coordinator merges results directly into
288
    /// `processed_items` according to `merge_strategy`, then signals
289
    /// `needs_render` so the UI picks up the new data on its next tick.
290
    ///
291
    /// Returns a `MatcherControl` that can be used to monitor progress or
292
    /// stop the matcher.
293
    #[allow(clippy::too_many_arguments)]
294
1.08k
    pub(crate) fn run(
295
1.08k
        &self,
296
1.08k
        query: &str,
297
1.08k
        item_pool: &Arc<ItemPool>,
298
1.08k
        thread_pool: &Arc<ThreadPool>,
299
1.08k
        processed_items: Arc<Mutex<Option<ProcessedItems>>>,
300
1.08k
        merge_strategy: MergeStrategy,
301
1.08k
        no_sort: bool,
302
1.08k
        tac: bool,
303
1.08k
        generation: usize,
304
1.08k
        current_generation: Arc<AtomicUsize>,
305
1.08k
        needs_render: Arc<AtomicBool>,
306
1.08k
    ) -> MatcherControl {
307
1.08k
        let matcher_engine = self.engine_factory.create_engine_with_case(query, self.case_matching);
308
1.08k
        debug!("engine: {
matcher_engine2
}");
309
1.08k
        let stopped = Arc::new(AtomicBool::new(false));
310
1.08k
        let stopped_clone = stopped.clone();
311
1.08k
        let interrupt = Arc::new(AtomicBool::new(false));
312
1.08k
        let interrupt_clone = interrupt.clone();
313
1.08k
        let processed = Arc::new(AtomicUsize::new(0));
314
1.08k
        let processed_clone = processed.clone();
315
1.08k
        let matched = Arc::new(AtomicUsize::new(0));
316
1.08k
        let matched_clone = matched.clone();
317
1.08k
        let rank_builder = self.rank_builder.clone();
318
319
        // Take items synchronously before spawning to avoid a race condition:
320
        // if we took items inside the spawned closure, a subsequent restart_matcher()
321
        // could call kill() + reset() before the old closure runs, causing the old
322
        // closure to re-take items that should belong to the new matcher.
323
1.08k
        let start = item_pool.num_taken();
324
1.08k
        let items = item_pool.take();
325
1.08k
        let total = items.len();
326
1.08k
        trace!("matcher start, total: {
total2
}");
327
328
        // The coordinator runs on a dedicated OS thread so it does not occupy
329
        // a pool slot while waiting for workers.  All pool threads are
330
        // therefore available for the parallel matching work.
331
1.08k
        let num_workers = thread_pool.num_threads();
332
1.08k
        let pool_for_work = Arc::clone(thread_pool);
333
334
1.08k
        std::thread::spawn(move || {
335
            // Process items in parallel using a shared work queue.  Each worker
336
            // thread atomically grabs the next available chunk, processes it,
337
            // and immediately merges its partial results.  This means threads
338
            // that finish early automatically pick up more work, providing
339
            // natural load balancing.
340
            //
341
            // The chunk size controls the granularity of work distribution and
342
            // the frequency of atomic counter updates / interrupt checks.
343
            const CHUNK_SIZE: usize = 1 << 12;
344
345
            // Convert items into an Arc slice so all workers can share them.
346
1.08k
            let shared_items: Arc<[Arc<dyn SkimItem>]> = items.into();
347
348
            // Clones for the process_chunk closure.
349
1.08k
            let matcher_engine: Arc<dyn crate::MatchEngine> = Arc::from(matcher_engine);
350
1.08k
            let interrupt_for_work = Arc::clone(&interrupt);
351
1.08k
            let processed_for_work = Arc::clone(&processed);
352
1.08k
            let matched_for_work = Arc::clone(&matched);
353
1.08k
            let rank_builder_for_work = Arc::clone(&rank_builder);
354
355
1.08k
            thread_pool::parallel_work_queue(
356
1.08k
                &pool_for_work,
357
1.08k
                num_workers,
358
1.08k
                &shared_items,
359
1.08k
                CHUNK_SIZE,
360
1.08k
                no_sort,
361
                // identity – seed value for each worker's local accumulator
362
1.08k
                Vec::<MatchedItem>::new,
363
                // process_chunk – called for each chunk; returns a Vec of matches
364
709
                move |chunk_start, chunk: &[Arc<dyn crate::SkimItem>]| {
365
                    // Check interrupt before processing this chunk.
366
709
                    if interrupt_for_work.load(Ordering::Relaxed) {
  Branch (366:24): [True: 0, False: 695]
-
  Branch (366:24): [True: 5, False: 9]
-
367
5
                        return Vec::new();
368
704
                    }
369
370
704
                    let mut local_matches = Vec::new();
371
704
                    let mut chunk_matched: usize = 0;
372
373
704
                    for (i, item) in chunk.iter().enumerate() {
374
52.9k
                        if let Some(match_result) = matcher_engine.match_item(item.as_ref()) {
  Branch (374:32): [True: 51.9k, False: 968]
-
  Branch (374:32): [True: 17, False: 1]
-
375
51.9k
                            chunk_matched += 1;
376
51.9k
                            let mut rank = match_result.rank;
377
51.9k
                            let batch_index = chunk_start + i;
378
                            // `take()` reverses each tac batch, so recover the
379
                            // item's stable ordinal in the original input stream.
380
51.9k
                            let index = input_index(tac, start, total, batch_index);
381
51.9k
                            rank.index = i32::try_from(index).unwrap_or(i32::MAX);
382
51.9k
                            local_matches.push(MatchedItem::new(
383
51.9k
                                Arc::clone(item),
384
51.9k
                                rank,
385
51.9k
                                Some(match_result.matched_range),
386
51.9k
                                &rank_builder_for_work,
387
51.9k
                            ));
388
969
                        }
389
                    }
390
391
                    // Flush counters for this chunk so the UI sees progress.
392
704
                    processed_for_work.fetch_add(chunk.len(), Ordering::Relaxed);
393
704
                    if chunk_matched > 0 {
  Branch (393:24): [True: 601, False: 94]
-
  Branch (393:24): [True: 9, False: 0]
-
394
610
                        matched_for_work.fetch_add(chunk_matched, Ordering::Relaxed);
395
94
                    }
396
397
704
                    local_matches
398
709
                },
399
                // reduce – accumulate chunk matches into the worker-local Vec.
400
                // No sorting here — that would be O(m²/chunk_size) per worker.
401
692
                |acc: &mut Vec<MatchedItem>, mut partial: Vec<MatchedItem>| {
402
692
                    if acc.len() >= partial.len() {
  Branch (402:24): [True: 94, False: 584]
-
  Branch (402:24): [True: 5, False: 9]
-
403
99
                        acc.extend(partial);
404
                    } else {
405
593
                        partial.append(acc);
406
593
                        *acc = partial;
407
                    }
408
692
                },
409
                // prepare – sort each worker's accumulator **on the worker
410
                // thread** so that sorting runs in parallel across all workers.
411
                // A single O((m/k)·log(m/k)) sort per worker is far cheaper
412
                // than sorting during reduce.
413
                // sort_unstable is used here because the worker's accumulator
414
                // has no pre-existing sorted runs (items were appended in
415
                // chunk order), so driftsort's run-detection overhead is pure
416
                // cost.  The final merge uses sort() so that driftsort can
417
                // exploit the k sorted runs produced by the workers.
418
1.38k
                |acc: &mut Vec<MatchedItem>| acc.sort_unstable(),
419
                // merge – concat pre-sorted worker results and sort().
420
                // Rust's stable sort detects the k sorted runs and merges
421
                // them in O(n log k), then writes into processed_items.
422
1.08k
                |worker_results: Vec<Vec<MatchedItem>>| {
423
1.08k
                    if interrupt.load(Ordering::SeqCst) {
  Branch (423:24): [True: 0, False: 1.05k]
-
  Branch (423:24): [True: 11, False: 19]
-
424
11
                        return;
425
1.07k
                    }
426
427
1.07k
                    merge_worker_results(
428
1.07k
                        worker_results,
429
1.07k
                        no_sort,
430
1.07k
                        &processed_items,
431
1.07k
                        merge_strategy,
432
1.07k
                        generation,
433
1.07k
                        &current_generation,
434
1.07k
                        &needs_render,
435
1.07k
                    );
436
1.08k
                },
437
            );
438
1.08k
            stopped.store(true, Ordering::Relaxed);
439
1.08k
        });
440
441
1.08k
        MatcherControl {
442
1.08k
            stopped: stopped_clone,
443
1.08k
            interrupt: interrupt_clone,
444
1.08k
            matched: matched_clone,
445
1.08k
            processed: processed_clone,
446
1.08k
        }
447
1.08k
    }
448
}
449
450
#[cfg(test)]
451
#[cfg_attr(coverage, coverage(off))]
452
mod tests {
453
    use super::*;
454
    use crate::Rank;
455
    use crate::item::RankBuilder;
456
    use crate::options::SkimOptionsBuilder;
457
458
    fn matched(text: &str, index: i32) -> MatchedItem {
459
        MatchedItem::new(
460
            Arc::new(text.to_string()),
461
            Rank {
462
                index,
463
                ..Default::default()
464
            },
465
            None,
466
            &RankBuilder::default(),
467
        )
468
    }
469
470
    #[test]
471
    fn from_options_exposes_case_and_factory() {
472
        let options = SkimOptionsBuilder::default()
473
            .case(CaseMatching::Ignore)
474
            .build()
475
            .unwrap();
476
        let matcher = Matcher::from_options(&options);
477
        assert_eq!(matcher.case_matching(), CaseMatching::Ignore);
478
        // The factory builds a working engine.
479
        let engine = matcher.engine_factory().create_engine("foo");
480
        assert!(engine.match_item(&"foobar".to_string()).is_some());
481
    }
482
483
    #[test]
484
    fn create_engine_factory_builds_fuzzy_engine() {
485
        let options = SkimOptions::default();
486
        let factory = Matcher::create_engine_factory(&options);
487
        let engine = factory.create_engine("fb");
488
        assert!(engine.match_item(&"foobar".to_string()).is_some());
489
    }
490
491
    #[test]
492
    fn create_engine_factory_regex_with_normalize() {
493
        let options = SkimOptionsBuilder::default()
494
            .regex(true)
495
            .normalize(true)
496
            .build()
497
            .unwrap();
498
        let (factory, _rank) = Matcher::create_engine_factory_with_builder(&options);
499
        let engine = factory.create_engine("ba.");
500
        assert!(engine.match_item(&"foobar".to_string()).is_some());
501
    }
502
503
    #[test]
504
    fn regex_factory_uses_configured_tiebreak() {
505
        let options = SkimOptionsBuilder::default()
506
            .regex(true)
507
            .tiebreak(vec![crate::RankCriteria::Length])
508
            .build()
509
            .unwrap();
510
        let (factory, rank_builder) = Matcher::create_engine_factory_with_builder(&options);
511
        let engine = factory.create_engine("a");
512
        let mut matches: Vec<_> = ["aaaa", "a", "aaa"]
513
            .into_iter()
514
            .enumerate()
515
            .map(|(index, text)| {
516
                let item: Arc<dyn SkimItem> = Arc::new(text.to_string());
517
                let mut result = engine.match_item(item.as_ref()).unwrap();
518
                result.rank.index = i32::try_from(index).unwrap();
519
                MatchedItem::new(item, result.rank, Some(result.matched_range), &rank_builder)
520
            })
521
            .collect();
522
        matches.sort();
523
524
        let output: Vec<_> = matches.iter().map(|item| item.text().into_owned()).collect();
525
        assert_eq!(output, ["a", "aaa", "aaaa"]);
526
    }
527
528
    fn merge_test_results(
529
        worker_results: Vec<Vec<MatchedItem>>,
530
        no_sort: bool,
531
        processed_items: &Mutex<Option<ProcessedItems>>,
532
        merge_strategy: MergeStrategy,
533
        needs_render: &AtomicBool,
534
    ) {
535
        let generation = AtomicUsize::new(0);
536
        merge_worker_results(
537
            worker_results,
538
            no_sort,
539
            processed_items,
540
            merge_strategy,
541
            0,
542
            &generation,
543
            needs_render,
544
        );
545
    }
546
547
    #[test]
548
    fn merge_worker_results_replace_sorts() {
549
        let processed = Mutex::new(None);
550
        let needs_render = AtomicBool::new(false);
551
        let workers = vec![vec![matched("b", 1)], vec![matched("a", 0)]];
552
        merge_test_results(workers, false, &processed, MergeStrategy::Replace, &needs_render);
553
554
        assert!(needs_render.load(Ordering::Relaxed));
555
        let guard = processed.lock().unwrap();
556
        let items = &guard.as_ref().unwrap().items;
557
        assert_eq!(items.len(), 2);
558
    }
559
560
    #[test]
561
    fn stale_generation_cannot_publish_results() {
562
        let processed = Mutex::new(None);
563
        let needs_render = AtomicBool::new(false);
564
        let generation = AtomicUsize::new(2);
565
566
        merge_worker_results(
567
            vec![vec![matched("stale", 0)]],
568
            false,
569
            &processed,
570
            MergeStrategy::Replace,
571
            1,
572
            &generation,
573
            &needs_render,
574
        );
575
576
        assert!(processed.lock().unwrap().is_none());
577
        assert!(!needs_render.load(Ordering::Relaxed));
578
    }
579
580
    #[test]
581
    fn current_results_replace_pending_stale_generation() {
582
        let processed = Mutex::new(Some(ProcessedItems {
583
            items: vec![matched("stale", 0)],
584
            merge: MergeStrategy::SortedMerge,
585
            generation: 0,
586
        }));
587
        let needs_render = AtomicBool::new(false);
588
        let generation = AtomicUsize::new(1);
589
590
        merge_worker_results(
591
            vec![vec![matched("current", 1)]],
592
            false,
593
            &processed,
594
            MergeStrategy::SortedMerge,
595
            1,
596
            &generation,
597
            &needs_render,
598
        );
599
600
        let guard = processed.lock().unwrap();
601
        let result = guard.as_ref().unwrap();
602
        assert_eq!(result.generation, 1);
603
        assert_eq!(result.items.len(), 1);
604
        assert_eq!(result.items[0].text(), "current");
605
        assert!(matches!(result.merge, MergeStrategy::Replace));
606
    }
607
608
    #[test]
609
    fn merge_worker_results_no_sort_preserves_chunk_order() {
610
        let processed = Mutex::new(None);
611
        let needs_render = AtomicBool::new(false);
612
        let workers = vec![
613
            vec![matched("a", 0), matched("b", 1)],
614
            vec![matched("c", 2), matched("d", 3)],
615
            vec![matched("e", 4), matched("f", 5)],
616
        ];
617
        merge_test_results(workers, true, &processed, MergeStrategy::Replace, &needs_render);
618
619
        let guard = processed.lock().unwrap();
620
        let items = &guard.as_ref().unwrap().items;
621
        let indexes: Vec<i32> = items.iter().map(|item| item.rank.index).collect();
622
        assert_eq!(indexes, vec![0, 1, 2, 3, 4, 5]);
623
    }
624
625
    #[test]
626
    fn merge_worker_results_append_no_sort_extends_existing() {
627
        let processed = Mutex::new(None);
628
        let needs_render = AtomicBool::new(false);
629
630
        // First append establishes the existing list.
631
        merge_test_results(
632
            vec![vec![matched("a", 0)]],
633
            true,
634
            &processed,
635
            MergeStrategy::Append,
636
            &needs_render,
637
        );
638
        // Second append with no_sort extends the existing list in place.
639
        merge_test_results(
640
            vec![vec![matched("b", 1)]],
641
            true,
642
            &processed,
643
            MergeStrategy::Append,
644
            &needs_render,
645
        );
646
647
        let guard = processed.lock().unwrap();
648
        assert_eq!(guard.as_ref().unwrap().items.len(), 2);
649
    }
650
651
    #[test]
652
    fn tac_input_index_spans_incremental_batches() {
653
        let first_batch: Vec<_> = (0..3).map(|index| input_index(true, 0, 3, index)).collect();
654
        let second_batch: Vec<_> = (0..2).map(|index| input_index(true, 3, 2, index)).collect();
655
656
        assert_eq!(first_batch, [2, 1, 0]);
657
        assert_eq!(second_batch, [4, 3]);
658
        assert_eq!(input_index(false, 3, 2, 0), 3);
659
        assert_eq!(input_index(false, 3, 2, 1), 4);
660
    }
661
662
    #[test]
663
    fn merge_worker_results_prepend_no_sort_places_new_batch_first() {
664
        let processed = Mutex::new(None);
665
        let needs_render = AtomicBool::new(false);
666
667
        merge_test_results(
668
            vec![vec![matched("c", 2), matched("b", 1), matched("a", 0)]],
669
            true,
670
            &processed,
671
            MergeStrategy::Prepend,
672
            &needs_render,
673
        );
674
        merge_test_results(
675
            vec![vec![matched("e", 4), matched("d", 3)]],
676
            true,
677
            &processed,
678
            MergeStrategy::Prepend,
679
            &needs_render,
680
        );
681
682
        let guard = processed.lock().unwrap();
683
        let indexes: Vec<i32> = guard
684
            .as_ref()
685
            .unwrap()
686
            .items
687
            .iter()
688
            .map(|item| item.rank.index)
689
            .collect();
690
        assert_eq!(indexes, [4, 3, 2, 1, 0]);
691
    }
692
}
\ No newline at end of file +
255
11
                factory = Box::new(NormalizedEngineFactory::new(factory));
256
396
            }
257
258
407
            let factory: Rc<dyn MatchEngineFactory> = Rc::new(factory);
259
407
            (factory, rank_builder)
260
        }
261
411
    }
262
263
    /// Creates a Matcher configured from the given `SkimOptions`.
264
    #[must_use]
265
408
    pub fn from_options(options: &SkimOptions) -> Self {
266
408
        let (engine_factory, rank_builder) = Self::create_engine_factory_with_builder(options);
267
408
        Matcher::builder(engine_factory)
268
408
            .case(options.case)
269
408
            .rank_builder(rank_builder)
270
408
            .build()
271
408
    }
272
273
    /// Returns the case matching setting for this matcher.
274
    #[must_use]
275
1
    pub fn case_matching(&self) -> CaseMatching {
276
1
        self.case_matching
277
1
    }
278
279
    /// Returns a reference to the engine factory.
280
    #[must_use]
281
1
    pub fn engine_factory(&self) -> &Rc<dyn MatchEngineFactory> {
282
1
        &self.engine_factory
283
1
    }
284
285
    /// Runs the matcher on items from the pool in a background thread.
286
    ///
287
    /// When matching completes, the coordinator merges results directly into
288
    /// `processed_items` according to `merge_strategy`, then signals
289
    /// `needs_render` so the UI picks up the new data on its next tick.
290
    ///
291
    /// Returns a `MatcherControl` that can be used to monitor progress or
292
    /// stop the matcher.
293
    #[allow(clippy::too_many_arguments)]
294
1.08k
    pub(crate) fn run(
295
1.08k
        &self,
296
1.08k
        query: &str,
297
1.08k
        item_pool: &Arc<ItemPool>,
298
1.08k
        thread_pool: &Arc<ThreadPool>,
299
1.08k
        processed_items: Arc<Mutex<Option<ProcessedItems>>>,
300
1.08k
        merge_strategy: MergeStrategy,
301
1.08k
        no_sort: bool,
302
1.08k
        tac: bool,
303
1.08k
        generation: usize,
304
1.08k
        current_generation: Arc<AtomicUsize>,
305
1.08k
        needs_render: Arc<AtomicBool>,
306
1.08k
    ) -> MatcherControl {
307
1.08k
        let matcher_engine = self.engine_factory.create_engine_with_case(query, self.case_matching);
308
1.08k
        debug!("engine: {
matcher_engine2
}");
309
1.08k
        let stopped = Arc::new(AtomicBool::new(false));
310
1.08k
        let stopped_clone = stopped.clone();
311
1.08k
        let interrupt = Arc::new(AtomicBool::new(false));
312
1.08k
        let interrupt_clone = interrupt.clone();
313
1.08k
        let processed = Arc::new(AtomicUsize::new(0));
314
1.08k
        let processed_clone = processed.clone();
315
1.08k
        let matched = Arc::new(AtomicUsize::new(0));
316
1.08k
        let matched_clone = matched.clone();
317
1.08k
        let rank_builder = self.rank_builder.clone();
318
319
        // Take items synchronously before spawning to avoid a race condition:
320
        // if we took items inside the spawned closure, a subsequent restart_matcher()
321
        // could call kill() + reset() before the old closure runs, causing the old
322
        // closure to re-take items that should belong to the new matcher.
323
1.08k
        let start = item_pool.num_taken();
324
1.08k
        let items = item_pool.take();
325
1.08k
        let total = items.len();
326
1.08k
        trace!("matcher start, total: {
total2
}");
327
328
        // The coordinator runs on a dedicated OS thread so it does not occupy
329
        // a pool slot while waiting for workers.  All pool threads are
330
        // therefore available for the parallel matching work.
331
1.08k
        let num_workers = thread_pool.num_threads();
332
1.08k
        let pool_for_work = Arc::clone(thread_pool);
333
334
1.08k
        std::thread::spawn(move || {
335
            // Process items in parallel using a shared work queue.  Each worker
336
            // thread atomically grabs the next available chunk, processes it,
337
            // and immediately merges its partial results.  This means threads
338
            // that finish early automatically pick up more work, providing
339
            // natural load balancing.
340
            //
341
            // The chunk size controls the granularity of work distribution and
342
            // the frequency of atomic counter updates / interrupt checks.
343
            const CHUNK_SIZE: usize = 1 << 12;
344
345
            // Convert items into an Arc slice so all workers can share them.
346
1.08k
            let shared_items: Arc<[Arc<dyn SkimItem>]> = items.into();
347
348
            // Clones for the process_chunk closure.
349
1.08k
            let matcher_engine: Arc<dyn crate::MatchEngine> = Arc::from(matcher_engine);
350
1.08k
            let interrupt_for_work = Arc::clone(&interrupt);
351
1.08k
            let processed_for_work = Arc::clone(&processed);
352
1.08k
            let matched_for_work = Arc::clone(&matched);
353
1.08k
            let rank_builder_for_work = Arc::clone(&rank_builder);
354
355
1.08k
            thread_pool::parallel_work_queue(
356
1.08k
                &pool_for_work,
357
1.08k
                num_workers,
358
1.08k
                &shared_items,
359
1.08k
                CHUNK_SIZE,
360
1.08k
                no_sort,
361
                // identity – seed value for each worker's local accumulator
362
1.08k
                Vec::<MatchedItem>::new,
363
                // process_chunk – called for each chunk; returns a Vec of matches
364
712
                move |chunk_start, chunk: &[Arc<dyn crate::SkimItem>]| {
365
                    // Check interrupt before processing this chunk.
366
712
                    if interrupt_for_work.load(Ordering::Relaxed) {
  Branch (366:24): [True: 0, False: 698]
+
  Branch (366:24): [True: 4, False: 10]
+
367
4
                        return Vec::new();
368
708
                    }
369
370
708
                    let mut local_matches = Vec::new();
371
708
                    let mut chunk_matched: usize = 0;
372
373
708
                    for (i, item) in chunk.iter().enumerate() {
374
52.9k
                        if let Some(match_result) = matcher_engine.match_item(item.as_ref()) {
  Branch (374:32): [True: 51.9k, False: 972]
+
  Branch (374:32): [True: 19, False: 1]
+
375
51.9k
                            chunk_matched += 1;
376
51.9k
                            let mut rank = match_result.rank;
377
51.9k
                            let batch_index = chunk_start + i;
378
                            // `take()` reverses each tac batch, so recover the
379
                            // item's stable ordinal in the original input stream.
380
51.9k
                            let index = input_index(tac, start, total, batch_index);
381
51.9k
                            rank.index = i32::try_from(index).unwrap_or(i32::MAX);
382
51.9k
                            local_matches.push(MatchedItem::new(
383
51.9k
                                Arc::clone(item),
384
51.9k
                                rank,
385
51.9k
                                Some(match_result.matched_range),
386
51.9k
                                &rank_builder_for_work,
387
51.9k
                            ));
388
973
                        }
389
                    }
390
391
                    // Flush counters for this chunk so the UI sees progress.
392
708
                    processed_for_work.fetch_add(chunk.len(), Ordering::Relaxed);
393
708
                    if chunk_matched > 0 {
  Branch (393:24): [True: 604, False: 94]
+
  Branch (393:24): [True: 10, False: 0]
+
394
614
                        matched_for_work.fetch_add(chunk_matched, Ordering::Relaxed);
395
94
                    }
396
397
708
                    local_matches
398
712
                },
399
                // reduce – accumulate chunk matches into the worker-local Vec.
400
                // No sorting here — that would be O(m²/chunk_size) per worker.
401
695
                |acc: &mut Vec<MatchedItem>, mut partial: Vec<MatchedItem>| {
402
695
                    if acc.len() >= partial.len() {
  Branch (402:24): [True: 94, False: 587]
+
  Branch (402:24): [True: 4, False: 10]
+
403
98
                        acc.extend(partial);
404
                    } else {
405
597
                        partial.append(acc);
406
597
                        *acc = partial;
407
                    }
408
695
                },
409
                // prepare – sort each worker's accumulator **on the worker
410
                // thread** so that sorting runs in parallel across all workers.
411
                // A single O((m/k)·log(m/k)) sort per worker is far cheaper
412
                // than sorting during reduce.
413
                // sort_unstable is used here because the worker's accumulator
414
                // has no pre-existing sorted runs (items were appended in
415
                // chunk order), so driftsort's run-detection overhead is pure
416
                // cost.  The final merge uses sort() so that driftsort can
417
                // exploit the k sorted runs produced by the workers.
418
1.39k
                |acc: &mut Vec<MatchedItem>| acc.sort_unstable(),
419
                // merge – concat pre-sorted worker results and sort().
420
                // Rust's stable sort detects the k sorted runs and merges
421
                // them in O(n log k), then writes into processed_items.
422
1.08k
                |worker_results: Vec<Vec<MatchedItem>>| {
423
1.08k
                    if interrupt.load(Ordering::SeqCst) {
  Branch (423:24): [True: 0, False: 1.05k]
+
  Branch (423:24): [True: 12, False: 21]
+
424
12
                        return;
425
1.07k
                    }
426
427
1.07k
                    merge_worker_results(
428
1.07k
                        worker_results,
429
1.07k
                        no_sort,
430
1.07k
                        &processed_items,
431
1.07k
                        merge_strategy,
432
1.07k
                        generation,
433
1.07k
                        &current_generation,
434
1.07k
                        &needs_render,
435
1.07k
                    );
436
1.08k
                },
437
            );
438
1.08k
            stopped.store(true, Ordering::Relaxed);
439
1.08k
        });
440
441
1.08k
        MatcherControl {
442
1.08k
            stopped: stopped_clone,
443
1.08k
            interrupt: interrupt_clone,
444
1.08k
            matched: matched_clone,
445
1.08k
            processed: processed_clone,
446
1.08k
        }
447
1.08k
    }
448
}
449
450
#[cfg(test)]
451
#[cfg_attr(coverage, coverage(off))]
452
mod tests {
453
    use super::*;
454
    use crate::Rank;
455
    use crate::item::RankBuilder;
456
    use crate::options::SkimOptionsBuilder;
457
458
    fn matched(text: &str, index: i32) -> MatchedItem {
459
        MatchedItem::new(
460
            Arc::new(text.to_string()),
461
            Rank {
462
                index,
463
                ..Default::default()
464
            },
465
            None,
466
            &RankBuilder::default(),
467
        )
468
    }
469
470
    #[test]
471
    fn from_options_exposes_case_and_factory() {
472
        let options = SkimOptionsBuilder::default()
473
            .case(CaseMatching::Ignore)
474
            .build()
475
            .unwrap();
476
        let matcher = Matcher::from_options(&options);
477
        assert_eq!(matcher.case_matching(), CaseMatching::Ignore);
478
        // The factory builds a working engine.
479
        let engine = matcher.engine_factory().create_engine("foo");
480
        assert!(engine.match_item(&"foobar".to_string()).is_some());
481
    }
482
483
    #[test]
484
    fn create_engine_factory_builds_fuzzy_engine() {
485
        let options = SkimOptions::default();
486
        let factory = Matcher::create_engine_factory(&options);
487
        let engine = factory.create_engine("fb");
488
        assert!(engine.match_item(&"foobar".to_string()).is_some());
489
    }
490
491
    #[test]
492
    fn create_engine_factory_regex_with_normalize() {
493
        let options = SkimOptionsBuilder::default()
494
            .regex(true)
495
            .normalize(true)
496
            .build()
497
            .unwrap();
498
        let (factory, _rank) = Matcher::create_engine_factory_with_builder(&options);
499
        let engine = factory.create_engine("ba.");
500
        assert!(engine.match_item(&"foobar".to_string()).is_some());
501
    }
502
503
    #[test]
504
    fn regex_factory_uses_configured_tiebreak() {
505
        let options = SkimOptionsBuilder::default()
506
            .regex(true)
507
            .tiebreak(vec![crate::RankCriteria::Length])
508
            .build()
509
            .unwrap();
510
        let (factory, rank_builder) = Matcher::create_engine_factory_with_builder(&options);
511
        let engine = factory.create_engine("a");
512
        let mut matches: Vec<_> = ["aaaa", "a", "aaa"]
513
            .into_iter()
514
            .enumerate()
515
            .map(|(index, text)| {
516
                let item: Arc<dyn SkimItem> = Arc::new(text.to_string());
517
                let mut result = engine.match_item(item.as_ref()).unwrap();
518
                result.rank.index = i32::try_from(index).unwrap();
519
                MatchedItem::new(item, result.rank, Some(result.matched_range), &rank_builder)
520
            })
521
            .collect();
522
        matches.sort();
523
524
        let output: Vec<_> = matches.iter().map(|item| item.text().into_owned()).collect();
525
        assert_eq!(output, ["a", "aaa", "aaaa"]);
526
    }
527
528
    fn merge_test_results(
529
        worker_results: Vec<Vec<MatchedItem>>,
530
        no_sort: bool,
531
        processed_items: &Mutex<Option<ProcessedItems>>,
532
        merge_strategy: MergeStrategy,
533
        needs_render: &AtomicBool,
534
    ) {
535
        let generation = AtomicUsize::new(0);
536
        merge_worker_results(
537
            worker_results,
538
            no_sort,
539
            processed_items,
540
            merge_strategy,
541
            0,
542
            &generation,
543
            needs_render,
544
        );
545
    }
546
547
    #[test]
548
    fn merge_worker_results_replace_sorts() {
549
        let processed = Mutex::new(None);
550
        let needs_render = AtomicBool::new(false);
551
        let workers = vec![vec![matched("b", 1)], vec![matched("a", 0)]];
552
        merge_test_results(workers, false, &processed, MergeStrategy::Replace, &needs_render);
553
554
        assert!(needs_render.load(Ordering::Relaxed));
555
        let guard = processed.lock().unwrap();
556
        let items = &guard.as_ref().unwrap().items;
557
        assert_eq!(items.len(), 2);
558
    }
559
560
    #[test]
561
    fn stale_generation_cannot_publish_results() {
562
        let processed = Mutex::new(None);
563
        let needs_render = AtomicBool::new(false);
564
        let generation = AtomicUsize::new(2);
565
566
        merge_worker_results(
567
            vec![vec![matched("stale", 0)]],
568
            false,
569
            &processed,
570
            MergeStrategy::Replace,
571
            1,
572
            &generation,
573
            &needs_render,
574
        );
575
576
        assert!(processed.lock().unwrap().is_none());
577
        assert!(!needs_render.load(Ordering::Relaxed));
578
    }
579
580
    #[test]
581
    fn current_results_replace_pending_stale_generation() {
582
        let processed = Mutex::new(Some(ProcessedItems {
583
            items: vec![matched("stale", 0)],
584
            merge: MergeStrategy::SortedMerge,
585
            generation: 0,
586
        }));
587
        let needs_render = AtomicBool::new(false);
588
        let generation = AtomicUsize::new(1);
589
590
        merge_worker_results(
591
            vec![vec![matched("current", 1)]],
592
            false,
593
            &processed,
594
            MergeStrategy::SortedMerge,
595
            1,
596
            &generation,
597
            &needs_render,
598
        );
599
600
        let guard = processed.lock().unwrap();
601
        let result = guard.as_ref().unwrap();
602
        assert_eq!(result.generation, 1);
603
        assert_eq!(result.items.len(), 1);
604
        assert_eq!(result.items[0].text(), "current");
605
        assert!(matches!(result.merge, MergeStrategy::Replace));
606
    }
607
608
    #[test]
609
    fn merge_worker_results_no_sort_preserves_chunk_order() {
610
        let processed = Mutex::new(None);
611
        let needs_render = AtomicBool::new(false);
612
        let workers = vec![
613
            vec![matched("a", 0), matched("b", 1)],
614
            vec![matched("c", 2), matched("d", 3)],
615
            vec![matched("e", 4), matched("f", 5)],
616
        ];
617
        merge_test_results(workers, true, &processed, MergeStrategy::Replace, &needs_render);
618
619
        let guard = processed.lock().unwrap();
620
        let items = &guard.as_ref().unwrap().items;
621
        let indexes: Vec<i32> = items.iter().map(|item| item.rank.index).collect();
622
        assert_eq!(indexes, vec![0, 1, 2, 3, 4, 5]);
623
    }
624
625
    #[test]
626
    fn merge_worker_results_append_no_sort_extends_existing() {
627
        let processed = Mutex::new(None);
628
        let needs_render = AtomicBool::new(false);
629
630
        // First append establishes the existing list.
631
        merge_test_results(
632
            vec![vec![matched("a", 0)]],
633
            true,
634
            &processed,
635
            MergeStrategy::Append,
636
            &needs_render,
637
        );
638
        // Second append with no_sort extends the existing list in place.
639
        merge_test_results(
640
            vec![vec![matched("b", 1)]],
641
            true,
642
            &processed,
643
            MergeStrategy::Append,
644
            &needs_render,
645
        );
646
647
        let guard = processed.lock().unwrap();
648
        assert_eq!(guard.as_ref().unwrap().items.len(), 2);
649
    }
650
651
    #[test]
652
    fn tac_input_index_spans_incremental_batches() {
653
        let first_batch: Vec<_> = (0..3).map(|index| input_index(true, 0, 3, index)).collect();
654
        let second_batch: Vec<_> = (0..2).map(|index| input_index(true, 3, 2, index)).collect();
655
656
        assert_eq!(first_batch, [2, 1, 0]);
657
        assert_eq!(second_batch, [4, 3]);
658
        assert_eq!(input_index(false, 3, 2, 0), 3);
659
        assert_eq!(input_index(false, 3, 2, 1), 4);
660
    }
661
662
    #[test]
663
    fn merge_worker_results_prepend_no_sort_places_new_batch_first() {
664
        let processed = Mutex::new(None);
665
        let needs_render = AtomicBool::new(false);
666
667
        merge_test_results(
668
            vec![vec![matched("c", 2), matched("b", 1), matched("a", 0)]],
669
            true,
670
            &processed,
671
            MergeStrategy::Prepend,
672
            &needs_render,
673
        );
674
        merge_test_results(
675
            vec![vec![matched("e", 4), matched("d", 3)]],
676
            true,
677
            &processed,
678
            MergeStrategy::Prepend,
679
            &needs_render,
680
        );
681
682
        let guard = processed.lock().unwrap();
683
        let indexes: Vec<i32> = guard
684
            .as_ref()
685
            .unwrap()
686
            .items
687
            .iter()
688
            .map(|item| item.rank.index)
689
            .collect();
690
        assert_eq!(indexes, [4, 3, 2, 1, 0]);
691
    }
692
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/options.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/options.rs.html index b579c429..a1ab8932 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/options.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/options.rs.html @@ -1,34 +1,34 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/options.rs
Line
Count
Source
1
//! Configuration options for skim.
2
//!
3
//! This module provides the `SkimOptions` struct and builder for configuring
4
//! all aspects of skim's behavior, including search, display, layout, and interaction settings.
5
6
use std::cell::RefCell;
7
use std::rc::Rc;
8
9
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10
use derive_builder::Builder;
11
#[cfg(feature = "image")]
12
use ratatui_image::picker::Picker;
13
use regex::Regex;
14
15
use crate::binds::KeyMap;
16
use crate::item::RankCriteria;
17
use crate::prelude::SkimItemReader;
18
use crate::reader::CommandCollector;
19
use crate::tui::actions::Action;
20
use crate::tui::options::{PreviewLayout, TuiLayout};
21
use crate::tui::statusline::{Info, InfoDisplay};
22
use crate::tui::{BorderType, PreviewCallback};
23
use crate::util::read_file_lines;
24
use crate::{CaseMatching, FuzzyAlgorithm, Selector, Typos};
25
26
const MIN_HEIGHT_PARSE_ERROR: &str = "min-height needs to be a non-negative integer";
27
28
496
pub(crate) fn parse_min_height(s: &str) -> Result<u16, String> {
29
496
    s.parse().map_err(|_| 
MIN_HEIGHT_PARSE_ERROR5
.to_string())
30
496
}
31
32
#[cfg(feature = "cli")]
33
482
fn parse_min_height_value(s: &str) -> Result<String, String> {
34
482
    parse_min_height(s).map(|_| 
s480
.to_string())
35
482
}
36
37
#[cfg(feature = "cli")]
38
/// Custom value parser for delimiter that handles escape sequences
39
481
fn parse_delimiter_value(s: &str) -> Result<Regex, String> {
40
481
    let unescaped = crate::util::unescape_delimiter(s);
41
481
    Regex::new(&unescaped).map_err(|e| 
format!0
("Invalid regex delimiter: {
e0
}"))
42
481
}
43
44
/// Custom value parser for border
45
///
46
/// Any undefined value falls back to [`BorderType::Plain`] (see the `FromStr` impl in `tui`)
47
/// instead of producing a parse error like the default `ValueEnum` parser would, while still
48
/// advertising the known variants in `--help` and shell completions by delegating
49
/// [`possible_values`](clap::builder::TypedValueParser::possible_values) to
50
/// [`BorderType`]'s [`ValueEnum`](clap::ValueEnum) members.
51
#[cfg(feature = "cli")]
52
#[derive(Clone)]
53
struct BorderValueParser;
54
55
#[cfg(feature = "cli")]
56
impl clap::builder::TypedValueParser for BorderValueParser {
57
    type Value = BorderType;
58
59
479
    fn parse_ref(
60
479
        &self,
61
479
        _cmd: &clap::Command,
62
479
        _arg: Option<&clap::Arg>,
63
479
        value: &std::ffi::OsStr,
64
479
    ) -> Result<Self::Value, clap::Error> {
65
        // `FromStr for BorderType` is infallible: unknown values map to `BorderType::Plain`.
66
479
        Ok(value.to_string_lossy().parse().unwrap_or(BorderType::Plain))
67
479
    }
68
69
17
    fn possible_values(&self) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
70
17
        Some(Box::new(
71
17
            <BorderType as clap::ValueEnum>::value_variants()
72
17
                .iter()
73
17
                .filter_map(clap::ValueEnum::to_possible_value),
74
17
        ))
75
17
    }
76
}
77
78
#[cfg(feature = "cli")]
79
/// Custom value parser for typo tolerance
80
///
81
/// - `"smart"` → `Typos::Smart` (adaptive: `pattern_length` / 4)
82
/// - `"disabled"` → `Typos::Disabled`
83
/// - `"N"` (N >= 0) → `Typos::Fixed(N)`
84
479
fn parse_typos(s: &str) -> Result<Typos, String> {
85
479
    if s.eq_ignore_ascii_case("smart") {
  Branch (85:8): [True: 3, False: 465]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/options.rs
Line
Count
Source
1
//! Configuration options for skim.
2
//!
3
//! This module provides the `SkimOptions` struct and builder for configuring
4
//! all aspects of skim's behavior, including search, display, layout, and interaction settings.
5
6
use std::cell::RefCell;
7
use std::rc::Rc;
8
9
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
10
use derive_builder::Builder;
11
#[cfg(feature = "image")]
12
use ratatui_image::picker::Picker;
13
use regex::Regex;
14
15
use crate::binds::KeyMap;
16
use crate::item::RankCriteria;
17
use crate::prelude::SkimItemReader;
18
use crate::reader::CommandCollector;
19
use crate::tui::actions::Action;
20
use crate::tui::options::{PreviewLayout, TuiLayout};
21
use crate::tui::statusline::{Info, InfoDisplay};
22
use crate::tui::{BorderType, PreviewCallback};
23
use crate::util::read_file_lines;
24
use crate::{CaseMatching, FuzzyAlgorithm, Selector, Typos};
25
26
const MIN_HEIGHT_PARSE_ERROR: &str = "min-height needs to be a non-negative integer";
27
28
495
pub(crate) fn parse_min_height(s: &str) -> Result<u16, String> {
29
495
    s.parse().map_err(|_| 
MIN_HEIGHT_PARSE_ERROR5
.to_string())
30
495
}
31
32
#[cfg(feature = "cli")]
33
481
fn parse_min_height_value(s: &str) -> Result<String, String> {
34
481
    parse_min_height(s).map(|_| 
s479
.to_string())
35
481
}
36
37
#[cfg(feature = "cli")]
38
/// Custom value parser for delimiter that handles escape sequences
39
480
fn parse_delimiter_value(s: &str) -> Result<Regex, String> {
40
480
    let unescaped = crate::util::unescape_delimiter(s);
41
480
    Regex::new(&unescaped).map_err(|e| 
format!0
("Invalid regex delimiter: {
e0
}"))
42
480
}
43
44
/// Custom value parser for border
45
///
46
/// Any undefined value falls back to [`BorderType::Plain`] (see the `FromStr` impl in `tui`)
47
/// instead of producing a parse error like the default `ValueEnum` parser would, while still
48
/// advertising the known variants in `--help` and shell completions by delegating
49
/// [`possible_values`](clap::builder::TypedValueParser::possible_values) to
50
/// [`BorderType`]'s [`ValueEnum`](clap::ValueEnum) members.
51
#[cfg(feature = "cli")]
52
#[derive(Clone)]
53
struct BorderValueParser;
54
55
#[cfg(feature = "cli")]
56
impl clap::builder::TypedValueParser for BorderValueParser {
57
    type Value = BorderType;
58
59
478
    fn parse_ref(
60
478
        &self,
61
478
        _cmd: &clap::Command,
62
478
        _arg: Option<&clap::Arg>,
63
478
        value: &std::ffi::OsStr,
64
478
    ) -> Result<Self::Value, clap::Error> {
65
        // `FromStr for BorderType` is infallible: unknown values map to `BorderType::Plain`.
66
478
        Ok(value.to_string_lossy().parse().unwrap_or(BorderType::Plain))
67
478
    }
68
69
17
    fn possible_values(&self) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
70
17
        Some(Box::new(
71
17
            <BorderType as clap::ValueEnum>::value_variants()
72
17
                .iter()
73
17
                .filter_map(clap::ValueEnum::to_possible_value),
74
17
        ))
75
17
    }
76
}
77
78
#[cfg(feature = "cli")]
79
/// Custom value parser for typo tolerance
80
///
81
/// - `"smart"` → `Typos::Smart` (adaptive: `pattern_length` / 4)
82
/// - `"disabled"` → `Typos::Disabled`
83
/// - `"N"` (N >= 0) → `Typos::Fixed(N)`
84
478
fn parse_typos(s: &str) -> Result<Typos, String> {
85
478
    if s.eq_ignore_ascii_case("smart") {
  Branch (85:8): [True: 5, False: 462]
 
  Branch (85:8): [True: 0, False: 11]
-
86
3
        Ok(Typos::Smart)
87
476
    } else if s.eq_ignore_ascii_case("disabled") {
  Branch (87:15): [True: 465, False: 0]
+
86
5
        Ok(Typos::Smart)
87
473
    } else if s.eq_ignore_ascii_case("disabled") {
  Branch (87:15): [True: 462, False: 0]
 
  Branch (87:15): [True: 11, False: 0]
-
88
476
        Ok(Typos::Disabled)
89
    } else {
90
0
        s.parse::<usize>()
91
0
            .map(Typos::from)
92
0
            .map_err(|_| format!("Invalid typos value '{s}': expected 'smart', 'disabled' or a non-negative integer"))
93
    }
94
479
}
95
96
/// The options for `--scheme`
97
#[derive(Debug, Clone, Default, PartialEq, Eq)]
98
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
99
pub enum MatchScheme {
100
    /// Default scheme, no modifications to the options
101
    #[default]
102
    Default,
103
    /// Path scheme: will find the furthest match in the item and set `pathname` as the main
104
    /// tiebreak
105
    Path,
106
    /// History scheme: will force `index` as the first tiebreak
107
    History,
108
}
109
110
/// Image rendering protocols
111
#[cfg(feature = "image")]
112
#[derive(Default, Debug, Clone, PartialEq, Eq)]
113
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
114
pub enum ImageProtocol {
115
    /// Default: automatically detect the available backend at startup
116
    #[default]
117
    Detect,
118
    /// Force halfblocks if you want blurry previews but a faster startup or if the detection fails
119
    Halfblocks,
120
}
121
122
/// sk - fuzzy finder in Rust
123
///
124
/// sk is a general purpose command-line fuzzy finder.
125
#[allow(missing_docs, clippy::struct_excessive_bools)] // derive_builder seems to have issues with doc comments ?
126
#[derive(Builder)]
127
#[builder(build_fn(name = "final_build"), setter(into, strip_option))]
128
#[builder(default)]
129
#[cfg_attr(feature = "cli", derive(clap::Parser))]
130
#[cfg_attr(
131
    feature = "cli",
132
    command(name = "sk", args_override_self = true, verbatim_doc_comment, version, about)
133
)]
134
#[derive(derive_more::Debug)]
135
pub struct SkimOptions {
136
    //  --- Search ---
137
    /// Show results in reverse order
138
    ///
139
    /// Often used in combination with --no-sort
140
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
141
    pub tac: bool,
142
143
    /// Minimum query length to start showing results
144
    ///
145
    /// Only show results when the query is at least this many characters long
146
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
147
    pub min_query_length: Option<usize>,
148
149
    /// Do not sort the results
150
    ///
151
    /// Often used in combination with --tac
152
    /// Example: `history | sk --tac --no-sort`
153
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
154
    pub no_sort: bool,
155
156
    /// Comma-separated list of sort criteria to apply when the scores are tied.
157
    ///
158
    /// * **score**: Score of the fuzzy match algorithm
159
    ///
160
    ///     - Each criterion could be negated, e.g. (-index)
161
    ///     - Each criterion should appear only once in the list
162
    #[cfg_attr(
163
        feature = "cli",
164
        arg(
165
            short,
166
            long,
167
            default_value = "score,begin,end",
168
            value_enum,
169
            value_delimiter = ',',
170
            help_heading = "Search",
171
            allow_hyphen_values = true,
172
            verbatim_doc_comment
173
        )
174
    )]
175
    pub tiebreak: Vec<RankCriteria>,
176
177
    /// Fields to be matched
178
    ///
179
    /// A field index expression can be a non-zero integer or a range expression (`[BEGIN]..[END]`).
180
    /// `--nth` and `--with-nth` take a comma-separated list of field index expressions.
181
    ///
182
    /// **Examples:**
183
    ///     1      The 1st field
184
    ///     2      The 2nd field
185
    ///     -1     The last field
186
    ///     -2     The 2nd to last field
187
    ///     3..5   From the 3rd field to the 5th field
188
    ///     2..    From the 2nd field to the last field
189
    ///     ..-3   From the 1st field to the 3rd to the last field
190
    ///     ..     All the fields
191
    #[cfg_attr(
192
        feature = "cli",
193
        arg(
194
            short,
195
            long,
196
            default_value = "",
197
            help_heading = "Search",
198
            verbatim_doc_comment,
199
            value_delimiter = ',',
200
            allow_hyphen_values = true,
201
        )
202
    )]
203
    pub nth: Vec<String>,
204
205
    /// Fields to be transformed
206
    ///
207
    /// See **nth** for the details
208
    #[cfg_attr(
209
        feature = "cli",
210
        arg(
211
            long,
212
            default_value = "",
213
            help_heading = "Search",
214
            value_delimiter = ',',
215
            allow_hyphen_values = true,
216
        )
217
    )]
218
    pub with_nth: Vec<String>,
219
220
    /// Fields to hide from display while keeping them searchable
221
    ///
222
    /// Takes the same comma-separated field index expressions as **nth**. The listed
223
    /// fields are removed from the displayed line but remain part of the text used for
224
    /// matching, so a query can still match them. Characters in the hidden fields are
225
    /// ignored for match highlighting and horizontal scrolling.
226
    ///
227
    /// See **nth** for the field index expression syntax.
228
    #[cfg_attr(
229
        feature = "cli",
230
        arg(
231
            long,
232
            default_value = "",
233
            help_heading = "Search",
234
            verbatim_doc_comment,
235
            value_delimiter = ',',
236
            allow_hyphen_values = true,
237
        )
238
    )]
239
    pub hide_nth: Vec<String>,
240
241
    /// Delimiter between fields
242
    ///
243
    /// In regex format, defaults to AWK-style. Escape sequences like \x00, \t, \n are supported.
244
    #[cfg_attr(
245
        feature = "cli",
246
        arg(short, long, default_value = r"[\t\n ]+", value_parser = parse_delimiter_value, help_heading = "Search")
247
    )]
248
    pub delimiter: Regex,
249
250
    /// Run in exact mode
251
    #[cfg_attr(feature = "cli", arg(short, long, help_heading = "Search"))]
252
    pub exact: bool,
253
254
    /// Start in regex mode instead of fuzzy-match
255
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
256
    pub regex: bool,
257
258
    /// Fuzzy matching algorithm
259
    ///
260
    /// - arinae (ari) Latest algorithm
261
    ///
262
    /// - `skim_v2` Legacy skim algorithm
263
    ///
264
    /// - clangd  Used in clangd for keyword completion
265
    ///
266
    /// - fzy     Algorithm from fzy (<https://github.com/jhawthorn/fzy>)
267
    ///
268
    /// - frizbee Algorithm used in the blink.cmp neovim plugin
269
    #[cfg_attr(
270
        feature = "cli",
271
        arg(
272
            long = "algo",
273
            value_enum,
274
            default_value = "arinae",
275
            help_heading = "Search",
276
            verbatim_doc_comment
277
        )
278
    )]
279
    pub algorithm: FuzzyAlgorithm,
280
281
    /// Case sensitivity
282
    ///
283
    /// Determines whether or not to ignore case while matching
284
    /// Note: this is not used for the Frizbee matcher, which uses a penalty system to favor
285
    /// case-sensitivity without enforcing it
286
    #[cfg_attr(
287
        feature = "cli",
288
        arg(long, default_value = "smart", value_enum, help_heading = "Search")
289
    )]
290
    pub case: CaseMatching,
291
292
    /// Enable typo-tolerant matching
293
    ///
294
    /// When passed without a value (`--typos`), uses adaptive formula (`pattern_length` / 4).
295
    /// When passed with a value (e.g. `--typos=2`), uses that exact number as the
296
    /// maximum allowed typos. `--typos=0` explicitly disables typo tolerance.
297
    /// Applies to both fzy and frizbee matchers.
298
    #[cfg_attr(
299
        feature = "cli",
300
        arg(long, default_value = "disabled", default_missing_value = "smart", num_args = 0..=1, value_parser = parse_typos, overrides_with = "no_typos", help_heading = "Search")
301
    )]
302
    pub typos: Typos,
303
304
    /// Disable typo-tolerant matching
305
    #[cfg_attr(feature = "cli", arg(long, overrides_with = "typos", help_heading = "Search"))]
306
    pub no_typos: bool,
307
308
    /// Normalize unicode characters
309
    ///
310
    /// When set, normalize accents and other unicode diacritics/others
311
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
312
    pub normalize: bool,
313
314
    /// Enable split matching and set delimiter
315
    ///
316
    /// Split matching runs the matcher in splits: `foo:bar` will match all items matching `foo`, then
317
    /// `:`, then `bar` if the delimiter is present, or match normally if not.
318
    #[cfg_attr(
319
        feature = "cli",
320
        arg(
321
            long,
322
            default_missing_value = ":",
323
            help_heading = "Search",
324
            num_args=0..
325
        )
326
    )]
327
    pub split_match: Option<char>,
328
329
    /// Highlight the last match found, not the first one
330
    /// This makes tiebreak more pertinent on path items where we want to prioritize a match on the
331
    /// last parts
332
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
333
    pub last_match: bool,
334
335
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search", default_value = "default"))]
336
    scheme: Option<MatchScheme>,
337
338
    //  --- Interface ---
339
    /// Comma-separated key, event, and action bindings
340
    ///
341
    /// `--bind` takes comma-separated `<trigger>:<action>` expressions. A trigger can be a key, the
342
    /// `double-click` mouse binding, a finder event (`change`, `start`, `load`, `result`, `focus`, `zero`, or
343
    /// `one`), or an action name. Use the
344
    /// `act-` prefix for action triggers; it is recommended to avoid ambiguity and required when the action
345
    /// name is also a key, for example `act-up:last`. See the [KEYBINDS] section for details and its
346
    /// [Default key bindings] subsection for the defaults.
347
    ///
348
    /// **Example**: `sk --bind=ctrl-j:accept,load:last,act-up:down`
349
    ///
350
    /// ## Multiple actions can be chained using + separator.
351
    ///
352
    /// **Example**: `sk --bind 'ctrl-a:select-all+accept'`
353
    ///
354
    /// # Special behaviors
355
    ///
356
    /// With `execute(...)` and `reload(...)` action, you can execute arbitrary commands without leaving sk.
357
    /// For example, you can turn sk into a simple file browser by binding enter key to less command like follows:
358
    ///
359
    /// ```bash
360
    /// sk --bind "enter:execute(less {})"
361
    /// ```
362
    ///
363
    /// Note: if no argument is supplied to reload, the default command is run.
364
    ///
365
    /// You can use the same placeholder expressions as in --preview.
366
    ///
367
    /// `sk` switches to the alternate screen when executing a command. However, if the command is
368
    /// expected to complete quickly, and you are not interested in its output, you might want to use
369
    /// execute-silent instead, which silently executes the command without the  switching.  Note  that  sk
370
    /// will  not  be  responsive  until the command is complete. For asynchronous execution, start your
371
    /// command as a background process (i.e. appending `&`).
372
    ///
373
    /// With the `if-query-empty` and `if-query-not-empty` actions, you could specify the action to execute
374
    /// depending on the query condition. For example:
375
    ///
376
    /// `sk --bind 'ctrl-d:if-query-empty(abort)+delete-char'`
377
    ///
378
    /// If  the query is empty, skim will execute abort action, otherwise execute delete-char action. It
379
    /// is equal to 'delete-char/eof'.
380
    #[cfg_attr(
381
        feature = "cli",
382
        arg(short, long, help_heading = "Interface", verbatim_doc_comment, default_value = "", num_args=0..)
383
    )]
384
    pub bind: Vec<String>,
385
386
    /// Enable multiple selection
387
    ///
388
    /// Uses Tab and S-Tab by default for selection
389
    #[cfg_attr(
390
        feature = "cli",
391
        arg(short, long, overrides_with = "no_multi", help_heading = "Interface")
392
    )]
393
    pub multi: bool,
394
395
    /// Disable multiple selection
396
    #[cfg_attr(feature = "cli", arg(long, overrides_with = "multi", help_heading = "Interface"))]
397
    pub no_multi: bool,
398
399
    /// Disable mouse
400
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
401
    pub no_mouse: bool,
402
403
    /// Command to invoke dynamically in interactive mode
404
    ///
405
    /// Will be invoked using `sh -c` on Unix-like systems and `cmd /c` on Windows
406
    #[cfg_attr(feature = "cli", arg(short, long, help_heading = "Interface"))]
407
    pub cmd: Option<String>,
408
409
    /// Start skim in interactive mode
410
    ///
411
    /// In interactive mode, sk will run the command specified by `--cmd` option and display the
412
    /// results.
413
    #[cfg_attr(feature = "cli", arg(short, long, help_heading = "Interface"))]
414
    pub interactive: bool,
415
416
    /// Replace replstr with the selected item in commands
417
    #[cfg_attr(feature = "cli", arg(short = 'I', default_value = "{}", help_heading = "Interface"))]
418
    pub replstr: String,
419
420
    /// Set color theme
421
    ///
422
    /// Format: [BASE][,COLOR:ANSI[:ATTR1:ATTR2:..]]
423
    /// See [THEME] section for details
424
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface", verbatim_doc_comment))]
425
    pub color: Option<String>,
426
427
    /// Highlight the entire current line, not just the text
428
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface", verbatim_doc_comment))]
429
    pub highlight_line: bool,
430
431
    /// Disable horizontal scroll
432
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
433
    pub no_hscroll: bool,
434
435
    /// Keep the right end of the line visible on overflow
436
    ///
437
    /// Effective only when the query string is empty
438
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
439
    pub keep_right: bool,
440
441
    /// Show the matched pattern at the line start
442
    ///
443
    /// Line  will  start  with  the  start of the matched pattern. Effective only when the query
444
    /// string is empty. Was designed to skip showing starts of paths of rg/grep results.
445
    ///
446
    /// e.g. sk -i -c "rg {q} --color=always" --skip-to-pattern '[^/]*:' --ansi
447
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface", verbatim_doc_comment))]
448
    pub skip_to_pattern: Option<String>,
449
450
    /// Do not clear previous line if the command returns an empty result
451
    ///
452
    /// Do not clear previous items if new command returns empty result. This might be useful  to
453
    /// reduce flickering when typing new commands and the half-complete commands are not valid.
454
    ///
455
    /// This is not the default behavior because similar use cases for `grep` and `rg` have already been
456
    /// optimized where empty query results actually mean "empty" and previous results should be
457
    /// cleared.
458
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface", verbatim_doc_comment))]
459
    pub no_clear_if_empty: bool,
460
461
    /// Do not clear items on start
462
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
463
    pub no_clear_start: bool,
464
465
    /// Do not clear screen on exit
466
    ///
467
    /// Do not clear finder interface on exit. If skim was started in full screen mode, it will not switch back to the
468
    /// original  screen, so you'll have to manually run tput rmcup to return. This option can be used to avoid
469
    /// flickering of the screen when your application needs to start skim multiple times in order.
470
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
471
    pub no_clear: bool,
472
473
    /// Show error message if command fails
474
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
475
    pub show_cmd_error: bool,
476
477
    /// Cycle the results by wrapping around when scrolling
478
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
479
    pub cycle: bool,
480
481
    /// Disable matching entirely
482
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
483
    pub disabled: bool,
484
485
    /// Disable items based on this regex pattern
486
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
487
    pub disable_pattern: Option<Regex>,
488
489
    //  --- Layout ---
490
    /// Set layout
491
    ///
492
    #[cfg_attr(
493
        feature = "cli",
494
        arg(long, help_heading = "Layout", verbatim_doc_comment, default_value = "default")
495
    )]
496
    pub layout: TuiLayout,
497
498
    /// Shorthand for reverse layout
499
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Layout", overrides_with = "layout"))]
500
    pub reverse: bool,
501
502
    /// Height of skim's window
503
    ///
504
    /// Can either be a row count or a percentage
505
    /// A negative row count will use `term height` - `value` as height
506
    #[cfg_attr(
507
        feature = "cli",
508
        arg(long, default_value = "100%", help_heading = "Layout", allow_hyphen_values = true)
509
    )]
510
    pub height: String,
511
512
    /// Disable height (force full screen)
513
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Layout"))]
514
    pub no_height: bool,
515
516
    /// Minimum height of skim's window as a non-negative row count
517
    ///
518
    /// Must be a non-negative row count, not a percentage.
519
    /// Useful when the height is set as a percentage.
520
    /// Ignored when --height is not specified.
521
    #[cfg_attr(
522
        feature = "cli",
523
        arg(
524
            long,
525
            default_value = "10",
526
            help_heading = "Layout",
527
            allow_hyphen_values = true,
528
            value_parser = parse_min_height_value,
529
            verbatim_doc_comment
530
        )
531
    )]
532
    pub min_height: String,
533
534
    /// Screen margin
535
    ///
536
    /// For each side, can be either a row count or a percentage of the terminal size
537
    ///
538
    /// Format can be one of:
539
    ///     - TRBL
540
    ///     - TB,RL
541
    ///     - T,RL,B
542
    ///     - T,R,B,L
543
    /// Example: 1,10%
544
    #[cfg_attr(
545
        feature = "cli",
546
        arg(long, default_value = "0", help_heading = "Layout", verbatim_doc_comment)
547
    )]
548
    pub margin: String,
549
550
    /// Set prompt
551
    #[cfg_attr(feature = "cli", arg(long, short, default_value = "> ", help_heading = "Layout"))]
552
    pub prompt: String,
553
554
    /// Set prompt in command mode
555
    #[cfg_attr(feature = "cli", arg(long, default_value = "c> ", help_heading = "Layout"))]
556
    pub cmd_prompt: String,
557
558
    /// Set selected item icon
559
    #[cfg_attr(
560
        feature = "cli",
561
        arg(long = "selector", alias = "pointer", default_value = ">", help_heading = "Layout")
562
    )]
563
    pub selector_icon: String,
564
565
    /// Set multi-selected item icon
566
    #[cfg_attr(
567
        feature = "cli",
568
        arg(
569
            long = "multi-selector",
570
            alias = "marker",
571
            default_value = ">",
572
            help_heading = "Layout"
573
        )
574
    )]
575
    pub multi_select_icon: String,
576
577
    //  --- Display ---
578
    /// Parse ANSI color codes in input strings
579
    ///
580
    /// When using skim as a library, this has no effect and ansi parsing should
581
    /// be enabled by manually injecting a `cmd_collector` like so:
582
    /// ```rust
583
    /// use skim::prelude::*;
584
    ///
585
    /// let _options = SkimOptionsBuilder::default()
586
    ///   .cmd("ls --color")
587
    ///   .cmd_collector(Rc::new(RefCell::new(SkimItemReader::new(
588
    ///     SkimItemReaderOption::default().ansi(true),
589
    ///     ))) as Rc<RefCell<dyn CommandCollector>>)
590
    ///   .build()
591
    ///   .unwrap();
592
    /// ```
593
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
594
    pub ansi: bool,
595
596
    /// Number of spaces that make up a tab
597
    #[cfg_attr(feature = "cli", arg(long, default_value = "8", help_heading = "Display"))]
598
    pub tabstop: usize,
599
600
    /// The characters used to display truncated lines
601
    #[cfg_attr(
602
        feature = "cli",
603
        arg(long, hide = true, allow_hyphen_values = true, default_value = "...")
604
    )]
605
    pub ellipsis: String,
606
607
    /// Set matching result count display position
608
    ///
609
    ///   - hidden  do not display info
610
    ///   - inline[:SEP]  display info in the same row as the input with an optional non-default
611
    ///     separator
612
    ///   - default  display info in a dedicated row above the input
613
    ///   - left  display all info left-aligned in a dedicated row above the input
614
    ///   - right  display all info right-aligned in a dedicated row above the input
615
    ///   - inline-right[:SEP]  display info right-aligned in the same row as the input with an optional
616
    ///     non-default separator
617
    #[cfg_attr(
618
        feature = "cli",
619
        arg(long, help_heading = "Display", default_value = "default", verbatim_doc_comment)
620
    )]
621
    pub info: Info,
622
623
    /// Alias for --info=hidden
624
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
625
    pub no_info: bool,
626
627
    /// Alias for --info=inline
628
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
629
    pub inline_info: bool,
630
631
    /// Set header, displayed next to the info
632
    ///
633
    /// The  given  string  will  be printed as the sticky header. The lines are displayed in the
634
    /// given order from top to bottom regardless of --layout option, and  are  not  affected  by
635
    /// --with-nth. ANSI color codes are processed even when --ansi is not set.
636
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
637
    pub header: Option<String>,
638
639
    /// Number of lines of the input treated as header
640
    ///
641
    /// The  first N lines of the input are treated as the sticky header. When `--with-nth` is set,
642
    /// the lines are transformed just like the other lines that follow.
643
    #[cfg_attr(feature = "cli", arg(long, default_value = "0", help_heading = "Display"))]
644
    pub header_lines: usize,
645
646
    /// Draw borders around the UI components
647
    ///
648
    #[cfg_attr(
649
        feature = "cli",
650
        arg(long, default_missing_value = "plain", help_heading = "Display", default_value = "none", num_args=0.., value_parser = BorderValueParser)
651
    )]
652
    pub border: BorderType,
653
654
    /// Do not collapse adjacent borders into a shared row or column
655
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
656
    pub border_no_collapse: bool,
657
658
    /// Disables all borders, including in tmux/zellij popups
659
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display", overrides_with = "border"))]
660
    pub no_border: bool,
661
662
    /// Wrap items in the item list
663
    #[cfg_attr(feature = "cli", arg(long = "wrap", help_heading = "Display"))]
664
    pub wrap_items: bool,
665
666
    /// Split item text into multiple display lines at the given separator character
667
    /// defaults to `\n` if `read0` is set, and `\\n` if not (matching literal `\n` in text)
668
    ///
669
    /// Each item's text will be split on the separator and each part will be
670
    /// displayed as a separate line within that item's row.
671
    #[cfg_attr(
672
        feature = "cli",
673
        arg(
674
            long = "multiline",
675
            help_heading = "Display",
676
            num_args = 0..=1
677
        )
678
    )]
679
    pub multiline: Option<Option<String>>,
680
681
    /// Set scrollbar style for the item list
682
    ///
683
    /// The optional value is used as the indicator
684
    #[cfg_attr(
685
        feature = "cli",
686
        arg(
687
            long,
688
            help_heading = "Display",
689
            value_name = "THUMB",
690
            overrides_with = "no_scrollbar",
691
            default_value = "▐",
692
            verbatim_doc_comment
693
        )
694
    )]
695
    pub scrollbar: String,
696
    /// Disable the scrollbar in the item list
697
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
698
    pub no_scrollbar: bool,
699
700
    //  --- History ---
701
    /// History file
702
    ///
703
    /// Load search history from the specified file and update the file on completion.
704
    ///
705
    /// When enabled, CTRL-N and CTRL-P are automatically remapped
706
    /// to next-history and previous-history.
707
    #[cfg_attr(feature = "cli", arg(long = "history", help_heading = "History"))]
708
    pub history_file: Option<String>,
709
710
    /// Maximum number of query history entries to keep
711
    #[cfg_attr(feature = "cli", arg(long, default_value = "1000", help_heading = "History"))]
712
    pub history_size: usize,
713
714
    /// Command history file
715
    ///
716
    /// Load command query history from the specified file and update the file on completion.
717
    ///
718
    /// When enabled, CTRL-N and CTRL-P are automatically remapped
719
    /// to next-history and previous-history.
720
    #[cfg_attr(feature = "cli", arg(long = "cmd-history", help_heading = "History"))]
721
    pub cmd_history_file: Option<String>,
722
723
    /// Maximum number of query history entries to keep
724
    #[cfg_attr(feature = "cli", arg(long, default_value = "1000", help_heading = "History"))]
725
    pub cmd_history_size: usize,
726
727
    //  --- Preview ---
728
    /// Preview command
729
    ///
730
    /// Execute the given command with `sh -c` on linux and `cmd /c` on windows for the current line and display the result on the preview window.
731
    /// `{}` in the command is the placeholder that is replaced to the single-quoted string of the current line.
732
    /// To transform the replacement string, specify field index expressions between the braces (See FIELD INDEX EXPRESSION for the details).
733
    ///
734
    /// **Examples**:
735
    ///
736
    /// ```bash
737
    /// sk --preview='head -$LINES {}'
738
    /// ls -l | sk --preview="echo user={3} when={-4..-2}; cat {-1}" --header-lines=1
739
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Preview", verbatim_doc_comment))]
740
    pub preview: Option<String>,
741
742
    /// Preview window layout
743
    ///
744
    /// Format: [up|down|left|right][:SIZE][:hidden][:[no]wrap][:[no]pty][:+SCROLL[-OFFSET]]
745
    ///
746
    /// Determine  the  layout of the preview window. If the argument ends with: hidden, the preview window will be hidden by
747
    /// default until toggle-preview action is triggered. Long lines are truncated by default.
748
    /// Line wrap can be enabled with `:wrap` flag.
749
    /// For more interactive commands or previews that draw complex interfaces, the preview can use a PTY with the `:pty` flag.
750
    ///
751
    /// Note: the preview will run in a PTY (interactive session) on Linux and when `wrap` is unset
752
    ///
753
    /// SIZE can be either:
754
    ///     - `0`, which will hide the preview window
755
    ///     - A positive size (eg `20`)
756
    ///     - A percentage of the total size (eg `50%`)
757
    ///     - A negative size, which will set the size of everything but the preview to that value
758
    ///
759
    /// +SCROLL[-OFFSET] determines the initial scroll offset of the preview window. SCROLL can be either a numeric integer
760
    /// or a single-field index expression that refers to a numeric integer. The optional -OFFSET part is for adjusting the
761
    /// base offset so that you can see the text above it. It should be given as a numeric integer (-INTEGER), or as a
762
    /// denominator form (-/INTEGER) for specifying a fraction of the preview window height.
763
    ///
764
    /// **Examples**:
765
    /// ```bash
766
    /// # Non-default scroll window positions and sizes
767
    /// sk --preview="head {}" --preview-window=up:30%
768
    /// sk --preview="file {}" --preview-window=down:2
769
    ///
770
    /// # Initial scroll offset is set to the line number of each line of
771
    /// # git grep output *minus* 5 lines (-5)
772
    /// git grep --line-number '' |
773
    ///   sk --delimiter:  --preview 'nl {1}' --preview-window +{2}-5
774
    ///
775
    ///             # Preview with bat, matching line in the middle of the window (-/2)
776
    ///             git grep --line-number '' |
777
    ///               sk --delimiter : \
778
    ///                   --preview 'bat --style=numbers --color=always --highlight-line {2} {1}' \
779
    ///                   --preview-window +{2}-/2
780
    /// ```
781
    #[cfg_attr(
782
        feature = "cli",
783
        arg(
784
            long,
785
            default_value = "right:50%",
786
            help_heading = "Preview",
787
            allow_hyphen_values = true
788
        )
789
    )]
790
    pub preview_window: PreviewLayout,
791
792
    /// Enable image preview
793
    ///
794
    /// This will render the preview argument as an image instead of running it as a command.
795
    ///
796
    /// If set to `detect` or if no value is passed, it will try to detect the available image backends at startup, which will add a small
797
    /// delay before the first render.
798
    /// If set to `halfblocks`, it will always use the `halfblocks` rendering method
799
    ///
800
    /// Note: the backend detection **will not** work when piping data into skim, use
801
    /// `SKIM_DEFAULT_COMMAND="find . -type f" sk --image` instead of `find . -type f | sk --image`
802
    #[cfg(feature = "image")]
803
    #[cfg_attr(
804
        feature = "cli",
805
        arg(long, help_heading = "Preview", value_enum, default_missing_value = "detect", num_args=0..)
806
    )]
807
    pub image: Option<ImageProtocol>,
808
809
    /// Terminal image protocol picker, queried after entering the alternate screen.
810
    /// Built from `options.image` and an stdio detection if needed
811
    #[cfg(feature = "image")]
812
    #[cfg_attr(feature = "cli", clap(skip))]
813
    #[builder(setter(skip))]
814
    #[debug(skip)]
815
    pub image_picker: Option<Picker>,
816
817
    //  --- Scripting ---
818
    /// Initial query
819
    #[cfg_attr(feature = "cli", arg(long, short, help_heading = "Scripting"))]
820
    pub query: Option<String>,
821
822
    /// Initial query in interactive mode
823
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
824
    pub cmd_query: Option<String>,
825
826
    /// Read input delimited by ASCII NUL(\\0) characters
827
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
828
    pub read0: bool,
829
830
    /// Print output delimited by ASCII NUL(\\0) characters
831
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
832
    pub print0: bool,
833
834
    /// Print the query as the first line
835
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
836
    pub print_query: bool,
837
838
    /// Print the command as the first line (after print-query)
839
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
840
    pub print_cmd: bool,
841
842
    /// Print the score after each item
843
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
844
    pub print_score: bool,
845
846
    /// Print the header as the first line (after print-score)
847
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
848
    pub print_header: bool,
849
850
    /// Print the current (highlighted) item as the first line (after print-header)
851
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
852
    pub print_current: bool,
853
854
    /// Set the output format
855
    /// If set, overrides all `print_` options
856
    /// Will be expanded the same way as preview or commands
857
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
858
    pub output_format: Option<String>,
859
860
    /// Print the ANSI codes, making the output exactly match the input even when `--ansi` is on
861
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", requires = "ansi"))]
862
    pub no_strip_ansi: bool,
863
864
    /// Do not enter the TUI if the query passed in `-q` matches only one item and return it
865
    #[cfg_attr(feature = "cli", arg(long, short = '1', help_heading = "Scripting"))]
866
    pub select_1: bool,
867
868
    /// Do not enter the TUI if the query passed in `-q` does not match any item
869
    #[cfg_attr(feature = "cli", arg(long, short = '0', help_heading = "Scripting"))]
870
    pub exit_0: bool,
871
872
    /// Synchronous search for multi-staged filtering
873
    ///
874
    /// Synchronous search for multi-staged filtering. If specified,
875
    /// `skim` will launch the TUI finder only after the input stream is complete.
876
    /// e.g. `sk --multi | sk --sync`
877
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
878
    pub sync: bool,
879
880
    /// Pre-select the first n items in multi-selection mode
881
    #[cfg_attr(feature = "cli", arg(long, default_value = "0", help_heading = "Scripting"))]
882
    pub pre_select_n: usize,
883
884
    /// Pre-select the matched items in multi-selection mode
885
    ///
886
    /// Check the doc for the detailed syntax:
887
    /// <https://docs.rs/regex/1.4.1/regex>/
888
    #[cfg_attr(feature = "cli", arg(long, default_value = "", help_heading = "Scripting"))]
889
    pub pre_select_pat: String,
890
891
    /// Pre-select the items separated by newline character
892
    ///
893
    /// Example: 'item1\nitem2'
894
    #[cfg_attr(feature = "cli", arg(long, default_value = "", help_heading = "Scripting"))]
895
    pub pre_select_items: String,
896
897
    /// Pre-select the items read from this file
898
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
899
    pub pre_select_file: Option<String>,
900
901
    /// Query for filter mode
902
    #[cfg_attr(feature = "cli", arg(long, short, help_heading = "Scripting"))]
903
    pub filter: Option<String>,
904
905
    /// Generate shell completion script
906
    ///
907
    /// Generate completion script for the specified shell: bash, zsh, fish, etc.
908
    /// The output can be directly sourced or saved to a file for automatic loading.
909
    /// Examples: `source <(sk --shell bash)` (immediate use)
910
    ///          `sk --shell bash >> ~/.bash_completion` (persistent use)
911
    ///
912
    /// Supported shells: bash, zsh, fish, powershell, elvish
913
    #[cfg(feature = "cli")]
914
    #[cfg_attr(
915
        feature = "cli",
916
        arg(long, value_name = "SHELL", help_heading = "Scripting", value_enum)
917
    )]
918
    pub shell: Option<crate::shell::Shell>,
919
920
    /// Generate shell key bindings - only for bash, zsh and fish
921
    ///
922
    /// Generate key bindings script after the shell completions
923
    /// See the `shell` option for more details
924
    #[cfg(feature = "cli")]
925
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", requires = "shell"))]
926
    pub shell_bindings: bool,
927
928
    /// Generate man page and output it to stdout
929
    #[cfg(feature = "cli")]
930
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
931
    pub man: bool,
932
933
    /// Run an IPC socket with optional name (defaults to `sk`)
934
    ///
935
    /// The socket expects Actions in Ron format (similar to Rust code), see `./src/tui/event.rs` for all possible Actions
936
    /// To write to it, see the `--remote` option or the man page
937
    #[cfg(feature = "listen")]
938
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", default_missing_value = "sk", num_args=0..))]
939
    pub listen: Option<String>,
940
941
    /// Send commands to an IPC socket with optional name (defaults to `sk`)
942
    ///
943
    /// The commands are read from stdin, one per line, in the same format as the actions in the
944
    /// bind flag. They can also be chained using `+` as a separator.
945
    /// All other arguments will be ignored
946
    #[cfg(feature = "listen")]
947
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", default_missing_value = "sk", num_args=0..))]
948
    pub remote: Option<String>,
949
950
    /// Run in a tmux or zellij popup
951
    ///
952
    /// Format: `sk --popup <center|top|bottom|left|right>[,SIZE[%]][,SIZE[%]]`
953
    /// Note: this will try to detect a Zellij session, then a Tmux session
954
    /// This means that in nested sessions, `skim` will prioritize Zellij over Tmux
955
    #[cfg_attr(feature = "cli", arg(long, verbatim_doc_comment, help_heading = "Display", default_missing_value = "center,50%", num_args=0.., alias = "tmux"))]
956
    pub popup: Option<String>,
957
958
    /// Set the log level
959
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
960
    pub log_level: Option<log::LevelFilter>,
961
962
    /// Pipe log output to a file
963
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
964
    pub log_file: Option<String>,
965
966
    /// Feature flags
967
    #[cfg_attr(feature = "cli", arg(long, hide = true, help_heading = "Scripting"))]
968
    pub flags: Vec<FeatureFlag>,
969
970
    // FZF compatibility args
971
    #[cfg_attr(feature = "cli", arg(short = 'x', long, hide = true))]
972
    #[builder(setter(skip))]
973
    extended: bool,
974
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
975
    #[builder(setter(skip))]
976
    literal: bool,
977
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = "10"))]
978
    #[builder(setter(skip))]
979
    hscroll_off: usize,
980
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
981
    #[builder(setter(skip))]
982
    filepath_word: bool,
983
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = ""))]
984
    #[builder(setter(skip))]
985
    jump_labels: String,
986
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
987
    #[builder(setter(skip))]
988
    no_bold: bool,
989
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
990
    #[builder(setter(skip))]
991
    phony: bool,
992
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
993
    #[builder(setter(skip))]
994
    tail: Option<usize>,
995
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
996
    #[builder(setter(skip))]
997
    style: Option<String>,
998
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
999
    #[builder(setter(skip))]
1000
    no_color: bool,
1001
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1002
    #[builder(setter(skip))]
1003
    padding: Option<String>,
1004
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1005
    #[builder(setter(skip))]
1006
    border_label: Option<String>,
1007
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1008
    #[builder(setter(skip))]
1009
    border_label_pos: Option<String>,
1010
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1011
    #[builder(setter(skip))]
1012
    wrap_sign: Option<String>,
1013
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1014
    #[builder(setter(skip))]
1015
    no_multi_line: bool,
1016
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1017
    #[builder(setter(skip))]
1018
    raw: bool,
1019
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1020
    #[builder(setter(skip))]
1021
    track: bool,
1022
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1023
    #[builder(setter(skip))]
1024
    gap: Option<usize>,
1025
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1026
    #[builder(setter(skip))]
1027
    gap_line: Option<String>,
1028
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = "0"))]
1029
    #[builder(setter(skip))]
1030
    freeze_left: usize,
1031
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = "0"))]
1032
    #[builder(setter(skip))]
1033
    freeze_right: usize,
1034
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = "0"))]
1035
    #[builder(setter(skip))]
1036
    scroll_off: usize,
1037
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1038
    #[builder(setter(skip))]
1039
    gutter: Option<String>,
1040
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1041
    #[builder(setter(skip))]
1042
    gutter_raw: Option<String>,
1043
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1044
    #[builder(setter(skip))]
1045
    marker_multi_line: Option<String>,
1046
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1047
    #[builder(setter(skip))]
1048
    list_border: Option<String>,
1049
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1050
    #[builder(setter(skip))]
1051
    list_label: Option<String>,
1052
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1053
    #[builder(setter(skip))]
1054
    list_label_pos: Option<String>,
1055
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1056
    #[builder(setter(skip))]
1057
    no_input: bool,
1058
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1059
    #[builder(setter(skip))]
1060
    info_command: Option<String>,
1061
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1062
    #[builder(setter(skip))]
1063
    separator: Option<String>,
1064
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1065
    #[builder(setter(skip))]
1066
    no_separator: bool,
1067
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1068
    #[builder(setter(skip))]
1069
    ghost: Option<String>,
1070
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1071
    #[builder(setter(skip))]
1072
    input_border: Option<String>,
1073
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1074
    #[builder(setter(skip))]
1075
    input_label: Option<String>,
1076
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1077
    #[builder(setter(skip))]
1078
    input_label_pos: Option<String>,
1079
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1080
    #[builder(setter(skip))]
1081
    preview_label: Option<String>,
1082
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1083
    #[builder(setter(skip))]
1084
    preview_label_pos: Option<String>,
1085
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1086
    #[builder(setter(skip))]
1087
    header_first: bool,
1088
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1089
    #[builder(setter(skip))]
1090
    header_border: Option<String>,
1091
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1092
    #[builder(setter(skip))]
1093
    header_lines_border: Option<String>,
1094
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1095
    #[builder(setter(skip))]
1096
    footer: Option<String>,
1097
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1098
    #[builder(setter(skip))]
1099
    footer_border: Option<String>,
1100
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1101
    #[builder(setter(skip))]
1102
    footer_label: Option<String>,
1103
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1104
    #[builder(setter(skip))]
1105
    footer_label_pos: Option<String>,
1106
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1107
    #[builder(setter(skip))]
1108
    with_shell: Option<String>,
1109
1110
    /// Deprecated, kept for compatibility purposes. See `accept()` bind instead.
1111
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Deprecated", default_value = ""))]
1112
    expect: String,
1113
1114
    /// Command collector for reading items from commands
1115
    #[cfg_attr(feature = "cli", clap(skip = Rc::new(RefCell::new(SkimItemReader::default())) as Rc<RefCell<dyn CommandCollector>>))]
1116
    #[builder(setter(into = false))]
1117
    #[debug(skip)]
1118
    pub cmd_collector: Rc<RefCell<dyn CommandCollector>>,
1119
    /// Query history entries loaded from history file
1120
    #[cfg_attr(feature = "cli", clap(skip))]
1121
    pub query_history: Vec<String>,
1122
    /// Command history entries loaded from cmd history file
1123
    #[cfg_attr(feature = "cli", clap(skip))]
1124
    pub cmd_history: Vec<String>,
1125
    /// Selector for pre-selecting items
1126
    #[cfg_attr(feature = "cli", clap(skip))]
1127
    #[builder(setter(into = false))]
1128
    #[debug(skip)]
1129
    pub selector: Option<Rc<dyn Selector>>,
1130
    /// Preview Callback
1131
    ///
1132
    /// Used to define a function or closure for the preview window, instead of a shell command.
1133
    ///
1134
    /// The function will take a `Vec<Arc<dyn SkimItem>>>` containing the currently selected items
1135
    /// and return a Vec<String> with the lines to display in UTF-8
1136
    #[cfg_attr(feature = "cli", clap(skip))]
1137
    #[debug(skip)]
1138
    pub preview_fn: Option<PreviewCallback>,
1139
1140
    /// The internal (parsed) keymap
1141
    #[cfg_attr(feature = "cli", clap(skip))]
1142
    pub keymap: KeyMap,
1143
1144
    /// Follow-up action bindings, keyed by the canonical action name.
1145
    ///
1146
    /// Populated from `--bind` entries whose "key" is an action name rather than
1147
    /// a real key (e.g. `reload:first`). After an action runs, the chain bound to
1148
    /// its name is queued.
1149
    #[cfg_attr(feature = "cli", clap(skip))]
1150
    pub action_binds: std::collections::HashMap<String, Vec<Action>>,
1151
}
1152
1153
impl Default for SkimOptions {
1154
    #[allow(clippy::too_many_lines)]
1155
402
    fn default() -> Self {
1156
402
        Self {
1157
402
            split_match: None,
1158
402
            no_strip_ansi: false,
1159
402
            wrap_items: false,
1160
402
            multiline: None,
1161
402
            #[cfg(feature = "listen")]
1162
402
            listen: None,
1163
402
            #[cfg(feature = "listen")]
1164
402
            remote: None,
1165
402
            print_header: false,
1166
402
            print_current: false,
1167
402
            disabled: false,
1168
402
            disable_pattern: None,
1169
402
            tac: Default::default(),
1170
402
            min_query_length: Default::default(),
1171
402
            no_sort: Default::default(),
1172
402
            tiebreak: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End],
1173
402
            nth: Default::default(),
1174
402
            with_nth: Default::default(),
1175
402
            hide_nth: Default::default(),
1176
402
            delimiter: Regex::new(r"[\t\n ]+").unwrap(),
1177
402
            exact: Default::default(),
1178
402
            regex: Default::default(),
1179
402
            algorithm: Default::default(),
1180
402
            case: Default::default(),
1181
402
            typos: Typos::Disabled,
1182
402
            no_typos: false,
1183
402
            normalize: false,
1184
402
            last_match: false,
1185
402
            bind: Default::default(),
1186
402
            multi: Default::default(),
1187
402
            no_multi: Default::default(),
1188
402
            no_mouse: Default::default(),
1189
402
            cmd: Default::default(),
1190
402
            interactive: Default::default(),
1191
402
            replstr: String::from("{}"),
1192
402
            color: Default::default(),
1193
402
            no_hscroll: Default::default(),
1194
402
            keep_right: Default::default(),
1195
402
            skip_to_pattern: Default::default(),
1196
402
            no_clear_if_empty: Default::default(),
1197
402
            no_clear_start: Default::default(),
1198
402
            no_clear: Default::default(),
1199
402
            show_cmd_error: Default::default(),
1200
402
            layout: TuiLayout::default(),
1201
402
            reverse: Default::default(),
1202
402
            height: String::from("100%"),
1203
402
            no_height: Default::default(),
1204
402
            min_height: String::from("10"),
1205
402
            margin: Default::default(),
1206
402
            prompt: String::from("> "),
1207
402
            cmd_prompt: String::from("c> "),
1208
402
            selector_icon: String::from(">"),
1209
402
            multi_select_icon: String::from(">"),
1210
402
            ansi: Default::default(),
1211
402
            tabstop: 8,
1212
402
            info: Default::default(),
1213
402
            no_info: Default::default(),
1214
402
            inline_info: Default::default(),
1215
402
            header: Default::default(),
1216
402
            header_lines: Default::default(),
1217
402
            history_file: Default::default(),
1218
402
            history_size: 1000,
1219
402
            cmd_history_file: Default::default(),
1220
402
            cmd_history_size: 1000,
1221
402
            preview: Default::default(),
1222
402
            preview_window: PreviewLayout::default(),
1223
402
            #[cfg(feature = "image")]
1224
402
            image: None,
1225
402
            #[cfg(feature = "image")]
1226
402
            image_picker: None,
1227
402
            query: Default::default(),
1228
402
            cmd_query: Default::default(),
1229
402
            read0: Default::default(),
1230
402
            print0: Default::default(),
1231
402
            print_query: Default::default(),
1232
402
            print_cmd: Default::default(),
1233
402
            print_score: Default::default(),
1234
402
            output_format: Default::default(),
1235
402
            select_1: Default::default(),
1236
402
            exit_0: Default::default(),
1237
402
            sync: Default::default(),
1238
402
            pre_select_n: Default::default(),
1239
402
            pre_select_pat: Default::default(),
1240
402
            pre_select_items: Default::default(),
1241
402
            pre_select_file: Default::default(),
1242
402
            filter: Default::default(),
1243
402
            popup: Default::default(),
1244
402
            log_file: Default::default(),
1245
402
            extended: Default::default(),
1246
402
            literal: Default::default(),
1247
402
            cycle: Default::default(),
1248
402
            hscroll_off: 10,
1249
402
            filepath_word: Default::default(),
1250
402
            jump_labels: String::from("abcdefghijklmnopqrstuvwxyz"),
1251
402
            border: Default::default(),
1252
402
            border_no_collapse: Default::default(),
1253
402
            no_bold: Default::default(),
1254
402
            phony: Default::default(),
1255
402
            scheme: Default::default(),
1256
402
            tail: Default::default(),
1257
402
            style: Default::default(),
1258
402
            no_color: Default::default(),
1259
402
            padding: Default::default(),
1260
402
            border_label: Default::default(),
1261
402
            border_label_pos: Default::default(),
1262
402
            highlight_line: Default::default(),
1263
402
            wrap_sign: Default::default(),
1264
402
            no_multi_line: Default::default(),
1265
402
            raw: Default::default(),
1266
402
            track: Default::default(),
1267
402
            gap: Default::default(),
1268
402
            gap_line: Default::default(),
1269
402
            freeze_left: Default::default(),
1270
402
            freeze_right: Default::default(),
1271
402
            scroll_off: Default::default(),
1272
402
            gutter: Default::default(),
1273
402
            gutter_raw: Default::default(),
1274
402
            marker_multi_line: Default::default(),
1275
402
            ellipsis: Default::default(),
1276
402
            scrollbar: Default::default(),
1277
402
            no_scrollbar: Default::default(),
1278
402
            list_border: Default::default(),
1279
402
            list_label: Default::default(),
1280
402
            list_label_pos: Default::default(),
1281
402
            no_input: Default::default(),
1282
402
            info_command: Default::default(),
1283
402
            separator: Default::default(),
1284
402
            no_separator: Default::default(),
1285
402
            ghost: Default::default(),
1286
402
            input_border: Default::default(),
1287
402
            input_label: Default::default(),
1288
402
            input_label_pos: Default::default(),
1289
402
            preview_label: Default::default(),
1290
402
            preview_label_pos: Default::default(),
1291
402
            header_first: Default::default(),
1292
402
            header_border: Default::default(),
1293
402
            header_lines_border: Default::default(),
1294
402
            footer: Default::default(),
1295
402
            footer_border: Default::default(),
1296
402
            footer_label: Default::default(),
1297
402
            footer_label_pos: Default::default(),
1298
402
            with_shell: Default::default(),
1299
402
            expect: Default::default(),
1300
402
            cmd_collector: Rc::new(RefCell::new(SkimItemReader::default())) as Rc<RefCell<dyn CommandCollector>>,
1301
402
            query_history: Default::default(),
1302
402
            cmd_history: Default::default(),
1303
402
            selector: Default::default(),
1304
402
            preview_fn: Default::default(),
1305
402
            keymap: Default::default(),
1306
402
            action_binds: Default::default(),
1307
402
            #[cfg(feature = "cli")]
1308
402
            shell: Default::default(),
1309
402
            #[cfg(feature = "cli")]
1310
402
            man: false,
1311
402
            #[cfg(feature = "cli")]
1312
402
            shell_bindings: false,
1313
402
            flags: Default::default(),
1314
402
            log_level: Default::default(),
1315
402
            no_border: false,
1316
402
        }
1317
402
    }
1318
}
1319
1320
impl SkimOptionsBuilder {
1321
    /// Builds the `SkimOptions` from the builder
1322
    ///
1323
    /// # Errors
1324
    ///
1325
    /// Returns an error if any required fields are missing.
1326
67
    pub fn build(&mut self) -> Result<SkimOptions, SkimOptionsBuilderError> {
1327
67
        self.final_build().map(SkimOptions::build)
1328
67
    }
1329
}
1330
1331
impl SkimOptions {
1332
    /// Finalizes the options by applying defaults and initializing components
1333
    #[must_use]
1334
566
    pub fn build(mut self) -> Self {
1335
566
        if self.no_height {
  Branch (1335:12): [True: 0, False: 469]
+
88
473
        Ok(Typos::Disabled)
89
    } else {
90
0
        s.parse::<usize>()
91
0
            .map(Typos::from)
92
0
            .map_err(|_| format!("Invalid typos value '{s}': expected 'smart', 'disabled' or a non-negative integer"))
93
    }
94
478
}
95
96
/// The options for `--scheme`
97
#[derive(Debug, Clone, Default, PartialEq, Eq)]
98
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
99
pub enum MatchScheme {
100
    /// Default scheme, no modifications to the options
101
    #[default]
102
    Default,
103
    /// Path scheme: will find the furthest match in the item and set `pathname` as the main
104
    /// tiebreak
105
    Path,
106
    /// History scheme: will force `index` as the first tiebreak
107
    History,
108
}
109
110
/// Image rendering protocols
111
#[cfg(feature = "image")]
112
#[derive(Default, Debug, Clone, PartialEq, Eq)]
113
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
114
pub enum ImageProtocol {
115
    /// Default: automatically detect the available backend at startup
116
    #[default]
117
    Detect,
118
    /// Force halfblocks if you want blurry previews but a faster startup or if the detection fails
119
    Halfblocks,
120
}
121
122
/// sk - fuzzy finder in Rust
123
///
124
/// sk is a general purpose command-line fuzzy finder.
125
#[allow(missing_docs, clippy::struct_excessive_bools)] // derive_builder seems to have issues with doc comments ?
126
#[derive(Builder)]
127
#[builder(build_fn(name = "final_build"), setter(into, strip_option))]
128
#[builder(default)]
129
#[cfg_attr(feature = "cli", derive(clap::Parser))]
130
#[cfg_attr(
131
    feature = "cli",
132
    command(name = "sk", args_override_self = true, verbatim_doc_comment, version, about)
133
)]
134
#[derive(derive_more::Debug)]
135
pub struct SkimOptions {
136
    //  --- Search ---
137
    /// Show results in reverse order
138
    ///
139
    /// Often used in combination with --no-sort
140
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
141
    pub tac: bool,
142
143
    /// Minimum query length to start showing results
144
    ///
145
    /// Only show results when the query is at least this many characters long
146
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
147
    pub min_query_length: Option<usize>,
148
149
    /// Do not sort the results
150
    ///
151
    /// Often used in combination with --tac
152
    /// Example: `history | sk --tac --no-sort`
153
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
154
    pub no_sort: bool,
155
156
    /// Comma-separated list of sort criteria to apply when the scores are tied.
157
    ///
158
    /// * **score**: Score of the fuzzy match algorithm
159
    ///
160
    ///     - Each criterion could be negated, e.g. (-index)
161
    ///     - Each criterion should appear only once in the list
162
    #[cfg_attr(
163
        feature = "cli",
164
        arg(
165
            short,
166
            long,
167
            default_value = "score,begin,end",
168
            value_enum,
169
            value_delimiter = ',',
170
            help_heading = "Search",
171
            allow_hyphen_values = true,
172
            verbatim_doc_comment
173
        )
174
    )]
175
    pub tiebreak: Vec<RankCriteria>,
176
177
    /// Fields to be matched
178
    ///
179
    /// A field index expression can be a non-zero integer or a range expression (`[BEGIN]..[END]`).
180
    /// `--nth` and `--with-nth` take a comma-separated list of field index expressions.
181
    ///
182
    /// **Examples:**
183
    ///     1      The 1st field
184
    ///     2      The 2nd field
185
    ///     -1     The last field
186
    ///     -2     The 2nd to last field
187
    ///     3..5   From the 3rd field to the 5th field
188
    ///     2..    From the 2nd field to the last field
189
    ///     ..-3   From the 1st field to the 3rd to the last field
190
    ///     ..     All the fields
191
    #[cfg_attr(
192
        feature = "cli",
193
        arg(
194
            short,
195
            long,
196
            default_value = "",
197
            help_heading = "Search",
198
            verbatim_doc_comment,
199
            value_delimiter = ',',
200
            allow_hyphen_values = true,
201
        )
202
    )]
203
    pub nth: Vec<String>,
204
205
    /// Fields to be transformed
206
    ///
207
    /// See **nth** for the details
208
    #[cfg_attr(
209
        feature = "cli",
210
        arg(
211
            long,
212
            default_value = "",
213
            help_heading = "Search",
214
            value_delimiter = ',',
215
            allow_hyphen_values = true,
216
        )
217
    )]
218
    pub with_nth: Vec<String>,
219
220
    /// Fields to hide from display while keeping them searchable
221
    ///
222
    /// Takes the same comma-separated field index expressions as **nth**. The listed
223
    /// fields are removed from the displayed line but remain part of the text used for
224
    /// matching, so a query can still match them. Characters in the hidden fields are
225
    /// ignored for match highlighting and horizontal scrolling.
226
    ///
227
    /// See **nth** for the field index expression syntax.
228
    #[cfg_attr(
229
        feature = "cli",
230
        arg(
231
            long,
232
            default_value = "",
233
            help_heading = "Search",
234
            verbatim_doc_comment,
235
            value_delimiter = ',',
236
            allow_hyphen_values = true,
237
        )
238
    )]
239
    pub hide_nth: Vec<String>,
240
241
    /// Delimiter between fields
242
    ///
243
    /// In regex format, defaults to AWK-style. Escape sequences like \x00, \t, \n are supported.
244
    #[cfg_attr(
245
        feature = "cli",
246
        arg(short, long, default_value = r"[\t\n ]+", value_parser = parse_delimiter_value, help_heading = "Search")
247
    )]
248
    pub delimiter: Regex,
249
250
    /// Run in exact mode
251
    #[cfg_attr(feature = "cli", arg(short, long, help_heading = "Search"))]
252
    pub exact: bool,
253
254
    /// Start in regex mode instead of fuzzy-match
255
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
256
    pub regex: bool,
257
258
    /// Fuzzy matching algorithm
259
    ///
260
    /// - arinae (ari) Latest algorithm
261
    ///
262
    /// - `skim_v2` Legacy skim algorithm
263
    ///
264
    /// - clangd  Used in clangd for keyword completion
265
    ///
266
    /// - fzy     Algorithm from fzy (<https://github.com/jhawthorn/fzy>)
267
    ///
268
    /// - frizbee Algorithm used in the blink.cmp neovim plugin
269
    #[cfg_attr(
270
        feature = "cli",
271
        arg(
272
            long = "algo",
273
            value_enum,
274
            default_value = "arinae",
275
            help_heading = "Search",
276
            verbatim_doc_comment
277
        )
278
    )]
279
    pub algorithm: FuzzyAlgorithm,
280
281
    /// Case sensitivity
282
    ///
283
    /// Determines whether or not to ignore case while matching
284
    /// Note: this is not used for the Frizbee matcher, which uses a penalty system to favor
285
    /// case-sensitivity without enforcing it
286
    #[cfg_attr(
287
        feature = "cli",
288
        arg(long, default_value = "smart", value_enum, help_heading = "Search")
289
    )]
290
    pub case: CaseMatching,
291
292
    /// Enable typo-tolerant matching
293
    ///
294
    /// When passed without a value (`--typos`), uses adaptive formula (`pattern_length` / 4).
295
    /// When passed with a value (e.g. `--typos=2`), uses that exact number as the
296
    /// maximum allowed typos. `--typos=0` explicitly disables typo tolerance.
297
    /// Applies to both fzy and frizbee matchers.
298
    #[cfg_attr(
299
        feature = "cli",
300
        arg(long, default_value = "disabled", default_missing_value = "smart", num_args = 0..=1, value_parser = parse_typos, overrides_with = "no_typos", help_heading = "Search")
301
    )]
302
    pub typos: Typos,
303
304
    /// Disable typo-tolerant matching
305
    #[cfg_attr(feature = "cli", arg(long, overrides_with = "typos", help_heading = "Search"))]
306
    pub no_typos: bool,
307
308
    /// Normalize unicode characters
309
    ///
310
    /// When set, normalize accents and other unicode diacritics/others
311
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
312
    pub normalize: bool,
313
314
    /// Enable split matching and set delimiter
315
    ///
316
    /// Split matching runs the matcher in splits: `foo:bar` will match all items matching `foo`, then
317
    /// `:`, then `bar` if the delimiter is present, or match normally if not.
318
    #[cfg_attr(
319
        feature = "cli",
320
        arg(
321
            long,
322
            default_missing_value = ":",
323
            help_heading = "Search",
324
            num_args=0..
325
        )
326
    )]
327
    pub split_match: Option<char>,
328
329
    /// Highlight the last match found, not the first one
330
    /// This makes tiebreak more pertinent on path items where we want to prioritize a match on the
331
    /// last parts
332
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search"))]
333
    pub last_match: bool,
334
335
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Search", default_value = "default"))]
336
    scheme: Option<MatchScheme>,
337
338
    //  --- Interface ---
339
    /// Comma-separated key, event, and action bindings
340
    ///
341
    /// `--bind` takes comma-separated `<trigger>:<action>` expressions. A trigger can be a key, the
342
    /// `double-click` mouse binding, a finder event (`change`, `start`, `load`, `result`, `focus`, `zero`, or
343
    /// `one`), or an action name. Use the
344
    /// `act-` prefix for action triggers; it is recommended to avoid ambiguity and required when the action
345
    /// name is also a key, for example `act-up:last`. See the [KEYBINDS] section for details and its
346
    /// [Default key bindings] subsection for the defaults.
347
    ///
348
    /// **Example**: `sk --bind=ctrl-j:accept,load:last,act-up:down`
349
    ///
350
    /// ## Multiple actions can be chained using + separator.
351
    ///
352
    /// **Example**: `sk --bind 'ctrl-a:select-all+accept'`
353
    ///
354
    /// # Special behaviors
355
    ///
356
    /// With `execute(...)` and `reload(...)` action, you can execute arbitrary commands without leaving sk.
357
    /// For example, you can turn sk into a simple file browser by binding enter key to less command like follows:
358
    ///
359
    /// ```bash
360
    /// sk --bind "enter:execute(less {})"
361
    /// ```
362
    ///
363
    /// Note: if no argument is supplied to reload, the default command is run.
364
    ///
365
    /// You can use the same placeholder expressions as in --preview.
366
    ///
367
    /// `sk` switches to the alternate screen when executing a command. However, if the command is
368
    /// expected to complete quickly, and you are not interested in its output, you might want to use
369
    /// execute-silent instead, which silently executes the command without the  switching.  Note  that  sk
370
    /// will  not  be  responsive  until the command is complete. For asynchronous execution, start your
371
    /// command as a background process (i.e. appending `&`).
372
    ///
373
    /// With the `if-query-empty` and `if-query-not-empty` actions, you could specify the action to execute
374
    /// depending on the query condition. For example:
375
    ///
376
    /// `sk --bind 'ctrl-d:if-query-empty(abort)+delete-char'`
377
    ///
378
    /// If  the query is empty, skim will execute abort action, otherwise execute delete-char action. It
379
    /// is equal to 'delete-char/eof'.
380
    #[cfg_attr(
381
        feature = "cli",
382
        arg(short, long, help_heading = "Interface", verbatim_doc_comment, default_value = "", num_args=0..)
383
    )]
384
    pub bind: Vec<String>,
385
386
    /// Enable multiple selection
387
    ///
388
    /// Uses Tab and S-Tab by default for selection
389
    #[cfg_attr(
390
        feature = "cli",
391
        arg(short, long, overrides_with = "no_multi", help_heading = "Interface")
392
    )]
393
    pub multi: bool,
394
395
    /// Disable multiple selection
396
    #[cfg_attr(feature = "cli", arg(long, overrides_with = "multi", help_heading = "Interface"))]
397
    pub no_multi: bool,
398
399
    /// Disable mouse
400
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
401
    pub no_mouse: bool,
402
403
    /// Command to invoke dynamically in interactive mode
404
    ///
405
    /// Will be invoked using `sh -c` on Unix-like systems and `cmd /c` on Windows
406
    #[cfg_attr(feature = "cli", arg(short, long, help_heading = "Interface"))]
407
    pub cmd: Option<String>,
408
409
    /// Start skim in interactive mode
410
    ///
411
    /// In interactive mode, sk will run the command specified by `--cmd` option and display the
412
    /// results.
413
    #[cfg_attr(feature = "cli", arg(short, long, help_heading = "Interface"))]
414
    pub interactive: bool,
415
416
    /// Replace replstr with the selected item in commands
417
    #[cfg_attr(feature = "cli", arg(short = 'I', default_value = "{}", help_heading = "Interface"))]
418
    pub replstr: String,
419
420
    /// Set color theme
421
    ///
422
    /// Format: [BASE][,COLOR:ANSI[:ATTR1:ATTR2:..]]
423
    /// See [THEME] section for details
424
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface", verbatim_doc_comment))]
425
    pub color: Option<String>,
426
427
    /// Highlight the entire current line, not just the text
428
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface", verbatim_doc_comment))]
429
    pub highlight_line: bool,
430
431
    /// Disable horizontal scroll
432
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
433
    pub no_hscroll: bool,
434
435
    /// Keep the right end of the line visible on overflow
436
    ///
437
    /// Effective only when the query string is empty
438
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
439
    pub keep_right: bool,
440
441
    /// Show the matched pattern at the line start
442
    ///
443
    /// Line  will  start  with  the  start of the matched pattern. Effective only when the query
444
    /// string is empty. Was designed to skip showing starts of paths of rg/grep results.
445
    ///
446
    /// e.g. sk -i -c "rg {q} --color=always" --skip-to-pattern '[^/]*:' --ansi
447
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface", verbatim_doc_comment))]
448
    pub skip_to_pattern: Option<String>,
449
450
    /// Do not clear previous line if the command returns an empty result
451
    ///
452
    /// Do not clear previous items if new command returns empty result. This might be useful  to
453
    /// reduce flickering when typing new commands and the half-complete commands are not valid.
454
    ///
455
    /// This is not the default behavior because similar use cases for `grep` and `rg` have already been
456
    /// optimized where empty query results actually mean "empty" and previous results should be
457
    /// cleared.
458
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface", verbatim_doc_comment))]
459
    pub no_clear_if_empty: bool,
460
461
    /// Do not clear items on start
462
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
463
    pub no_clear_start: bool,
464
465
    /// Do not clear screen on exit
466
    ///
467
    /// Do not clear finder interface on exit. If skim was started in full screen mode, it will not switch back to the
468
    /// original  screen, so you'll have to manually run tput rmcup to return. This option can be used to avoid
469
    /// flickering of the screen when your application needs to start skim multiple times in order.
470
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
471
    pub no_clear: bool,
472
473
    /// Show error message if command fails
474
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
475
    pub show_cmd_error: bool,
476
477
    /// Cycle the results by wrapping around when scrolling
478
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
479
    pub cycle: bool,
480
481
    /// Disable matching entirely
482
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
483
    pub disabled: bool,
484
485
    /// Disable items based on this regex pattern
486
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Interface"))]
487
    pub disable_pattern: Option<Regex>,
488
489
    //  --- Layout ---
490
    /// Set layout
491
    ///
492
    #[cfg_attr(
493
        feature = "cli",
494
        arg(long, help_heading = "Layout", verbatim_doc_comment, default_value = "default")
495
    )]
496
    pub layout: TuiLayout,
497
498
    /// Shorthand for reverse layout
499
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Layout", overrides_with = "layout"))]
500
    pub reverse: bool,
501
502
    /// Height of skim's window
503
    ///
504
    /// Can either be a row count or a percentage
505
    /// A negative row count will use `term height` - `value` as height
506
    #[cfg_attr(
507
        feature = "cli",
508
        arg(long, default_value = "100%", help_heading = "Layout", allow_hyphen_values = true)
509
    )]
510
    pub height: String,
511
512
    /// Disable height (force full screen)
513
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Layout"))]
514
    pub no_height: bool,
515
516
    /// Minimum height of skim's window as a non-negative row count
517
    ///
518
    /// Must be a non-negative row count, not a percentage.
519
    /// Useful when the height is set as a percentage.
520
    /// Ignored when --height is not specified.
521
    #[cfg_attr(
522
        feature = "cli",
523
        arg(
524
            long,
525
            default_value = "10",
526
            help_heading = "Layout",
527
            allow_hyphen_values = true,
528
            value_parser = parse_min_height_value,
529
            verbatim_doc_comment
530
        )
531
    )]
532
    pub min_height: String,
533
534
    /// Screen margin
535
    ///
536
    /// For each side, can be either a row count or a percentage of the terminal size
537
    ///
538
    /// Format can be one of:
539
    ///     - TRBL
540
    ///     - TB,RL
541
    ///     - T,RL,B
542
    ///     - T,R,B,L
543
    /// Example: 1,10%
544
    #[cfg_attr(
545
        feature = "cli",
546
        arg(long, default_value = "0", help_heading = "Layout", verbatim_doc_comment)
547
    )]
548
    pub margin: String,
549
550
    /// Set prompt
551
    #[cfg_attr(feature = "cli", arg(long, short, default_value = "> ", help_heading = "Layout"))]
552
    pub prompt: String,
553
554
    /// Set prompt in command mode
555
    #[cfg_attr(feature = "cli", arg(long, default_value = "c> ", help_heading = "Layout"))]
556
    pub cmd_prompt: String,
557
558
    /// Set selected item icon
559
    #[cfg_attr(
560
        feature = "cli",
561
        arg(long = "selector", alias = "pointer", default_value = ">", help_heading = "Layout")
562
    )]
563
    pub selector_icon: String,
564
565
    /// Set multi-selected item icon
566
    #[cfg_attr(
567
        feature = "cli",
568
        arg(
569
            long = "multi-selector",
570
            alias = "marker",
571
            default_value = ">",
572
            help_heading = "Layout"
573
        )
574
    )]
575
    pub multi_select_icon: String,
576
577
    //  --- Display ---
578
    /// Parse ANSI color codes in input strings
579
    ///
580
    /// When using skim as a library, this has no effect and ansi parsing should
581
    /// be enabled by manually injecting a `cmd_collector` like so:
582
    /// ```rust
583
    /// use skim::prelude::*;
584
    ///
585
    /// let _options = SkimOptionsBuilder::default()
586
    ///   .cmd("ls --color")
587
    ///   .cmd_collector(Rc::new(RefCell::new(SkimItemReader::new(
588
    ///     SkimItemReaderOption::default().ansi(true),
589
    ///     ))) as Rc<RefCell<dyn CommandCollector>>)
590
    ///   .build()
591
    ///   .unwrap();
592
    /// ```
593
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
594
    pub ansi: bool,
595
596
    /// Number of spaces that make up a tab
597
    #[cfg_attr(feature = "cli", arg(long, default_value = "8", help_heading = "Display"))]
598
    pub tabstop: usize,
599
600
    /// The characters used to display truncated lines
601
    #[cfg_attr(
602
        feature = "cli",
603
        arg(long, hide = true, allow_hyphen_values = true, default_value = "...")
604
    )]
605
    pub ellipsis: String,
606
607
    /// Set matching result count display position
608
    ///
609
    ///   - hidden  do not display info
610
    ///   - inline[:SEP]  display info in the same row as the input with an optional non-default
611
    ///     separator
612
    ///   - default  display info in a dedicated row above the input
613
    ///   - left  display all info left-aligned in a dedicated row above the input
614
    ///   - right  display all info right-aligned in a dedicated row above the input
615
    ///   - inline-right[:SEP]  display info right-aligned in the same row as the input with an optional
616
    ///     non-default separator
617
    #[cfg_attr(
618
        feature = "cli",
619
        arg(long, help_heading = "Display", default_value = "default", verbatim_doc_comment)
620
    )]
621
    pub info: Info,
622
623
    /// Alias for --info=hidden
624
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
625
    pub no_info: bool,
626
627
    /// Alias for --info=inline
628
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
629
    pub inline_info: bool,
630
631
    /// Set header, displayed next to the info
632
    ///
633
    /// The  given  string  will  be printed as the sticky header. The lines are displayed in the
634
    /// given order from top to bottom regardless of --layout option, and  are  not  affected  by
635
    /// --with-nth. ANSI color codes are processed even when --ansi is not set.
636
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
637
    pub header: Option<String>,
638
639
    /// Number of lines of the input treated as header
640
    ///
641
    /// The  first N lines of the input are treated as the sticky header. When `--with-nth` is set,
642
    /// the lines are transformed just like the other lines that follow.
643
    #[cfg_attr(feature = "cli", arg(long, default_value = "0", help_heading = "Display"))]
644
    pub header_lines: usize,
645
646
    /// Draw borders around the UI components
647
    ///
648
    #[cfg_attr(
649
        feature = "cli",
650
        arg(long, default_missing_value = "plain", help_heading = "Display", default_value = "none", num_args=0.., value_parser = BorderValueParser)
651
    )]
652
    pub border: BorderType,
653
654
    /// Do not collapse adjacent borders into a shared row or column
655
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
656
    pub border_no_collapse: bool,
657
658
    /// Disables all borders, including in tmux/zellij popups
659
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display", overrides_with = "border"))]
660
    pub no_border: bool,
661
662
    /// Wrap items in the item list
663
    #[cfg_attr(feature = "cli", arg(long = "wrap", help_heading = "Display"))]
664
    pub wrap_items: bool,
665
666
    /// Split item text into multiple display lines at the given separator character
667
    /// defaults to `\n` if `read0` is set, and `\\n` if not (matching literal `\n` in text)
668
    ///
669
    /// Each item's text will be split on the separator and each part will be
670
    /// displayed as a separate line within that item's row.
671
    #[cfg_attr(
672
        feature = "cli",
673
        arg(
674
            long = "multiline",
675
            help_heading = "Display",
676
            num_args = 0..=1
677
        )
678
    )]
679
    pub multiline: Option<Option<String>>,
680
681
    /// Set scrollbar style for the item list
682
    ///
683
    /// The optional value is used as the indicator
684
    #[cfg_attr(
685
        feature = "cli",
686
        arg(
687
            long,
688
            help_heading = "Display",
689
            value_name = "THUMB",
690
            overrides_with = "no_scrollbar",
691
            default_value = "▐",
692
            verbatim_doc_comment
693
        )
694
    )]
695
    pub scrollbar: String,
696
    /// Disable the scrollbar in the item list
697
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Display"))]
698
    pub no_scrollbar: bool,
699
700
    //  --- History ---
701
    /// History file
702
    ///
703
    /// Load search history from the specified file and update the file on completion.
704
    ///
705
    /// When enabled, CTRL-N and CTRL-P are automatically remapped
706
    /// to next-history and previous-history.
707
    #[cfg_attr(feature = "cli", arg(long = "history", help_heading = "History"))]
708
    pub history_file: Option<String>,
709
710
    /// Maximum number of query history entries to keep
711
    #[cfg_attr(feature = "cli", arg(long, default_value = "1000", help_heading = "History"))]
712
    pub history_size: usize,
713
714
    /// Command history file
715
    ///
716
    /// Load command query history from the specified file and update the file on completion.
717
    ///
718
    /// When enabled, CTRL-N and CTRL-P are automatically remapped
719
    /// to next-history and previous-history.
720
    #[cfg_attr(feature = "cli", arg(long = "cmd-history", help_heading = "History"))]
721
    pub cmd_history_file: Option<String>,
722
723
    /// Maximum number of query history entries to keep
724
    #[cfg_attr(feature = "cli", arg(long, default_value = "1000", help_heading = "History"))]
725
    pub cmd_history_size: usize,
726
727
    //  --- Preview ---
728
    /// Preview command
729
    ///
730
    /// Execute the given command with `sh -c` on linux and `cmd /c` on windows for the current line and display the result on the preview window.
731
    /// `{}` in the command is the placeholder that is replaced to the single-quoted string of the current line.
732
    /// To transform the replacement string, specify field index expressions between the braces (See FIELD INDEX EXPRESSION for the details).
733
    ///
734
    /// **Examples**:
735
    ///
736
    /// ```bash
737
    /// sk --preview='head -$LINES {}'
738
    /// ls -l | sk --preview="echo user={3} when={-4..-2}; cat {-1}" --header-lines=1
739
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Preview", verbatim_doc_comment))]
740
    pub preview: Option<String>,
741
742
    /// Preview window layout
743
    ///
744
    /// Format: [up|down|left|right][:SIZE][:hidden][:[no]wrap][:[no]pty][:+SCROLL[-OFFSET]]
745
    ///
746
    /// Determine  the  layout of the preview window. If the argument ends with: hidden, the preview window will be hidden by
747
    /// default until toggle-preview action is triggered. Long lines are truncated by default.
748
    /// Line wrap can be enabled with `:wrap` flag.
749
    /// For more interactive commands or previews that draw complex interfaces, the preview can use a PTY with the `:pty` flag.
750
    ///
751
    /// Note: the preview will run in a PTY (interactive session) on Linux and when `wrap` is unset
752
    ///
753
    /// SIZE can be either:
754
    ///     - `0`, which will hide the preview window
755
    ///     - A positive size (eg `20`)
756
    ///     - A percentage of the total size (eg `50%`)
757
    ///     - A negative size, which will set the size of everything but the preview to that value
758
    ///
759
    /// +SCROLL[-OFFSET] determines the initial scroll offset of the preview window. SCROLL can be either a numeric integer
760
    /// or a single-field index expression that refers to a numeric integer. The optional -OFFSET part is for adjusting the
761
    /// base offset so that you can see the text above it. It should be given as a numeric integer (-INTEGER), or as a
762
    /// denominator form (-/INTEGER) for specifying a fraction of the preview window height.
763
    ///
764
    /// **Examples**:
765
    /// ```bash
766
    /// # Non-default scroll window positions and sizes
767
    /// sk --preview="head {}" --preview-window=up:30%
768
    /// sk --preview="file {}" --preview-window=down:2
769
    ///
770
    /// # Initial scroll offset is set to the line number of each line of
771
    /// # git grep output *minus* 5 lines (-5)
772
    /// git grep --line-number '' |
773
    ///   sk --delimiter:  --preview 'nl {1}' --preview-window +{2}-5
774
    ///
775
    ///             # Preview with bat, matching line in the middle of the window (-/2)
776
    ///             git grep --line-number '' |
777
    ///               sk --delimiter : \
778
    ///                   --preview 'bat --style=numbers --color=always --highlight-line {2} {1}' \
779
    ///                   --preview-window +{2}-/2
780
    /// ```
781
    #[cfg_attr(
782
        feature = "cli",
783
        arg(
784
            long,
785
            default_value = "right:50%",
786
            help_heading = "Preview",
787
            allow_hyphen_values = true
788
        )
789
    )]
790
    pub preview_window: PreviewLayout,
791
792
    /// Enable image preview
793
    ///
794
    /// This will render the preview argument as an image instead of running it as a command.
795
    ///
796
    /// If set to `detect` or if no value is passed, it will try to detect the available image backends at startup, which will add a small
797
    /// delay before the first render.
798
    /// If set to `halfblocks`, it will always use the `halfblocks` rendering method
799
    ///
800
    /// Note: the backend detection **will not** work when piping data into skim, use
801
    /// `SKIM_DEFAULT_COMMAND="find . -type f" sk --image` instead of `find . -type f | sk --image`
802
    #[cfg(feature = "image")]
803
    #[cfg_attr(
804
        feature = "cli",
805
        arg(long, help_heading = "Preview", value_enum, default_missing_value = "detect", num_args=0..)
806
    )]
807
    pub image: Option<ImageProtocol>,
808
809
    /// Terminal image protocol picker, queried after entering the alternate screen.
810
    /// Built from `options.image` and an stdio detection if needed
811
    #[cfg(feature = "image")]
812
    #[cfg_attr(feature = "cli", clap(skip))]
813
    #[builder(setter(skip))]
814
    #[debug(skip)]
815
    pub image_picker: Option<Picker>,
816
817
    //  --- Scripting ---
818
    /// Initial query
819
    #[cfg_attr(feature = "cli", arg(long, short, help_heading = "Scripting"))]
820
    pub query: Option<String>,
821
822
    /// Initial query in interactive mode
823
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
824
    pub cmd_query: Option<String>,
825
826
    /// Read input delimited by ASCII NUL(\\0) characters
827
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
828
    pub read0: bool,
829
830
    /// Print output delimited by ASCII NUL(\\0) characters
831
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
832
    pub print0: bool,
833
834
    /// Print the query as the first line
835
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
836
    pub print_query: bool,
837
838
    /// Print the command as the first line (after print-query)
839
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
840
    pub print_cmd: bool,
841
842
    /// Print the score after each item
843
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
844
    pub print_score: bool,
845
846
    /// Print the header as the first line (after print-score)
847
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
848
    pub print_header: bool,
849
850
    /// Print the current (highlighted) item as the first line (after print-header)
851
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
852
    pub print_current: bool,
853
854
    /// Set the output format
855
    /// If set, overrides all `print_` options
856
    /// Will be expanded the same way as preview or commands
857
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
858
    pub output_format: Option<String>,
859
860
    /// Print the ANSI codes, making the output exactly match the input even when `--ansi` is on
861
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", requires = "ansi"))]
862
    pub no_strip_ansi: bool,
863
864
    /// Do not enter the TUI if the query passed in `-q` matches only one item and return it
865
    #[cfg_attr(feature = "cli", arg(long, short = '1', help_heading = "Scripting"))]
866
    pub select_1: bool,
867
868
    /// Do not enter the TUI if the query passed in `-q` does not match any item
869
    #[cfg_attr(feature = "cli", arg(long, short = '0', help_heading = "Scripting"))]
870
    pub exit_0: bool,
871
872
    /// Synchronous search for multi-staged filtering
873
    ///
874
    /// Synchronous search for multi-staged filtering. If specified,
875
    /// `skim` will launch the TUI finder only after the input stream is complete.
876
    /// e.g. `sk --multi | sk --sync`
877
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
878
    pub sync: bool,
879
880
    /// Pre-select the first n items in multi-selection mode
881
    #[cfg_attr(feature = "cli", arg(long, default_value = "0", help_heading = "Scripting"))]
882
    pub pre_select_n: usize,
883
884
    /// Pre-select the matched items in multi-selection mode
885
    ///
886
    /// Check the doc for the detailed syntax:
887
    /// <https://docs.rs/regex/1.4.1/regex>/
888
    #[cfg_attr(feature = "cli", arg(long, default_value = "", help_heading = "Scripting"))]
889
    pub pre_select_pat: String,
890
891
    /// Pre-select the items separated by newline character
892
    ///
893
    /// Example: 'item1\nitem2'
894
    #[cfg_attr(feature = "cli", arg(long, default_value = "", help_heading = "Scripting"))]
895
    pub pre_select_items: String,
896
897
    /// Pre-select the items read from this file
898
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
899
    pub pre_select_file: Option<String>,
900
901
    /// Query for filter mode
902
    #[cfg_attr(feature = "cli", arg(long, short, help_heading = "Scripting"))]
903
    pub filter: Option<String>,
904
905
    /// Generate shell completion script
906
    ///
907
    /// Generate completion script for the specified shell: bash, zsh, fish, etc.
908
    /// The output can be directly sourced or saved to a file for automatic loading.
909
    /// Examples: `source <(sk --shell bash)` (immediate use)
910
    ///          `sk --shell bash >> ~/.bash_completion` (persistent use)
911
    ///
912
    /// Supported shells: bash, zsh, fish, powershell, elvish
913
    #[cfg(feature = "cli")]
914
    #[cfg_attr(
915
        feature = "cli",
916
        arg(long, value_name = "SHELL", help_heading = "Scripting", value_enum)
917
    )]
918
    pub shell: Option<crate::shell::Shell>,
919
920
    /// Generate shell key bindings - only for bash, zsh and fish
921
    ///
922
    /// Generate key bindings script after the shell completions
923
    /// See the `shell` option for more details
924
    #[cfg(feature = "cli")]
925
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", requires = "shell"))]
926
    pub shell_bindings: bool,
927
928
    /// Generate man page and output it to stdout
929
    #[cfg(feature = "cli")]
930
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
931
    pub man: bool,
932
933
    /// Run an IPC socket with optional name (defaults to `sk`)
934
    ///
935
    /// The socket expects Actions in Ron format (similar to Rust code), see `./src/tui/event.rs` for all possible Actions
936
    /// To write to it, see the `--remote` option or the man page
937
    #[cfg(feature = "listen")]
938
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", default_missing_value = "sk", num_args=0..))]
939
    pub listen: Option<String>,
940
941
    /// Send commands to an IPC socket with optional name (defaults to `sk`)
942
    ///
943
    /// The commands are read from stdin, one per line, in the same format as the actions in the
944
    /// bind flag. They can also be chained using `+` as a separator.
945
    /// All other arguments will be ignored
946
    #[cfg(feature = "listen")]
947
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting", default_missing_value = "sk", num_args=0..))]
948
    pub remote: Option<String>,
949
950
    /// Run in a tmux or zellij popup
951
    ///
952
    /// Format: `sk --popup <center|top|bottom|left|right>[,SIZE[%]][,SIZE[%]]`
953
    /// Note: this will try to detect a Zellij session, then a Tmux session
954
    /// This means that in nested sessions, `skim` will prioritize Zellij over Tmux
955
    #[cfg_attr(feature = "cli", arg(long, verbatim_doc_comment, help_heading = "Display", default_missing_value = "center,50%", num_args=0.., alias = "tmux"))]
956
    pub popup: Option<String>,
957
958
    /// Set the log level
959
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
960
    pub log_level: Option<log::LevelFilter>,
961
962
    /// Pipe log output to a file
963
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Scripting"))]
964
    pub log_file: Option<String>,
965
966
    /// Feature flags
967
    #[cfg_attr(feature = "cli", arg(long, hide = true, help_heading = "Scripting"))]
968
    pub flags: Vec<FeatureFlag>,
969
970
    // FZF compatibility args
971
    #[cfg_attr(feature = "cli", arg(short = 'x', long, hide = true))]
972
    #[builder(setter(skip))]
973
    extended: bool,
974
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
975
    #[builder(setter(skip))]
976
    literal: bool,
977
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = "10"))]
978
    #[builder(setter(skip))]
979
    hscroll_off: usize,
980
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
981
    #[builder(setter(skip))]
982
    filepath_word: bool,
983
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = ""))]
984
    #[builder(setter(skip))]
985
    jump_labels: String,
986
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
987
    #[builder(setter(skip))]
988
    no_bold: bool,
989
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
990
    #[builder(setter(skip))]
991
    phony: bool,
992
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
993
    #[builder(setter(skip))]
994
    tail: Option<usize>,
995
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
996
    #[builder(setter(skip))]
997
    style: Option<String>,
998
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
999
    #[builder(setter(skip))]
1000
    no_color: bool,
1001
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1002
    #[builder(setter(skip))]
1003
    padding: Option<String>,
1004
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1005
    #[builder(setter(skip))]
1006
    border_label: Option<String>,
1007
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1008
    #[builder(setter(skip))]
1009
    border_label_pos: Option<String>,
1010
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1011
    #[builder(setter(skip))]
1012
    wrap_sign: Option<String>,
1013
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1014
    #[builder(setter(skip))]
1015
    no_multi_line: bool,
1016
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1017
    #[builder(setter(skip))]
1018
    raw: bool,
1019
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1020
    #[builder(setter(skip))]
1021
    track: bool,
1022
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1023
    #[builder(setter(skip))]
1024
    gap: Option<usize>,
1025
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1026
    #[builder(setter(skip))]
1027
    gap_line: Option<String>,
1028
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = "0"))]
1029
    #[builder(setter(skip))]
1030
    freeze_left: usize,
1031
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = "0"))]
1032
    #[builder(setter(skip))]
1033
    freeze_right: usize,
1034
    #[cfg_attr(feature = "cli", arg(long, hide = true, default_value = "0"))]
1035
    #[builder(setter(skip))]
1036
    scroll_off: usize,
1037
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1038
    #[builder(setter(skip))]
1039
    gutter: Option<String>,
1040
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1041
    #[builder(setter(skip))]
1042
    gutter_raw: Option<String>,
1043
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1044
    #[builder(setter(skip))]
1045
    marker_multi_line: Option<String>,
1046
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1047
    #[builder(setter(skip))]
1048
    list_border: Option<String>,
1049
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1050
    #[builder(setter(skip))]
1051
    list_label: Option<String>,
1052
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1053
    #[builder(setter(skip))]
1054
    list_label_pos: Option<String>,
1055
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1056
    #[builder(setter(skip))]
1057
    no_input: bool,
1058
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1059
    #[builder(setter(skip))]
1060
    info_command: Option<String>,
1061
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1062
    #[builder(setter(skip))]
1063
    separator: Option<String>,
1064
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1065
    #[builder(setter(skip))]
1066
    no_separator: bool,
1067
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1068
    #[builder(setter(skip))]
1069
    ghost: Option<String>,
1070
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1071
    #[builder(setter(skip))]
1072
    input_border: Option<String>,
1073
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1074
    #[builder(setter(skip))]
1075
    input_label: Option<String>,
1076
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1077
    #[builder(setter(skip))]
1078
    input_label_pos: Option<String>,
1079
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1080
    #[builder(setter(skip))]
1081
    preview_label: Option<String>,
1082
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1083
    #[builder(setter(skip))]
1084
    preview_label_pos: Option<String>,
1085
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1086
    #[builder(setter(skip))]
1087
    header_first: bool,
1088
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1089
    #[builder(setter(skip))]
1090
    header_border: Option<String>,
1091
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1092
    #[builder(setter(skip))]
1093
    header_lines_border: Option<String>,
1094
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1095
    #[builder(setter(skip))]
1096
    footer: Option<String>,
1097
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1098
    #[builder(setter(skip))]
1099
    footer_border: Option<String>,
1100
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1101
    #[builder(setter(skip))]
1102
    footer_label: Option<String>,
1103
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1104
    #[builder(setter(skip))]
1105
    footer_label_pos: Option<String>,
1106
    #[cfg_attr(feature = "cli", arg(long, hide = true))]
1107
    #[builder(setter(skip))]
1108
    with_shell: Option<String>,
1109
1110
    /// Deprecated, kept for compatibility purposes. See `accept()` bind instead.
1111
    #[cfg_attr(feature = "cli", arg(long, help_heading = "Deprecated", default_value = ""))]
1112
    expect: String,
1113
1114
    /// Command collector for reading items from commands
1115
    #[cfg_attr(feature = "cli", clap(skip = Rc::new(RefCell::new(SkimItemReader::default())) as Rc<RefCell<dyn CommandCollector>>))]
1116
    #[builder(setter(into = false))]
1117
    #[debug(skip)]
1118
    pub cmd_collector: Rc<RefCell<dyn CommandCollector>>,
1119
    /// Query history entries loaded from history file
1120
    #[cfg_attr(feature = "cli", clap(skip))]
1121
    pub query_history: Vec<String>,
1122
    /// Command history entries loaded from cmd history file
1123
    #[cfg_attr(feature = "cli", clap(skip))]
1124
    pub cmd_history: Vec<String>,
1125
    /// Selector for pre-selecting items
1126
    #[cfg_attr(feature = "cli", clap(skip))]
1127
    #[builder(setter(into = false))]
1128
    #[debug(skip)]
1129
    pub selector: Option<Rc<dyn Selector>>,
1130
    /// Preview Callback
1131
    ///
1132
    /// Used to define a function or closure for the preview window, instead of a shell command.
1133
    ///
1134
    /// The function will take a `Vec<Arc<dyn SkimItem>>>` containing the currently selected items
1135
    /// and return a Vec<String> with the lines to display in UTF-8
1136
    #[cfg_attr(feature = "cli", clap(skip))]
1137
    #[debug(skip)]
1138
    pub preview_fn: Option<PreviewCallback>,
1139
1140
    /// The internal (parsed) keymap
1141
    #[cfg_attr(feature = "cli", clap(skip))]
1142
    pub keymap: KeyMap,
1143
1144
    /// Follow-up action bindings, keyed by the canonical action name.
1145
    ///
1146
    /// Populated from `--bind` entries whose "key" is an action name rather than
1147
    /// a real key (e.g. `reload:first`). After an action runs, the chain bound to
1148
    /// its name is queued.
1149
    #[cfg_attr(feature = "cli", clap(skip))]
1150
    pub action_binds: std::collections::HashMap<String, Vec<Action>>,
1151
}
1152
1153
impl Default for SkimOptions {
1154
    #[allow(clippy::too_many_lines)]
1155
403
    fn default() -> Self {
1156
403
        Self {
1157
403
            split_match: None,
1158
403
            no_strip_ansi: false,
1159
403
            wrap_items: false,
1160
403
            multiline: None,
1161
403
            #[cfg(feature = "listen")]
1162
403
            listen: None,
1163
403
            #[cfg(feature = "listen")]
1164
403
            remote: None,
1165
403
            print_header: false,
1166
403
            print_current: false,
1167
403
            disabled: false,
1168
403
            disable_pattern: None,
1169
403
            tac: Default::default(),
1170
403
            min_query_length: Default::default(),
1171
403
            no_sort: Default::default(),
1172
403
            tiebreak: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End],
1173
403
            nth: Default::default(),
1174
403
            with_nth: Default::default(),
1175
403
            hide_nth: Default::default(),
1176
403
            delimiter: Regex::new(r"[\t\n ]+").unwrap(),
1177
403
            exact: Default::default(),
1178
403
            regex: Default::default(),
1179
403
            algorithm: Default::default(),
1180
403
            case: Default::default(),
1181
403
            typos: Typos::Disabled,
1182
403
            no_typos: false,
1183
403
            normalize: false,
1184
403
            last_match: false,
1185
403
            bind: Default::default(),
1186
403
            multi: Default::default(),
1187
403
            no_multi: Default::default(),
1188
403
            no_mouse: Default::default(),
1189
403
            cmd: Default::default(),
1190
403
            interactive: Default::default(),
1191
403
            replstr: String::from("{}"),
1192
403
            color: Default::default(),
1193
403
            no_hscroll: Default::default(),
1194
403
            keep_right: Default::default(),
1195
403
            skip_to_pattern: Default::default(),
1196
403
            no_clear_if_empty: Default::default(),
1197
403
            no_clear_start: Default::default(),
1198
403
            no_clear: Default::default(),
1199
403
            show_cmd_error: Default::default(),
1200
403
            layout: TuiLayout::default(),
1201
403
            reverse: Default::default(),
1202
403
            height: String::from("100%"),
1203
403
            no_height: Default::default(),
1204
403
            min_height: String::from("10"),
1205
403
            margin: Default::default(),
1206
403
            prompt: String::from("> "),
1207
403
            cmd_prompt: String::from("c> "),
1208
403
            selector_icon: String::from(">"),
1209
403
            multi_select_icon: String::from(">"),
1210
403
            ansi: Default::default(),
1211
403
            tabstop: 8,
1212
403
            info: Default::default(),
1213
403
            no_info: Default::default(),
1214
403
            inline_info: Default::default(),
1215
403
            header: Default::default(),
1216
403
            header_lines: Default::default(),
1217
403
            history_file: Default::default(),
1218
403
            history_size: 1000,
1219
403
            cmd_history_file: Default::default(),
1220
403
            cmd_history_size: 1000,
1221
403
            preview: Default::default(),
1222
403
            preview_window: PreviewLayout::default(),
1223
403
            #[cfg(feature = "image")]
1224
403
            image: None,
1225
403
            #[cfg(feature = "image")]
1226
403
            image_picker: None,
1227
403
            query: Default::default(),
1228
403
            cmd_query: Default::default(),
1229
403
            read0: Default::default(),
1230
403
            print0: Default::default(),
1231
403
            print_query: Default::default(),
1232
403
            print_cmd: Default::default(),
1233
403
            print_score: Default::default(),
1234
403
            output_format: Default::default(),
1235
403
            select_1: Default::default(),
1236
403
            exit_0: Default::default(),
1237
403
            sync: Default::default(),
1238
403
            pre_select_n: Default::default(),
1239
403
            pre_select_pat: Default::default(),
1240
403
            pre_select_items: Default::default(),
1241
403
            pre_select_file: Default::default(),
1242
403
            filter: Default::default(),
1243
403
            popup: Default::default(),
1244
403
            log_file: Default::default(),
1245
403
            extended: Default::default(),
1246
403
            literal: Default::default(),
1247
403
            cycle: Default::default(),
1248
403
            hscroll_off: 10,
1249
403
            filepath_word: Default::default(),
1250
403
            jump_labels: String::from("abcdefghijklmnopqrstuvwxyz"),
1251
403
            border: Default::default(),
1252
403
            border_no_collapse: Default::default(),
1253
403
            no_bold: Default::default(),
1254
403
            phony: Default::default(),
1255
403
            scheme: Default::default(),
1256
403
            tail: Default::default(),
1257
403
            style: Default::default(),
1258
403
            no_color: Default::default(),
1259
403
            padding: Default::default(),
1260
403
            border_label: Default::default(),
1261
403
            border_label_pos: Default::default(),
1262
403
            highlight_line: Default::default(),
1263
403
            wrap_sign: Default::default(),
1264
403
            no_multi_line: Default::default(),
1265
403
            raw: Default::default(),
1266
403
            track: Default::default(),
1267
403
            gap: Default::default(),
1268
403
            gap_line: Default::default(),
1269
403
            freeze_left: Default::default(),
1270
403
            freeze_right: Default::default(),
1271
403
            scroll_off: Default::default(),
1272
403
            gutter: Default::default(),
1273
403
            gutter_raw: Default::default(),
1274
403
            marker_multi_line: Default::default(),
1275
403
            ellipsis: Default::default(),
1276
403
            scrollbar: Default::default(),
1277
403
            no_scrollbar: Default::default(),
1278
403
            list_border: Default::default(),
1279
403
            list_label: Default::default(),
1280
403
            list_label_pos: Default::default(),
1281
403
            no_input: Default::default(),
1282
403
            info_command: Default::default(),
1283
403
            separator: Default::default(),
1284
403
            no_separator: Default::default(),
1285
403
            ghost: Default::default(),
1286
403
            input_border: Default::default(),
1287
403
            input_label: Default::default(),
1288
403
            input_label_pos: Default::default(),
1289
403
            preview_label: Default::default(),
1290
403
            preview_label_pos: Default::default(),
1291
403
            header_first: Default::default(),
1292
403
            header_border: Default::default(),
1293
403
            header_lines_border: Default::default(),
1294
403
            footer: Default::default(),
1295
403
            footer_border: Default::default(),
1296
403
            footer_label: Default::default(),
1297
403
            footer_label_pos: Default::default(),
1298
403
            with_shell: Default::default(),
1299
403
            expect: Default::default(),
1300
403
            cmd_collector: Rc::new(RefCell::new(SkimItemReader::default())) as Rc<RefCell<dyn CommandCollector>>,
1301
403
            query_history: Default::default(),
1302
403
            cmd_history: Default::default(),
1303
403
            selector: Default::default(),
1304
403
            preview_fn: Default::default(),
1305
403
            keymap: Default::default(),
1306
403
            action_binds: Default::default(),
1307
403
            #[cfg(feature = "cli")]
1308
403
            shell: Default::default(),
1309
403
            #[cfg(feature = "cli")]
1310
403
            man: false,
1311
403
            #[cfg(feature = "cli")]
1312
403
            shell_bindings: false,
1313
403
            flags: Default::default(),
1314
403
            log_level: Default::default(),
1315
403
            no_border: false,
1316
403
        }
1317
403
    }
1318
}
1319
1320
impl SkimOptionsBuilder {
1321
    /// Builds the `SkimOptions` from the builder
1322
    ///
1323
    /// # Errors
1324
    ///
1325
    /// Returns an error if any required fields are missing.
1326
67
    pub fn build(&mut self) -> Result<SkimOptions, SkimOptionsBuilderError> {
1327
67
        self.final_build().map(SkimOptions::build)
1328
67
    }
1329
}
1330
1331
impl SkimOptions {
1332
    /// Finalizes the options by applying defaults and initializing components
1333
    #[must_use]
1334
565
    pub fn build(mut self) -> Self {
1335
565
        if self.no_height {
  Branch (1335:12): [True: 0, False: 468]
 
  Branch (1335:12): [True: 1, False: 96]
-
1336
1
            self.height = String::from("100%");
1337
565
        }
1338
1339
566
        if let Some(None) = self.multiline {
  Branch (1339:16): [True: 34, False: 435]
+
1336
1
            self.height = String::from("100%");
1337
564
        }
1338
1339
565
        if let Some(None) = self.multiline {
  Branch (1339:16): [True: 36, False: 432]
 
  Branch (1339:16): [True: 2, False: 95]
-
1340
36
            if self.read0 {
  Branch (1340:16): [True: 2, False: 32]
+
1340
38
            if self.read0 {
  Branch (1340:16): [True: 3, False: 33]
 
  Branch (1340:16): [True: 1, False: 1]
-
1341
3
                self.multiline = Some(Some(String::from("\n")));
1342
            } else {
1343
33
                self.multiline = Some(Some(String::from("\\n")));
1344
            }
1345
530
        }
1346
1347
566
        self.keymap = self.bind.iter().fold(KeyMap::default(), |mut res, part| 
{473
1348
473
            res.add_keymaps_str(part);
1349
473
            res
1350
473
        });
1351
1352
        // Bindings whose "key" is an action name (e.g. `reload:first`) become
1353
        // follow-up actions that run right after that action.
1354
566
        self.action_binds = self
1355
566
            .bind
1356
566
            .iter()
1357
473
            .flat_map(|part| crate::binds::parse_action_binds(crate::binds::split_top_level(part, ',').into_iter()))
1358
            .collect();
1359
1360
566
        if self.reverse {
  Branch (1360:12): [True: 7, False: 462]
+
1341
4
                self.multiline = Some(Some(String::from("\n")));
1342
            } else {
1343
34
                self.multiline = Some(Some(String::from("\\n")));
1344
            }
1345
527
        }
1346
1347
565
        self.keymap = self.bind.iter().fold(KeyMap::default(), |mut res, part| 
{472
1348
472
            res.add_keymaps_str(part);
1349
472
            res
1350
472
        });
1351
1352
        // Bindings whose "key" is an action name (e.g. `reload:first`) become
1353
        // follow-up actions that run right after that action.
1354
565
        self.action_binds = self
1355
565
            .bind
1356
565
            .iter()
1357
472
            .flat_map(|part| crate::binds::parse_action_binds(crate::binds::split_top_level(part, ',').into_iter()))
1358
            .collect();
1359
1360
565
        if self.reverse {
  Branch (1360:12): [True: 6, False: 462]
 
  Branch (1360:12): [True: 1, False: 96]
-
1361
8
            self.layout = TuiLayout::Reverse;
1362
558
        }
1363
566
        if self.history_file.is_some() || 
self.cmd_history_file.is_some()564
{
  Branch (1363:12): [True: 1, False: 468]
-  Branch (1363:43): [True: 0, False: 468]
+
1361
7
            self.layout = TuiLayout::Reverse;
1362
558
        }
1363
565
        if self.history_file.is_some() || 
self.cmd_history_file.is_some()563
{
  Branch (1363:12): [True: 1, False: 467]
+  Branch (1363:43): [True: 0, False: 467]
 
  Branch (1363:12): [True: 1, False: 96]
   Branch (1363:43): [True: 0, False: 96]
-
1364
2
            self.init_histories();
1365
2
            self.keymap.insert(
1366
2
                KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
1367
2
                vec![Action::PreviousHistory],
1368
2
            );
1369
2
            self.keymap.insert(
1370
2
                KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
1371
2
                vec![Action::NextHistory],
1372
2
            );
1373
564
        }
1374
566
        if self.no_scrollbar {
  Branch (1374:12): [True: 1, False: 468]
+
1364
2
            self.init_histories();
1365
2
            self.keymap.insert(
1366
2
                KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
1367
2
                vec![Action::PreviousHistory],
1368
2
            );
1369
2
            self.keymap.insert(
1370
2
                KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
1371
2
                vec![Action::NextHistory],
1372
2
            );
1373
563
        }
1374
565
        if self.no_scrollbar {
  Branch (1374:12): [True: 1, False: 467]
 
  Branch (1374:12): [True: 1, False: 96]
-
1375
2
            self.scrollbar = String::new();
1376
564
        }
1377
566
        if self.inline_info {
  Branch (1377:12): [True: 6, False: 463]
+
1375
2
            self.scrollbar = String::new();
1376
563
        }
1377
565
        if self.inline_info {
  Branch (1377:12): [True: 6, False: 462]
 
  Branch (1377:12): [True: 4, False: 93]
-
1378
10
            self.info = Info {
1379
10
                display: InfoDisplay::Inline,
1380
10
                separator: Some(String::from(crate::tui::statusline::DEFAULT_SEPARATOR)),
1381
10
            };
1382
556
        }
1383
566
        if self.no_info {
  Branch (1383:12): [True: 1, False: 468]
+
1378
10
            self.info = Info {
1379
10
                display: InfoDisplay::Inline,
1380
10
                separator: Some(String::from(crate::tui::statusline::DEFAULT_SEPARATOR)),
1381
10
            };
1382
555
        }
1383
565
        if self.no_info {
  Branch (1383:12): [True: 1, False: 467]
 
  Branch (1383:12): [True: 2, False: 95]
-
1384
3
            self.info = Info {
1385
3
                display: InfoDisplay::Hidden,
1386
3
                separator: None,
1387
3
            };
1388
563
        }
1389
566
        if self.no_typos {
  Branch (1389:12): [True: 3, False: 466]
+
1384
3
            self.info = Info {
1385
3
                display: InfoDisplay::Hidden,
1386
3
                separator: None,
1387
3
            };
1388
562
        }
1389
565
        if self.no_typos {
  Branch (1389:12): [True: 4, False: 464]
 
  Branch (1389:12): [True: 1, False: 96]
-
1390
4
            self.typos = Typos::Disabled;
1391
562
        }
1392
566
        if self.no_border {
  Branch (1392:12): [True: 0, False: 469]
+
1390
5
            self.typos = Typos::Disabled;
1391
560
        }
1392
565
        if self.no_border {
  Branch (1392:12): [True: 0, False: 468]
 
  Branch (1392:12): [True: 2, False: 95]
-
1393
2
            self.border = BorderType::ForceOff;
1394
564
        }
1395
1396
566
        if let Some(ref filter_query) = self.filter
  Branch (1396:16): [True: 15, False: 454]
+
1393
2
            self.border = BorderType::ForceOff;
1394
563
        }
1395
1396
565
        if let Some(ref filter_query) = self.filter
  Branch (1396:16): [True: 15, False: 453]
 
  Branch (1396:16): [True: 5, False: 92]
 
1397
20
            && self.query.is_none()
  Branch (1397:16): [True: 15, False: 0]
 
  Branch (1397:16): [True: 4, False: 1]
-
1398
        {
1399
19
            self.query = Some(filter_query.clone());
1400
547
        }
1401
1402
566
        match self.scheme {
1403
562
            None | Some(MatchScheme::Default) => (),
1404
            Some(MatchScheme::Path) => {
1405
3
                self.last_match = true;
1406
3
                self.tiebreak.insert(0, RankCriteria::PathName);
1407
3
                self.tiebreak.insert(0, RankCriteria::Score);
1408
            }
1409
1
            Some(MatchScheme::History) => self.tiebreak.insert(0, RankCriteria::Index),
1410
        }
1411
1412
566
        self
1413
566
    }
1414
    /// Initializes history from configured history files
1415
3
    pub fn init_histories(&mut self) {
1416
3
        if let Some(histfile) = &self.history_file {
  Branch (1416:16): [True: 1, False: 0]
+
1398
        {
1399
19
            self.query = Some(filter_query.clone());
1400
546
        }
1401
1402
565
        match self.scheme {
1403
561
            None | Some(MatchScheme::Default) => (),
1404
            Some(MatchScheme::Path) => {
1405
3
                self.last_match = true;
1406
3
                self.tiebreak.insert(0, RankCriteria::PathName);
1407
3
                self.tiebreak.insert(0, RankCriteria::Score);
1408
            }
1409
1
            Some(MatchScheme::History) => self.tiebreak.insert(0, RankCriteria::Index),
1410
        }
1411
1412
565
        self
1413
565
    }
1414
    /// Initializes history from configured history files
1415
3
    pub fn init_histories(&mut self) {
1416
3
        if let Some(histfile) = &self.history_file {
  Branch (1416:16): [True: 1, False: 0]
 
  Branch (1416:16): [True: 2, False: 0]
 
1417
3
            self.query_history.extend(read_file_lines(histfile).unwrap_or_default());
1418
0
        }
1419
1420
3
        if let Some(cmd_histfile) = &self.cmd_history_file {
  Branch (1420:16): [True: 0, False: 1]
 
  Branch (1420:16): [True: 1, False: 1]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/output.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/output.rs.html
index 74c2b7b2..248999af 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/output.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/output.rs.html
@@ -1,29 +1,29 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/output.rs
Line
Count
Source
1
use std::io::{self, Write};
2
3
use derive_builder::Builder;
4
5
use crate::item::MatchedItem;
6
use crate::options::SkimOptions;
7
use crate::tui::Event;
8
use crate::tui::actions::Action;
9
10
/// Output from running skim, containing the final selection and state
11
#[derive(Debug)]
12
pub struct SkimOutput {
13
    /// The final event that makes skim accept/quit.
14
    /// Was designed to determine if skim quit or accept.
15
    /// Typically there are only two options: `Event::EvActAbort` | `Event::EvActAccept`
16
    pub final_event: Event,
17
18
    /// quick pass for judging if skim aborts.
19
    pub is_abort: bool,
20
21
    /// The final key that makes skim accept/quit.
22
    /// Note that it might be `Key::Null` if it is triggered by skim.
23
    pub final_key: crossterm::event::KeyEvent,
24
25
    /// The query
26
    pub query: String,
27
28
    /// The command query
29
    pub cmd: String,
30
31
    /// The selected items.
32
    pub selected_items: Vec<MatchedItem>,
33
34
    /// The current item
35
    pub current: Option<MatchedItem>,
36
37
    /// The header
38
    pub header: String,
39
}
40
41
impl SkimOutput {
42
    /// Serialize this output to `out` according to the CLI output options.
43
    ///
44
    /// This is the formatting half of skim's output and is intentionally
45
    /// independent of stdout so it can be exercised by unit tests: the binary
46
    /// passes a locked, buffered stdout, while tests pass a `Vec<u8>`.
47
    ///
48
    /// # Errors
49
    ///
50
    /// Returns any [`io::Error`] produced while writing to `out`.
51
41
    pub fn write_output<W: Write>(&self, out: &mut W, opts: &BinOptions) -> io::Result<()> {
52
41
        if let Some(ref output_format) = opts.output_format {
  Branch (52:16): [Folded - Ignored]
-
  Branch (52:16): [True: 6, False: 23]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/output.rs
Line
Count
Source
1
use std::io::{self, Write};
2
3
use derive_builder::Builder;
4
5
use crate::item::MatchedItem;
6
use crate::options::SkimOptions;
7
use crate::tui::Event;
8
use crate::tui::actions::Action;
9
10
/// Output from running skim, containing the final selection and state
11
#[derive(Debug)]
12
pub struct SkimOutput {
13
    /// The final event that makes skim accept/quit.
14
    /// Was designed to determine if skim quit or accept.
15
    /// Typically there are only two options: `Event::EvActAbort` | `Event::EvActAccept`
16
    pub final_event: Event,
17
18
    /// quick pass for judging if skim aborts.
19
    pub is_abort: bool,
20
21
    /// The final key that makes skim accept/quit.
22
    /// Note that it might be `Key::Null` if it is triggered by skim.
23
    pub final_key: crossterm::event::KeyEvent,
24
25
    /// The query
26
    pub query: String,
27
28
    /// The command query
29
    pub cmd: String,
30
31
    /// The selected items.
32
    pub selected_items: Vec<MatchedItem>,
33
34
    /// The current item
35
    pub current: Option<MatchedItem>,
36
37
    /// The header
38
    pub header: String,
39
}
40
41
impl SkimOutput {
42
    /// Serialize this output to `out` according to the CLI output options.
43
    ///
44
    /// This is the formatting half of skim's output and is intentionally
45
    /// independent of stdout so it can be exercised by unit tests: the binary
46
    /// passes a locked, buffered stdout, while tests pass a `Vec<u8>`.
47
    ///
48
    /// # Errors
49
    ///
50
    /// Returns any [`io::Error`] produced while writing to `out`.
51
41
    pub fn write_output<W: Write>(&self, out: &mut W, opts: &BinOptions) -> io::Result<()> {
52
41
        if let Some(ref output_format) = opts.output_format {
  Branch (52:16): [True: 6, False: 23]
+
  Branch (52:16): [Folded - Ignored]
 
  Branch (52:16): [True: 1, False: 11]
-
53
7
            write!(
54
7
                out,
55
                "{}{}",
56
7
                crate::printf(
57
7
                    output_format,
58
7
                    &opts.delimiter,
59
7
                    &opts.replstr,
60
7
                    &self.selected_items.iter(),
61
7
                    &self.current,
62
7
                    &self.query,
63
7
                    &self.cmd,
64
7
                    false
65
7
                ),
66
7
                opts.output_ending
67
0
            )?;
68
7
            return Ok(());
69
34
        }
70
71
34
        if opts.print_query {
  Branch (71:12): [Folded - Ignored]
-
  Branch (71:12): [True: 2, False: 21]
+
53
7
            write!(
54
7
                out,
55
                "{}{}",
56
7
                crate::printf(
57
7
                    output_format,
58
7
                    &opts.delimiter,
59
7
                    &opts.replstr,
60
7
                    &self.selected_items.iter(),
61
7
                    &self.current,
62
7
                    &self.query,
63
7
                    &self.cmd,
64
7
                    false
65
7
                ),
66
7
                opts.output_ending
67
0
            )?;
68
7
            return Ok(());
69
34
        }
70
71
34
        if opts.print_query {
  Branch (71:12): [True: 2, False: 21]
+
  Branch (71:12): [Folded - Ignored]
 
  Branch (71:12): [True: 1, False: 10]
-
72
3
            write!(out, "{}{}", self.query, opts.output_ending)
?0
;
73
31
        }
74
75
34
        if opts.print_cmd {
  Branch (75:12): [Folded - Ignored]
-
  Branch (75:12): [True: 1, False: 22]
+
72
3
            write!(out, "{}{}", self.query, opts.output_ending)
?0
;
73
31
        }
74
75
34
        if opts.print_cmd {
  Branch (75:12): [True: 1, False: 22]
+
  Branch (75:12): [Folded - Ignored]
 
  Branch (75:12): [True: 1, False: 10]
-
76
2
            write!(out, "{}{}", self.cmd, opts.output_ending)
?0
;
77
32
        }
78
79
34
        if opts.print_header {
  Branch (79:12): [Folded - Ignored]
-
  Branch (79:12): [True: 1, False: 22]
+
76
2
            write!(out, "{}{}", self.cmd, opts.output_ending)
?0
;
77
32
        }
78
79
34
        if opts.print_header {
  Branch (79:12): [True: 1, False: 22]
+
  Branch (79:12): [Folded - Ignored]
 
  Branch (79:12): [True: 1, False: 10]
-
80
2
            write!(out, "{}{}", self.header, opts.output_ending)
?0
;
81
32
        }
82
83
34
        if opts.print_current {
  Branch (83:12): [Folded - Ignored]
-
  Branch (83:12): [True: 1, False: 22]
+
80
2
            write!(out, "{}{}", self.header, opts.output_ending)
?0
;
81
32
        }
82
83
34
        if opts.print_current {
  Branch (83:12): [True: 1, False: 22]
+
  Branch (83:12): [Folded - Ignored]
 
  Branch (83:12): [True: 2, False: 9]
-
84
3
            if let Some(ref current) = self.current {
  Branch (84:20): [Folded - Ignored]
-
  Branch (84:20): [True: 1, False: 0]
+
84
3
            if let Some(ref current) = self.current {
  Branch (84:20): [True: 1, False: 0]
+
  Branch (84:20): [Folded - Ignored]
 
  Branch (84:20): [True: 1, False: 1]
-
85
2
                write!(out, "{}{}", current.output(), opts.output_ending)
?0
;
86
            } else {
87
1
                write!(out, "{}", opts.output_ending)
?0
;
88
            }
89
31
        }
90
91
34
        if let Event::Action(Action::Accept(Some(accept_key))) = &self.final_event {
  Branch (91:16): [Folded - Ignored]
-
  Branch (91:16): [True: 1, False: 22]
+
85
2
                write!(out, "{}{}", current.output(), opts.output_ending)
?0
;
86
            } else {
87
1
                write!(out, "{}", opts.output_ending)
?0
;
88
            }
89
31
        }
90
91
34
        if let Event::Action(Action::Accept(Some(accept_key))) = &self.final_event {
  Branch (91:16): [True: 1, False: 22]
+
  Branch (91:16): [Folded - Ignored]
 
  Branch (91:16): [True: 1, False: 10]
-
92
2
            write!(out, "{}{}", accept_key, opts.output_ending)
?0
;
93
32
        }
94
95
34
        for item in &self.selected_items {
96
50.0k
            if opts.strip_ansi {
  Branch (96:16): [Folded - Ignored]
-
  Branch (96:16): [True: 0, False: 50.0k]
+
92
2
            write!(out, "{}{}", accept_key, opts.output_ending)
?0
;
93
32
        }
94
95
34
        for item in &self.selected_items {
96
50.0k
            if opts.strip_ansi {
  Branch (96:16): [True: 0, False: 50.0k]
+
  Branch (96:16): [Folded - Ignored]
 
  Branch (96:16): [True: 2, False: 13]
-
97
2
                write!(
98
2
                    out,
99
                    "{}{}",
100
2
                    crate::helper::item::strip_ansi(&item.output()).0,
101
2
                    opts.output_ending
102
0
                )?;
103
            } else {
104
50.0k
                write!(out, "{}{}", item.output(), opts.output_ending)
?0
;
105
            }
106
50.0k
            if opts.print_score {
  Branch (106:16): [Folded - Ignored]
-
  Branch (106:16): [True: 1, False: 50.0k]
+
97
2
                write!(
98
2
                    out,
99
                    "{}{}",
100
2
                    crate::helper::item::strip_ansi(&item.output()).0,
101
2
                    opts.output_ending
102
0
                )?;
103
            } else {
104
50.0k
                write!(out, "{}{}", item.output(), opts.output_ending)
?0
;
105
            }
106
50.0k
            if opts.print_score {
  Branch (106:16): [True: 1, False: 50.0k]
+
  Branch (106:16): [Folded - Ignored]
 
  Branch (106:16): [True: 2, False: 13]
 
107
3
                write!(out, "{}{}", item.rank.score, opts.output_ending)
?0
;
108
50.0k
            }
109
        }
110
111
34
        Ok(())
112
41
    }
113
}
114
115
/// Options controlling how a [`SkimOutput`] is serialized to the terminal.
116
///
117
/// These mirror the CLI's output-related flags (`--print-query`, `--print0`,
118
/// `--print-score`, …) and are derived from [`SkimOptions`] via
119
/// [`BinOptions::from_opts`].
120
#[derive(Builder)]
121
#[allow(missing_docs, clippy::struct_excessive_bools)]
122
pub struct BinOptions {
123
    output_ending: String,
124
    print_query: bool,
125
    print_cmd: bool,
126
    print_score: bool,
127
    print_header: bool,
128
    print_current: bool,
129
    strip_ansi: bool,
130
    output_format: Option<String>,
131
    delimiter: regex::Regex,
132
    replstr: String,
133
}
134
135
impl BinOptions {
136
    /// Build the output options from the parsed [`SkimOptions`].
137
    #[must_use]
138
52
    pub fn from_opts(opts: &SkimOptions) -> Self {
139
        Self {
140
52
            print_query: opts.print_query,
141
52
            print_cmd: opts.print_cmd,
142
52
            print_score: opts.print_score,
143
52
            print_header: opts.print_header,
144
52
            print_current: opts.print_current,
145
52
            output_ending: String::from(if opts.print0 { 
"\0"3
} else {
"\n"49
}),
  Branch (145:44): [True: 1, False: 35]
 
  Branch (145:44): [True: 2, False: 14]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/popup/mod.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/popup/mod.rs.html
index 58ff6d6c..9b42610d 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/popup/mod.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/popup/mod.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/popup/mod.rs
Line
Count
Source
1
//! Tmux & Zellij integration utilities.
2
//!
3
//! This module provides functionality for running skim within tmux/zellij panes,
4
//! allowing skim to be used as a tmux popup or split pane.
5
6
mod tmux;
7
mod zellij;
8
9
use std::borrow::Cow;
10
use std::fmt::Write as FmtWrite;
11
use std::io::{BufRead as _, BufReader, BufWriter, IsTerminal as _, Write as _};
12
use std::process::ExitStatus;
13
use std::sync::Arc;
14
use std::sync::atomic::{AtomicBool, Ordering};
15
use std::thread;
16
17
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
18
use nix::sys::stat::Mode;
19
use nix::unistd::mkfifo;
20
21
use crate::item::{MatchedItem, RankBuilder};
22
use crate::tui::Event;
23
use crate::tui::actions::Action;
24
use crate::{Rank, SkimItem, SkimOptions, SkimOutput};
25
26
use tmux::TmuxPopup;
27
use zellij::ZellijPopup;
28
29
#[derive(Debug, PartialEq, Eq)]
30
enum PopupWindowDir {
31
    Center,
32
    Top,
33
    Bottom,
34
    Left,
35
    Right,
36
}
37
38
impl From<&str> for PopupWindowDir {
39
37
    fn from(value: &str) -> Self {
40
        use PopupWindowDir::{Bottom, Center, Left, Right, Top};
41
37
        match value {
42
4
            "top" => Top,
43
3
            "bottom" => Bottom,
44
3
            "left" => Left,
45
3
            "right" => Right,
46
24
            _ => Center, // includes "center" and all unknown values
47
        }
48
37
    }
49
}
50
51
trait SkimPopup {
52
    fn from_options(options: &SkimOptions) -> Box<dyn SkimPopup>
53
    where
54
        Self: Sized;
55
    fn add_env(&mut self, key: &str, value: &str);
56
    fn run_and_wait(&mut self, command: &str) -> std::io::Result<ExitStatus>;
57
}
58
59
struct SkimPopupOutput {
60
    line: String,
61
}
62
63
impl SkimItem for SkimPopupOutput {
64
1
    fn text(&self) -> Cow<'_, str> {
65
1
        Cow::from(&self.line)
66
1
    }
67
}
68
69
/// Returns true if a compatible multiplexer is running and we are not already in a popup
70
/// (`$_SKIM_POPUP`)
71
#[must_use]
72
7
pub fn check_env() -> bool {
73
7
    std::env::var("_SKIM_POPUP").is_err() && (
tmux::is_available()6
||
zellij::is_available()1
)
  Branch (73:5): [True: 5, False: 0]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/popup/mod.rs
Line
Count
Source
1
//! Tmux & Zellij integration utilities.
2
//!
3
//! This module provides functionality for running skim within tmux/zellij panes,
4
//! allowing skim to be used as a tmux popup or split pane.
5
6
mod tmux;
7
mod zellij;
8
9
use std::borrow::Cow;
10
use std::fmt::Write as FmtWrite;
11
use std::io::{BufRead as _, BufReader, BufWriter, IsTerminal as _, Write as _};
12
use std::process::ExitStatus;
13
use std::sync::Arc;
14
use std::sync::atomic::{AtomicBool, Ordering};
15
use std::thread;
16
17
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
18
use nix::sys::stat::Mode;
19
use nix::unistd::mkfifo;
20
21
use crate::item::{MatchedItem, RankBuilder};
22
use crate::tui::Event;
23
use crate::tui::actions::Action;
24
use crate::{Rank, SkimItem, SkimOptions, SkimOutput};
25
26
use tmux::TmuxPopup;
27
use zellij::ZellijPopup;
28
29
#[derive(Debug, PartialEq, Eq)]
30
enum PopupWindowDir {
31
    Center,
32
    Top,
33
    Bottom,
34
    Left,
35
    Right,
36
}
37
38
impl From<&str> for PopupWindowDir {
39
37
    fn from(value: &str) -> Self {
40
        use PopupWindowDir::{Bottom, Center, Left, Right, Top};
41
37
        match value {
42
4
            "top" => Top,
43
3
            "bottom" => Bottom,
44
3
            "left" => Left,
45
3
            "right" => Right,
46
24
            _ => Center, // includes "center" and all unknown values
47
        }
48
37
    }
49
}
50
51
trait SkimPopup {
52
    fn from_options(options: &SkimOptions) -> Box<dyn SkimPopup>
53
    where
54
        Self: Sized;
55
    fn add_env(&mut self, key: &str, value: &str);
56
    fn run_and_wait(&mut self, command: &str) -> std::io::Result<ExitStatus>;
57
}
58
59
struct SkimPopupOutput {
60
    line: String,
61
}
62
63
impl SkimItem for SkimPopupOutput {
64
1
    fn text(&self) -> Cow<'_, str> {
65
1
        Cow::from(&self.line)
66
1
    }
67
}
68
69
/// Returns true if a compatible multiplexer is running and we are not already in a popup
70
/// (`$_SKIM_POPUP`)
71
#[must_use]
72
7
pub fn check_env() -> bool {
73
7
    std::env::var("_SKIM_POPUP").is_err() && (
tmux::is_available()6
||
zellij::is_available()1
)
  Branch (73:5): [True: 5, False: 0]
   Branch (73:47): [True: 5, False: 0]
 
  Branch (73:5): [True: 1, False: 1]
   Branch (73:47): [True: 0, False: 1]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/popup/tmux.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/popup/tmux.rs.html
index 82d132fe..979d5ae9 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/popup/tmux.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/popup/tmux.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/popup/tmux.rs
Line
Count
Source
1
use crate::SkimOptions;
2
3
use super::{PopupWindowDir, SkimPopup};
4
use std::process::{Command, ExitStatus, Stdio};
5
6
12
pub fn is_available() -> bool {
7
12
    cfg!(unix) && std::env::var("TMUX").is_ok() && 
which::which("tmux").is_ok()10
  Branch (7:19): [True: 10, False: 0]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/popup/tmux.rs
Line
Count
Source
1
use crate::SkimOptions;
2
3
use super::{PopupWindowDir, SkimPopup};
4
use std::process::{Command, ExitStatus, Stdio};
5
6
12
pub fn is_available() -> bool {
7
12
    cfg!(unix) && std::env::var("TMUX").is_ok() && 
which::which("tmux").is_ok()10
  Branch (7:19): [True: 10, False: 0]
 
  Branch (7:19): [True: 0, False: 2]
 
8
12
}
9
10
pub(super) struct TmuxPopup {
11
    cmd: Command,
12
}
13
14
impl TmuxPopup {
15
19
    fn build(options: &SkimOptions) -> Self {
16
19
        let arg = options.popup.as_ref().expect("this arg should be present to get here");
17
        // `is_available` already guarantees tmux is on PATH before we reach here
18
        // in production; fall back to the bare name so arg-building (and tests)
19
        // work even when the binary cannot be resolved.
20
19
        let mut cmd = Command::new(which::which("tmux").unwrap_or_else(|_| 
"tmux"0
.into()));
21
19
        cmd.arg("display-popup").arg("-E").args([
22
19
            "-d",
23
19
            &std::env::current_dir()
24
19
                .ok()
25
19
                .map_or(".".to_string(), |d| d.to_string_lossy().to_string()),
26
        ]);
27
28
        let border = {
29
            use crate::tui::BorderType::{ForceOff, None, Plain, Rounded, Thick};
30
19
            match options.border {
31
0
                ForceOff => "none",
32
16
                None | Plain => "single",
33
1
                Rounded => "rounded",
34
1
                Thick => "heavy",
35
1
                _ => "double",
36
            }
37
        };
38
39
19
        let (raw_dir, size) = arg.split_once(',').unwrap_or((arg, "50%"));
40
19
        let dir = PopupWindowDir::from(raw_dir);
41
19
        let (height, width) = if let Some((lhs, rhs)) = size.split_once(',') {
  Branch (41:38): [True: 0, False: 5]
 
  Branch (41:38): [True: 2, False: 12]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/popup/zellij.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/popup/zellij.rs.html
index 9c2a660e..77f14a01 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/popup/zellij.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/popup/zellij.rs.html
@@ -1,4 +1,4 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/popup/zellij.rs
Line
Count
Source
1
use super::{PopupWindowDir, SkimPopup};
2
use crate::SkimOptions;
3
use crate::tui::Size;
4
5
use std::fmt::Write as _;
6
use std::process::{Command, ExitStatus, Stdio};
7
8
7
pub fn is_available() -> bool {
9
7
    std::env::var("ZELLIJ").is_ok() && 
which::which("zellij").is_ok()0
  Branch (9:5): [True: 0, False: 5]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/popup/zellij.rs
Line
Count
Source
1
use super::{PopupWindowDir, SkimPopup};
2
use crate::SkimOptions;
3
use crate::tui::Size;
4
5
use std::fmt::Write as _;
6
use std::process::{Command, ExitStatus, Stdio};
7
8
7
pub fn is_available() -> bool {
9
7
    std::env::var("ZELLIJ").is_ok() && 
which::which("zellij").is_ok()0
  Branch (9:5): [True: 0, False: 5]
 
  Branch (9:5): [True: 0, False: 2]
 
10
7
}
11
12
pub(super) struct ZellijPopup {
13
    cmd: Command,
14
    env: String,
15
}
16
17
20
fn middle_coord(size: Size, var: &str) -> Size {
18
20
    match size {
19
17
        Size::Percent(p) => Size::Percent(100u16.saturating_sub(p) / 2),
20
        Size::Fixed(cells) => Size::Fixed(
21
2
            std::env::var(var)
22
2
                .map_or(80u16, |s| 
s.parse()1
.unwrap_or(
801
))
23
2
                .saturating_sub(cells)
24
2
                / 2,
25
        ),
26
1
        Size::Neg(cells) => Size::Fixed(cells / 2),
27
    }
28
20
}
29
30
5
fn align_end_coord(size: Size, var: &str) -> Size {
31
5
    match size {
32
3
        Size::Percent(p) => Size::Percent(100 - p),
33
        Size::Fixed(cols) => Size::Fixed(
34
1
            std::env::var(var)
35
1
                .map_or(80u16, |s| s.parse().unwrap_or(80))
36
1
                .saturating_sub(cols),
37
        ),
38
1
        Size::Neg(cells) => Size::Fixed(cells),
39
    }
40
5
}
41
42
impl ZellijPopup {
43
10
    fn build(options: &SkimOptions) -> Self {
44
        // `is_available` already guarantees zellij is on PATH before we reach
45
        // here in production; fall back to the bare name so arg-building (and
46
        // tests) work even when the binary cannot be resolved.
47
10
        let mut cmd = Command::new(which::which("zellij").unwrap_or_else(|_| 
"zellij"0
.into()));
48
10
        cmd.arg("run")
49
10
            .arg("--floating")
50
10
            .arg("--block-until-exit")
51
10
            .arg("--close-on-exit")
52
10
            .args(["--pinned", "true"])
53
10
            .args(["--name", "skim"])
54
            .args([
55
10
                "--cwd",
56
10
                &std::env::current_dir()
57
10
                    .ok()
58
10
                    .map_or(".".to_string(), |d| d.to_string_lossy().to_string()),
59
            ]);
60
61
10
        if options.border == crate::tui::BorderType::ForceOff {
  Branch (61:12): [True: 0, False: 0]
 
  Branch (61:12): [True: 1, False: 9]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/reader.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/reader.rs.html
index da5e724f..7cef63f6 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/reader.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/reader.rs.html
@@ -1,11 +1,11 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/reader.rs
Line
Count
Source
1
//! Reader is used for reading items from datasource (e.g. stdin or command output)
2
//!
3
//! After reading in a line, reader will save an item into the pool(items)
4
use crate::item::ItemPool;
5
use crate::options::SkimOptions;
6
use crate::prelude::{Sender, SkimItemReader};
7
use crate::spinlock::SpinLock;
8
use crate::thread_pool::ThreadPool;
9
use crate::{SkimItem, SkimItemReceiver};
10
use std::cell::RefCell;
11
use std::rc::Rc;
12
use std::sync::Arc;
13
use std::sync::atomic::{AtomicUsize, Ordering};
14
use std::thread::JoinHandle;
15
use std::time::Duration;
16
17
/// Trait for collecting items from command output
18
pub trait CommandCollector {
19
    /// execute the `cmd` and produce a
20
    /// - skim item producer
21
    /// - a channel sender, any message send would mean to terminate the `cmd` process (for now).
22
    ///
23
    /// Internally, the command collector may start several threads(components), the collector
24
    /// should add `1` on every thread creation and sub `1` on thread termination. reader would use
25
    /// this information to determine whether the collector had stopped or not.
26
    fn invoke(
27
        &mut self,
28
        cmd: &str,
29
        components_to_stop: Arc<AtomicUsize>,
30
    ) -> (SkimItemReceiver, crate::prelude::Sender<i32>);
31
32
    /// Provides a shared thread pool so that chunk-processing work submitted
33
    /// by this collector competes for the same threads as the matcher rather
34
    /// than spawning additional OS threads.  The default implementation is a
35
    /// no-op; collectors that support pool-based I/O should override it.
36
0
    fn set_thread_pool(&mut self, _pool: Arc<ThreadPool>) {}
37
}
38
39
/// Handle for controlling a running reader
40
pub struct ReaderControl {
41
    tx_interrupt: Sender<i32>,
42
    tx_interrupt_cmd: Option<Sender<i32>>,
43
    components_to_stop: Arc<AtomicUsize>,
44
    collector_handle: Option<JoinHandle<()>>,
45
    items: Arc<SpinLock<Vec<Arc<dyn SkimItem>>>>,
46
}
47
48
impl ReaderControl {
49
    /// Kills the reader and waits for all components to stop
50
536
    pub fn kill(&mut self) {
51
536
        debug!(
52
            "kill reader, components before: {}",
53
2
            self.components_to_stop.load(Ordering::SeqCst)
54
        );
55
56
536
        let _ = self.tx_interrupt_cmd.clone().map(|tx| 
tx113
.send(
1113
));
57
536
        let _ = self.tx_interrupt.send(1);
58
536
        if let Some(handle) = self.collector_handle.take() {
  Branch (58:16): [True: 431, False: 75]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/reader.rs
Line
Count
Source
1
//! Reader is used for reading items from datasource (e.g. stdin or command output)
2
//!
3
//! After reading in a line, reader will save an item into the pool(items)
4
use crate::item::ItemPool;
5
use crate::options::SkimOptions;
6
use crate::prelude::{Sender, SkimItemReader};
7
use crate::spinlock::SpinLock;
8
use crate::thread_pool::ThreadPool;
9
use crate::{SkimItem, SkimItemReceiver};
10
use std::cell::RefCell;
11
use std::rc::Rc;
12
use std::sync::Arc;
13
use std::sync::atomic::{AtomicUsize, Ordering};
14
use std::thread::JoinHandle;
15
use std::time::Duration;
16
17
/// Trait for collecting items from command output
18
pub trait CommandCollector {
19
    /// execute the `cmd` and produce a
20
    /// - skim item producer
21
    /// - a channel sender, any message send would mean to terminate the `cmd` process (for now).
22
    ///
23
    /// Internally, the command collector may start several threads(components), the collector
24
    /// should add `1` on every thread creation and sub `1` on thread termination. reader would use
25
    /// this information to determine whether the collector had stopped or not.
26
    fn invoke(
27
        &mut self,
28
        cmd: &str,
29
        components_to_stop: Arc<AtomicUsize>,
30
    ) -> (SkimItemReceiver, crate::prelude::Sender<i32>);
31
32
    /// Provides a shared thread pool so that chunk-processing work submitted
33
    /// by this collector competes for the same threads as the matcher rather
34
    /// than spawning additional OS threads.  The default implementation is a
35
    /// no-op; collectors that support pool-based I/O should override it.
36
0
    fn set_thread_pool(&mut self, _pool: Arc<ThreadPool>) {}
37
}
38
39
/// Handle for controlling a running reader
40
pub struct ReaderControl {
41
    tx_interrupt: Sender<i32>,
42
    tx_interrupt_cmd: Option<Sender<i32>>,
43
    components_to_stop: Arc<AtomicUsize>,
44
    collector_handle: Option<JoinHandle<()>>,
45
    items: Arc<SpinLock<Vec<Arc<dyn SkimItem>>>>,
46
}
47
48
impl ReaderControl {
49
    /// Kills the reader and waits for all components to stop
50
535
    pub fn kill(&mut self) {
51
535
        debug!(
52
            "kill reader, components before: {}",
53
2
            self.components_to_stop.load(Ordering::SeqCst)
54
        );
55
56
535
        let _ = self.tx_interrupt_cmd.clone().map(|tx| 
tx114
.send(
1114
));
57
535
        let _ = self.tx_interrupt.send(1);
58
535
        if let Some(handle) = self.collector_handle.take() {
  Branch (58:16): [True: 430, False: 75]
 
  Branch (58:16): [True: 23, False: 7]
-
59
454
            let _ = handle.join();
60
82
        }
61
        // Command collectors can own additional components outside the reader's
62
        // join handle. Wait without consuming a CPU while they process the signal.
63
536
        while self.components_to_stop.load(Ordering::Acquire) != 0 {
  Branch (63:15): [True: 0, False: 506]
+
59
453
            let _ = handle.join();
60
82
        }
61
        // Command collectors can own additional components outside the reader's
62
        // join handle. Wait without consuming a CPU while they process the signal.
63
535
        while self.components_to_stop.load(Ordering::Acquire) != 0 {
  Branch (63:15): [True: 0, False: 505]
 
  Branch (63:15): [True: 0, False: 30]
-
64
0
            std::thread::sleep(Duration::from_millis(1));
65
        }
66
536
    }
67
68
    /// Takes all items collected so far
69
    #[must_use]
70
1
    pub fn take(&self) -> Vec<Arc<dyn SkimItem>> {
71
1
        let mut items = self.items.lock();
72
1
        let mut ret = Vec::with_capacity(items.len());
73
1
        ret.append(&mut items);
74
1
        ret
75
1
    }
76
77
    /// Returns true if the reader has finished and no items remain
78
    #[must_use]
79
10.7k
    pub fn is_done(&self) -> bool {
80
10.7k
        let items = self.items.lock();
81
10.7k
        self.components_to_stop.load(Ordering::SeqCst) == 0 && 
items.is_empty()10.4k
  Branch (81:9): [True: 10.4k, False: 256]
-
  Branch (81:9): [True: 21, False: 11]
-
82
10.7k
    }
83
}
84
85
impl Drop for ReaderControl {
86
454
    fn drop(&mut self) {
87
454
        self.kill();
88
454
    }
89
}
90
91
/// Reader for streaming items from commands or other sources
92
pub struct Reader {
93
    cmd_collector: Rc<RefCell<dyn CommandCollector>>,
94
    rx_item: Option<SkimItemReceiver>,
95
}
96
97
impl Reader {
98
    /// Creates a new reader from skim options
99
    #[must_use]
100
404
    pub fn from_options(options: &SkimOptions) -> Self {
101
404
        Self {
102
404
            cmd_collector: options.cmd_collector.clone(),
103
404
            rx_item: None,
104
404
        }
105
404
    }
106
107
    /// Sets the item source (if None, will use command collector)
108
    #[must_use]
109
409
    pub fn source(mut self, rx_item: Option<SkimItemReceiver>) -> Self {
110
409
        self.rx_item = rx_item;
111
409
        self
112
409
    }
113
114
    /// Forwards a shared thread pool to the underlying [`CommandCollector`] so
115
    /// that I/O work shares the matcher's thread budget instead of spawning
116
    /// separate OS threads.
117
404
    pub fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
118
404
        self.cmd_collector.borrow_mut().set_thread_pool(pool);
119
404
    }
120
121
    /// Starts the reader and returns a control handle
122
2
    pub fn run(&mut self, app_tx: Sender<Vec<Arc<dyn SkimItem>>>, cmd: &str) -> ReaderControl {
123
2
        let components_to_stop: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
124
2
        let items = Arc::new(SpinLock::new(Vec::new()));
125
126
2
        let (rx_item, tx_interrupt_cmd) = self.rx_item.take().map_or_else(
127
1
            || {
128
1
                let components_to_stop_clone = components_to_stop.clone();
129
1
                let (rx_item, tx_interrupt_cmd) = self.cmd_collector.borrow_mut().invoke(cmd, components_to_stop_clone);
130
1
                (rx_item, Some(tx_interrupt_cmd))
131
1
            },
132
1
            |rx| (rx, None),
133
        );
134
135
2
        let components_to_stop_clone = components_to_stop.clone();
136
        let (tx_interrupt, collector_handle) =
137
2
            collect_items(components_to_stop_clone, rx_item, move |items| _ = app_tx.send(items));
138
139
2
        ReaderControl {
140
2
            tx_interrupt,
141
2
            tx_interrupt_cmd,
142
2
            components_to_stop,
143
2
            collector_handle: Some(collector_handle),
144
2
            items,
145
2
        }
146
2
    }
147
148
    /// Starts collecting items and sending them to the pool directly
149
    /// Returns a control handle
150
452
    pub fn collect(&mut self, item_pool: Arc<ItemPool>, cmd: &str) -> ReaderControl {
151
452
        let components_to_stop: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
152
452
        let items = Arc::new(SpinLock::new(Vec::new()));
153
154
452
        let (rx_item, tx_interrupt_cmd) = self.rx_item.take().map_or_else(
155
69
            || {
156
69
                let components_to_stop_clone = components_to_stop.clone();
157
69
                let (rx_item, tx_interrupt_cmd) = self.cmd_collector.borrow_mut().invoke(cmd, components_to_stop_clone);
158
69
                (rx_item, Some(tx_interrupt_cmd))
159
69
            },
160
383
            |rx| (rx, None),
161
        );
162
163
452
        let components_to_stop_clone = components_to_stop.clone();
164
452
        let (tx_interrupt, collector_handle) = collect_items(components_to_stop_clone, rx_item, move |items| 
{427
165
427
            item_pool.append(items);
166
427
        });
167
452
        debug!("collect: started ({
components_to_stop1
:?} components)");
168
169
452
        ReaderControl {
170
452
            tx_interrupt,
171
452
            tx_interrupt_cmd,
172
452
            components_to_stop,
173
452
            collector_handle: Some(collector_handle),
174
452
            items,
175
452
        }
176
452
    }
177
}
178
179
impl Default for Reader {
180
7
    fn default() -> Self {
181
7
        Self {
182
7
            cmd_collector: Rc::new(RefCell::new(SkimItemReader::default())) as Rc<RefCell<dyn CommandCollector>>,
183
7
            rx_item: Default::default(),
184
7
        }
185
7
    }
186
}
187
188
454
fn collect_items<F>(
189
454
    components_to_stop: Arc<AtomicUsize>,
190
454
    rx_item: SkimItemReceiver,
191
454
    callback: F,
192
454
) -> (Sender<i32>, JoinHandle<()>)
193
454
where
194
454
    F: Fn(Vec<Arc<dyn SkimItem>>) + Send + 'static,
195
{
196
454
    let (tx_interrupt, rx_interrupt) = crate::prelude::bounded(8);
197
198
454
    components_to_stop.fetch_add(1, Ordering::AcqRel);
199
454
    let handle = std::thread::spawn(move || {
200
454
        debug!(
"collect_item start"1
);
201
202
        loop {
203
947
            if let Ok(Some(msg)) = rx_interrupt.try_recv() {
  Branch (203:20): [True: 0, False: 0]
-  Branch (203:20): [True: 0, False: 901]
-
  Branch (203:20): [True: 0, False: 5]
-  Branch (203:20): [True: 1, False: 40]
-
204
1
                debug!("interrupt: {
msg0
}");
205
1
                break;
206
946
            }
207
946
            match rx_item.recv_timeout(std::time::Duration::from_millis(1)) {
208
                Ok(items) => {
209
429
                    trace!("collect_item: got {} items", 
items.len()2
);
210
429
                    callback(items);
211
                }
212
64
                Err(kanal::ReceiveErrorTimeout::Timeout) => {
213
64
                    // No items within the timeout — loop back to check the
214
64
                    // interrupt channel before blocking again.
215
64
                }
216
                Err(kanal::ReceiveErrorTimeout::Closed | kanal::ReceiveErrorTimeout::SendClosed) => {
217
453
                    break;
218
                }
219
            }
220
        }
221
222
454
        components_to_stop.fetch_sub(1, Ordering::AcqRel);
223
454
        debug!(
"collect_item stop"1
);
224
454
    });
225
226
454
    (tx_interrupt, handle)
227
454
}
228
229
#[cfg(test)]
230
#[cfg_attr(coverage, coverage(off))]
231
mod tests {
232
    use super::*;
233
    use std::io::Cursor;
234
    use std::time::{Duration, Instant};
235
236
    fn source(text: &str) -> SkimItemReceiver {
237
        SkimItemReader::default().of_bufread(Cursor::new(text.to_owned().into_bytes()))
238
    }
239
240
    /// Spin until `cond` holds or a short timeout elapses.
241
    fn wait_until(mut cond: impl FnMut() -> bool) {
242
        let start = Instant::now();
243
        while !cond() && start.elapsed() < Duration::from_secs(5) {
244
            std::thread::sleep(Duration::from_millis(2));
245
        }
246
    }
247
248
    #[test]
249
    fn collect_streams_items_into_pool() {
250
        let pool = Arc::new(ItemPool::new());
251
        let mut reader = Reader::default().source(Some(source("a\nb\nc\n")));
252
        let control = reader.collect(pool.clone(), "");
253
        wait_until(|| pool.len() == 3);
254
        assert_eq!(pool.len(), 3);
255
        drop(control);
256
    }
257
258
    #[test]
259
    fn run_sends_items_to_channel() {
260
        let (tx, rx) = kanal::unbounded::<Vec<Arc<dyn SkimItem>>>();
261
        let mut reader = Reader::default().source(Some(source("x\ny\n")));
262
        let control = reader.run(tx, "");
263
        wait_until(|| control.is_done());
264
265
        let mut count = 0;
266
        while let Ok(Some(batch)) = rx.try_recv() {
267
            count += batch.len();
268
        }
269
        assert_eq!(count, 2);
270
        drop(control);
271
    }
272
273
    #[test]
274
    fn is_done_true_after_completion() {
275
        let pool = Arc::new(ItemPool::new());
276
        let mut reader = Reader::default().source(Some(source("only\n")));
277
        let control = reader.collect(pool.clone(), "");
278
        wait_until(|| control.is_done());
279
        assert!(control.is_done());
280
    }
281
282
    #[test]
283
    fn kill_stops_all_components() {
284
        let pool = Arc::new(ItemPool::new());
285
        let mut reader = Reader::default().source(Some(source("a\nb\n")));
286
        let mut control = reader.collect(pool, "");
287
        control.kill();
288
        // After kill, no components remain running.
289
        assert!(control.is_done());
290
    }
291
292
    #[test]
293
    fn run_without_source_invokes_command() {
294
        // With no preset source, `run` falls back to invoking the command via
295
        // the command collector.
296
        #[cfg(unix)]
297
        let cmd = "printf 'a\\nb\\n'";
298
        #[cfg(windows)]
299
        let cmd = "echo a & echo b";
300
301
        let (tx, rx) = kanal::unbounded::<Vec<Arc<dyn SkimItem>>>();
302
        let mut reader = Reader::default();
303
        let control = reader.run(tx, cmd);
304
        wait_until(|| control.is_done());
305
306
        let mut count = 0;
307
        while let Ok(Some(batch)) = rx.try_recv() {
308
            count += batch.len();
309
        }
310
        assert_eq!(count, 2);
311
        drop(control);
312
    }
313
314
    #[test]
315
    fn collect_without_source_invokes_command() {
316
        // Same command-invoking fallback for the pool-collecting path.
317
        #[cfg(unix)]
318
        let cmd = "printf 'x\\ny\\nz\\n'";
319
        #[cfg(windows)]
320
        let cmd = "echo x & echo y & echo z";
321
322
        let pool = Arc::new(ItemPool::new());
323
        let mut reader = Reader::default();
324
        let control = reader.collect(pool.clone(), cmd);
325
        wait_until(|| pool.len() == 3);
326
        assert_eq!(pool.len(), 3);
327
        drop(control);
328
    }
329
330
    #[test]
331
    fn take_returns_empty_for_pool_collection() {
332
        // `collect` routes items to the pool, not the control's own buffer.
333
        let pool = Arc::new(ItemPool::new());
334
        let mut reader = Reader::default().source(Some(source("a\n")));
335
        let control = reader.collect(pool, "");
336
        wait_until(|| control.is_done());
337
        assert!(control.take().is_empty());
338
    }
339
}
\ No newline at end of file +
64
0
            std::thread::sleep(Duration::from_millis(1));
65
        }
66
535
    }
67
68
    /// Takes all items collected so far
69
    #[must_use]
70
1
    pub fn take(&self) -> Vec<Arc<dyn SkimItem>> {
71
1
        let mut items = self.items.lock();
72
1
        let mut ret = Vec::with_capacity(items.len());
73
1
        ret.append(&mut items);
74
1
        ret
75
1
    }
76
77
    /// Returns true if the reader has finished and no items remain
78
    #[must_use]
79
10.7k
    pub fn is_done(&self) -> bool {
80
10.7k
        let items = self.items.lock();
81
10.7k
        self.components_to_stop.load(Ordering::SeqCst) == 0 && 
items.is_empty()10.4k
  Branch (81:9): [True: 10.4k, False: 240]
+
  Branch (81:9): [True: 26, False: 16]
+
82
10.7k
    }
83
}
84
85
impl Drop for ReaderControl {
86
453
    fn drop(&mut self) {
87
453
        self.kill();
88
453
    }
89
}
90
91
/// Reader for streaming items from commands or other sources
92
pub struct Reader {
93
    cmd_collector: Rc<RefCell<dyn CommandCollector>>,
94
    rx_item: Option<SkimItemReceiver>,
95
}
96
97
impl Reader {
98
    /// Creates a new reader from skim options
99
    #[must_use]
100
403
    pub fn from_options(options: &SkimOptions) -> Self {
101
403
        Self {
102
403
            cmd_collector: options.cmd_collector.clone(),
103
403
            rx_item: None,
104
403
        }
105
403
    }
106
107
    /// Sets the item source (if None, will use command collector)
108
    #[must_use]
109
408
    pub fn source(mut self, rx_item: Option<SkimItemReceiver>) -> Self {
110
408
        self.rx_item = rx_item;
111
408
        self
112
408
    }
113
114
    /// Forwards a shared thread pool to the underlying [`CommandCollector`] so
115
    /// that I/O work shares the matcher's thread budget instead of spawning
116
    /// separate OS threads.
117
403
    pub fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
118
403
        self.cmd_collector.borrow_mut().set_thread_pool(pool);
119
403
    }
120
121
    /// Starts the reader and returns a control handle
122
2
    pub fn run(&mut self, app_tx: Sender<Vec<Arc<dyn SkimItem>>>, cmd: &str) -> ReaderControl {
123
2
        let components_to_stop: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
124
2
        let items = Arc::new(SpinLock::new(Vec::new()));
125
126
2
        let (rx_item, tx_interrupt_cmd) = self.rx_item.take().map_or_else(
127
1
            || {
128
1
                let components_to_stop_clone = components_to_stop.clone();
129
1
                let (rx_item, tx_interrupt_cmd) = self.cmd_collector.borrow_mut().invoke(cmd, components_to_stop_clone);
130
1
                (rx_item, Some(tx_interrupt_cmd))
131
1
            },
132
1
            |rx| (rx, None),
133
        );
134
135
2
        let components_to_stop_clone = components_to_stop.clone();
136
        let (tx_interrupt, collector_handle) =
137
2
            collect_items(components_to_stop_clone, rx_item, move |items| _ = app_tx.send(items));
138
139
2
        ReaderControl {
140
2
            tx_interrupt,
141
2
            tx_interrupt_cmd,
142
2
            components_to_stop,
143
2
            collector_handle: Some(collector_handle),
144
2
            items,
145
2
        }
146
2
    }
147
148
    /// Starts collecting items and sending them to the pool directly
149
    /// Returns a control handle
150
451
    pub fn collect(&mut self, item_pool: Arc<ItemPool>, cmd: &str) -> ReaderControl {
151
451
        let components_to_stop: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
152
451
        let items = Arc::new(SpinLock::new(Vec::new()));
153
154
451
        let (rx_item, tx_interrupt_cmd) = self.rx_item.take().map_or_else(
155
70
            || {
156
70
                let components_to_stop_clone = components_to_stop.clone();
157
70
                let (rx_item, tx_interrupt_cmd) = self.cmd_collector.borrow_mut().invoke(cmd, components_to_stop_clone);
158
70
                (rx_item, Some(tx_interrupt_cmd))
159
70
            },
160
381
            |rx| (rx, None),
161
        );
162
163
451
        let components_to_stop_clone = components_to_stop.clone();
164
451
        let (tx_interrupt, collector_handle) = collect_items(components_to_stop_clone, rx_item, move |items| 
{425
165
425
            item_pool.append(items);
166
425
        });
167
451
        debug!("collect: started ({
components_to_stop1
:?} components)");
168
169
451
        ReaderControl {
170
451
            tx_interrupt,
171
451
            tx_interrupt_cmd,
172
451
            components_to_stop,
173
451
            collector_handle: Some(collector_handle),
174
451
            items,
175
451
        }
176
451
    }
177
}
178
179
impl Default for Reader {
180
7
    fn default() -> Self {
181
7
        Self {
182
7
            cmd_collector: Rc::new(RefCell::new(SkimItemReader::default())) as Rc<RefCell<dyn CommandCollector>>,
183
7
            rx_item: Default::default(),
184
7
        }
185
7
    }
186
}
187
188
453
fn collect_items<F>(
189
453
    components_to_stop: Arc<AtomicUsize>,
190
453
    rx_item: SkimItemReceiver,
191
453
    callback: F,
192
453
) -> (Sender<i32>, JoinHandle<()>)
193
453
where
194
453
    F: Fn(Vec<Arc<dyn SkimItem>>) + Send + 'static,
195
{
196
453
    let (tx_interrupt, rx_interrupt) = crate::prelude::bounded(8);
197
198
453
    components_to_stop.fetch_add(1, Ordering::AcqRel);
199
453
    let handle = std::thread::spawn(move || {
200
453
        debug!(
"collect_item start"1
);
201
202
        loop {
203
931
            if let Ok(Some(msg)) = rx_interrupt.try_recv() {
  Branch (203:20): [True: 0, False: 0]
+  Branch (203:20): [True: 0, False: 882]
+
  Branch (203:20): [True: 0, False: 9]
+  Branch (203:20): [True: 2, False: 38]
+
204
2
                debug!("interrupt: {
msg0
}");
205
2
                break;
206
929
            }
207
929
            match rx_item.recv_timeout(std::time::Duration::from_millis(1)) {
208
                Ok(items) => {
209
427
                    trace!("collect_item: got {} items", 
items.len()2
);
210
427
                    callback(items);
211
                }
212
51
                Err(kanal::ReceiveErrorTimeout::Timeout) => {
213
51
                    // No items within the timeout — loop back to check the
214
51
                    // interrupt channel before blocking again.
215
51
                }
216
                Err(kanal::ReceiveErrorTimeout::Closed | kanal::ReceiveErrorTimeout::SendClosed) => {
217
451
                    break;
218
                }
219
            }
220
        }
221
222
453
        components_to_stop.fetch_sub(1, Ordering::AcqRel);
223
453
        debug!(
"collect_item stop"1
);
224
453
    });
225
226
453
    (tx_interrupt, handle)
227
453
}
228
229
#[cfg(test)]
230
#[cfg_attr(coverage, coverage(off))]
231
mod tests {
232
    use super::*;
233
    use std::io::Cursor;
234
    use std::time::{Duration, Instant};
235
236
    fn source(text: &str) -> SkimItemReceiver {
237
        SkimItemReader::default().of_bufread(Cursor::new(text.to_owned().into_bytes()))
238
    }
239
240
    /// Spin until `cond` holds or a short timeout elapses.
241
    fn wait_until(mut cond: impl FnMut() -> bool) {
242
        let start = Instant::now();
243
        while !cond() && start.elapsed() < Duration::from_secs(5) {
244
            std::thread::sleep(Duration::from_millis(2));
245
        }
246
    }
247
248
    #[test]
249
    fn collect_streams_items_into_pool() {
250
        let pool = Arc::new(ItemPool::new());
251
        let mut reader = Reader::default().source(Some(source("a\nb\nc\n")));
252
        let control = reader.collect(pool.clone(), "");
253
        wait_until(|| pool.len() == 3);
254
        assert_eq!(pool.len(), 3);
255
        drop(control);
256
    }
257
258
    #[test]
259
    fn run_sends_items_to_channel() {
260
        let (tx, rx) = kanal::unbounded::<Vec<Arc<dyn SkimItem>>>();
261
        let mut reader = Reader::default().source(Some(source("x\ny\n")));
262
        let control = reader.run(tx, "");
263
        wait_until(|| control.is_done());
264
265
        let mut count = 0;
266
        while let Ok(Some(batch)) = rx.try_recv() {
267
            count += batch.len();
268
        }
269
        assert_eq!(count, 2);
270
        drop(control);
271
    }
272
273
    #[test]
274
    fn is_done_true_after_completion() {
275
        let pool = Arc::new(ItemPool::new());
276
        let mut reader = Reader::default().source(Some(source("only\n")));
277
        let control = reader.collect(pool.clone(), "");
278
        wait_until(|| control.is_done());
279
        assert!(control.is_done());
280
    }
281
282
    #[test]
283
    fn kill_stops_all_components() {
284
        let pool = Arc::new(ItemPool::new());
285
        let mut reader = Reader::default().source(Some(source("a\nb\n")));
286
        let mut control = reader.collect(pool, "");
287
        control.kill();
288
        // After kill, no components remain running.
289
        assert!(control.is_done());
290
    }
291
292
    #[test]
293
    fn run_without_source_invokes_command() {
294
        // With no preset source, `run` falls back to invoking the command via
295
        // the command collector.
296
        #[cfg(unix)]
297
        let cmd = "printf 'a\\nb\\n'";
298
        #[cfg(windows)]
299
        let cmd = "echo a & echo b";
300
301
        let (tx, rx) = kanal::unbounded::<Vec<Arc<dyn SkimItem>>>();
302
        let mut reader = Reader::default();
303
        let control = reader.run(tx, cmd);
304
        wait_until(|| control.is_done());
305
306
        let mut count = 0;
307
        while let Ok(Some(batch)) = rx.try_recv() {
308
            count += batch.len();
309
        }
310
        assert_eq!(count, 2);
311
        drop(control);
312
    }
313
314
    #[test]
315
    fn collect_without_source_invokes_command() {
316
        // Same command-invoking fallback for the pool-collecting path.
317
        #[cfg(unix)]
318
        let cmd = "printf 'x\\ny\\nz\\n'";
319
        #[cfg(windows)]
320
        let cmd = "echo x & echo y & echo z";
321
322
        let pool = Arc::new(ItemPool::new());
323
        let mut reader = Reader::default();
324
        let control = reader.collect(pool.clone(), cmd);
325
        wait_until(|| pool.len() == 3);
326
        assert_eq!(pool.len(), 3);
327
        drop(control);
328
    }
329
330
    #[test]
331
    fn take_returns_empty_for_pool_collection() {
332
        // `collect` routes items to the pool, not the control's own buffer.
333
        let pool = Arc::new(ItemPool::new());
334
        let mut reader = Reader::default().source(Some(source("a\n")));
335
        let control = reader.collect(pool, "");
336
        wait_until(|| control.is_done());
337
        assert!(control.take().is_empty());
338
    }
339
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/shell.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/shell.rs.html index 4dc3c4e9..ca91024b 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/shell.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/shell.rs.html @@ -1,7 +1,7 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/shell.rs
Line
Count
Source
1
//! Provides helpers to easily generate shell completions
2
use std::io::Write;
3
4
use clap::CommandFactory;
5
6
use crate::SkimOptions;
7
8
/// Available shells for completion generation
9
#[derive(Clone, clap::ValueEnum, PartialEq, Debug)]
10
pub enum Shell {
11
    /// Bourne Again `SHell`
12
    Bash,
13
    /// Elvish shell
14
    Elvish,
15
    /// Friendly Interactive `SHell`
16
    Fish,
17
    /// Nushell (nu)
18
    Nushell,
19
    /// `PowerShell`
20
    PowerShell,
21
    /// Zsh
22
    Zsh,
23
}
24
25
/// Generate the completion and write it to stdout
26
8
pub fn generate_completions(sh: &Shell, output: &mut impl Write) {
27
    use Shell::{Bash, Elvish, Fish, Nushell, PowerShell, Zsh};
28
8
    let cmd = &mut SkimOptions::command();
29
8
    let bin_name = "sk";
30
31
8
    if *sh == Nushell {
  Branch (31:8): [Folded - Ignored]
-
  Branch (31:8): [True: 0, False: 2]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/shell.rs
Line
Count
Source
1
//! Provides helpers to easily generate shell completions
2
use std::io::Write;
3
4
use clap::CommandFactory;
5
6
use crate::SkimOptions;
7
8
/// Available shells for completion generation
9
#[derive(Clone, clap::ValueEnum, PartialEq, Debug)]
10
pub enum Shell {
11
    /// Bourne Again `SHell`
12
    Bash,
13
    /// Elvish shell
14
    Elvish,
15
    /// Friendly Interactive `SHell`
16
    Fish,
17
    /// Nushell (nu)
18
    Nushell,
19
    /// `PowerShell`
20
    PowerShell,
21
    /// Zsh
22
    Zsh,
23
}
24
25
/// Generate the completion and write it to stdout
26
8
pub fn generate_completions(sh: &Shell, output: &mut impl Write) {
27
    use Shell::{Bash, Elvish, Fish, Nushell, PowerShell, Zsh};
28
8
    let cmd = &mut SkimOptions::command();
29
8
    let bin_name = "sk";
30
31
8
    if *sh == Nushell {
  Branch (31:8): [True: 0, False: 2]
+
  Branch (31:8): [Folded - Ignored]
 
  Branch (31:8): [True: 1, False: 5]
-
32
1
        clap_complete::generate(clap_complete_nushell::Nushell, cmd, bin_name, output);
33
    } else {
34
7
        let clap_shell: clap_complete::Shell = match sh {
35
2
            Bash => clap_complete::Shell::Bash,
36
1
            Elvish => clap_complete::Shell::Elvish,
37
1
            Fish => clap_complete::Shell::Fish,
38
1
            PowerShell => clap_complete::Shell::PowerShell,
39
2
            Zsh => clap_complete::Shell::Zsh,
40
0
            Nushell => unreachable!(),
41
        };
42
7
        clap_complete::generate(clap_shell, cmd, bin_name, output);
43
    }
44
8
}
45
46
/// Generate the key-bindings script and write it to the given writer
47
/// # Errors
48
/// This errors if it fails to write the bytes to the output
49
7
pub fn generate_key_bindings(sh: &Shell, output: &mut impl Write) -> std::io::Result<()> {
50
    use Shell::{Bash, Fish, Zsh};
51
7
    let binds_script = match sh {
52
1
        Bash => include_str!("../shell/key-bindings.bash"),
53
2
        Zsh => include_str!("../shell/key-bindings.zsh"),
54
1
        Fish => include_str!("../shell/key-bindings.fish"),
55
3
        _ => "",
56
    };
57
7
    if !binds_script.is_empty() {
  Branch (57:8): [Folded - Ignored]
-
  Branch (57:8): [True: 1, False: 0]
+
32
1
        clap_complete::generate(clap_complete_nushell::Nushell, cmd, bin_name, output);
33
    } else {
34
7
        let clap_shell: clap_complete::Shell = match sh {
35
2
            Bash => clap_complete::Shell::Bash,
36
1
            Elvish => clap_complete::Shell::Elvish,
37
1
            Fish => clap_complete::Shell::Fish,
38
1
            PowerShell => clap_complete::Shell::PowerShell,
39
2
            Zsh => clap_complete::Shell::Zsh,
40
0
            Nushell => unreachable!(),
41
        };
42
7
        clap_complete::generate(clap_shell, cmd, bin_name, output);
43
    }
44
8
}
45
46
/// Generate the key-bindings script and write it to the given writer
47
/// # Errors
48
/// This errors if it fails to write the bytes to the output
49
7
pub fn generate_key_bindings(sh: &Shell, output: &mut impl Write) -> std::io::Result<()> {
50
    use Shell::{Bash, Fish, Zsh};
51
7
    let binds_script = match sh {
52
1
        Bash => include_str!("../shell/key-bindings.bash"),
53
2
        Zsh => include_str!("../shell/key-bindings.zsh"),
54
1
        Fish => include_str!("../shell/key-bindings.fish"),
55
3
        _ => "",
56
    };
57
7
    if !binds_script.is_empty() {
  Branch (57:8): [True: 1, False: 0]
+
  Branch (57:8): [Folded - Ignored]
 
  Branch (57:8): [True: 3, False: 3]
 
58
4
        output.write_all(binds_script.as_bytes())
?0
;
59
3
    }
60
7
    Ok(())
61
7
}
62
63
#[cfg(test)]
64
#[path = "shell_tests.rs"]
65
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/skim.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/skim.rs.html index c8007253..e3c088ca 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/skim.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/skim.rs.html @@ -1,4 +1,4 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/skim.rs
Line
Count
Source
1
//! Module containing skim's entry point
2
use std::io::{BufWriter, Stderr};
3
use std::sync::Arc;
4
use std::time::Duration;
5
6
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
7
use eyre::{self, OptionExt, Result};
8
#[cfg(feature = "image")]
9
use ratatui_image::picker::Picker;
10
use tokio::runtime::Handle;
11
use tokio::select;
12
use tokio::task::block_in_place;
13
14
use crate::binds::SkimEvent;
15
use crate::reader::{Reader, ReaderControl};
16
use crate::tui::actions::Action;
17
#[cfg(feature = "image")]
18
use crate::tui::util::detect_image_picker;
19
use crate::tui::{App, Event, Size, TICK_RATE, Tui};
20
use crate::{SkimItem, SkimItemReceiver, SkimOptions, SkimOutput};
21
22
/// Stream type yielded by the IPC listener. With the `listen` feature disabled the
23
/// listener branch can never fire, so its payload type is uninhabited.
24
#[cfg(feature = "listen")]
25
type RemoteStream = interprocess::local_socket::tokio::Stream;
26
#[cfg(not(feature = "listen"))]
27
type RemoteStream = std::convert::Infallible;
28
29
/// Main entry point for running skim
30
pub struct Skim<Backend = ratatui::backend::CrosstermBackend<BufWriter<Stderr>>>
31
where
32
    Backend: ratatui::backend::Backend,
33
    Backend::Error: Send + Sync + 'static,
34
{
35
    app: App,
36
    tui: Option<Tui<Backend>>,
37
    height: Size,
38
    reader: Reader,
39
    reader_done: bool,
40
    initial_cmd: String,
41
    reader_control: Option<ReaderControl>,
42
    matcher_interval: Option<tokio::time::Interval>,
43
    #[cfg(feature = "listen")]
44
    listener: Option<interprocess::local_socket::tokio::Listener>,
45
    final_event: Event,
46
    final_key: KeyEvent,
47
    /// Whether the `start` event has already been fired (fired exactly once).
48
    start_fired: bool,
49
}
50
51
impl Skim {
52
    /// Run skim, collecting items from the source and using options
53
    ///
54
    /// # Params
55
    ///
56
    /// - options: the "complex" options that control how skim behaves
57
    /// - source: a stream of items to be passed to skim for filtering.
58
    ///   If None is given, skim will invoke the command given to fetch the items.
59
    ///
60
    /// # Returns
61
    ///
62
    /// - None: on internal errors.
63
    /// - `SkimOutput`: the collected key, event, query, selected items, etc.
64
    ///
65
    /// # Errors
66
    ///
67
    /// Returns an error if skim initialization or the TUI loop fails.
68
    ///
69
    /// # Panics
70
    ///
71
    /// Panics if the tui fails to initialize
72
32
    pub fn run_with(options: SkimOptions, source: Option<SkimItemReceiver>) -> Result<SkimOutput> {
73
32
        trace!(
"running skim"1
);
74
32
        let mut skim = Self::init(options, source)
?0
;
75
76
32
        skim.start();
77
78
32
        if skim.should_enter() {
  Branch (78:12): [True: 10, False: 22]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/skim.rs
Line
Count
Source
1
//! Module containing skim's entry point
2
use std::io::{BufWriter, Stderr};
3
use std::sync::Arc;
4
use std::time::Duration;
5
6
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
7
use eyre::{self, OptionExt, Result};
8
#[cfg(feature = "image")]
9
use ratatui_image::picker::Picker;
10
use tokio::runtime::Handle;
11
use tokio::select;
12
use tokio::task::block_in_place;
13
14
use crate::binds::SkimEvent;
15
use crate::reader::{Reader, ReaderControl};
16
use crate::tui::actions::Action;
17
#[cfg(feature = "image")]
18
use crate::tui::util::detect_image_picker;
19
use crate::tui::{App, Event, Size, TICK_RATE, Tui};
20
use crate::{SkimItem, SkimItemReceiver, SkimOptions, SkimOutput};
21
22
/// Stream type yielded by the IPC listener. With the `listen` feature disabled the
23
/// listener branch can never fire, so its payload type is uninhabited.
24
#[cfg(feature = "listen")]
25
type RemoteStream = interprocess::local_socket::tokio::Stream;
26
#[cfg(not(feature = "listen"))]
27
type RemoteStream = std::convert::Infallible;
28
29
/// Main entry point for running skim
30
pub struct Skim<Backend = ratatui::backend::CrosstermBackend<BufWriter<Stderr>>>
31
where
32
    Backend: ratatui::backend::Backend,
33
    Backend::Error: Send + Sync + 'static,
34
{
35
    app: App,
36
    tui: Option<Tui<Backend>>,
37
    height: Size,
38
    reader: Reader,
39
    reader_done: bool,
40
    initial_cmd: String,
41
    reader_control: Option<ReaderControl>,
42
    matcher_interval: Option<tokio::time::Interval>,
43
    #[cfg(feature = "listen")]
44
    listener: Option<interprocess::local_socket::tokio::Listener>,
45
    final_event: Event,
46
    final_key: KeyEvent,
47
    /// Whether the `start` event has already been fired (fired exactly once).
48
    start_fired: bool,
49
}
50
51
impl Skim {
52
    /// Run skim, collecting items from the source and using options
53
    ///
54
    /// # Params
55
    ///
56
    /// - options: the "complex" options that control how skim behaves
57
    /// - source: a stream of items to be passed to skim for filtering.
58
    ///   If None is given, skim will invoke the command given to fetch the items.
59
    ///
60
    /// # Returns
61
    ///
62
    /// - None: on internal errors.
63
    /// - `SkimOutput`: the collected key, event, query, selected items, etc.
64
    ///
65
    /// # Errors
66
    ///
67
    /// Returns an error if skim initialization or the TUI loop fails.
68
    ///
69
    /// # Panics
70
    ///
71
    /// Panics if the tui fails to initialize
72
32
    pub fn run_with(options: SkimOptions, source: Option<SkimItemReceiver>) -> Result<SkimOutput> {
73
32
        trace!(
"running skim"1
);
74
32
        let mut skim = Self::init(options, source)
?0
;
75
76
32
        skim.start();
77
78
32
        if skim.should_enter() {
  Branch (78:12): [True: 10, False: 22]
 
  Branch (78:12): [Folded - Ignored]
 
79
10
            skim.init_tui()
?0
;
80
10
            let task = async {
81
10
                skim.enter().await
?0
;
82
10
                skim.run().await
?0
;
83
10
                eyre::Ok(())
84
10
            };
85
86
10
            if let Ok(handle) = Handle::try_current() {
  Branch (86:20): [True: 0, False: 10]
 
  Branch (86:20): [Folded - Ignored]
@@ -6,141 +6,142 @@
 
  Branch (123:16): [Folded - Ignored]
 
124
0
                tx.send(batch)?;
125
0
                batch = Vec::with_capacity(BATCH_SIZE);
126
0
            }
127
0
            batch.push(Arc::new(item) as Arc<dyn SkimItem>);
128
        }
129
0
        tx.send(batch)?;
130
0
        Self::run_with(options, Some(rx))
131
0
    }
132
133
    /// Initialize the TUI with the default crossterm backend, but do not enter it yet
134
    ///
135
    /// # Errors
136
    ///
137
    /// Returns an error if the TUI backend cannot be initialized.
138
10
    pub fn init_tui(&mut self) -> Result<()> {
139
10
        let mut tui = Tui::new_with_height(self.height)
?0
;
140
10
        if self.app.options.no_mouse {
  Branch (140:12): [True: 0, False: 10]
 
  Branch (140:12): [Folded - Ignored]
-
141
0
            tui.disable_mouse();
142
10
        }
143
10
        let min_height = crate::options::parse_min_height(&self.app.options.min_height).map_err(eyre::Report::msg)
?0
;
144
10
        tui.min_height(min_height)
?0
;
145
10
        self.tui = Some(tui);
146
10
        Ok(())
147
10
    }
148
}
149
150
impl<Backend: ratatui::backend::Backend + 'static> Skim<Backend>
151
where
152
    Backend::Error: Send + Sync + 'static,
153
{
154
    /// Initialize skim, without starting anything yet
155
    ///
156
    /// # Errors
157
    ///
158
    /// Returns an error if parsing the height or other options fails.
159
404
    pub fn init(options: SkimOptions, source: Option<SkimItemReceiver>) -> Result<Self> {
160
404
        let height = Size::try_from(options.height.as_str())
?0
;
161
162
        // application state
163
        // Initialize theme from options
164
404
        let theme = Arc::new(crate::theme::ColorTheme::init_from_options(&options));
165
404
        let mut reader = Reader::from_options(&options).source(source);
166
404
        let cmd = options.cmd.clone().unwrap_or_default();
167
168
404
        let app = App::from_options(options, theme.clone(), cmd.clone());
169
170
        // Give the reader its own dedicated pool (⌈N/3⌉ threads) so it never
171
        // competes with the matcher's pool (⌊2N/3⌋ threads) for the same
172
        // worker threads.
173
404
        reader.set_thread_pool(Arc::clone(&app.reader_pool));
174
175
        //------------------------------------------------------------------------------
176
        // reader
177
        // In interactive mode, expand all placeholders ({}, {q}, etc) with initial query (empty or from --query)
178
404
        let initial_cmd = if app.options.interactive && 
app.options.cmd.is_some()31
{
  Branch (178:30): [True: 0, False: 11]
+
141
0
            tui.disable_mouse();
142
10
        }
143
10
        let min_height = crate::options::parse_min_height(&self.app.options.min_height).map_err(eyre::Report::msg)
?0
;
144
10
        tui.min_height(min_height)
?0
;
145
10
        self.tui = Some(tui);
146
10
        Ok(())
147
10
    }
148
}
149
150
impl<Backend: ratatui::backend::Backend + 'static> Skim<Backend>
151
where
152
    Backend::Error: Send + Sync + 'static,
153
{
154
    /// Initialize skim, without starting anything yet
155
    ///
156
    /// # Errors
157
    ///
158
    /// Returns an error if parsing the height or other options fails.
159
403
    pub fn init(options: SkimOptions, source: Option<SkimItemReceiver>) -> Result<Self> {
160
403
        let height = Size::try_from(options.height.as_str())
?0
;
161
162
        // application state
163
        // Initialize theme from options
164
403
        let theme = Arc::new(crate::theme::ColorTheme::init_from_options(&options));
165
403
        let mut reader = Reader::from_options(&options).source(source);
166
403
        let cmd = options.cmd.clone().unwrap_or_default();
167
168
403
        let app = App::from_options(options, theme.clone(), cmd.clone());
169
170
        // Give the reader its own dedicated pool (⌈N/3⌉ threads) so it never
171
        // competes with the matcher's pool (⌊2N/3⌋ threads) for the same
172
        // worker threads.
173
403
        reader.set_thread_pool(Arc::clone(&app.reader_pool));
174
175
        //------------------------------------------------------------------------------
176
        // reader
177
        // In interactive mode, expand all placeholders ({}, {q}, etc) with initial query (empty or from --query)
178
403
        let initial_cmd = if app.options.interactive && 
app.options.cmd.is_some()32
{
  Branch (178:30): [True: 0, False: 32]
   Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 0, False: 32]
+
  Branch (178:30): [True: 0, False: 36]
+  Branch (178:57): [True: 0, False: 0]
+
  Branch (178:30): [True: 22, False: 0]
+  Branch (178:57): [True: 17, False: 5]
+
  Branch (178:30): [True: 0, False: 12]
+  Branch (178:57): [True: 0, False: 0]
+
  Branch (178:30): [True: 0, False: 5]
+  Branch (178:57): [True: 0, False: 0]
+
  Branch (178:30): [True: 2, False: 3]
+  Branch (178:57): [True: 2, False: 0]
+
  Branch (178:30): [True: 0, False: 1]
+  Branch (178:57): [True: 0, False: 0]
+
  Branch (178:30): [True: 0, False: 3]
+  Branch (178:57): [True: 0, False: 0]
+
  Branch (178:30): [True: 0, False: 23]
+  Branch (178:57): [True: 0, False: 0]
+
  Branch (178:30): [True: 0, False: 12]
+  Branch (178:57): [True: 0, False: 0]
+
  Branch (178:30): [True: 1, False: 15]
+  Branch (178:57): [True: 0, False: 1]
+
  Branch (178:30): [True: 0, False: 35]
+  Branch (178:57): [True: 0, False: 0]
+
  Branch (178:30): [True: 0, False: 9]
+  Branch (178:57): [True: 0, False: 0]
+
  Branch (178:30): [True: 5, False: 121]
+  Branch (178:57): [True: 5, False: 0]
+
  Branch (178:30): [True: 2, False: 25]
+  Branch (178:57): [True: 2, False: 0]
+
  Branch (178:30): [True: 0, False: 7]
+  Branch (178:57): [True: 0, False: 0]
+
  Branch (178:30): [True: 0, False: 11]
   Branch (178:57): [True: 0, False: 0]
 
  Branch (178:30): [True: 0, False: 12]
   Branch (178:57): [True: 0, False: 0]
 
  Branch (178:30): [True: 0, False: 9]
   Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 1, False: 3]
-  Branch (178:57): [True: 1, False: 0]
-
  Branch (178:30): [True: 0, False: 7]
-  Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 0, False: 9]
-  Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 2, False: 24]
-  Branch (178:57): [True: 2, False: 0]
-
  Branch (178:30): [True: 0, False: 39]
-  Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 0, False: 33]
-  Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 22, False: 0]
-  Branch (178:57): [True: 17, False: 5]
-
  Branch (178:30): [True: 5, False: 125]
-  Branch (178:57): [True: 5, False: 0]
-
  Branch (178:30): [True: 0, False: 11]
-  Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 1, False: 15]
-  Branch (178:57): [True: 0, False: 1]
-
  Branch (178:30): [True: 0, False: 1]
-  Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 0, False: 2]
-  Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 0, False: 22]
-  Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 0, False: 5]
-  Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 0, False: 13]
-  Branch (178:57): [True: 0, False: 0]
-
179
25
            let expanded = app.expand_cmd(&cmd, true);
180
25
            log::debug!("Interactive mode: initial_cmd = {
expanded0
:?} (from template {
cmd0
:?})");
181
25
            expanded
182
        } else {
183
379
            cmd.clone()
184
        };
185
404
        Ok(Self {
186
404
            app,
187
404
            height,
188
404
            reader,
189
404
            reader_done: false,
190
404
            initial_cmd,
191
404
            tui: None,
192
404
            reader_control: None,
193
404
            matcher_interval: None,
194
404
            #[cfg(feature = "listen")]
195
404
            listener: None,
196
404
            final_event: Event::Quit,
197
404
            final_key: KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
198
404
            start_fired: false,
199
404
        })
200
404
    }
201
202
    /// Start the reader and matcher, but do not enter the TUI yet
203
404
    pub fn start(&mut self) {
204
404
        debug!("Starting reader with initial_cmd: {:?}", 
self.initial_cmd1
);
205
404
        self.reader_control = Some(self.reader.collect(self.app.item_pool.clone(), &self.initial_cmd));
206
404
        self.app.restart_matcher(true);
207
        // If the TUI is already available (e.g. test harnesses that build the
208
        // TUI before starting), fire the `start` event now. In the normal
209
        // binary flow the TUI is created after `start()`, so `enter()` fires it.
210
404
        self.fire_start_event();
211
404
    }
212
213
    /// Fire the `start` event exactly once, as soon as the TUI event channel is
214
    /// available. The event is routed through the keymap like any other key, so
215
    /// a `--bind start:<action>` binding runs when skim comes up. In sync mode,
216
    /// render the completed matcher output first so the action sees every item.
217
575
    fn fire_start_event(&mut self) {
218
575
        if self.start_fired {
  Branch (218:12): [True: 0, False: 11]
-
  Branch (218:12): [True: 157, False: 42]
+
179
26
            let expanded = app.expand_cmd(&cmd, true);
180
26
            log::debug!("Interactive mode: initial_cmd = {
expanded0
:?} (from template {
cmd0
:?})");
181
26
            expanded
182
        } else {
183
377
            cmd.clone()
184
        };
185
403
        Ok(Self {
186
403
            app,
187
403
            height,
188
403
            reader,
189
403
            reader_done: false,
190
403
            initial_cmd,
191
403
            tui: None,
192
403
            reader_control: None,
193
403
            matcher_interval: None,
194
403
            #[cfg(feature = "listen")]
195
403
            listener: None,
196
403
            final_event: Event::Quit,
197
403
            final_key: KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
198
403
            start_fired: false,
199
403
        })
200
403
    }
201
202
    /// Start the reader and matcher, but do not enter the TUI yet
203
403
    pub fn start(&mut self) {
204
403
        debug!("Starting reader with initial_cmd: {:?}", 
self.initial_cmd1
);
205
403
        self.reader_control = Some(self.reader.collect(self.app.item_pool.clone(), &self.initial_cmd));
206
403
        self.app.restart_matcher(true);
207
        // If the TUI is already available (e.g. test harnesses that build the
208
        // TUI before starting), fire the `start` event now. In the normal
209
        // binary flow the TUI is created after `start()`, so `enter()` fires it.
210
403
        self.fire_start_event();
211
403
    }
212
213
    /// Fire the `start` event exactly once, as soon as the TUI event channel is
214
    /// available. The event is routed through the keymap like any other key, so
215
    /// a `--bind start:<action>` binding runs when skim comes up. In sync mode,
216
    /// render the completed matcher output first so the action sees every item.
217
573
    fn fire_start_event(&mut self) {
218
573
        if self.start_fired {
  Branch (218:12): [True: 154, False: 42]
+
  Branch (218:12): [True: 0, False: 36]
+
  Branch (218:12): [True: 0, False: 22]
+
  Branch (218:12): [True: 0, False: 12]
+
  Branch (218:12): [True: 0, False: 5]
+
  Branch (218:12): [True: 0, False: 5]
+
  Branch (218:12): [True: 0, False: 1]
+
  Branch (218:12): [True: 0, False: 3]
+
  Branch (218:12): [True: 0, False: 23]
+
  Branch (218:12): [True: 0, False: 12]
+
  Branch (218:12): [True: 6, False: 16]
+
  Branch (218:12): [True: 0, False: 35]
+
  Branch (218:12): [True: 0, False: 9]
+
  Branch (218:12): [True: 0, False: 126]
+
  Branch (218:12): [True: 0, False: 27]
+
  Branch (218:12): [True: 0, False: 7]
+
  Branch (218:12): [True: 0, False: 11]
 
  Branch (218:12): [True: 0, False: 12]
 
  Branch (218:12): [True: 0, False: 9]
-
  Branch (218:12): [True: 0, False: 4]
-
  Branch (218:12): [True: 0, False: 7]
-
  Branch (218:12): [True: 0, False: 9]
-
  Branch (218:12): [True: 0, False: 26]
-
  Branch (218:12): [True: 0, False: 39]
-
  Branch (218:12): [True: 0, False: 33]
-
  Branch (218:12): [True: 0, False: 22]
-
  Branch (218:12): [True: 0, False: 130]
-
  Branch (218:12): [True: 0, False: 11]
-
  Branch (218:12): [True: 4, False: 16]
-
  Branch (218:12): [True: 0, False: 1]
-
  Branch (218:12): [True: 0, False: 2]
-
  Branch (218:12): [True: 0, False: 22]
-
  Branch (218:12): [True: 0, False: 5]
-
  Branch (218:12): [True: 0, False: 13]
-
219
161
            return;
220
414
        }
221
414
        if let Some(tui) = self.tui.as_ref() {
  Branch (221:16): [True: 11, False: 0]
-
  Branch (221:16): [True: 10, False: 32]
+
219
160
            return;
220
413
        }
221
413
        if let Some(tui) = self.tui.as_ref() {
  Branch (221:16): [True: 10, False: 32]
+
  Branch (221:16): [True: 36, False: 0]
+
  Branch (221:16): [True: 22, False: 0]
+
  Branch (221:16): [True: 12, False: 0]
+
  Branch (221:16): [True: 5, False: 0]
+
  Branch (221:16): [True: 5, False: 0]
+
  Branch (221:16): [True: 1, False: 0]
+
  Branch (221:16): [True: 3, False: 0]
+
  Branch (221:16): [True: 23, False: 0]
+
  Branch (221:16): [True: 12, False: 0]
+
  Branch (221:16): [True: 16, False: 0]
+
  Branch (221:16): [True: 35, False: 0]
+
  Branch (221:16): [True: 9, False: 0]
+
  Branch (221:16): [True: 126, False: 0]
+
  Branch (221:16): [True: 27, False: 0]
+
  Branch (221:16): [True: 7, False: 0]
+
  Branch (221:16): [True: 11, False: 0]
 
  Branch (221:16): [True: 12, False: 0]
 
  Branch (221:16): [True: 9, False: 0]
-
  Branch (221:16): [True: 4, False: 0]
-
  Branch (221:16): [True: 7, False: 0]
-
  Branch (221:16): [True: 9, False: 0]
-
  Branch (221:16): [True: 26, False: 0]
-
  Branch (221:16): [True: 39, False: 0]
-
  Branch (221:16): [True: 33, False: 0]
-
  Branch (221:16): [True: 22, False: 0]
-
  Branch (221:16): [True: 130, False: 0]
-
  Branch (221:16): [True: 11, False: 0]
-
  Branch (221:16): [True: 16, False: 0]
-
  Branch (221:16): [True: 1, False: 0]
-
  Branch (221:16): [True: 2, False: 0]
-
  Branch (221:16): [True: 22, False: 0]
-
  Branch (221:16): [True: 5, False: 0]
-
  Branch (221:16): [True: 13, False: 0]
-
222
382
            if self.app.options.sync && 
tui.event_tx.try_send(Event::Render).is_err()2
{
  Branch (222:16): [True: 0, False: 11]
+
222
381
            if self.app.options.sync && 
tui.event_tx.try_send(Event::Render).is_err()2
{
  Branch (222:16): [True: 0, False: 10]
   Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 10]
+
  Branch (222:16): [True: 0, False: 36]
+  Branch (222:41): [True: 0, False: 0]
+
  Branch (222:16): [True: 0, False: 22]
+  Branch (222:41): [True: 0, False: 0]
+
  Branch (222:16): [True: 0, False: 12]
+  Branch (222:41): [True: 0, False: 0]
+
  Branch (222:16): [True: 0, False: 5]
+  Branch (222:41): [True: 0, False: 0]
+
  Branch (222:16): [True: 0, False: 5]
+  Branch (222:41): [True: 0, False: 0]
+
  Branch (222:16): [True: 0, False: 1]
+  Branch (222:41): [True: 0, False: 0]
+
  Branch (222:16): [True: 0, False: 3]
+  Branch (222:41): [True: 0, False: 0]
+
  Branch (222:16): [True: 0, False: 23]
+  Branch (222:41): [True: 0, False: 0]
+
  Branch (222:16): [True: 0, False: 12]
+  Branch (222:41): [True: 0, False: 0]
+
  Branch (222:16): [True: 1, False: 15]
+  Branch (222:41): [True: 0, False: 1]
+
  Branch (222:16): [True: 0, False: 35]
+  Branch (222:41): [True: 0, False: 0]
+
  Branch (222:16): [True: 0, False: 9]
+  Branch (222:41): [True: 0, False: 0]
+
  Branch (222:16): [True: 0, False: 126]
+  Branch (222:41): [True: 0, False: 0]
+
  Branch (222:16): [True: 1, False: 26]
+  Branch (222:41): [True: 0, False: 1]
+
  Branch (222:16): [True: 0, False: 7]
+  Branch (222:41): [True: 0, False: 0]
+
  Branch (222:16): [True: 0, False: 11]
   Branch (222:41): [True: 0, False: 0]
 
  Branch (222:16): [True: 0, False: 12]
   Branch (222:41): [True: 0, False: 0]
 
  Branch (222:16): [True: 0, False: 9]
   Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 4]
-  Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 7]
-  Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 9]
-  Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 1, False: 25]
-  Branch (222:41): [True: 0, False: 1]
-
  Branch (222:16): [True: 0, False: 39]
-  Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 33]
-  Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 22]
-  Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 130]
-  Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 11]
-  Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 1, False: 15]
-  Branch (222:41): [True: 0, False: 1]
-
  Branch (222:16): [True: 0, False: 1]
-  Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 2]
-  Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 22]
-  Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 5]
-  Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 13]
-  Branch (222:41): [True: 0, False: 0]
-
223
0
                return;
224
382
            }
225
382
            if tui.event_tx.try_send(Event::Key(SkimEvent::Start.into())).is_ok() {
  Branch (225:16): [True: 11, False: 0]
-
  Branch (225:16): [True: 10, False: 0]
+
223
0
                return;
224
381
            }
225
381
            if tui.event_tx.try_send(Event::Key(SkimEvent::Start.into())).is_ok() {
  Branch (225:16): [True: 10, False: 0]
+
  Branch (225:16): [True: 36, False: 0]
+
  Branch (225:16): [True: 22, False: 0]
+
  Branch (225:16): [True: 12, False: 0]
+
  Branch (225:16): [True: 5, False: 0]
+
  Branch (225:16): [True: 5, False: 0]
+
  Branch (225:16): [True: 1, False: 0]
+
  Branch (225:16): [True: 3, False: 0]
+
  Branch (225:16): [True: 23, False: 0]
+
  Branch (225:16): [True: 12, False: 0]
+
  Branch (225:16): [True: 16, False: 0]
+
  Branch (225:16): [True: 35, False: 0]
+
  Branch (225:16): [True: 9, False: 0]
+
  Branch (225:16): [True: 126, False: 0]
+
  Branch (225:16): [True: 27, False: 0]
+
  Branch (225:16): [True: 7, False: 0]
+
  Branch (225:16): [True: 11, False: 0]
 
  Branch (225:16): [True: 12, False: 0]
 
  Branch (225:16): [True: 9, False: 0]
-
  Branch (225:16): [True: 4, False: 0]
-
  Branch (225:16): [True: 7, False: 0]
-
  Branch (225:16): [True: 9, False: 0]
-
  Branch (225:16): [True: 26, False: 0]
-
  Branch (225:16): [True: 39, False: 0]
-
  Branch (225:16): [True: 33, False: 0]
-
  Branch (225:16): [True: 22, False: 0]
-
  Branch (225:16): [True: 130, False: 0]
-
  Branch (225:16): [True: 11, False: 0]
-
  Branch (225:16): [True: 16, False: 0]
-
  Branch (225:16): [True: 1, False: 0]
-
  Branch (225:16): [True: 2, False: 0]
-
  Branch (225:16): [True: 22, False: 0]
-
  Branch (225:16): [True: 5, False: 0]
-
  Branch (225:16): [True: 13, False: 0]
-
226
382
                self.start_fired = true;
227
0
            }
228
32
        }
229
575
    }
230
231
    /// Handle a reload event by killing the current reader, clearing items, and starting a new reader.
232
    ///
233
    /// This encapsulates the reload logic from the main event loop so it can
234
    /// be reused by test harnesses without reimplementing it.
235
43
    pub fn handle_reload(&mut self, new_cmd: &str) {
236
43
        debug!("reloading with cmd {
new_cmd0
}");
237
        // Kill the current reader
238
43
        if let Some(rc) = self.reader_control.as_mut() {
  Branch (238:16): [True: 0, False: 0]
+
226
381
                self.start_fired = true;
227
0
            }
228
32
        }
229
573
    }
230
231
    /// Handle a reload event by killing the current reader, clearing items, and starting a new reader.
232
    ///
233
    /// This encapsulates the reload logic from the main event loop so it can
234
    /// be reused by test harnesses without reimplementing it.
235
43
    pub fn handle_reload(&mut self, new_cmd: &str) {
236
43
        debug!("reloading with cmd {
new_cmd0
}");
237
        // Kill the current reader
238
43
        if let Some(rc) = self.reader_control.as_mut() {
  Branch (238:16): [True: 0, False: 0]
 
  Branch (238:16): [True: 0, False: 0]
+
  Branch (238:16): [True: 33, False: 0]
 
  Branch (238:16): [True: 0, False: 0]
 
  Branch (238:16): [True: 0, False: 0]
 
  Branch (238:16): [True: 6, False: 0]
@@ -149,17 +150,17 @@
 
  Branch (238:16): [True: 0, False: 0]
 
  Branch (238:16): [True: 0, False: 0]
 
  Branch (238:16): [True: 0, False: 0]
-
  Branch (238:16): [True: 33, False: 0]
+
  Branch (238:16): [True: 0, False: 0]
+
  Branch (238:16): [True: 0, False: 0]
 
  Branch (238:16): [True: 4, False: 0]
 
  Branch (238:16): [True: 0, False: 0]
 
  Branch (238:16): [True: 0, False: 0]
 
  Branch (238:16): [True: 0, False: 0]
 
  Branch (238:16): [True: 0, False: 0]
 
  Branch (238:16): [True: 0, False: 0]
-
  Branch (238:16): [True: 0, False: 0]
-
  Branch (238:16): [True: 0, False: 0]
 
239
43
            rc.kill();
240
0
        }
241
        // Clear items
242
43
        self.app.item_pool.clear();
243
        // Clear displayed items unless no_clear_if_empty is set
244
43
        if !self.app.options.no_clear_if_empty {
  Branch (244:12): [True: 0, False: 0]
 
  Branch (244:12): [True: 0, False: 0]
+
  Branch (244:12): [True: 33, False: 0]
 
  Branch (244:12): [True: 0, False: 0]
 
  Branch (244:12): [True: 0, False: 0]
 
  Branch (244:12): [True: 6, False: 0]
@@ -168,111 +169,110 @@
 
  Branch (244:12): [True: 0, False: 0]
 
  Branch (244:12): [True: 0, False: 0]
 
  Branch (244:12): [True: 0, False: 0]
-
  Branch (244:12): [True: 33, False: 0]
+
  Branch (244:12): [True: 0, False: 0]
+
  Branch (244:12): [True: 0, False: 0]
 
  Branch (244:12): [True: 3, False: 1]
 
  Branch (244:12): [True: 0, False: 0]
 
  Branch (244:12): [True: 0, False: 0]
 
  Branch (244:12): [True: 0, False: 0]
 
  Branch (244:12): [True: 0, False: 0]
 
  Branch (244:12): [True: 0, False: 0]
-
  Branch (244:12): [True: 0, False: 0]
-
  Branch (244:12): [True: 0, False: 0]
-
245
42
            self.app.item_list.clear();
246
1
        }
247
43
        self.app.restart_matcher(true);
248
        // Start a new reader with the new command
249
43
        self.reader_control = Some(self.reader.collect(self.app.item_pool.clone(), new_cmd));
250
43
        self.reader_done = false;
251
        // A new read is in flight: arm the `load` event to fire again once the
252
        // new item set has been read and rendered.
253
43
        self.app.reader_done = false;
254
43
        self.app.load_event_fired = false;
255
43
    }
256
257
    /// Check if the reader has finished and restart the matcher if needed.
258
    ///
259
    /// This encapsulates the reader-status check from the main event loop
260
    /// so it can be reused by test harnesses.
261
    ///
262
    /// Returns `true` if the reader has completed.
263
9.71k
    pub fn check_reader(&mut self) -> bool {
264
9.71k
        if self
  Branch (264:12): [True: 604, False: 2]
-
  Branch (264:12): [True: 134, False: 0]
-
  Branch (264:12): [True: 865, False: 4]
-
  Branch (264:12): [True: 99, False: 1]
-
  Branch (264:12): [True: 169, False: 25]
+
245
42
            self.app.item_list.clear();
246
1
        }
247
43
        self.app.restart_matcher(true);
248
        // Start a new reader with the new command
249
43
        self.reader_control = Some(self.reader.collect(self.app.item_pool.clone(), new_cmd));
250
43
        self.reader_done = false;
251
        // A new read is in flight: arm the `load` event to fire again once the
252
        // new item set has been read and rendered.
253
43
        self.app.reader_done = false;
254
43
        self.app.load_event_fired = false;
255
43
    }
256
257
    /// Check if the reader has finished and restart the matcher if needed.
258
    ///
259
    /// This encapsulates the reader-status check from the main event loop
260
    /// so it can be reused by test harnesses.
261
    ///
262
    /// Returns `true` if the reader has completed.
263
9.73k
    pub fn check_reader(&mut self) -> bool {
264
9.73k
        if self
  Branch (264:12): [True: 129, False: 2]
+
  Branch (264:12): [True: 718, False: 11]
+
  Branch (264:12): [True: 752, False: 109]
+
  Branch (264:12): [True: 394, False: 4]
+
  Branch (264:12): [True: 112, False: 1]
+
  Branch (264:12): [True: 179, False: 25]
+
  Branch (264:12): [True: 24, False: 0]
+
  Branch (264:12): [True: 117, False: 0]
+
  Branch (264:12): [True: 798, False: 4]
+
  Branch (264:12): [True: 129, False: 4]
+
  Branch (264:12): [True: 10, False: 3]
+
  Branch (264:12): [True: 557, False: 8]
+
  Branch (264:12): [True: 584, False: 2]
+
  Branch (264:12): [True: 2.46k, False: 54]
+
  Branch (264:12): [True: 747, False: 2]
 
  Branch (264:12): [True: 274, False: 1]
-
  Branch (264:12): [True: 99, False: 5]
-
  Branch (264:12): [True: 737, False: 1]
-
  Branch (264:12): [True: 749, False: 11]
-
  Branch (264:12): [True: 537, False: 11]
-
  Branch (264:12): [True: 753, False: 108]
-
  Branch (264:12): [True: 2.50k, False: 53]
 
  Branch (264:12): [True: 548, False: 0]
-
  Branch (264:12): [True: 7, False: 5]
-
  Branch (264:12): [True: 24, False: 1]
-
  Branch (264:12): [True: 75, False: 0]
-
  Branch (264:12): [True: 788, False: 3]
-
  Branch (264:12): [True: 112, False: 2]
-
  Branch (264:12): [True: 404, False: 3]
-
265
9.71k
            .reader_control
266
9.71k
            .as_ref()
267
9.71k
            .is_some_and(super::reader::ReaderControl::is_done)
268
9.48k
            && !self.reader_done
  Branch (268:16): [True: 11, False: 593]
-
  Branch (268:16): [True: 10, False: 124]
+
  Branch (264:12): [True: 865, False: 3]
+
  Branch (264:12): [True: 99, False: 4]
+
265
9.73k
            .reader_control
266
9.73k
            .as_ref()
267
9.73k
            .is_some_and(super::reader::ReaderControl::is_done)
268
9.49k
            && !self.reader_done
  Branch (268:16): [True: 10, False: 119]
+
  Branch (268:16): [True: 36, False: 682]
+
  Branch (268:16): [True: 55, False: 697]
+
  Branch (268:16): [True: 12, False: 382]
+
  Branch (268:16): [True: 5, False: 107]
+
  Branch (268:16): [True: 11, False: 168]
+
  Branch (268:16): [True: 1, False: 23]
+
  Branch (268:16): [True: 3, False: 114]
+
  Branch (268:16): [True: 23, False: 775]
+
  Branch (268:16): [True: 12, False: 117]
+
  Branch (268:16): [True: 7, False: 3]
+
  Branch (268:16): [True: 35, False: 522]
+
  Branch (268:16): [True: 9, False: 575]
+
  Branch (268:16): [True: 130, False: 2.33k]
+
  Branch (268:16): [True: 27, False: 720]
+
  Branch (268:16): [True: 7, False: 267]
+
  Branch (268:16): [True: 11, False: 537]
 
  Branch (268:16): [True: 12, False: 853]
 
  Branch (268:16): [True: 9, False: 90]
-
  Branch (268:16): [True: 10, False: 159]
-
  Branch (268:16): [True: 7, False: 267]
-
  Branch (268:16): [True: 9, False: 90]
-
  Branch (268:16): [True: 26, False: 711]
-
  Branch (268:16): [True: 39, False: 710]
-
  Branch (268:16): [True: 33, False: 504]
-
  Branch (268:16): [True: 55, False: 698]
-
  Branch (268:16): [True: 134, False: 2.36k]
-
  Branch (268:16): [True: 11, False: 537]
-
  Branch (268:16): [True: 6, False: 1]
-
  Branch (268:16): [True: 1, False: 23]
-
  Branch (268:16): [True: 2, False: 73]
-
  Branch (268:16): [True: 22, False: 766]
-
  Branch (268:16): [True: 5, False: 107]
-
  Branch (268:16): [True: 13, False: 391]
-
269
        {
270
415
            self.reader_done = true;
271
            // Signal that reading is complete. The `load` event is fired later
272
            // from `App::poll_completion_events` (the heartbeat handler) once the
273
            // reader is done, the matcher has stopped, and every item has been
274
            // consumed, so a `load` binding sees a fully-populated, stable list.
275
415
            self.app.reader_done = true;
276
415
            self.app.restart_matcher(false);
277
            // If the matcher already consumed everything, stop the periodic
278
            // interval immediately rather than waiting for the next tick.
279
415
            if self.app.matcher_control.stopped() && 
self.app.item_pool.num_not_taken() == 0107
{
  Branch (279:16): [True: 0, False: 11]
+
269
        {
270
415
            self.reader_done = true;
271
            // Signal that reading is complete. The `load` event is fired later
272
            // from `App::poll_completion_events` (the heartbeat handler) once the
273
            // reader is done, the matcher has stopped, and every item has been
274
            // consumed, so a `load` binding sees a fully-populated, stable list.
275
415
            self.app.reader_done = true;
276
415
            self.app.restart_matcher(false);
277
            // If the matcher already consumed everything, stop the periodic
278
            // interval immediately rather than waiting for the next tick.
279
415
            if self.app.matcher_control.stopped() && 
self.app.item_pool.num_not_taken() == 0100
{
  Branch (279:16): [True: 2, False: 8]
+  Branch (279:54): [True: 2, False: 0]
+
  Branch (279:16): [True: 0, False: 36]
   Branch (279:54): [True: 0, False: 0]
-
  Branch (279:16): [True: 0, False: 10]
+
  Branch (279:16): [True: 54, False: 1]
+  Branch (279:54): [True: 54, False: 0]
+
  Branch (279:16): [True: 0, False: 12]
+  Branch (279:54): [True: 0, False: 0]
+
  Branch (279:16): [True: 2, False: 3]
+  Branch (279:54): [True: 2, False: 0]
+
  Branch (279:16): [True: 1, False: 10]
+  Branch (279:54): [True: 1, False: 0]
+
  Branch (279:16): [True: 0, False: 1]
+  Branch (279:54): [True: 0, False: 0]
+
  Branch (279:16): [True: 0, False: 3]
+  Branch (279:54): [True: 0, False: 0]
+
  Branch (279:16): [True: 9, False: 14]
+  Branch (279:54): [True: 9, False: 0]
+
  Branch (279:16): [True: 0, False: 12]
+  Branch (279:54): [True: 0, False: 0]
+
  Branch (279:16): [True: 0, False: 7]
+  Branch (279:54): [True: 0, False: 0]
+
  Branch (279:16): [True: 2, False: 33]
+  Branch (279:54): [True: 2, False: 0]
+
  Branch (279:16): [True: 1, False: 8]
+  Branch (279:54): [True: 1, False: 0]
+
  Branch (279:16): [True: 22, False: 108]
+  Branch (279:54): [True: 18, False: 4]
+
  Branch (279:16): [True: 3, False: 24]
+  Branch (279:54): [True: 3, False: 0]
+
  Branch (279:16): [True: 0, False: 7]
+  Branch (279:54): [True: 0, False: 0]
+
  Branch (279:16): [True: 0, False: 11]
   Branch (279:54): [True: 0, False: 0]
 
  Branch (279:16): [True: 0, False: 12]
   Branch (279:54): [True: 0, False: 0]
-
  Branch (279:16): [True: 5, False: 4]
-  Branch (279:54): [True: 5, False: 0]
-
  Branch (279:16): [True: 1, False: 9]
-  Branch (279:54): [True: 1, False: 0]
-
  Branch (279:16): [True: 0, False: 7]
-  Branch (279:54): [True: 0, False: 0]
-
  Branch (279:16): [True: 2, False: 7]
-  Branch (279:54): [True: 2, False: 0]
-
  Branch (279:16): [True: 4, False: 22]
+
  Branch (279:16): [True: 4, False: 5]
   Branch (279:54): [True: 4, False: 0]
-
  Branch (279:16): [True: 1, False: 38]
-  Branch (279:54): [True: 1, False: 0]
-
  Branch (279:16): [True: 5, False: 28]
-  Branch (279:54): [True: 5, False: 0]
-
  Branch (279:16): [True: 53, False: 2]
-  Branch (279:54): [True: 53, False: 0]
-
  Branch (279:16): [True: 20, False: 114]
-  Branch (279:54): [True: 16, False: 4]
-
  Branch (279:16): [True: 0, False: 11]
-  Branch (279:54): [True: 0, False: 0]
-
  Branch (279:16): [True: 0, False: 6]
-  Branch (279:54): [True: 0, False: 0]
-
  Branch (279:16): [True: 0, False: 1]
-  Branch (279:54): [True: 0, False: 0]
-
  Branch (279:16): [True: 0, False: 2]
-  Branch (279:54): [True: 0, False: 0]
-
  Branch (279:16): [True: 13, False: 9]
-  Branch (279:54): [True: 13, False: 0]
-
  Branch (279:16): [True: 3, False: 2]
-  Branch (279:54): [True: 3, False: 0]
-
  Branch (279:16): [True: 0, False: 13]
-  Branch (279:54): [True: 0, False: 0]
-
280
103
                trace!(
"matcher interval stopped in check_reader: all items consumed"0
);
281
103
                self.matcher_interval = None;
282
312
            }
283
415
            true
284
        } else {
285
9.30k
            false
286
        }
287
9.71k
    }
288
289
    /// Returns `true` if the reader is done (has finished producing items).
290
1.44k
    pub fn reader_done(&self) -> bool {
291
1.44k
        self.reader_done
  Branch (291:9): [True: 60, False: 13]
-
  Branch (291:9): [Folded - Ignored]
-  Branch (291:9): [True: 88, False: 16]
-
  Branch (291:9): [True: 9, False: 10]
-
  Branch (291:9): [True: 15, False: 23]
-
  Branch (291:9): [True: 25, False: 8]
-
  Branch (291:9): [True: 9, False: 14]
-
  Branch (291:9): [True: 67, False: 27]
-
  Branch (291:9): [True: 50, False: 50]
-
  Branch (291:9): [True: 50, False: 44]
+
280
96
                trace!(
"matcher interval stopped in check_reader: all items consumed"0
);
281
96
                self.matcher_interval = None;
282
319
            }
283
415
            true
284
        } else {
285
9.32k
            false
286
        }
287
9.73k
    }
288
289
    /// Returns `true` if the reader is done (has finished producing items).
290
1.44k
    pub fn reader_done(&self) -> bool {
291
1.44k
        self.reader_done
  Branch (291:9): [Folded - Ignored]
+  Branch (291:9): [True: 47, False: 47]
 
  Branch (291:9): [True: 81, False: 130]
-
  Branch (291:9): [True: 224, False: 183]
-
  Branch (291:9): [True: 50, False: 11]
+
  Branch (291:9): [True: 32, False: 16]
+
  Branch (291:9): [True: 10, False: 6]
+
  Branch (291:9): [True: 16, False: 24]
+
  Branch (291:9): [True: 1, False: 1]
+
  Branch (291:9): [True: 11, False: 3]
+
  Branch (291:9): [True: 83, False: 27]
+
  Branch (291:9): [True: 12, False: 16]
 
  Branch (291:9): [True: 1, False: 0]
-
  Branch (291:9): [True: 1, False: 2]
-
  Branch (291:9): [True: 7, False: 2]
-
  Branch (291:9): [True: 82, False: 25]
-
  Branch (291:9): [True: 10, False: 7]
-
  Branch (291:9): [True: 33, False: 16]
-
292
862
            && self
293
862
                .reader_control
294
862
                .as_ref()
295
862
                .is_none_or(super::reader::ReaderControl::is_done)
296
1.44k
    }
297
298
    /// Returns `true` if the matcher is stopped
299
7
    pub fn matcher_stopped(&self) -> bool {
300
7
        self.app.matcher_control.stopped()
301
7
    }
302
303
    /// Initialize the TUI with a caller-provided instance.
304
    ///
305
    /// Use this instead of [`init_tui()`](Skim::init_tui) when you need a
306
    /// non-default backend (e.g. `TestBackend` for snapshot tests).
307
372
    pub fn init_tui_with(&mut self, tui: Tui<Backend>) {
308
372
        self.tui = Some(tui);
309
372
    }
310
311
    /// Returns a shared reference to the application state.
312
14.0k
    pub fn app(&self) -> &App {
313
14.0k
        &self.app
314
14.0k
    }
315
316
    /// Returns a mutable reference to the application state.
317
5
    pub fn app_mut(&mut self) -> &mut App {
318
5
        &mut self.app
319
5
    }
320
321
    /// Returns a shared reference to the TUI.
322
    ///
323
    /// # Panics
324
    ///
325
    /// Panics if the TUI has not been initialized yet.
326
612
    pub fn tui_ref(&self) -> &Tui<Backend> {
327
612
        self.tui.as_ref().expect("TUI needs to be initialized before access")
328
612
    }
329
330
    /// Returns a mutable reference to the TUI.
331
    ///
332
    /// # Panics
333
    ///
334
    /// Panics if the TUI has not been initialized yet.
335
20.9k
    pub fn tui_mut(&mut self) -> &mut Tui<Backend> {
336
20.9k
        self.tui.as_mut().expect("TUI needs to be initialized before access")
337
20.9k
    }
338
339
    /// Returns mutable references to both the app and the TUI simultaneously.
340
    ///
341
    /// This is useful when you need to call `app.handle_event(tui, ...)` or
342
    /// `tui.draw(|frame| frame.render_widget(app, ...))`, which require
343
    /// disjoint mutable borrows of both fields.
344
    ///
345
    /// # Panics
346
    ///
347
    /// Panics if the TUI has not been initialized yet.
348
8.59k
    pub fn app_and_tui(&mut self) -> (&mut App, &mut Tui<Backend>) {
349
8.59k
        (
350
8.59k
            &mut self.app,
351
8.59k
            self.tui.as_mut().expect("TUI needs to be initialized before access"),
352
8.59k
        )
353
8.59k
    }
354
355
    /// Returns a shared reference to the final event that caused skim to quit.
356
3
    pub fn final_event(&self) -> &Event {
357
3
        &self.final_event
358
3
    }
359
360
    /// Returns a clone of the TUI event sender.
361
    ///
362
    /// Use this to send events (e.g. [`Event::Render`], [`Event::Action`])
363
    /// to the running skim instance from outside the event loop. The sender
364
    /// is cheap to clone and can be moved into async blocks or other tasks.
365
    ///
366
    /// Must be called after [`init_tui()`](Skim::init_tui).
367
    ///
368
    /// # Panics
369
    ///
370
    /// Panics if `init_tui` has not been called before this method.
371
1
    pub fn event_sender(&self) -> tokio::sync::mpsc::Sender<Event> {
372
1
        self.tui
373
1
            .as_ref()
374
1
            .expect("TUI needs to be initialized using Skim::init_tui before getting the event sender")
375
1
            .event_tx
376
1
            .clone()
377
1
    }
378
379
    /// Enter the TUI
380
    ///
381
    /// # Errors
382
    ///
383
    /// Returns an error if the TUI cannot be entered or the input listener fails.
384
    ///
385
    /// # Panics
386
    ///
387
    /// Panics if `init_tui` has not been called before this method.
388
    ///
389
    /// # Async
390
    ///
391
    /// This will call `init_listener`, which requires a tokio runtime
392
    /// Though it is not technically async code, this is a good hint.
393
    #[allow(clippy::unused_async, unknown_lints, clippy::unused_async_trait_impl)]
394
10
    pub async fn enter(&mut self) -> Result<()> {
395
10
        debug!(
"Entering TUI"0
);
396
10
        let tui = self
397
10
            .tui
398
10
            .as_mut()
399
10
            .expect("TUI needs to be initialized using Skim::init_tui before entering");
400
401
10
        tui.enter_terminal()
?0
;
402
        #[cfg(feature = "image")]
403
10
        if self.app.options.image == Some(crate::options::ImageProtocol::Detect) {
  Branch (403:12): [True: 0, False: 10]
+
  Branch (291:9): [True: 52, False: 43]
+
  Branch (291:9): [True: 58, False: 11]
+
  Branch (291:9): [True: 220, False: 180]
+
  Branch (291:9): [True: 68, False: 29]
+
  Branch (291:9): [True: 25, False: 8]
+
  Branch (291:9): [True: 50, False: 11]
+
  Branch (291:9): [True: 88, False: 15]
+
  Branch (291:9): [True: 9, False: 13]
+
292
864
            && self
293
864
                .reader_control
294
864
                .as_ref()
295
864
                .is_none_or(super::reader::ReaderControl::is_done)
296
1.44k
    }
297
298
    /// Returns `true` if the matcher is stopped
299
7
    pub fn matcher_stopped(&self) -> bool {
300
7
        self.app.matcher_control.stopped()
301
7
    }
302
303
    /// Initialize the TUI with a caller-provided instance.
304
    ///
305
    /// Use this instead of [`init_tui()`](Skim::init_tui) when you need a
306
    /// non-default backend (e.g. `TestBackend` for snapshot tests).
307
371
    pub fn init_tui_with(&mut self, tui: Tui<Backend>) {
308
371
        self.tui = Some(tui);
309
371
    }
310
311
    /// Returns a shared reference to the application state.
312
14.1k
    pub fn app(&self) -> &App {
313
14.1k
        &self.app
314
14.1k
    }
315
316
    /// Returns a mutable reference to the application state.
317
5
    pub fn app_mut(&mut self) -> &mut App {
318
5
        &mut self.app
319
5
    }
320
321
    /// Returns a shared reference to the TUI.
322
    ///
323
    /// # Panics
324
    ///
325
    /// Panics if the TUI has not been initialized yet.
326
611
    pub fn tui_ref(&self) -> &Tui<Backend> {
327
611
        self.tui.as_ref().expect("TUI needs to be initialized before access")
328
611
    }
329
330
    /// Returns a mutable reference to the TUI.
331
    ///
332
    /// # Panics
333
    ///
334
    /// Panics if the TUI has not been initialized yet.
335
20.9k
    pub fn tui_mut(&mut self) -> &mut Tui<Backend> {
336
20.9k
        self.tui.as_mut().expect("TUI needs to be initialized before access")
337
20.9k
    }
338
339
    /// Returns mutable references to both the app and the TUI simultaneously.
340
    ///
341
    /// This is useful when you need to call `app.handle_event(tui, ...)` or
342
    /// `tui.draw(|frame| frame.render_widget(app, ...))`, which require
343
    /// disjoint mutable borrows of both fields.
344
    ///
345
    /// # Panics
346
    ///
347
    /// Panics if the TUI has not been initialized yet.
348
8.61k
    pub fn app_and_tui(&mut self) -> (&mut App, &mut Tui<Backend>) {
349
8.61k
        (
350
8.61k
            &mut self.app,
351
8.61k
            self.tui.as_mut().expect("TUI needs to be initialized before access"),
352
8.61k
        )
353
8.61k
    }
354
355
    /// Returns a shared reference to the final event that caused skim to quit.
356
3
    pub fn final_event(&self) -> &Event {
357
3
        &self.final_event
358
3
    }
359
360
    /// Returns a clone of the TUI event sender.
361
    ///
362
    /// Use this to send events (e.g. [`Event::Render`], [`Event::Action`])
363
    /// to the running skim instance from outside the event loop. The sender
364
    /// is cheap to clone and can be moved into async blocks or other tasks.
365
    ///
366
    /// Must be called after [`init_tui()`](Skim::init_tui).
367
    ///
368
    /// # Panics
369
    ///
370
    /// Panics if `init_tui` has not been called before this method.
371
1
    pub fn event_sender(&self) -> tokio::sync::mpsc::Sender<Event> {
372
1
        self.tui
373
1
            .as_ref()
374
1
            .expect("TUI needs to be initialized using Skim::init_tui before getting the event sender")
375
1
            .event_tx
376
1
            .clone()
377
1
    }
378
379
    /// Enter the TUI
380
    ///
381
    /// # Errors
382
    ///
383
    /// Returns an error if the TUI cannot be entered or the input listener fails.
384
    ///
385
    /// # Panics
386
    ///
387
    /// Panics if `init_tui` has not been called before this method.
388
    ///
389
    /// # Async
390
    ///
391
    /// This will call `init_listener`, which requires a tokio runtime
392
    /// Though it is not technically async code, this is a good hint.
393
    #[allow(clippy::unused_async, unknown_lints, clippy::unused_async_trait_impl)]
394
10
    pub async fn enter(&mut self) -> Result<()> {
395
10
        debug!(
"Entering TUI"0
);
396
10
        let tui = self
397
10
            .tui
398
10
            .as_mut()
399
10
            .expect("TUI needs to be initialized using Skim::init_tui before entering");
400
401
10
        tui.enter_terminal()
?0
;
402
        #[cfg(feature = "image")]
403
10
        if self.app.options.image == Some(crate::options::ImageProtocol::Detect) {
  Branch (403:12): [True: 0, False: 10]
 
  Branch (403:12): [Folded - Ignored]
 
404
0
            if !tui.is_fullscreen {
  Branch (404:16): [True: 0, False: 0]
 
  Branch (404:16): [Folded - Ignored]
@@ -282,17 +282,17 @@
 
  Branch (416:19): [Folded - Ignored]
 
417
0
            let picker = Picker::halfblocks();
418
0
            self.app.options.image_picker = Some(picker.clone());
419
0
            self.app.preview.set_image_picker(Some(picker));
420
10
        }
421
422
10
        self.init_listener()
?0
;
423
10
        self.tui
424
10
            .as_mut()
425
10
            .expect("TUI needs to be initialized using Skim::init_tui before starting")
426
10
            .start();
427
        // In the normal binary flow the TUI is created after `start()`, so this
428
        // is the first point at which the `start` event can be queued.
429
10
        self.fire_start_event();
430
10
        Ok(())
431
10
    }
432
433
    /// Checks read-0 select-1, filter, and sync to wait and returns whether or not we should enter
434
    ///
435
    /// # Panics
436
    ///
437
    /// Panics if `start` has not been called before this method.
438
38
    pub fn should_enter(&mut self) -> bool {
439
38
        let reader_control = self
440
38
            .reader_control
441
38
            .as_ref()
442
38
            .expect("reader_control needs to be initialized using Skim::start");
443
38
        let app = &mut self.app;
444
445
        // Filter mode: wait for all items to be read and matched, then return without entering TUI
446
38
        if app.options.filter.is_some() {
  Branch (446:12): [True: 15, False: 17]
 
  Branch (446:12): [True: 3, False: 3]
-
447
18
            trace!(
"filter mode: waiting for all items to be processed"0
);
448
            loop {
449
                // `--min-query-length` short-circuits `restart_matcher`, so the pool would
450
                // never be drained and this loop would spin forever. There is nothing to
451
                // match in that case: stop as soon as the reader is done.
452
107
                if app.query_below_min_length() {
  Branch (452:20): [True: 0, False: 101]
-
  Branch (452:20): [True: 2, False: 4]
+
447
18
            trace!(
"filter mode: waiting for all items to be processed"0
);
448
            loop {
449
                // `--min-query-length` short-circuits `restart_matcher`, so the pool would
450
                // never be drained and this loop would spin forever. There is nothing to
451
                // match in that case: stop as soon as the reader is done.
452
83
                if app.query_below_min_length() {
  Branch (452:20): [True: 0, False: 72]
+
  Branch (452:20): [True: 2, False: 9]
 
453
2
                    if reader_control.is_done() {
  Branch (453:24): [True: 0, False: 0]
 
  Branch (453:24): [True: 1, False: 1]
-
454
1
                        debug!(
"filter mode: query shorter than --min-query-length, no results"0
);
455
1
                        app.item_list.items.clear();
456
1
                        return false;
457
1
                    }
458
1
                    std::thread::sleep(Duration::from_millis(1));
459
1
                    continue;
460
105
                }
461
105
                let matcher_stopped = app.matcher_control.stopped();
462
105
                let reader_done = reader_control.is_done();
463
105
                if matcher_stopped && 
reader_done19
&&
app.item_pool.num_not_taken() == 017
{
  Branch (463:20): [True: 17, False: 84]
-  Branch (463:39): [True: 15, False: 2]
-  Branch (463:54): [True: 15, False: 0]
-
  Branch (463:20): [True: 2, False: 2]
+
454
1
                        debug!(
"filter mode: query shorter than --min-query-length, no results"0
);
455
1
                        app.item_list.items.clear();
456
1
                        return false;
457
1
                    }
458
1
                    std::thread::sleep(Duration::from_millis(1));
459
1
                    continue;
460
81
                }
461
81
                let matcher_stopped = app.matcher_control.stopped();
462
81
                let reader_done = reader_control.is_done();
463
81
                if matcher_stopped && 
reader_done20
&&
app.item_pool.num_not_taken() == 018
{
  Branch (463:20): [True: 18, False: 54]
+  Branch (463:39): [True: 16, False: 2]
+  Branch (463:54): [True: 15, False: 1]
+
  Branch (463:20): [True: 2, False: 7]
   Branch (463:39): [True: 2, False: 0]
   Branch (463:54): [True: 2, False: 0]
-
464
17
                    break;
465
88
                }
466
88
                std::thread::sleep(Duration::from_millis(1));
467
88
                app.restart_matcher(false);
468
            }
469
17
            app.item_list.items = app
470
17
                .item_list
471
17
                .processed_items
472
17
                .lock()
473
17
                .unwrap_or_else(std::sync::PoisonError::into_inner)
474
17
                .take()
475
17
                .unwrap_or_default()
476
17
                .items
477
17
                .into_iter()
478
50.0k
                .filter(|i| !i.item.disabled())
479
                .collect();
480
17
            debug!("filter mode: matched {} items", 
app.item_list.items.len()0
);
481
17
            return false;
482
20
        }
483
484
        // Deal with read-0 / select-1
485
20
        let min_items_before_enter = if app.options.exit_0 {
  Branch (485:41): [True: 0, False: 17]
+
464
17
                    break;
465
64
                }
466
64
                std::thread::sleep(Duration::from_millis(1));
467
64
                app.restart_matcher(false);
468
            }
469
17
            app.item_list.items = app
470
17
                .item_list
471
17
                .processed_items
472
17
                .lock()
473
17
                .unwrap_or_else(std::sync::PoisonError::into_inner)
474
17
                .take()
475
17
                .unwrap_or_default()
476
17
                .items
477
17
                .into_iter()
478
50.0k
                .filter(|i| !i.item.disabled())
479
                .collect();
480
17
            debug!("filter mode: matched {} items", 
app.item_list.items.len()0
);
481
17
            return false;
482
20
        }
483
484
        // Deal with read-0 / select-1
485
20
        let min_items_before_enter = if app.options.exit_0 {
  Branch (485:41): [True: 0, False: 17]
 
  Branch (485:41): [True: 1, False: 2]
 
486
1
            1
487
19
        } else if app.options.select_1 {
  Branch (487:19): [True: 7, False: 10]
 
  Branch (487:19): [True: 1, False: 1]
@@ -302,13 +302,13 @@
   Branch (494:42): [True: 0, False: 10]
 
  Branch (494:12): [True: 3, False: 0]
   Branch (494:42): [True: 0, False: 0]
-
495
10
            trace!(
496
                "checking matcher, stopped: {}, processed: {}, matched: {}/{}, pool: {}, query: {}, reader_control_done: {}",
497
1
                app.matcher_control.stopped(),
498
1
                app.matcher_control.get_num_processed(),
499
1
                app.matcher_control.get_num_matched(),
500
1
                min_items_before_enter,
501
1
                app.item_pool.num_not_taken(),
502
1
                app.input.value,
503
1
                reader_control.is_done()
504
            );
505
35
            while app.matcher_control.get_num_matched() < min_items_before_enter
  Branch (505:19): [True: 26, False: 0]
-
  Branch (505:19): [True: 8, False: 1]
-
506
34
                && (!app.matcher_control.stopped() || 
!reader_control.is_done()13
)
  Branch (506:21): [True: 15, False: 11]
-  Branch (506:55): [True: 4, False: 7]
-
  Branch (506:21): [True: 6, False: 2]
-  Branch (506:55): [True: 0, False: 2]
-
507
            {
508
25
                trace!(
"still waiting"3
);
509
25
                std::thread::sleep(Duration::from_millis(1));
510
25
                app.restart_matcher(false);
511
            }
512
10
            trace!(
513
                "checked matcher, stopped: {}, processed: {}, pool: {}, query: {}, reader_control_done: {}",
514
1
                app.matcher_control.stopped(),
515
1
                app.matcher_control.get_num_processed(),
516
1
                app.item_pool.num_not_taken(),
517
1
                app.input.value,
518
1
                reader_control.is_done()
519
            );
520
10
            trace!(
521
1
                "checking for matched item count before entering: {}/{min_items_before_enter}",
522
1
                app.matcher_control.get_num_matched()
523
            );
524
10
            if app.matcher_control.get_num_matched() == min_items_before_enter - 1 {
  Branch (524:16): [True: 7, False: 0]
+
495
10
            trace!(
496
                "checking matcher, stopped: {}, processed: {}, matched: {}/{}, pool: {}, query: {}, reader_control_done: {}",
497
1
                app.matcher_control.stopped(),
498
1
                app.matcher_control.get_num_processed(),
499
1
                app.matcher_control.get_num_matched(),
500
1
                min_items_before_enter,
501
1
                app.item_pool.num_not_taken(),
502
1
                app.input.value,
503
1
                reader_control.is_done()
504
            );
505
29
            while app.matcher_control.get_num_matched() < min_items_before_enter
  Branch (505:19): [True: 18, False: 0]
+
  Branch (505:19): [True: 10, False: 1]
+
506
28
                && (!app.matcher_control.stopped() || 
!reader_control.is_done()11
)
  Branch (506:21): [True: 11, False: 7]
+  Branch (506:55): [True: 0, False: 7]
+
  Branch (506:21): [True: 6, False: 4]
+  Branch (506:55): [True: 2, False: 2]
+
507
            {
508
19
                trace!(
"still waiting"2
);
509
19
                std::thread::sleep(Duration::from_millis(1));
510
19
                app.restart_matcher(false);
511
            }
512
10
            trace!(
513
                "checked matcher, stopped: {}, processed: {}, pool: {}, query: {}, reader_control_done: {}",
514
1
                app.matcher_control.stopped(),
515
1
                app.matcher_control.get_num_processed(),
516
1
                app.item_pool.num_not_taken(),
517
1
                app.input.value,
518
1
                reader_control.is_done()
519
            );
520
10
            trace!(
521
1
                "checking for matched item count before entering: {}/{min_items_before_enter}",
522
1
                app.matcher_control.get_num_matched()
523
            );
524
10
            if app.matcher_control.get_num_matched() == min_items_before_enter - 1 {
  Branch (524:16): [True: 7, False: 0]
 
  Branch (524:16): [True: 1, False: 2]
 
525
8
                app.item_list.items = app
526
8
                    .item_list
527
8
                    .processed_items
528
8
                    .lock()
529
8
                    .unwrap_or_else(std::sync::PoisonError::into_inner)
530
8
                    .take()
531
8
                    .unwrap_or_default()
532
8
                    .items;
533
8
                debug!("early exit, result: {:?}", 
app.results()1
);
534
8
                return false;
535
2
            }
536
10
        }
537
12
        true
538
38
    }
539
540
    /// Initialize the IPC socket listener
541
    /// This needs to be called from an async context despite being sync
542
    #[cfg_attr(not(feature = "listen"), allow(clippy::unnecessary_wraps, clippy::unused_self))]
543
10
    fn init_listener(&mut self) -> Result<()> {
544
        #[cfg(feature = "listen")]
545
10
        if let Some(socket_name) = &self.app.options.listen {
  Branch (545:16): [True: 3, False: 7]
 
  Branch (545:16): [Folded - Ignored]
@@ -318,34 +318,34 @@
 
  Branch (573:22): [True: 1, False: 5]
 
574
1
            self.app.input.to_string()
575
37
        } else if self.app.options.cmd_query.is_some() {
  Branch (575:19): [True: 0, False: 32]
 
  Branch (575:19): [True: 1, False: 4]
-
576
1
            self.app.options.cmd_query.clone().unwrap()
577
        } else {
578
36
            self.initial_cmd.clone()
579
        };
580
38
        let query = self.app.input.to_string();
581
38
        let current = self.app.item_list.selected();
582
38
        let header = self.app.header.header.clone();
583
38
        let final_event = self.final_event.clone();
584
38
        let final_key = self.final_key;
585
586
38
        drop(self);
587
588
38
        SkimOutput {
589
38
            final_event,
590
38
            is_abort,
591
38
            final_key,
592
38
            query,
593
38
            cmd,
594
38
            selected_items,
595
38
            current,
596
38
            header,
597
38
        }
598
38
    }
599
600
    /// Returns true if skim has finished (the user accepted or aborted)
601
1
    pub fn should_quit(&self) -> bool {
602
1
        self.app.should_quit
603
1
    }
604
605
    /// If `needs_render` has been set (e.g. by the matcher thread), immediately
606
    /// send a `Render` event to the TUI so the screen updates without waiting
607
    /// for the next heartbeat tick.  Respects the 30 FPS frame-rate cap.
608
22
    fn try_flush_render(&mut self) {
609
        use std::sync::atomic::Ordering;
610
22
        if self.app.needs_render.load(Ordering::Relaxed)
  Branch (610:12): [True: 20, False: 0]
-
  Branch (610:12): [True: 1, False: 1]
-
611
21
            && self.app.last_render_timer.elapsed().as_millis() > 1000 / u128::from(TICK_RATE)
  Branch (611:16): [True: 10, False: 10]
-
  Branch (611:16): [True: 1, False: 0]
-
612
        {
613
11
            self.app.needs_render.store(false, Ordering::Relaxed);
614
11
            self.app.last_render_timer = std::time::Instant::now();
615
11
            if let Some(tui) = self.tui.as_ref() {
  Branch (615:20): [True: 10, False: 0]
-
  Branch (615:20): [True: 1, False: 0]
-
616
11
                let _ = tui.event_tx.try_send(Event::Render);
617
0
            }
618
11
        }
619
22
    }
620
621
    /// Process a single event loop iteration.
622
    ///
623
    /// This awaits the next event from the TUI, matcher, or IPC listener,
624
    /// processes it, and returns. Use this in your own event loop when you
625
    /// need fine-grained control over the application lifecycle.
626
    ///
627
    /// Returns `Ok(true)` if skim should quit, `Ok(false)` to continue.
628
    ///
629
    /// # Errors
630
    ///
631
    /// Returns an error if the TUI cannot produce the next event or if event handling fails.
632
    ///
633
    /// # Panics
634
    ///
635
    /// Panics if `init_tui` has not been called before this method.
636
    ///
637
    /// # Example
638
    ///
639
    /// ```ignore
640
    /// while !skim.tick().await? {
641
    ///     // do your own work between ticks
642
    /// }
643
    /// ```
644
161
    pub async fn tick(&mut self) -> Result<bool> {
645
        // Retry the one-shot `start` event until the (bounded) event channel
646
        // accepts it. `start()`/`enter()` fire it eagerly, but if the channel was
647
        // momentarily full there, this guarantees it is not lost. Idempotent: the
648
        // `start_fired` guard makes every call after the first a no-op.
649
161
        self.fire_start_event();
650
161
        let matcher_interval = &mut self.matcher_interval;
651
161
        let items_available = self.app.item_pool.items_available.clone();
652
161
        select! {
653
161
            event = self.tui.as_mut().expect("TUI should be initialized before the event loop can start").next() => {
654
138
                let evt = event.ok_or_eyre("Could not acquire next event")
?0
;
655
656
138
                if let Event::Key(k) = &evt {
  Branch (656:24): [True: 47, False: 87]
-
  Branch (656:24): [True: 2, False: 2]
-
657
49
                  self.final_key.clone_from(k);
658
                } else {
659
89
                  self.final_event = evt.clone();
660
                }
661
662
663
                // Handle reload event separately
664
138
                if let Event::Reload(new_cmd) = &evt {
  Branch (664:24): [True: 0, False: 134]
-
  Branch (664:24): [True: 0, False: 4]
-
665
0
                    self.handle_reload(&new_cmd.clone());
666
                } else {
667
138
                    self.app.handle_event(self.tui.as_mut().expect("TUI should be initialized before handling events"), &evt)
?0
;
668
                }
669
670
138
                if let Some(action) = self.app.final_action.take() {
  Branch (670:24): [True: 10, False: 124]
-
  Branch (670:24): [True: 2, False: 2]
-
671
12
                    self.final_event = Event::Action(action);
672
126
                }
673
674
                // Check reader status and update
675
138
                self.check_reader();
676
            }
677
95
            () = async {
678
95
                match matcher_interval {
679
24
                    Some(interval) => { interval.tick().await; },
680
71
                    None => std::future::pending::<()>().await,
681
                }
682
10
            } => {
683
              // Check for a pending debounced restart (e.g. the user typed
684
              // while a match-all was running).  Checking here (every 10ms)
685
              // rather than only on the heartbeat (83ms) eliminates most of
686
              // the latency between query change and matcher restart.
687
10
              if self.app.pending_matcher_restart {
  Branch (687:18): [True: 0, False: 10]
+
576
1
            self.app.options.cmd_query.clone().unwrap()
577
        } else {
578
36
            self.initial_cmd.clone()
579
        };
580
38
        let query = self.app.input.to_string();
581
38
        let current = self.app.item_list.selected();
582
38
        let header = self.app.header.header.clone();
583
38
        let final_event = self.final_event.clone();
584
38
        let final_key = self.final_key;
585
586
38
        drop(self);
587
588
38
        SkimOutput {
589
38
            final_event,
590
38
            is_abort,
591
38
            final_key,
592
38
            query,
593
38
            cmd,
594
38
            selected_items,
595
38
            current,
596
38
            header,
597
38
        }
598
38
    }
599
600
    /// Returns true if skim has finished (the user accepted or aborted)
601
1
    pub fn should_quit(&self) -> bool {
602
1
        self.app.should_quit
603
1
    }
604
605
    /// If `needs_render` has been set (e.g. by the matcher thread), immediately
606
    /// send a `Render` event to the TUI so the screen updates without waiting
607
    /// for the next heartbeat tick.  Respects the 30 FPS frame-rate cap.
608
23
    fn try_flush_render(&mut self) {
609
        use std::sync::atomic::Ordering;
610
23
        if self.app.needs_render.load(Ordering::Relaxed)
  Branch (610:12): [True: 20, False: 0]
+
  Branch (610:12): [True: 2, False: 1]
+
611
22
            && self.app.last_render_timer.elapsed().as_millis() > 1000 / u128::from(TICK_RATE)
  Branch (611:16): [True: 10, False: 10]
+
  Branch (611:16): [True: 2, False: 0]
+
612
        {
613
12
            self.app.needs_render.store(false, Ordering::Relaxed);
614
12
            self.app.last_render_timer = std::time::Instant::now();
615
12
            if let Some(tui) = self.tui.as_ref() {
  Branch (615:20): [True: 10, False: 0]
+
  Branch (615:20): [True: 2, False: 0]
+
616
12
                let _ = tui.event_tx.try_send(Event::Render);
617
0
            }
618
11
        }
619
23
    }
620
621
    /// Process a single event loop iteration.
622
    ///
623
    /// This awaits the next event from the TUI, matcher, or IPC listener,
624
    /// processes it, and returns. Use this in your own event loop when you
625
    /// need fine-grained control over the application lifecycle.
626
    ///
627
    /// Returns `Ok(true)` if skim should quit, `Ok(false)` to continue.
628
    ///
629
    /// # Errors
630
    ///
631
    /// Returns an error if the TUI cannot produce the next event or if event handling fails.
632
    ///
633
    /// # Panics
634
    ///
635
    /// Panics if `init_tui` has not been called before this method.
636
    ///
637
    /// # Example
638
    ///
639
    /// ```ignore
640
    /// while !skim.tick().await? {
641
    ///     // do your own work between ticks
642
    /// }
643
    /// ```
644
160
    pub async fn tick(&mut self) -> Result<bool> {
645
        // Retry the one-shot `start` event until the (bounded) event channel
646
        // accepts it. `start()`/`enter()` fire it eagerly, but if the channel was
647
        // momentarily full there, this guarantees it is not lost. Idempotent: the
648
        // `start_fired` guard makes every call after the first a no-op.
649
160
        self.fire_start_event();
650
160
        let matcher_interval = &mut self.matcher_interval;
651
160
        let items_available = self.app.item_pool.items_available.clone();
652
160
        select! {
653
160
            event = self.tui.as_mut().expect("TUI should be initialized before the event loop can start").next() => {
654
136
                let evt = event.ok_or_eyre("Could not acquire next event")
?0
;
655
656
136
                if let Event::Key(k) = &evt {
  Branch (656:24): [True: 47, False: 84]
+
  Branch (656:24): [True: 2, False: 3]
+
657
49
                  self.final_key.clone_from(k);
658
                } else {
659
87
                  self.final_event = evt.clone();
660
                }
661
662
663
                // Handle reload event separately
664
136
                if let Event::Reload(new_cmd) = &evt {
  Branch (664:24): [True: 0, False: 131]
+
  Branch (664:24): [True: 0, False: 5]
+
665
0
                    self.handle_reload(&new_cmd.clone());
666
                } else {
667
136
                    self.app.handle_event(self.tui.as_mut().expect("TUI should be initialized before handling events"), &evt)
?0
;
668
                }
669
670
136
                if let Some(action) = self.app.final_action.take() {
  Branch (670:24): [True: 10, False: 121]
+
  Branch (670:24): [True: 2, False: 3]
+
671
12
                    self.final_event = Event::Action(action);
672
124
                }
673
674
                // Check reader status and update
675
136
                self.check_reader();
676
            }
677
96
            () = async {
678
96
                match matcher_interval {
679
26
                    Some(interval) => { interval.tick().await; },
680
70
                    None => std::future::pending::<()>().await,
681
                }
682
8
            } => {
683
              // Check for a pending debounced restart (e.g. the user typed
684
              // while a match-all was running).  Checking here (every 10ms)
685
              // rather than only on the heartbeat (83ms) eliminates most of
686
              // the latency between query change and matcher restart.
687
8
              if self.app.pending_matcher_restart {
  Branch (687:18): [True: 0, False: 8]
 
  Branch (687:18): [True: 0, False: 0]
-
688
0
                  self.app.restart_matcher(true);
689
              } else {
690
10
                  self.app.restart_matcher(false);
691
              }
692
              // Check if the matcher (or reader) has set the render flag and
693
              // flush a render event immediately instead of waiting for the
694
              // next heartbeat tick.  This can shave up to ~80ms of latency
695
              // when the heartbeat runs at 12 Hz.
696
10
              self.try_flush_render();
697
              // Once the reader has finished and the matcher has consumed all
698
              // items, stop the periodic interval — it can only produce empty
699
              // ticks from this point.  The `items_available` Notify branch
700
              // still handles the (rare) case of late-arriving items.
701
10
              if self.reader_done
  Branch (701:18): [True: 10, False: 0]
+
688
0
                  self.app.restart_matcher(true);
689
              } else {
690
8
                  self.app.restart_matcher(false);
691
              }
692
              // Check if the matcher (or reader) has set the render flag and
693
              // flush a render event immediately instead of waiting for the
694
              // next heartbeat tick.  This can shave up to ~80ms of latency
695
              // when the heartbeat runs at 12 Hz.
696
8
              self.try_flush_render();
697
              // Once the reader has finished and the matcher has consumed all
698
              // items, stop the periodic interval — it can only produce empty
699
              // ticks from this point.  The `items_available` Notify branch
700
              // still handles the (rare) case of late-arriving items.
701
8
              if self.reader_done
  Branch (701:18): [True: 8, False: 0]
 
  Branch (701:18): [True: 0, False: 0]
-
702
10
                  && self.app.matcher_control.stopped()
  Branch (702:22): [True: 10, False: 0]
+
702
8
                  && self.app.matcher_control.stopped()
  Branch (702:22): [True: 8, False: 0]
 
  Branch (702:22): [True: 0, False: 0]
-
703
10
                  && self.app.item_pool.num_not_taken() == 0
  Branch (703:22): [True: 10, False: 0]
+
703
8
                  && self.app.item_pool.num_not_taken() == 0
  Branch (703:22): [True: 8, False: 0]
 
  Branch (703:22): [True: 0, False: 0]
-
704
              {
705
10
                  trace!(
"matcher interval stopped: reader done, matcher idle, no pending items"0
);
706
10
                  self.matcher_interval = None;
707
0
              }
708
            }
709
            // Wake immediately when new items arrive in the pool so the matcher
710
            // can pick them up without waiting for the next periodic interval.
711
161
            () = items_available.notified() => {
712
10
                self.app.restart_matcher(false);
713
10
                self.try_flush_render();
714
            }
715
            // IPC listener branch. `tokio::select!` does not allow `#[cfg]` on a
716
            // branch, so the branch stays but its body is gated: with the `listen`
717
            // feature off the future is a never-resolving `pending()` and the handler
718
            // is unreachable (`RemoteStream` is uninhabited).
719
131
            Ok(stream) = async {
720
                #[cfg(feature = "listen")]
721
131
                if let Some(l) = self.listener.as_ref() {
  Branch (721:24): [True: 37, False: 92]
-
  Branch (721:24): [True: 0, False: 2]
-
722
37
                    return interprocess::local_socket::traits::tokio::Listener::accept(l).await;
723
94
                }
724
94
                std::future::pending::<std::io::Result<RemoteStream>>().await
725
3
            } => {
726
                #[cfg(feature = "listen")]
727
                {
728
3
                    debug!(
"Listener accepted a connection"0
);
729
3
                    let event_tx_clone_ipc = self.tui.as_ref().expect("TUI should be initialized before listening").event_tx.clone();
730
3
                    tokio::spawn(async move {
731
                        use tokio::io::AsyncBufReadExt;
732
3
                        let reader = tokio::io::BufReader::new(stream);
733
3
                        let mut lines = reader.lines();
734
6
                        while let Ok(Some(line)) = lines.next_line().await {
  Branch (734:35): [True: 3, False: 0]
+
704
              {
705
8
                  trace!(
"matcher interval stopped: reader done, matcher idle, no pending items"0
);
706
8
                  self.matcher_interval = None;
707
0
              }
708
            }
709
            // Wake immediately when new items arrive in the pool so the matcher
710
            // can pick them up without waiting for the next periodic interval.
711
160
            () = items_available.notified() => {
712
13
                self.app.restart_matcher(false);
713
13
                self.try_flush_render();
714
            }
715
            // IPC listener branch. `tokio::select!` does not allow `#[cfg]` on a
716
            // branch, so the branch stays but its body is gated: with the `listen`
717
            // feature off the future is a never-resolving `pending()` and the handler
718
            // is unreachable (`RemoteStream` is uninhabited).
719
128
            Ok(stream) = async {
720
                #[cfg(feature = "listen")]
721
128
                if let Some(l) = self.listener.as_ref() {
  Branch (721:24): [True: 40, False: 85]
+
  Branch (721:24): [True: 0, False: 3]
+
722
40
                    return interprocess::local_socket::traits::tokio::Listener::accept(l).await;
723
88
                }
724
88
                std::future::pending::<std::io::Result<RemoteStream>>().await
725
3
            } => {
726
                #[cfg(feature = "listen")]
727
                {
728
3
                    debug!(
"Listener accepted a connection"0
);
729
3
                    let event_tx_clone_ipc = self.tui.as_ref().expect("TUI should be initialized before listening").event_tx.clone();
730
3
                    tokio::spawn(async move {
731
                        use tokio::io::AsyncBufReadExt;
732
3
                        let reader = tokio::io::BufReader::new(stream);
733
3
                        let mut lines = reader.lines();
734
6
                        while let Ok(Some(line)) = lines.next_line().await {
  Branch (734:35): [True: 3, False: 0]
 
  Branch (734:35): [True: 0, False: 0]
 
735
3
                            debug!("listener: got {
line0
}");
736
3
                            if let Ok(act) = ron::from_str::<Action>(&line) {
  Branch (736:36): [True: 3, False: 0]
 
  Branch (736:36): [True: 0, False: 0]
 
737
3
                                debug!("listener: parsed into action {
act0
:?}");
738
3
                                if let Err(e) = event_tx_clone_ipc.try_send(Event::Action(act)) {
  Branch (738:40): [True: 0, False: 3]
 
  Branch (738:40): [True: 0, False: 0]
-
739
0
                                    warn!("listener: failed to send action to backend: {e:?}");
740
3
                                }
741
0
                            }
742
                        }
743
0
                    });
744
                }
745
                #[cfg(not(feature = "listen"))]
746
                match stream {}
747
            }
748
        }
749
750
161
        Ok(self.app.should_quit)
751
161
    }
752
753
    /// Run the event loop on the current task until skim quits.
754
    ///
755
    /// This is a convenience wrapper around [`tick()`](Self::tick) that loops
756
    /// until the user accepts or aborts. Use `tick()` directly if you need
757
    /// to interleave your own logic between iterations.
758
    ///
759
    /// # Errors
760
    ///
761
    /// Returns an error if any tick in the event loop fails.
762
12
    pub async fn run(&mut self) -> Result<()> {
763
12
        self.matcher_interval = Some(tokio::time::interval(Duration::from_millis(10)));
764
12
        trace!(
"Starting event loop"0
);
765
        loop {
766
161
            if self.tick().await
?0
{
  Branch (766:16): [True: 10, False: 147]
-
  Branch (766:16): [True: 2, False: 2]
-
767
12
                break Ok(());
768
149
            }
769
        }
770
12
    }
771
772
    /// Spawn the event loop and run a user-provided future concurrently.
773
    ///
774
    /// This consumes `self`, spawns the event loop as a local task, and runs
775
    /// `user_task` alongside it. When the user accepts or aborts in the TUI,
776
    /// the event loop completes and the [`SkimOutput`] is returned — regardless
777
    /// of whether `user_task` has finished.
778
    ///
779
    /// Use this when you need to send items or do other work concurrently
780
    /// while the TUI is running.
781
    ///
782
    /// # Errors
783
    ///
784
    /// Returns an error if the event loop or the task join fails.
785
    ///
786
    /// # Example
787
    ///
788
    /// ```ignore
789
    /// let output = skim.run_until(async {
790
    ///     for i in 1..=10 {
791
    ///         tx.send(vec![Arc::new(format!("item {i}"))]);
792
    ///         tokio::time::sleep(Duration::from_millis(100)).await;
793
    ///     }
794
    /// }).await?;
795
    /// ```
796
0
    pub async fn run_until<F: Future + 'static>(mut self, user_task: F) -> Result<SkimOutput> {
797
0
        let local = tokio::task::LocalSet::new();
798
0
        local
799
0
            .run_until(async {
800
0
                let handle = tokio::task::spawn_local(async move {
801
0
                    self.run().await?;
802
0
                    Ok(self.output())
803
0
                });
804
0
                tokio::task::spawn_local(user_task);
805
0
                handle.await?
806
0
            })
807
0
            .await
808
0
    }
809
}
810
811
#[cfg(test)]
812
#[path = "skim_tests.rs"]
813
mod tests;
\ No newline at end of file +
739
0
                                    warn!("listener: failed to send action to backend: {e:?}");
740
3
                                }
741
0
                            }
742
                        }
743
0
                    });
744
                }
745
                #[cfg(not(feature = "listen"))]
746
                match stream {}
747
            }
748
        }
749
750
160
        Ok(self.app.should_quit)
751
160
    }
752
753
    /// Run the event loop on the current task until skim quits.
754
    ///
755
    /// This is a convenience wrapper around [`tick()`](Self::tick) that loops
756
    /// until the user accepts or aborts. Use `tick()` directly if you need
757
    /// to interleave your own logic between iterations.
758
    ///
759
    /// # Errors
760
    ///
761
    /// Returns an error if any tick in the event loop fails.
762
12
    pub async fn run(&mut self) -> Result<()> {
763
12
        self.matcher_interval = Some(tokio::time::interval(Duration::from_millis(10)));
764
12
        trace!(
"Starting event loop"0
);
765
        loop {
766
160
            if self.tick().await
?0
{
  Branch (766:16): [True: 10, False: 144]
+
  Branch (766:16): [True: 2, False: 4]
+
767
12
                break Ok(());
768
148
            }
769
        }
770
12
    }
771
772
    /// Spawn the event loop and run a user-provided future concurrently.
773
    ///
774
    /// This consumes `self`, spawns the event loop as a local task, and runs
775
    /// `user_task` alongside it. When the user accepts or aborts in the TUI,
776
    /// the event loop completes and the [`SkimOutput`] is returned — regardless
777
    /// of whether `user_task` has finished.
778
    ///
779
    /// Use this when you need to send items or do other work concurrently
780
    /// while the TUI is running.
781
    ///
782
    /// # Errors
783
    ///
784
    /// Returns an error if the event loop or the task join fails.
785
    ///
786
    /// # Example
787
    ///
788
    /// ```ignore
789
    /// let output = skim.run_until(async {
790
    ///     for i in 1..=10 {
791
    ///         tx.send(vec![Arc::new(format!("item {i}"))]);
792
    ///         tokio::time::sleep(Duration::from_millis(100)).await;
793
    ///     }
794
    /// }).await?;
795
    /// ```
796
0
    pub async fn run_until<F: Future + 'static>(mut self, user_task: F) -> Result<SkimOutput> {
797
0
        let local = tokio::task::LocalSet::new();
798
0
        local
799
0
            .run_until(async {
800
0
                let handle = tokio::task::spawn_local(async move {
801
0
                    self.run().await?;
802
0
                    Ok(self.output())
803
0
                });
804
0
                tokio::task::spawn_local(user_task);
805
0
                handle.await?
806
0
            })
807
0
            .await
808
0
    }
809
}
810
811
#[cfg(test)]
812
#[path = "skim_tests.rs"]
813
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/skim_item.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/skim_item.rs.html index 54edd2b6..f1bc392a 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/skim_item.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/skim_item.rs.html @@ -1 +1 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/skim_item.rs
Line
Count
Source
1
use ratatui::text::Line;
2
use std::borrow::Cow;
3
use std::fmt::{Debug, Display};
4
5
use crate::{AsAny, DisplayContext, ItemPreview, PreviewContext};
6
7
/// A `SkimItem` defines what's been processed(fetched, matched, previewed and returned) by skim
8
///
9
/// # Downcast Example
10
/// Skim will return the item back, but in `Arc<dyn SkimItem>` form. We might want a reference
11
/// to the concrete type instead of trait object. Skim provide a somehow "complicated" way to
12
/// `downcast` it back to the reference of the original concrete type.
13
///
14
/// ```rust
15
/// use skim::prelude::*;
16
///
17
/// struct MyItem {}
18
/// impl SkimItem for MyItem {
19
///     fn text(&self) -> Cow<str> {
20
///         unimplemented!()
21
///     }
22
/// }
23
///
24
/// impl MyItem {
25
///     pub fn mutable(&mut self) -> i32 {
26
///         1
27
///     }
28
///
29
///     pub fn immutable(&self) -> i32 {
30
///         0
31
///     }
32
/// }
33
///
34
/// let mut ret: Arc<dyn SkimItem> = Arc::new(MyItem{});
35
/// let mutable: &mut MyItem = Arc::get_mut(&mut ret)
36
///     .expect("item is referenced by others")
37
///     .as_any_mut() // cast to Any
38
///     .downcast_mut::<MyItem>() // downcast to (mut) concrete type
39
///     .expect("something wrong with downcast");
40
/// assert_eq!(mutable.mutable(), 1);
41
///
42
/// let immutable: &MyItem = (*ret).as_any() // cast to Any
43
///     .downcast_ref::<MyItem>() // downcast to concrete type
44
///     .expect("something wrong with downcast");
45
/// assert_eq!(immutable.immutable(), 0)
46
/// ```
47
pub trait SkimItem: AsAny + Send + Sync + 'static {
48
    /// The string to be used for matching (without color)
49
    fn text(&self) -> Cow<'_, str>;
50
51
    /// The content to be displayed on the item list, could contain ANSI properties
52
111
    fn display(&self, context: DisplayContext) -> Line<'_> {
53
111
        context.to_line(self.text())
54
111
    }
55
56
    /// Custom preview content, default to `ItemPreview::Global` which will use global preview
57
    /// setting(i.e. the command set by `preview` option)
58
68
    fn preview(&self, _context: PreviewContext) -> ItemPreview {
59
68
        ItemPreview::Global
60
68
    }
61
62
    /// Get output text(after accept), default to `text()`
63
    ///
64
    /// Note that this function is intended to be used by the caller of skim and will not be used by
65
    /// skim. And since skim will return the item back in `SkimOutput`, if string is not what you
66
    /// want, you could still use `downcast` to retain the pointer to the original struct.
67
51
    fn output(&self) -> Cow<'_, str> {
68
51
        self.text()
69
51
    }
70
71
    /// Limit the matching ranges of the `get_text` of the item.
72
    /// providing (`start_byte`, `end_byte`) of the range
73
397
    fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> {
74
397
        None
75
397
    }
76
77
    /// Byte ranges of `text()` that are hidden from display (via `--hide-nth`).
78
    ///
79
    /// Characters inside these ranges are removed from the rendered line and ignored
80
    /// for match highlighting and horizontal scrolling, but stay part of `text()` so
81
    /// they remain searchable. Ranges are expressed as (`start_byte`, `end_byte`) and
82
    /// are expected to be sorted and non-overlapping. Returns `None` when nothing is
83
    /// hidden.
84
112
    fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
85
112
        None
86
112
    }
87
88
    /// Returns true if the item should be disabled
89
    /// Disabled items cannot be selected
90
342
    fn disabled(&self) -> bool {
91
342
        false
92
342
    }
93
}
94
95
//------------------------------------------------------------------------------
96
// Implement SkimItem for raw strings
97
98
impl<T: AsRef<str> + Send + Sync + 'static> SkimItem for T {
99
598
    fn text(&self) -> Cow<'_, str> {
100
598
        Cow::Borrowed(self.as_ref())
101
598
    }
102
}
103
104
impl Display for dyn SkimItem {
105
1
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106
1
        f.write_str(&self.text())
107
1
    }
108
}
109
impl Debug for dyn SkimItem {
110
1
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111
1
        f.write_fmt(format_args!("SkimItem {{ text: {} }}", self.text()))
112
1
    }
113
}
114
115
#[cfg(test)]
116
#[cfg_attr(coverage, coverage(off))]
117
mod tests {
118
    use super::*;
119
    use std::sync::Arc;
120
121
    #[test]
122
    fn blanket_impl_default_methods() {
123
        let item = "hello".to_string();
124
        assert_eq!(item.text(), "hello");
125
        // `output` defaults to `text`.
126
        assert_eq!(item.output(), "hello");
127
        assert!(item.get_matching_ranges().is_none());
128
        assert!(!item.disabled());
129
    }
130
131
    #[test]
132
    fn display_and_debug_for_trait_object() {
133
        let item: Arc<dyn SkimItem> = Arc::new("world".to_string());
134
        let as_dyn: &dyn SkimItem = &*item;
135
        assert_eq!(format!("{as_dyn}"), "world");
136
        assert!(format!("{as_dyn:?}").contains("world"));
137
    }
138
}
\ No newline at end of file +

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/skim_item.rs
Line
Count
Source
1
use ratatui::text::Line;
2
use std::borrow::Cow;
3
use std::fmt::{Debug, Display};
4
5
use crate::{AsAny, DisplayContext, ItemPreview, PreviewContext};
6
7
/// A `SkimItem` defines what's been processed(fetched, matched, previewed and returned) by skim
8
///
9
/// # Downcast Example
10
/// Skim will return the item back, but in `Arc<dyn SkimItem>` form. We might want a reference
11
/// to the concrete type instead of trait object. Skim provide a somehow "complicated" way to
12
/// `downcast` it back to the reference of the original concrete type.
13
///
14
/// ```rust
15
/// use skim::prelude::*;
16
///
17
/// struct MyItem {}
18
/// impl SkimItem for MyItem {
19
///     fn text(&self) -> Cow<str> {
20
///         unimplemented!()
21
///     }
22
/// }
23
///
24
/// impl MyItem {
25
///     pub fn mutable(&mut self) -> i32 {
26
///         1
27
///     }
28
///
29
///     pub fn immutable(&self) -> i32 {
30
///         0
31
///     }
32
/// }
33
///
34
/// let mut ret: Arc<dyn SkimItem> = Arc::new(MyItem{});
35
/// let mutable: &mut MyItem = Arc::get_mut(&mut ret)
36
///     .expect("item is referenced by others")
37
///     .as_any_mut() // cast to Any
38
///     .downcast_mut::<MyItem>() // downcast to (mut) concrete type
39
///     .expect("something wrong with downcast");
40
/// assert_eq!(mutable.mutable(), 1);
41
///
42
/// let immutable: &MyItem = (*ret).as_any() // cast to Any
43
///     .downcast_ref::<MyItem>() // downcast to concrete type
44
///     .expect("something wrong with downcast");
45
/// assert_eq!(immutable.immutable(), 0)
46
/// ```
47
pub trait SkimItem: AsAny + Send + Sync + 'static {
48
    /// The string to be used for matching (without color)
49
    fn text(&self) -> Cow<'_, str>;
50
51
    /// The content to be displayed on the item list, could contain ANSI properties
52
111
    fn display(&self, context: DisplayContext) -> Line<'_> {
53
111
        context.to_line(self.text())
54
111
    }
55
56
    /// Custom preview content, default to `ItemPreview::Global` which will use global preview
57
    /// setting(i.e. the command set by `preview` option)
58
68
    fn preview(&self, _context: PreviewContext) -> ItemPreview {
59
68
        ItemPreview::Global
60
68
    }
61
62
    /// Get output text(after accept), default to `text()`
63
    ///
64
    /// Note that this function is intended to be used by the caller of skim and will not be used by
65
    /// skim. And since skim will return the item back in `SkimOutput`, if string is not what you
66
    /// want, you could still use `downcast` to retain the pointer to the original struct.
67
51
    fn output(&self) -> Cow<'_, str> {
68
51
        self.text()
69
51
    }
70
71
    /// Limit the matching ranges of the `get_text` of the item.
72
    /// providing (`start_byte`, `end_byte`) of the range
73
397
    fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> {
74
397
        None
75
397
    }
76
77
    /// Byte ranges of `text()` that are hidden from display (via `--hide-nth`).
78
    ///
79
    /// Characters inside these ranges are removed from the rendered line and ignored
80
    /// for match highlighting and horizontal scrolling, but stay part of `text()` so
81
    /// they remain searchable. Ranges are expressed as (`start_byte`, `end_byte`) and
82
    /// are expected to be sorted and non-overlapping. Returns `None` when nothing is
83
    /// hidden.
84
112
    fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
85
112
        None
86
112
    }
87
88
    /// Returns true if the item should be disabled
89
    /// Disabled items cannot be selected
90
342
    fn disabled(&self) -> bool {
91
342
        false
92
342
    }
93
}
94
95
//------------------------------------------------------------------------------
96
// Implement SkimItem for raw strings
97
98
impl<T: AsRef<str> + Send + Sync + 'static> SkimItem for T {
99
604
    fn text(&self) -> Cow<'_, str> {
100
604
        Cow::Borrowed(self.as_ref())
101
604
    }
102
}
103
104
impl Display for dyn SkimItem {
105
1
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106
1
        f.write_str(&self.text())
107
1
    }
108
}
109
impl Debug for dyn SkimItem {
110
1
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111
1
        f.write_fmt(format_args!("SkimItem {{ text: {} }}", self.text()))
112
1
    }
113
}
114
115
#[cfg(test)]
116
#[cfg_attr(coverage, coverage(off))]
117
mod tests {
118
    use super::*;
119
    use std::sync::Arc;
120
121
    #[test]
122
    fn blanket_impl_default_methods() {
123
        let item = "hello".to_string();
124
        assert_eq!(item.text(), "hello");
125
        // `output` defaults to `text`.
126
        assert_eq!(item.output(), "hello");
127
        assert!(item.get_matching_ranges().is_none());
128
        assert!(!item.disabled());
129
    }
130
131
    #[test]
132
    fn display_and_debug_for_trait_object() {
133
        let item: Arc<dyn SkimItem> = Arc::new("world".to_string());
134
        let as_dyn: &dyn SkimItem = &*item;
135
        assert_eq!(format!("{as_dyn}"), "world");
136
        assert!(format!("{as_dyn:?}").contains("world"));
137
    }
138
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/spinlock.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/spinlock.rs.html index 8d98a35b..732e2e64 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/spinlock.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/spinlock.rs.html @@ -1,6 +1,6 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/spinlock.rs
Line
Count
Source
1
//! `SpinLock` implemented using `AtomicBool`
2
//! Just like Mutex except:
3
//!
4
//! 1. It uses CAS for locking, more efficient in low contention
5
//! 2. Use `.lock()` instead of `.lock().unwrap()` to retrieve the guard.
6
//! 3. It doesn't handle poison so data is still available on thread panic.
7
use std::cell::UnsafeCell;
8
use std::ops::{Deref, DerefMut};
9
use std::sync::atomic::{AtomicBool, Ordering};
10
11
/// A spin lock that uses busy-waiting instead of OS blocking
12
#[derive(Default)]
13
pub struct SpinLock<T: ?Sized> {
14
    locked: AtomicBool,
15
    data: UnsafeCell<T>,
16
}
17
18
unsafe impl<T: ?Sized + Send> Send for SpinLock<T> {}
19
unsafe impl<T: ?Sized + Send> Sync for SpinLock<T> {}
20
21
/// RAII guard for a spin lock, automatically releases the lock when dropped
22
pub struct SpinLockGuard<'a, T: ?Sized + 'a> {
23
    // funny underscores due to how Deref/DerefMut currently work (they
24
    // disregard field privacy).
25
    __lock: &'a SpinLock<T>,
26
}
27
28
impl<'a, T: ?Sized + 'a> SpinLockGuard<'a, T> {
29
    /// Creates a guard after its lock has been acquired.
30
22.2k
    fn new(lock: &'a SpinLock<T>) -> SpinLockGuard<'a, T> {
31
22.2k
        Self { __lock: lock }
32
22.2k
    }
33
}
34
35
unsafe impl<T: ?Sized + Sync> Sync for SpinLockGuard<'_, T> {}
36
37
impl<T> SpinLock<T> {
38
    /// Creates a new unlocked spin lock containing the given value
39
1.56k
    pub fn new(t: T) -> SpinLock<T> {
40
1.56k
        Self {
41
1.56k
            locked: AtomicBool::new(false),
42
1.56k
            data: UnsafeCell::new(t),
43
1.56k
        }
44
1.56k
    }
45
}
46
47
impl<T: ?Sized> SpinLock<T> {
48
    /// Acquires the lock, blocking the current thread until it succeeds
49
22.2k
    pub fn lock(&self) -> SpinLockGuard<'_, T> {
50
32.1k
        while self
  Branch (50:15): [True: 9.80k, False: 16.1k]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/spinlock.rs
Line
Count
Source
1
//! `SpinLock` implemented using `AtomicBool`
2
//! Just like Mutex except:
3
//!
4
//! 1. It uses CAS for locking, more efficient in low contention
5
//! 2. Use `.lock()` instead of `.lock().unwrap()` to retrieve the guard.
6
//! 3. It doesn't handle poison so data is still available on thread panic.
7
use std::cell::UnsafeCell;
8
use std::ops::{Deref, DerefMut};
9
use std::sync::atomic::{AtomicBool, Ordering};
10
11
/// A spin lock that uses busy-waiting instead of OS blocking
12
#[derive(Default)]
13
pub struct SpinLock<T: ?Sized> {
14
    locked: AtomicBool,
15
    data: UnsafeCell<T>,
16
}
17
18
unsafe impl<T: ?Sized + Send> Send for SpinLock<T> {}
19
unsafe impl<T: ?Sized + Send> Sync for SpinLock<T> {}
20
21
/// RAII guard for a spin lock, automatically releases the lock when dropped
22
pub struct SpinLockGuard<'a, T: ?Sized + 'a> {
23
    // funny underscores due to how Deref/DerefMut currently work (they
24
    // disregard field privacy).
25
    __lock: &'a SpinLock<T>,
26
}
27
28
impl<'a, T: ?Sized + 'a> SpinLockGuard<'a, T> {
29
    /// Creates a guard after its lock has been acquired.
30
22.2k
    fn new(lock: &'a SpinLock<T>) -> SpinLockGuard<'a, T> {
31
22.2k
        Self { __lock: lock }
32
22.2k
    }
33
}
34
35
unsafe impl<T: ?Sized + Sync> Sync for SpinLockGuard<'_, T> {}
36
37
impl<T> SpinLock<T> {
38
    /// Creates a new unlocked spin lock containing the given value
39
1.55k
    pub fn new(t: T) -> SpinLock<T> {
40
1.55k
        Self {
41
1.55k
            locked: AtomicBool::new(false),
42
1.55k
            data: UnsafeCell::new(t),
43
1.55k
        }
44
1.55k
    }
45
}
46
47
impl<T: ?Sized> SpinLock<T> {
48
    /// Acquires the lock, blocking the current thread until it succeeds
49
22.2k
    pub fn lock(&self) -> SpinLockGuard<'_, T> {
50
30.1k
        while self
  Branch (50:15): [True: 7.86k, False: 16.0k]
   Branch (50:15): [True: 0, False: 2]
-  Branch (50:15): [True: 0, False: 189]
-  Branch (50:15): [True: 67, False: 6.00k]
+  Branch (50:15): [True: 0, False: 201]
+  Branch (50:15): [True: 0, False: 6.00k]
   Branch (50:15): [True: 0, False: 2]
-
51
32.1k
            .locked
52
32.1k
            .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed)
53
32.1k
            .is_err()
54
        {
55
9.86k
            core::hint::spin_loop();
56
        }
57
22.2k
        SpinLockGuard::new(self)
58
22.2k
    }
59
}
60
61
impl<T: ?Sized> Deref for SpinLockGuard<'_, T> {
62
    type Target = T;
63
64
16.6k
    fn deref(&self) -> &T {
65
16.6k
        unsafe { &*self.__lock.data.get() }
66
16.6k
    }
67
}
68
69
impl<T: ?Sized> DerefMut for SpinLockGuard<'_, T> {
70
6.55k
    fn deref_mut(&mut self) -> &mut T {
71
6.55k
        unsafe { &mut *self.__lock.data.get() }
72
6.55k
    }
73
}
74
75
impl<T: ?Sized> Drop for SpinLockGuard<'_, T> {
76
    #[inline]
77
22.2k
    fn drop(&mut self) {
78
22.2k
        self.__lock.locked.store(false, Ordering::Release);
79
22.2k
    }
80
}
81
82
#[cfg(test)]
83
#[cfg_attr(coverage, coverage(off))]
84
mod tests {
85
    use super::*;
86
    use std::sync::Arc;
87
    use std::sync::mpsc::channel;
88
    use std::thread;
89
90
    #[test]
91
    fn smoke() {
92
        let m = SpinLock::new(());
93
        drop(m.lock());
94
        drop(m.lock());
95
    }
96
97
    #[test]
98
    fn lots_and_lots() {
99
        const J: u32 = 1000;
100
        const K: u32 = 3;
101
102
        fn inc(m: &SpinLock<u32>) {
103
            for _ in 0..J {
104
                *m.lock() += 1;
105
            }
106
        }
107
108
        let m = Arc::new(SpinLock::new(0));
109
        let (tx, rx) = channel();
110
        for _ in 0..K {
111
            let tx2 = tx.clone();
112
            let m2 = m.clone();
113
            thread::spawn(move || {
114
                inc(&m2);
115
                tx2.send(()).unwrap();
116
            });
117
            let tx2 = tx.clone();
118
            let m2 = m.clone();
119
            thread::spawn(move || {
120
                inc(&m2);
121
                tx2.send(()).unwrap();
122
            });
123
        }
124
125
        drop(tx);
126
        for _ in 0..2 * K {
127
            rx.recv().unwrap();
128
        }
129
        assert_eq!(*m.lock(), J * K * 2);
130
    }
131
132
    #[test]
133
    fn test_mutex_unsized() {
134
        let mutex = SpinLock::new([1, 2, 3]);
135
        {
136
            let b = &mut *mutex.lock();
137
            b[0] = 4;
138
            b[2] = 5;
139
        }
140
        let comp: &[i32] = &[4, 2, 5];
141
        assert_eq!(&*mutex.lock(), comp);
142
    }
143
}
\ No newline at end of file +
51
30.1k
            .locked
52
30.1k
            .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed)
53
30.1k
            .is_err()
54
        {
55
7.86k
            core::hint::spin_loop();
56
        }
57
22.2k
        SpinLockGuard::new(self)
58
22.2k
    }
59
}
60
61
impl<T: ?Sized> Deref for SpinLockGuard<'_, T> {
62
    type Target = T;
63
64
16.6k
    fn deref(&self) -> &T {
65
16.6k
        unsafe { &*self.__lock.data.get() }
66
16.6k
    }
67
}
68
69
impl<T: ?Sized> DerefMut for SpinLockGuard<'_, T> {
70
6.54k
    fn deref_mut(&mut self) -> &mut T {
71
6.54k
        unsafe { &mut *self.__lock.data.get() }
72
6.54k
    }
73
}
74
75
impl<T: ?Sized> Drop for SpinLockGuard<'_, T> {
76
    #[inline]
77
22.2k
    fn drop(&mut self) {
78
22.2k
        self.__lock.locked.store(false, Ordering::Release);
79
22.2k
    }
80
}
81
82
#[cfg(test)]
83
#[cfg_attr(coverage, coverage(off))]
84
mod tests {
85
    use super::*;
86
    use std::sync::Arc;
87
    use std::sync::mpsc::channel;
88
    use std::thread;
89
90
    #[test]
91
    fn smoke() {
92
        let m = SpinLock::new(());
93
        drop(m.lock());
94
        drop(m.lock());
95
    }
96
97
    #[test]
98
    fn lots_and_lots() {
99
        const J: u32 = 1000;
100
        const K: u32 = 3;
101
102
        fn inc(m: &SpinLock<u32>) {
103
            for _ in 0..J {
104
                *m.lock() += 1;
105
            }
106
        }
107
108
        let m = Arc::new(SpinLock::new(0));
109
        let (tx, rx) = channel();
110
        for _ in 0..K {
111
            let tx2 = tx.clone();
112
            let m2 = m.clone();
113
            thread::spawn(move || {
114
                inc(&m2);
115
                tx2.send(()).unwrap();
116
            });
117
            let tx2 = tx.clone();
118
            let m2 = m.clone();
119
            thread::spawn(move || {
120
                inc(&m2);
121
                tx2.send(()).unwrap();
122
            });
123
        }
124
125
        drop(tx);
126
        for _ in 0..2 * K {
127
            rx.recv().unwrap();
128
        }
129
        assert_eq!(*m.lock(), J * K * 2);
130
    }
131
132
    #[test]
133
    fn test_mutex_unsized() {
134
        let mutex = SpinLock::new([1, 2, 3]);
135
        {
136
            let b = &mut *mutex.lock();
137
            b[0] = 4;
138
            b[2] = 5;
139
        }
140
        let comp: &[i32] = &[4, 2, 5];
141
        assert_eq!(&*mutex.lock(), comp);
142
    }
143
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/theme.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/theme.rs.html index 05cbc9b0..8e32bc8f 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/theme.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/theme.rs.html @@ -1,37 +1,37 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/theme.rs
Line
Count
Source
1
//! Handle the color theme
2
use ratatui::style::{Color, Modifier, Style};
3
4
use crate::options::SkimOptions;
5
6
/// The color scheme of skim's UI
7
///
8
/// <pre>
9
/// +----------------+
10
/// | >selected line |  --> selected & normal(fg/bg) & matched
11
/// |> current line  |  --> cursor & current & current_match
12
/// |  normal line   |
13
/// |\ 8/10          |  --> spinner & info
14
/// |> query         |  --> prompt & query
15
/// +----------------+
16
/// </pre>
17
#[derive(Copy, Clone, Debug)]
18
pub struct ColorTheme {
19
    /// Non-selected lines and general text
20
    pub normal: Style,
21
    /// Matched text on non-current lines
22
    pub matched: Style,
23
    /// Current line, non-matched text
24
    pub current: Style,
25
    /// Current line, matched text
26
    pub current_match: Style,
27
    /// Query text/input
28
    pub query: Style,
29
    /// Spinner
30
    pub spinner: Style,
31
    /// Info (outside of spinner)
32
    pub info: Style,
33
    /// Prompt prefix
34
    pub prompt: Style,
35
    /// Cursor/Selector/pointer (prefix of current item)
36
    pub cursor: Style,
37
    /// Multi-selector/marker (prefix of selected items)
38
    pub selected: Style,
39
    /// Header lines
40
    pub header: Style,
41
    /// Border
42
    pub border: Style,
43
    /// Scrollbar thumb on the item list
44
    pub scrollbar: Style,
45
}
46
47
impl Default for ColorTheme {
48
    /// Theme defaults to Dark256
49
299
    fn default() -> Self {
50
299
        ColorTheme::dark256()
51
299
    }
52
}
53
54
#[allow(dead_code)]
55
impl ColorTheme {
56
    /// Setup the theme from the skim options
57
    #[must_use]
58
407
    pub fn init_from_options(options: &SkimOptions) -> ColorTheme {
59
        // register
60
407
        if let Some(color) = options.color.clone() {
  Branch (60:16): [True: 5, False: 383]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/theme.rs
Line
Count
Source
1
//! Handle the color theme
2
use ratatui::style::{Color, Modifier, Style};
3
4
use crate::options::SkimOptions;
5
6
/// The color scheme of skim's UI
7
///
8
/// <pre>
9
/// +----------------+
10
/// | >selected line |  --> selected & normal(fg/bg) & matched
11
/// |> current line  |  --> cursor & current & current_match
12
/// |  normal line   |
13
/// |\ 8/10          |  --> spinner & info
14
/// |> query         |  --> prompt & query
15
/// +----------------+
16
/// </pre>
17
#[derive(Copy, Clone, Debug)]
18
pub struct ColorTheme {
19
    /// Non-selected lines and general text
20
    pub normal: Style,
21
    /// Matched text on non-current lines
22
    pub matched: Style,
23
    /// Current line, non-matched text
24
    pub current: Style,
25
    /// Current line, matched text
26
    pub current_match: Style,
27
    /// Query text/input
28
    pub query: Style,
29
    /// Spinner
30
    pub spinner: Style,
31
    /// Info (outside of spinner)
32
    pub info: Style,
33
    /// Prompt prefix
34
    pub prompt: Style,
35
    /// Cursor/Selector/pointer (prefix of current item)
36
    pub cursor: Style,
37
    /// Multi-selector/marker (prefix of selected items)
38
    pub selected: Style,
39
    /// Header lines
40
    pub header: Style,
41
    /// Border
42
    pub border: Style,
43
    /// Scrollbar thumb on the item list
44
    pub scrollbar: Style,
45
}
46
47
impl Default for ColorTheme {
48
    /// Theme defaults to Dark256
49
300
    fn default() -> Self {
50
300
        ColorTheme::dark256()
51
300
    }
52
}
53
54
#[allow(dead_code)]
55
impl ColorTheme {
56
    /// Setup the theme from the skim options
57
    #[must_use]
58
406
    pub fn init_from_options(options: &SkimOptions) -> ColorTheme {
59
        // register
60
406
        if let Some(color) = options.color.clone() {
  Branch (60:16): [True: 6, False: 381]
 
  Branch (60:16): [True: 1, False: 18]
-
61
6
            ColorTheme::from_options(&color)
62
        } else {
63
            // Check for NO_COLOR environment variable
64
401
            match std::env::var_os("NO_COLOR") {
65
2
                Some(no_color) if !no_color.is_empty() => 
ColorTheme::none()1
,
  Branch (65:35): [True: 0, False: 0]
+
61
7
            ColorTheme::from_options(&color)
62
        } else {
63
            // Check for NO_COLOR environment variable
64
399
            match std::env::var_os("NO_COLOR") {
65
2
                Some(no_color) if !no_color.is_empty() => 
ColorTheme::none()1
,
  Branch (65:35): [True: 0, False: 0]
 
  Branch (65:35): [True: 1, False: 1]
-
66
400
                _ => ColorTheme::dark256(),
67
            }
68
        }
69
407
    }
70
71
820
    fn none() -> Self {
72
820
        let def = Style::default();
73
820
        Self {
74
820
            spinner: Style::default().bold(),
75
820
            normal: def,
76
820
            matched: def,
77
820
            current: def,
78
820
            current_match: def,
79
820
            query: def,
80
820
            info: def,
81
820
            prompt: def,
82
820
            cursor: def,
83
820
            selected: def,
84
820
            header: def,
85
820
            border: def,
86
820
            scrollbar: def,
87
820
        }
88
820
    }
89
90
2
    fn bw() -> Self {
91
2
        let base = ColorTheme::none();
92
2
        ColorTheme {
93
2
            matched: base.matched.underlined(),
94
2
            current: base.current.reversed(),
95
2
            current_match: base.current_match.reversed().underlined(),
96
2
            ..base
97
2
        }
98
2
    }
99
100
4
    fn default16() -> Self {
101
4
        let base = ColorTheme::none();
102
4
        ColorTheme {
103
4
            matched: base.matched.fg(Color::Green),
104
4
            current: base.current.fg(Color::Yellow),
105
4
            current_match: base.current_match.fg(Color::Green),
106
4
            spinner: base.spinner.fg(Color::Green),
107
4
            info: base.info.fg(Color::White),
108
4
            prompt: base.prompt.fg(Color::Blue),
109
4
            cursor: base.cursor.fg(Color::Red),
110
4
            selected: base.selected.fg(Color::Magenta),
111
4
            header: base.header.fg(Color::Cyan),
112
4
            border: base.border.fg(Color::Black),
113
4
            scrollbar: base.scrollbar.fg(Color::Black),
114
4
            ..base
115
4
        }
116
4
    }
117
118
793
    fn dark256() -> Self {
119
793
        let base = ColorTheme::none();
120
793
        ColorTheme {
121
793
            matched: base.matched.fg(Color::Indexed(108)).bg(Color::Indexed(0)),
122
793
            current: base.current.bg(Color::Indexed(236)),
123
793
            current_match: base.current_match.fg(Color::Indexed(151)).bg(Color::Indexed(236)),
124
793
            spinner: base.spinner.fg(Color::Indexed(148)),
125
793
            info: base.info.fg(Color::Indexed(144)),
126
793
            prompt: base.prompt.fg(Color::Indexed(110)),
127
793
            cursor: base.cursor.fg(Color::Indexed(161)),
128
793
            selected: base.selected.fg(Color::Indexed(168)),
129
793
            header: base.header.fg(Color::Indexed(109)),
130
793
            border: base.border.fg(Color::Indexed(59)),
131
793
            scrollbar: base.scrollbar.fg(Color::Indexed(59)),
132
793
            ..base
133
793
        }
134
793
    }
135
136
2
    fn molokai256() -> Self {
137
2
        let base = ColorTheme::none();
138
2
        ColorTheme {
139
2
            matched: base.matched.fg(Color::Indexed(234)).bg(Color::Indexed(186)),
140
2
            current: base.current.bg(Color::Indexed(236)),
141
2
            current_match: base.current_match.fg(Color::Indexed(234)).bg(Color::Indexed(186)),
142
2
            spinner: base.spinner.fg(Color::Indexed(148)),
143
2
            info: base.info.fg(Color::Indexed(144)),
144
2
            prompt: base.prompt.fg(Color::Indexed(110)),
145
2
            cursor: base.cursor.fg(Color::Indexed(161)),
146
2
            selected: base.selected.fg(Color::Indexed(168)),
147
2
            header: base.header.fg(Color::Indexed(109)),
148
2
            border: base.border.fg(Color::Indexed(59)),
149
2
            scrollbar: base.scrollbar.fg(Color::Indexed(59)),
150
2
            ..base
151
2
        }
152
2
    }
153
154
2
    fn light256() -> Self {
155
2
        let base = ColorTheme::none();
156
2
        ColorTheme {
157
2
            matched: base.matched.fg(Color::Indexed(0)).bg(Color::Indexed(220)),
158
2
            current: base.current.bg(Color::Indexed(251)),
159
2
            current_match: base.current_match.fg(Color::Indexed(66)).bg(Color::Indexed(251)),
160
2
            spinner: base.spinner.fg(Color::Indexed(65)),
161
2
            info: base.info.fg(Color::Indexed(101)),
162
2
            prompt: base.prompt.fg(Color::Indexed(25)),
163
2
            cursor: base.cursor.fg(Color::Indexed(161)),
164
2
            selected: base.selected.fg(Color::Indexed(168)),
165
2
            header: base.header.fg(Color::Indexed(31)),
166
2
            border: base.border.fg(Color::Indexed(145)),
167
2
            scrollbar: base.scrollbar.fg(Color::Indexed(145)),
168
2
            ..base
169
2
        }
170
2
    }
171
172
    #[allow(unused_variables)]
173
3
    fn catppuccin_mocha() -> Self {
174
3
        let base = ColorTheme::none();
175
3
        let text = Color::Rgb(205, 214, 244);
176
3
        let subtext0 = Color::Rgb(166, 173, 200);
177
3
        let subtext1 = Color::Rgb(186, 194, 222);
178
3
        let overlay0 = Color::Rgb(108, 112, 134);
179
3
        let surface0 = Color::Rgb(49, 50, 68);
180
3
        let blue = Color::Rgb(137, 180, 250);
181
3
        let red = Color::Rgb(243, 139, 168);
182
3
        let lavender = Color::Rgb(180, 190, 254);
183
3
        let sapphire = Color::Rgb(116, 199, 236);
184
3
        Self {
185
3
            normal: base.normal.fg(text),
186
3
            matched: base.matched.fg(blue).underlined(),
187
3
            current: base.current.bg(surface0),
188
3
            current_match: base.current_match.fg(red).underlined(),
189
3
            query: base.query.fg(text),
190
3
            spinner: base.spinner.fg(subtext1).bold(),
191
3
            info: base.info.fg(subtext1),
192
3
            prompt: base.prompt.fg(lavender),
193
3
            cursor: base.cursor.fg(red),
194
3
            selected: base.selected.fg(red),
195
3
            header: base.header.fg(subtext1),
196
3
            border: base.header.fg(lavender),
197
3
            scrollbar: base.scrollbar.fg(overlay0),
198
3
        }
199
3
    }
200
    #[allow(unused_variables)]
201
3
    fn catppuccin_macchiato() -> Self {
202
3
        let base = ColorTheme::none();
203
3
        let text = Color::Rgb(202, 211, 245);
204
3
        let subtext0 = Color::Rgb(165, 173, 203);
205
3
        let subtext1 = Color::Rgb(184, 192, 224);
206
3
        let overlay0 = Color::Rgb(110, 115, 141);
207
3
        let surface0 = Color::Rgb(54, 58, 79);
208
3
        let blue = Color::Rgb(138, 173, 244);
209
3
        let red = Color::Rgb(237, 135, 150);
210
3
        let lavender = Color::Rgb(183, 189, 248);
211
3
        let sapphire = Color::Rgb(125, 196, 228);
212
3
        Self {
213
3
            normal: base.normal.fg(text),
214
3
            matched: base.matched.fg(blue).underlined(),
215
3
            current: base.current.bg(surface0),
216
3
            current_match: base.current_match.fg(red).underlined(),
217
3
            query: base.query.fg(text),
218
3
            spinner: base.spinner.fg(subtext1).bold(),
219
3
            info: base.info.fg(subtext1),
220
3
            prompt: base.prompt.fg(lavender),
221
3
            cursor: base.cursor.fg(red),
222
3
            selected: base.selected.fg(red),
223
3
            header: base.header.fg(subtext1),
224
3
            border: base.header.fg(lavender),
225
3
            scrollbar: base.scrollbar.fg(overlay0),
226
3
        }
227
3
    }
228
    #[allow(unused_variables)]
229
3
    fn catppuccin_latte() -> Self {
230
3
        let base = ColorTheme::none();
231
3
        let text = Color::Rgb(76, 79, 105);
232
3
        let subtext0 = Color::Rgb(108, 111, 133);
233
3
        let subtext1 = Color::Rgb(92, 95, 119);
234
3
        let overlay0 = Color::Rgb(156, 160, 176);
235
3
        let surface0 = Color::Rgb(204, 208, 218);
236
3
        let blue = Color::Rgb(30, 102, 245);
237
3
        let red = Color::Rgb(210, 15, 57);
238
3
        let lavender = Color::Rgb(114, 135, 253);
239
3
        let sapphire = Color::Rgb(32, 159, 181);
240
3
        Self {
241
3
            normal: base.normal.fg(text),
242
3
            matched: base.matched.fg(blue).underlined(),
243
3
            current: base.current.bg(surface0),
244
3
            current_match: base.current_match.fg(red).underlined(),
245
3
            query: base.query.fg(text),
246
3
            spinner: base.spinner.fg(subtext1).bold(),
247
3
            info: base.info.fg(subtext1),
248
3
            prompt: base.prompt.fg(lavender),
249
3
            cursor: base.cursor.fg(red),
250
3
            selected: base.selected.fg(red),
251
3
            header: base.header.fg(subtext1),
252
3
            border: base.header.fg(lavender),
253
3
            scrollbar: base.scrollbar.fg(overlay0),
254
3
        }
255
3
    }
256
    #[allow(unused_variables)]
257
3
    fn catppuccin_frappe() -> Self {
258
3
        let base = ColorTheme::none();
259
3
        let text = Color::Rgb(198, 208, 245);
260
3
        let subtext0 = Color::Rgb(165, 173, 206);
261
3
        let subtext1 = Color::Rgb(181, 191, 226);
262
3
        let overlay0 = Color::Rgb(115, 121, 148);
263
3
        let surface0 = Color::Rgb(65, 69, 89);
264
3
        let blue = Color::Rgb(140, 170, 238);
265
3
        let red = Color::Rgb(231, 130, 132);
266
3
        let lavender = Color::Rgb(186, 187, 241);
267
3
        let sapphire = Color::Rgb(133, 193, 220);
268
3
        Self {
269
3
            normal: base.normal.fg(text),
270
3
            matched: base.matched.fg(blue).underlined(),
271
3
            current: base.current.bg(surface0),
272
3
            current_match: base.current_match.fg(red).underlined(),
273
3
            query: base.query.fg(text),
274
3
            spinner: base.spinner.fg(subtext1).bold(),
275
3
            info: base.info.fg(subtext1),
276
3
            prompt: base.prompt.fg(lavender),
277
3
            cursor: base.cursor.fg(red),
278
3
            selected: base.selected.fg(red),
279
3
            header: base.header.fg(subtext1),
280
3
            border: base.header.fg(lavender),
281
3
            scrollbar: base.scrollbar.fg(overlay0),
282
3
        }
283
3
    }
284
285
77
    fn set_color(&mut self, name: &str, spec: &str) {
286
77
        let spec_parts: Vec<_> = spec.split(&['+', ':']).collect();
287
288
        // Compute modifiers
289
77
        let mut modifier = Modifier::empty();
290
77
        for part in spec_parts.iter().skip(1) {
291
23
            if matches!(*part, "x" | "regular") {
292
2
                modifier = Modifier::empty();
293
            } else {
294
21
                modifier |= match *part {
295
7
                    "b" | "bold" => Modifier::BOLD,
296
7
                    "u" | "underlined" => Modifier::UNDERLINED,
297
1
                    "c" | "crossed-out" => Modifier::CROSSED_OUT,
298
1
                    "d" | "dim" => Modifier::DIM,
299
2
                    "i" | "italic" => Modifier::ITALIC,
300
2
                    "r" | "reverse" => Modifier::REVERSED,
301
                    m => {
302
1
                        debug!("Unknown modifier '{
m0
}'");
303
1
                        Modifier::empty()
304
                    }
305
                };
306
            }
307
        }
308
        // Apply - check for layer suffixes (_fg, -fg, _bg, -bg, _u, -u, etc.)
309
77
        let (component_name, layer) = if name.ends_with("_fg") || 
name.ends_with("-fg")76
{
  Branch (309:42): [True: 0, False: 10]
-  Branch (309:67): [True: 0, False: 10]
+
66
398
                _ => ColorTheme::dark256(),
67
            }
68
        }
69
406
    }
70
71
820
    fn none() -> Self {
72
820
        let def = Style::default();
73
820
        Self {
74
820
            spinner: Style::default().bold(),
75
820
            normal: def,
76
820
            matched: def,
77
820
            current: def,
78
820
            current_match: def,
79
820
            query: def,
80
820
            info: def,
81
820
            prompt: def,
82
820
            cursor: def,
83
820
            selected: def,
84
820
            header: def,
85
820
            border: def,
86
820
            scrollbar: def,
87
820
        }
88
820
    }
89
90
2
    fn bw() -> Self {
91
2
        let base = ColorTheme::none();
92
2
        ColorTheme {
93
2
            matched: base.matched.underlined(),
94
2
            current: base.current.reversed(),
95
2
            current_match: base.current_match.reversed().underlined(),
96
2
            ..base
97
2
        }
98
2
    }
99
100
4
    fn default16() -> Self {
101
4
        let base = ColorTheme::none();
102
4
        ColorTheme {
103
4
            matched: base.matched.fg(Color::Green),
104
4
            current: base.current.fg(Color::Yellow),
105
4
            current_match: base.current_match.fg(Color::Green),
106
4
            spinner: base.spinner.fg(Color::Green),
107
4
            info: base.info.fg(Color::White),
108
4
            prompt: base.prompt.fg(Color::Blue),
109
4
            cursor: base.cursor.fg(Color::Red),
110
4
            selected: base.selected.fg(Color::Magenta),
111
4
            header: base.header.fg(Color::Cyan),
112
4
            border: base.border.fg(Color::Black),
113
4
            scrollbar: base.scrollbar.fg(Color::Black),
114
4
            ..base
115
4
        }
116
4
    }
117
118
793
    fn dark256() -> Self {
119
793
        let base = ColorTheme::none();
120
793
        ColorTheme {
121
793
            matched: base.matched.fg(Color::Indexed(108)).bg(Color::Indexed(0)),
122
793
            current: base.current.bg(Color::Indexed(236)),
123
793
            current_match: base.current_match.fg(Color::Indexed(151)).bg(Color::Indexed(236)),
124
793
            spinner: base.spinner.fg(Color::Indexed(148)),
125
793
            info: base.info.fg(Color::Indexed(144)),
126
793
            prompt: base.prompt.fg(Color::Indexed(110)),
127
793
            cursor: base.cursor.fg(Color::Indexed(161)),
128
793
            selected: base.selected.fg(Color::Indexed(168)),
129
793
            header: base.header.fg(Color::Indexed(109)),
130
793
            border: base.border.fg(Color::Indexed(59)),
131
793
            scrollbar: base.scrollbar.fg(Color::Indexed(59)),
132
793
            ..base
133
793
        }
134
793
    }
135
136
2
    fn molokai256() -> Self {
137
2
        let base = ColorTheme::none();
138
2
        ColorTheme {
139
2
            matched: base.matched.fg(Color::Indexed(234)).bg(Color::Indexed(186)),
140
2
            current: base.current.bg(Color::Indexed(236)),
141
2
            current_match: base.current_match.fg(Color::Indexed(234)).bg(Color::Indexed(186)),
142
2
            spinner: base.spinner.fg(Color::Indexed(148)),
143
2
            info: base.info.fg(Color::Indexed(144)),
144
2
            prompt: base.prompt.fg(Color::Indexed(110)),
145
2
            cursor: base.cursor.fg(Color::Indexed(161)),
146
2
            selected: base.selected.fg(Color::Indexed(168)),
147
2
            header: base.header.fg(Color::Indexed(109)),
148
2
            border: base.border.fg(Color::Indexed(59)),
149
2
            scrollbar: base.scrollbar.fg(Color::Indexed(59)),
150
2
            ..base
151
2
        }
152
2
    }
153
154
2
    fn light256() -> Self {
155
2
        let base = ColorTheme::none();
156
2
        ColorTheme {
157
2
            matched: base.matched.fg(Color::Indexed(0)).bg(Color::Indexed(220)),
158
2
            current: base.current.bg(Color::Indexed(251)),
159
2
            current_match: base.current_match.fg(Color::Indexed(66)).bg(Color::Indexed(251)),
160
2
            spinner: base.spinner.fg(Color::Indexed(65)),
161
2
            info: base.info.fg(Color::Indexed(101)),
162
2
            prompt: base.prompt.fg(Color::Indexed(25)),
163
2
            cursor: base.cursor.fg(Color::Indexed(161)),
164
2
            selected: base.selected.fg(Color::Indexed(168)),
165
2
            header: base.header.fg(Color::Indexed(31)),
166
2
            border: base.border.fg(Color::Indexed(145)),
167
2
            scrollbar: base.scrollbar.fg(Color::Indexed(145)),
168
2
            ..base
169
2
        }
170
2
    }
171
172
    #[allow(unused_variables)]
173
3
    fn catppuccin_mocha() -> Self {
174
3
        let base = ColorTheme::none();
175
3
        let text = Color::Rgb(205, 214, 244);
176
3
        let subtext0 = Color::Rgb(166, 173, 200);
177
3
        let subtext1 = Color::Rgb(186, 194, 222);
178
3
        let overlay0 = Color::Rgb(108, 112, 134);
179
3
        let surface0 = Color::Rgb(49, 50, 68);
180
3
        let blue = Color::Rgb(137, 180, 250);
181
3
        let red = Color::Rgb(243, 139, 168);
182
3
        let lavender = Color::Rgb(180, 190, 254);
183
3
        let sapphire = Color::Rgb(116, 199, 236);
184
3
        Self {
185
3
            normal: base.normal.fg(text),
186
3
            matched: base.matched.fg(blue).underlined(),
187
3
            current: base.current.bg(surface0),
188
3
            current_match: base.current_match.fg(red).underlined(),
189
3
            query: base.query.fg(text),
190
3
            spinner: base.spinner.fg(subtext1).bold(),
191
3
            info: base.info.fg(subtext1),
192
3
            prompt: base.prompt.fg(lavender),
193
3
            cursor: base.cursor.fg(red),
194
3
            selected: base.selected.fg(red),
195
3
            header: base.header.fg(subtext1),
196
3
            border: base.header.fg(lavender),
197
3
            scrollbar: base.scrollbar.fg(overlay0),
198
3
        }
199
3
    }
200
    #[allow(unused_variables)]
201
3
    fn catppuccin_macchiato() -> Self {
202
3
        let base = ColorTheme::none();
203
3
        let text = Color::Rgb(202, 211, 245);
204
3
        let subtext0 = Color::Rgb(165, 173, 203);
205
3
        let subtext1 = Color::Rgb(184, 192, 224);
206
3
        let overlay0 = Color::Rgb(110, 115, 141);
207
3
        let surface0 = Color::Rgb(54, 58, 79);
208
3
        let blue = Color::Rgb(138, 173, 244);
209
3
        let red = Color::Rgb(237, 135, 150);
210
3
        let lavender = Color::Rgb(183, 189, 248);
211
3
        let sapphire = Color::Rgb(125, 196, 228);
212
3
        Self {
213
3
            normal: base.normal.fg(text),
214
3
            matched: base.matched.fg(blue).underlined(),
215
3
            current: base.current.bg(surface0),
216
3
            current_match: base.current_match.fg(red).underlined(),
217
3
            query: base.query.fg(text),
218
3
            spinner: base.spinner.fg(subtext1).bold(),
219
3
            info: base.info.fg(subtext1),
220
3
            prompt: base.prompt.fg(lavender),
221
3
            cursor: base.cursor.fg(red),
222
3
            selected: base.selected.fg(red),
223
3
            header: base.header.fg(subtext1),
224
3
            border: base.header.fg(lavender),
225
3
            scrollbar: base.scrollbar.fg(overlay0),
226
3
        }
227
3
    }
228
    #[allow(unused_variables)]
229
3
    fn catppuccin_latte() -> Self {
230
3
        let base = ColorTheme::none();
231
3
        let text = Color::Rgb(76, 79, 105);
232
3
        let subtext0 = Color::Rgb(108, 111, 133);
233
3
        let subtext1 = Color::Rgb(92, 95, 119);
234
3
        let overlay0 = Color::Rgb(156, 160, 176);
235
3
        let surface0 = Color::Rgb(204, 208, 218);
236
3
        let blue = Color::Rgb(30, 102, 245);
237
3
        let red = Color::Rgb(210, 15, 57);
238
3
        let lavender = Color::Rgb(114, 135, 253);
239
3
        let sapphire = Color::Rgb(32, 159, 181);
240
3
        Self {
241
3
            normal: base.normal.fg(text),
242
3
            matched: base.matched.fg(blue).underlined(),
243
3
            current: base.current.bg(surface0),
244
3
            current_match: base.current_match.fg(red).underlined(),
245
3
            query: base.query.fg(text),
246
3
            spinner: base.spinner.fg(subtext1).bold(),
247
3
            info: base.info.fg(subtext1),
248
3
            prompt: base.prompt.fg(lavender),
249
3
            cursor: base.cursor.fg(red),
250
3
            selected: base.selected.fg(red),
251
3
            header: base.header.fg(subtext1),
252
3
            border: base.header.fg(lavender),
253
3
            scrollbar: base.scrollbar.fg(overlay0),
254
3
        }
255
3
    }
256
    #[allow(unused_variables)]
257
3
    fn catppuccin_frappe() -> Self {
258
3
        let base = ColorTheme::none();
259
3
        let text = Color::Rgb(198, 208, 245);
260
3
        let subtext0 = Color::Rgb(165, 173, 206);
261
3
        let subtext1 = Color::Rgb(181, 191, 226);
262
3
        let overlay0 = Color::Rgb(115, 121, 148);
263
3
        let surface0 = Color::Rgb(65, 69, 89);
264
3
        let blue = Color::Rgb(140, 170, 238);
265
3
        let red = Color::Rgb(231, 130, 132);
266
3
        let lavender = Color::Rgb(186, 187, 241);
267
3
        let sapphire = Color::Rgb(133, 193, 220);
268
3
        Self {
269
3
            normal: base.normal.fg(text),
270
3
            matched: base.matched.fg(blue).underlined(),
271
3
            current: base.current.bg(surface0),
272
3
            current_match: base.current_match.fg(red).underlined(),
273
3
            query: base.query.fg(text),
274
3
            spinner: base.spinner.fg(subtext1).bold(),
275
3
            info: base.info.fg(subtext1),
276
3
            prompt: base.prompt.fg(lavender),
277
3
            cursor: base.cursor.fg(red),
278
3
            selected: base.selected.fg(red),
279
3
            header: base.header.fg(subtext1),
280
3
            border: base.header.fg(lavender),
281
3
            scrollbar: base.scrollbar.fg(overlay0),
282
3
        }
283
3
    }
284
285
80
    fn set_color(&mut self, name: &str, spec: &str) {
286
80
        let spec_parts: Vec<_> = spec.split(&['+', ':']).collect();
287
288
        // Compute modifiers
289
80
        let mut modifier = Modifier::empty();
290
80
        for part in spec_parts.iter().skip(1) {
291
23
            if matches!(*part, "x" | "regular") {
292
2
                modifier = Modifier::empty();
293
            } else {
294
21
                modifier |= match *part {
295
7
                    "b" | "bold" => Modifier::BOLD,
296
7
                    "u" | "underlined" => Modifier::UNDERLINED,
297
1
                    "c" | "crossed-out" => Modifier::CROSSED_OUT,
298
1
                    "d" | "dim" => Modifier::DIM,
299
2
                    "i" | "italic" => Modifier::ITALIC,
300
2
                    "r" | "reverse" => Modifier::REVERSED,
301
                    m => {
302
1
                        debug!("Unknown modifier '{
m0
}'");
303
1
                        Modifier::empty()
304
                    }
305
                };
306
            }
307
        }
308
        // Apply - check for layer suffixes (_fg, -fg, _bg, -bg, _u, -u, etc.)
309
80
        let (component_name, layer) = if name.ends_with("_fg") || 
name.ends_with("-fg")79
{
  Branch (309:42): [True: 0, False: 13]
+  Branch (309:67): [True: 0, False: 13]
 
  Branch (309:42): [True: 1, False: 66]
   Branch (309:67): [True: 1, False: 65]
-
310
2
            (&name[..name.len() - 3], "fg")
311
75
        } else if name.ends_with("_bg") || 
name.ends_with("-bg")69
{
  Branch (311:19): [True: 5, False: 5]
-  Branch (311:44): [True: 0, False: 5]
+
310
2
            (&name[..name.len() - 3], "fg")
311
78
        } else if name.ends_with("_bg") || 
name.ends_with("-bg")71
{
  Branch (311:19): [True: 6, False: 7]
+  Branch (311:44): [True: 0, False: 7]
 
  Branch (311:19): [True: 1, False: 64]
   Branch (311:44): [True: 2, False: 62]
-
312
8
            (&name[..name.len() - 3], "bg")
313
67
        } else if name.ends_with("_u") || 
name.ends_with("-u")66
{
  Branch (313:19): [True: 0, False: 5]
-  Branch (313:43): [True: 0, False: 5]
+
312
9
            (&name[..name.len() - 3], "bg")
313
69
        } else if name.ends_with("_u") || 
name.ends_with("-u")68
{
  Branch (313:19): [True: 0, False: 7]
+  Branch (313:43): [True: 0, False: 7]
 
  Branch (313:19): [True: 1, False: 61]
   Branch (313:43): [True: 1, False: 60]
-
314
2
            (&name[..name.len() - 2], "u")
315
65
        } else if name.ends_with("_underline") || 
name.ends_with("-underline")64
{
  Branch (315:19): [True: 0, False: 5]
-  Branch (315:51): [True: 0, False: 5]
+
314
2
            (&name[..name.len() - 2], "u")
315
67
        } else if name.ends_with("_underline") || 
name.ends_with("-underline")66
{
  Branch (315:19): [True: 0, False: 7]
+  Branch (315:51): [True: 0, False: 7]
 
  Branch (315:19): [True: 1, False: 59]
   Branch (315:51): [True: 1, False: 58]
-
316
2
            (&name[..name.len() - 10], "underline")
317
63
        } else if name == "bg" {
  Branch (317:19): [True: 0, False: 5]
+
316
2
            (&name[..name.len() - 10], "underline")
317
65
        } else if name == "bg" {
  Branch (317:19): [True: 0, False: 7]
 
  Branch (317:19): [True: 1, False: 57]
-
318
1
            ("", "bg")
319
        } else {
320
62
            (name, "fg")
321
        };
322
323
77
        let target_style = match component_name {
324
2
            "" | "normal" => &mut self.normal,
325
35
            "matched" | "hl" => &mut self.matched,
326
9
            "current" | "fg+" | "bg+" => &mut self.current,
327
7
            "current_match" | "hl+" => &mut self.current_match,
328
1
            "query" => &mut self.query,
329
1
            "spinner" => &mut self.spinner,
330
2
            "info" => &mut self.info,
331
10
            "prompt" => &mut self.prompt,
332
2
            "cursor" | "pointer" => &mut self.cursor,
333
2
            "selected" | "marker" => &mut self.selected,
334
1
            "header" => &mut self.header,
335
1
            "border" => &mut self.border,
336
3
            "scrollbar" => &mut self.scrollbar,
337
1
            _ => return,
338
        };
339
340
        // Handle color reset with `-1`
341
76
        let raw_color = spec_parts[0];
342
        // Compute color
343
76
        let new_color = if raw_color.len() == 7 && 
raw_color.starts_with('#')10
{
  Branch (343:28): [True: 0, False: 10]
+
318
1
            ("", "bg")
319
        } else {
320
64
            (name, "fg")
321
        };
322
323
80
        let target_style = match component_name {
324
2
            "" | "normal" => &mut self.normal,
325
36
            "matched" | "hl" => &mut self.matched,
326
10
            "current" | "fg+" | "bg+" => &mut self.current,
327
8
            "current_match" | "hl+" => &mut self.current_match,
328
1
            "query" => &mut self.query,
329
1
            "spinner" => &mut self.spinner,
330
2
            "info" => &mut self.info,
331
10
            "prompt" => &mut self.prompt,
332
2
            "cursor" | "pointer" => &mut self.cursor,
333
2
            "selected" | "marker" => &mut self.selected,
334
1
            "header" => &mut self.header,
335
1
            "border" => &mut self.border,
336
3
            "scrollbar" => &mut self.scrollbar,
337
1
            _ => return,
338
        };
339
340
        // Handle color reset with `-1`
341
79
        let raw_color = spec_parts[0];
342
        // Compute color
343
79
        let new_color = if raw_color.len() == 7 && 
raw_color.starts_with('#')10
{
  Branch (343:28): [True: 0, False: 13]
   Branch (343:52): [True: 0, False: 0]
 
  Branch (343:28): [True: 10, False: 56]
   Branch (343:52): [True: 9, False: 1]
-
344
            // RGB Hex color
345
9
            let r = u8::from_str_radix(&raw_color[1..3], 16).unwrap_or(255);
346
9
            let g = u8::from_str_radix(&raw_color[3..5], 16).unwrap_or(255);
347
9
            let b = u8::from_str_radix(&raw_color[5..7], 16).unwrap_or(255);
348
9
            Some(Color::Rgb(r, g, b))
349
67
        } else if raw_color == "-1" {
  Branch (349:19): [True: 0, False: 10]
+
344
            // RGB Hex color
345
9
            let r = u8::from_str_radix(&raw_color[1..3], 16).unwrap_or(255);
346
9
            let g = u8::from_str_radix(&raw_color[3..5], 16).unwrap_or(255);
347
9
            let b = u8::from_str_radix(&raw_color[5..7], 16).unwrap_or(255);
348
9
            Some(Color::Rgb(r, g, b))
349
70
        } else if raw_color == "-1" {
  Branch (349:19): [True: 0, False: 13]
 
  Branch (349:19): [True: 6, False: 51]
-
350
6
            Some(Color::Reset)
351
        } else {
352
61
            raw_color.parse::<u8>().ok().map(Color::Indexed).or_else(|| 
{1
353
1
                if !raw_color.is_empty() {
  Branch (353:20): [True: 0, False: 0]
+
350
6
            Some(Color::Reset)
351
        } else {
352
64
            raw_color.parse::<u8>().ok().map(Color::Indexed).or_else(|| 
{1
353
1
                if !raw_color.is_empty() {
  Branch (353:20): [True: 0, False: 0]
 
  Branch (353:20): [True: 1, False: 0]
-
354
1
                    debug!("Unknown color '{}'", 
spec_parts[0]0
);
355
0
                }
356
1
                None
357
1
            })
358
        };
359
360
76
        let layer_override = if component_name == "bg+" { 
"bg"3
} else {
layer73
};
  Branch (360:33): [True: 0, False: 10]
+
354
1
                    debug!("Unknown color '{}'", 
spec_parts[0]0
);
355
0
                }
356
1
                None
357
1
            })
358
        };
359
360
79
        let layer_override = if component_name == "bg+" { 
"bg"3
} else {
layer76
};
  Branch (360:33): [True: 0, False: 13]
 
  Branch (360:33): [True: 3, False: 63]
-
361
76
        set_style(target_style, layer_override, new_color, modifier);
362
77
    }
363
364
81
    fn from_options(color: &str) -> Self {
365
81
        let mut theme = ColorTheme::dark256();
366
81
        for pair in color.split(',') {
367
101
            if let Some((name, spec)) = pair.split_once(':') {
  Branch (367:20): [True: 10, False: 0]
+
361
79
        set_style(target_style, layer_override, new_color, modifier);
362
80
    }
363
364
82
    fn from_options(color: &str) -> Self {
365
82
        let mut theme = ColorTheme::dark256();
366
82
        for pair in color.split(',') {
367
104
            if let Some((name, spec)) = pair.split_once(':') {
  Branch (367:20): [True: 13, False: 0]
 
  Branch (367:20): [True: 67, False: 24]
-
368
77
                theme.set_color(name, spec);
369
            } else {
370
24
                theme = match pair {
371
1
                    "molokai" => ColorTheme::molokai256(),
372
1
                    "light" => ColorTheme::light256(),
373
3
                    "16" => ColorTheme::default16(),
374
1
                    "bw" => ColorTheme::bw(),
375
2
                    "none" | "empty" => ColorTheme::none(),
376
7
                    "dark" | "default" => ColorTheme::dark256(),
377
2
                    "catppuccin_mocha" | "catppuccin-mocha" => ColorTheme::catppuccin_mocha(),
378
2
                    "catppuccin_macchiato" | "catppuccin-macchiato" => ColorTheme::catppuccin_macchiato(),
379
2
                    "catppuccin_latte" | "catppuccin-latte" => ColorTheme::catppuccin_latte(),
380
2
                    "catppuccin_frappe" | "catppuccin-frappe" => ColorTheme::catppuccin_frappe(),
381
                    t => {
382
1
                        debug!("Unknown color theme '{
t0
}'");
383
1
                        ColorTheme::dark256()
384
                    }
385
                };
386
            }
387
        }
388
81
        theme
389
81
    }
390
}
391
392
76
fn set_style(s: &mut Style, layer: &str, color: Option<Color>, modifier: Modifier) {
393
76
    if let Some(c) = color {
  Branch (393:12): [True: 10, False: 0]
+
368
80
                theme.set_color(name, spec);
369
            } else {
370
24
                theme = match pair {
371
1
                    "molokai" => ColorTheme::molokai256(),
372
1
                    "light" => ColorTheme::light256(),
373
3
                    "16" => ColorTheme::default16(),
374
1
                    "bw" => ColorTheme::bw(),
375
2
                    "none" | "empty" => ColorTheme::none(),
376
7
                    "dark" | "default" => ColorTheme::dark256(),
377
2
                    "catppuccin_mocha" | "catppuccin-mocha" => ColorTheme::catppuccin_mocha(),
378
2
                    "catppuccin_macchiato" | "catppuccin-macchiato" => ColorTheme::catppuccin_macchiato(),
379
2
                    "catppuccin_latte" | "catppuccin-latte" => ColorTheme::catppuccin_latte(),
380
2
                    "catppuccin_frappe" | "catppuccin-frappe" => ColorTheme::catppuccin_frappe(),
381
                    t => {
382
1
                        debug!("Unknown color theme '{
t0
}'");
383
1
                        ColorTheme::dark256()
384
                    }
385
                };
386
            }
387
        }
388
82
        theme
389
82
    }
390
}
391
392
79
fn set_style(s: &mut Style, layer: &str, color: Option<Color>, modifier: Modifier) {
393
79
    if let Some(c) = color {
  Branch (393:12): [True: 13, False: 0]
 
  Branch (393:12): [True: 65, False: 1]
-
394
75
        *s = match layer {
395
59
            "fg" => s.fg(c),
396
12
            "bg" => s.bg(c),
397
4
            "u" | "underline" => s.underline_color(c),
398
0
            _ => *s,
399
        }
400
1
    }
401
76
    *s = s.add_modifier(modifier);
402
76
}
403
404
#[cfg(test)]
405
#[path = "theme_tests.rs"]
406
mod tests;
\ No newline at end of file +
394
78
        *s = match layer {
395
61
            "fg" => s.fg(c),
396
13
            "bg" => s.bg(c),
397
4
            "u" | "underline" => s.underline_color(c),
398
0
            _ => *s,
399
        }
400
1
    }
401
79
    *s = s.add_modifier(modifier);
402
79
}
403
404
#[cfg(test)]
405
#[path = "theme_tests.rs"]
406
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/thread_pool.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/thread_pool.rs.html index 506d1c02..4da6f8b7 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/thread_pool.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/thread_pool.rs.html @@ -1,18 +1,18 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/thread_pool.rs
Line
Count
Source
1
//! A lightweight thread pool with a shared work queue.
2
//!
3
//! Worker threads pick up the next available job as soon as they finish their
4
//! current one, giving natural load balancing without work-stealing.
5
6
use std::cell::UnsafeCell;
7
use std::collections::VecDeque;
8
use std::sync::atomic::{AtomicUsize, Ordering};
9
use std::sync::{Arc, Condvar, Mutex};
10
use std::thread;
11
12
// ---------------------------------------------------------------------------
13
// Thread-count partitioning
14
// ---------------------------------------------------------------------------
15
16
/// Splits `n` logical threads between the reader pipeline and the matcher.
17
///
18
/// Returns `(reader, matcher)` where:
19
/// - `reader`  = ⌈n / 3⌉, minimum 1
20
/// - `matcher` = ⌊2n / 3⌋, minimum 1
21
///
22
/// On single-core machines (`n = 1`) both values are 1 so neither subsystem
23
/// starves; the OS time-slices between the two small pools as usual.
24
#[must_use]
25
1.88k
pub fn partition_threads(n: usize) -> (usize, usize) {
26
1.88k
    let reader = n.div_ceil(3); // ⌈n/3⌉
27
1.88k
    let matcher = (2 * n) / 3; // ⌊2n/3⌋
28
1.88k
    (reader.max(1), matcher.max(1))
29
1.88k
}
30
31
// ---------------------------------------------------------------------------
32
// Job type
33
// ---------------------------------------------------------------------------
34
35
type Job = Box<dyn FnOnce() + Send + 'static>;
36
37
// ---------------------------------------------------------------------------
38
// Shared state between the pool handle and workers
39
// ---------------------------------------------------------------------------
40
41
struct SharedState {
42
    queue: Mutex<QueueInner>,
43
    /// Wakes workers when a new job is enqueued or shutdown is requested.
44
    job_available: Condvar,
45
}
46
47
struct QueueInner {
48
    jobs: VecDeque<Job>,
49
    shutdown: bool,
50
}
51
52
// ---------------------------------------------------------------------------
53
// ThreadPool
54
// ---------------------------------------------------------------------------
55
56
/// A simple thread pool backed by a FIFO work queue.
57
///
58
/// Each worker thread blocks on a shared condvar and picks up the next
59
/// available job as soon as it becomes idle.  This means that when a thread
60
/// finishes a job (including any reduce/merge work that was part of that job)
61
/// it immediately checks for the next queued job without any extra
62
/// coordination.
63
pub struct ThreadPool {
64
    shared: Arc<SharedState>,
65
    workers: Vec<thread::JoinHandle<()>>,
66
    num_threads: usize,
67
}
68
69
impl ThreadPool {
70
    /// Creates a new pool with `num_threads` worker threads.
71
    ///
72
    /// # Panics
73
    ///
74
    /// Panics if `num_threads` is 0.
75
    #[must_use]
76
2.36k
    pub fn new(num_threads: usize) -> Self {
77
2.36k
        assert!(num_threads > 0, "ThreadPool requires at least 1 thread");
78
79
2.36k
        let shared = Arc::new(SharedState {
80
2.36k
            queue: Mutex::new(QueueInner {
81
2.36k
                jobs: VecDeque::new(),
82
2.36k
                shutdown: false,
83
2.36k
            }),
84
2.36k
            job_available: Condvar::new(),
85
2.36k
        });
86
87
2.36k
        let mut workers = Vec::with_capacity(num_threads);
88
89
2.36k
        for _ in 0..num_threads {
90
4.74k
            let worker_shared = Arc::clone(&shared);
91
4.74k
            workers.push(thread::spawn(move || worker_loop(&worker_shared)));
92
        }
93
94
2.36k
        Self {
95
2.36k
            shared,
96
2.36k
            workers,
97
2.36k
            num_threads,
98
2.36k
        }
99
2.36k
    }
100
101
    /// Returns the number of worker threads in the pool.
102
    #[inline]
103
    #[must_use]
104
1.53k
    pub fn num_threads(&self) -> usize {
105
1.53k
        self.num_threads
106
1.53k
    }
107
108
    /// Submits a closure to be executed by the next available worker.
109
    ///
110
    /// The lock is dropped *before* notifying the condvar so that the woken
111
    /// worker can acquire it immediately instead of blocking on the notifier.
112
    ///
113
    /// # Panics
114
    ///
115
    /// Panics if the internal job-queue mutex is poisoned.
116
427
    pub fn spawn<F>(&self, f: F)
117
427
    where
118
427
        F: FnOnce() + Send + 'static,
119
    {
120
        {
121
427
            let mut queue = self.shared.queue.lock().unwrap();
122
427
            queue.jobs.push_back(Box::new(f));
123
        } // lock dropped before notify
124
427
        self.shared.job_available.notify_one();
125
427
    }
126
127
    /// Submits multiple closures in a single lock acquisition, then wakes
128
    /// exactly as many workers as there are new jobs (capped at the pool size).
129
    /// This avoids unnecessary wakes when fewer jobs than workers are
130
    /// submitted.
131
    ///
132
    /// # Panics
133
    ///
134
    /// Panics if the internal job-queue mutex is poisoned.
135
704
    pub fn spawn_batch<I>(&self, jobs: I)
136
704
    where
137
704
        I: IntoIterator<Item = Box<dyn FnOnce() + Send + 'static>>,
138
    {
139
        let count = {
140
704
            let mut queue = self.shared.queue.lock().unwrap();
141
704
            let before = queue.jobs.len();
142
704
            for job in jobs {
143
1.42k
                queue.jobs.push_back(job);
144
            }
145
704
            queue.jobs.len() - before
146
        }; // lock dropped before notify
147
704
        if count >= self.num_threads {
  Branch (147:12): [True: 684, False: 0]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/thread_pool.rs
Line
Count
Source
1
//! A lightweight thread pool with a shared work queue.
2
//!
3
//! Worker threads pick up the next available job as soon as they finish their
4
//! current one, giving natural load balancing without work-stealing.
5
6
use std::cell::UnsafeCell;
7
use std::collections::VecDeque;
8
use std::sync::atomic::{AtomicUsize, Ordering};
9
use std::sync::{Arc, Condvar, Mutex};
10
use std::thread;
11
12
// ---------------------------------------------------------------------------
13
// Thread-count partitioning
14
// ---------------------------------------------------------------------------
15
16
/// Splits `n` logical threads between the reader pipeline and the matcher.
17
///
18
/// Returns `(reader, matcher)` where:
19
/// - `reader`  = ⌈n / 3⌉, minimum 1
20
/// - `matcher` = ⌊2n / 3⌋, minimum 1
21
///
22
/// On single-core machines (`n = 1`) both values are 1 so neither subsystem
23
/// starves; the OS time-slices between the two small pools as usual.
24
#[must_use]
25
1.88k
pub fn partition_threads(n: usize) -> (usize, usize) {
26
1.88k
    let reader = n.div_ceil(3); // ⌈n/3⌉
27
1.88k
    let matcher = (2 * n) / 3; // ⌊2n/3⌋
28
1.88k
    (reader.max(1), matcher.max(1))
29
1.88k
}
30
31
// ---------------------------------------------------------------------------
32
// Job type
33
// ---------------------------------------------------------------------------
34
35
type Job = Box<dyn FnOnce() + Send + 'static>;
36
37
// ---------------------------------------------------------------------------
38
// Shared state between the pool handle and workers
39
// ---------------------------------------------------------------------------
40
41
struct SharedState {
42
    queue: Mutex<QueueInner>,
43
    /// Wakes workers when a new job is enqueued or shutdown is requested.
44
    job_available: Condvar,
45
}
46
47
struct QueueInner {
48
    jobs: VecDeque<Job>,
49
    shutdown: bool,
50
}
51
52
// ---------------------------------------------------------------------------
53
// ThreadPool
54
// ---------------------------------------------------------------------------
55
56
/// A simple thread pool backed by a FIFO work queue.
57
///
58
/// Each worker thread blocks on a shared condvar and picks up the next
59
/// available job as soon as it becomes idle.  This means that when a thread
60
/// finishes a job (including any reduce/merge work that was part of that job)
61
/// it immediately checks for the next queued job without any extra
62
/// coordination.
63
pub struct ThreadPool {
64
    shared: Arc<SharedState>,
65
    workers: Vec<thread::JoinHandle<()>>,
66
    num_threads: usize,
67
}
68
69
impl ThreadPool {
70
    /// Creates a new pool with `num_threads` worker threads.
71
    ///
72
    /// # Panics
73
    ///
74
    /// Panics if `num_threads` is 0.
75
    #[must_use]
76
2.36k
    pub fn new(num_threads: usize) -> Self {
77
2.36k
        assert!(num_threads > 0, "ThreadPool requires at least 1 thread");
78
79
2.36k
        let shared = Arc::new(SharedState {
80
2.36k
            queue: Mutex::new(QueueInner {
81
2.36k
                jobs: VecDeque::new(),
82
2.36k
                shutdown: false,
83
2.36k
            }),
84
2.36k
            job_available: Condvar::new(),
85
2.36k
        });
86
87
2.36k
        let mut workers = Vec::with_capacity(num_threads);
88
89
2.36k
        for _ in 0..num_threads {
90
4.73k
            let worker_shared = Arc::clone(&shared);
91
4.73k
            workers.push(thread::spawn(move || worker_loop(&worker_shared)));
92
        }
93
94
2.36k
        Self {
95
2.36k
            shared,
96
2.36k
            workers,
97
2.36k
            num_threads,
98
2.36k
        }
99
2.36k
    }
100
101
    /// Returns the number of worker threads in the pool.
102
    #[inline]
103
    #[must_use]
104
1.53k
    pub fn num_threads(&self) -> usize {
105
1.53k
        self.num_threads
106
1.53k
    }
107
108
    /// Submits a closure to be executed by the next available worker.
109
    ///
110
    /// The lock is dropped *before* notifying the condvar so that the woken
111
    /// worker can acquire it immediately instead of blocking on the notifier.
112
    ///
113
    /// # Panics
114
    ///
115
    /// Panics if the internal job-queue mutex is poisoned.
116
426
    pub fn spawn<F>(&self, f: F)
117
426
    where
118
426
        F: FnOnce() + Send + 'static,
119
    {
120
        {
121
426
            let mut queue = self.shared.queue.lock().unwrap();
122
426
            queue.jobs.push_back(Box::new(f));
123
        } // lock dropped before notify
124
426
        self.shared.job_available.notify_one();
125
426
    }
126
127
    /// Submits multiple closures in a single lock acquisition, then wakes
128
    /// exactly as many workers as there are new jobs (capped at the pool size).
129
    /// This avoids unnecessary wakes when fewer jobs than workers are
130
    /// submitted.
131
    ///
132
    /// # Panics
133
    ///
134
    /// Panics if the internal job-queue mutex is poisoned.
135
707
    pub fn spawn_batch<I>(&self, jobs: I)
136
707
    where
137
707
        I: IntoIterator<Item = Box<dyn FnOnce() + Send + 'static>>,
138
    {
139
        let count = {
140
707
            let mut queue = self.shared.queue.lock().unwrap();
141
707
            let before = queue.jobs.len();
142
707
            for job in jobs {
143
1.43k
                queue.jobs.push_back(job);
144
            }
145
707
            queue.jobs.len() - before
146
        }; // lock dropped before notify
147
707
        if count >= self.num_threads {
  Branch (147:12): [True: 687, False: 0]
 
  Branch (147:12): [True: 20, False: 0]
-
148
704
            self.shared.job_available.notify_all();
149
        } else {
150
0
            for _ in 0..count {
151
0
                self.shared.job_available.notify_one();
152
            }
153
        }
154
704
    }
155
}
156
157
impl Drop for ThreadPool {
158
2.36k
    fn drop(&mut self) {
159
        // Signal shutdown.
160
        {
161
2.36k
            let mut queue = self.shared.queue.lock().unwrap();
162
2.36k
            queue.shutdown = true;
163
        }
164
2.36k
        self.shared.job_available.notify_all();
165
166
        // Join all workers (ignore panics from individual threads).
167
2.36k
        for handle in self.workers.drain(..) {
168
4.73k
            let _ = handle.join();
169
        }
170
2.36k
    }
171
}
172
173
// ---------------------------------------------------------------------------
174
// Worker loop
175
// ---------------------------------------------------------------------------
176
177
4.74k
fn worker_loop(shared: &SharedState) {
178
    loop {
179
        let next_job = {
180
6.59k
            let mut queue = shared.queue.lock().unwrap();
181
            loop {
182
12.4k
                if let Some(ready) = queue.jobs.pop_front() {
  Branch (182:24): [True: 1.77k, False: 8.34k]
-
  Branch (182:24): [True: 76, False: 2.26k]
-
183
1.85k
                    break Some(ready);
184
10.6k
                }
185
10.6k
                if queue.shutdown {
  Branch (185:20): [True: 3.39k, False: 4.95k]
-
  Branch (185:20): [True: 1.34k, False: 915]
-
186
4.74k
                    break None;
187
5.86k
                }
188
5.86k
                queue = shared.job_available.wait(queue).unwrap();
189
            }
190
        };
191
192
6.59k
        match next_job {
193
1.85k
            Some(runnable) => runnable(),
194
4.74k
            None => return, // shutdown
195
        }
196
    }
197
4.74k
}
198
199
// ---------------------------------------------------------------------------
200
// Parallel work-queue helpers used by the matcher
201
// ---------------------------------------------------------------------------
202
// Cache-line–aligned result slot
203
// ---------------------------------------------------------------------------
204
205
/// A single worker's result slot, padded to a full cache line so that
206
/// concurrent writes to adjacent slots by different cores don't cause
207
/// false sharing.
208
///
209
/// Alignment is platform-dependant, this is taken from <https://docs.rs/crossbeam-utils/0.8.21/src/crossbeam_utils/cache_padded.rs.html>
210
///
211
// Starting from Intel's Sandy Bridge, spatial prefetcher is now pulling pairs of 64-byte cache
212
// lines at a time, so we have to align to 128 bytes rather than 64.
213
#[cfg_attr(
214
    any(
215
        target_arch = "x86_64",
216
        target_arch = "aarch64",
217
        target_arch = "arm64ec",
218
        target_arch = "powerpc64",
219
    ),
220
    repr(align(128))
221
)]
222
// arm, mips, mips64, sparc, and hexagon have 32-byte cache line size.
223
#[cfg_attr(
224
    any(
225
        target_arch = "arm",
226
        target_arch = "mips",
227
        target_arch = "mips32r6",
228
        target_arch = "mips64",
229
        target_arch = "mips64r6",
230
        target_arch = "sparc",
231
        target_arch = "hexagon",
232
    ),
233
    repr(align(32))
234
)]
235
// m68k has 16-byte cache line size.
236
#[cfg_attr(target_arch = "m68k", repr(align(16)))]
237
// s390x has 256-byte cache line size.
238
#[cfg_attr(target_arch = "s390x", repr(align(256)))]
239
// x86, wasm, riscv, and sparc64 have 64-byte cache line size.
240
// All others are assumed to have 64-byte cache line size.
241
#[cfg_attr(
242
    not(any(
243
        target_arch = "x86_64",
244
        target_arch = "aarch64",
245
        target_arch = "arm64ec",
246
        target_arch = "powerpc64",
247
        target_arch = "arm",
248
        target_arch = "mips",
249
        target_arch = "mips32r6",
250
        target_arch = "mips64",
251
        target_arch = "mips64r6",
252
        target_arch = "sparc",
253
        target_arch = "hexagon",
254
        target_arch = "m68k",
255
        target_arch = "s390x",
256
    )),
257
    repr(align(64))
258
)]
259
struct Slot<R> {
260
    value: UnsafeCell<Option<R>>,
261
}
262
263
// SAFETY: each slot is written by exactly one worker (unique `worker_id`)
264
// and read by the coordinator only after the barrier guarantees all writes
265
// are visible.  No two threads ever access the same slot concurrently.
266
unsafe impl<R: Send> Send for Slot<R> {}
267
unsafe impl<R: Send> Sync for Slot<R> {}
268
269
impl<R> Slot<R> {
270
1.41k
    fn new() -> Self {
271
1.41k
        Self {
272
1.41k
            value: UnsafeCell::new(None),
273
1.41k
        }
274
1.41k
    }
275
}
276
277
// ---------------------------------------------------------------------------
278
279
/// Processes `items` in parallel across `num_workers` threads from the given
280
/// pool, then hands the results to `merge`.
281
///
282
/// The work is split into chunks of `chunk_size`.  Each worker thread
283
/// repeatedly grabs the next available chunk (via an atomic counter), runs
284
/// `process_chunk` on it, and folds the partial result into a *local*
285
/// accumulator using `reduce`.  When all chunks are consumed, each worker
286
/// calls `prepare` on its local accumulator (e.g. to sort it) — this step
287
/// runs **in parallel** across all workers — and then writes the prepared
288
/// result into its slot. The coordinator collects every worker's result and
289
/// passes them all to `merge` in a single call. When `preserve_chunk_order` is
290
/// set, reduction and preparation are skipped and each chunk result is stored
291
/// directly in its chunk-indexed slot.
292
///
293
/// Because each worker picks up the *next* chunk as soon as it finishes the
294
/// previous one, faster threads naturally do more work without any explicit
295
/// work-stealing.
296
///
297
/// # Parameters
298
///
299
/// * `pool`           – thread pool whose workers will execute the chunks.
300
/// * `num_workers`    – how many workers to dispatch (capped to pool size internally by caller).
301
/// * `items`          – the data to process; shared read-only across workers via `Arc`.
302
/// * `chunk_size`     – number of items per chunk.
303
/// * `preserve_chunk_order` – store results by chunk index and skip reduction/preparation.
304
/// * `identity`       – the identity/seed value for per-worker local accumulators (called once per worker).
305
/// * `process_chunk`  – `(chunk_start_index, &[T]) -> R` – processes one chunk.
306
/// * `reduce`         – folds a per-chunk result into a worker-local accumulator (`&mut acc, partial`).
307
/// * `prepare`        – called on each worker's finished accumulator **on the worker thread** (runs in parallel).  Use this for expensive per-worker work like sorting.
308
/// * `merge`          – called once on the coordinator with all per-worker results.
309
#[allow(clippy::too_many_arguments)]
310
1.09k
pub fn parallel_work_queue<S, T, R, P, M, I, W, G>(
311
1.09k
    pool: &ThreadPool,
312
1.09k
    num_workers: usize,
313
1.09k
    items: &Arc<S>,
314
1.09k
    chunk_size: usize,
315
1.09k
    preserve_chunk_order: bool,
316
1.09k
    identity: I,
317
1.09k
    process_chunk: P,
318
1.09k
    reduce: M,
319
1.09k
    prepare: W,
320
1.09k
    merge: G,
321
1.09k
) where
322
1.09k
    S: AsRef<[T]> + Send + Sync + ?Sized + 'static,
323
1.09k
    T: Send + Sync + 'static,
324
1.09k
    R: Send + 'static,
325
1.09k
    P: Fn(usize, &[T]) -> R + Send + Sync + 'static,
326
1.09k
    M: Fn(&mut R, R) + Send + Sync + 'static,
327
1.09k
    I: Fn() -> R + Send + Sync + 'static,
328
1.09k
    W: Fn(&mut R) + Send + Sync + 'static,
329
1.09k
    G: FnOnce(Vec<R>),
330
{
331
1.09k
    let items_slice: &[T] = AsRef::<[T]>::as_ref(&**items);
332
1.09k
    let total = items_slice.len();
333
1.09k
    if total == 0 {
  Branch (333:8): [True: 373, False: 684]
-
  Branch (333:8): [True: 16, False: 14]
+
148
707
            self.shared.job_available.notify_all();
149
        } else {
150
0
            for _ in 0..count {
151
0
                self.shared.job_available.notify_one();
152
            }
153
        }
154
707
    }
155
}
156
157
impl Drop for ThreadPool {
158
2.36k
    fn drop(&mut self) {
159
        // Signal shutdown.
160
        {
161
2.36k
            let mut queue = self.shared.queue.lock().unwrap();
162
2.36k
            queue.shutdown = true;
163
        }
164
2.36k
        self.shared.job_available.notify_all();
165
166
        // Join all workers (ignore panics from individual threads).
167
2.36k
        for handle in self.workers.drain(..) {
168
4.73k
            let _ = handle.join();
169
        }
170
2.36k
    }
171
}
172
173
// ---------------------------------------------------------------------------
174
// Worker loop
175
// ---------------------------------------------------------------------------
176
177
4.73k
fn worker_loop(shared: &SharedState) {
178
    loop {
179
        let next_job = {
180
6.58k
            let mut queue = shared.queue.lock().unwrap();
181
            loop {
182
12.4k
                if let Some(ready) = queue.jobs.pop_front() {
  Branch (182:24): [True: 1.78k, False: 8.35k]
+
  Branch (182:24): [True: 76, False: 2.27k]
+
183
1.85k
                    break Some(ready);
184
10.6k
                }
185
10.6k
                if queue.shutdown {
  Branch (185:20): [True: 3.38k, False: 4.96k]
+
  Branch (185:20): [True: 1.34k, False: 933]
+
186
4.73k
                    break None;
187
5.89k
                }
188
5.89k
                queue = shared.job_available.wait(queue).unwrap();
189
            }
190
        };
191
192
6.58k
        match next_job {
193
1.85k
            Some(runnable) => runnable(),
194
4.73k
            None => return, // shutdown
195
        }
196
    }
197
4.73k
}
198
199
// ---------------------------------------------------------------------------
200
// Parallel work-queue helpers used by the matcher
201
// ---------------------------------------------------------------------------
202
// Cache-line–aligned result slot
203
// ---------------------------------------------------------------------------
204
205
/// A single worker's result slot, padded to a full cache line so that
206
/// concurrent writes to adjacent slots by different cores don't cause
207
/// false sharing.
208
///
209
/// Alignment is platform-dependant, this is taken from <https://docs.rs/crossbeam-utils/0.8.21/src/crossbeam_utils/cache_padded.rs.html>
210
///
211
// Starting from Intel's Sandy Bridge, spatial prefetcher is now pulling pairs of 64-byte cache
212
// lines at a time, so we have to align to 128 bytes rather than 64.
213
#[cfg_attr(
214
    any(
215
        target_arch = "x86_64",
216
        target_arch = "aarch64",
217
        target_arch = "arm64ec",
218
        target_arch = "powerpc64",
219
    ),
220
    repr(align(128))
221
)]
222
// arm, mips, mips64, sparc, and hexagon have 32-byte cache line size.
223
#[cfg_attr(
224
    any(
225
        target_arch = "arm",
226
        target_arch = "mips",
227
        target_arch = "mips32r6",
228
        target_arch = "mips64",
229
        target_arch = "mips64r6",
230
        target_arch = "sparc",
231
        target_arch = "hexagon",
232
    ),
233
    repr(align(32))
234
)]
235
// m68k has 16-byte cache line size.
236
#[cfg_attr(target_arch = "m68k", repr(align(16)))]
237
// s390x has 256-byte cache line size.
238
#[cfg_attr(target_arch = "s390x", repr(align(256)))]
239
// x86, wasm, riscv, and sparc64 have 64-byte cache line size.
240
// All others are assumed to have 64-byte cache line size.
241
#[cfg_attr(
242
    not(any(
243
        target_arch = "x86_64",
244
        target_arch = "aarch64",
245
        target_arch = "arm64ec",
246
        target_arch = "powerpc64",
247
        target_arch = "arm",
248
        target_arch = "mips",
249
        target_arch = "mips32r6",
250
        target_arch = "mips64",
251
        target_arch = "mips64r6",
252
        target_arch = "sparc",
253
        target_arch = "hexagon",
254
        target_arch = "m68k",
255
        target_arch = "s390x",
256
    )),
257
    repr(align(64))
258
)]
259
struct Slot<R> {
260
    value: UnsafeCell<Option<R>>,
261
}
262
263
// SAFETY: each slot is written by exactly one worker (unique `worker_id`)
264
// and read by the coordinator only after the barrier guarantees all writes
265
// are visible.  No two threads ever access the same slot concurrently.
266
unsafe impl<R: Send> Send for Slot<R> {}
267
unsafe impl<R: Send> Sync for Slot<R> {}
268
269
impl<R> Slot<R> {
270
1.42k
    fn new() -> Self {
271
1.42k
        Self {
272
1.42k
            value: UnsafeCell::new(None),
273
1.42k
        }
274
1.42k
    }
275
}
276
277
// ---------------------------------------------------------------------------
278
279
/// Processes `items` in parallel across `num_workers` threads from the given
280
/// pool, then hands the results to `merge`.
281
///
282
/// The work is split into chunks of `chunk_size`.  Each worker thread
283
/// repeatedly grabs the next available chunk (via an atomic counter), runs
284
/// `process_chunk` on it, and folds the partial result into a *local*
285
/// accumulator using `reduce`.  When all chunks are consumed, each worker
286
/// calls `prepare` on its local accumulator (e.g. to sort it) — this step
287
/// runs **in parallel** across all workers — and then writes the prepared
288
/// result into its slot. The coordinator collects every worker's result and
289
/// passes them all to `merge` in a single call. When `preserve_chunk_order` is
290
/// set, reduction and preparation are skipped and each chunk result is stored
291
/// directly in its chunk-indexed slot.
292
///
293
/// Because each worker picks up the *next* chunk as soon as it finishes the
294
/// previous one, faster threads naturally do more work without any explicit
295
/// work-stealing.
296
///
297
/// # Parameters
298
///
299
/// * `pool`           – thread pool whose workers will execute the chunks.
300
/// * `num_workers`    – how many workers to dispatch (capped to pool size internally by caller).
301
/// * `items`          – the data to process; shared read-only across workers via `Arc`.
302
/// * `chunk_size`     – number of items per chunk.
303
/// * `preserve_chunk_order` – store results by chunk index and skip reduction/preparation.
304
/// * `identity`       – the identity/seed value for per-worker local accumulators (called once per worker).
305
/// * `process_chunk`  – `(chunk_start_index, &[T]) -> R` – processes one chunk.
306
/// * `reduce`         – folds a per-chunk result into a worker-local accumulator (`&mut acc, partial`).
307
/// * `prepare`        – called on each worker's finished accumulator **on the worker thread** (runs in parallel).  Use this for expensive per-worker work like sorting.
308
/// * `merge`          – called once on the coordinator with all per-worker results.
309
#[allow(clippy::too_many_arguments)]
310
1.09k
pub fn parallel_work_queue<S, T, R, P, M, I, W, G>(
311
1.09k
    pool: &ThreadPool,
312
1.09k
    num_workers: usize,
313
1.09k
    items: &Arc<S>,
314
1.09k
    chunk_size: usize,
315
1.09k
    preserve_chunk_order: bool,
316
1.09k
    identity: I,
317
1.09k
    process_chunk: P,
318
1.09k
    reduce: M,
319
1.09k
    prepare: W,
320
1.09k
    merge: G,
321
1.09k
) where
322
1.09k
    S: AsRef<[T]> + Send + Sync + ?Sized + 'static,
323
1.09k
    T: Send + Sync + 'static,
324
1.09k
    R: Send + 'static,
325
1.09k
    P: Fn(usize, &[T]) -> R + Send + Sync + 'static,
326
1.09k
    M: Fn(&mut R, R) + Send + Sync + 'static,
327
1.09k
    I: Fn() -> R + Send + Sync + 'static,
328
1.09k
    W: Fn(&mut R) + Send + Sync + 'static,
329
1.09k
    G: FnOnce(Vec<R>),
330
{
331
1.09k
    let items_slice: &[T] = AsRef::<[T]>::as_ref(&**items);
332
1.09k
    let total = items_slice.len();
333
1.09k
    if total == 0 {
  Branch (333:8): [True: 367, False: 687]
+
  Branch (333:8): [True: 19, False: 14]
 
  Branch (333:8): [True: 0, False: 1]
 
  Branch (333:8): [True: 0, False: 1]
 
  Branch (333:8): [True: 1, False: 0]
 
  Branch (333:8): [True: 0, False: 1]
 
  Branch (333:8): [True: 0, False: 1]
 
  Branch (333:8): [True: 0, False: 1]
-
334
390
        merge(Vec::new());
335
390
        return;
336
703
    }
337
338
703
    let num_chunks = total.div_ceil(chunk_size);
339
340
    // Shared atomic counter – workers fetch-add to grab the next chunk index.
341
703
    let next_chunk = Arc::new(AtomicUsize::new(0));
342
343
    // Ordered mode uses one slot per chunk; otherwise each worker writes its
344
    // reduced result to its own slot. The coordinator reads only after the barrier.
345
703
    let num_slots = if preserve_chunk_order { 
num_chunks7
} else {
num_workers696
};
  Branch (345:24): [True: 6, False: 678]
+
334
387
        merge(Vec::new());
335
387
        return;
336
706
    }
337
338
706
    let num_chunks = total.div_ceil(chunk_size);
339
340
    // Shared atomic counter – workers fetch-add to grab the next chunk index.
341
706
    let next_chunk = Arc::new(AtomicUsize::new(0));
342
343
    // Ordered mode uses one slot per chunk; otherwise each worker writes its
344
    // reduced result to its own slot. The coordinator reads only after the barrier.
345
706
    let num_slots = if preserve_chunk_order { 
num_chunks7
} else {
num_workers699
};
  Branch (345:24): [True: 6, False: 681]
 
  Branch (345:24): [True: 0, False: 14]
 
  Branch (345:24): [True: 1, False: 0]
 
  Branch (345:24): [True: 0, False: 1]
@@ -20,7 +20,7 @@
 
  Branch (345:24): [True: 0, False: 1]
 
  Branch (345:24): [True: 0, False: 1]
 
  Branch (345:24): [True: 0, False: 1]
-
346
1.41k
    let slots: Arc<Vec<Slot<R>>> = 
Arc::new703
((
0..num_slots703
).map(|_| Slot::new()).collect());
347
348
    // Barrier: we wait until all workers have finished.
349
703
    let remaining = Arc::new(AtomicCounter::new(num_workers));
350
    // Register the coordinator thread so workers can unpark it.
351
703
    remaining.set_waiter();
352
353
703
    let process_chunk = Arc::new(process_chunk);
354
703
    let reduce = Arc::new(reduce);
355
703
    let prepare = Arc::new(prepare);
356
703
    let identity = Arc::new(identity);
357
358
    // Build all jobs up-front and submit them in a single batch to minimise
359
    // lock acquisitions on the work queue.
360
703
    let jobs: Vec<Box<dyn FnOnce() + Send + 'static>> = (0..num_workers)
361
1.41k
        .map(|worker_id| {
362
1.41k
            let w_items = Arc::clone(items);
363
1.41k
            let w_next_chunk = Arc::clone(&next_chunk);
364
1.41k
            let w_slots: Arc<Vec<Slot<R>>> = Arc::clone(&slots);
365
1.41k
            let w_remaining = Arc::clone(&remaining);
366
1.41k
            let w_process_chunk = Arc::clone(&process_chunk);
367
1.41k
            let w_reduce = Arc::clone(&reduce);
368
1.41k
            let w_prepare = Arc::clone(&prepare);
369
1.41k
            let w_identity = Arc::clone(&identity);
370
371
1.41k
            let job: Box<dyn FnOnce() + Send + 'static> = Box::new(move || {
372
                // Scope all Arc-holding work so clones are dropped before we
373
                // signal completion.  This lets the coordinator safely unwrap
374
                // the outer Arcs.
375
1.41k
                let local_acc = if preserve_chunk_order {
  Branch (375:36): [True: 12, False: 1.35k]
+
346
1.42k
    let slots: Arc<Vec<Slot<R>>> = 
Arc::new706
((
0..num_slots706
).map(|_| Slot::new()).collect());
347
348
    // Barrier: we wait until all workers have finished.
349
706
    let remaining = Arc::new(AtomicCounter::new(num_workers));
350
    // Register the coordinator thread so workers can unpark it.
351
706
    remaining.set_waiter();
352
353
706
    let process_chunk = Arc::new(process_chunk);
354
706
    let reduce = Arc::new(reduce);
355
706
    let prepare = Arc::new(prepare);
356
706
    let identity = Arc::new(identity);
357
358
    // Build all jobs up-front and submit them in a single batch to minimise
359
    // lock acquisitions on the work queue.
360
706
    let jobs: Vec<Box<dyn FnOnce() + Send + 'static>> = (0..num_workers)
361
1.42k
        .map(|worker_id| {
362
1.42k
            let w_items = Arc::clone(items);
363
1.42k
            let w_next_chunk = Arc::clone(&next_chunk);
364
1.42k
            let w_slots: Arc<Vec<Slot<R>>> = Arc::clone(&slots);
365
1.42k
            let w_remaining = Arc::clone(&remaining);
366
1.42k
            let w_process_chunk = Arc::clone(&process_chunk);
367
1.42k
            let w_reduce = Arc::clone(&reduce);
368
1.42k
            let w_prepare = Arc::clone(&prepare);
369
1.42k
            let w_identity = Arc::clone(&identity);
370
371
1.42k
            let job: Box<dyn FnOnce() + Send + 'static> = Box::new(move || {
372
                // Scope all Arc-holding work so clones are dropped before we
373
                // signal completion.  This lets the coordinator safely unwrap
374
                // the outer Arcs.
375
1.42k
                let local_acc = if preserve_chunk_order {
  Branch (375:36): [True: 12, False: 1.36k]
 
  Branch (375:36): [True: 0, False: 28]
 
  Branch (375:36): [True: 4, False: 0]
 
  Branch (375:36): [True: 0, False: 1]
@@ -36,7 +36,7 @@
 
  Branch (378:28): [True: 0, False: 0]
 
  Branch (378:28): [True: 0, False: 0]
 
  Branch (378:28): [True: 0, False: 0]
-
379
16
                            break;
380
21
                        }
381
382
21
                        let start = chunk_idx * chunk_size;
383
21
                        let end = total.min(start + chunk_size);
384
21
                        let slice: &[T] = AsRef::<[T]>::as_ref(&*w_items);
385
21
                        let partial = w_process_chunk(start, &slice[start..end]);
386
387
                        // SAFETY: every chunk index is handed out once, so each
388
                        // slot has one writer. The coordinator reads after the barrier.
389
21
                        unsafe { *w_slots[chunk_idx].value.get() = Some(partial) };
390
                    }
391
16
                    None
392
                } else {
393
1.39k
                    let mut local_acc = w_identity();
394
                    loop {
395
2.12k
                        let chunk_idx = w_next_chunk.fetch_add(1, Ordering::Relaxed);
396
2.12k
                        if chunk_idx >= num_chunks {
  Branch (396:28): [True: 1.35k, False: 678]
+
379
16
                            break;
380
21
                        }
381
382
21
                        let start = chunk_idx * chunk_size;
383
21
                        let end = total.min(start + chunk_size);
384
21
                        let slice: &[T] = AsRef::<[T]>::as_ref(&*w_items);
385
21
                        let partial = w_process_chunk(start, &slice[start..end]);
386
387
                        // SAFETY: every chunk index is handed out once, so each
388
                        // slot has one writer. The coordinator reads after the barrier.
389
21
                        unsafe { *w_slots[chunk_idx].value.get() = Some(partial) };
390
                    }
391
16
                    None
392
                } else {
393
1.40k
                    let mut local_acc = w_identity();
394
                    loop {
395
2.13k
                        let chunk_idx = w_next_chunk.fetch_add(1, Ordering::Relaxed);
396
2.13k
                        if chunk_idx >= num_chunks {
  Branch (396:28): [True: 1.36k, False: 681]
 
  Branch (396:28): [True: 28, False: 14]
 
  Branch (396:28): [True: 0, False: 0]
 
  Branch (396:28): [True: 1, False: 10]
@@ -44,7 +44,7 @@
 
  Branch (396:28): [True: 1, False: 10]
 
  Branch (396:28): [True: 4, False: 16]
 
  Branch (396:28): [True: 8, False: 2]
-
397
1.39k
                            break;
398
730
                        }
399
400
730
                        let start = chunk_idx * chunk_size;
401
730
                        let end = total.min(start + chunk_size);
402
730
                        let slice: &[T] = AsRef::<[T]>::as_ref(&*w_items);
403
730
                        let partial = w_process_chunk(start, &slice[start..end]);
404
730
                        w_reduce(&mut local_acc, partial);
405
                    }
406
407
                    // Run prepare (e.g. sort) while still on the worker thread
408
                    // so that this work happens in parallel across workers.
409
1.39k
                    w_prepare(&mut local_acc);
410
1.39k
                    Some(local_acc)
411
                };
412
413
1.41k
                if let Some(local_acc) = local_acc {
  Branch (413:24): [True: 1.35k, False: 12]
+
397
1.40k
                            break;
398
733
                        }
399
400
733
                        let start = chunk_idx * chunk_size;
401
733
                        let end = total.min(start + chunk_size);
402
733
                        let slice: &[T] = AsRef::<[T]>::as_ref(&*w_items);
403
733
                        let partial = w_process_chunk(start, &slice[start..end]);
404
733
                        w_reduce(&mut local_acc, partial);
405
                    }
406
407
                    // Run prepare (e.g. sort) while still on the worker thread
408
                    // so that this work happens in parallel across workers.
409
1.40k
                    w_prepare(&mut local_acc);
410
1.40k
                    Some(local_acc)
411
                };
412
413
1.42k
                if let Some(local_acc) = local_acc {
  Branch (413:24): [True: 1.36k, False: 12]
 
  Branch (413:24): [True: 28, False: 0]
 
  Branch (413:24): [True: 0, False: 4]
 
  Branch (413:24): [True: 1, False: 0]
@@ -52,7 +52,7 @@
 
  Branch (413:24): [True: 1, False: 0]
 
  Branch (413:24): [True: 4, False: 0]
 
  Branch (413:24): [True: 8, False: 0]
-
414
                    // SAFETY: each worker_id is unique; no other thread writes to
415
                    // this slot, and the coordinator reads only after the barrier.
416
1.39k
                    unsafe { *w_slots[worker_id].value.get() = Some(local_acc) };
417
16
                }
418
419
                // Drop the slots Arc *before* signalling completion.
420
                // The coordinator calls Arc::into_inner(slots) after wait_for_zero
421
                // returns; that requires the strong count to be exactly 1.  Without
422
                // this explicit drop, w_slots would still be alive in the closure
423
                // frame when dec_and_notify wakes the coordinator, causing
424
                // Arc::into_inner to spuriously return None and silently lose results.
425
1.41k
                drop(w_slots);
426
427
                // Signal completion.
428
1.41k
                w_remaining.dec_and_notify();
429
1.41k
            });
430
1.41k
            job
431
1.41k
        })
432
        .collect();
433
434
703
    pool.spawn_batch(jobs);
435
436
    // Block until all workers are done.
437
703
    remaining.wait_for_zero();
438
439
    // Collect worker or chunk results in slot order and hand them to `merge`.
440
    // Workers dropped their `w_slots` Arc clone explicitly before signalling
441
    // completion, so we are the sole owner here.
442
703
    if let Some(slots) = Arc::into_inner(slots) {
  Branch (442:12): [True: 684, False: 0]
+
414
                    // SAFETY: each worker_id is unique; no other thread writes to
415
                    // this slot, and the coordinator reads only after the barrier.
416
1.40k
                    unsafe { *w_slots[worker_id].value.get() = Some(local_acc) };
417
16
                }
418
419
                // Drop the slots Arc *before* signalling completion.
420
                // The coordinator calls Arc::into_inner(slots) after wait_for_zero
421
                // returns; that requires the strong count to be exactly 1.  Without
422
                // this explicit drop, w_slots would still be alive in the closure
423
                // frame when dec_and_notify wakes the coordinator, causing
424
                // Arc::into_inner to spuriously return None and silently lose results.
425
1.42k
                drop(w_slots);
426
427
                // Signal completion.
428
1.42k
                w_remaining.dec_and_notify();
429
1.42k
            });
430
1.42k
            job
431
1.42k
        })
432
        .collect();
433
434
706
    pool.spawn_batch(jobs);
435
436
    // Block until all workers are done.
437
706
    remaining.wait_for_zero();
438
439
    // Collect worker or chunk results in slot order and hand them to `merge`.
440
    // Workers dropped their `w_slots` Arc clone explicitly before signalling
441
    // completion, so we are the sole owner here.
442
706
    if let Some(slots) = Arc::into_inner(slots) {
  Branch (442:12): [True: 687, False: 0]
 
  Branch (442:12): [True: 14, False: 0]
 
  Branch (442:12): [True: 1, False: 0]
 
  Branch (442:12): [True: 1, False: 0]
@@ -60,10 +60,10 @@
 
  Branch (442:12): [True: 1, False: 0]
 
  Branch (442:12): [True: 1, False: 0]
 
  Branch (442:12): [True: 1, False: 0]
-
443
1.41k
        let results: Vec<R> = 
slots.into_iter()703
.filter_map(|slot| slot.value.into_inner()).collect();
444
445
703
        merge(results);
446
    } else {
447
0
        log::error!("More than one ref to the slots remaining after workers exit. This SHOULD NOT happen.");
448
    }
449
1.09k
}
450
451
// ---------------------------------------------------------------------------
452
// AtomicCounter with parking (avoids spinning in the coordinator)
453
// ---------------------------------------------------------------------------
454
455
struct AtomicCounter {
456
    count: AtomicUsize,
457
    /// The thread that called `wait_for_zero`.  Workers unpark it when the
458
    /// count reaches zero.  Set once by `wait_for_zero` before any worker can
459
    /// finish, so plain `Relaxed` loads inside `dec_and_notify` are fine
460
    /// (the `fetch_sub` with `AcqRel` provides the necessary ordering).
461
    waiter: UnsafeCell<Option<thread::Thread>>,
462
}
463
464
// SAFETY: `waiter` is written exactly once (by the coordinator in
465
// `set_waiter`, before any worker can observe it via `dec_and_notify`)
466
// and read by workers only after that write is visible (guaranteed by the
467
// `AcqRel` ordering on the atomic counter operations).
468
unsafe impl Send for AtomicCounter {}
469
unsafe impl Sync for AtomicCounter {}
470
471
impl AtomicCounter {
472
703
    fn new(n: usize) -> Self {
473
703
        Self {
474
703
            count: AtomicUsize::new(n),
475
703
            waiter: UnsafeCell::new(None),
476
703
        }
477
703
    }
478
479
    /// Register the current thread as the waiter.
480
    ///
481
    /// Must be called exactly once, before any worker calls `dec_and_notify`.
482
703
    fn set_waiter(&self) {
483
        // SAFETY: called once by the coordinator before workers start.
484
703
        unsafe { *self.waiter.get() = Some(thread::current()) };
485
703
    }
486
487
    /// Decrements the counter by one and unparks the waiter if it reaches zero.
488
    ///
489
    /// # Panics (debug only)
490
    ///
491
    /// Debug-asserts that the counter has not already reached zero, which
492
    /// would indicate a double-decrement bug.
493
1.41k
    fn dec_and_notify(&self) {
494
1.41k
        let prev = self.count.fetch_sub(1, Ordering::AcqRel);
495
1.41k
        debug_assert!(
prev > 00
, "AtomicCounter decremented below zero — double-decrement bug?");
496
1.41k
        if prev == 1 {
  Branch (496:12): [True: 684, False: 684]
+
443
1.42k
        let results: Vec<R> = 
slots.into_iter()706
.filter_map(|slot| slot.value.into_inner()).collect();
444
445
706
        merge(results);
446
    } else {
447
0
        log::error!("More than one ref to the slots remaining after workers exit. This SHOULD NOT happen.");
448
    }
449
1.09k
}
450
451
// ---------------------------------------------------------------------------
452
// AtomicCounter with parking (avoids spinning in the coordinator)
453
// ---------------------------------------------------------------------------
454
455
struct AtomicCounter {
456
    count: AtomicUsize,
457
    /// The thread that called `wait_for_zero`.  Workers unpark it when the
458
    /// count reaches zero.  Set once by `wait_for_zero` before any worker can
459
    /// finish, so plain `Relaxed` loads inside `dec_and_notify` are fine
460
    /// (the `fetch_sub` with `AcqRel` provides the necessary ordering).
461
    waiter: UnsafeCell<Option<thread::Thread>>,
462
}
463
464
// SAFETY: `waiter` is written exactly once (by the coordinator in
465
// `set_waiter`, before any worker can observe it via `dec_and_notify`)
466
// and read by workers only after that write is visible (guaranteed by the
467
// `AcqRel` ordering on the atomic counter operations).
468
unsafe impl Send for AtomicCounter {}
469
unsafe impl Sync for AtomicCounter {}
470
471
impl AtomicCounter {
472
706
    fn new(n: usize) -> Self {
473
706
        Self {
474
706
            count: AtomicUsize::new(n),
475
706
            waiter: UnsafeCell::new(None),
476
706
        }
477
706
    }
478
479
    /// Register the current thread as the waiter.
480
    ///
481
    /// Must be called exactly once, before any worker calls `dec_and_notify`.
482
706
    fn set_waiter(&self) {
483
        // SAFETY: called once by the coordinator before workers start.
484
706
        unsafe { *self.waiter.get() = Some(thread::current()) };
485
706
    }
486
487
    /// Decrements the counter by one and unparks the waiter if it reaches zero.
488
    ///
489
    /// # Panics (debug only)
490
    ///
491
    /// Debug-asserts that the counter has not already reached zero, which
492
    /// would indicate a double-decrement bug.
493
1.42k
    fn dec_and_notify(&self) {
494
1.42k
        let prev = self.count.fetch_sub(1, Ordering::AcqRel);
495
1.42k
        debug_assert!(
prev > 00
, "AtomicCounter decremented below zero — double-decrement bug?");
496
1.42k
        if prev == 1 {
  Branch (496:12): [True: 687, False: 687]
 
  Branch (496:12): [True: 19, False: 27]
-
497
            // We just decremented from 1 → 0.
498
            // SAFETY: waiter was set before workers were dispatched.
499
703
            if let Some(t) = unsafe { &*self.waiter.get() } {
  Branch (499:20): [True: 684, False: 0]
+
497
            // We just decremented from 1 → 0.
498
            // SAFETY: waiter was set before workers were dispatched.
499
706
            if let Some(t) = unsafe { &*self.waiter.get() } {
  Branch (499:20): [True: 687, False: 0]
 
  Branch (499:20): [True: 19, False: 0]
-
500
703
                t.unpark();
501
0
            }
502
711
        }
503
1.41k
    }
504
505
    /// Blocks until the counter reaches zero.
506
703
    fn wait_for_zero(&self) {
507
1.30k
        while self.count.load(Ordering::Acquire) > 0 {
  Branch (507:15): [True: 587, False: 684]
-
  Branch (507:15): [True: 15, False: 19]
-
508
602
            thread::park();
509
        }
510
703
    }
511
}
512
513
#[cfg(test)]
514
#[path = "thread_pool_tests.rs"]
515
mod tests;
\ No newline at end of file +
500
706
                t.unpark();
501
0
            }
502
714
        }
503
1.42k
    }
504
505
    /// Blocks until the counter reaches zero.
506
706
    fn wait_for_zero(&self) {
507
1.32k
        while self.count.load(Ordering::Acquire) > 0 {
  Branch (507:15): [True: 606, False: 687]
+
  Branch (507:15): [True: 16, False: 19]
+
508
622
            thread::park();
509
        }
510
706
    }
511
}
512
513
#[cfg(test)]
514
#[path = "thread_pool_tests.rs"]
515
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/actions.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/actions.rs.html index 01ae6253..349e8662 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/tui/actions.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/actions.rs.html @@ -1,3 +1,3 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/actions.rs
Line
Count
Source
1
//! Action definitions, the action catalog, and action parsing.
2
//!
3
//! The [`Action`] enum, its canonical names and its parser are all generated
4
//! from a single source of truth: the [`define_action_catalog`] invocation at
5
//! the bottom of this module. Each entry declares the variant (with its doc
6
//! comment and payload types), the kebab-case name accepted by `--bind`, and
7
//! the expression that builds the variant from an optional argument.
8
9
use std::future::Future;
10
use std::pin::Pin;
11
use std::sync::{Arc, Mutex};
12
13
use derive_more::{Debug, Eq, PartialEq};
14
15
use super::event::Event;
16
17
type BoxError = Box<dyn std::error::Error + Sync + Send>;
18
type BoxFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<Event>, BoxError>> + Send + 'a>>;
19
20
/// Trait object stored inside [`ActionCallback`].
21
///
22
/// Having an explicit trait (rather than a bare `dyn Fn` type alias) allows
23
/// Rust to correctly resolve the higher-ranked lifetime in the return type.
24
trait AsyncCallbackFn: Send {
25
    fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a>;
26
}
27
28
/// Adapter that stores a concrete async closure and implements [`AsyncCallbackFn`].
29
struct AsyncFnWrapper<F>(F);
30
31
impl<F, Fut> AsyncCallbackFn for AsyncFnWrapper<F>
32
where
33
    F: for<'a> Fn(&'a mut crate::tui::App) -> Fut + Send,
34
    Fut: Future<Output = Result<Vec<Event>, BoxError>> + Send + 'static,
35
{
36
1
    fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a> {
37
1
        Box::pin((self.0)(app))
38
1
    }
39
}
40
41
/// Adapter that stores a plain synchronous closure and implements [`AsyncCallbackFn`].
42
struct SyncFnWrapper<F>(F);
43
44
impl<F> AsyncCallbackFn for SyncFnWrapper<F>
45
where
46
    F: Fn(&mut crate::tui::App) -> Result<Vec<Event>, BoxError> + Send,
47
{
48
1
    fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a> {
49
1
        Box::pin(std::future::ready((self.0)(app)))
50
1
    }
51
}
52
53
/// A custom action callback that receives a mutable reference to the App.
54
///
55
/// The closure will be called with a mutable reference to App and should return
56
/// a vec of events that will be processed after the callback completes.
57
///
58
/// Both sync and async closures are supported:
59
/// - Use [`ActionCallback::new`] to wrap an **async** closure or block.
60
/// - Use [`ActionCallback::new_sync`] to wrap a plain synchronous closure.
61
#[derive(Clone)]
62
pub struct ActionCallback(Arc<Mutex<dyn AsyncCallbackFn>>);
63
64
impl std::fmt::Debug for ActionCallback {
65
2
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66
2
        f.debug_struct("ActionCallback").finish()
67
2
    }
68
}
69
70
impl ActionCallback {
71
    /// Create a new action callback from an **async** closure or block.
72
    ///
73
    /// ```rust,ignore
74
    /// ActionCallback::new(|app| async move {
75
    ///     // async work here …
76
    ///     Ok(vec![])
77
    /// });
78
    /// ```
79
2
    pub fn new<F, Fut>(f: F) -> Self
80
2
    where
81
2
        F: for<'a> Fn(&'a mut crate::tui::App) -> Fut + Send + 'static,
82
2
        Fut: Future<Output = Result<Vec<Event>, BoxError>> + Send + 'static,
83
    {
84
2
        Self(Arc::new(Mutex::new(AsyncFnWrapper(f))))
85
2
    }
86
87
    /// Create a new action callback from a plain **synchronous** closure.
88
    ///
89
    /// This is a convenience wrapper; the closure is lifted into an immediately-
90
    /// resolving future so it integrates with the same async call site.
91
    ///
92
    /// ```rust,ignore
93
    /// ActionCallback::new_sync(|app| {
94
    ///     Ok(vec![Event::Action(Action::SelectAll)])
95
    /// });
96
    /// ```
97
2
    pub fn new_sync<F>(f: F) -> Self
98
2
    where
99
2
        F: Fn(&mut crate::tui::App) -> Result<Vec<Event>, BoxError> + Send + 'static,
100
    {
101
2
        Self(Arc::new(Mutex::new(SyncFnWrapper(f))))
102
2
    }
103
104
    /// Call the callback with an App reference, driving the returned future to completion.
105
    ///
106
    /// Must be called from within a Tokio multi-thread runtime context.
107
2
    pub(crate) fn call(&self, app: &mut crate::tui::App) -> Result<Vec<Event>, BoxError> {
108
2
        let callback = self.0.lock().unwrap();
109
2
        let fut = callback.call(app);
110
        // We are inside a synchronous call stack that originates from an async
111
        // tokio context.  `block_in_place` moves the current thread out of the
112
        // async worker pool temporarily so we can block on the future without
113
        // starving the runtime.
114
2
        tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
115
2
    }
116
}
117
118
70
fn parse_conditional(arg: Option<String>, constructor: fn(String, Option<String>) -> Action) -> Option<Action> {
119
70
    let arg = arg
?30
;
120
40
    let (then, otherwise) = match arg.split_once('+') {
121
1
        Some((then, "")) => (then, None),
122
2
        Some((then, otherwise)) => (then, Some(otherwise.to_string())),
123
37
        None => (arg.as_str(), None),
124
    };
125
40
    Some(constructor(then.to_string(), otherwise))
126
70
}
127
128
/// Documentation for a single entry of the action catalog.
129
///
130
/// Produced by `define_action_catalog!` and exposed through [`ACTION_CATALOG`];
131
/// used to generate the actions list of the manpage.
132
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133
pub struct ActionDoc {
134
    /// Canonical kebab-case name, as accepted by [`parse_action`].
135
    pub name: &'static str,
136
    /// Whether the action carries an argument (`name(...)` / `name:...`).
137
    pub takes_arg: bool,
138
    /// The action's rustdoc, one line per doc comment line.
139
    pub doc: &'static str,
140
}
141
142
impl ActionDoc {
143
    /// The action as it is spelled in a binding, with `(...)` for actions taking an argument.
144
    #[must_use]
145
649
    pub fn display_name(&self) -> String {
146
649
        if self.takes_arg {
  Branch (146:12): [True: 29, False: 43]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/actions.rs
Line
Count
Source
1
//! Action definitions, the action catalog, and action parsing.
2
//!
3
//! The [`Action`] enum, its canonical names and its parser are all generated
4
//! from a single source of truth: the [`define_action_catalog`] invocation at
5
//! the bottom of this module. Each entry declares the variant (with its doc
6
//! comment and payload types), the kebab-case name accepted by `--bind`, and
7
//! the expression that builds the variant from an optional argument.
8
9
use std::future::Future;
10
use std::pin::Pin;
11
use std::sync::{Arc, Mutex};
12
13
use derive_more::{Debug, Eq, PartialEq};
14
15
use super::event::Event;
16
17
type BoxError = Box<dyn std::error::Error + Sync + Send>;
18
type BoxFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<Event>, BoxError>> + Send + 'a>>;
19
20
/// Trait object stored inside [`ActionCallback`].
21
///
22
/// Having an explicit trait (rather than a bare `dyn Fn` type alias) allows
23
/// Rust to correctly resolve the higher-ranked lifetime in the return type.
24
trait AsyncCallbackFn: Send {
25
    fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a>;
26
}
27
28
/// Adapter that stores a concrete async closure and implements [`AsyncCallbackFn`].
29
struct AsyncFnWrapper<F>(F);
30
31
impl<F, Fut> AsyncCallbackFn for AsyncFnWrapper<F>
32
where
33
    F: for<'a> Fn(&'a mut crate::tui::App) -> Fut + Send,
34
    Fut: Future<Output = Result<Vec<Event>, BoxError>> + Send + 'static,
35
{
36
1
    fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a> {
37
1
        Box::pin((self.0)(app))
38
1
    }
39
}
40
41
/// Adapter that stores a plain synchronous closure and implements [`AsyncCallbackFn`].
42
struct SyncFnWrapper<F>(F);
43
44
impl<F> AsyncCallbackFn for SyncFnWrapper<F>
45
where
46
    F: Fn(&mut crate::tui::App) -> Result<Vec<Event>, BoxError> + Send,
47
{
48
1
    fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a> {
49
1
        Box::pin(std::future::ready((self.0)(app)))
50
1
    }
51
}
52
53
/// A custom action callback that receives a mutable reference to the App.
54
///
55
/// The closure will be called with a mutable reference to App and should return
56
/// a vec of events that will be processed after the callback completes.
57
///
58
/// Both sync and async closures are supported:
59
/// - Use [`ActionCallback::new`] to wrap an **async** closure or block.
60
/// - Use [`ActionCallback::new_sync`] to wrap a plain synchronous closure.
61
#[derive(Clone)]
62
pub struct ActionCallback(Arc<Mutex<dyn AsyncCallbackFn>>);
63
64
impl std::fmt::Debug for ActionCallback {
65
2
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66
2
        f.debug_struct("ActionCallback").finish()
67
2
    }
68
}
69
70
impl ActionCallback {
71
    /// Create a new action callback from an **async** closure or block.
72
    ///
73
    /// ```rust,ignore
74
    /// ActionCallback::new(|app| async move {
75
    ///     // async work here …
76
    ///     Ok(vec![])
77
    /// });
78
    /// ```
79
2
    pub fn new<F, Fut>(f: F) -> Self
80
2
    where
81
2
        F: for<'a> Fn(&'a mut crate::tui::App) -> Fut + Send + 'static,
82
2
        Fut: Future<Output = Result<Vec<Event>, BoxError>> + Send + 'static,
83
    {
84
2
        Self(Arc::new(Mutex::new(AsyncFnWrapper(f))))
85
2
    }
86
87
    /// Create a new action callback from a plain **synchronous** closure.
88
    ///
89
    /// This is a convenience wrapper; the closure is lifted into an immediately-
90
    /// resolving future so it integrates with the same async call site.
91
    ///
92
    /// ```rust,ignore
93
    /// ActionCallback::new_sync(|app| {
94
    ///     Ok(vec![Event::Action(Action::SelectAll)])
95
    /// });
96
    /// ```
97
2
    pub fn new_sync<F>(f: F) -> Self
98
2
    where
99
2
        F: Fn(&mut crate::tui::App) -> Result<Vec<Event>, BoxError> + Send + 'static,
100
    {
101
2
        Self(Arc::new(Mutex::new(SyncFnWrapper(f))))
102
2
    }
103
104
    /// Call the callback with an App reference, driving the returned future to completion.
105
    ///
106
    /// Must be called from within a Tokio multi-thread runtime context.
107
2
    pub(crate) fn call(&self, app: &mut crate::tui::App) -> Result<Vec<Event>, BoxError> {
108
2
        let callback = self.0.lock().unwrap();
109
2
        let fut = callback.call(app);
110
        // We are inside a synchronous call stack that originates from an async
111
        // tokio context.  `block_in_place` moves the current thread out of the
112
        // async worker pool temporarily so we can block on the future without
113
        // starving the runtime.
114
2
        tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
115
2
    }
116
}
117
118
70
fn parse_conditional(arg: Option<String>, constructor: fn(String, Option<String>) -> Action) -> Option<Action> {
119
70
    let arg = arg
?30
;
120
40
    let (then, otherwise) = match arg.split_once('+') {
121
1
        Some((then, "")) => (then, None),
122
2
        Some((then, otherwise)) => (then, Some(otherwise.to_string())),
123
37
        None => (arg.as_str(), None),
124
    };
125
40
    Some(constructor(then.to_string(), otherwise))
126
70
}
127
128
/// Documentation for a single entry of the action catalog.
129
///
130
/// Produced by `define_action_catalog!` and exposed through [`ACTION_CATALOG`];
131
/// used to generate the actions list of the manpage.
132
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133
pub struct ActionDoc {
134
    /// Canonical kebab-case name, as accepted by [`parse_action`].
135
    pub name: &'static str,
136
    /// Whether the action carries an argument (`name(...)` / `name:...`).
137
    pub takes_arg: bool,
138
    /// The action's rustdoc, one line per doc comment line.
139
    pub doc: &'static str,
140
}
141
142
impl ActionDoc {
143
    /// The action as it is spelled in a binding, with `(...)` for actions taking an argument.
144
    #[must_use]
145
649
    pub fn display_name(&self) -> String {
146
649
        if self.takes_arg {
  Branch (146:12): [True: 29, False: 43]
   Branch (146:12): [True: 233, False: 344]
-
147
262
            format!("{}(...)", self.name)
148
        } else {
149
387
            self.name.to_string()
150
        }
151
649
    }
152
153
    /// The action's documentation collapsed into a single line.
154
    #[must_use]
155
651
    pub fn summary(&self) -> String {
156
651
        self.doc
157
651
            .lines()
158
651
            .map(str::trim)
159
848
            .filter(|line| !line.is_empty())
160
            .collect::<Vec<_>>()
161
651
            .join(" ")
162
651
    }
163
164
    /// Whether this action can be named in a binding.
165
    ///
166
    /// [`Action::Custom`] exists only for library users building actions in
167
    /// Rust, so it has no spelling [`parse_action`] accepts.
168
    #[must_use]
169
658
    pub fn is_bindable(&self) -> bool {
170
        // Actions that require an argument only parse with one, so retry with
171
        // the parser's empty placeholder before giving up.
172
658
        parse_action(self.name)
173
109
            .or_else(|| parse_action(&format!("{}()", self.name)))
174
            .is_some()
175
658
    }
176
}
177
178
/// Expands to the argument marker used in the manpage, ignoring the payload types it is handed.
179
macro_rules! action_arg_marker {
180
    ($($payload:tt)*) => {
181
        true
182
    };
183
}
184
185
/// Declares the whole action catalog in one place.
186
///
187
/// Every entry has the shape
188
///
189
/// ```text
190
/// /// doc comment
191
/// Variant(payload types…) => "canonical-name" => constructor expression
192
/// ```
193
///
194
/// Doc comments are captured (so they can be re-emitted on the variant *and*
195
/// rendered into the manpage), which means any other attribute has to be passed
196
/// through the optional `@attrs[…]` group instead — a bare `#[…]` would be
197
/// ambiguous with the doc comments:
198
///
199
/// ```text
200
/// /// doc comment
201
/// @attrs[debug("custom")]
202
/// Variant(payload) => "canonical-name" => constructor expression
203
/// ```
204
///
205
/// and the macro generates, from that single list:
206
/// - the [`Action`] enum (with the doc comments and payloads as written),
207
/// - [`Action::name`], mapping each variant to its canonical name,
208
/// - `parse_named_action`, mapping a name plus optional argument back to a variant,
209
/// - [`ACTION_CATALOG`], the name/argument/documentation list the manpage is generated from.
210
///
211
/// The identifier before the `;` is the name bound to the optional argument
212
/// (`Option<String>`) inside the constructor expressions.
213
macro_rules! define_action_catalog {
214
    (
215
        $arg:ident;
216
        $(
217
            $(#[doc = $doc:literal])*
218
            $(@attrs[$($attr:meta),+ $(,)?])?
219
            $variant:ident $(($($payload:ty),+ $(,)?))? => $name:literal => $parsed:expr
220
        ),+ $(,)?
221
    ) => {
222
        /// Actions that can be performed in skim
223
        #[derive(Debug, Clone, PartialEq, Eq)]
224
        #[cfg_attr(feature = "listen", derive(serde::Serialize, serde::Deserialize))]
225
        pub enum Action {
226
            $(
227
                $(#[doc = $doc])*
228
                $($(#[$attr])+)?
229
                $variant $(($($payload),+))?,
230
            )+
231
        }
232
233
        /// Every action, in declaration order, with its name, argument marker and documentation.
234
        ///
235
        /// This is the source the manpage's action list is generated from, so a
236
        /// new entry in the catalog is documented automatically.
237
        pub const ACTION_CATALOG: &[ActionDoc] = &[
238
            $(ActionDoc {
239
                name: $name,
240
                takes_arg: false $(|| action_arg_marker!($($payload)+))?,
241
                doc: concat!($($doc, "\n"),*),
242
            }),+
243
        ];
244
245
        impl Action {
246
            /// Returns the canonical kebab-case name of this action — the same spelling
247
            /// [`parse_action`] accepts.
248
            ///
249
            /// This lets an action be bound as if it were an event (e.g. `reload:first`):
250
            /// after the action runs, any follow-up chain keyed by this name is queued.
251
            /// The name ignores the action's arguments, so `down` matches `Down(1)` and
252
            /// `Down(5)` alike.
253
            #[must_use]
254
1.18k
            pub fn name(&self) -> &'static str {
255
1.18k
                match self {
256
1.18k
                    $(Self::$variant { .. } => $name),+
257
1.18k
                }
258
1.18k
            }
259
        }
260
261
1.16k
        fn parse_named_action(action: &str, $arg: Option<String>) -> Option<Action> {
262
            #[allow(clippy::enum_glob_use)]
263
            use Action::*;
264
1.16k
            match action {
265
                $($name => $parsed),+,
266
13
                _ => None,
267
            }
268
1.16k
        }
269
    };
270
}
271
272
define_action_catalog! {
273
    arg;
274
    /// Abort and exit with error
275
    Abort => "abort" => Some(Abort),
276
    /// Accept selection and exit with optional key.
277
    ///
278
    /// The argument is printed when the binding is triggered.
279
    Accept(Option<String>) => "accept" => Some(Accept(arg)),
280
    /// Add a character to the query
281
18
    AddChar(char) => "add-char" => arg.map(|s| AddChar(s.chars().next().unwrap_or_default())),
282
    /// Append to selection and select
283
    AppendAndSelect => "append-and-select" => Some(AppendAndSelect),
284
    /// Move cursor backward one character
285
    BackwardChar => "backward-char" => Some(BackwardChar),
286
    /// Delete character before cursor
287
    BackwardDeleteChar => "backward-delete-char" => Some(BackwardDeleteChar),
288
    /// Delete character before cursor or exit if the query is empty
289
    BackwardDeleteCharEof => "backward-delete-char/eof" => Some(BackwardDeleteCharEof),
290
    /// Delete word before cursor
291
    BackwardKillWord => "backward-kill-word" => Some(BackwardKillWord),
292
    /// Move cursor backward one word
293
    BackwardWord => "backward-word" => Some(BackwardWord),
294
    /// Move cursor to beginning of line
295
    BeginningOfLine => "beginning-of-line" => Some(BeginningOfLine),
296
    /// Bind one or more keys to action chains.
297
    ///
298
    /// The argument is a comma-separated list of `trigger:action[+action]` bindings to add,
299
    /// using the same syntax as `--bind`, including action triggers such as `act-up:last`.
300
    Bind(String) => "bind" => arg.map(Bind),
301
    /// Cancel current operation
302
    Cancel => "cancel" => Some(Cancel),
303
    /// Clear the screen
304
    ClearScreen => "clear-screen" => Some(ClearScreen),
305
    /// Delete character under cursor
306
    DeleteChar => "delete-char" => Some(DeleteChar),
307
    /// Delete character or exit if empty
308
    DeleteCharEof => "delete-char/eof" => Some(DeleteCharEof),
309
    /// Deselect all items
310
    DeselectAll => "deselect-all" => Some(DeselectAll),
311
    /// Move selection down by N items
312
4
    Down(u16) => "down" => Some(Down(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
313
    /// Move cursor to end of line
314
    EndOfLine => "end-of-line" => Some(EndOfLine),
315
    /// Execute a command.
316
    ///
317
    /// The argument is a command, see COMMAND EXPANSION for details.
318
    Execute(String) => "execute" => arg.map(Execute),
319
    /// Execute a command silently.
320
    ///
321
    /// The argument is a command, see COMMAND EXPANSION for details.
322
    ExecuteSilent(String) => "execute-silent" => arg.map(ExecuteSilent),
323
    /// Jump to first item in list
324
    First => "first" => Some(First),
325
    /// Move cursor forward one character
326
    ForwardChar => "forward-char" => Some(ForwardChar),
327
    /// Move cursor forward one word
328
    ForwardWord => "forward-word" => Some(ForwardWord),
329
    /// Execute action if query is empty
330
    IfQueryEmpty(String, Option<String>) => "if-query-empty" => parse_conditional(arg, IfQueryEmpty),
331
    /// Execute action if query is not empty
332
    IfQueryNotEmpty(String, Option<String>) => "if-query-not-empty" => parse_conditional(arg, IfQueryNotEmpty),
333
    /// Execute action if no items match
334
    IfNonMatched(String, Option<String>) => "if-non-matched" => parse_conditional(arg, IfNonMatched),
335
    /// Ignore the action
336
    Ignore => "ignore" => Some(Ignore),
337
    /// Delete from cursor to end of line
338
    KillLine => "kill-line" => Some(KillLine),
339
    /// Delete word after cursor
340
    KillWord => "kill-word" => Some(KillWord),
341
    /// Jump to last item in list
342
    Last => "last" => Some(Last),
343
    /// Move to next history entry (requires `--history` or `--cmd-history`)
344
    NextHistory => "next-history" => Some(NextHistory),
345
    /// Scroll down by half a page
346
1
    HalfPageDown(i32) => "half-page-down" => Some(HalfPageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
347
    /// Scroll up by half a page
348
0
    HalfPageUp(i32) => "half-page-up" => Some(HalfPageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
349
    /// Scroll down by a page
350
0
    PageDown(i32) => "page-down" => Some(PageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
351
    /// Scroll up by a page
352
1
    PageUp(i32) => "page-up" => Some(PageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
353
    /// Scroll preview up
354
1
    PreviewUp(i32) => "preview-up" => Some(PreviewUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
355
    /// Scroll preview down
356
0
    PreviewDown(i32) => "preview-down" => Some(PreviewDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
357
    /// Scroll preview left
358
0
    PreviewLeft(i32) => "preview-left" => Some(PreviewLeft(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
359
    /// Scroll preview right
360
0
    PreviewRight(i32) => "preview-right" => Some(PreviewRight(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
361
    /// Scroll preview up by a page
362
0
    PreviewPageUp(i32) => "preview-page-up" => Some(PreviewPageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
363
    /// Scroll preview down by a page
364
0
    PreviewPageDown(i32) => "preview-page-down" => Some(PreviewPageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
365
    /// Move to previous history entry (requires `--history` or `--cmd-history`)
366
    PreviousHistory => "previous-history" => Some(PreviousHistory),
367
    /// Redraw the screen
368
    Redraw => "redraw" => Some(Redraw),
369
    /// Refresh the command
370
    RefreshCmd => "refresh-cmd" => Some(RefreshCmd),
371
    /// Refresh the preview
372
    RefreshPreview => "refresh-preview" => Some(RefreshPreview),
373
    /// Restart the matcher
374
    RestartMatcher => "restart-matcher" => Some(RestartMatcher),
375
    /// Reload with optional new command
376
    Reload(Option<String>) => "reload" => Some(Reload(arg)),
377
    /// Rotate through matching modes
378
    RotateMode => "rotate-mode" => Some(RotateMode),
379
    /// Scroll item list left
380
0
    ScrollLeft(i32) => "scroll-left" => Some(ScrollLeft(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
381
    /// Scroll item list right
382
1
    ScrollRight(i32) => "scroll-right" => Some(ScrollRight(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
383
    /// Select all items
384
    SelectAll => "select-all" => Some(SelectAll),
385
    /// Select a specific row
386
3
    SelectRow(usize) => "select-row" => Some(SelectRow(arg.and_then(|s| s.parse().ok()).unwrap_or_default())),
387
    /// Select current item
388
    Select => "select" => Some(Select),
389
    /// Suppress the default behaviour of the action this is bound to.
390
    ///
391
    /// Only meaningful as a follow-up bound to an action (e.g. `act-up:suppress`):
392
    /// it cancels that action's own effect, so the remaining follow-up chain
393
    /// runs in its place. On its own it is a no-op (equivalent to `ignore`).
394
    Suppress => "suppress" => Some(Suppress),
395
    /// Set the interactive-mode command and rerun it.
396
    ///
397
    /// The argument is an expanded expression, see COMMAND EXPANSION for details.
398
    SetCmd(String) => "set-cmd" => arg.map(SetCmd),
399
    /// Set the header (or disable it on an empty value)
400
    SetHeader(Option<String>) => "set-header" => Some(SetHeader(arg)),
401
    /// Set the preview cmd and rerun preview.
402
    ///
403
    /// The argument is an expanded expression, see COMMAND EXPANSION for details.
404
    SetPreviewCmd(String) => "set-preview-cmd" => arg.map(SetPreviewCmd),
405
    /// Set the query to the expanded value.
406
    ///
407
    /// The argument is an expanded expression, see COMMAND EXPANSION for details.
408
    SetQuery(String) => "set-query" => arg.map(SetQuery),
409
    /// Toggle selection of current item
410
    Toggle => "toggle" => Some(Toggle),
411
    /// Toggle selection of all items
412
    ToggleAll => "toggle-all" => Some(ToggleAll),
413
    /// Toggle and move in
414
    ToggleIn => "toggle-in" => Some(ToggleIn),
415
    /// Toggle interactive mode
416
    ToggleInteractive => "toggle-interactive" => Some(ToggleInteractive),
417
    /// Toggle and move out
418
    ToggleOut => "toggle-out" => Some(ToggleOut),
419
    /// Toggle preview visibility
420
    TogglePreview => "toggle-preview" => Some(TogglePreview),
421
    /// Toggle preview line wrapping
422
    TogglePreviewWrap => "toggle-preview-wrap" => Some(TogglePreviewWrap),
423
    /// Toggle sorting
424
    ToggleSort => "toggle-sort" => Some(ToggleSort),
425
    /// Jump to first item in list (alias for First)
426
    Top => "top" => Some(Top),
427
    /// Unbind one or more keys.
428
    ///
429
    /// The argument is a comma-separated list of keys or action triggers (e.g. `act-up`) to unbind.
430
    Unbind(String) => "unbind" => arg.map(Unbind),
431
    /// Discard line (unix-style)
432
    UnixLineDiscard => "unix-line-discard" => Some(UnixLineDiscard),
433
    /// Delete word backward (unix-style)
434
    UnixWordRubout => "unix-word-rubout" => Some(UnixWordRubout),
435
    /// Move selection up by N items
436
2
    Up(u16) => "up" => Some(Up(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
437
    /// Yank (paste)
438
    Yank => "yank" => Some(Yank),
439
    /// Custom action from lib
440
    @attrs[
441
        debug("custom"),
442
        eq(skip),
443
        partial_eq(skip),
444
        cfg_attr(feature = "listen", serde(skip)),
445
    ]
446
    Custom(ActionCallback) => "custom" => None,
447
}
448
449
/// Parses an action string into an Action enum
450
///
451
/// Returns `None` if the action is unrecognized, or if it is specified without
452
/// an argument it requires (the `if-*` actions, `execute`, `set-query`, … — see
453
/// the `arg.map(…)` arms of the catalog).
454
#[must_use]
455
1.16k
pub fn parse_action(raw_action: &str) -> Option<Action> {
456
1.16k
    let parts = raw_action.split_once([':', '(', ')']);
457
    let action;
458
1.16k
    let mut arg = None;
459
1.16k
    match parts {
460
935
        None => action = raw_action,
461
16
        Some((act, "")) => action = act,
462
        Some((act, a)) => {
463
211
            action = act;
464
211
            arg = Some(a.trim_end_matches(')').to_string());
465
        }
466
    }
467
1.16k
    debug!("parse_action: action={
action0
}, arg={
arg0
:?}");
468
469
1.16k
    parse_named_action(action, arg)
470
1.16k
}
471
472
#[cfg(test)]
473
#[path = "actions_tests.rs"]
474
mod tests;
\ No newline at end of file +
147
262
            format!("{}(...)", self.name)
148
        } else {
149
387
            self.name.to_string()
150
        }
151
649
    }
152
153
    /// The action's documentation collapsed into a single line.
154
    #[must_use]
155
651
    pub fn summary(&self) -> String {
156
651
        self.doc
157
651
            .lines()
158
651
            .map(str::trim)
159
848
            .filter(|line| !line.is_empty())
160
            .collect::<Vec<_>>()
161
651
            .join(" ")
162
651
    }
163
164
    /// Whether this action can be named in a binding.
165
    ///
166
    /// [`Action::Custom`] exists only for library users building actions in
167
    /// Rust, so it has no spelling [`parse_action`] accepts.
168
    #[must_use]
169
658
    pub fn is_bindable(&self) -> bool {
170
        // Actions that require an argument only parse with one, so retry with
171
        // the parser's empty placeholder before giving up.
172
658
        parse_action(self.name)
173
109
            .or_else(|| parse_action(&format!("{}()", self.name)))
174
            .is_some()
175
658
    }
176
}
177
178
/// Expands to the argument marker used in the manpage, ignoring the payload types it is handed.
179
macro_rules! action_arg_marker {
180
    ($($payload:tt)*) => {
181
        true
182
    };
183
}
184
185
/// Declares the whole action catalog in one place.
186
///
187
/// Every entry has the shape
188
///
189
/// ```text
190
/// /// doc comment
191
/// Variant(payload types…) => "canonical-name" => constructor expression
192
/// ```
193
///
194
/// Doc comments are captured (so they can be re-emitted on the variant *and*
195
/// rendered into the manpage), which means any other attribute has to be passed
196
/// through the optional `@attrs[…]` group instead — a bare `#[…]` would be
197
/// ambiguous with the doc comments:
198
///
199
/// ```text
200
/// /// doc comment
201
/// @attrs[debug("custom")]
202
/// Variant(payload) => "canonical-name" => constructor expression
203
/// ```
204
///
205
/// and the macro generates, from that single list:
206
/// - the [`Action`] enum (with the doc comments and payloads as written),
207
/// - [`Action::name`], mapping each variant to its canonical name,
208
/// - `parse_named_action`, mapping a name plus optional argument back to a variant,
209
/// - [`ACTION_CATALOG`], the name/argument/documentation list the manpage is generated from.
210
///
211
/// The identifier before the `;` is the name bound to the optional argument
212
/// (`Option<String>`) inside the constructor expressions.
213
macro_rules! define_action_catalog {
214
    (
215
        $arg:ident;
216
        $(
217
            $(#[doc = $doc:literal])*
218
            $(@attrs[$($attr:meta),+ $(,)?])?
219
            $variant:ident $(($($payload:ty),+ $(,)?))? => $name:literal => $parsed:expr
220
        ),+ $(,)?
221
    ) => {
222
        /// Actions that can be performed in skim
223
        #[derive(Debug, Clone, PartialEq, Eq)]
224
        #[cfg_attr(feature = "listen", derive(serde::Serialize, serde::Deserialize))]
225
        pub enum Action {
226
            $(
227
                $(#[doc = $doc])*
228
                $($(#[$attr])+)?
229
                $variant $(($($payload),+))?,
230
            )+
231
        }
232
233
        /// Every action, in declaration order, with its name, argument marker and documentation.
234
        ///
235
        /// This is the source the manpage's action list is generated from, so a
236
        /// new entry in the catalog is documented automatically.
237
        pub const ACTION_CATALOG: &[ActionDoc] = &[
238
            $(ActionDoc {
239
                name: $name,
240
                takes_arg: false $(|| action_arg_marker!($($payload)+))?,
241
                doc: concat!($($doc, "\n"),*),
242
            }),+
243
        ];
244
245
        impl Action {
246
            /// Returns the canonical kebab-case name of this action — the same spelling
247
            /// [`parse_action`] accepts.
248
            ///
249
            /// This lets an action be bound as if it were an event (e.g. `reload:first`):
250
            /// after the action runs, any follow-up chain keyed by this name is queued.
251
            /// The name ignores the action's arguments, so `down` matches `Down(1)` and
252
            /// `Down(5)` alike.
253
            #[must_use]
254
1.19k
            pub fn name(&self) -> &'static str {
255
1.19k
                match self {
256
1.19k
                    $(Self::$variant { .. } => $name),+
257
1.19k
                }
258
1.19k
            }
259
        }
260
261
1.16k
        fn parse_named_action(action: &str, $arg: Option<String>) -> Option<Action> {
262
            #[allow(clippy::enum_glob_use)]
263
            use Action::*;
264
1.16k
            match action {
265
                $($name => $parsed),+,
266
13
                _ => None,
267
            }
268
1.16k
        }
269
    };
270
}
271
272
define_action_catalog! {
273
    arg;
274
    /// Abort and exit with error
275
    Abort => "abort" => Some(Abort),
276
    /// Accept selection and exit with optional key.
277
    ///
278
    /// The argument is printed when the binding is triggered.
279
    Accept(Option<String>) => "accept" => Some(Accept(arg)),
280
    /// Add a character to the query
281
18
    AddChar(char) => "add-char" => arg.map(|s| AddChar(s.chars().next().unwrap_or_default())),
282
    /// Append to selection and select
283
    AppendAndSelect => "append-and-select" => Some(AppendAndSelect),
284
    /// Move cursor backward one character
285
    BackwardChar => "backward-char" => Some(BackwardChar),
286
    /// Delete character before cursor
287
    BackwardDeleteChar => "backward-delete-char" => Some(BackwardDeleteChar),
288
    /// Delete character before cursor or exit if the query is empty
289
    BackwardDeleteCharEof => "backward-delete-char/eof" => Some(BackwardDeleteCharEof),
290
    /// Delete word before cursor
291
    BackwardKillWord => "backward-kill-word" => Some(BackwardKillWord),
292
    /// Move cursor backward one word
293
    BackwardWord => "backward-word" => Some(BackwardWord),
294
    /// Move cursor to beginning of line
295
    BeginningOfLine => "beginning-of-line" => Some(BeginningOfLine),
296
    /// Bind one or more keys to action chains.
297
    ///
298
    /// The argument is a comma-separated list of `trigger:action[+action]` bindings to add,
299
    /// using the same syntax as `--bind`, including action triggers such as `act-up:last`.
300
    Bind(String) => "bind" => arg.map(Bind),
301
    /// Cancel current operation
302
    Cancel => "cancel" => Some(Cancel),
303
    /// Clear the screen
304
    ClearScreen => "clear-screen" => Some(ClearScreen),
305
    /// Delete character under cursor
306
    DeleteChar => "delete-char" => Some(DeleteChar),
307
    /// Delete character or exit if empty
308
    DeleteCharEof => "delete-char/eof" => Some(DeleteCharEof),
309
    /// Deselect all items
310
    DeselectAll => "deselect-all" => Some(DeselectAll),
311
    /// Move selection down by N items
312
4
    Down(u16) => "down" => Some(Down(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
313
    /// Move cursor to end of line
314
    EndOfLine => "end-of-line" => Some(EndOfLine),
315
    /// Execute a command.
316
    ///
317
    /// The argument is a command, see COMMAND EXPANSION for details.
318
    Execute(String) => "execute" => arg.map(Execute),
319
    /// Execute a command silently.
320
    ///
321
    /// The argument is a command, see COMMAND EXPANSION for details.
322
    ExecuteSilent(String) => "execute-silent" => arg.map(ExecuteSilent),
323
    /// Jump to first item in list
324
    First => "first" => Some(First),
325
    /// Move cursor forward one character
326
    ForwardChar => "forward-char" => Some(ForwardChar),
327
    /// Move cursor forward one word
328
    ForwardWord => "forward-word" => Some(ForwardWord),
329
    /// Execute action if query is empty
330
    IfQueryEmpty(String, Option<String>) => "if-query-empty" => parse_conditional(arg, IfQueryEmpty),
331
    /// Execute action if query is not empty
332
    IfQueryNotEmpty(String, Option<String>) => "if-query-not-empty" => parse_conditional(arg, IfQueryNotEmpty),
333
    /// Execute action if no items match
334
    IfNonMatched(String, Option<String>) => "if-non-matched" => parse_conditional(arg, IfNonMatched),
335
    /// Ignore the action
336
    Ignore => "ignore" => Some(Ignore),
337
    /// Delete from cursor to end of line
338
    KillLine => "kill-line" => Some(KillLine),
339
    /// Delete word after cursor
340
    KillWord => "kill-word" => Some(KillWord),
341
    /// Jump to last item in list
342
    Last => "last" => Some(Last),
343
    /// Move to next history entry (requires `--history` or `--cmd-history`)
344
    NextHistory => "next-history" => Some(NextHistory),
345
    /// Scroll down by half a page
346
1
    HalfPageDown(i32) => "half-page-down" => Some(HalfPageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
347
    /// Scroll up by half a page
348
0
    HalfPageUp(i32) => "half-page-up" => Some(HalfPageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
349
    /// Scroll down by a page
350
0
    PageDown(i32) => "page-down" => Some(PageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
351
    /// Scroll up by a page
352
1
    PageUp(i32) => "page-up" => Some(PageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
353
    /// Scroll preview up
354
1
    PreviewUp(i32) => "preview-up" => Some(PreviewUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
355
    /// Scroll preview down
356
0
    PreviewDown(i32) => "preview-down" => Some(PreviewDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
357
    /// Scroll preview left
358
0
    PreviewLeft(i32) => "preview-left" => Some(PreviewLeft(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
359
    /// Scroll preview right
360
0
    PreviewRight(i32) => "preview-right" => Some(PreviewRight(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
361
    /// Scroll preview up by a page
362
0
    PreviewPageUp(i32) => "preview-page-up" => Some(PreviewPageUp(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
363
    /// Scroll preview down by a page
364
0
    PreviewPageDown(i32) => "preview-page-down" => Some(PreviewPageDown(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
365
    /// Move to previous history entry (requires `--history` or `--cmd-history`)
366
    PreviousHistory => "previous-history" => Some(PreviousHistory),
367
    /// Redraw the screen
368
    Redraw => "redraw" => Some(Redraw),
369
    /// Refresh the command
370
    RefreshCmd => "refresh-cmd" => Some(RefreshCmd),
371
    /// Refresh the preview
372
    RefreshPreview => "refresh-preview" => Some(RefreshPreview),
373
    /// Restart the matcher
374
    RestartMatcher => "restart-matcher" => Some(RestartMatcher),
375
    /// Reload with optional new command
376
    Reload(Option<String>) => "reload" => Some(Reload(arg)),
377
    /// Rotate through matching modes
378
    RotateMode => "rotate-mode" => Some(RotateMode),
379
    /// Scroll item list left
380
0
    ScrollLeft(i32) => "scroll-left" => Some(ScrollLeft(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
381
    /// Scroll item list right
382
1
    ScrollRight(i32) => "scroll-right" => Some(ScrollRight(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
383
    /// Select all items
384
    SelectAll => "select-all" => Some(SelectAll),
385
    /// Select a specific row
386
3
    SelectRow(usize) => "select-row" => Some(SelectRow(arg.and_then(|s| s.parse().ok()).unwrap_or_default())),
387
    /// Select current item
388
    Select => "select" => Some(Select),
389
    /// Suppress the default behaviour of the action this is bound to.
390
    ///
391
    /// Only meaningful as a follow-up bound to an action (e.g. `act-up:suppress`):
392
    /// it cancels that action's own effect, so the remaining follow-up chain
393
    /// runs in its place. On its own it is a no-op (equivalent to `ignore`).
394
    Suppress => "suppress" => Some(Suppress),
395
    /// Set the interactive-mode command and rerun it.
396
    ///
397
    /// The argument is an expanded expression, see COMMAND EXPANSION for details.
398
    SetCmd(String) => "set-cmd" => arg.map(SetCmd),
399
    /// Set the header (or disable it on an empty value)
400
    SetHeader(Option<String>) => "set-header" => Some(SetHeader(arg)),
401
    /// Set the preview cmd and rerun preview.
402
    ///
403
    /// The argument is an expanded expression, see COMMAND EXPANSION for details.
404
    SetPreviewCmd(String) => "set-preview-cmd" => arg.map(SetPreviewCmd),
405
    /// Set the query to the expanded value.
406
    ///
407
    /// The argument is an expanded expression, see COMMAND EXPANSION for details.
408
    SetQuery(String) => "set-query" => arg.map(SetQuery),
409
    /// Toggle selection of current item
410
    Toggle => "toggle" => Some(Toggle),
411
    /// Toggle selection of all items
412
    ToggleAll => "toggle-all" => Some(ToggleAll),
413
    /// Toggle and move in
414
    ToggleIn => "toggle-in" => Some(ToggleIn),
415
    /// Toggle interactive mode
416
    ToggleInteractive => "toggle-interactive" => Some(ToggleInteractive),
417
    /// Toggle and move out
418
    ToggleOut => "toggle-out" => Some(ToggleOut),
419
    /// Toggle preview visibility
420
    TogglePreview => "toggle-preview" => Some(TogglePreview),
421
    /// Toggle preview line wrapping
422
    TogglePreviewWrap => "toggle-preview-wrap" => Some(TogglePreviewWrap),
423
    /// Toggle sorting
424
    ToggleSort => "toggle-sort" => Some(ToggleSort),
425
    /// Jump to first item in list (alias for First)
426
    Top => "top" => Some(Top),
427
    /// Unbind one or more keys.
428
    ///
429
    /// The argument is a comma-separated list of keys or action triggers (e.g. `act-up`) to unbind.
430
    Unbind(String) => "unbind" => arg.map(Unbind),
431
    /// Discard line (unix-style)
432
    UnixLineDiscard => "unix-line-discard" => Some(UnixLineDiscard),
433
    /// Delete word backward (unix-style)
434
    UnixWordRubout => "unix-word-rubout" => Some(UnixWordRubout),
435
    /// Move selection up by N items
436
2
    Up(u16) => "up" => Some(Up(arg.and_then(|s| s.parse().ok()).unwrap_or(1))),
437
    /// Yank (paste)
438
    Yank => "yank" => Some(Yank),
439
    /// Custom action from lib
440
    @attrs[
441
        debug("custom"),
442
        eq(skip),
443
        partial_eq(skip),
444
        cfg_attr(feature = "listen", serde(skip)),
445
    ]
446
    Custom(ActionCallback) => "custom" => None,
447
}
448
449
/// Parses an action string into an Action enum
450
///
451
/// Returns `None` if the action is unrecognized, or if it is specified without
452
/// an argument it requires (the `if-*` actions, `execute`, `set-query`, … — see
453
/// the `arg.map(…)` arms of the catalog).
454
#[must_use]
455
1.16k
pub fn parse_action(raw_action: &str) -> Option<Action> {
456
1.16k
    let parts = raw_action.split_once([':', '(', ')']);
457
    let action;
458
1.16k
    let mut arg = None;
459
1.16k
    match parts {
460
937
        None => action = raw_action,
461
16
        Some((act, "")) => action = act,
462
        Some((act, a)) => {
463
211
            action = act;
464
211
            arg = Some(a.trim_end_matches(')').to_string());
465
        }
466
    }
467
1.16k
    debug!("parse_action: action={
action0
}, arg={
arg0
:?}");
468
469
1.16k
    parse_named_action(action, arg)
470
1.16k
}
471
472
#[cfg(test)]
473
#[path = "actions_tests.rs"]
474
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/app.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/app.rs.html index fb805797..b1a3dbcc 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/tui/app.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/app.rs.html @@ -1,175 +1,175 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/app.rs
Line
Count
Source
1
use std::process::Stdio;
2
use std::rc::Rc;
3
use std::sync::Arc;
4
use std::sync::atomic::{AtomicBool, Ordering};
5
6
use crate::item::{ItemPool, MatchedItem};
7
use crate::matcher::{Matcher, MatcherControl};
8
use crate::prelude::ExactOrFuzzyEngineFactory;
9
use crate::tui::input::StatusInfo;
10
use crate::tui::layout::{AppLayout, LayoutTemplate};
11
use crate::tui::options::TuiLayout;
12
use crate::tui::statusline::InfoDisplay;
13
use crate::tui::widget::SkimWidget;
14
use crate::tui::{SkimRender, TICK_RATE};
15
use crate::{ItemPreview, PreviewContext, Rank, SkimItem, SkimOptions, util};
16
17
#[cfg(test)]
18
#[path = "app_tests.rs"]
19
mod tests;
20
21
use super::actions::Action;
22
use super::header::Header;
23
use super::item_list::ItemList;
24
use super::{Event, Tui, input, preview};
25
use crate::binds::SkimEvent;
26
use crate::thread_pool::{self, ThreadPool};
27
use crossterm::event::{KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
28
use eyre::{Result, bail};
29
use input::Input;
30
use preview::Preview;
31
use ratatui::buffer::Buffer;
32
use ratatui::crossterm::event::KeyCode::Char;
33
use ratatui::layout::Rect;
34
use ratatui::prelude::Backend;
35
use ratatui::widgets::Widget;
36
use std::sync::LazyLock;
37
38
533
static NUM_THREADS: LazyLock<usize> = LazyLock::new(|| {
39
533
    std::thread::available_parallelism()
40
533
        .ok()
41
533
        .map_or_else(|| 0, std::num::NonZero::get)
42
533
});
43
44
const MATCHER_DEBOUNCE_MS: u128 = 200;
45
const HIDE_GRACE_MS: u128 = 500;
46
const DOUBLE_CLICK_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
47
48
/// Application state for skim's TUI
49
#[allow(clippy::struct_excessive_bools)]
50
pub struct App {
51
    /// Pool of items to be filtered
52
    pub item_pool: Arc<ItemPool>,
53
    /// Thread pool used by the matcher (⌊2N/3⌋ threads).
54
    pub matcher_pool: Arc<ThreadPool>,
55
    /// Thread pool used by the reader pipeline (⌈N/3⌉ threads).
56
    pub reader_pool: Arc<ThreadPool>,
57
    /// Whether the application should quit
58
    pub should_quit: bool,
59
    /// The terminating action, including one dispatched inside a follow-up or conditional chain.
60
    pub(crate) final_action: Option<Action>,
61
62
    /// Current cursor position (x, y)
63
    pub cursor_pos: (u16, u16),
64
    /// Control handle for the matcher thread
65
    pub matcher_control: MatcherControl,
66
    /// The matcher for filtering items
67
    pub matcher: Matcher,
68
    /// Register for yank/paste operations
69
    pub yank_register: String,
70
    /// Last time the matcher was restarted
71
    pub last_matcher_restart: std::time::Instant,
72
    /// Whether a matcher restart is pending
73
    pub pending_matcher_restart: bool,
74
    /// Whether or not we need a render on the next heartbeat
75
    pub needs_render: Arc<AtomicBool>,
76
    /// Time of the last render
77
    pub last_render_timer: std::time::Instant,
78
79
    /// Input field widget
80
    pub input: Input,
81
    /// Preview pane widget
82
    pub preview: Preview,
83
    /// Header widget
84
    pub header: Header,
85
    /// Item list widget
86
    pub item_list: ItemList,
87
    /// Color theme
88
    pub theme: Arc<crate::theme::ColorTheme>,
89
90
    /// Timer for tracking matcher activity
91
    pub matcher_timer: std::time::Instant,
92
93
    /// Last time spinner visibility changed
94
    pub spinner_last_change: std::time::Instant,
95
    /// Whether to show the spinner (controlled with debouncing)
96
    pub show_spinner: bool,
97
    /// Start time for spinner animation (set once at app creation, never reset)
98
    pub spinner_start: std::time::Instant,
99
100
    /// Query history navigation
101
    pub query_history: Vec<String>,
102
    /// Current position in query history
103
    pub history_index: Option<usize>,
104
    /// Saved input when navigating history
105
    pub saved_input: String,
106
107
    /// Command history navigation (for interactive mode)
108
    pub cmd_history: Vec<String>,
109
    /// Current position in command history
110
    pub cmd_history_index: Option<usize>,
111
    /// Saved command input when navigating history
112
    pub saved_cmd_input: String,
113
114
    /// Skim configuration options
115
    pub options: SkimOptions,
116
    /// The command being executed
117
    pub cmd: String,
118
    /// Pre-computed layout template built from options; rebuilt when options change.
119
    pub layout_template: LayoutTemplate,
120
    /// The header height used when `layout_template` was last built.
121
    /// Used to detect when multiline header items arrive and the template needs
122
    /// to be rebuilt to allocate the correct number of rows.
123
    last_header_height: u16,
124
    /// Concrete widget areas for the last rendered frame; updated in `render()`.
125
    pub layout: AppLayout,
126
    /// Last time preview was spawned (for debouncing)
127
    pub last_preview_spawn: std::time::Instant,
128
    /// Whether a preview run was debounced and needs to be retried
129
    pub pending_preview_run: bool,
130
    reader_timer: std::time::Instant,
131
    items_just_updated: bool,
132
    /// Records if we are scrolling (mouse down on the scrollbar and no mouse up yet)
133
    currently_scrolling: bool,
134
    /// Time of the previous left click, used to recognize `double-click` bindings.
135
    last_left_click: std::time::Instant,
136
    /// Set by [`Skim::check_reader`] once the reader has finished producing
137
    /// items. Reset on `reload`. Drives the one-shot `load` event.
138
    pub(crate) reader_done: bool,
139
    /// Whether the `load` event has been fired for the current read. Reset on
140
    /// `reload` so a new read fires `load` again.
141
    pub(crate) load_event_fired: bool,
142
    /// Set whenever a matcher run is (re)started; edge-triggers the one-shot
143
    /// `result` (and `zero`/`one`) events once that run completes, polled from
144
    /// the heartbeat.
145
    pub(crate) result_pending: bool,
146
    /// The last item that had focus, tracked so the `focus` event fires only
147
    /// when the focused item actually changes on cursor movement.
148
    last_focused: Option<Arc<dyn SkimItem>>,
149
}
150
151
impl Widget for &mut App {
152
2.73k
    fn render(self, area: Rect, buf: &mut Buffer) {
153
2.73k
        let mut res = SkimRender::default();
154
2.73k
        let has_border = self.options.border.is_some();
155
156
        // Update header with reserved items (from --header-lines).  When
157
        // --multiline is active a header-line item may occupy more rows than
158
        // the initial estimate (which was based purely on item count).  Detect
159
        // that change and rebuild the layout template so the header area gets
160
        // the right height before the frame is drawn.
161
2.73k
        self.header.set_header_lines(self.item_pool.reserved());
162
2.73k
        let current_header_height = self.header.height();
163
2.73k
        if current_header_height != self.last_header_height {
  Branch (163:12): [True: 7, False: 2.69k]
-
  Branch (163:12): [True: 0, False: 27]
-
164
7
            self.last_header_height = current_header_height;
165
7
            self.layout_template = LayoutTemplate::from_options(&self.options, current_header_height);
166
2.72k
        }
167
2.73k
        self.layout = self.layout_template.apply(area);
168
169
2.73k
        if let Some(header_area) = self.layout.header_area {
  Branch (169:16): [True: 96, False: 2.60k]
-
  Branch (169:16): [True: 0, False: 27]
-
170
96
            res |= self.header.render(header_area, buf);
171
2.63k
        }
172
173
2.73k
        if let Some(preview_area) = self.layout.preview_area {
  Branch (173:16): [True: 247, False: 2.45k]
-
  Branch (173:16): [True: 2, False: 25]
-
174
249
            res |= self.preview.render(preview_area, buf);
175
2.48k
        }
176
177
2.73k
        res |= self.item_list.render(self.layout.list_area, buf);
178
179
        // Render the input after the item list so that the status shows correct information.
180
2.73k
        self.input.status_info = if self.options.info.display == InfoDisplay::Hidden {
  Branch (180:37): [True: 59, False: 2.64k]
-
  Branch (180:37): [True: 1, False: 26]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/app.rs
Line
Count
Source
1
use std::process::Stdio;
2
use std::rc::Rc;
3
use std::sync::Arc;
4
use std::sync::atomic::{AtomicBool, Ordering};
5
6
use crate::item::{ItemPool, MatchedItem};
7
use crate::matcher::{Matcher, MatcherControl};
8
use crate::prelude::ExactOrFuzzyEngineFactory;
9
use crate::tui::input::StatusInfo;
10
use crate::tui::layout::{AppLayout, LayoutTemplate};
11
use crate::tui::options::TuiLayout;
12
use crate::tui::statusline::InfoDisplay;
13
use crate::tui::widget::SkimWidget;
14
use crate::tui::{SkimRender, TICK_RATE};
15
use crate::{ItemPreview, PreviewContext, Rank, SkimItem, SkimOptions, util};
16
17
#[cfg(test)]
18
#[path = "app_tests.rs"]
19
mod tests;
20
21
use super::actions::Action;
22
use super::header::Header;
23
use super::item_list::ItemList;
24
use super::{Event, Tui, input, preview};
25
use crate::binds::SkimEvent;
26
use crate::thread_pool::{self, ThreadPool};
27
use crossterm::event::{KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
28
use eyre::{Result, bail};
29
use input::Input;
30
use preview::Preview;
31
use ratatui::buffer::Buffer;
32
use ratatui::crossterm::event::KeyCode::Char;
33
use ratatui::layout::Rect;
34
use ratatui::prelude::Backend;
35
use ratatui::widgets::Widget;
36
use std::sync::LazyLock;
37
38
532
static NUM_THREADS: LazyLock<usize> = LazyLock::new(|| {
39
532
    std::thread::available_parallelism()
40
532
        .ok()
41
532
        .map_or_else(|| 0, std::num::NonZero::get)
42
532
});
43
44
const MATCHER_DEBOUNCE_MS: u128 = 200;
45
const HIDE_GRACE_MS: u128 = 500;
46
const DOUBLE_CLICK_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
47
48
/// Application state for skim's TUI
49
#[allow(clippy::struct_excessive_bools)]
50
pub struct App {
51
    /// Pool of items to be filtered
52
    pub item_pool: Arc<ItemPool>,
53
    /// Thread pool used by the matcher (⌊2N/3⌋ threads).
54
    pub matcher_pool: Arc<ThreadPool>,
55
    /// Thread pool used by the reader pipeline (⌈N/3⌉ threads).
56
    pub reader_pool: Arc<ThreadPool>,
57
    /// Whether the application should quit
58
    pub should_quit: bool,
59
    /// The terminating action, including one dispatched inside a follow-up or conditional chain.
60
    pub(crate) final_action: Option<Action>,
61
62
    /// Current cursor position (x, y)
63
    pub cursor_pos: (u16, u16),
64
    /// Control handle for the matcher thread
65
    pub matcher_control: MatcherControl,
66
    /// The matcher for filtering items
67
    pub matcher: Matcher,
68
    /// Register for yank/paste operations
69
    pub yank_register: String,
70
    /// Last time the matcher was restarted
71
    pub last_matcher_restart: std::time::Instant,
72
    /// Whether a matcher restart is pending
73
    pub pending_matcher_restart: bool,
74
    /// Whether or not we need a render on the next heartbeat
75
    pub needs_render: Arc<AtomicBool>,
76
    /// Time of the last render
77
    pub last_render_timer: std::time::Instant,
78
79
    /// Input field widget
80
    pub input: Input,
81
    /// Preview pane widget
82
    pub preview: Preview,
83
    /// Header widget
84
    pub header: Header,
85
    /// Item list widget
86
    pub item_list: ItemList,
87
    /// Color theme
88
    pub theme: Arc<crate::theme::ColorTheme>,
89
90
    /// Timer for tracking matcher activity
91
    pub matcher_timer: std::time::Instant,
92
93
    /// Last time spinner visibility changed
94
    pub spinner_last_change: std::time::Instant,
95
    /// Whether to show the spinner (controlled with debouncing)
96
    pub show_spinner: bool,
97
    /// Start time for spinner animation (set once at app creation, never reset)
98
    pub spinner_start: std::time::Instant,
99
100
    /// Query history navigation
101
    pub query_history: Vec<String>,
102
    /// Current position in query history
103
    pub history_index: Option<usize>,
104
    /// Saved input when navigating history
105
    pub saved_input: String,
106
107
    /// Command history navigation (for interactive mode)
108
    pub cmd_history: Vec<String>,
109
    /// Current position in command history
110
    pub cmd_history_index: Option<usize>,
111
    /// Saved command input when navigating history
112
    pub saved_cmd_input: String,
113
114
    /// Skim configuration options
115
    pub options: SkimOptions,
116
    /// The command being executed
117
    pub cmd: String,
118
    /// Pre-computed layout template built from options; rebuilt when options change.
119
    pub layout_template: LayoutTemplate,
120
    /// The header height used when `layout_template` was last built.
121
    /// Used to detect when multiline header items arrive and the template needs
122
    /// to be rebuilt to allocate the correct number of rows.
123
    last_header_height: u16,
124
    /// Concrete widget areas for the last rendered frame; updated in `render()`.
125
    pub layout: AppLayout,
126
    /// Last time preview was spawned (for debouncing)
127
    pub last_preview_spawn: std::time::Instant,
128
    /// Whether a preview run was debounced and needs to be retried
129
    pub pending_preview_run: bool,
130
    reader_timer: std::time::Instant,
131
    items_just_updated: bool,
132
    /// Records if we are scrolling (mouse down on the scrollbar and no mouse up yet)
133
    currently_scrolling: bool,
134
    /// Time of the previous left click, used to recognize `double-click` bindings.
135
    last_left_click: std::time::Instant,
136
    /// Set by [`Skim::check_reader`] once the reader has finished producing
137
    /// items. Reset on `reload`. Drives the one-shot `load` event.
138
    pub(crate) reader_done: bool,
139
    /// Whether the `load` event has been fired for the current read. Reset on
140
    /// `reload` so a new read fires `load` again.
141
    pub(crate) load_event_fired: bool,
142
    /// Set whenever a matcher run is (re)started; edge-triggers the one-shot
143
    /// `result` (and `zero`/`one`) events once that run completes, polled from
144
    /// the heartbeat.
145
    pub(crate) result_pending: bool,
146
    /// The last item that had focus, tracked so the `focus` event fires only
147
    /// when the focused item actually changes on cursor movement.
148
    last_focused: Option<Arc<dyn SkimItem>>,
149
}
150
151
impl Widget for &mut App {
152
2.73k
    fn render(self, area: Rect, buf: &mut Buffer) {
153
2.73k
        let mut res = SkimRender::default();
154
2.73k
        let has_border = self.options.border.is_some();
155
156
        // Update header with reserved items (from --header-lines).  When
157
        // --multiline is active a header-line item may occupy more rows than
158
        // the initial estimate (which was based purely on item count).  Detect
159
        // that change and rebuild the layout template so the header area gets
160
        // the right height before the frame is drawn.
161
2.73k
        self.header.set_header_lines(self.item_pool.reserved());
162
2.73k
        let current_header_height = self.header.height();
163
2.73k
        if current_header_height != self.last_header_height {
  Branch (163:12): [True: 7, False: 2.70k]
+
  Branch (163:12): [True: 0, False: 28]
+
164
7
            self.last_header_height = current_header_height;
165
7
            self.layout_template = LayoutTemplate::from_options(&self.options, current_header_height);
166
2.73k
        }
167
2.73k
        self.layout = self.layout_template.apply(area);
168
169
2.73k
        if let Some(header_area) = self.layout.header_area {
  Branch (169:16): [True: 99, False: 2.61k]
+
  Branch (169:16): [True: 0, False: 28]
+
170
99
            res |= self.header.render(header_area, buf);
171
2.63k
        }
172
173
2.73k
        if let Some(preview_area) = self.layout.preview_area {
  Branch (173:16): [True: 234, False: 2.47k]
+
  Branch (173:16): [True: 2, False: 26]
+
174
236
            res |= self.preview.render(preview_area, buf);
175
2.50k
        }
176
177
2.73k
        res |= self.item_list.render(self.layout.list_area, buf);
178
179
        // Render the input after the item list so that the status shows correct information.
180
2.73k
        self.input.status_info = if self.options.info.display == InfoDisplay::Hidden {
  Branch (180:37): [True: 59, False: 2.65k]
+
  Branch (180:37): [True: 1, False: 27]
 
181
60
            None
182
        } else {
183
            Some(StatusInfo {
184
2.67k
                total: self.item_pool.len(),
185
2.67k
                matched: self.item_list.count(),
186
2.67k
                processed: self.matcher_control.get_num_processed(),
187
2.67k
                show_spinner: self.show_spinner,
188
2.67k
                matcher_mode: if self.options.regex {
  Branch (188:34): [True: 3, False: 2.64k]
-
  Branch (188:34): [True: 1, False: 25]
-
189
4
                    "RE".to_string()
190
                } else {
191
2.66k
                    String::new()
192
                },
193
2.67k
                multi_selection: self.options.multi,
194
2.67k
                selected: self.item_list.selection.len(),
195
2.67k
                current_item_idx: self.item_list.current,
196
2.67k
                hscroll_offset: i64::from(self.item_list.manual_hscroll),
197
2.67k
                start: Some(self.spinner_start),
198
2.67k
                inline_separator: self
199
2.67k
                    .options
200
2.67k
                    .info
201
2.67k
                    .separator()
202
2.67k
                    .unwrap_or(super::statusline::DEFAULT_SEPARATOR)
203
2.67k
                    .to_string(),
204
            })
205
        };
206
2.73k
        res |= self.input.render(self.layout.input_area, buf);
207
208
        // Cursor position needs to account for input border and title.
209
2.73k
        self.cursor_pos = (
210
2.73k
            self.layout.input_area.x + self.input.cursor_pos() + u16::from(has_border),
211
2.73k
            self.layout.input_area.y
212
2.73k
                + u16::from(!(self.options.layout == TuiLayout::Reverse && 
self.options.border.is_none()52
)),
  Branch (212:31): [True: 51, False: 2.65k]
-
  Branch (212:31): [True: 1, False: 26]
-
213
        );
214
2.73k
        if res.run_preview {
  Branch (214:12): [True: 373, False: 2.33k]
-
  Branch (214:12): [True: 0, False: 27]
-
215
373
            self.pending_preview_run = true;
216
2.35k
        }
217
2.73k
    }
218
}
219
220
impl Default for App {
221
137
    fn default() -> Self {
222
137
        let theme = Arc::new(crate::theme::ColorTheme::default());
223
137
        let opts = SkimOptions::default();
224
137
        let header = Header::from_options(&opts, theme.clone());
225
137
        let initial_header_height = header.height();
226
137
        let layout_template = LayoutTemplate::from_options(&opts, initial_header_height);
227
137
        let layout = layout_template.apply(Rect::default());
228
137
        let (reader_threads, matcher_threads) = thread_pool::partition_threads(*NUM_THREADS);
229
137
        Self {
230
137
            input: Input::from_options(&opts, theme.clone()),
231
137
            preview: Preview::from_options(&opts, theme.clone()),
232
137
            header,
233
137
            item_list: ItemList::from_options(&opts, theme.clone()),
234
137
            matcher_pool: Arc::new(ThreadPool::new(matcher_threads)),
235
137
            reader_pool: Arc::new(ThreadPool::new(reader_threads)),
236
137
            item_pool: Arc::default(),
237
137
            theme,
238
137
            should_quit: false,
239
137
            final_action: None,
240
137
            cursor_pos: (0, 0),
241
137
            matcher: Matcher::builder(Rc::new(ExactOrFuzzyEngineFactory::builder().build()))
242
137
                .case(crate::CaseMatching::default())
243
137
                .build(),
244
137
            yank_register: String::new(),
245
137
            matcher_control: MatcherControl::default(),
246
137
            matcher_timer: std::time::Instant::now(),
247
137
            last_matcher_restart: std::time::Instant::now(),
248
137
            pending_matcher_restart: false,
249
137
            needs_render: Arc::new(AtomicBool::new(true)),
250
137
            last_render_timer: std::time::Instant::now()
251
137
                .checked_sub(std::time::Duration::from_secs(1))
252
137
                .unwrap(),
253
137
            // spinner initial state
254
137
            spinner_last_change: std::time::Instant::now(),
255
137
            show_spinner: false,
256
137
            spinner_start: std::time::Instant::now(),
257
137
            query_history: Vec::new(),
258
137
            history_index: None,
259
137
            saved_input: String::new(),
260
137
            cmd_history: Vec::new(),
261
137
            cmd_history_index: None,
262
137
            saved_cmd_input: String::new(),
263
137
            options: opts,
264
137
            cmd: String::new(),
265
137
            last_header_height: initial_header_height,
266
137
            layout_template,
267
137
            layout,
268
137
            last_preview_spawn: std::time::Instant::now(),
269
137
            pending_preview_run: false,
270
137
            reader_timer: std::time::Instant::now(),
271
137
            items_just_updated: false,
272
137
            currently_scrolling: false,
273
137
            last_left_click: std::time::Instant::now()
274
137
                .checked_sub(DOUBLE_CLICK_INTERVAL * 2)
275
137
                .unwrap(),
276
137
            reader_done: false,
277
137
            load_event_fired: false,
278
137
            result_pending: false,
279
137
            last_focused: None,
280
137
        }
281
137
    }
282
}
283
284
impl App {
285
    /// Creates a new App from skim options.
286
    ///
287
    /// # Panics
288
    ///
289
    /// Panics if the thread pool cannot be built (system resource exhaustion).
290
    #[must_use]
291
405
    pub fn from_options(options: SkimOptions, theme: Arc<crate::theme::ColorTheme>, cmd: String) -> Self {
292
405
        let header = Header::from_options(&options, theme.clone());
293
405
        let initial_header_height = header.height();
294
405
        let layout_template = LayoutTemplate::from_options(&options, initial_header_height);
295
405
        let layout = layout_template.apply(Rect::default());
296
405
        let (mut reader_threads, mut matcher_threads) = thread_pool::partition_threads(*NUM_THREADS);
297
405
        if options.flags.contains(&crate::options::FeatureFlag::SingleReader) {
  Branch (297:12): [True: 0, False: 388]
+
  Branch (188:34): [True: 1, False: 26]
+
189
4
                    "RE".to_string()
190
                } else {
191
2.67k
                    String::new()
192
                },
193
2.67k
                multi_selection: self.options.multi,
194
2.67k
                selected: self.item_list.selection.len(),
195
2.67k
                current_item_idx: self.item_list.current,
196
2.67k
                hscroll_offset: i64::from(self.item_list.manual_hscroll),
197
2.67k
                start: Some(self.spinner_start),
198
2.67k
                inline_separator: self
199
2.67k
                    .options
200
2.67k
                    .info
201
2.67k
                    .separator()
202
2.67k
                    .unwrap_or(super::statusline::DEFAULT_SEPARATOR)
203
2.67k
                    .to_string(),
204
            })
205
        };
206
2.73k
        res |= self.input.render(self.layout.input_area, buf);
207
208
        // Cursor position needs to account for input border and title.
209
2.73k
        self.cursor_pos = (
210
2.73k
            self.layout.input_area.x + self.input.cursor_pos() + u16::from(has_border),
211
2.73k
            self.layout.input_area.y
212
2.73k
                + u16::from(!(self.options.layout == TuiLayout::Reverse && 
self.options.border.is_none()49
)),
  Branch (212:31): [True: 48, False: 2.66k]
+
  Branch (212:31): [True: 1, False: 27]
+
213
        );
214
2.73k
        if res.run_preview {
  Branch (214:12): [True: 375, False: 2.33k]
+
  Branch (214:12): [True: 0, False: 28]
+
215
375
            self.pending_preview_run = true;
216
2.36k
        }
217
2.73k
    }
218
}
219
220
impl Default for App {
221
137
    fn default() -> Self {
222
137
        let theme = Arc::new(crate::theme::ColorTheme::default());
223
137
        let opts = SkimOptions::default();
224
137
        let header = Header::from_options(&opts, theme.clone());
225
137
        let initial_header_height = header.height();
226
137
        let layout_template = LayoutTemplate::from_options(&opts, initial_header_height);
227
137
        let layout = layout_template.apply(Rect::default());
228
137
        let (reader_threads, matcher_threads) = thread_pool::partition_threads(*NUM_THREADS);
229
137
        Self {
230
137
            input: Input::from_options(&opts, theme.clone()),
231
137
            preview: Preview::from_options(&opts, theme.clone()),
232
137
            header,
233
137
            item_list: ItemList::from_options(&opts, theme.clone()),
234
137
            matcher_pool: Arc::new(ThreadPool::new(matcher_threads)),
235
137
            reader_pool: Arc::new(ThreadPool::new(reader_threads)),
236
137
            item_pool: Arc::default(),
237
137
            theme,
238
137
            should_quit: false,
239
137
            final_action: None,
240
137
            cursor_pos: (0, 0),
241
137
            matcher: Matcher::builder(Rc::new(ExactOrFuzzyEngineFactory::builder().build()))
242
137
                .case(crate::CaseMatching::default())
243
137
                .build(),
244
137
            yank_register: String::new(),
245
137
            matcher_control: MatcherControl::default(),
246
137
            matcher_timer: std::time::Instant::now(),
247
137
            last_matcher_restart: std::time::Instant::now(),
248
137
            pending_matcher_restart: false,
249
137
            needs_render: Arc::new(AtomicBool::new(true)),
250
137
            last_render_timer: std::time::Instant::now()
251
137
                .checked_sub(std::time::Duration::from_secs(1))
252
137
                .unwrap(),
253
137
            // spinner initial state
254
137
            spinner_last_change: std::time::Instant::now(),
255
137
            show_spinner: false,
256
137
            spinner_start: std::time::Instant::now(),
257
137
            query_history: Vec::new(),
258
137
            history_index: None,
259
137
            saved_input: String::new(),
260
137
            cmd_history: Vec::new(),
261
137
            cmd_history_index: None,
262
137
            saved_cmd_input: String::new(),
263
137
            options: opts,
264
137
            cmd: String::new(),
265
137
            last_header_height: initial_header_height,
266
137
            layout_template,
267
137
            layout,
268
137
            last_preview_spawn: std::time::Instant::now(),
269
137
            pending_preview_run: false,
270
137
            reader_timer: std::time::Instant::now(),
271
137
            items_just_updated: false,
272
137
            currently_scrolling: false,
273
137
            last_left_click: std::time::Instant::now()
274
137
                .checked_sub(DOUBLE_CLICK_INTERVAL * 2)
275
137
                .unwrap(),
276
137
            reader_done: false,
277
137
            load_event_fired: false,
278
137
            result_pending: false,
279
137
            last_focused: None,
280
137
        }
281
137
    }
282
}
283
284
impl App {
285
    /// Creates a new App from skim options.
286
    ///
287
    /// # Panics
288
    ///
289
    /// Panics if the thread pool cannot be built (system resource exhaustion).
290
    #[must_use]
291
404
    pub fn from_options(options: SkimOptions, theme: Arc<crate::theme::ColorTheme>, cmd: String) -> Self {
292
404
        let header = Header::from_options(&options, theme.clone());
293
404
        let initial_header_height = header.height();
294
404
        let layout_template = LayoutTemplate::from_options(&options, initial_header_height);
295
404
        let layout = layout_template.apply(Rect::default());
296
404
        let (mut reader_threads, mut matcher_threads) = thread_pool::partition_threads(*NUM_THREADS);
297
404
        if options.flags.contains(&crate::options::FeatureFlag::SingleReader) {
  Branch (297:12): [True: 0, False: 387]
 
  Branch (297:12): [True: 1, False: 16]
-
298
1
            reader_threads = 1;
299
404
        }
300
405
        if options.flags.contains(&crate::options::FeatureFlag::SingleMatcher) {
  Branch (300:12): [True: 0, False: 388]
+
298
1
            reader_threads = 1;
299
403
        }
300
404
        if options.flags.contains(&crate::options::FeatureFlag::SingleMatcher) {
  Branch (300:12): [True: 0, False: 387]
 
  Branch (300:12): [True: 1, False: 16]
-
301
1
            matcher_threads = 1;
302
404
        }
303
405
        Self {
304
405
            input: Input::from_options(&options, theme.clone()),
305
405
            preview: Preview::from_options(&options, theme.clone()),
306
405
            header,
307
405
            matcher_pool: Arc::new(ThreadPool::new(matcher_threads)),
308
405
            reader_pool: Arc::new(ThreadPool::new(reader_threads)),
309
405
            item_pool: Arc::new(ItemPool::from_options(&options)),
310
405
            item_list: ItemList::from_options(&options, theme.clone()),
311
405
            theme,
312
405
            should_quit: false,
313
405
            final_action: None,
314
405
            cursor_pos: (0, 0),
315
405
            matcher: Matcher::from_options(&options),
316
405
            yank_register: String::new(),
317
405
            matcher_control: MatcherControl::default(),
318
405
            reader_timer: std::time::Instant::now(),
319
405
            matcher_timer: std::time::Instant::now(),
320
405
            last_matcher_restart: std::time::Instant::now(),
321
405
            pending_matcher_restart: false,
322
405
            needs_render: Arc::new(AtomicBool::new(true)),
323
405
            last_render_timer: std::time::Instant::now()
324
405
                .checked_sub(std::time::Duration::from_secs(1))
325
405
                .unwrap(),
326
405
            // spinner initial state
327
405
            spinner_last_change: std::time::Instant::now(),
328
405
            show_spinner: false,
329
405
            spinner_start: std::time::Instant::now(),
330
405
            items_just_updated: false,
331
405
            query_history: options.query_history.clone(),
332
405
            history_index: None,
333
405
            saved_input: String::new(),
334
405
            cmd_history: options.cmd_history.clone(),
335
405
            cmd_history_index: None,
336
405
            saved_cmd_input: String::new(),
337
405
            options,
338
405
            cmd,
339
405
            last_header_height: initial_header_height,
340
405
            layout_template,
341
405
            layout,
342
405
            last_preview_spawn: std::time::Instant::now()
343
405
                .checked_sub(std::time::Duration::from_secs(1))
344
405
                .unwrap(),
345
405
            pending_preview_run: false,
346
405
            currently_scrolling: false,
347
405
            last_left_click: std::time::Instant::now()
348
405
                .checked_sub(DOUBLE_CLICK_INTERVAL * 2)
349
405
                .unwrap(),
350
405
            reader_done: false,
351
405
            load_event_fired: false,
352
405
            result_pending: false,
353
405
            last_focused: None,
354
405
        }
355
405
    }
356
357
    /// Rebuild the layout template for the given terminal dimensions.
358
    ///
359
    /// The template encodes all option-derived constraints; `apply` in render
360
    /// is then a cheap set of rect splits with no option inspection.
361
    /// Called on `Event::Resize(cols, rows)` and whenever a layout-affecting
362
    /// option changes (e.g. `TogglePreview`).
363
4
    pub fn resize(&mut self, cols: u16, rows: u16) {
364
4
        self.layout_template = LayoutTemplate::from_options(&self.options, self.header.height());
365
4
        self.layout = self.layout_template.apply(Rect::new(0, 0, cols, rows));
366
4
    }
367
368
    /// Calculate preview offset from offset expression (e.g., "+123", "+{2}", "+{2}-2")
369
10
    fn calculate_preview_offset(&self, offset_expr: &str) -> u16 {
370
        // Remove the leading '+'
371
10
        let expr = offset_expr.trim_start_matches('+');
372
373
        // Substitute field placeholders using printf
374
10
        let substituted = self.expand_cmd(expr, true);
375
        // Evaluate the expression (handle simple arithmetic like "321-2")
376
10
        if let Some((left, right)) = substituted.split_once('-') {
  Branch (376:16): [True: 2, False: 4]
+
301
1
            matcher_threads = 1;
302
403
        }
303
404
        Self {
304
404
            input: Input::from_options(&options, theme.clone()),
305
404
            preview: Preview::from_options(&options, theme.clone()),
306
404
            header,
307
404
            matcher_pool: Arc::new(ThreadPool::new(matcher_threads)),
308
404
            reader_pool: Arc::new(ThreadPool::new(reader_threads)),
309
404
            item_pool: Arc::new(ItemPool::from_options(&options)),
310
404
            item_list: ItemList::from_options(&options, theme.clone()),
311
404
            theme,
312
404
            should_quit: false,
313
404
            final_action: None,
314
404
            cursor_pos: (0, 0),
315
404
            matcher: Matcher::from_options(&options),
316
404
            yank_register: String::new(),
317
404
            matcher_control: MatcherControl::default(),
318
404
            reader_timer: std::time::Instant::now(),
319
404
            matcher_timer: std::time::Instant::now(),
320
404
            last_matcher_restart: std::time::Instant::now(),
321
404
            pending_matcher_restart: false,
322
404
            needs_render: Arc::new(AtomicBool::new(true)),
323
404
            last_render_timer: std::time::Instant::now()
324
404
                .checked_sub(std::time::Duration::from_secs(1))
325
404
                .unwrap(),
326
404
            // spinner initial state
327
404
            spinner_last_change: std::time::Instant::now(),
328
404
            show_spinner: false,
329
404
            spinner_start: std::time::Instant::now(),
330
404
            items_just_updated: false,
331
404
            query_history: options.query_history.clone(),
332
404
            history_index: None,
333
404
            saved_input: String::new(),
334
404
            cmd_history: options.cmd_history.clone(),
335
404
            cmd_history_index: None,
336
404
            saved_cmd_input: String::new(),
337
404
            options,
338
404
            cmd,
339
404
            last_header_height: initial_header_height,
340
404
            layout_template,
341
404
            layout,
342
404
            last_preview_spawn: std::time::Instant::now()
343
404
                .checked_sub(std::time::Duration::from_secs(1))
344
404
                .unwrap(),
345
404
            pending_preview_run: false,
346
404
            currently_scrolling: false,
347
404
            last_left_click: std::time::Instant::now()
348
404
                .checked_sub(DOUBLE_CLICK_INTERVAL * 2)
349
404
                .unwrap(),
350
404
            reader_done: false,
351
404
            load_event_fired: false,
352
404
            result_pending: false,
353
404
            last_focused: None,
354
404
        }
355
404
    }
356
357
    /// Rebuild the layout template for the given terminal dimensions.
358
    ///
359
    /// The template encodes all option-derived constraints; `apply` in render
360
    /// is then a cheap set of rect splits with no option inspection.
361
    /// Called on `Event::Resize(cols, rows)` and whenever a layout-affecting
362
    /// option changes (e.g. `TogglePreview`).
363
4
    pub fn resize(&mut self, cols: u16, rows: u16) {
364
4
        self.layout_template = LayoutTemplate::from_options(&self.options, self.header.height());
365
4
        self.layout = self.layout_template.apply(Rect::new(0, 0, cols, rows));
366
4
    }
367
368
    /// Calculate preview offset from offset expression (e.g., "+123", "+{2}", "+{2}-2")
369
10
    fn calculate_preview_offset(&self, offset_expr: &str) -> u16 {
370
        // Remove the leading '+'
371
10
        let expr = offset_expr.trim_start_matches('+');
372
373
        // Substitute field placeholders using printf
374
10
        let substituted = self.expand_cmd(expr, true);
375
        // Evaluate the expression (handle simple arithmetic like "321-2")
376
10
        if let Some((left, right)) = substituted.split_once('-') {
  Branch (376:16): [True: 2, False: 4]
 
  Branch (376:16): [True: 2, False: 2]
 
377
11
            let left_val = 
left4
.trim_matches(|x: char| !x.is_numeric()).parse::<u16>().unwrap_or(
04
);
378
4
            let right_val = right
379
4
                .trim_matches(|x: char| !x.is_numeric())
380
                .parse::<u16>()
381
4
                .unwrap_or(0);
382
4
            left_val.saturating_sub(right_val)
383
6
        } else if let Some((left, right)) = substituted.split_once('+') {
  Branch (383:23): [True: 0, False: 4]
 
  Branch (383:23): [True: 1, False: 1]
-
384
1
            let left_val = left.trim_matches(|x: char| !x.is_numeric()).parse::<u16>().unwrap_or(0);
385
1
            let right_val = right
386
1
                .trim_matches(|x: char| !x.is_numeric())
387
                .parse::<u16>()
388
1
                .unwrap_or(0);
389
1
            left_val.saturating_add(right_val)
390
        } else {
391
5
            substituted
392
14
                .trim_matches(|x: char| !x.is_numeric())
393
                .parse::<u16>()
394
5
                .unwrap_or(0)
395
        }
396
10
    }
397
398
93
    fn needs_render(&mut self) {
399
93
        self.needs_render.store(true, Ordering::Relaxed);
400
93
    }
401
402
    /// Call after items are added or filtered (e.g., `Event::NewItem`, matcher completes)
403
4
    fn on_items_updated(&mut self) {
404
4
        self.pending_matcher_restart = true;
405
4
        trace!("Got new items, len {}", 
self.item_pool.len()0
);
406
        // mark reader activity and reset reader timer
407
4
        self.reader_timer = std::time::Instant::now();
408
4
        self.items_just_updated = true;
409
4
    }
410
411
    /// Call after selection changes (e.g., selection actions, `Event::Key`).
412
    ///
413
    /// Emits the `focus` event when the focused item actually changed, so a
414
    /// `focus:<action>` binding runs on cursor movement.
415
154
    fn on_selection_changed(&mut self) -> Vec<Event> {
416
154
        let mut events = vec![Event::RunPreview];
417
154
        events.extend(self.take_focus_event());
418
154
        events
419
154
    }
420
421
    /// Returns a `focus` event if the focused item changed since the last call,
422
    /// updating the tracked item. Used by [`Self::on_selection_changed`].
423
2.86k
    fn take_focus_event(&mut self) -> Option<Event> {
424
2.86k
        let focused = self.item_list.selected().map(|m| 
m2.21k
.item);
425
2.86k
        let changed = match (&self.last_focused, &focused) {
426
1.87k
            (Some(prev), Some(curr)) => !Arc::ptr_eq(prev, curr),
427
611
            (None, None) => false,
428
375
            _ => true,
429
        };
430
2.86k
        if changed {
  Branch (430:12): [True: 450, False: 2.35k]
-
  Branch (430:12): [True: 44, False: 14]
-
431
494
            self.last_focused = focused;
432
494
            Some(Event::Key(SkimEvent::Focus.into()))
433
        } else {
434
2.36k
            None
435
        }
436
2.86k
    }
437
438
    /// Polls the async reader/matcher state and returns any newly-due
439
    /// completion events (`load`, `result`, `zero`, `one`).
440
    ///
441
    /// Each is edge-triggered by a flag so it fires once per read / search:
442
    /// `load` when the reader finishes, `result` (plus `zero`/`one` from the
443
    /// matcher's authoritative count) when a search completes. Called from the
444
    /// heartbeat.
445
1.77k
    fn poll_completion_events(&mut self) -> Vec<Event> {
446
1.77k
        let mut events = Vec::new();
447
448
1.77k
        if self.reader_done
  Branch (448:12): [True: 1.76k, False: 0]
+
384
1
            let left_val = left.trim_matches(|x: char| !x.is_numeric()).parse::<u16>().unwrap_or(0);
385
1
            let right_val = right
386
1
                .trim_matches(|x: char| !x.is_numeric())
387
                .parse::<u16>()
388
1
                .unwrap_or(0);
389
1
            left_val.saturating_add(right_val)
390
        } else {
391
5
            substituted
392
14
                .trim_matches(|x: char| !x.is_numeric())
393
                .parse::<u16>()
394
5
                .unwrap_or(0)
395
        }
396
10
    }
397
398
93
    fn needs_render(&mut self) {
399
93
        self.needs_render.store(true, Ordering::Relaxed);
400
93
    }
401
402
    /// Call after items are added or filtered (e.g., `Event::NewItem`, matcher completes)
403
4
    fn on_items_updated(&mut self) {
404
4
        self.pending_matcher_restart = true;
405
4
        trace!("Got new items, len {}", 
self.item_pool.len()0
);
406
        // mark reader activity and reset reader timer
407
4
        self.reader_timer = std::time::Instant::now();
408
4
        self.items_just_updated = true;
409
4
    }
410
411
    /// Call after selection changes (e.g., selection actions, `Event::Key`).
412
    ///
413
    /// Emits the `focus` event when the focused item actually changed, so a
414
    /// `focus:<action>` binding runs on cursor movement.
415
154
    fn on_selection_changed(&mut self) -> Vec<Event> {
416
154
        let mut events = vec![Event::RunPreview];
417
154
        events.extend(self.take_focus_event());
418
154
        events
419
154
    }
420
421
    /// Returns a `focus` event if the focused item changed since the last call,
422
    /// updating the tracked item. Used by [`Self::on_selection_changed`].
423
2.86k
    fn take_focus_event(&mut self) -> Option<Event> {
424
2.86k
        let focused = self.item_list.selected().map(|m| 
m2.22k
.item);
425
2.86k
        let changed = match (&self.last_focused, &focused) {
426
1.88k
            (Some(prev), Some(curr)) => !Arc::ptr_eq(prev, curr),
427
607
            (None, None) => false,
428
376
            _ => true,
429
        };
430
2.86k
        if changed {
  Branch (430:12): [True: 452, False: 2.35k]
+
  Branch (430:12): [True: 44, False: 15]
+
431
496
            self.last_focused = focused;
432
496
            Some(Event::Key(SkimEvent::Focus.into()))
433
        } else {
434
2.37k
            None
435
        }
436
2.86k
    }
437
438
    /// Polls the async reader/matcher state and returns any newly-due
439
    /// completion events (`load`, `result`, `zero`, `one`).
440
    ///
441
    /// Each is edge-triggered by a flag so it fires once per read / search:
442
    /// `load` when the reader finishes, `result` (plus `zero`/`one` from the
443
    /// matcher's authoritative count) when a search completes. Called from the
444
    /// heartbeat.
445
1.77k
    fn poll_completion_events(&mut self) -> Vec<Event> {
446
1.77k
        let mut events = Vec::new();
447
448
1.77k
        if self.reader_done
  Branch (448:12): [True: 1.76k, False: 0]
 
  Branch (448:12): [True: 4, False: 2]
-
449
1.77k
            && !self.load_event_fired
  Branch (449:16): [True: 434, False: 1.33k]
+
449
1.77k
            && !self.load_event_fired
  Branch (449:16): [True: 432, False: 1.33k]
 
  Branch (449:16): [True: 3, False: 1]
-
450
437
            && self.matcher_control.stopped()
  Branch (450:16): [True: 434, False: 0]
+
450
435
            && self.matcher_control.stopped()
  Branch (450:16): [True: 432, False: 0]
 
  Branch (450:16): [True: 3, False: 0]
-
451
437
            && self.item_pool.num_not_taken() == 0
  Branch (451:16): [True: 388, False: 46]
+
451
435
            && self.item_pool.num_not_taken() == 0
  Branch (451:16): [True: 389, False: 43]
 
  Branch (451:16): [True: 2, False: 1]
-
452
        {
453
390
            self.load_event_fired = true;
454
390
            events.push(Event::Key(SkimEvent::Load.into()));
455
1.38k
        }
456
457
1.77k
        if self.result_pending && 
self.matcher_control.stopped()741
{
  Branch (457:12): [True: 738, False: 1.03k]
-  Branch (457:35): [True: 737, False: 1]
+
452
        {
453
391
            self.load_event_fired = true;
454
391
            events.push(Event::Key(SkimEvent::Load.into()));
455
1.38k
        }
456
457
1.77k
        if self.result_pending && 
self.matcher_control.stopped()743
{
  Branch (457:12): [True: 740, False: 1.02k]
+  Branch (457:35): [True: 739, False: 1]
 
  Branch (457:12): [True: 3, False: 3]
   Branch (457:35): [True: 2, False: 1]
-
458
739
            self.result_pending = false;
459
739
            events.push(Event::Key(SkimEvent::Result.into()));
460
739
            if self.reader_done {
  Branch (460:16): [True: 737, False: 0]
+
458
741
            self.result_pending = false;
459
741
            events.push(Event::Key(SkimEvent::Result.into()));
460
741
            if self.reader_done {
  Branch (460:16): [True: 739, False: 0]
 
  Branch (460:16): [True: 1, False: 1]
-
461
738
                match self.matcher_control.get_num_matched() {
462
170
                    0 => events.push(Event::Key(SkimEvent::Zero.into())),
463
220
                    1 => events.push(Event::Key(SkimEvent::One.into())),
464
348
                    _ => {}
465
                }
466
1
            }
467
1.03k
        }
468
469
1.77k
        events
470
1.77k
    }
471
472
    /// Call when query changes (e.g., `AddChar`, `BackwardDeleteChar`, etc.)
473
394
    fn on_query_changed(&mut self) -> Vec<Event> {
474
        // In interactive mode with --cmd, execute the command with {} substitution
475
394
        if self.options.interactive && 
self.options.cmd.is_some()46
{
  Branch (475:12): [True: 45, False: 329]
+
461
740
                match self.matcher_control.get_num_matched() {
462
169
                    0 => events.push(Event::Key(SkimEvent::Zero.into())),
463
222
                    1 => events.push(Event::Key(SkimEvent::One.into())),
464
349
                    _ => {}
465
                }
466
1
            }
467
1.03k
        }
468
469
1.77k
        events
470
1.77k
    }
471
472
    /// Call when query changes (e.g., `AddChar`, `BackwardDeleteChar`, etc.)
473
397
    fn on_query_changed(&mut self) -> Vec<Event> {
474
        // In interactive mode with --cmd, execute the command with {} substitution
475
397
        if self.options.interactive && 
self.options.cmd.is_some()46
{
  Branch (475:12): [True: 45, False: 332]
   Branch (475:40): [True: 43, False: 2]
 
  Branch (475:12): [True: 1, False: 19]
   Branch (475:40): [True: 0, False: 1]
-
476
43
            let expanded_cmd = self.expand_cmd(&self.cmd, true);
477
43
            return vec![Event::Reload(expanded_cmd)];
478
351
        }
479
351
        self.restart_matcher_debounced();
480
351
        vec![
481
351
            Event::Key(crate::binds::SkimEvent::Change.into()), // fire the `change` event binding
482
351
            Event::RunPreview,
483
        ]
484
394
    }
485
486
1.77k
    fn update_spinner(&mut self) {
487
1.77k
        let matcher_running = !self.matcher_control.stopped();
488
1.77k
        let time_since_match = self.matcher_timer.elapsed();
489
1.77k
        let reading = self.item_pool.num_not_taken() != 0;
490
491
1.77k
        let should_show_spinner = reading || (
matcher_running1.72k
&&
time_since_match.as_millis() > MATCHER_DEBOUNCE_MS0
);
  Branch (491:35): [True: 46, False: 1.72k]
+
476
43
            let expanded_cmd = self.expand_cmd(&self.cmd, true);
477
43
            return vec![Event::Reload(expanded_cmd)];
478
354
        }
479
354
        self.restart_matcher_debounced();
480
354
        vec![
481
354
            Event::Key(crate::binds::SkimEvent::Change.into()), // fire the `change` event binding
482
354
            Event::RunPreview,
483
        ]
484
397
    }
485
486
1.76k
    fn update_spinner(&mut self) {
487
1.76k
        let matcher_running = !self.matcher_control.stopped();
488
1.76k
        let time_since_match = self.matcher_timer.elapsed();
489
1.76k
        let reading = self.item_pool.num_not_taken() != 0;
490
491
1.76k
        let should_show_spinner = reading || (
matcher_running1.72k
&&
time_since_match.as_millis() > MATCHER_DEBOUNCE_MS0
);
  Branch (491:35): [True: 43, False: 1.72k]
   Branch (491:47): [True: 0, False: 1.72k]
 
  Branch (491:35): [True: 2, False: 1]
   Branch (491:47): [True: 0, False: 1]
-
492
493
1.77k
        if should_show_spinner && 
!self.show_spinner48
{
  Branch (493:12): [True: 46, False: 1.72k]
-  Branch (493:35): [True: 20, False: 26]
+
492
493
1.76k
        if should_show_spinner && 
!self.show_spinner45
{
  Branch (493:12): [True: 43, False: 1.72k]
+  Branch (493:35): [True: 19, False: 24]
 
  Branch (493:12): [True: 2, False: 1]
   Branch (493:35): [True: 2, False: 0]
-
494
22
            self.toggle_spinner();
495
1.74k
        } else if !should_show_spinner && 
self.show_spinner1.72k
{
  Branch (495:19): [True: 1.72k, False: 26]
-  Branch (495:43): [True: 4, False: 1.71k]
+
494
21
            self.toggle_spinner();
495
1.74k
        } else if !should_show_spinner && 
self.show_spinner1.72k
{
  Branch (495:19): [True: 1.72k, False: 24]
+  Branch (495:43): [True: 8, False: 1.71k]
 
  Branch (495:19): [True: 1, False: 0]
   Branch (495:43): [True: 1, False: 0]
-
496
            // Hide spinner only after grace period to avoid flickering
497
5
            if self.spinner_last_change.elapsed().as_millis() >= HIDE_GRACE_MS {
  Branch (497:16): [True: 0, False: 4]
+
496
            // Hide spinner only after grace period to avoid flickering
497
9
            if self.spinner_last_change.elapsed().as_millis() >= HIDE_GRACE_MS {
  Branch (497:16): [True: 0, False: 8]
 
  Branch (497:16): [True: 1, False: 0]
-
498
1
                self.toggle_spinner();
499
4
            }
500
1.74k
        }
501
1.77k
        if self.show_spinner {
  Branch (501:12): [True: 50, False: 1.71k]
+
498
1
                self.toggle_spinner();
499
8
            }
500
1.73k
        }
501
1.76k
        if self.show_spinner {
  Branch (501:12): [True: 51, False: 1.71k]
 
  Branch (501:12): [True: 2, False: 1]
-
502
52
            self.needs_render.store(true, Ordering::Relaxed);
503
1.71k
        }
504
1.77k
    }
505
506
    #[allow(clippy::too_many_lines)]
507
1.37k
    fn run_preview<B: Backend>(&mut self, tui: &mut Tui<B>) -> Result<()>
508
1.37k
    where
509
1.37k
        B::Error: Send + Sync + 'static,
510
    {
511
        // Debounce preview spawning to prevent overwhelming the system during rapid scrolling
512
        const DEBOUNCE_MS: u64 = 50;
513
1.37k
        let now = std::time::Instant::now();
514
1.37k
        let elapsed = now.duration_since(self.last_preview_spawn);
515
516
1.37k
        if elapsed.as_millis() < u128::from(DEBOUNCE_MS) {
  Branch (516:12): [True: 49, False: 58]
-
  Branch (516:12): [True: 0, False: 10]
+
502
53
            self.needs_render.store(true, Ordering::Relaxed);
503
1.71k
        }
504
1.76k
    }
505
506
    #[allow(clippy::too_many_lines)]
507
1.37k
    fn run_preview<B: Backend>(&mut self, tui: &mut Tui<B>) -> Result<()>
508
1.37k
    where
509
1.37k
        B::Error: Send + Sync + 'static,
510
    {
511
        // Debounce preview spawning to prevent overwhelming the system during rapid scrolling
512
        const DEBOUNCE_MS: u64 = 50;
513
1.37k
        let now = std::time::Instant::now();
514
1.37k
        let elapsed = now.duration_since(self.last_preview_spawn);
515
516
1.37k
        if elapsed.as_millis() < u128::from(DEBOUNCE_MS) {
  Branch (516:12): [True: 0, False: 10]
+
  Branch (516:12): [True: 67, False: 56]
+
  Branch (516:12): [True: 6, False: 10]
+
  Branch (516:12): [True: 65, False: 41]
+
  Branch (516:12): [True: 4, False: 9]
+
  Branch (516:12): [True: 10, False: 12]
+
  Branch (516:12): [True: 2, False: 2]
+
  Branch (516:12): [True: 0, False: 8]
+
  Branch (516:12): [True: 19, False: 48]
+
  Branch (516:12): [True: 0, False: 9]
+
  Branch (516:12): [True: 4, False: 9]
+
  Branch (516:12): [True: 29, False: 50]
+
  Branch (516:12): [True: 49, False: 58]
+
  Branch (516:12): [True: 171, False: 223]
+
  Branch (516:12): [True: 51, False: 61]
+
  Branch (516:12): [True: 0, False: 22]
+
  Branch (516:12): [True: 44, False: 51]
 
  Branch (516:12): [True: 81, False: 89]
 
  Branch (516:12): [True: 0, False: 9]
-
  Branch (516:12): [True: 10, False: 12]
-
  Branch (516:12): [True: 0, False: 22]
-
  Branch (516:12): [True: 0, False: 9]
-
  Branch (516:12): [True: 51, False: 61]
-
  Branch (516:12): [True: 67, False: 56]
-
  Branch (516:12): [True: 29, False: 50]
-
  Branch (516:12): [True: 6, False: 10]
-
  Branch (516:12): [True: 171, False: 223]
-
  Branch (516:12): [True: 44, False: 51]
-
  Branch (516:12): [True: 4, False: 9]
-
  Branch (516:12): [True: 2, False: 2]
-
  Branch (516:12): [True: 0, False: 5]
-
  Branch (516:12): [True: 19, False: 48]
-
  Branch (516:12): [True: 4, False: 9]
-
  Branch (516:12): [True: 65, False: 41]
-
517
            // Mark that we have a pending preview to run after the debounce period
518
602
            self.pending_preview_run = true;
519
602
            return Ok(());
520
774
        }
521
522
774
        self.pending_preview_run = false;
523
774
        self.last_preview_spawn = now;
524
525
774
        if let Some(preview_opt) = &self.options.preview
  Branch (525:16): [True: 0, False: 58]
+
517
            // Mark that we have a pending preview to run after the debounce period
518
602
            self.pending_preview_run = true;
519
602
            return Ok(());
520
777
        }
521
522
777
        self.pending_preview_run = false;
523
777
        self.last_preview_spawn = now;
524
525
777
        if let Some(preview_opt) = &self.options.preview
  Branch (525:16): [True: 0, False: 10]
+
  Branch (525:16): [True: 56, False: 0]
 
  Branch (525:16): [True: 0, False: 10]
-
  Branch (525:16): [True: 0, False: 89]
+
  Branch (525:16): [True: 0, False: 41]
 
  Branch (525:16): [True: 0, False: 9]
 
  Branch (525:16): [True: 0, False: 12]
-
  Branch (525:16): [True: 0, False: 22]
-
  Branch (525:16): [True: 0, False: 9]
-
  Branch (525:16): [True: 5, False: 56]
-
  Branch (525:16): [True: 56, False: 0]
-
  Branch (525:16): [True: 0, False: 50]
-
  Branch (525:16): [True: 0, False: 10]
-
  Branch (525:16): [True: 5, False: 218]
-
  Branch (525:16): [True: 0, False: 51]
-
  Branch (525:16): [True: 6, False: 3]
 
  Branch (525:16): [True: 2, False: 0]
-
  Branch (525:16): [True: 0, False: 5]
+
  Branch (525:16): [True: 0, False: 8]
 
  Branch (525:16): [True: 0, False: 48]
 
  Branch (525:16): [True: 0, False: 9]
-
  Branch (525:16): [True: 0, False: 41]
+
  Branch (525:16): [True: 6, False: 3]
+
  Branch (525:16): [True: 0, False: 50]
+
  Branch (525:16): [True: 0, False: 58]
+
  Branch (525:16): [True: 5, False: 218]
+
  Branch (525:16): [True: 5, False: 56]
+
  Branch (525:16): [True: 0, False: 22]
+
  Branch (525:16): [True: 0, False: 51]
+
  Branch (525:16): [True: 0, False: 89]
+
  Branch (525:16): [True: 0, False: 9]
 
526
74
            && let Some(item) = self.item_list.selected()
  Branch (526:20): [True: 0, False: 0]
-
  Branch (526:20): [True: 0, False: 0]
-
  Branch (526:20): [True: 0, False: 0]
-
  Branch (526:20): [True: 0, False: 0]
-
  Branch (526:20): [True: 0, False: 0]
-
  Branch (526:20): [True: 0, False: 0]
-
  Branch (526:20): [True: 0, False: 0]
-
  Branch (526:20): [True: 5, False: 0]
 
  Branch (526:20): [True: 56, False: 0]
 
  Branch (526:20): [True: 0, False: 0]
 
  Branch (526:20): [True: 0, False: 0]
-
  Branch (526:20): [True: 5, False: 0]
+
  Branch (526:20): [True: 0, False: 0]
+
  Branch (526:20): [True: 0, False: 0]
+
  Branch (526:20): [True: 2, False: 0]
+
  Branch (526:20): [True: 0, False: 0]
+
  Branch (526:20): [True: 0, False: 0]
 
  Branch (526:20): [True: 0, False: 0]
 
  Branch (526:20): [True: 6, False: 0]
-
  Branch (526:20): [True: 2, False: 0]
+
  Branch (526:20): [True: 0, False: 0]
+
  Branch (526:20): [True: 0, False: 0]
+
  Branch (526:20): [True: 5, False: 0]
+
  Branch (526:20): [True: 5, False: 0]
 
  Branch (526:20): [True: 0, False: 0]
 
  Branch (526:20): [True: 0, False: 0]
 
  Branch (526:20): [True: 0, False: 0]
 
  Branch (526:20): [True: 0, False: 0]
 
527
        {
528
74
            let selection: Vec<_> = self.item_list.selection.iter().map(|i| 
i.text()12
.into_owned()).collect();
529
74
            let selection_str: Vec<_> = selection.iter().map(std::string::String::as_str).collect();
530
74
            let selected = self.item_list.selected();
531
            let ctx = PreviewContext {
532
74
                query: &self.input.value,
533
74
                cmd_query: if self.options.interactive {
  Branch (533:31): [True: 0, False: 0]
-
  Branch (533:31): [True: 0, False: 0]
-
  Branch (533:31): [True: 0, False: 0]
-
  Branch (533:31): [True: 0, False: 0]
-
  Branch (533:31): [True: 0, False: 0]
-
  Branch (533:31): [True: 0, False: 0]
-
  Branch (533:31): [True: 0, False: 0]
-
  Branch (533:31): [True: 0, False: 5]
 
  Branch (533:31): [True: 0, False: 56]
 
  Branch (533:31): [True: 0, False: 0]
 
  Branch (533:31): [True: 0, False: 0]
-
  Branch (533:31): [True: 0, False: 5]
+
  Branch (533:31): [True: 0, False: 0]
+
  Branch (533:31): [True: 0, False: 0]
+
  Branch (533:31): [True: 0, False: 2]
+
  Branch (533:31): [True: 0, False: 0]
+
  Branch (533:31): [True: 0, False: 0]
 
  Branch (533:31): [True: 0, False: 0]
 
  Branch (533:31): [True: 0, False: 6]
-
  Branch (533:31): [True: 0, False: 2]
+
  Branch (533:31): [True: 0, False: 0]
+
  Branch (533:31): [True: 0, False: 0]
+
  Branch (533:31): [True: 0, False: 5]
+
  Branch (533:31): [True: 0, False: 5]
 
  Branch (533:31): [True: 0, False: 0]
 
  Branch (533:31): [True: 0, False: 0]
 
  Branch (533:31): [True: 0, False: 0]
 
  Branch (533:31): [True: 0, False: 0]
 
534
0
                    &self.input.value
535
                } else {
536
74
                    self.options.cmd_query.as_deref().unwrap_or(&self.input.value)
537
                },
538
74
                width: self.preview.cols as usize,
539
74
                height: self.preview.rows as usize,
540
74
                current_index: selected
541
74
                    .as_ref()
542
74
                    .map(|i| i.rank.index.unsigned_abs() as usize)
543
                    .unwrap_or_default(),
544
74
                current_selection: &selected.map(|i| i.text().into_owned()).unwrap_or_default(),
545
74
                selected_indices: &self
546
74
                    .item_list
547
74
                    .selection
548
74
                    .iter()
549
12
                    .map(|v| v.rank.index.unsigned_abs() as usize)
550
                    .collect::<Vec<_>>(),
551
74
                selections: &selection_str,
552
            };
553
74
            let preview = item.preview(ctx);
554
74
            let preview_ready = !matches!(
555
74
                preview,
556
74
                ItemPreview::Global | ItemPreview::Command(_) | ItemPreview::CommandWithPos(_, _)
557
74
            );
558
            #[cfg(feature = "image")]
559
74
            let quote_cmd = self.options.image.is_none();
560
            #[cfg(not(feature = "image"))]
561
            let quote_cmd = true;
562
74
            match preview {
563
1
                ItemPreview::Command(cmd) => self.preview.spawn(tui, &self.expand_cmd(&cmd, quote_cmd))
?0
,
564
                ItemPreview::Text(t) | ItemPreview::AnsiText(t) => {
565
1
                    self.preview.content(&t.bytes().collect::<Vec<_>>())
?0
;
566
                }
567
                ItemPreview::CommandWithPos(cmd, preview_position) => {
568
                    // Execute command and apply position after content is ready
569
2
                    self.preview.spawn(tui, &self.expand_cmd(&cmd, quote_cmd))
?0
;
570
                    // Apply position offsets
571
2
                    let v_scroll = match preview_position.v_scroll {
572
0
                        crate::tui::Size::Fixed(n) => n,
573
1
                        crate::tui::Size::Neg(n) => self.preview.rows.saturating_sub(n),
574
                        crate::tui::Size::Percent(p) => {
575
1
                            u16::try_from(u32::from(self.preview.rows) * u32::from(p) / 100).unwrap_or(u16::MAX)
576
                        }
577
                    };
578
2
                    let v_offset = match preview_position.v_offset {
579
0
                        crate::tui::Size::Fixed(n) => n,
580
1
                        crate::tui::Size::Neg(n) => self.preview.rows.saturating_sub(n),
581
                        crate::tui::Size::Percent(p) => {
582
1
                            u16::try_from(u32::from(self.preview.rows) * u32::from(p) / 100).unwrap_or(u16::MAX)
583
                        }
584
                    };
585
2
                    self.preview.scroll_y = usize::from(v_scroll);
586
2
                    self.preview.scroll_down(v_offset);
587
588
2
                    let h_scroll = match preview_position.h_scroll {
589
0
                        crate::tui::Size::Fixed(n) => n,
590
1
                        crate::tui::Size::Neg(n) => self.preview.cols.saturating_sub(n),
591
                        crate::tui::Size::Percent(p) => {
592
1
                            u16::try_from(u32::from(self.preview.cols) * u32::from(p) / 100).unwrap_or(u16::MAX)
593
                        }
594
                    };
595
2
                    let h_offset = match preview_position.h_offset {
596
1
                        crate::tui::Size::Fixed(n) => n,
597
1
                        crate::tui::Size::Neg(n) => self.preview.cols.saturating_sub(n),
598
                        crate::tui::Size::Percent(p) => {
599
0
                            u16::try_from(u32::from(self.preview.cols) * u32::from(p) / 100).unwrap_or(u16::MAX)
600
                        }
601
                    };
602
2
                    self.preview.scroll_x = usize::from(h_scroll).saturating_add(usize::from(h_offset));
603
                }
604
2
                ItemPreview::TextWithPos(t, preview_position) | ItemPreview::AnsiWithPos(t, preview_position) => self
605
2
                    .preview
606
2
                    .content_with_position(&t.bytes().collect::<Vec<_>>(), preview_position)
?0
,
607
68
                ItemPreview::Global => self.preview.spawn(tui, &self.expand_cmd(preview_opt, quote_cmd))
?0
,
608
            }
609
74
            if preview_ready {
  Branch (609:16): [True: 0, False: 0]
-
  Branch (609:16): [True: 0, False: 0]
-
  Branch (609:16): [True: 0, False: 0]
-
  Branch (609:16): [True: 0, False: 0]
-
  Branch (609:16): [True: 0, False: 0]
-
  Branch (609:16): [True: 0, False: 0]
-
  Branch (609:16): [True: 0, False: 0]
-
  Branch (609:16): [True: 0, False: 5]
 
  Branch (609:16): [True: 0, False: 56]
 
  Branch (609:16): [True: 0, False: 0]
 
  Branch (609:16): [True: 0, False: 0]
-
  Branch (609:16): [True: 0, False: 5]
 
  Branch (609:16): [True: 0, False: 0]
-
  Branch (609:16): [True: 3, False: 3]
+
  Branch (609:16): [True: 0, False: 0]
 
  Branch (609:16): [True: 0, False: 2]
 
  Branch (609:16): [True: 0, False: 0]
 
  Branch (609:16): [True: 0, False: 0]
 
  Branch (609:16): [True: 0, False: 0]
+
  Branch (609:16): [True: 3, False: 3]
 
  Branch (609:16): [True: 0, False: 0]
-
610
3
                let _ = tui.event_tx.try_send(Event::PreviewReady);
611
71
            }
612
700
        } else if let Some(cb) = &self.options.preview_fn {
  Branch (612:23): [True: 0, False: 58]
+
  Branch (609:16): [True: 0, False: 0]
+
  Branch (609:16): [True: 0, False: 5]
+
  Branch (609:16): [True: 0, False: 5]
+
  Branch (609:16): [True: 0, False: 0]
+
  Branch (609:16): [True: 0, False: 0]
+
  Branch (609:16): [True: 0, False: 0]
+
  Branch (609:16): [True: 0, False: 0]
+
610
3
                let _ = tui.event_tx.try_send(Event::PreviewReady);
611
71
            }
612
703
        } else if let Some(cb) = &self.options.preview_fn {
  Branch (612:23): [True: 0, False: 10]
+
  Branch (612:23): [True: 0, False: 0]
 
  Branch (612:23): [True: 0, False: 10]
-
  Branch (612:23): [True: 0, False: 89]
+
  Branch (612:23): [True: 0, False: 41]
 
  Branch (612:23): [True: 0, False: 9]
 
  Branch (612:23): [True: 0, False: 12]
-
  Branch (612:23): [True: 0, False: 22]
-
  Branch (612:23): [True: 0, False: 9]
-
  Branch (612:23): [True: 0, False: 56]
 
  Branch (612:23): [True: 0, False: 0]
-
  Branch (612:23): [True: 0, False: 50]
-
  Branch (612:23): [True: 0, False: 10]
-
  Branch (612:23): [True: 0, False: 218]
-
  Branch (612:23): [True: 0, False: 51]
-
  Branch (612:23): [True: 3, False: 0]
-
  Branch (612:23): [True: 0, False: 0]
-
  Branch (612:23): [True: 0, False: 5]
+
  Branch (612:23): [True: 0, False: 8]
 
  Branch (612:23): [True: 0, False: 48]
 
  Branch (612:23): [True: 0, False: 9]
-
  Branch (612:23): [True: 0, False: 41]
+
  Branch (612:23): [True: 3, False: 0]
+
  Branch (612:23): [True: 0, False: 50]
+
  Branch (612:23): [True: 0, False: 58]
+
  Branch (612:23): [True: 0, False: 218]
+
  Branch (612:23): [True: 0, False: 56]
+
  Branch (612:23): [True: 0, False: 22]
+
  Branch (612:23): [True: 0, False: 51]
+
  Branch (612:23): [True: 0, False: 89]
+
  Branch (612:23): [True: 0, False: 9]
 
613
            let selection: Vec<Arc<dyn SkimItem>>;
614
3
            if self.options.multi {
  Branch (614:16): [True: 0, False: 0]
 
  Branch (614:16): [True: 0, False: 0]
 
  Branch (614:16): [True: 0, False: 0]
@@ -180,15 +180,15 @@
 
  Branch (614:16): [True: 0, False: 0]
 
  Branch (614:16): [True: 0, False: 0]
 
  Branch (614:16): [True: 0, False: 0]
-
  Branch (614:16): [True: 0, False: 0]
-
  Branch (614:16): [True: 0, False: 0]
-
  Branch (614:16): [True: 0, False: 0]
 
  Branch (614:16): [True: 1, False: 2]
 
  Branch (614:16): [True: 0, False: 0]
 
  Branch (614:16): [True: 0, False: 0]
 
  Branch (614:16): [True: 0, False: 0]
 
  Branch (614:16): [True: 0, False: 0]
 
  Branch (614:16): [True: 0, False: 0]
+
  Branch (614:16): [True: 0, False: 0]
+
  Branch (614:16): [True: 0, False: 0]
+
  Branch (614:16): [True: 0, False: 0]
 
615
1
                selection = self.item_list.selection.iter().map(|i| i.item.clone()).collect();
616
2
            } else if let Some(sel) = self.item_list.selected() {
  Branch (616:27): [True: 0, False: 0]
 
  Branch (616:27): [True: 0, False: 0]
 
  Branch (616:27): [True: 0, False: 0]
@@ -199,209 +199,206 @@
 
  Branch (616:27): [True: 0, False: 0]
 
  Branch (616:27): [True: 0, False: 0]
 
  Branch (616:27): [True: 0, False: 0]
-
  Branch (616:27): [True: 0, False: 0]
-
  Branch (616:27): [True: 0, False: 0]
-
  Branch (616:27): [True: 0, False: 0]
 
  Branch (616:27): [True: 1, False: 1]
 
  Branch (616:27): [True: 0, False: 0]
 
  Branch (616:27): [True: 0, False: 0]
 
  Branch (616:27): [True: 0, False: 0]
 
  Branch (616:27): [True: 0, False: 0]
 
  Branch (616:27): [True: 0, False: 0]
-
617
1
                selection = vec![sel.item];
618
            } else {
619
1
                selection = Vec::new();
620
            }
621
3
            self.preview.content(&cb(selection).join("\n").into_bytes())
?0
;
622
697
        }
623
774
        Ok(())
624
1.37k
    }
625
626
    /// Handles a TUI event and updates application state
627
    ///
628
    /// # Errors
629
    ///
630
    /// Returns an error if drawing, sending events, or spawning a preview fails.
631
    #[allow(clippy::too_many_lines)]
632
8.76k
    pub fn handle_event<B: Backend>(&mut self, tui: &mut Tui<B>, event: &Event) -> Result<()>
633
8.76k
    where
634
8.76k
        B::Error: Send + Sync + 'static,
635
    {
636
8.76k
        trace!("handling event {
event0
:?}");
637
8.76k
        match event {
638
            Event::Render => {
639
                // Always render to avoid freezing, but the render function itself can optimize
640
2.70k
                tui.get_frame();
641
2.70k
                tui.draw(|f| {
642
2.70k
                    f.render_widget(&mut *self, f.area());
643
2.70k
                    f.set_cursor_position(self.cursor_pos);
644
2.70k
                })
?0
;
645
                // Matcher output is merged into the item list during rendering,
646
                // so this is where result-driven focus changes become observable.
647
2.70k
                if let Some(event) = self.take_focus_event() {
  Branch (647:24): [True: 10, False: 179]
-
  Branch (647:24): [True: 10, False: 20]
+
  Branch (616:27): [True: 0, False: 0]
+
  Branch (616:27): [True: 0, False: 0]
+
  Branch (616:27): [True: 0, False: 0]
+
617
1
                selection = vec![sel.item];
618
            } else {
619
1
                selection = Vec::new();
620
            }
621
3
            self.preview.content(&cb(selection).join("\n").into_bytes())
?0
;
622
700
        }
623
777
        Ok(())
624
1.37k
    }
625
626
    /// Handles a TUI event and updates application state
627
    ///
628
    /// # Errors
629
    ///
630
    /// Returns an error if drawing, sending events, or spawning a preview fails.
631
    #[allow(clippy::too_many_lines)]
632
8.78k
    pub fn handle_event<B: Backend>(&mut self, tui: &mut Tui<B>, event: &Event) -> Result<()>
633
8.78k
    where
634
8.78k
        B::Error: Send + Sync + 'static,
635
    {
636
8.78k
        trace!("handling event {
event0
:?}");
637
8.78k
        match event {
638
            Event::Render => {
639
                // Always render to avoid freezing, but the render function itself can optimize
640
2.71k
                tui.get_frame();
641
2.71k
                tui.draw(|f| {
642
2.71k
                    f.render_widget(&mut *self, f.area());
643
2.71k
                    f.set_cursor_position(self.cursor_pos);
644
2.71k
                })
?0
;
645
                // Matcher output is merged into the item list during rendering,
646
                // so this is where result-driven focus changes become observable.
647
2.71k
                if let Some(event) = self.take_focus_event() {
  Branch (647:24): [True: 10, False: 20]
+
  Branch (647:24): [True: 34, False: 160]
+
  Branch (647:24): [True: 5, False: 220]
+
  Branch (647:24): [True: 25, False: 83]
+
  Branch (647:24): [True: 4, False: 28]
+
  Branch (647:24): [True: 15, False: 34]
+
  Branch (647:24): [True: 1, False: 6]
+
  Branch (647:24): [True: 5, False: 30]
+
  Branch (647:24): [True: 10, False: 224]
+
  Branch (647:24): [True: 9, False: 27]
+
  Branch (647:24): [True: 2, False: 2]
+
  Branch (647:24): [True: 33, False: 116]
+
  Branch (647:24): [True: 10, False: 173]
+
  Branch (647:24): [True: 137, False: 526]
+
  Branch (647:24): [True: 28, False: 186]
+
  Branch (647:24): [True: 12, False: 71]
+
  Branch (647:24): [True: 16, False: 146]
 
  Branch (647:24): [True: 18, False: 261]
 
  Branch (647:24): [True: 9, False: 18]
-
  Branch (647:24): [True: 15, False: 31]
-
  Branch (647:24): [True: 12, False: 71]
-
  Branch (647:24): [True: 9, False: 18]
-
  Branch (647:24): [True: 28, False: 183]
-
  Branch (647:24): [True: 34, False: 170]
-
  Branch (647:24): [True: 33, False: 110]
-
  Branch (647:24): [True: 5, False: 220]
-
  Branch (647:24): [True: 137, False: 538]
-
  Branch (647:24): [True: 16, False: 146]
-
  Branch (647:24): [True: 2, False: 1]
-
  Branch (647:24): [True: 1, False: 6]
-
  Branch (647:24): [True: 3, False: 20]
-
  Branch (647:24): [True: 10, False: 221]
-
  Branch (647:24): [True: 4, False: 28]
-
  Branch (647:24): [True: 25, False: 86]
-
648
381
                    tui.event_tx.try_send(event)
?0
;
649
2.32k
                }
650
            }
651
            Event::Heartbeat | Event::Tick => {
652
                // Heartbeat is used for periodic UI updates
653
1.76k
                self.update_spinner();
654
1.76k
                if self.preview.is_loading() {
  Branch (654:20): [True: 0, False: 80]
-
  Branch (654:20): [True: 0, False: 47]
+
648
383
                    tui.event_tx.try_send(event)
?0
;
649
2.33k
                }
650
            }
651
            Event::Heartbeat | Event::Tick => {
652
                // Heartbeat is used for periodic UI updates
653
1.76k
                self.update_spinner();
654
1.76k
                if self.preview.is_loading() {
  Branch (654:20): [True: 0, False: 44]
+
  Branch (654:20): [True: 4, False: 142]
+
  Branch (654:20): [True: 0, False: 133]
+
  Branch (654:20): [True: 0, False: 101]
+
  Branch (654:20): [True: 0, False: 17]
+
  Branch (654:20): [True: 0, False: 36]
+
  Branch (654:20): [True: 0, False: 5]
+
  Branch (654:20): [True: 0, False: 16]
+
  Branch (654:20): [True: 0, False: 148]
+
  Branch (654:20): [True: 0, False: 24]
+
  Branch (654:20): [True: 0, False: 1]
+
  Branch (654:20): [True: 0, False: 114]
+
  Branch (654:20): [True: 0, False: 76]
+
  Branch (654:20): [True: 1, False: 502]
+
  Branch (654:20): [True: 2, False: 145]
+
  Branch (654:20): [True: 0, False: 40]
+
  Branch (654:20): [True: 0, False: 78]
 
  Branch (654:20): [True: 0, False: 120]
 
  Branch (654:20): [True: 0, False: 18]
-
  Branch (654:20): [True: 0, False: 34]
-
  Branch (654:20): [True: 0, False: 40]
-
  Branch (654:20): [True: 0, False: 18]
-
  Branch (654:20): [True: 2, False: 143]
-
  Branch (654:20): [True: 5, False: 147]
-
  Branch (654:20): [True: 0, False: 110]
-
  Branch (654:20): [True: 0, False: 133]
-
  Branch (654:20): [True: 1, False: 510]
-
  Branch (654:20): [True: 0, False: 78]
-
  Branch (654:20): [True: 0, False: 1]
-
  Branch (654:20): [True: 0, False: 5]
-
  Branch (654:20): [True: 0, False: 11]
-
  Branch (654:20): [True: 0, False: 146]
-
  Branch (654:20): [True: 0, False: 17]
-
  Branch (654:20): [True: 0, False: 103]
-
655
8
                    self.needs_render.store(true, Ordering::Relaxed);
656
1.76k
                }
657
658
1.76k
                if self.pending_matcher_restart {
  Branch (658:20): [True: 0, False: 80]
-
  Branch (658:20): [True: 0, False: 47]
+
655
7
                    self.needs_render.store(true, Ordering::Relaxed);
656
1.76k
                }
657
658
1.76k
                if self.pending_matcher_restart {
  Branch (658:20): [True: 0, False: 44]
+
  Branch (658:20): [True: 0, False: 146]
+
  Branch (658:20): [True: 0, False: 133]
+
  Branch (658:20): [True: 0, False: 101]
+
  Branch (658:20): [True: 0, False: 17]
+
  Branch (658:20): [True: 0, False: 36]
+
  Branch (658:20): [True: 0, False: 5]
+
  Branch (658:20): [True: 0, False: 16]
+
  Branch (658:20): [True: 0, False: 148]
+
  Branch (658:20): [True: 0, False: 24]
+
  Branch (658:20): [True: 1, False: 0]
+
  Branch (658:20): [True: 0, False: 114]
+
  Branch (658:20): [True: 0, False: 76]
+
  Branch (658:20): [True: 0, False: 503]
+
  Branch (658:20): [True: 1, False: 146]
+
  Branch (658:20): [True: 0, False: 40]
+
  Branch (658:20): [True: 0, False: 78]
 
  Branch (658:20): [True: 0, False: 120]
 
  Branch (658:20): [True: 0, False: 18]
-
  Branch (658:20): [True: 0, False: 34]
-
  Branch (658:20): [True: 0, False: 40]
-
  Branch (658:20): [True: 0, False: 18]
-
  Branch (658:20): [True: 1, False: 144]
-
  Branch (658:20): [True: 0, False: 152]
-
  Branch (658:20): [True: 0, False: 110]
-
  Branch (658:20): [True: 0, False: 133]
-
  Branch (658:20): [True: 0, False: 511]
-
  Branch (658:20): [True: 0, False: 78]
-
  Branch (658:20): [True: 1, False: 0]
-
  Branch (658:20): [True: 0, False: 5]
-
  Branch (658:20): [True: 0, False: 11]
-
  Branch (658:20): [True: 0, False: 146]
-
  Branch (658:20): [True: 0, False: 17]
-
  Branch (658:20): [True: 0, False: 103]
-
659
2
                    self.restart_matcher(true);
660
1.76k
                }
661
1.76k
                if self.needs_render.load(Ordering::Relaxed)
  Branch (661:20): [True: 62, False: 18]
-
  Branch (661:20): [True: 20, False: 27]
+
659
2
                    self.restart_matcher(true);
660
1.76k
                }
661
1.76k
                if self.needs_render.load(Ordering::Relaxed)
  Branch (661:20): [True: 20, False: 24]
+
  Branch (661:20): [True: 98, False: 48]
+
  Branch (661:20): [True: 57, False: 76]
+
  Branch (661:20): [True: 32, False: 69]
+
  Branch (661:20): [True: 10, False: 7]
+
  Branch (661:20): [True: 15, False: 21]
+
  Branch (661:20): [True: 4, False: 1]
+
  Branch (661:20): [True: 12, False: 4]
+
  Branch (661:20): [True: 60, False: 88]
+
  Branch (661:20): [True: 15, False: 9]
+
  Branch (661:20): [True: 1, False: 0]
+
  Branch (661:20): [True: 42, False: 72]
+
  Branch (661:20): [True: 58, False: 18]
+
  Branch (661:20): [True: 201, False: 302]
+
  Branch (661:20): [True: 59, False: 88]
+
  Branch (661:20): [True: 25, False: 15]
+
  Branch (661:20): [True: 50, False: 28]
 
  Branch (661:20): [True: 88, False: 32]
 
  Branch (661:20): [True: 9, False: 9]
-
  Branch (661:20): [True: 13, False: 21]
-
  Branch (661:20): [True: 25, False: 15]
-
  Branch (661:20): [True: 9, False: 9]
-
  Branch (661:20): [True: 57, False: 88]
-
  Branch (661:20): [True: 105, False: 47]
-
  Branch (661:20): [True: 38, False: 72]
-
  Branch (661:20): [True: 57, False: 76]
-
  Branch (661:20): [True: 209, False: 302]
-
  Branch (661:20): [True: 50, False: 28]
-
  Branch (661:20): [True: 1, False: 0]
-
  Branch (661:20): [True: 4, False: 1]
-
  Branch (661:20): [True: 7, False: 4]
-
  Branch (661:20): [True: 58, False: 88]
-
  Branch (661:20): [True: 10, False: 7]
-
  Branch (661:20): [True: 34, False: 69]
-
662
856
                    && self.last_render_timer.elapsed().as_millis() > 1000 / u128::from(TICK_RATE)
  Branch (662:24): [True: 60, False: 2]
-
  Branch (662:24): [True: 10, False: 10]
+
662
856
                    && self.last_render_timer.elapsed().as_millis() > 1000 / u128::from(TICK_RATE)
  Branch (662:24): [True: 10, False: 10]
+
  Branch (662:24): [True: 96, False: 2]
+
  Branch (662:24): [True: 57, False: 0]
+
  Branch (662:24): [True: 32, False: 0]
+
  Branch (662:24): [True: 10, False: 0]
+
  Branch (662:24): [True: 14, False: 1]
+
  Branch (662:24): [True: 4, False: 0]
+
  Branch (662:24): [True: 11, False: 1]
+
  Branch (662:24): [True: 59, False: 1]
+
  Branch (662:24): [True: 12, False: 3]
+
  Branch (662:24): [True: 1, False: 0]
+
  Branch (662:24): [True: 39, False: 3]
+
  Branch (662:24): [True: 58, False: 0]
+
  Branch (662:24): [True: 190, False: 11]
+
  Branch (662:24): [True: 57, False: 2]
+
  Branch (662:24): [True: 25, False: 0]
+
  Branch (662:24): [True: 50, False: 0]
 
  Branch (662:24): [True: 88, False: 0]
 
  Branch (662:24): [True: 9, False: 0]
-
  Branch (662:24): [True: 13, False: 0]
-
  Branch (662:24): [True: 25, False: 0]
-
  Branch (662:24): [True: 9, False: 0]
-
  Branch (662:24): [True: 56, False: 1]
-
  Branch (662:24): [True: 100, False: 5]
-
  Branch (662:24): [True: 37, False: 1]
-
  Branch (662:24): [True: 57, False: 0]
-
  Branch (662:24): [True: 194, False: 15]
-
  Branch (662:24): [True: 50, False: 0]
-
  Branch (662:24): [True: 1, False: 0]
-
  Branch (662:24): [True: 4, False: 0]
-
  Branch (662:24): [True: 7, False: 0]
-
  Branch (662:24): [True: 58, False: 0]
-
  Branch (662:24): [True: 10, False: 0]
-
  Branch (662:24): [True: 33, False: 1]
-
663
                {
664
821
                    debug!(
"Triggering render"0
);
665
821
                    self.needs_render.store(false, Ordering::Relaxed);
666
821
                    self.last_render_timer = std::time::Instant::now();
667
821
                    tui.event_tx.try_send(Event::Render)
?0
;
668
948
                }
669
670
                // Fire the reader/matcher-completion events (`load`, `result`,
671
                // `zero`, `one`). These track async state that has no synchronous
672
                // callback, so they are polled here on the heartbeat rather than
673
                // in the render path. A `Render` is queued first so a binding
674
                // that inspects the list (e.g. `load:first`) sees the final one.
675
1.76k
                let completion_events = self.poll_completion_events();
676
1.76k
                if !completion_events.is_empty() {
  Branch (676:20): [True: 60, False: 20]
-
  Branch (676:20): [True: 10, False: 37]
+
663
                {
664
822
                    debug!(
"Triggering render"0
);
665
822
                    self.needs_render.store(false, Ordering::Relaxed);
666
822
                    self.last_render_timer = std::time::Instant::now();
667
822
                    tui.event_tx.try_send(Event::Render)
?0
;
668
945
                }
669
670
                // Fire the reader/matcher-completion events (`load`, `result`,
671
                // `zero`, `one`). These track async state that has no synchronous
672
                // callback, so they are polled here on the heartbeat rather than
673
                // in the render path. A `Render` is queued first so a binding
674
                // that inspects the list (e.g. `load:first`) sees the final one.
675
1.76k
                let completion_events = self.poll_completion_events();
676
1.76k
                if !completion_events.is_empty() {
  Branch (676:20): [True: 10, False: 34]
+
  Branch (676:20): [True: 36, False: 110]
+
  Branch (676:20): [True: 57, False: 76]
+
  Branch (676:20): [True: 32, False: 69]
+
  Branch (676:20): [True: 10, False: 7]
+
  Branch (676:20): [True: 14, False: 22]
+
  Branch (676:20): [True: 1, False: 4]
+
  Branch (676:20): [True: 11, False: 5]
+
  Branch (676:20): [True: 59, False: 89]
+
  Branch (676:20): [True: 12, False: 12]
+
  Branch (676:20): [True: 0, False: 1]
+
  Branch (676:20): [True: 39, False: 75]
+
  Branch (676:20): [True: 58, False: 18]
+
  Branch (676:20): [True: 178, False: 325]
+
  Branch (676:20): [True: 50, False: 97]
+
  Branch (676:20): [True: 25, False: 15]
+
  Branch (676:20): [True: 50, False: 28]
 
  Branch (676:20): [True: 88, False: 32]
 
  Branch (676:20): [True: 9, False: 9]
-
  Branch (676:20): [True: 13, False: 21]
-
  Branch (676:20): [True: 25, False: 15]
-
  Branch (676:20): [True: 9, False: 9]
-
  Branch (676:20): [True: 49, False: 96]
-
  Branch (676:20): [True: 39, False: 113]
-
  Branch (676:20): [True: 37, False: 73]
-
  Branch (676:20): [True: 57, False: 76]
-
  Branch (676:20): [True: 182, False: 329]
-
  Branch (676:20): [True: 50, False: 28]
-
  Branch (676:20): [True: 0, False: 1]
-
  Branch (676:20): [True: 1, False: 4]
-
  Branch (676:20): [True: 7, False: 4]
-
  Branch (676:20): [True: 58, False: 88]
-
  Branch (676:20): [True: 10, False: 7]
-
  Branch (676:20): [True: 33, False: 70]
-
677
737
                    tui.event_tx.try_send(Event::Render)
?0
;
678
737
                    for evt in completion_events {
679
1.51k
                        tui.event_tx.try_send(evt)
?0
;
680
                    }
681
1.03k
                }
682
683
                // Check if a debounced preview run needs to be executed
684
1.76k
                if self.pending_preview_run
  Branch (684:20): [True: 58, False: 22]
-
  Branch (684:20): [True: 10, False: 37]
+
677
739
                    tui.event_tx.try_send(Event::Render)
?0
;
678
739
                    for evt in completion_events {
679
1.51k
                        tui.event_tx.try_send(evt)
?0
;
680
                    }
681
1.02k
                }
682
683
                // Check if a debounced preview run needs to be executed
684
1.76k
                if self.pending_preview_run
  Branch (684:20): [True: 10, False: 34]
+
  Branch (684:20): [True: 63, False: 83]
+
  Branch (684:20): [True: 10, False: 123]
+
  Branch (684:20): [True: 86, False: 15]
+
  Branch (684:20): [True: 8, False: 9]
+
  Branch (684:20): [True: 19, False: 17]
+
  Branch (684:20): [True: 2, False: 3]
+
  Branch (684:20): [True: 0, False: 16]
+
  Branch (684:20): [True: 25, False: 123]
+
  Branch (684:20): [True: 9, False: 15]
+
  Branch (684:20): [True: 0, False: 1]
+
  Branch (684:20): [True: 60, False: 54]
+
  Branch (684:20): [True: 58, False: 18]
+
  Branch (684:20): [True: 299, False: 204]
+
  Branch (684:20): [True: 67, False: 80]
+
  Branch (684:20): [True: 4, False: 36]
+
  Branch (684:20): [True: 56, False: 22]
 
  Branch (684:20): [True: 94, False: 26]
 
  Branch (684:20): [True: 9, False: 9]
-
  Branch (684:20): [True: 19, False: 15]
-
  Branch (684:20): [True: 4, False: 36]
-
  Branch (684:20): [True: 9, False: 9]
-
  Branch (684:20): [True: 67, False: 78]
-
  Branch (684:20): [True: 63, False: 89]
-
  Branch (684:20): [True: 60, False: 50]
-
  Branch (684:20): [True: 10, False: 123]
-
  Branch (684:20): [True: 299, False: 212]
-
  Branch (684:20): [True: 56, False: 22]
-
  Branch (684:20): [True: 0, False: 1]
-
  Branch (684:20): [True: 2, False: 3]
-
  Branch (684:20): [True: 0, False: 11]
-
  Branch (684:20): [True: 25, False: 121]
-
  Branch (684:20): [True: 8, False: 9]
-
  Branch (684:20): [True: 86, False: 17]
-
685
879
                    && let Err(e) = self.run_preview(tui)
  Branch (685:28): [True: 0, False: 58]
-
  Branch (685:28): [True: 0, False: 10]
-
  Branch (685:28): [True: 0, False: 94]
-
  Branch (685:28): [True: 0, False: 9]
-
  Branch (685:28): [True: 0, False: 19]
-
  Branch (685:28): [True: 0, False: 4]
-
  Branch (685:28): [True: 0, False: 9]
-
  Branch (685:28): [True: 0, False: 67]
+
685
879
                    && let Err(e) = self.run_preview(tui)
  Branch (685:28): [True: 0, False: 10]
 
  Branch (685:28): [True: 0, False: 63]
-
  Branch (685:28): [True: 0, False: 60]
 
  Branch (685:28): [True: 0, False: 10]
-
  Branch (685:28): [True: 0, False: 299]
-
  Branch (685:28): [True: 0, False: 56]
-
  Branch (685:28): [True: 0, False: 0]
+
  Branch (685:28): [True: 0, False: 86]
+
  Branch (685:28): [True: 0, False: 8]
+
  Branch (685:28): [True: 0, False: 19]
 
  Branch (685:28): [True: 0, False: 2]
 
  Branch (685:28): [True: 0, False: 0]
 
  Branch (685:28): [True: 0, False: 25]
-
  Branch (685:28): [True: 0, False: 8]
-
  Branch (685:28): [True: 0, False: 86]
-
686
                {
687
0
                    warn!("Heartbeat RunPreview: error {e:?}");
688
1.76k
                }
689
            }
690
            Event::RunPreview => {
691
485
                if let Err(e) = self.run_preview(tui) {
  Branch (691:24): [True: 0, False: 49]
+
  Branch (685:28): [True: 0, False: 9]
+
  Branch (685:28): [True: 0, False: 0]
+
  Branch (685:28): [True: 0, False: 60]
+
  Branch (685:28): [True: 0, False: 58]
+
  Branch (685:28): [True: 0, False: 299]
+
  Branch (685:28): [True: 0, False: 67]
+
  Branch (685:28): [True: 0, False: 4]
+
  Branch (685:28): [True: 0, False: 56]
+
  Branch (685:28): [True: 0, False: 94]
+
  Branch (685:28): [True: 0, False: 9]
+
686
                {
687
0
                    warn!("Heartbeat RunPreview: error {e:?}");
688
1.76k
                }
689
            }
690
            Event::RunPreview => {
691
488
                if let Err(e) = self.run_preview(tui) {
  Branch (691:24): [True: 0, False: 0]
+
  Branch (691:24): [True: 0, False: 60]
+
  Branch (691:24): [True: 0, False: 6]
+
  Branch (691:24): [True: 0, False: 20]
+
  Branch (691:24): [True: 0, False: 5]
+
  Branch (691:24): [True: 0, False: 3]
+
  Branch (691:24): [True: 0, False: 2]
+
  Branch (691:24): [True: 0, False: 8]
+
  Branch (691:24): [True: 0, False: 42]
 
  Branch (691:24): [True: 0, False: 0]
+
  Branch (691:24): [True: 0, False: 1]
+
  Branch (691:24): [True: 0, False: 19]
+
  Branch (691:24): [True: 0, False: 49]
+
  Branch (691:24): [True: 0, False: 95]
+
  Branch (691:24): [True: 0, False: 45]
+
  Branch (691:24): [True: 0, False: 18]
+
  Branch (691:24): [True: 0, False: 39]
 
  Branch (691:24): [True: 0, False: 76]
 
  Branch (691:24): [True: 0, False: 0]
-
  Branch (691:24): [True: 0, False: 3]
-
  Branch (691:24): [True: 0, False: 18]
-
  Branch (691:24): [True: 0, False: 0]
-
  Branch (691:24): [True: 0, False: 45]
-
  Branch (691:24): [True: 0, False: 60]
-
  Branch (691:24): [True: 0, False: 19]
-
  Branch (691:24): [True: 0, False: 6]
-
  Branch (691:24): [True: 0, False: 95]
-
  Branch (691:24): [True: 0, False: 39]
-
  Branch (691:24): [True: 0, False: 1]
-
  Branch (691:24): [True: 0, False: 2]
-
  Branch (691:24): [True: 0, False: 5]
-
  Branch (691:24): [True: 0, False: 42]
-
  Branch (691:24): [True: 0, False: 5]
-
  Branch (691:24): [True: 0, False: 20]
-
692
0
                    warn!("RunPreview: error {e:?}");
693
485
                }
694
            }
695
            Event::RunExecute(cmd) => {
696
1
                tui.run_execute(cmd)
?0
;
697
1
                self.handle_event(tui, &Event::Redraw)
?0
;
698
            }
699
            Event::Clear => {
700
1
                tui.clear()
?0
;
701
            }
702
            Event::Redraw => {
703
                // Avoid `Event::Redraw` (which calls `tui.clear()`): ratatui's
704
                // `Terminal::clear` first queries the cursor position, and
705
                // crossterm writes that query (`ESC [ 6 n`) to *stdout*. skim
706
                // renders to stderr and its stdout is frequently redirected
707
                // (`sk > file`, `find | sk | …`); there the query reaches no
708
                // terminal, no reply ever comes, and the UI stalls for seconds
709
                // before erroring out. Resetting both of ratatui's diff buffers
710
                // instead makes the next draw repaint every cell — no cursor
711
                // query, and it works for both fullscreen and inline viewports.
712
2
                tui.force_full_redraw();
713
2
                self.handle_event(tui, &Event::Render)
?0
;
714
            }
715
            Event::Quit | Event::Close => {
716
0
                tui.exit()?;
717
0
                self.should_quit = true;
718
            }
719
            Event::PreviewReady => {
720
69
                self.preview.mark_ready();
721
                // Apply preview offset if configured
722
69
                if let Some(offset_expr) = &self.options.preview_window.offset {
  Branch (722:24): [True: 0, False: 0]
-
  Branch (722:24): [True: 0, False: 0]
-
  Branch (722:24): [True: 0, False: 0]
-
  Branch (722:24): [True: 0, False: 0]
-
  Branch (722:24): [True: 0, False: 0]
-
  Branch (722:24): [True: 0, False: 0]
-
  Branch (722:24): [True: 0, False: 0]
-
  Branch (722:24): [True: 0, False: 5]
+
692
0
                    warn!("RunPreview: error {e:?}");
693
488
                }
694
            }
695
            Event::RunExecute(cmd) => {
696
1
                tui.run_execute(cmd)
?0
;
697
1
                self.handle_event(tui, &Event::Redraw)
?0
;
698
            }
699
            Event::Clear => {
700
1
                tui.clear()
?0
;
701
            }
702
            Event::Redraw => {
703
                // Avoid `Event::Redraw` (which calls `tui.clear()`): ratatui's
704
                // `Terminal::clear` first queries the cursor position, and
705
                // crossterm writes that query (`ESC [ 6 n`) to *stdout*. skim
706
                // renders to stderr and its stdout is frequently redirected
707
                // (`sk > file`, `find | sk | …`); there the query reaches no
708
                // terminal, no reply ever comes, and the UI stalls for seconds
709
                // before erroring out. Resetting both of ratatui's diff buffers
710
                // instead makes the next draw repaint every cell — no cursor
711
                // query, and it works for both fullscreen and inline viewports.
712
2
                tui.force_full_redraw();
713
2
                self.handle_event(tui, &Event::Render)
?0
;
714
            }
715
            Event::Quit | Event::Close => {
716
0
                tui.exit()?;
717
0
                self.should_quit = true;
718
            }
719
            Event::PreviewReady => {
720
69
                self.preview.mark_ready();
721
                // Apply preview offset if configured
722
69
                if let Some(offset_expr) = &self.options.preview_window.offset {
  Branch (722:24): [True: 0, False: 0]
 
  Branch (722:24): [True: 6, False: 50]
 
  Branch (722:24): [True: 0, False: 0]
 
  Branch (722:24): [True: 0, False: 0]
-
  Branch (722:24): [True: 0, False: 5]
 
  Branch (722:24): [True: 0, False: 0]
-
  Branch (722:24): [True: 0, False: 1]
+
  Branch (722:24): [True: 0, False: 0]
 
  Branch (722:24): [True: 0, False: 2]
 
  Branch (722:24): [True: 0, False: 0]
 
  Branch (722:24): [True: 0, False: 0]
 
  Branch (722:24): [True: 0, False: 0]
+
  Branch (722:24): [True: 0, False: 1]
 
  Branch (722:24): [True: 0, False: 0]
-
723
6
                    let offset = self.calculate_preview_offset(offset_expr);
724
6
                    self.preview.set_offset(offset);
725
63
                }
726
69
                self.needs_render();
727
            }
728
            Event::Error(msg) => {
729
1
                tui.exit()
?0
;
730
1
                bail!(msg.to_owned());
731
            }
732
            Event::Action(act) => {
733
539
                let events = self.handle_action(act)
?0
;
734
539
                for evt in events {
735
877
                    tui.event_tx.try_send(evt)
?0
;
736
                }
737
539
                tui.event_tx.try_send(Event::Render)
?0
;
738
            }
739
            Event::Key(key) => {
740
3.16k
                let events = self.handle_key(key);
741
3.16k
                for evt in events {
742
525
                    tui.event_tx.try_send(evt)
?0
;
743
                }
744
            }
745
            Event::Paste(text) => {
746
                // Strip newlines/carriage returns from pasted text so they don't
747
                // trigger Accept or get inserted as invisible characters.
748
10
                let cleaned: String = 
text.chars()2
.filter(|c| *c != '\n' &&
*c != '\r'8
).collect();
  Branch (748:63): [True: 0, False: 0]
-
  Branch (748:63): [True: 0, False: 0]
-
  Branch (748:63): [True: 0, False: 0]
-
  Branch (748:63): [True: 0, False: 0]
+
  Branch (722:24): [True: 0, False: 0]
+
  Branch (722:24): [True: 0, False: 5]
+
  Branch (722:24): [True: 0, False: 5]
+
  Branch (722:24): [True: 0, False: 0]
+
  Branch (722:24): [True: 0, False: 0]
+
  Branch (722:24): [True: 0, False: 0]
+
  Branch (722:24): [True: 0, False: 0]
+
723
6
                    let offset = self.calculate_preview_offset(offset_expr);
724
6
                    self.preview.set_offset(offset);
725
63
                }
726
69
                self.needs_render();
727
            }
728
            Event::Error(msg) => {
729
1
                tui.exit()
?0
;
730
1
                bail!(msg.to_owned());
731
            }
732
            Event::Action(act) => {
733
542
                let events = self.handle_action(act)
?0
;
734
542
                for evt in events {
735
883
                    tui.event_tx.try_send(evt)
?0
;
736
                }
737
542
                tui.event_tx.try_send(Event::Render)
?0
;
738
            }
739
            Event::Key(key) => {
740
3.17k
                let events = self.handle_key(key);
741
3.17k
                for evt in events {
742
528
                    tui.event_tx.try_send(evt)
?0
;
743
                }
744
            }
745
            Event::Paste(text) => {
746
                // Strip newlines/carriage returns from pasted text so they don't
747
                // trigger Accept or get inserted as invisible characters.
748
10
                let cleaned: String = 
text.chars()2
.filter(|c| *c != '\n' &&
*c != '\r'8
).collect();
  Branch (748:63): [True: 0, False: 0]
 
  Branch (748:63): [True: 0, False: 0]
 
  Branch (748:63): [True: 0, False: 0]
 
  Branch (748:63): [True: 0, False: 0]
@@ -417,6 +414,9 @@
 
  Branch (748:63): [True: 0, False: 0]
 
  Branch (748:63): [True: 0, False: 0]
 
  Branch (748:63): [True: 0, False: 0]
+
  Branch (748:63): [True: 0, False: 0]
+
  Branch (748:63): [True: 0, False: 0]
+
  Branch (748:63): [True: 0, False: 0]
 
749
2
                if !cleaned.is_empty() {
  Branch (749:20): [True: 0, False: 0]
 
  Branch (749:20): [True: 0, False: 0]
 
  Branch (749:20): [True: 0, False: 0]
@@ -427,15 +427,15 @@
 
  Branch (749:20): [True: 0, False: 0]
 
  Branch (749:20): [True: 0, False: 0]
 
  Branch (749:20): [True: 0, False: 0]
-
  Branch (749:20): [True: 0, False: 0]
-
  Branch (749:20): [True: 0, False: 0]
-
  Branch (749:20): [True: 0, False: 0]
 
  Branch (749:20): [True: 1, False: 1]
 
  Branch (749:20): [True: 0, False: 0]
 
  Branch (749:20): [True: 0, False: 0]
 
  Branch (749:20): [True: 0, False: 0]
 
  Branch (749:20): [True: 0, False: 0]
 
  Branch (749:20): [True: 0, False: 0]
+
  Branch (749:20): [True: 0, False: 0]
+
  Branch (749:20): [True: 0, False: 0]
+
  Branch (749:20): [True: 0, False: 0]
 
750
1
                    self.input.insert_str(&cleaned);
751
1
                    let events = self.on_query_changed();
752
1
                    for evt in events {
753
2
                        tui.event_tx.try_send(evt)
?0
;
754
                    }
755
1
                }
756
            }
757
            Event::Resize(cols, rows) => {
758
                // We need to manually resize Fixed viewports
759
2
                if !tui.is_fullscreen {
  Branch (759:20): [True: 0, False: 0]
 
  Branch (759:20): [True: 0, False: 0]
 
  Branch (759:20): [True: 0, False: 0]
@@ -446,15 +446,15 @@
 
  Branch (759:20): [True: 0, False: 0]
 
  Branch (759:20): [True: 0, False: 0]
 
  Branch (759:20): [True: 0, False: 0]
-
  Branch (759:20): [True: 0, False: 0]
-
  Branch (759:20): [True: 0, False: 0]
-
  Branch (759:20): [True: 0, False: 0]
 
  Branch (759:20): [True: 1, False: 1]
 
  Branch (759:20): [True: 0, False: 0]
 
  Branch (759:20): [True: 0, False: 0]
 
  Branch (759:20): [True: 0, False: 0]
 
  Branch (759:20): [True: 0, False: 0]
 
  Branch (759:20): [True: 0, False: 0]
+
  Branch (759:20): [True: 0, False: 0]
+
  Branch (759:20): [True: 0, False: 0]
+
  Branch (759:20): [True: 0, False: 0]
 
760
1
                    let curr = tui.terminal.get_frame().area();
761
1
                    let _ = tui.terminal.resize(Rect {
762
1
                        x: curr.x,
763
1
                        y: curr.y,
764
1
                        width: *cols,
765
1
                        height: *rows,
766
1
                    });
767
1
                }
768
2
                self.resize(*cols, *rows);
769
2
                if let Err(e) = self.run_preview(tui) {
  Branch (769:24): [True: 0, False: 0]
 
  Branch (769:24): [True: 0, False: 0]
 
  Branch (769:24): [True: 0, False: 0]
@@ -465,30 +465,30 @@
 
  Branch (769:24): [True: 0, False: 0]
 
  Branch (769:24): [True: 0, False: 0]
 
  Branch (769:24): [True: 0, False: 0]
-
  Branch (769:24): [True: 0, False: 0]
-
  Branch (769:24): [True: 0, False: 0]
-
  Branch (769:24): [True: 0, False: 0]
 
  Branch (769:24): [True: 0, False: 2]
 
  Branch (769:24): [True: 0, False: 0]
 
  Branch (769:24): [True: 0, False: 0]
 
  Branch (769:24): [True: 0, False: 0]
 
  Branch (769:24): [True: 0, False: 0]
 
  Branch (769:24): [True: 0, False: 0]
-
770
0
                    warn!("error while rerunnig preview after resize: {e}");
771
2
                }
772
            }
773
            Event::Mouse(mouse_event) => {
774
13
                let events = self.handle_mouse(*mouse_event)
?0
;
775
13
                for evt in events {
776
16
                    tui.event_tx.try_send(evt)
?0
;
777
                }
778
            }
779
            Event::InvalidInput => {
780
1
                warn!(
"Received invalid input"0
);
781
            }
782
            Event::ClearItems => {
783
1
                self.item_pool.clear();
784
1
                self.restart_matcher(true);
785
            }
786
            Event::AppendItems(items) => {
787
1
                self.item_pool.append(items.to_owned());
788
1
                self.restart_matcher(false);
789
            }
790
            Event::Reload(_) => {
791
0
                unreachable!("Reload is handled by the TUI event loop in lib.rs")
792
            }
793
        }
794
795
8.75k
        Ok(())
796
8.76k
    }
797
    /// Handles new items received from the reader
798
4
    pub fn handle_items(&mut self, items: Vec<Arc<dyn SkimItem>>) {
799
4
        self.item_pool.append(items);
800
4
        trace!("Got new items, len {}", 
self.item_pool.len()0
);
801
4
        self.on_items_updated();
802
4
    }
803
3.17k
    fn handle_key(&mut self, key: &KeyEvent) -> Vec<Event> {
804
3.17k
        let normalized_key = KeyEvent::new(key.code, key.modifiers);
805
3.17k
        debug!("key event: {
key0
:?}, normalized: {
normalized_key0
:?}");
806
807
3.17k
        if let Some(act) = &self.options.keymap.get(&normalized_key) {
  Branch (807:16): [True: 179, False: 2.98k]
+
  Branch (769:24): [True: 0, False: 0]
+
  Branch (769:24): [True: 0, False: 0]
+
  Branch (769:24): [True: 0, False: 0]
+
770
0
                    warn!("error while rerunnig preview after resize: {e}");
771
2
                }
772
            }
773
            Event::Mouse(mouse_event) => {
774
13
                let events = self.handle_mouse(*mouse_event)
?0
;
775
13
                for evt in events {
776
16
                    tui.event_tx.try_send(evt)
?0
;
777
                }
778
            }
779
            Event::InvalidInput => {
780
1
                warn!(
"Received invalid input"0
);
781
            }
782
            Event::ClearItems => {
783
1
                self.item_pool.clear();
784
1
                self.restart_matcher(true);
785
            }
786
            Event::AppendItems(items) => {
787
1
                self.item_pool.append(items.to_owned());
788
1
                self.restart_matcher(false);
789
            }
790
            Event::Reload(_) => {
791
0
                unreachable!("Reload is handled by the TUI event loop in lib.rs")
792
            }
793
        }
794
795
8.78k
        Ok(())
796
8.78k
    }
797
    /// Handles new items received from the reader
798
4
    pub fn handle_items(&mut self, items: Vec<Arc<dyn SkimItem>>) {
799
4
        self.item_pool.append(items);
800
4
        trace!("Got new items, len {}", 
self.item_pool.len()0
);
801
4
        self.on_items_updated();
802
4
    }
803
3.18k
    fn handle_key(&mut self, key: &KeyEvent) -> Vec<Event> {
804
3.18k
        let normalized_key = KeyEvent::new(key.code, key.modifiers);
805
3.18k
        debug!("key event: {
key0
:?}, normalized: {
normalized_key0
:?}");
806
807
3.18k
        if let Some(act) = &self.options.keymap.get(&normalized_key) {
  Branch (807:16): [True: 179, False: 2.99k]
 
  Branch (807:16): [True: 4, False: 8]
-
808
183
            debug!("{
act0
:?}");
809
195
            return 
act.iter()183
.map(|a| Event::Action(a.clone())).collect();
810
2.99k
        }
811
2.99k
        match key.modifiers {
812
            KeyModifiers::CONTROL => {
813
3
                if let Char('c') = key.code {
  Branch (813:24): [True: 0, False: 0]
+
808
183
            debug!("{
act0
:?}");
809
195
            return 
act.iter()183
.map(|a| Event::Action(a.clone())).collect();
810
3.00k
        }
811
3.00k
        match key.modifiers {
812
            KeyModifiers::CONTROL => {
813
3
                if let Char('c') = key.code {
  Branch (813:24): [True: 0, False: 0]
 
  Branch (813:24): [True: 1, False: 2]
-
814
1
                    return vec![Event::Quit];
815
2
                }
816
            }
817
            KeyModifiers::NONE => {
818
2.98k
                if let Char(c) = key.code {
  Branch (818:24): [True: 331, False: 2.65k]
+
814
1
                    return vec![Event::Quit];
815
2
                }
816
            }
817
            KeyModifiers::NONE => {
818
2.99k
                if let Char(c) = key.code {
  Branch (818:24): [True: 334, False: 2.66k]
 
  Branch (818:24): [True: 2, False: 0]
-
819
333
                    return vec![Event::Action(Action::AddChar(c))];
820
2.65k
                }
821
            }
822
            KeyModifiers::SHIFT => {
823
2
                if let Char(c) = key.code {
  Branch (823:24): [True: 0, False: 0]
+
819
336
                    return vec![Event::Action(Action::AddChar(c))];
820
2.66k
                }
821
            }
822
            KeyModifiers::SHIFT => {
823
2
                if let Char(c) = key.code {
  Branch (823:24): [True: 0, False: 0]
 
  Branch (823:24): [True: 1, False: 1]
-
824
1
                    return vec![Event::Action(Action::AddChar(c.to_uppercase().next().unwrap()))];
825
1
                }
826
            }
827
1
            _ => (),
828
        }
829
2.65k
        vec![]
830
3.17k
    }
831
832
    /// Runs an action, then directly dispatches any follow-up actions bound to it.
833
    ///
834
    /// Follow-ups use non-recursive (`noremap`) semantics: an action in the
835
    /// follow-up chain does not trigger its own follow-up binding. If the chain
836
    /// contains [`Action::Suppress`], the triggering action is skipped.
837
678
    fn handle_action(&mut self, act: &Action) -> Result<Vec<Event>> {
838
678
        let follow = self.options.action_binds.get(act.name()).cloned();
839
678
        let suppress_default = follow
840
678
            .as_ref()
841
5
            .is_some_and(|chain| chain.iter().any(|a| matches!(a, Action::Suppress)));
842
843
678
        let mut events = if suppress_default {
  Branch (843:29): [True: 1, False: 535]
+
824
1
                    return vec![Event::Action(Action::AddChar(c.to_uppercase().next().unwrap()))];
825
1
                }
826
            }
827
1
            _ => (),
828
        }
829
2.66k
        vec![]
830
3.18k
    }
831
832
    /// Runs an action, then directly dispatches any follow-up actions bound to it.
833
    ///
834
    /// Follow-ups use non-recursive (`noremap`) semantics: an action in the
835
    /// follow-up chain does not trigger its own follow-up binding. If the chain
836
    /// contains [`Action::Suppress`], the triggering action is skipped.
837
681
    fn handle_action(&mut self, act: &Action) -> Result<Vec<Event>> {
838
681
        let follow = self.options.action_binds.get(act.name()).cloned();
839
681
        let suppress_default = follow
840
681
            .as_ref()
841
5
            .is_some_and(|chain| chain.iter().any(|a| matches!(a, Action::Suppress)));
842
843
681
        let mut events = if suppress_default {
  Branch (843:29): [True: 1, False: 538]
 
  Branch (843:29): [True: 1, False: 141]
-
844
2
            Vec::new()
845
        } else {
846
676
            self.dispatch_action(act)
?0
847
        };
848
678
        if let Some(chain) = follow {
  Branch (848:16): [True: 3, False: 533]
+
844
2
            Vec::new()
845
        } else {
846
679
            self.dispatch_action(act)
?0
847
        };
848
681
        if let Some(chain) = follow {
  Branch (848:16): [True: 3, False: 536]
 
  Branch (848:16): [True: 2, False: 140]
-
849
8
            for action in 
chain.iter()5
.filter(|a| !matches!(a, Action::Suppress)) {
850
6
                events.extend(self.dispatch_action(action)
?0
);
851
            }
852
673
        }
853
678
        Ok(events)
854
678
    }
855
856
14
    fn dispatch_conditional(&mut self, condition: bool, then: &str, otherwise: Option<&str>) -> Result<Vec<Event>> {
857
14
        let Some(chain) = condition.then_some(then).or(otherwise) else {
  Branch (857:13): [True: 1, False: 1]
+
849
8
            for action in 
chain.iter()5
.filter(|a| !matches!(a, Action::Suppress)) {
850
6
                events.extend(self.dispatch_action(action)
?0
);
851
            }
852
676
        }
853
681
        Ok(events)
854
681
    }
855
856
14
    fn dispatch_conditional(&mut self, condition: bool, then: &str, otherwise: Option<&str>) -> Result<Vec<Event>> {
857
14
        let Some(chain) = condition.then_some(then).or(otherwise) else {
  Branch (857:13): [True: 1, False: 1]
 
  Branch (857:13): [True: 9, False: 3]
-
858
4
            return Ok(Vec::new());
859
        };
860
        // `if-*` branch chains are stored unparsed (see `parse_action`), so an
861
        // invalid action name only surfaces here, at dispatch time. Log and
862
        // skip the chain instead of erroring out of the event loop, matching
863
        // the invalid-chain handling of `parse_action_binds`.
864
10
        let actions = match crate::binds::parse_action_chain(chain) {
865
9
            Ok(actions) => actions,
866
            Err(err) => {
867
1
                warn!("Ignoring conditional action chain `{
chain0
}`: {
err0
}");
868
1
                return Ok(Vec::new());
869
            }
870
        };
871
9
        let mut events = Vec::new();
872
9
        for action in actions {
873
9
            events.extend(self.dispatch_action(&action)
?0
);
874
        }
875
9
        Ok(events)
876
14
    }
877
878
    #[allow(clippy::too_many_lines)]
879
691
    fn dispatch_action(&mut self, act: &Action) -> Result<Vec<Event>> {
880
        #[allow(clippy::enum_glob_use)]
881
        use Action::*;
882
        use ratatui::widgets::ListDirection::{BottomToTop, TopToBottom};
883
691
        match act {
884
            Abort | Accept(_) => {
885
25
                self.should_quit = true;
886
25
                self.final_action = Some(act.clone());
887
            }
888
            AddChar(c) => {
889
335
                self.input.insert(*c);
890
335
                return Ok(self.on_query_changed());
891
            }
892
            AppendAndSelect => {
893
2
                let value = self.input.value.clone();
894
2
                let item: Arc<dyn SkimItem> = Arc::new(value);
895
2
                let rank = Rank {
896
2
                    index: i32::try_from(self.item_pool.len()).unwrap_or(i32::MAX),
897
2
                    ..Default::default()
898
2
                };
899
2
                self.item_pool.append(vec![item.clone()]);
900
2
                self.item_list.append(&mut vec![MatchedItem::new(
901
2
                    item,
902
2
                    rank,
903
2
                    None,
904
2
                    &self.matcher.rank_builder,
905
2
                )]);
906
2
                self.item_list.select_row(self.item_list.items.len() - 1);
907
2
                self.restart_matcher_debounced();
908
2
                return Ok(self.on_selection_changed());
909
            }
910
            BackwardChar => {
911
6
                self.input.move_cursor(-1);
912
            }
913
            BackwardDeleteChar => {
914
7
                if self.input.delete(-1).is_some() {
  Branch (914:20): [True: 6, False: 0]
+
858
4
            return Ok(Vec::new());
859
        };
860
        // `if-*` branch chains are stored unparsed (see `parse_action`), so an
861
        // invalid action name only surfaces here, at dispatch time. Log and
862
        // skip the chain instead of erroring out of the event loop, matching
863
        // the invalid-chain handling of `parse_action_binds`.
864
10
        let actions = match crate::binds::parse_action_chain(chain) {
865
9
            Ok(actions) => actions,
866
            Err(err) => {
867
1
                warn!("Ignoring conditional action chain `{
chain0
}`: {
err0
}");
868
1
                return Ok(Vec::new());
869
            }
870
        };
871
9
        let mut events = Vec::new();
872
9
        for action in actions {
873
9
            events.extend(self.dispatch_action(&action)
?0
);
874
        }
875
9
        Ok(events)
876
14
    }
877
878
    #[allow(clippy::too_many_lines)]
879
694
    fn dispatch_action(&mut self, act: &Action) -> Result<Vec<Event>> {
880
        #[allow(clippy::enum_glob_use)]
881
        use Action::*;
882
        use ratatui::widgets::ListDirection::{BottomToTop, TopToBottom};
883
694
        match act {
884
            Abort | Accept(_) => {
885
25
                self.should_quit = true;
886
25
                self.final_action = Some(act.clone());
887
            }
888
            AddChar(c) => {
889
338
                self.input.insert(*c);
890
338
                return Ok(self.on_query_changed());
891
            }
892
            AppendAndSelect => {
893
2
                let value = self.input.value.clone();
894
2
                let item: Arc<dyn SkimItem> = Arc::new(value);
895
2
                let rank = Rank {
896
2
                    index: i32::try_from(self.item_pool.len()).unwrap_or(i32::MAX),
897
2
                    ..Default::default()
898
2
                };
899
2
                self.item_pool.append(vec![item.clone()]);
900
2
                self.item_list.append(&mut vec![MatchedItem::new(
901
2
                    item,
902
2
                    rank,
903
2
                    None,
904
2
                    &self.matcher.rank_builder,
905
2
                )]);
906
2
                self.item_list.select_row(self.item_list.items.len() - 1);
907
2
                self.restart_matcher_debounced();
908
2
                return Ok(self.on_selection_changed());
909
            }
910
            BackwardChar => {
911
6
                self.input.move_cursor(-1);
912
            }
913
            BackwardDeleteChar => {
914
7
                if self.input.delete(-1).is_some() {
  Branch (914:20): [True: 6, False: 0]
 
  Branch (914:20): [True: 1, False: 0]
 
915
7
                    return Ok(self.on_query_changed());
916
0
                }
917
            }
918
            BackwardDeleteCharEof => {
919
2
                if self.input.is_empty() {
  Branch (919:20): [True: 0, False: 0]
 
  Branch (919:20): [True: 1, False: 1]
@@ -532,7 +532,7 @@
 
  Branch (1319:20): [True: 1, False: 1]
 
1320
4
                    return Ok(self.on_query_changed());
1321
1
                }
1322
            }
1323
            UnixWordRubout => {
1324
21
                if !self.input.delete_backward_to_whitespace().is_empty() {
  Branch (1324:20): [True: 19, False: 0]
 
  Branch (1324:20): [True: 1, False: 1]
-
1325
20
                    return Ok(self.on_query_changed());
1326
1
                }
1327
            }
1328
            Up(n) => {
1329
64
                match self.item_list.direction {
1330
1
                    TopToBottom => self.item_list.scroll_by(-i32::from(*n)),
1331
63
                    BottomToTop => self.item_list.scroll_by(i32::from(*n)),
1332
                }
1333
64
                return Ok(self.on_selection_changed());
1334
            }
1335
            Yank => {
1336
                // Insert from yank register at cursor position
1337
3
                self.input.insert_str(&self.yank_register);
1338
3
                return Ok(self.on_query_changed());
1339
            }
1340
            Custom(cb) => {
1341
2
                return cb.call(self).map_err(|e| 
eyre::eyre!0
("{}",
e0
));
1342
            }
1343
        }
1344
119
        Ok(Vec::default())
1345
691
    }
1346
1347
    /// Returns the selected items as results
1348
42
    pub fn results(&mut self) -> Vec<MatchedItem> {
1349
42
        if self.options.filter.is_some() {
  Branch (1349:12): [True: 15, False: 18]
+
1325
20
                    return Ok(self.on_query_changed());
1326
1
                }
1327
            }
1328
            Up(n) => {
1329
64
                match self.item_list.direction {
1330
1
                    TopToBottom => self.item_list.scroll_by(-i32::from(*n)),
1331
63
                    BottomToTop => self.item_list.scroll_by(i32::from(*n)),
1332
                }
1333
64
                return Ok(self.on_selection_changed());
1334
            }
1335
            Yank => {
1336
                // Insert from yank register at cursor position
1337
3
                self.input.insert_str(&self.yank_register);
1338
3
                return Ok(self.on_query_changed());
1339
            }
1340
            Custom(cb) => {
1341
2
                return cb.call(self).map_err(|e| 
eyre::eyre!0
("{}",
e0
));
1342
            }
1343
        }
1344
119
        Ok(Vec::default())
1345
694
    }
1346
1347
    /// Returns the selected items as results
1348
42
    pub fn results(&mut self) -> Vec<MatchedItem> {
1349
42
        if self.options.filter.is_some() {
  Branch (1349:12): [True: 15, False: 18]
 
  Branch (1349:12): [True: 1, False: 8]
 
1350
            // In filter mode, drain items to avoid cloning
1351
16
            std::mem::take(&mut self.item_list.items)
1352
26
        } else if self.options.multi && 
!self.item_list.selection.is_empty()2
{
  Branch (1352:19): [True: 0, False: 18]
   Branch (1352:41): [True: 0, False: 0]
@@ -540,37 +540,37 @@
   Branch (1352:41): [True: 2, False: 0]
 
1353
2
            self.item_list.selection.clone().into_iter().collect()
1354
24
        } else if let Some(sel) = self.item_list.selected() {
  Branch (1354:23): [True: 18, False: 0]
 
  Branch (1354:23): [True: 1, False: 5]
-
1355
19
            vec![sel]
1356
        } else {
1357
5
            vec![]
1358
        }
1359
42
    }
1360
1361
    /// Whether the current query is shorter than `--min-query-length`, meaning no
1362
    /// results should be produced yet.
1363
    ///
1364
    /// Always false when `--min-query-length` is unset, or under `--disabled`, where
1365
    /// the input is not used as a query.
1366
    #[must_use]
1367
1.44k
    pub fn query_below_min_length(&self) -> bool {
1368
1.44k
        self.options
1369
1.44k
            .min_query_length
1370
21
            .is_some_and(|min| !self.options.disabled && self.input.value.chars().count() < min)
  Branch (1370:32): [True: 13, False: 0]
-
  Branch (1370:32): [True: 8, False: 0]
-
1371
1.44k
    }
1372
1373
    /// Restart the matcher to process items in the item pool.
1374
    ///
1375
    /// If `force` is true, the matcher will be restarted even if it's currently running.
1376
    /// If `force` is false, the matcher will only be restarted if there are new items
1377
    /// to process or if the previous matcher has completed.
1378
1.34k
    pub fn restart_matcher(&mut self, force: bool) {
1379
        use crate::tui::item_list::MergeStrategy;
1380
        // Check if query meets minimum length requirement
1381
1.34k
        if self.query_below_min_length() {
  Branch (1381:12): [True: 10, False: 1.29k]
-
  Branch (1381:12): [True: 2, False: 39]
-
1382
            // Query is too short, clear items and don't run matcher
1383
12
            self.matcher_control.kill();
1384
12
            self.item_list.matcher_generation.fetch_add(1, Ordering::AcqRel);
1385
12
            self.item_list
1386
12
                .processed_items
1387
12
                .lock()
1388
12
                .unwrap_or_else(std::sync::PoisonError::into_inner)
1389
12
                .take();
1390
12
            self.item_list.items.clear();
1391
12
            self.item_list.current = 0;
1392
12
            self.item_list.offset = 0;
1393
12
            return;
1394
1.32k
        }
1395
1396
1.32k
        let matcher_stopped = self.matcher_control.stopped();
1397
1.32k
        if force || (
matcher_stopped546
&&
self.item_pool.num_not_taken() > 0449
) {
  Branch (1397:12): [True: 760, False: 530]
-  Branch (1397:22): [True: 436, False: 94]
-  Branch (1397:41): [True: 297, False: 139]
-
  Branch (1397:12): [True: 23, False: 16]
-  Branch (1397:22): [True: 13, False: 3]
-  Branch (1397:41): [True: 7, False: 6]
+
1355
19
            vec![sel]
1356
        } else {
1357
5
            vec![]
1358
        }
1359
42
    }
1360
1361
    /// Whether the current query is shorter than `--min-query-length`, meaning no
1362
    /// results should be produced yet.
1363
    ///
1364
    /// Always false when `--min-query-length` is unset, or under `--disabled`, where
1365
    /// the input is not used as a query.
1366
    #[must_use]
1367
1.39k
    pub fn query_below_min_length(&self) -> bool {
1368
1.39k
        self.options
1369
1.39k
            .min_query_length
1370
25
            .is_some_and(|min| !self.options.disabled && self.input.value.chars().count() < min)
  Branch (1370:32): [True: 13, False: 0]
+
  Branch (1370:32): [True: 12, False: 0]
+
1371
1.39k
    }
1372
1373
    /// Restart the matcher to process items in the item pool.
1374
    ///
1375
    /// If `force` is true, the matcher will be restarted even if it's currently running.
1376
    /// If `force` is false, the matcher will only be restarted if there are new items
1377
    /// to process or if the previous matcher has completed.
1378
1.31k
    pub fn restart_matcher(&mut self, force: bool) {
1379
        use crate::tui::item_list::MergeStrategy;
1380
        // Check if query meets minimum length requirement
1381
1.31k
        if self.query_below_min_length() {
  Branch (1381:12): [True: 10, False: 1.25k]
+
  Branch (1381:12): [True: 2, False: 48]
+
1382
            // Query is too short, clear items and don't run matcher
1383
12
            self.matcher_control.kill();
1384
12
            self.item_list.matcher_generation.fetch_add(1, Ordering::AcqRel);
1385
12
            self.item_list
1386
12
                .processed_items
1387
12
                .lock()
1388
12
                .unwrap_or_else(std::sync::PoisonError::into_inner)
1389
12
                .take();
1390
12
            self.item_list.items.clear();
1391
12
            self.item_list.current = 0;
1392
12
            self.item_list.offset = 0;
1393
12
            return;
1394
1.30k
        }
1395
1396
1.30k
        let matcher_stopped = self.matcher_control.stopped();
1397
1.30k
        if force || (
matcher_stopped517
&&
self.item_pool.num_not_taken() > 0439
) {
  Branch (1397:12): [True: 762, False: 492]
+  Branch (1397:22): [True: 422, False: 70]
+  Branch (1397:41): [True: 292, False: 130]
+
  Branch (1397:12): [True: 23, False: 25]
+  Branch (1397:22): [True: 17, False: 8]
+  Branch (1397:41): [True: 10, False: 7]
 
1398
1.08k
            trace!("restarting matcher, force={
force2
}");
1399
            // Reset debounce timer on any restart to prevent interference
1400
1.08k
            self.last_matcher_restart = std::time::Instant::now();
1401
1.08k
            self.pending_matcher_restart = false;
1402
1.08k
            self.matcher_control.kill();
1403
            // record matcher start time for statusline spinner/progress
1404
1.08k
            self.matcher_timer = std::time::Instant::now();
1405
            // In interactive mode, use empty query so all items are shown
1406
            // The input contains the command to execute, not a filter query
1407
1.08k
            let query = if self.options.disabled {
  Branch (1407:28): [True: 2, False: 1.05k]
-
  Branch (1407:28): [True: 0, False: 30]
-
1408
2
                ""
1409
1.08k
            } else if self.options.interactive {
  Branch (1409:23): [True: 84, False: 971]
-
  Branch (1409:23): [True: 2, False: 28]
-
1410
86
                &input::Input::default()
1411
            } else {
1412
999
                &self.input
1413
            };
1414
1.08k
            let item_pool = self.item_pool.clone();
1415
1.08k
            let thread_pool = &self.matcher_pool;
1416
1.08k
            let no_sort = self.options.no_sort;
1417
1418
1.08k
            if force {
  Branch (1418:16): [True: 760, False: 297]
-
  Branch (1418:16): [True: 23, False: 7]
-
1419
783
                self.item_pool.reset();
1420
304
            }
1421
1422
1.08k
            let generation = if force {
  Branch (1422:33): [True: 760, False: 297]
-
  Branch (1422:33): [True: 23, False: 7]
-
1423
783
                self.item_list.matcher_generation.fetch_add(1, Ordering::AcqRel) + 1
1424
            } else {
1425
304
                self.item_list.matcher_generation.load(Ordering::Acquire)
1426
            };
1427
1428
1.08k
            let merge_strategy = if force {
  Branch (1428:37): [True: 760, False: 297]
-
  Branch (1428:37): [True: 23, False: 7]
-
1429
783
                MergeStrategy::Replace
1430
304
            } else if no_sort && 
self.options.tac4
{
  Branch (1430:23): [True: 4, False: 293]
-  Branch (1430:34): [True: 0, False: 4]
-
  Branch (1430:23): [True: 0, False: 7]
+
  Branch (1407:28): [True: 0, False: 33]
+
1408
2
                ""
1409
1.08k
            } else if self.options.interactive {
  Branch (1409:23): [True: 85, False: 967]
+
  Branch (1409:23): [True: 2, False: 31]
+
1410
87
                &input::Input::default()
1411
            } else {
1412
998
                &self.input
1413
            };
1414
1.08k
            let item_pool = self.item_pool.clone();
1415
1.08k
            let thread_pool = &self.matcher_pool;
1416
1.08k
            let no_sort = self.options.no_sort;
1417
1418
1.08k
            if force {
  Branch (1418:16): [True: 762, False: 292]
+
  Branch (1418:16): [True: 23, False: 10]
+
1419
785
                self.item_pool.reset();
1420
302
            }
1421
1422
1.08k
            let generation = if force {
  Branch (1422:33): [True: 762, False: 292]
+
  Branch (1422:33): [True: 23, False: 10]
+
1423
785
                self.item_list.matcher_generation.fetch_add(1, Ordering::AcqRel) + 1
1424
            } else {
1425
302
                self.item_list.matcher_generation.load(Ordering::Acquire)
1426
            };
1427
1428
1.08k
            let merge_strategy = if force {
  Branch (1428:37): [True: 762, False: 292]
+
  Branch (1428:37): [True: 23, False: 10]
+
1429
785
                MergeStrategy::Replace
1430
302
            } else if no_sort && 
self.options.tac3
{
  Branch (1430:23): [True: 3, False: 289]
+  Branch (1430:34): [True: 0, False: 3]
+
  Branch (1430:23): [True: 0, False: 10]
   Branch (1430:34): [True: 0, False: 0]
-
1431
0
                MergeStrategy::Prepend
1432
304
            } else if no_sort {
  Branch (1432:23): [True: 4, False: 293]
-
  Branch (1432:23): [True: 0, False: 7]
-
1433
4
                MergeStrategy::Append
1434
            } else {
1435
300
                MergeStrategy::SortedMerge
1436
            };
1437
1438
1.08k
            self.matcher_control = self.matcher.run(
1439
1.08k
                query,
1440
1.08k
                &item_pool,
1441
1.08k
                thread_pool,
1442
1.08k
                self.item_list.processed_items.clone(),
1443
1.08k
                merge_strategy,
1444
1.08k
                no_sort,
1445
1.08k
                self.options.tac,
1446
1.08k
                generation,
1447
1.08k
                self.item_list.matcher_generation.clone(),
1448
1.08k
                self.needs_render.clone(),
1449
1.08k
            );
1450
            // A new search is in flight; arm the `result`/`zero`/`one` events to
1451
            // fire once it completes and its results are rendered.
1452
1.08k
            self.result_pending = true;
1453
242
        }
1454
1.34k
    }
1455
1456
8
    fn yank(&mut self, contents: String) {
1457
8
        self.yank_register = contents;
1458
8
    }
1459
1460
    /// Expand placeholders in a command string with current app state.
1461
    /// Replaces {}, {q}, {cq}, {n}, {+}, {+n}, and field patterns.
1462
    ///
1463
    /// Note: in command mode, the replstr is replaced by the current query
1464
    #[must_use]
1465
164
    pub fn expand_cmd(&self, cmd: &str, quote_args: bool) -> String {
1466
164
        util::printf(
1467
164
            cmd,
1468
164
            &self.options.delimiter,
1469
164
            &self.options.replstr,
1470
164
            &self.item_list.selection.iter(),
1471
164
            &self.item_list.selected(),
1472
164
            &self.input.value,
1473
164
            &self.input.value,
1474
164
            quote_args,
1475
164
        )
1476
164
    }
1477
1478
    /// Restart matcher with debouncing to avoid excessive restarts during rapid typing
1479
353
    fn restart_matcher_debounced(&mut self) {
1480
        const DEBOUNCE_MS: u64 = 10;
1481
1482
353
        if self.options.disabled {
  Branch (1482:12): [True: 1, False: 331]
+
1431
0
                MergeStrategy::Prepend
1432
302
            } else if no_sort {
  Branch (1432:23): [True: 3, False: 289]
+
  Branch (1432:23): [True: 0, False: 10]
+
1433
3
                MergeStrategy::Append
1434
            } else {
1435
299
                MergeStrategy::SortedMerge
1436
            };
1437
1438
1.08k
            self.matcher_control = self.matcher.run(
1439
1.08k
                query,
1440
1.08k
                &item_pool,
1441
1.08k
                thread_pool,
1442
1.08k
                self.item_list.processed_items.clone(),
1443
1.08k
                merge_strategy,
1444
1.08k
                no_sort,
1445
1.08k
                self.options.tac,
1446
1.08k
                generation,
1447
1.08k
                self.item_list.matcher_generation.clone(),
1448
1.08k
                self.needs_render.clone(),
1449
1.08k
            );
1450
            // A new search is in flight; arm the `result`/`zero`/`one` events to
1451
            // fire once it completes and its results are rendered.
1452
1.08k
            self.result_pending = true;
1453
215
        }
1454
1.31k
    }
1455
1456
8
    fn yank(&mut self, contents: String) {
1457
8
        self.yank_register = contents;
1458
8
    }
1459
1460
    /// Expand placeholders in a command string with current app state.
1461
    /// Replaces {}, {q}, {cq}, {n}, {+}, {+n}, and field patterns.
1462
    ///
1463
    /// Note: in command mode, the replstr is replaced by the current query
1464
    #[must_use]
1465
165
    pub fn expand_cmd(&self, cmd: &str, quote_args: bool) -> String {
1466
165
        util::printf(
1467
165
            cmd,
1468
165
            &self.options.delimiter,
1469
165
            &self.options.replstr,
1470
165
            &self.item_list.selection.iter(),
1471
165
            &self.item_list.selected(),
1472
165
            &self.input.value,
1473
165
            &self.input.value,
1474
165
            quote_args,
1475
165
        )
1476
165
    }
1477
1478
    /// Restart matcher with debouncing to avoid excessive restarts during rapid typing
1479
356
    fn restart_matcher_debounced(&mut self) {
1480
        const DEBOUNCE_MS: u64 = 10;
1481
1482
356
        if self.options.disabled {
  Branch (1482:12): [True: 1, False: 334]
 
  Branch (1482:12): [True: 0, False: 21]
-
1483
1
            return;
1484
352
        }
1485
1486
        // If enough time has passed since last restart, restart immediately
1487
352
        if self.last_matcher_restart.elapsed().as_millis() > u128::from(DEBOUNCE_MS) {
  Branch (1487:12): [True: 330, False: 1]
+
1483
1
            return;
1484
355
        }
1485
1486
        // If enough time has passed since last restart, restart immediately
1487
355
        if self.last_matcher_restart.elapsed().as_millis() > u128::from(DEBOUNCE_MS) {
  Branch (1487:12): [True: 333, False: 1]
 
  Branch (1487:12): [True: 0, False: 21]
-
1488
330
            debug!(
"restart_matcher_debounced: true"0
);
1489
330
            self.restart_matcher(true);
1490
        } else {
1491
22
            debug!(
"restart_matcher_debounced: false"0
);
1492
22
            self.pending_matcher_restart = true;
1493
        }
1494
353
    }
1495
1496
    /// Returns the border-adjusted inner rect of the list area.
1497
36
    fn list_inner_area(&self) -> ratatui::layout::Rect {
1498
36
        let list_area = self.layout.list_area;
1499
36
        if self.options.border.is_some() {
  Branch (1499:12): [True: 0, False: 2]
+
1488
333
            debug!(
"restart_matcher_debounced: true"0
);
1489
333
            self.restart_matcher(true);
1490
        } else {
1491
22
            debug!(
"restart_matcher_debounced: false"0
);
1492
22
            self.pending_matcher_restart = true;
1493
        }
1494
356
    }
1495
1496
    /// Returns the border-adjusted inner rect of the list area.
1497
36
    fn list_inner_area(&self) -> ratatui::layout::Rect {
1498
36
        let list_area = self.layout.list_area;
1499
36
        if self.options.border.is_some() {
  Branch (1499:12): [True: 0, False: 2]
 
  Branch (1499:12): [True: 12, False: 22]
 
1500
12
            ratatui::layout::Rect {
1501
12
                x: list_area.x + 1,
1502
12
                y: list_area.y + 1,
1503
12
                width: list_area.width.saturating_sub(2),
1504
12
                height: list_area.height.saturating_sub(2),
1505
12
            }
1506
        } else {
1507
24
            list_area
1508
        }
1509
36
    }
1510
1511
    /// Returns the inner rect of the list area and the x column of the scrollbar, but only
1512
    /// when the scrollbar is actually rendered (config enabled and items overflow the area).
1513
14
    fn scrollbar_column(&self) -> Option<(ratatui::layout::Rect, u16)> {
1514
14
        let inner = self.list_inner_area();
1515
14
        let available_rows = inner.height as usize;
1516
14
        if inner.width == 0 || self.item_list.scrollbar_thumb.is_empty() || 
self.item_list.items.len() <= available_rows7
  Branch (1516:12): [True: 0, False: 1]
   Branch (1516:32): [True: 0, False: 1]
@@ -604,4 +604,4 @@
 
  Branch (1607:29): [True: 6, False: 7]
 
1608
6
            Vec::new()
1609
        } else {
1610
8
            self.on_selection_changed()
1611
        };
1612
14
        if double_click {
  Branch (1612:12): [True: 0, False: 1]
 
  Branch (1612:12): [True: 1, False: 12]
-
1613
1
            events.push(Event::Key(SkimEvent::DoubleClick.into()));
1614
13
        }
1615
14
        Ok(events)
1616
18
    }
1617
24
    fn toggle_spinner(&mut self) {
1618
24
        self.show_spinner = !self.show_spinner;
1619
24
        self.spinner_last_change = std::time::Instant::now();
1620
24
        self.needs_render.store(true, Ordering::Relaxed);
1621
24
    }
1622
}
\ No newline at end of file +
1613
1
            events.push(Event::Key(SkimEvent::DoubleClick.into()));
1614
13
        }
1615
14
        Ok(events)
1616
18
    }
1617
23
    fn toggle_spinner(&mut self) {
1618
23
        self.show_spinner = !self.show_spinner;
1619
23
        self.spinner_last_change = std::time::Instant::now();
1620
23
        self.needs_render.store(true, Ordering::Relaxed);
1621
23
    }
1622
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/backend.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/backend.rs.html index da9a98a4..06a622c6 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/tui/backend.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/backend.rs.html @@ -1,24 +1,23 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/backend.rs
Line
Count
Source
1
use std::io::{BufWriter, stderr};
2
use std::ops::{Deref, DerefMut};
3
use std::process::Stdio;
4
use std::sync::Once;
5
6
use crossterm::event::{
7
    DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, KeyEventKind,
8
    KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
9
};
10
use crossterm::terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen};
11
use crossterm::{self, cursor};
12
use eyre::Result;
13
use futures::{FutureExt as _, StreamExt as _};
14
use ratatui::layout::Rect;
15
use ratatui::prelude::{Backend, CrosstermBackend};
16
use ratatui::{TerminalOptions, Viewport};
17
use tokio::sync::mpsc::{Receiver, Sender, channel};
18
use tokio::task::JoinHandle;
19
use tokio_util::sync::CancellationToken;
20
21
use super::util::cursor_pos_from_tty;
22
use super::{Event, Size, TICK_RATE};
23
24
static PANIC_HOOK_SET: Once = Once::new();
25
26
/// Terminal user interface handler for skim
27
pub struct Tui<B: Backend = ratatui::backend::CrosstermBackend<BufWriter<std::io::Stderr>>>
28
where
29
    B::Error: Send + Sync + 'static,
30
{
31
    /// The ratatui terminal instance
32
    pub terminal: ratatui::Terminal<B>,
33
    /// Background task handle for event polling
34
    pub task: Option<JoinHandle<()>>,
35
    /// Receiver for TUI events
36
    pub event_rx: Receiver<Event>,
37
    /// Sender for TUI events
38
    pub event_tx: Sender<Event>,
39
    /// Tick rate for updates (ticks per second)
40
    pub tick_rate: f64,
41
    /// Token for cancelling background tasks
42
    pub cancellation_token: CancellationToken,
43
    /// Whether running in fullscreen mode
44
    pub is_fullscreen: bool,
45
    /// The terminal's rect (drawing) area, set if the layout is inline
46
    rect: Option<Rect>,
47
    enable_mouse: bool,
48
}
49
50
impl Tui {
51
    /// Creates a TUI with the default backend (buffered stderr) and the specified height
52
    ///
53
    /// # Errors
54
    ///
55
    /// Returns an error if the TUI backend cannot be initialized.
56
10
    pub fn new_with_height(height: Size) -> Result<Self> {
57
10
        let backend = CrosstermBackend::new(std::io::BufWriter::new(stderr()));
58
10
        Self::new_with_height_and_backend(backend, height)
59
10
    }
60
    /// Disable mouse handling.
61
    /// Needs to be called before enter.
62
0
    pub fn disable_mouse(&mut self) -> &mut Self {
63
0
        self.enable_mouse = false;
64
0
        self
65
0
    }
66
}
67
68
impl<B: Backend> Tui<B>
69
where
70
    B::Error: Send + Sync + 'static,
71
{
72
    /// Creates a new TUI with the specified backend and height
73
    ///
74
    /// # Errors
75
    ///
76
    /// Returns an error if the terminal size cannot be determined or setup fails.
77
    ///
78
    /// # Panics
79
    ///
80
    /// Panics if the terminal size cannot be read from the backend.
81
424
    pub fn new_with_height_and_backend(backend: B, height: Size) -> Result<Self> {
82
424
        let event_channel = channel(1024 * 1024);
83
84
424
        let term_height = backend.size().expect("Failed to get terminal height").height;
85
424
        let lines = match height {
86
417
            Size::Percent(100) => None,
87
0
            Size::Fixed(lines) => Some(lines),
88
7
            Size::Percent(p) => Some(term_height * p / 100),
89
0
            Size::Neg(lines) => Some(term_height.saturating_sub(lines)),
90
        };
91
92
        let rect: Option<Rect>;
93
424
        let viewport = if let Some(mut height) = lines {
  Branch (93:31): [True: 0, False: 11]
-
  Branch (93:31): [True: 7, False: 3]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/backend.rs
Line
Count
Source
1
use std::io::{BufWriter, stderr};
2
use std::ops::{Deref, DerefMut};
3
use std::process::Stdio;
4
use std::sync::Once;
5
6
use crossterm::event::{
7
    DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, KeyEventKind,
8
    KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
9
};
10
use crossterm::terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen};
11
use crossterm::{self, cursor};
12
use eyre::Result;
13
use futures::{FutureExt as _, StreamExt as _};
14
use ratatui::layout::Rect;
15
use ratatui::prelude::{Backend, CrosstermBackend};
16
use ratatui::{TerminalOptions, Viewport};
17
use tokio::sync::mpsc::{Receiver, Sender, channel};
18
use tokio::task::JoinHandle;
19
use tokio_util::sync::CancellationToken;
20
21
use super::util::cursor_pos_from_tty;
22
use super::{Event, Size, TICK_RATE};
23
24
static PANIC_HOOK_SET: Once = Once::new();
25
26
/// Terminal user interface handler for skim
27
pub struct Tui<B: Backend = ratatui::backend::CrosstermBackend<BufWriter<std::io::Stderr>>>
28
where
29
    B::Error: Send + Sync + 'static,
30
{
31
    /// The ratatui terminal instance
32
    pub terminal: ratatui::Terminal<B>,
33
    /// Background task handle for event polling
34
    pub task: Option<JoinHandle<()>>,
35
    /// Receiver for TUI events
36
    pub event_rx: Receiver<Event>,
37
    /// Sender for TUI events
38
    pub event_tx: Sender<Event>,
39
    /// Tick rate for updates (ticks per second)
40
    pub tick_rate: f64,
41
    /// Token for cancelling background tasks
42
    pub cancellation_token: CancellationToken,
43
    /// Whether running in fullscreen mode
44
    pub is_fullscreen: bool,
45
    /// The terminal's rect (drawing) area, set if the layout is inline
46
    rect: Option<Rect>,
47
    enable_mouse: bool,
48
}
49
50
impl Tui {
51
    /// Creates a TUI with the default backend (buffered stderr) and the specified height
52
    ///
53
    /// # Errors
54
    ///
55
    /// Returns an error if the TUI backend cannot be initialized.
56
10
    pub fn new_with_height(height: Size) -> Result<Self> {
57
10
        let backend = CrosstermBackend::new(std::io::BufWriter::new(stderr()));
58
10
        Self::new_with_height_and_backend(backend, height)
59
10
    }
60
    /// Disable mouse handling.
61
    /// Needs to be called before enter.
62
0
    pub fn disable_mouse(&mut self) -> &mut Self {
63
0
        self.enable_mouse = false;
64
0
        self
65
0
    }
66
}
67
68
impl<B: Backend> Tui<B>
69
where
70
    B::Error: Send + Sync + 'static,
71
{
72
    /// Creates a new TUI with the specified backend and height
73
    ///
74
    /// # Errors
75
    ///
76
    /// Returns an error if the terminal size cannot be determined or setup fails.
77
    ///
78
    /// # Panics
79
    ///
80
    /// Panics if the terminal size cannot be read from the backend.
81
423
    pub fn new_with_height_and_backend(backend: B, height: Size) -> Result<Self> {
82
423
        let event_channel = channel(1024 * 1024);
83
84
423
        let term_height = backend.size().expect("Failed to get terminal height").height;
85
423
        let lines = match height {
86
416
            Size::Percent(100) => None,
87
0
            Size::Fixed(lines) => Some(lines),
88
7
            Size::Percent(p) => Some(term_height * p / 100),
89
0
            Size::Neg(lines) => Some(term_height.saturating_sub(lines)),
90
        };
91
92
        let rect: Option<Rect>;
93
423
        let viewport = if let Some(mut height) = lines {
  Branch (93:31): [True: 7, False: 3]
+
  Branch (93:31): [True: 0, False: 36]
+
  Branch (93:31): [True: 0, False: 22]
+
  Branch (93:31): [True: 0, False: 12]
+
  Branch (93:31): [True: 0, False: 5]
+
  Branch (93:31): [True: 0, False: 5]
+
  Branch (93:31): [True: 0, False: 1]
+
  Branch (93:31): [True: 0, False: 3]
+
  Branch (93:31): [True: 0, False: 23]
+
  Branch (93:31): [True: 0, False: 12]
+
  Branch (93:31): [True: 0, False: 58]
+
  Branch (93:31): [True: 0, False: 35]
+
  Branch (93:31): [True: 0, False: 9]
+
  Branch (93:31): [True: 0, False: 126]
+
  Branch (93:31): [True: 0, False: 27]
+
  Branch (93:31): [True: 0, False: 7]
+
  Branch (93:31): [True: 0, False: 11]
 
  Branch (93:31): [True: 0, False: 12]
 
  Branch (93:31): [True: 0, False: 9]
-
  Branch (93:31): [True: 0, False: 4]
-
  Branch (93:31): [True: 0, False: 7]
-
  Branch (93:31): [True: 0, False: 9]
-
  Branch (93:31): [True: 0, False: 26]
-
  Branch (93:31): [True: 0, False: 39]
-
  Branch (93:31): [True: 0, False: 33]
-
  Branch (93:31): [True: 0, False: 22]
-
  Branch (93:31): [True: 0, False: 130]
-
  Branch (93:31): [True: 0, False: 11]
-
  Branch (93:31): [True: 0, False: 58]
-
  Branch (93:31): [True: 0, False: 1]
-
  Branch (93:31): [True: 0, False: 2]
-
  Branch (93:31): [True: 0, False: 22]
-
  Branch (93:31): [True: 0, False: 5]
-
  Branch (93:31): [True: 0, False: 13]
-
94
            // Until https://github.com/crossterm-rs/crossterm/issues/919 is fixed, we need to do it ourselves
95
7
            let cursor_pos = cursor_pos_from_tty()
?0
;
96
7
            let mut y = cursor_pos.1 - 1;
97
7
            height = height.min(term_height);
98
7
            if term_height - cursor_pos.1 < height {
  Branch (98:16): [True: 0, False: 0]
-
  Branch (98:16): [True: 2, False: 5]
+
94
            // Until https://github.com/crossterm-rs/crossterm/issues/919 is fixed, we need to do it ourselves
95
7
            let cursor_pos = cursor_pos_from_tty()
?0
;
96
7
            let mut y = cursor_pos.1 - 1;
97
7
            height = height.min(term_height);
98
7
            if term_height - cursor_pos.1 < height {
  Branch (98:16): [True: 2, False: 5]
 
  Branch (98:16): [True: 0, False: 0]
 
  Branch (98:16): [True: 0, False: 0]
 
  Branch (98:16): [True: 0, False: 0]
@@ -36,29 +35,27 @@
 
  Branch (98:16): [True: 0, False: 0]
 
  Branch (98:16): [True: 0, False: 0]
 
  Branch (98:16): [True: 0, False: 0]
-
99
2
                let to_scroll = height - (term_height - cursor_pos.1) - 1;
100
2
                crossterm::execute!(stderr(), crossterm::terminal::ScrollUp(to_scroll))
?0
;
101
2
                y = y.saturating_sub(to_scroll);
102
5
            }
103
7
            rect = Some(Rect::new(
104
7
                0,
105
7
                y,
106
7
                backend.size().expect("Failed to get terminal width").width - 1,
107
7
                height,
108
7
            ));
109
7
            Viewport::Fixed(rect.unwrap())
110
        } else {
111
417
            rect = None;
112
417
            Viewport::Fullscreen
113
        };
114
115
424
        set_panic_hook();
116
        Ok(Self {
117
424
            terminal: ratatui::Terminal::with_options(backend, TerminalOptions { viewport })
?0
,
118
424
            task: None,
119
424
            rect,
120
424
            event_rx: event_channel.1,
121
424
            event_tx: event_channel.0,
122
424
            tick_rate: f64::from(TICK_RATE),
123
424
            cancellation_token: CancellationToken::default(),
124
424
            is_fullscreen: lines.is_none(),
125
424
            enable_mouse: true,
126
        })
127
424
    }
128
129
    /// Enters the TUI by enabling raw mode and starting event handling
130
    ///
131
    /// # Errors
132
    ///
133
    /// Returns an error if enabling raw mode or mouse capture fails.
134
0
    pub fn enter(&mut self) -> Result<()> {
135
0
        self.enter_terminal()?;
136
0
        self.start();
137
0
        Ok(())
138
0
    }
139
140
    /// Enables terminal modes and enters the alternate screen without starting event polling.
141
    ///
142
    /// This lets callers run terminal queries after alternate-screen entry but
143
    /// before the event stream starts reading terminal input.
144
    ///
145
    /// # Errors
146
    ///
147
    /// Returns an error if enabling raw mode or terminal features fails.
148
10
    pub fn enter_terminal(&mut self) -> Result<()> {
149
10
        crossterm::terminal::enable_raw_mode()
?0
;
150
        // On Windows, install a console ctrl handler so that CTRL_C_EVENT
151
        // performs terminal cleanup instead of killing the process abruptly.
152
        #[cfg(windows)]
153
        super::windows::install_ctrl_c_handler()?;
154
155
10
        self.execute_enter()
?0
;
156
10
        Ok(())
157
10
    }
158
159
    /// Exits the TUI by stopping event handling and disabling raw mode
160
    ///
161
    /// # Errors
162
    ///
163
    /// Returns an error if disabling raw mode or mouse capture fails.
164
425
    pub fn exit(&mut self) -> Result<()> {
165
425
        self.stop();
166
425
        cleanup_terminal()
?0
;
167
        // Remove our console ctrl handler now that raw mode is off.
168
        #[cfg(windows)]
169
        super::windows::uninstall_ctrl_c_handler();
170
        // When using the inline layout, we want to remove all previous output
171
        //  -> reset cursor at the top of the drawing area
172
425
        if !self.is_fullscreen {
  Branch (172:12): [True: 0, False: 11]
-
  Branch (172:12): [True: 7, False: 3]
+
  Branch (98:16): [True: 0, False: 0]
+
99
2
                let to_scroll = height - (term_height - cursor_pos.1) - 1;
100
2
                crossterm::execute!(stderr(), crossterm::terminal::ScrollUp(to_scroll))
?0
;
101
2
                y = y.saturating_sub(to_scroll);
102
5
            }
103
7
            rect = Some(Rect::new(
104
7
                0,
105
7
                y,
106
7
                backend.size().expect("Failed to get terminal width").width - 1,
107
7
                height,
108
7
            ));
109
7
            Viewport::Fixed(rect.unwrap())
110
        } else {
111
416
            rect = None;
112
416
            Viewport::Fullscreen
113
        };
114
115
423
        set_panic_hook();
116
        Ok(Self {
117
423
            terminal: ratatui::Terminal::with_options(backend, TerminalOptions { viewport })
?0
,
118
423
            task: None,
119
423
            rect,
120
423
            event_rx: event_channel.1,
121
423
            event_tx: event_channel.0,
122
423
            tick_rate: f64::from(TICK_RATE),
123
423
            cancellation_token: CancellationToken::default(),
124
423
            is_fullscreen: lines.is_none(),
125
423
            enable_mouse: true,
126
        })
127
423
    }
128
129
    /// Enters the TUI by enabling raw mode and starting event handling
130
    ///
131
    /// # Errors
132
    ///
133
    /// Returns an error if enabling raw mode or mouse capture fails.
134
0
    pub fn enter(&mut self) -> Result<()> {
135
0
        self.enter_terminal()?;
136
0
        self.start();
137
0
        Ok(())
138
0
    }
139
140
    /// Enables terminal modes and enters the alternate screen without starting event polling.
141
    ///
142
    /// This lets callers run terminal queries after alternate-screen entry but
143
    /// before the event stream starts reading terminal input.
144
    ///
145
    /// # Errors
146
    ///
147
    /// Returns an error if enabling raw mode or terminal features fails.
148
10
    pub fn enter_terminal(&mut self) -> Result<()> {
149
10
        crossterm::terminal::enable_raw_mode()
?0
;
150
        // On Windows, install a console ctrl handler so that CTRL_C_EVENT
151
        // performs terminal cleanup instead of killing the process abruptly.
152
        #[cfg(windows)]
153
        super::windows::install_ctrl_c_handler()?;
154
155
10
        self.execute_enter()
?0
;
156
10
        Ok(())
157
10
    }
158
159
    /// Exits the TUI by stopping event handling and disabling raw mode
160
    ///
161
    /// # Errors
162
    ///
163
    /// Returns an error if disabling raw mode or mouse capture fails.
164
424
    pub fn exit(&mut self) -> Result<()> {
165
424
        self.stop();
166
424
        cleanup_terminal()
?0
;
167
        // Remove our console ctrl handler now that raw mode is off.
168
        #[cfg(windows)]
169
        super::windows::uninstall_ctrl_c_handler();
170
        // When using the inline layout, we want to remove all previous output
171
        //  -> reset cursor at the top of the drawing area
172
424
        if !self.is_fullscreen {
  Branch (172:12): [True: 7, False: 3]
+
  Branch (172:12): [True: 0, False: 36]
+
  Branch (172:12): [True: 0, False: 22]
+
  Branch (172:12): [True: 0, False: 12]
+
  Branch (172:12): [True: 0, False: 5]
+
  Branch (172:12): [True: 0, False: 5]
+
  Branch (172:12): [True: 0, False: 1]
+
  Branch (172:12): [True: 0, False: 3]
+
  Branch (172:12): [True: 0, False: 23]
+
  Branch (172:12): [True: 0, False: 12]
+
  Branch (172:12): [True: 2, False: 57]
+
  Branch (172:12): [True: 0, False: 35]
+
  Branch (172:12): [True: 0, False: 9]
+
  Branch (172:12): [True: 0, False: 126]
+
  Branch (172:12): [True: 0, False: 27]
+
  Branch (172:12): [True: 0, False: 7]
+
  Branch (172:12): [True: 0, False: 11]
 
  Branch (172:12): [True: 0, False: 12]
 
  Branch (172:12): [True: 0, False: 9]
-
  Branch (172:12): [True: 0, False: 4]
-
  Branch (172:12): [True: 0, False: 7]
-
  Branch (172:12): [True: 0, False: 9]
-
  Branch (172:12): [True: 0, False: 26]
-
  Branch (172:12): [True: 0, False: 39]
-
  Branch (172:12): [True: 0, False: 33]
-
  Branch (172:12): [True: 0, False: 22]
-
  Branch (172:12): [True: 0, False: 130]
-
  Branch (172:12): [True: 0, False: 11]
-
  Branch (172:12): [True: 2, False: 57]
-
  Branch (172:12): [True: 0, False: 1]
-
  Branch (172:12): [True: 0, False: 2]
-
  Branch (172:12): [True: 0, False: 22]
-
  Branch (172:12): [True: 0, False: 5]
-
  Branch (172:12): [True: 0, False: 13]
-
173
9
            let area = self.get_frame().area();
174
9
            let orig = ratatui::layout::Position { x: area.x, y: area.y };
175
9
            crossterm::execute!(
176
9
                stderr(),
177
                cursor::MoveTo(orig.x, orig.y),
178
                Clear(ClearType::FromCursorDown)
179
0
            )?;
180
9
            self.set_cursor_position(orig)
?0
;
181
416
        }
182
425
        Ok(())
183
425
    }
184
    /// Stops the TUI event loop
185
    /// Equivalent to `self.cancel()`
186
426
    pub fn stop(&self) {
187
426
        self.cancel();
188
426
    }
189
    /// Forces the next [`draw`](ratatui::Terminal::draw) to repaint every cell.
190
    ///
191
    /// ratatui only writes cells that differ from the previously drawn buffer.
192
    /// After the display has been disturbed out from under it — e.g. an
193
    /// `execute` action that ran a child program and re-entered the alternate
194
    /// screen — that cached buffer is stale and a normal draw would leave the
195
    /// screen partially blank. Resetting *both* double buffers makes the next
196
    /// draw diff against an empty buffer and thus repaint everything.
197
    ///
198
    /// Unlike [`ratatui::Terminal::clear`], this performs no cursor-position
199
    /// query (which crossterm writes to stdout and which stalls when stdout is
200
    /// redirected), and it is viewport-agnostic (works for fullscreen and
201
    /// inline layouts alike).
202
2
    pub fn force_full_redraw(&mut self) {
203
2
        self.terminal.swap_buffers();
204
2
        self.terminal.swap_buffers();
205
2
    }
206
    /// Stops the input reader and waits for it to release the terminal.
207
    ///
208
    /// Unlike [`stop`](Self::stop), this blocks until the background task has
209
    /// observed the cancellation and dropped its `EventStream`, so crossterm's
210
    /// internal reader thread has stopped reading the terminal before this
211
    /// returns. Call this before handing the terminal to a foreground child
212
    /// process (e.g. an `execute` action): otherwise skim's reader competes
213
    /// with the child for keystrokes and interactive TUIs appear to freeze.
214
    ///
215
    /// Restart the reader afterwards with [`start`](Self::start).
216
    ///
217
    /// # Panics
218
    ///
219
    /// Panics if called from outside a multi-threaded Tokio runtime, since it
220
    /// uses `block_in_place` to await the reader task from synchronous code.
221
1
    pub fn stop_and_join(&mut self) {
222
1
        self.cancel();
223
1
        if let Some(task) = self.task.take() {
  Branch (223:16): [True: 0, False: 0]
-
  Branch (223:16): [True: 0, False: 0]
-
  Branch (223:16): [True: 0, False: 0]
-
  Branch (223:16): [True: 0, False: 0]
+
173
9
            let area = self.get_frame().area();
174
9
            let orig = ratatui::layout::Position { x: area.x, y: area.y };
175
9
            crossterm::execute!(
176
9
                stderr(),
177
                cursor::MoveTo(orig.x, orig.y),
178
                Clear(ClearType::FromCursorDown)
179
0
            )?;
180
9
            self.set_cursor_position(orig)
?0
;
181
415
        }
182
424
        Ok(())
183
424
    }
184
    /// Stops the TUI event loop
185
    /// Equivalent to `self.cancel()`
186
425
    pub fn stop(&self) {
187
425
        self.cancel();
188
425
    }
189
    /// Forces the next [`draw`](ratatui::Terminal::draw) to repaint every cell.
190
    ///
191
    /// ratatui only writes cells that differ from the previously drawn buffer.
192
    /// After the display has been disturbed out from under it — e.g. an
193
    /// `execute` action that ran a child program and re-entered the alternate
194
    /// screen — that cached buffer is stale and a normal draw would leave the
195
    /// screen partially blank. Resetting *both* double buffers makes the next
196
    /// draw diff against an empty buffer and thus repaint everything.
197
    ///
198
    /// Unlike [`ratatui::Terminal::clear`], this performs no cursor-position
199
    /// query (which crossterm writes to stdout and which stalls when stdout is
200
    /// redirected), and it is viewport-agnostic (works for fullscreen and
201
    /// inline layouts alike).
202
2
    pub fn force_full_redraw(&mut self) {
203
2
        self.terminal.swap_buffers();
204
2
        self.terminal.swap_buffers();
205
2
    }
206
    /// Stops the input reader and waits for it to release the terminal.
207
    ///
208
    /// Unlike [`stop`](Self::stop), this blocks until the background task has
209
    /// observed the cancellation and dropped its `EventStream`, so crossterm's
210
    /// internal reader thread has stopped reading the terminal before this
211
    /// returns. Call this before handing the terminal to a foreground child
212
    /// process (e.g. an `execute` action): otherwise skim's reader competes
213
    /// with the child for keystrokes and interactive TUIs appear to freeze.
214
    ///
215
    /// Restart the reader afterwards with [`start`](Self::start).
216
    ///
217
    /// # Panics
218
    ///
219
    /// Panics if called from outside a multi-threaded Tokio runtime, since it
220
    /// uses `block_in_place` to await the reader task from synchronous code.
221
1
    pub fn stop_and_join(&mut self) {
222
1
        self.cancel();
223
1
        if let Some(task) = self.task.take() {
  Branch (223:16): [True: 0, False: 0]
 
  Branch (223:16): [True: 0, False: 0]
 
  Branch (223:16): [True: 0, False: 0]
 
  Branch (223:16): [True: 0, False: 0]
@@ -74,10 +71,10 @@
 
  Branch (223:16): [True: 0, False: 0]
 
  Branch (223:16): [True: 0, False: 0]
 
  Branch (223:16): [True: 0, False: 0]
-
224
            // We are on a synchronous call stack nested inside the async event
225
            // loop. `block_in_place` moves this worker off the async pool so we
226
            // can block on the task's completion without starving the runtime.
227
0
            let _ = tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(task));
228
1
        }
229
1
    }
230
    /// Cancels all background tasks
231
429
    pub fn cancel(&self) {
232
429
        self.cancellation_token.cancel();
233
429
    }
234
    /// Starts the event loop for handling keyboard and timer events
235
11
    pub fn start(&mut self) {
236
11
        let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate);
237
11
        let event_tx_clone = self.event_tx.clone();
238
        // Cancel any previously running reader before spawning a new one.
239
11
        if self.task.is_some() {
  Branch (239:12): [True: 0, False: 0]
-
  Branch (239:12): [True: 0, False: 10]
-
  Branch (239:12): [True: 0, False: 0]
-
  Branch (239:12): [True: 0, False: 0]
+
  Branch (223:16): [True: 0, False: 0]
+
  Branch (223:16): [True: 0, False: 0]
+
  Branch (223:16): [True: 0, False: 0]
+
224
            // We are on a synchronous call stack nested inside the async event
225
            // loop. `block_in_place` moves this worker off the async pool so we
226
            // can block on the task's completion without starving the runtime.
227
0
            let _ = tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(task));
228
1
        }
229
1
    }
230
    /// Cancels all background tasks
231
428
    pub fn cancel(&self) {
232
428
        self.cancellation_token.cancel();
233
428
    }
234
    /// Starts the event loop for handling keyboard and timer events
235
11
    pub fn start(&mut self) {
236
11
        let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate);
237
11
        let event_tx_clone = self.event_tx.clone();
238
        // Cancel any previously running reader before spawning a new one.
239
11
        if self.task.is_some() {
  Branch (239:12): [True: 0, False: 10]
 
  Branch (239:12): [True: 0, False: 0]
 
  Branch (239:12): [True: 0, False: 0]
 
  Branch (239:12): [True: 0, False: 0]
@@ -93,8 +90,10 @@
 
  Branch (239:12): [True: 0, False: 0]
 
  Branch (239:12): [True: 0, False: 0]
 
  Branch (239:12): [True: 0, False: 0]
-
240
0
            self.cancel();
241
11
        }
242
        // Install a fresh cancellation token: a `CancellationToken` stays
243
        // cancelled once cancelled, so reusing the old one (after `stop`,
244
        // `stop_and_join`, or a prior `start`) would make the new task observe
245
        // the cancellation immediately and exit without reading any input.
246
        // This is what lets the reader resume after an `execute` action.
247
11
        self.cancellation_token = CancellationToken::new();
248
11
        let cancellation_token_clone = self.cancellation_token.clone();
249
11
        self.task = Some(tokio::spawn(async move {
250
11
            let mut reader = crossterm::event::EventStream::new();
251
11
            let mut tick_interval = tokio::time::interval(tick_delay);
252
            loop {
253
64
                let tick_delay = tick_interval.tick();
254
64
                let crossterm_event = reader.next().fuse();
255
64
                tokio::select! {
256
64
                    () = cancellation_token_clone.cancelled() => {
257
1
                        break;
258
                    }
259
64
                    maybe_event = crossterm_event => {
260
6
                      match maybe_event {
261
                        Some(Ok(crossterm::event::Event::Key(key))) => {
262
7
                          if key.kind == KeyEventKind::Press {
  Branch (262:30): [True: 0, False: 0]
-
  Branch (262:30): [True: 7, False: 0]
+
  Branch (239:12): [True: 0, False: 0]
+
  Branch (239:12): [True: 0, False: 0]
+
  Branch (239:12): [True: 0, False: 0]
+
240
0
            self.cancel();
241
11
        }
242
        // Install a fresh cancellation token: a `CancellationToken` stays
243
        // cancelled once cancelled, so reusing the old one (after `stop`,
244
        // `stop_and_join`, or a prior `start`) would make the new task observe
245
        // the cancellation immediately and exit without reading any input.
246
        // This is what lets the reader resume after an `execute` action.
247
11
        self.cancellation_token = CancellationToken::new();
248
11
        let cancellation_token_clone = self.cancellation_token.clone();
249
11
        self.task = Some(tokio::spawn(async move {
250
11
            let mut reader = crossterm::event::EventStream::new();
251
11
            let mut tick_interval = tokio::time::interval(tick_delay);
252
            loop {
253
61
                let tick_delay = tick_interval.tick();
254
61
                let crossterm_event = reader.next().fuse();
255
61
                tokio::select! {
256
61
                    () = cancellation_token_clone.cancelled() => {
257
1
                        break;
258
                    }
259
61
                    maybe_event = crossterm_event => {
260
6
                      match maybe_event {
261
                        Some(Ok(crossterm::event::Event::Key(key))) => {
262
7
                          if key.kind == KeyEventKind::Press {
  Branch (262:30): [True: 7, False: 0]
 
  Branch (262:30): [True: 0, False: 0]
 
  Branch (262:30): [True: 0, False: 0]
 
  Branch (262:30): [True: 0, False: 0]
@@ -112,8 +111,8 @@
 
  Branch (262:30): [True: 0, False: 0]
 
  Branch (262:30): [True: 0, False: 0]
 
  Branch (262:30): [True: 0, False: 0]
-
263
7
                            _ = event_tx_clone.try_send(Event::Key(key));
264
0
                          }
265
                        }
266
                        Some(Ok(crossterm::event::Event::Paste(text))) => {
267
0
                          _ = event_tx_clone.try_send(Event::Paste(text));
268
                        }
269
                        Some(Ok(crossterm::event::Event::Mouse(mouse))) => {
270
0
                          _ = event_tx_clone.try_send(Event::Mouse(mouse));
271
                        }
272
                        Some(Ok(crossterm::event::Event::Resize(cols, rows))) => {
273
0
                          _ = event_tx_clone.try_send(Event::Resize(cols, rows));
274
0
                          _ = event_tx_clone.try_send(Event::Render);
275
                        }
276
                        Some(Err(e)) => {
277
0
                          _ = event_tx_clone.try_send(Event::Error(e.to_string()));
278
                        }
279
18.4E
                        None | Some(Ok(_)) => {},
280
                      }
281
                    },
282
64
                    _ = tick_delay => {
283
47
                        _ = event_tx_clone.try_send(Event::Heartbeat);
284
                    },
285
                }
286
            }
287
1
        }));
288
11
    }
289
290
    /// Gets the next event from the event queue
291
161
    pub async fn next(&mut self) -> Option<Event> 
{147
292
147
        self.event_rx.recv().await
293
138
    }
294
295
10
    fn execute_enter(&self) -> Result<()> {
296
10
        crossterm::execute!(stderr(), EnableBracketedPaste)
?0
;
297
10
        if self.enable_mouse {
  Branch (297:12): [True: 0, False: 0]
-
  Branch (297:12): [True: 10, False: 0]
+
  Branch (262:30): [True: 0, False: 0]
+
263
7
                            _ = event_tx_clone.try_send(Event::Key(key));
264
0
                          }
265
                        }
266
                        Some(Ok(crossterm::event::Event::Paste(text))) => {
267
0
                          _ = event_tx_clone.try_send(Event::Paste(text));
268
                        }
269
                        Some(Ok(crossterm::event::Event::Mouse(mouse))) => {
270
0
                          _ = event_tx_clone.try_send(Event::Mouse(mouse));
271
                        }
272
                        Some(Ok(crossterm::event::Event::Resize(cols, rows))) => {
273
0
                          _ = event_tx_clone.try_send(Event::Resize(cols, rows));
274
0
                          _ = event_tx_clone.try_send(Event::Render);
275
                        }
276
                        Some(Err(e)) => {
277
0
                          _ = event_tx_clone.try_send(Event::Error(e.to_string()));
278
                        }
279
18.4E
                        None | Some(Ok(_)) => {},
280
                      }
281
                    },
282
61
                    _ = tick_delay => {
283
44
                        _ = event_tx_clone.try_send(Event::Heartbeat);
284
                    },
285
                }
286
            }
287
1
        }));
288
11
    }
289
290
    /// Gets the next event from the event queue
291
160
    pub async fn next(&mut self) -> Option<Event> 
{148
292
148
        self.event_rx.recv().await
293
136
    }
294
295
10
    fn execute_enter(&self) -> Result<()> {
296
10
        crossterm::execute!(stderr(), EnableBracketedPaste)
?0
;
297
10
        if self.enable_mouse {
  Branch (297:12): [True: 10, False: 0]
 
  Branch (297:12): [True: 0, False: 0]
 
  Branch (297:12): [True: 0, False: 0]
 
  Branch (297:12): [True: 0, False: 0]
@@ -131,8 +130,8 @@
 
  Branch (297:12): [True: 0, False: 0]
 
  Branch (297:12): [True: 0, False: 0]
 
  Branch (297:12): [True: 0, False: 0]
-
298
10
            crossterm::execute!(stderr(), EnableMouseCapture)
?0
;
299
0
        }
300
10
        if self.is_fullscreen {
  Branch (300:12): [True: 0, False: 0]
-
  Branch (300:12): [True: 3, False: 7]
+
  Branch (297:12): [True: 0, False: 0]
+
298
10
            crossterm::execute!(stderr(), EnableMouseCapture)
?0
;
299
0
        }
300
10
        if self.is_fullscreen {
  Branch (300:12): [True: 3, False: 7]
 
  Branch (300:12): [True: 0, False: 0]
 
  Branch (300:12): [True: 0, False: 0]
 
  Branch (300:12): [True: 0, False: 0]
@@ -150,8 +149,9 @@
 
  Branch (300:12): [True: 0, False: 0]
 
  Branch (300:12): [True: 0, False: 0]
 
  Branch (300:12): [True: 0, False: 0]
-
301
3
            crossterm::execute!(stderr(), EnterAlternateScreen, cursor::Hide)
?0
;
302
7
        }
303
10
        if let Err(e) = crossterm::execute!(
  Branch (303:16): [True: 0, False: 0]
-
  Branch (303:16): [True: 0, False: 10]
+
  Branch (300:12): [True: 0, False: 0]
+
301
3
            crossterm::execute!(stderr(), EnterAlternateScreen, cursor::Hide)
?0
;
302
7
        }
303
10
        if let Err(e) = crossterm::execute!(
  Branch (303:16): [True: 0, False: 10]
+
  Branch (303:16): [True: 0, False: 0]
 
  Branch (303:16): [True: 0, False: 0]
 
  Branch (303:16): [True: 0, False: 0]
 
  Branch (303:16): [True: 0, False: 0]
@@ -274,15 +274,15 @@
 
  Branch (373:12): [True: 0, False: 0]
 
  Branch (373:12): [True: 0, False: 0]
 
  Branch (373:12): [True: 0, False: 0]
-
  Branch (373:12): [True: 0, False: 0]
-
  Branch (373:12): [True: 0, False: 0]
-
  Branch (373:12): [True: 0, False: 0]
 
  Branch (373:12): [True: 0, False: 1]
 
  Branch (373:12): [True: 0, False: 0]
 
  Branch (373:12): [True: 0, False: 0]
 
  Branch (373:12): [True: 0, False: 0]
 
  Branch (373:12): [True: 0, False: 0]
 
  Branch (373:12): [True: 0, False: 0]
+
  Branch (373:12): [True: 0, False: 0]
+
  Branch (373:12): [True: 0, False: 0]
+
  Branch (373:12): [True: 0, False: 0]
 
374
0
            in_raw_mode = self.pause()?;
375
1
        }
376
377
1
        let mut command = crate::shell_cmd(cmd);
378
1
        command.stdin(execute_child_stdin());
379
1
        let _ = command.spawn().and_then(|mut c| c.wait());
380
381
1
        let mut restore_result = Ok(());
382
1
        if has_tty {
  Branch (382:12): [True: 0, False: 0]
 
  Branch (382:12): [True: 0, False: 0]
 
  Branch (382:12): [True: 0, False: 0]
@@ -293,15 +293,15 @@
 
  Branch (382:12): [True: 0, False: 0]
 
  Branch (382:12): [True: 0, False: 0]
 
  Branch (382:12): [True: 0, False: 0]
-
  Branch (382:12): [True: 0, False: 0]
-
  Branch (382:12): [True: 0, False: 0]
-
  Branch (382:12): [True: 0, False: 0]
 
  Branch (382:12): [True: 0, False: 1]
 
  Branch (382:12): [True: 0, False: 0]
 
  Branch (382:12): [True: 0, False: 0]
 
  Branch (382:12): [True: 0, False: 0]
 
  Branch (382:12): [True: 0, False: 0]
 
  Branch (382:12): [True: 0, False: 0]
+
  Branch (382:12): [True: 0, False: 0]
+
  Branch (382:12): [True: 0, False: 0]
+
  Branch (382:12): [True: 0, False: 0]
 
383
0
            restore_result = self.resume(in_raw_mode);
384
1
        }
385
386
        // Resume skim's input reader now that the terminal is ours again.
387
1
        self.start();
388
1
        restore_result
389
1
    }
390
391
    /// Set the minimum height of an inline viewport.
392
    ///
393
    /// Scrolls the terminal when there are not enough rows below the viewport's
394
    /// current origin.
395
    ///
396
    /// # Errors
397
    ///
398
    /// Returns an error if the terminal size cannot be read, the terminal cannot
399
    /// be scrolled, or the viewport cannot be resized.
400
11
    pub fn min_height(&mut self, min_height: u16) -> Result<()> {
401
11
        if self.is_fullscreen {
  Branch (401:12): [True: 3, False: 7]
 
  Branch (401:12): [True: 0, False: 1]
 
402
3
            return Ok(());
403
8
        }
404
405
8
        let Some(current_rect) = self.rect else {
  Branch (405:13): [True: 7, False: 0]
@@ -310,25 +310,25 @@
 
  Branch (410:12): [True: 0, False: 1]
 
411
4
            return Ok(());
412
4
        }
413
414
4
        if to_scroll > 0 {
  Branch (414:12): [True: 2, False: 1]
 
  Branch (414:12): [True: 1, False: 0]
-
415
3
            crossterm::execute!(stderr(), crossterm::terminal::ScrollUp(to_scroll))
?0
;
416
1
        }
417
4
        debug!("min_height: resizing TUI to {
rect0
:?}");
418
4
        self.resize(rect)
?0
;
419
4
        self.rect = Some(rect);
420
4
        Ok(())
421
11
    }
422
}
423
424
12
fn rect_with_min_height(mut rect: Rect, min_height: u16, terminal_height: u16) -> (Rect, u16) {
425
12
    rect.height = rect.height.max(min_height).min(terminal_height);
426
12
    let lowest_origin = terminal_height.saturating_sub(rect.height);
427
12
    let to_scroll = rect.y.saturating_sub(lowest_origin);
428
12
    rect.y = rect.y.saturating_sub(to_scroll);
429
12
    (rect, to_scroll)
430
12
}
431
432
impl<B: Backend> Deref for Tui<B>
433
where
434
    B::Error: Send + Sync + 'static,
435
{
436
    type Target = ratatui::Terminal<B>;
437
438
620
    fn deref(&self) -> &Self::Target {
439
620
        &self.terminal
440
620
    }
441
}
442
443
impl<B: Backend> DerefMut for Tui<B>
444
where
445
    B::Error: Send + Sync + 'static,
446
{
447
5.44k
    fn deref_mut(&mut self) -> &mut Self::Target {
448
5.44k
        &mut self.terminal
449
5.44k
    }
450
}
451
452
impl<B: Backend> Drop for Tui<B>
453
where
454
    B::Error: Send + Sync + 'static,
455
{
456
424
    fn drop(&mut self) {
457
424
        if let Some(t) = self.task.take() {
  Branch (457:16): [True: 0, False: 11]
-
  Branch (457:16): [True: 10, False: 0]
+
415
3
            crossterm::execute!(stderr(), crossterm::terminal::ScrollUp(to_scroll))
?0
;
416
1
        }
417
4
        debug!("min_height: resizing TUI to {
rect0
:?}");
418
4
        self.resize(rect)
?0
;
419
4
        self.rect = Some(rect);
420
4
        Ok(())
421
11
    }
422
}
423
424
12
fn rect_with_min_height(mut rect: Rect, min_height: u16, terminal_height: u16) -> (Rect, u16) {
425
12
    rect.height = rect.height.max(min_height).min(terminal_height);
426
12
    let lowest_origin = terminal_height.saturating_sub(rect.height);
427
12
    let to_scroll = rect.y.saturating_sub(lowest_origin);
428
12
    rect.y = rect.y.saturating_sub(to_scroll);
429
12
    (rect, to_scroll)
430
12
}
431
432
impl<B: Backend> Deref for Tui<B>
433
where
434
    B::Error: Send + Sync + 'static,
435
{
436
    type Target = ratatui::Terminal<B>;
437
438
619
    fn deref(&self) -> &Self::Target {
439
619
        &self.terminal
440
619
    }
441
}
442
443
impl<B: Backend> DerefMut for Tui<B>
444
where
445
    B::Error: Send + Sync + 'static,
446
{
447
5.45k
    fn deref_mut(&mut self) -> &mut Self::Target {
448
5.45k
        &mut self.terminal
449
5.45k
    }
450
}
451
452
impl<B: Backend> Drop for Tui<B>
453
where
454
    B::Error: Send + Sync + 'static,
455
{
456
423
    fn drop(&mut self) {
457
423
        if let Some(t) = self.task.take() {
  Branch (457:16): [True: 10, False: 0]
+
  Branch (457:16): [True: 0, False: 36]
+
  Branch (457:16): [True: 0, False: 22]
+
  Branch (457:16): [True: 0, False: 12]
+
  Branch (457:16): [True: 0, False: 5]
+
  Branch (457:16): [True: 0, False: 5]
+
  Branch (457:16): [True: 0, False: 1]
+
  Branch (457:16): [True: 0, False: 3]
+
  Branch (457:16): [True: 0, False: 23]
+
  Branch (457:16): [True: 0, False: 12]
+
  Branch (457:16): [True: 1, False: 57]
+
  Branch (457:16): [True: 0, False: 35]
+
  Branch (457:16): [True: 0, False: 9]
+
  Branch (457:16): [True: 0, False: 126]
+
  Branch (457:16): [True: 0, False: 27]
+
  Branch (457:16): [True: 0, False: 7]
+
  Branch (457:16): [True: 0, False: 11]
 
  Branch (457:16): [True: 0, False: 12]
 
  Branch (457:16): [True: 0, False: 9]
-
  Branch (457:16): [True: 0, False: 4]
-
  Branch (457:16): [True: 0, False: 7]
-
  Branch (457:16): [True: 0, False: 9]
-
  Branch (457:16): [True: 0, False: 26]
-
  Branch (457:16): [True: 0, False: 39]
-
  Branch (457:16): [True: 0, False: 33]
-
  Branch (457:16): [True: 0, False: 22]
-
  Branch (457:16): [True: 0, False: 130]
-
  Branch (457:16): [True: 0, False: 11]
-
  Branch (457:16): [True: 1, False: 57]
-
  Branch (457:16): [True: 0, False: 1]
-
  Branch (457:16): [True: 0, False: 2]
-
  Branch (457:16): [True: 0, False: 22]
-
  Branch (457:16): [True: 0, False: 5]
-
  Branch (457:16): [True: 0, False: 13]
-
458
11
            t.abort();
459
413
        }
460
424
        let _ = self.exit();
461
424
    }
462
}
463
464
424
fn set_panic_hook() {
465
424
    PANIC_HOOK_SET.call_once(|| 
{423
466
423
        let hook = std::panic::take_hook();
467
423
        std::panic::set_hook(Box::new(move |panic_info| 
{19
468
19
            let _ = cleanup_terminal();
469
            #[cfg(windows)]
470
            super::windows::uninstall_ctrl_c_handler();
471
19
            hook(panic_info);
472
19
        }));
473
423
    });
474
424
}
475
476
/// Perform terminal cleanup: disable mouse capture, bracketed paste,
477
/// leave alternate screen, show cursor, and disable raw mode.
478
///
479
/// This is safe to call from any thread since:
480
/// - Escape sequences are written atomically to stderr
481
/// - `SetConsoleMode` (used by `disable_raw_mode`) is thread-safe on Windows
482
444
pub(crate) fn cleanup_terminal() -> std::io::Result<()> {
483
444
    if let Err(e) = crossterm::execute!(stderr(), PopKeyboardEnhancementFlags) {
  Branch (483:12): [True: 0, False: 384]
+
458
11
            t.abort();
459
412
        }
460
423
        let _ = self.exit();
461
423
    }
462
}
463
464
423
fn set_panic_hook() {
465
423
    PANIC_HOOK_SET.call_once(|| 
{422
466
422
        let hook = std::panic::take_hook();
467
422
        std::panic::set_hook(Box::new(move |panic_info| 
{18
468
18
            let _ = cleanup_terminal();
469
            #[cfg(windows)]
470
            super::windows::uninstall_ctrl_c_handler();
471
18
            hook(panic_info);
472
18
        }));
473
422
    });
474
423
}
475
476
/// Perform terminal cleanup: disable mouse capture, bracketed paste,
477
/// leave alternate screen, show cursor, and disable raw mode.
478
///
479
/// This is safe to call from any thread since:
480
/// - Escape sequences are written atomically to stderr
481
/// - `SetConsoleMode` (used by `disable_raw_mode`) is thread-safe on Windows
482
442
pub(crate) fn cleanup_terminal() -> std::io::Result<()> {
483
442
    if let Err(e) = crossterm::execute!(stderr(), PopKeyboardEnhancementFlags) {
  Branch (483:12): [True: 0, False: 382]
 
  Branch (483:12): [True: 0, False: 60]
-
484
0
        warn!("Failed to remove keyboard enhancement flags: {e}");
485
444
    }
486
444
    crossterm::execute!(
487
444
        stderr(),
488
        DisableMouseCapture,
489
        DisableBracketedPaste,
490
        LeaveAlternateScreen,
491
        cursor::Show
492
0
    )?;
493
444
    crossterm::terminal::disable_raw_mode()
?0
;
494
444
    Ok(())
495
444
}
496
497
/// Build the stdin handle for an `execute` child process.
498
///
499
/// skim's own stdin (fd 0) is frequently a pipe carrying the item list
500
/// (e.g. `find | sk`), which is useless as a keyboard source for an
501
/// interactive child. Hand the child a fresh handle to the controlling
502
/// terminal instead, so programs like `ncdu` or other ncurses TUIs can read
503
/// the keyboard even when skim's stdin is a pipe. Falls back to inheriting
504
/// skim's stdin if the terminal cannot be opened.
505
1
fn execute_child_stdin() -> std::process::Stdio {
506
    #[cfg(unix)]
507
1
    let tty = std::fs::File::open("/dev/tty");
508
    #[cfg(windows)]
509
    let tty = std::fs::OpenOptions::new().read(true).write(true).open("CONIN$");
510
1
    tty.map_or_else(|_| Stdio::inherit(), Stdio::from)
511
1
}
512
513
#[cfg(test)]
514
#[cfg_attr(coverage, coverage(off))]
515
mod tests {
516
    use super::*;
517
    use ratatui::backend::TestBackend;
518
519
    fn fullscreen_tui() -> Tui<TestBackend> {
520
        // Percent(100) selects the fullscreen viewport, avoiding any TTY cursor query.
521
        Tui::new_with_height_and_backend(TestBackend::new(80, 24), Size::Percent(100))
522
            .expect("failed to build test TUI")
523
    }
524
525
    fn inline_tui(rect: Rect) -> Tui<TestBackend> {
526
        let mut tui = fullscreen_tui();
527
        tui.is_fullscreen = false;
528
        tui.rect = Some(rect);
529
        tui.resize(rect).expect("failed to set initial viewport");
530
        tui
531
    }
532
533
    #[test]
534
    fn new_with_full_height_is_fullscreen() {
535
        let tui = fullscreen_tui();
536
        assert!(tui.is_fullscreen);
537
        assert!(tui.enable_mouse);
538
    }
539
540
    #[test]
541
    fn stop_cancels_token() {
542
        let tui = fullscreen_tui();
543
        assert!(!tui.cancellation_token.is_cancelled());
544
        tui.stop();
545
        assert!(tui.cancellation_token.is_cancelled());
546
    }
547
548
    #[test]
549
    fn cancel_is_idempotent() {
550
        let tui = fullscreen_tui();
551
        tui.cancel();
552
        tui.cancel();
553
        assert!(tui.cancellation_token.is_cancelled());
554
    }
555
556
    #[test]
557
    fn deref_exposes_terminal_frame() {
558
        let mut tui = fullscreen_tui();
559
        // Deref/DerefMut should expose the underlying ratatui terminal.
560
        let area = tui.get_frame().area();
561
        assert_eq!(area.width, 80);
562
        assert_eq!(area.height, 24);
563
    }
564
565
    #[test]
566
    fn min_height_resizes_and_scrolls_inline_terminal() {
567
        let mut tui = inline_tui(Rect::new(0, 20, 79, 4));
568
569
        tui.min_height(10).expect("failed to apply minimum height");
570
571
        assert_eq!(tui.get_frame().area(), Rect::new(0, 14, 79, 10));
572
        assert_eq!(tui.rect, Some(Rect::new(0, 14, 79, 10)));
573
    }
574
575
    #[test]
576
    fn min_height_keeps_origin_when_rows_are_available() {
577
        let rect = Rect::new(0, 5, 79, 4);
578
        let (rect, to_scroll) = rect_with_min_height(rect, 10, 24);
579
580
        assert_eq!(rect, Rect::new(0, 5, 79, 10));
581
        assert_eq!(to_scroll, 0);
582
    }
583
584
    #[test]
585
    fn min_height_scrolls_to_make_room() {
586
        let rect = Rect::new(0, 20, 79, 4);
587
        let (rect, to_scroll) = rect_with_min_height(rect, 10, 24);
588
589
        assert_eq!(rect, Rect::new(0, 14, 79, 10));
590
        assert_eq!(to_scroll, 6);
591
    }
592
593
    #[test]
594
    fn min_height_is_limited_to_terminal_height() {
595
        let rect = Rect::new(0, 20, 79, 4);
596
        let (rect, to_scroll) = rect_with_min_height(rect, 30, 24);
597
598
        assert_eq!(rect, Rect::new(0, 0, 79, 24));
599
        assert_eq!(to_scroll, 20);
600
    }
601
602
    #[test]
603
    fn existing_height_is_limited_to_terminal_height() {
604
        let rect = Rect::new(0, 0, 79, 30);
605
        let (rect, to_scroll) = rect_with_min_height(rect, 10, 24);
606
607
        assert_eq!(rect, Rect::new(0, 0, 79, 24));
608
        assert_eq!(to_scroll, 0);
609
    }
610
}
\ No newline at end of file +
484
0
        warn!("Failed to remove keyboard enhancement flags: {e}");
485
442
    }
486
442
    crossterm::execute!(
487
442
        stderr(),
488
        DisableMouseCapture,
489
        DisableBracketedPaste,
490
        LeaveAlternateScreen,
491
        cursor::Show
492
0
    )?;
493
442
    crossterm::terminal::disable_raw_mode()
?0
;
494
442
    Ok(())
495
442
}
496
497
/// Build the stdin handle for an `execute` child process.
498
///
499
/// skim's own stdin (fd 0) is frequently a pipe carrying the item list
500
/// (e.g. `find | sk`), which is useless as a keyboard source for an
501
/// interactive child. Hand the child a fresh handle to the controlling
502
/// terminal instead, so programs like `ncdu` or other ncurses TUIs can read
503
/// the keyboard even when skim's stdin is a pipe. Falls back to inheriting
504
/// skim's stdin if the terminal cannot be opened.
505
1
fn execute_child_stdin() -> std::process::Stdio {
506
    #[cfg(unix)]
507
1
    let tty = std::fs::File::open("/dev/tty");
508
    #[cfg(windows)]
509
    let tty = std::fs::OpenOptions::new().read(true).write(true).open("CONIN$");
510
1
    tty.map_or_else(|_| Stdio::inherit(), Stdio::from)
511
1
}
512
513
#[cfg(test)]
514
#[cfg_attr(coverage, coverage(off))]
515
mod tests {
516
    use super::*;
517
    use ratatui::backend::TestBackend;
518
519
    fn fullscreen_tui() -> Tui<TestBackend> {
520
        // Percent(100) selects the fullscreen viewport, avoiding any TTY cursor query.
521
        Tui::new_with_height_and_backend(TestBackend::new(80, 24), Size::Percent(100))
522
            .expect("failed to build test TUI")
523
    }
524
525
    fn inline_tui(rect: Rect) -> Tui<TestBackend> {
526
        let mut tui = fullscreen_tui();
527
        tui.is_fullscreen = false;
528
        tui.rect = Some(rect);
529
        tui.resize(rect).expect("failed to set initial viewport");
530
        tui
531
    }
532
533
    #[test]
534
    fn new_with_full_height_is_fullscreen() {
535
        let tui = fullscreen_tui();
536
        assert!(tui.is_fullscreen);
537
        assert!(tui.enable_mouse);
538
    }
539
540
    #[test]
541
    fn stop_cancels_token() {
542
        let tui = fullscreen_tui();
543
        assert!(!tui.cancellation_token.is_cancelled());
544
        tui.stop();
545
        assert!(tui.cancellation_token.is_cancelled());
546
    }
547
548
    #[test]
549
    fn cancel_is_idempotent() {
550
        let tui = fullscreen_tui();
551
        tui.cancel();
552
        tui.cancel();
553
        assert!(tui.cancellation_token.is_cancelled());
554
    }
555
556
    #[test]
557
    fn deref_exposes_terminal_frame() {
558
        let mut tui = fullscreen_tui();
559
        // Deref/DerefMut should expose the underlying ratatui terminal.
560
        let area = tui.get_frame().area();
561
        assert_eq!(area.width, 80);
562
        assert_eq!(area.height, 24);
563
    }
564
565
    #[test]
566
    fn min_height_resizes_and_scrolls_inline_terminal() {
567
        let mut tui = inline_tui(Rect::new(0, 20, 79, 4));
568
569
        tui.min_height(10).expect("failed to apply minimum height");
570
571
        assert_eq!(tui.get_frame().area(), Rect::new(0, 14, 79, 10));
572
        assert_eq!(tui.rect, Some(Rect::new(0, 14, 79, 10)));
573
    }
574
575
    #[test]
576
    fn min_height_keeps_origin_when_rows_are_available() {
577
        let rect = Rect::new(0, 5, 79, 4);
578
        let (rect, to_scroll) = rect_with_min_height(rect, 10, 24);
579
580
        assert_eq!(rect, Rect::new(0, 5, 79, 10));
581
        assert_eq!(to_scroll, 0);
582
    }
583
584
    #[test]
585
    fn min_height_scrolls_to_make_room() {
586
        let rect = Rect::new(0, 20, 79, 4);
587
        let (rect, to_scroll) = rect_with_min_height(rect, 10, 24);
588
589
        assert_eq!(rect, Rect::new(0, 14, 79, 10));
590
        assert_eq!(to_scroll, 6);
591
    }
592
593
    #[test]
594
    fn min_height_is_limited_to_terminal_height() {
595
        let rect = Rect::new(0, 20, 79, 4);
596
        let (rect, to_scroll) = rect_with_min_height(rect, 30, 24);
597
598
        assert_eq!(rect, Rect::new(0, 0, 79, 24));
599
        assert_eq!(to_scroll, 20);
600
    }
601
602
    #[test]
603
    fn existing_height_is_limited_to_terminal_height() {
604
        let rect = Rect::new(0, 0, 79, 30);
605
        let (rect, to_scroll) = rect_with_min_height(rect, 10, 24);
606
607
        assert_eq!(rect, Rect::new(0, 0, 79, 24));
608
        assert_eq!(to_scroll, 0);
609
    }
610
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/header.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/header.rs.html index 506cd8c7..186f484c 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/tui/header.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/header.rs.html @@ -1,29 +1,29 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/header.rs
Line
Count
Source
1
//! Header display widget for skim's TUI.
2
//!
3
//! This module provides the header widget that displays static text above the item list.
4
use crate::theme::ColorTheme;
5
use crate::tui::BorderType;
6
use crate::tui::options::TuiLayout;
7
use crate::tui::util::{char_display_width, clip_line_to_chars, style_line, style_text};
8
use crate::tui::widget::{SkimRender, SkimWidget};
9
use crate::{DisplayContext, SkimItem, SkimOptions};
10
11
use ansi_to_tui::IntoText;
12
use ratatui::buffer::Buffer;
13
use ratatui::layout::Rect;
14
use ratatui::text::{Line, Span, Text};
15
use ratatui::widgets::{Block, Borders, Paragraph, Widget};
16
use std::cmp::max;
17
use std::sync::Arc;
18
19
/// Header widget for displaying static text above the item list
20
// The field named `header` in `Header` is intentional — it holds the static
21
// header string that this widget displays. Renaming it would reduce clarity.
22
#[allow(clippy::struct_field_names)]
23
#[derive(Clone)]
24
pub struct Header {
25
    /// The static header string (from --header option), with expanded tabstop
26
    pub header: String,
27
    /// Dynamic header lines from input (from --header-lines option)
28
    pub header_lines: Vec<Arc<dyn SkimItem>>,
29
    /// Fixed number of rows reserved for dynamic header lines (`--header-lines`).
30
    /// Used as the row estimate before items arrive; once items are available
31
    /// `height()` counts the actual sub-lines produced by multiline splitting.
32
    header_lines_count: u16,
33
    /// When `--multiline` is active, the separator string used to split each
34
    /// header-line item into multiple display rows.
35
    multiline: Option<String>,
36
    /// The number of spaces to show before the header
37
    indent_size: u16,
38
    theme: Arc<ColorTheme>,
39
    /// Border type
40
    pub border: BorderType,
41
    /// Whether to reverse the order of `header_lines` (for default/bottom-to-top layout)
42
    reverse_lines: bool,
43
    /// Reverse layout
44
    reverse: bool,
45
}
46
47
impl Default for Header {
48
3
    fn default() -> Self {
49
3
        Self::_default()
50
3
    }
51
}
52
53
impl Header {
54
    /// Sets the color theme for the header
55
    #[must_use]
56
1
    pub fn theme(mut self, theme: Arc<ColorTheme>) -> Self {
57
1
        self.theme = theme;
58
1
        self
59
1
    }
60
    /// Returns the total height (in rows) reserved for this header widget.
61
    ///
62
    /// This value is stable at construction time: it is derived purely from
63
    /// `options.header` (static text) and `options.header_lines` (reserved-item
64
    /// count), so the layout does not shift as items arrive at runtime.
65
    #[must_use]
66
3.29k
    pub fn height(&self) -> u16 {
67
3.29k
        let static_lines = if self.header.is_empty() {
  Branch (67:31): [True: 3.00k, False: 92]
-
  Branch (67:31): [True: 197, False: 2]
-
68
3.20k
            0
69
        } else {
70
94
            u16::try_from(self.header.lines().count()).unwrap_or(u16::MAX)
71
        };
72
        // Once items have arrived, count actual terminal rows (accounting for
73
        // multiline splitting).  Before items arrive fall back to the
74
        // compile-time estimate so the layout is stable on the first frame.
75
3.29k
        let dynamic_lines = if self.header_lines.is_empty() {
  Branch (75:32): [True: 3.04k, False: 55]
-
  Branch (75:32): [True: 198, False: 1]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/header.rs
Line
Count
Source
1
//! Header display widget for skim's TUI.
2
//!
3
//! This module provides the header widget that displays static text above the item list.
4
use crate::theme::ColorTheme;
5
use crate::tui::BorderType;
6
use crate::tui::options::TuiLayout;
7
use crate::tui::util::{char_display_width, clip_line_to_chars, style_line, style_text};
8
use crate::tui::widget::{SkimRender, SkimWidget};
9
use crate::{DisplayContext, SkimItem, SkimOptions};
10
11
use ansi_to_tui::IntoText;
12
use ratatui::buffer::Buffer;
13
use ratatui::layout::Rect;
14
use ratatui::text::{Line, Span, Text};
15
use ratatui::widgets::{Block, Borders, Paragraph, Widget};
16
use std::cmp::max;
17
use std::sync::Arc;
18
19
/// Header widget for displaying static text above the item list
20
// The field named `header` in `Header` is intentional — it holds the static
21
// header string that this widget displays. Renaming it would reduce clarity.
22
#[allow(clippy::struct_field_names)]
23
#[derive(Clone)]
24
pub struct Header {
25
    /// The static header string (from --header option), with expanded tabstop
26
    pub header: String,
27
    /// Dynamic header lines from input (from --header-lines option)
28
    pub header_lines: Vec<Arc<dyn SkimItem>>,
29
    /// Fixed number of rows reserved for dynamic header lines (`--header-lines`).
30
    /// Used as the row estimate before items arrive; once items are available
31
    /// `height()` counts the actual sub-lines produced by multiline splitting.
32
    header_lines_count: u16,
33
    /// When `--multiline` is active, the separator string used to split each
34
    /// header-line item into multiple display rows.
35
    multiline: Option<String>,
36
    /// The number of spaces to show before the header
37
    indent_size: u16,
38
    theme: Arc<ColorTheme>,
39
    /// Border type
40
    pub border: BorderType,
41
    /// Whether to reverse the order of `header_lines` (for default/bottom-to-top layout)
42
    reverse_lines: bool,
43
    /// Reverse layout
44
    reverse: bool,
45
}
46
47
impl Default for Header {
48
3
    fn default() -> Self {
49
3
        Self::_default()
50
3
    }
51
}
52
53
impl Header {
54
    /// Sets the color theme for the header
55
    #[must_use]
56
1
    pub fn theme(mut self, theme: Arc<ColorTheme>) -> Self {
57
1
        self.theme = theme;
58
1
        self
59
1
    }
60
    /// Returns the total height (in rows) reserved for this header widget.
61
    ///
62
    /// This value is stable at construction time: it is derived purely from
63
    /// `options.header` (static text) and `options.header_lines` (reserved-item
64
    /// count), so the layout does not shift as items arrive at runtime.
65
    #[must_use]
66
3.30k
    pub fn height(&self) -> u16 {
67
3.30k
        let static_lines = if self.header.is_empty() {
  Branch (67:31): [True: 3.00k, False: 96]
+
  Branch (67:31): [True: 198, False: 2]
+
68
3.20k
            0
69
        } else {
70
98
            u16::try_from(self.header.lines().count()).unwrap_or(u16::MAX)
71
        };
72
        // Once items have arrived, count actual terminal rows (accounting for
73
        // multiline splitting).  Before items arrive fall back to the
74
        // compile-time estimate so the layout is stable on the first frame.
75
3.30k
        let dynamic_lines = if self.header_lines.is_empty() {
  Branch (75:32): [True: 3.04k, False: 55]
+
  Branch (75:32): [True: 199, False: 1]
 
76
3.24k
            self.header_lines_count
77
56
        } else if let Some(sep) = self.multiline.as_deref() {
  Branch (77:23): [True: 3, False: 52]
 
  Branch (77:23): [True: 0, False: 1]
-
78
3
            self.header_lines
79
3
                .iter()
80
3
                .map(|item| u16::try_from(item.text().split(sep).count().max(1)).unwrap_or(1))
81
                .sum()
82
        } else {
83
53
            u16::try_from(self.header_lines.len()).unwrap_or(u16::MAX)
84
        };
85
3.29k
        static_lines + dynamic_lines
86
3.29k
    }
87
88
    /// Sets the dynamic header lines from input (--header-lines)
89
2.73k
    pub fn set_header_lines(&mut self, items: Vec<Arc<dyn SkimItem>>) {
90
2.73k
        self.header_lines = items;
91
2.73k
        if self.reverse_lines {
  Branch (91:12): [True: 2.63k, False: 71]
-
  Branch (91:12): [True: 28, False: 0]
-
92
2.66k
            self.header_lines.reverse();
93
71
        }
94
2.73k
    }
95
138
    fn header_text<'a>(&self) -> Text<'a> {
96
138
        let mut res = self.header.into_text().unwrap();
97
138
        style_text(&mut res, self.theme.header);
98
138
        res
99
138
    }
100
}
101
102
/// Expands tab characters to spaces based on tabstop width and current position
103
561
fn apply_tabstop(text: &str, tabstop: usize) -> String {
104
561
    let mut result = String::new();
105
561
    let mut current_width = 0;
106
107
561
    for ch in text.chars() {
108
217
        if ch == '\t' {
  Branch (108:12): [True: 0, False: 170]
+
78
3
            self.header_lines
79
3
                .iter()
80
3
                .map(|item| u16::try_from(item.text().split(sep).count().max(1)).unwrap_or(1))
81
                .sum()
82
        } else {
83
53
            u16::try_from(self.header_lines.len()).unwrap_or(u16::MAX)
84
        };
85
3.30k
        static_lines + dynamic_lines
86
3.30k
    }
87
88
    /// Sets the dynamic header lines from input (--header-lines)
89
2.73k
    pub fn set_header_lines(&mut self, items: Vec<Arc<dyn SkimItem>>) {
90
2.73k
        self.header_lines = items;
91
2.73k
        if self.reverse_lines {
  Branch (91:12): [True: 2.64k, False: 65]
+
  Branch (91:12): [True: 29, False: 0]
+
92
2.67k
            self.header_lines.reverse();
93
65
        }
94
2.73k
    }
95
144
    fn header_text<'a>(&self) -> Text<'a> {
96
144
        let mut res = self.header.into_text().unwrap();
97
144
        style_text(&mut res, self.theme.header);
98
144
        res
99
144
    }
100
}
101
102
/// Expands tab characters to spaces based on tabstop width and current position
103
560
fn apply_tabstop(text: &str, tabstop: usize) -> String {
104
560
    let mut result = String::new();
105
560
    let mut current_width = 0;
106
107
560
    for ch in text.chars() {
108
226
        if ch == '\t' {
  Branch (108:12): [True: 0, False: 179]
 
  Branch (108:12): [True: 3, False: 44]
-
109
3
            let tab_width = tabstop - (current_width % tabstop);
110
3
            result.push_str(&" ".repeat(tab_width));
111
3
            current_width += tab_width;
112
        } else {
113
214
            result.push(ch);
114
214
            current_width += char_display_width(ch);
115
        }
116
    }
117
118
561
    result
119
561
}
120
121
impl SkimWidget for Header {
122
557
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
123
557
        let tabstop = max(1, options.tabstop);
124
557
        let header = options.header.clone().unwrap_or_default();
125
126
        // Expand tabs once during initialization
127
557
        let expanded_header = apply_tabstop(&header, tabstop);
128
129
        // In default layout (bottom-to-top), header_lines should be reversed
130
        // to match the visual flow of the item list
131
557
        let reverse_lines = options.layout == TuiLayout::Default;
132
133
557
        Self {
134
557
            header: expanded_header,
135
557
            header_lines: Vec::new(),
136
557
            header_lines_count: options
137
557
                .header_lines
138
557
                .try_into()
139
557
                .expect("header_lines count overflows u16"),
140
557
            multiline: options.multiline.as_ref().and_then(std::clone::Clone::clone),
141
557
            indent_size: (options.selector_icon.chars().count() + options.multi_select_icon.chars().count())
142
557
                .try_into()
143
557
                .expect("Failed to fit selector lens into an u16"),
144
557
            theme,
145
557
            border: options.border,
146
557
            reverse_lines,
147
557
            reverse: options.layout == TuiLayout::Reverse,
148
557
        }
149
557
    }
150
151
98
    fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender {
152
98
        let block = if let Some(border_type) = self.border.into_ratatui() {
  Branch (152:28): [True: 18, False: 78]
+
109
3
            let tab_width = tabstop - (current_width % tabstop);
110
3
            result.push_str(&" ".repeat(tab_width));
111
3
            current_width += tab_width;
112
        } else {
113
223
            result.push(ch);
114
223
            current_width += char_display_width(ch);
115
        }
116
    }
117
118
560
    result
119
560
}
120
121
impl SkimWidget for Header {
122
556
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
123
556
        let tabstop = max(1, options.tabstop);
124
556
        let header = options.header.clone().unwrap_or_default();
125
126
        // Expand tabs once during initialization
127
556
        let expanded_header = apply_tabstop(&header, tabstop);
128
129
        // In default layout (bottom-to-top), header_lines should be reversed
130
        // to match the visual flow of the item list
131
556
        let reverse_lines = options.layout == TuiLayout::Default;
132
133
556
        Self {
134
556
            header: expanded_header,
135
556
            header_lines: Vec::new(),
136
556
            header_lines_count: options
137
556
                .header_lines
138
556
                .try_into()
139
556
                .expect("header_lines count overflows u16"),
140
556
            multiline: options.multiline.as_ref().and_then(std::clone::Clone::clone),
141
556
            indent_size: (options.selector_icon.chars().count() + options.multi_select_icon.chars().count())
142
556
                .try_into()
143
556
                .expect("Failed to fit selector lens into an u16"),
144
556
            theme,
145
556
            border: options.border,
146
556
            reverse_lines,
147
556
            reverse: options.layout == TuiLayout::Reverse,
148
556
        }
149
556
    }
150
151
101
    fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender {
152
101
        let block = if let Some(border_type) = self.border.into_ratatui() {
  Branch (152:28): [True: 18, False: 81]
 
  Branch (152:28): [True: 0, False: 2]
-
153
18
            Block::default()
154
18
                .borders(Borders::ALL)
155
18
                .border_type(border_type)
156
18
                .border_style(self.theme.border)
157
        } else {
158
80
            Block::default()
159
        }
160
98
        .padding(ratatui::widgets::Padding::left(self.indent_size));
161
162
98
        let content_height = if self.header.is_empty() {
  Branch (162:33): [True: 28, False: 68]
+
153
18
            Block::default()
154
18
                .borders(Borders::ALL)
155
18
                .border_type(border_type)
156
18
                .border_style(self.theme.border)
157
        } else {
158
83
            Block::default()
159
        }
160
101
        .padding(ratatui::widgets::Padding::left(self.indent_size));
161
162
101
        let content_height = if self.header.is_empty() {
  Branch (162:33): [True: 28, False: 71]
 
  Branch (162:33): [True: 1, False: 1]
-
163
29
            0
164
        } else {
165
69
            self.header_text().lines.len()
166
98
        } + if let Some(sep) = self.multiline.as_deref() {
  Branch (166:20): [True: 9, False: 87]
+
163
29
            0
164
        } else {
165
72
            self.header_text().lines.len()
166
101
        } + if let Some(sep) = self.multiline.as_deref() {
  Branch (166:20): [True: 12, False: 87]
 
  Branch (166:20): [True: 0, False: 2]
-
167
9
            self.header_lines
168
9
                .iter()
169
3
                .map(|item| item.text().split(sep).count().max(1))
170
                .sum()
171
        } else {
172
89
            self.header_lines.len()
173
        };
174
175
98
        let container_height = if self.border.is_some() {
  Branch (175:35): [True: 18, False: 78]
+
167
12
            self.header_lines
168
12
                .iter()
169
3
                .map(|item| item.text().split(sep).count().max(1))
170
                .sum()
171
        } else {
172
89
            self.header_lines.len()
173
        };
174
175
101
        let container_height = if self.border.is_some() {
  Branch (175:35): [True: 18, False: 81]
 
  Branch (175:35): [True: 0, False: 2]
-
176
18
            area.height - 2
177
        } else {
178
80
            area.height
179
        };
180
181
        // Combine static header with dynamic header_lines
182
98
        let mut combined_header = if self.reverse && 
!self.header.is_empty()21
{
  Branch (182:38): [True: 21, False: 75]
+
176
18
            area.height - 2
177
        } else {
178
83
            area.height
179
        };
180
181
        // Combine static header with dynamic header_lines
182
101
        let mut combined_header = if self.reverse && 
!self.header.is_empty()21
{
  Branch (182:38): [True: 21, False: 78]
   Branch (182:54): [True: 15, False: 6]
 
  Branch (182:38): [True: 0, False: 2]
   Branch (182:54): [True: 0, False: 0]
-
183
15
            self.header_text()
184
        } else {
185
83
            let mut r = Text::default();
186
83
            for _ in 0..(container_height.saturating_sub(content_height.try_into().unwrap())) {
187
5
                r.push_line("");
188
            }
189
83
            r
190
        };
191
192
98
        let display_context = DisplayContext {
193
98
            base_style: self.theme.header,
194
98
            ..Default::default()
195
98
        };
196
197
98
        for item in &self.header_lines {
198
91
            if let Some(sep) = self.multiline.as_deref() {
  Branch (198:20): [True: 3, False: 88]
+
183
15
            self.header_text()
184
        } else {
185
86
            let mut r = Text::default();
186
86
            for _ in 0..(container_height.saturating_sub(content_height.try_into().unwrap())) {
187
5
                r.push_line("");
188
            }
189
86
            r
190
        };
191
192
101
        let display_context = DisplayContext {
193
101
            base_style: self.theme.header,
194
101
            ..Default::default()
195
101
        };
196
197
101
        for item in &self.header_lines {
198
91
            if let Some(sep) = self.multiline.as_deref() {
  Branch (198:20): [True: 3, False: 88]
 
  Branch (198:20): [True: 0, False: 0]
-
199
3
                let item_text = item.text();
200
3
                let sub_lines: Vec<&str> = item_text.split(sep).collect();
201
202
                // First sub-line: call display() so that ANSI styling is applied,
203
                // then clip the result to the character count of that sub-part.
204
3
                let first_char_len = sub_lines.first().map_or(0, |s| s.chars().count());
205
3
                let full_display = item.display(display_context.clone());
206
3
                let mut first_line = clip_line_to_chars(full_display, first_char_len);
207
3
                style_line(&mut first_line, self.theme.header);
208
3
                combined_header.push_line(first_line);
209
210
                // Remaining sub-lines: plain styled text (no ANSI re-processing needed).
211
3
                for sub_text in sub_lines.iter().skip(1) {
212
3
                    let mut line = Line::from(vec![Span::styled(sub_text.to_string(), display_context.base_style)]);
213
3
                    style_line(&mut line, self.theme.header);
214
3
                    combined_header.push_line(line);
215
                }
216
            } else {
217
88
                let mut line = item.display(display_context.clone());
218
88
                style_line(&mut line, self.theme.header);
219
88
                combined_header.push_line(line);
220
            }
221
        }
222
223
        // Add static header (from --header)
224
98
        if !self.reverse && 
!self.header.is_empty()77
{
  Branch (224:12): [True: 75, False: 21]
-  Branch (224:29): [True: 53, False: 22]
+
199
3
                let item_text = item.text();
200
3
                let sub_lines: Vec<&str> = item_text.split(sep).collect();
201
202
                // First sub-line: call display() so that ANSI styling is applied,
203
                // then clip the result to the character count of that sub-part.
204
3
                let first_char_len = sub_lines.first().map_or(0, |s| s.chars().count());
205
3
                let full_display = item.display(display_context.clone());
206
3
                let mut first_line = clip_line_to_chars(full_display, first_char_len);
207
3
                style_line(&mut first_line, self.theme.header);
208
3
                combined_header.push_line(first_line);
209
210
                // Remaining sub-lines: plain styled text (no ANSI re-processing needed).
211
3
                for sub_text in sub_lines.iter().skip(1) {
212
3
                    let mut line = Line::from(vec![Span::styled(sub_text.to_string(), display_context.base_style)]);
213
3
                    style_line(&mut line, self.theme.header);
214
3
                    combined_header.push_line(line);
215
                }
216
            } else {
217
88
                let mut line = item.display(display_context.clone());
218
88
                style_line(&mut line, self.theme.header);
219
88
                combined_header.push_line(line);
220
            }
221
        }
222
223
        // Add static header (from --header)
224
101
        if !self.reverse && 
!self.header.is_empty()80
{
  Branch (224:12): [True: 78, False: 21]
+  Branch (224:29): [True: 56, False: 22]
 
  Branch (224:12): [True: 2, False: 0]
   Branch (224:29): [True: 1, False: 1]
-
225
54
            combined_header += self.header_text();
226
44
        }
227
228
98
        Paragraph::new(combined_header)
229
98
            .style(self.theme.header)
230
98
            .block(block)
231
98
            .render(area, buf);
232
233
98
        SkimRender::default()
234
98
    }
235
}
236
237
#[cfg(test)]
238
#[cfg_attr(coverage, coverage(off))]
239
mod tests {
240
    use super::*;
241
    use crate::options::SkimOptionsBuilder;
242
    use ratatui::buffer::Buffer;
243
    use ratatui::layout::Rect;
244
245
    fn header_with(options: &SkimOptions) -> Header {
246
        Header::from_options(options, Arc::new(ColorTheme::default()))
247
    }
248
249
    fn buffer_text(buf: &Buffer) -> String {
250
        let area = buf.area;
251
        let mut out = String::new();
252
        for y in 0..area.height {
253
            for x in 0..area.width {
254
                out.push_str(buf[(x, y)].symbol());
255
            }
256
            out.push('\n');
257
        }
258
        out
259
    }
260
261
    #[test]
262
    fn apply_tabstop_expands_to_column() {
263
        // A tab advances to the next multiple of the tabstop width.
264
        assert_eq!(apply_tabstop("a\tb", 4), "a   b");
265
        assert_eq!(apply_tabstop("\t", 4), "    ");
266
        assert_eq!(apply_tabstop("ab\tc", 4), "ab  c");
267
        assert_eq!(apply_tabstop("noTabs", 8), "noTabs");
268
    }
269
270
    #[test]
271
    fn default_header_is_empty_with_zero_height() {
272
        let header = Header::default();
273
        assert_eq!(header.height(), 0);
274
    }
275
276
    #[test]
277
    fn theme_setter_is_chainable() {
278
        let header = Header::default().theme(Arc::new(ColorTheme::default()));
279
        assert_eq!(header.height(), 0);
280
    }
281
282
    #[test]
283
    fn height_counts_static_header_lines() {
284
        let options = SkimOptionsBuilder::default()
285
            .header("line one\nline two")
286
            .build()
287
            .unwrap();
288
        let header = header_with(&options);
289
        assert_eq!(header.height(), 2);
290
    }
291
292
    #[test]
293
    fn height_includes_reserved_header_lines_count() {
294
        let options = SkimOptionsBuilder::default().header_lines(3usize).build().unwrap();
295
        let header = header_with(&options);
296
        // No items yet → falls back to the reserved count.
297
        assert_eq!(header.height(), 3);
298
    }
299
300
    #[test]
301
    fn set_header_lines_counts_dynamic_items() {
302
        let options = SkimOptionsBuilder::default().header_lines(2usize).build().unwrap();
303
        let mut header = header_with(&options);
304
        let items: Vec<Arc<dyn SkimItem>> = vec![Arc::new("a".to_string()), Arc::new("b".to_string())];
305
        header.set_header_lines(items);
306
        assert_eq!(header.height(), 2);
307
    }
308
309
    #[test]
310
    fn render_writes_static_header_text() {
311
        let options = SkimOptionsBuilder::default().header("MYHEADER").build().unwrap();
312
        let mut header = header_with(&options);
313
        let area = Rect::new(0, 0, 20, 3);
314
        let mut buf = Buffer::empty(area);
315
        header.render(area, &mut buf);
316
        assert!(buffer_text(&buf).contains("MYHEADER"));
317
    }
318
319
    #[test]
320
    fn render_empty_header_does_not_panic() {
321
        let mut header = Header::default();
322
        let area = Rect::new(0, 0, 20, 3);
323
        let mut buf = Buffer::empty(area);
324
        // Exercises the blank-line padding branch for an empty header.
325
        let _ = header.render(area, &mut buf);
326
    }
327
}
\ No newline at end of file +
225
57
            combined_header += self.header_text();
226
44
        }
227
228
101
        Paragraph::new(combined_header)
229
101
            .style(self.theme.header)
230
101
            .block(block)
231
101
            .render(area, buf);
232
233
101
        SkimRender::default()
234
101
    }
235
}
236
237
#[cfg(test)]
238
#[cfg_attr(coverage, coverage(off))]
239
mod tests {
240
    use super::*;
241
    use crate::options::SkimOptionsBuilder;
242
    use ratatui::buffer::Buffer;
243
    use ratatui::layout::Rect;
244
245
    fn header_with(options: &SkimOptions) -> Header {
246
        Header::from_options(options, Arc::new(ColorTheme::default()))
247
    }
248
249
    fn buffer_text(buf: &Buffer) -> String {
250
        let area = buf.area;
251
        let mut out = String::new();
252
        for y in 0..area.height {
253
            for x in 0..area.width {
254
                out.push_str(buf[(x, y)].symbol());
255
            }
256
            out.push('\n');
257
        }
258
        out
259
    }
260
261
    #[test]
262
    fn apply_tabstop_expands_to_column() {
263
        // A tab advances to the next multiple of the tabstop width.
264
        assert_eq!(apply_tabstop("a\tb", 4), "a   b");
265
        assert_eq!(apply_tabstop("\t", 4), "    ");
266
        assert_eq!(apply_tabstop("ab\tc", 4), "ab  c");
267
        assert_eq!(apply_tabstop("noTabs", 8), "noTabs");
268
    }
269
270
    #[test]
271
    fn default_header_is_empty_with_zero_height() {
272
        let header = Header::default();
273
        assert_eq!(header.height(), 0);
274
    }
275
276
    #[test]
277
    fn theme_setter_is_chainable() {
278
        let header = Header::default().theme(Arc::new(ColorTheme::default()));
279
        assert_eq!(header.height(), 0);
280
    }
281
282
    #[test]
283
    fn height_counts_static_header_lines() {
284
        let options = SkimOptionsBuilder::default()
285
            .header("line one\nline two")
286
            .build()
287
            .unwrap();
288
        let header = header_with(&options);
289
        assert_eq!(header.height(), 2);
290
    }
291
292
    #[test]
293
    fn height_includes_reserved_header_lines_count() {
294
        let options = SkimOptionsBuilder::default().header_lines(3usize).build().unwrap();
295
        let header = header_with(&options);
296
        // No items yet → falls back to the reserved count.
297
        assert_eq!(header.height(), 3);
298
    }
299
300
    #[test]
301
    fn set_header_lines_counts_dynamic_items() {
302
        let options = SkimOptionsBuilder::default().header_lines(2usize).build().unwrap();
303
        let mut header = header_with(&options);
304
        let items: Vec<Arc<dyn SkimItem>> = vec![Arc::new("a".to_string()), Arc::new("b".to_string())];
305
        header.set_header_lines(items);
306
        assert_eq!(header.height(), 2);
307
    }
308
309
    #[test]
310
    fn render_writes_static_header_text() {
311
        let options = SkimOptionsBuilder::default().header("MYHEADER").build().unwrap();
312
        let mut header = header_with(&options);
313
        let area = Rect::new(0, 0, 20, 3);
314
        let mut buf = Buffer::empty(area);
315
        header.render(area, &mut buf);
316
        assert!(buffer_text(&buf).contains("MYHEADER"));
317
    }
318
319
    #[test]
320
    fn render_empty_header_does_not_panic() {
321
        let mut header = Header::default();
322
        let area = Rect::new(0, 0, 20, 3);
323
        let mut buf = Buffer::empty(area);
324
        // Exercises the blank-line padding branch for an empty header.
325
        let _ = header.render(area, &mut buf);
326
    }
327
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/input.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/input.rs.html index 712eb754..cd259160 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/tui/input.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/input.rs.html @@ -1,20 +1,20 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/input.rs
Line
Count
Source
1
use std::fmt::Write as _;
2
use std::ops::{Deref, DerefMut};
3
use std::sync::Arc;
4
use std::time::Instant;
5
6
use ansi_to_tui::IntoText;
7
use ratatui::prelude::*;
8
use ratatui::widgets::Widget;
9
use unicode_display_width::width as display_width;
10
11
use crate::SkimOptions;
12
use crate::helper::item::strip_ansi;
13
use crate::theme::ColorTheme;
14
use crate::tui::BorderType;
15
use crate::tui::options::TuiLayout;
16
use crate::tui::statusline::{Info, InfoDisplay, spinner_char};
17
use crate::tui::util::style_line;
18
use crate::tui::widget::{SkimRender, SkimWidget};
19
20
/// Status information to display in the input widget's title
21
#[derive(Clone, Default)]
22
pub struct StatusInfo {
23
    /// Total number of items
24
    pub total: usize,
25
    /// Number of matched items
26
    pub matched: usize,
27
    /// Number of processed items
28
    pub processed: usize,
29
    /// Whether the spinner should be shown (controlled by App with debouncing)
30
    pub show_spinner: bool,
31
    /// Current matcher mode (e.g., "RE" for regex)
32
    pub matcher_mode: String,
33
    /// Whether multi-selection mode is enabled
34
    pub multi_selection: bool,
35
    /// Number of selected items
36
    pub selected: usize,
37
    /// Index of the current item
38
    pub current_item_idx: usize,
39
    /// Horizontal scroll offset
40
    pub hscroll_offset: i64,
41
    /// Start time for calculating spinner animation
42
    pub start: Option<Instant>,
43
    /// Inline prefix/separator (when the spinner is hidden)
44
    pub inline_separator: String,
45
}
46
47
impl StatusInfo {
48
    /// Build the left-aligned title string (spinner, matched/total, mode, progress, selection)
49
    /// Used for Default info display mode (separate line)
50
2.62k
    pub fn left_title(&self) -> String {
51
2.62k
        let mut parts = String::new();
52
53
        // Spinner
54
2.62k
        if self.show_spinner
  Branch (54:12): [True: 54, False: 2.54k]
-
  Branch (54:12): [True: 1, False: 28]
-
55
55
            && let Some(start) = self.start
  Branch (55:20): [True: 54, False: 0]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/input.rs
Line
Count
Source
1
use std::fmt::Write as _;
2
use std::ops::{Deref, DerefMut};
3
use std::sync::Arc;
4
use std::time::Instant;
5
6
use ansi_to_tui::IntoText;
7
use ratatui::prelude::*;
8
use ratatui::widgets::Widget;
9
use unicode_display_width::width as display_width;
10
11
use crate::SkimOptions;
12
use crate::helper::item::strip_ansi;
13
use crate::theme::ColorTheme;
14
use crate::tui::BorderType;
15
use crate::tui::options::TuiLayout;
16
use crate::tui::statusline::{Info, InfoDisplay, spinner_char};
17
use crate::tui::util::style_line;
18
use crate::tui::widget::{SkimRender, SkimWidget};
19
20
/// Status information to display in the input widget's title
21
#[derive(Clone, Default)]
22
pub struct StatusInfo {
23
    /// Total number of items
24
    pub total: usize,
25
    /// Number of matched items
26
    pub matched: usize,
27
    /// Number of processed items
28
    pub processed: usize,
29
    /// Whether the spinner should be shown (controlled by App with debouncing)
30
    pub show_spinner: bool,
31
    /// Current matcher mode (e.g., "RE" for regex)
32
    pub matcher_mode: String,
33
    /// Whether multi-selection mode is enabled
34
    pub multi_selection: bool,
35
    /// Number of selected items
36
    pub selected: usize,
37
    /// Index of the current item
38
    pub current_item_idx: usize,
39
    /// Horizontal scroll offset
40
    pub hscroll_offset: i64,
41
    /// Start time for calculating spinner animation
42
    pub start: Option<Instant>,
43
    /// Inline prefix/separator (when the spinner is hidden)
44
    pub inline_separator: String,
45
}
46
47
impl StatusInfo {
48
    /// Build the left-aligned title string (spinner, matched/total, mode, progress, selection)
49
    /// Used for Default info display mode (separate line)
50
2.63k
    pub fn left_title(&self) -> String {
51
2.63k
        let mut parts = String::new();
52
53
        // Spinner
54
2.63k
        if self.show_spinner
  Branch (54:12): [True: 60, False: 2.54k]
+
  Branch (54:12): [True: 1, False: 29]
+
55
61
            && let Some(start) = self.start
  Branch (55:20): [True: 60, False: 0]
 
  Branch (55:20): [True: 1, False: 0]
-
56
        {
57
55
            parts.push(spinner_char(start));
58
55
            parts.push(' ');
59
        } else {
60
2.57k
            parts.push_str("  ");
61
        }
62
63
        // Matched/total
64
2.62k
        let _ = write!(parts, "{}/{}", self.matched, self.total);
65
66
        // Matcher mode
67
2.62k
        if !self.matcher_mode.is_empty() {
  Branch (67:12): [True: 3, False: 2.59k]
-
  Branch (67:12): [True: 2, False: 27]
-
68
5
            let _ = write!(parts, "/{}", self.matcher_mode);
69
2.62k
        }
70
71
        // Progress percentage
72
2.62k
        if self.show_spinner && 
self.total > 055
&&
self.processed != self.total55
{
  Branch (72:12): [True: 54, False: 2.54k]
-  Branch (72:33): [True: 54, False: 0]
-  Branch (72:51): [True: 54, False: 0]
-
  Branch (72:12): [True: 1, False: 28]
+
56
        {
57
61
            parts.push(spinner_char(start));
58
61
            parts.push(' ');
59
        } else {
60
2.57k
            parts.push_str("  ");
61
        }
62
63
        // Matched/total
64
2.63k
        let _ = write!(parts, "{}/{}", self.matched, self.total);
65
66
        // Matcher mode
67
2.63k
        if !self.matcher_mode.is_empty() {
  Branch (67:12): [True: 3, False: 2.60k]
+
  Branch (67:12): [True: 2, False: 28]
+
68
5
            let _ = write!(parts, "/{}", self.matcher_mode);
69
2.62k
        }
70
71
        // Progress percentage
72
2.63k
        if self.show_spinner && 
self.total > 061
&&
self.processed != self.total61
{
  Branch (72:12): [True: 60, False: 2.54k]
+  Branch (72:33): [True: 60, False: 0]
+  Branch (72:51): [True: 53, False: 7]
+
  Branch (72:12): [True: 1, False: 29]
   Branch (72:33): [True: 1, False: 0]
   Branch (72:51): [True: 1, False: 0]
-
73
55
            let pct = self.processed.saturating_mul(100) / self.total;
74
55
            let _ = write!(parts, " ({pct}%)");
75
2.57k
        }
76
77
        // Selection count
78
2.62k
        if self.multi_selection && 
self.selected > 0149
{
  Branch (78:12): [True: 148, False: 2.45k]
-  Branch (78:36): [True: 89, False: 59]
-
  Branch (78:12): [True: 1, False: 28]
+
73
54
            let pct = self.processed.saturating_mul(100) / self.total;
74
54
            let _ = write!(parts, " ({pct}%)");
75
2.58k
        }
76
77
        // Selection count
78
2.63k
        if self.multi_selection && 
self.selected > 0145
{
  Branch (78:12): [True: 144, False: 2.46k]
+  Branch (78:36): [True: 89, False: 55]
+
  Branch (78:12): [True: 1, False: 29]
   Branch (78:36): [True: 1, False: 0]
-
79
90
            let _ = write!(parts, " [{}]", self.selected);
80
2.53k
        }
81
82
2.62k
        parts
83
2.62k
    }
84
85
    /// Get the inline separator character: spinner when active, '<' otherwise
86
    /// Used for Inline info display mode
87
49
    pub fn inline_separator_or_spinner(&self) -> String {
88
49
        if self.show_spinner
  Branch (88:12): [True: 0, False: 47]
+
79
90
            let _ = write!(parts, " [{}]", self.selected);
80
2.54k
        }
81
82
2.63k
        parts
83
2.63k
    }
84
85
    /// Get the inline separator character: spinner when active, '<' otherwise
86
    /// Used for Inline info display mode
87
49
    pub fn inline_separator_or_spinner(&self) -> String {
88
49
        if self.show_spinner
  Branch (88:12): [True: 0, False: 47]
 
  Branch (88:12): [True: 1, False: 1]
 
89
1
            && let Some(start) = self.start
  Branch (89:20): [True: 0, False: 0]
 
  Branch (89:20): [True: 1, False: 0]
@@ -28,7 +28,7 @@
   Branch (121:36): [True: 0, False: 0]
 
  Branch (121:12): [True: 1, False: 0]
   Branch (121:36): [True: 1, False: 0]
-
122
1
            let _ = write!(parts, " [{}]", self.selected);
123
47
        }
124
125
48
        parts
126
48
    }
127
128
    /// Build the right-aligned title string (current index / hscroll)
129
2.67k
    pub fn right_title(&self) -> String {
130
2.67k
        format!("{}/{}", self.current_item_idx, self.hscroll_offset)
131
2.67k
    }
132
}
133
134
pub struct Input {
135
    pub prompt: String,
136
    /// see `alternate_value`
137
    alternate_prompt: String,
138
    pub value: String,
139
    /// cmd query when in normal mode, query when in interactive mode
140
    alternate_value: String,
141
    pub cursor_pos: u16,
142
    pub alternate_cursor_pos: u16,
143
    pub theme: Arc<ColorTheme>,
144
    /// Border type
145
    pub border: BorderType,
146
    /// Status information to display as the input's title
147
    pub status_info: Option<StatusInfo>,
148
    /// How to display the info/status (default, inline, or hidden)
149
    pub info: Info,
150
    /// Whether layout is reversed (status goes below input instead of above)
151
    pub reverse: bool,
152
}
153
154
impl Default for Input {
155
104
    fn default() -> Self {
156
104
        Self::_default()
157
104
    }
158
}
159
160
impl Input {
161
337
    pub fn insert(&mut self, c: char) {
162
337
        self.value.insert(self.cursor_pos.into(), c);
163
        // unwrap: len_utf8 < 4
164
337
        self.move_cursor(c.len_utf8().try_into().unwrap());
165
337
    }
166
22
    pub fn insert_str(&mut self, s: &str) {
167
22
        self.value.insert_str(self.cursor_pos as usize, s);
168
        // `cursor_pos` is a byte offset (see `move_cursor_to`), so advance by the
169
        // inserted byte length, not the char count.
170
22
        self.move_cursor(s.len().try_into().expect("Failed to fit inserted str len into an i32"));
171
22
    }
172
74
    fn nchars(&self) -> usize {
173
74
        self.value.chars().count()
174
74
    }
175
13
    pub fn delete(&mut self, offset: i32) -> Option<char> {
176
13
        if self.value.is_empty() {
  Branch (176:12): [True: 0, False: 6]
+
122
1
            let _ = write!(parts, " [{}]", self.selected);
123
47
        }
124
125
48
        parts
126
48
    }
127
128
    /// Build the right-aligned title string (current index / hscroll)
129
2.68k
    pub fn right_title(&self) -> String {
130
2.68k
        format!("{}/{}", self.current_item_idx, self.hscroll_offset)
131
2.68k
    }
132
}
133
134
pub struct Input {
135
    pub prompt: String,
136
    /// see `alternate_value`
137
    alternate_prompt: String,
138
    pub value: String,
139
    /// cmd query when in normal mode, query when in interactive mode
140
    alternate_value: String,
141
    pub cursor_pos: u16,
142
    pub alternate_cursor_pos: u16,
143
    pub theme: Arc<ColorTheme>,
144
    /// Border type
145
    pub border: BorderType,
146
    /// Status information to display as the input's title
147
    pub status_info: Option<StatusInfo>,
148
    /// How to display the info/status (default, inline, or hidden)
149
    pub info: Info,
150
    /// Whether layout is reversed (status goes below input instead of above)
151
    pub reverse: bool,
152
}
153
154
impl Default for Input {
155
105
    fn default() -> Self {
156
105
        Self::_default()
157
105
    }
158
}
159
160
impl Input {
161
340
    pub fn insert(&mut self, c: char) {
162
340
        self.value.insert(self.cursor_pos.into(), c);
163
        // unwrap: len_utf8 < 4
164
340
        self.move_cursor(c.len_utf8().try_into().unwrap());
165
340
    }
166
22
    pub fn insert_str(&mut self, s: &str) {
167
22
        self.value.insert_str(self.cursor_pos as usize, s);
168
        // `cursor_pos` is a byte offset (see `move_cursor_to`), so advance by the
169
        // inserted byte length, not the char count.
170
22
        self.move_cursor(s.len().try_into().expect("Failed to fit inserted str len into an i32"));
171
22
    }
172
74
    fn nchars(&self) -> usize {
173
74
        self.value.chars().count()
174
74
    }
175
13
    pub fn delete(&mut self, offset: i32) -> Option<char> {
176
13
        if self.value.is_empty() {
  Branch (176:12): [True: 0, False: 6]
 
  Branch (176:12): [True: 2, False: 5]
 
177
2
            return None;
178
11
        }
179
11
        let new_pos = i32::from(self.cursor_pos) + offset;
180
11
        if new_pos < 0 || usize::try_from(new_pos).map_or(true, |p| p >= self.value.len()) {
  Branch (180:12): [True: 0, False: 6]
   Branch (180:27): [True: 0, False: 6]
@@ -36,13 +36,13 @@
   Branch (180:27): [True: 1, False: 4]
 
181
1
            return None;
182
10
        }
183
10
        let pos = self.value.floor_char_boundary(new_pos.unsigned_abs() as usize);
184
10
        let ch = self.value.remove(pos);
185
        // Only move cursor if deleting backwards
186
10
        if offset < 0 {
  Branch (186:12): [True: 6, False: 0]
 
  Branch (186:12): [True: 2, False: 2]
-
187
8
            self.move_cursor(-1);
188
2
        }
189
10
        Some(ch)
190
13
    }
191
382
    pub fn move_cursor(&mut self, offset: i32) {
192
382
        if offset == 0 {
  Branch (192:12): [True: 0, False: 352]
+
187
8
            self.move_cursor(-1);
188
2
        }
189
10
        Some(ch)
190
13
    }
191
385
    pub fn move_cursor(&mut self, offset: i32) {
192
385
        if offset == 0 {
  Branch (192:12): [True: 0, False: 355]
 
  Branch (192:12): [True: 1, False: 29]
-
193
1
            return;
194
381
        }
195
381
        if offset < 0 {
  Branch (195:12): [True: 11, False: 341]
+
193
1
            return;
194
384
        }
195
384
        if offset < 0 {
  Branch (195:12): [True: 11, False: 344]
 
  Branch (195:12): [True: 4, False: 25]
-
196
15
            let new_pos = (i32::from(self.cursor_pos) + offset).max(0).unsigned_abs() as usize;
197
15
            self.move_cursor_to(u16::try_from(self.value.floor_char_boundary(new_pos)).unwrap_or(u16::MAX));
198
        } else {
199
366
            let new_pos = (i32::from(self.cursor_pos) + offset).unsigned_abs() as usize;
200
366
            self.move_cursor_to(u16::try_from(self.value.ceil_char_boundary(new_pos)).unwrap_or(u16::MAX));
201
        }
202
382
    }
203
427
    pub fn move_cursor_to(&mut self, pos: u16) {
204
427
        if self.value.is_char_boundary(pos as usize) {
  Branch (204:12): [True: 369, False: 0]
+
196
15
            let new_pos = (i32::from(self.cursor_pos) + offset).max(0).unsigned_abs() as usize;
197
15
            self.move_cursor_to(u16::try_from(self.value.floor_char_boundary(new_pos)).unwrap_or(u16::MAX));
198
        } else {
199
369
            let new_pos = (i32::from(self.cursor_pos) + offset).unsigned_abs() as usize;
200
369
            self.move_cursor_to(u16::try_from(self.value.ceil_char_boundary(new_pos)).unwrap_or(u16::MAX));
201
        }
202
385
    }
203
430
    pub fn move_cursor_to(&mut self, pos: u16) {
204
430
        if self.value.is_char_boundary(pos as usize) {
  Branch (204:12): [True: 372, False: 0]
 
  Branch (204:12): [True: 58, False: 0]
-
205
427
            self.cursor_pos = u16::clamp(pos, 0, u16::try_from(self.value.len()).unwrap_or(u16::MAX));
206
        } else {
207
0
            warn!("Invalid cursor pos");
208
        }
209
427
    }
210
26
    pub fn move_to_end(&mut self) {
211
26
        self.move_cursor_to(
212
26
            self.value
213
26
                .len()
214
26
                .try_into()
215
26
                .expect("Failed to fit input len into an u16"),
216
26
        );
217
26
    }
218
219
    /// Check if a character is a word character (alphanumeric only)
220
118
    fn is_word_char(ch: char) -> bool {
221
118
        ch.is_alphanumeric()
222
118
    }
223
224
    /// Find the position of the end of the next word (alphanumeric boundaries for deletion)
225
4
    fn find_next_word_end(&self, start_pos: usize) -> usize {
226
4
        let mut pos = start_pos;
227
228
        // Skip any non-word characters
229
7
        while pos < self.nchars() {
  Branch (229:15): [True: 2, False: 0]
+
205
430
            self.cursor_pos = u16::clamp(pos, 0, u16::try_from(self.value.len()).unwrap_or(u16::MAX));
206
        } else {
207
0
            warn!("Invalid cursor pos");
208
        }
209
430
    }
210
26
    pub fn move_to_end(&mut self) {
211
26
        self.move_cursor_to(
212
26
            self.value
213
26
                .len()
214
26
                .try_into()
215
26
                .expect("Failed to fit input len into an u16"),
216
26
        );
217
26
    }
218
219
    /// Check if a character is a word character (alphanumeric only)
220
118
    fn is_word_char(ch: char) -> bool {
221
118
        ch.is_alphanumeric()
222
118
    }
223
224
    /// Find the position of the end of the next word (alphanumeric boundaries for deletion)
225
4
    fn find_next_word_end(&self, start_pos: usize) -> usize {
226
4
        let mut pos = start_pos;
227
228
        // Skip any non-word characters
229
7
        while pos < self.nchars() {
  Branch (229:15): [True: 2, False: 0]
 
  Branch (229:15): [True: 5, False: 0]
 
230
7
            let ch = self.value.chars().nth(pos).unwrap();
231
7
            if Self::is_word_char(ch) {
  Branch (231:16): [True: 2, False: 0]
 
  Branch (231:16): [True: 2, False: 3]
@@ -92,22 +92,22 @@
   Branch (356:26): [True: 8, False: 2]
 
357
62
            pos -= 1;
358
        }
359
360
21
        let deleted = self.value[pos..self.cursor_pos as usize].to_string();
361
21
        self.value = format!("{}{}", &self.value[..pos], &self.value[self.cursor_pos as usize..]);
362
21
        self.cursor_pos = u16::try_from(pos).unwrap_or(u16::MAX);
363
21
        deleted
364
23
    }
365
366
5
    pub fn delete_forward_word(&mut self) -> String {
367
5
        if self.cursor_pos as usize >= self.value.len() {
  Branch (367:12): [True: 0, False: 2]
 
  Branch (367:12): [True: 1, False: 2]
-
368
1
            return String::new();
369
4
        }
370
4
        let end_pos = self.find_next_word_end(self.cursor_pos as usize);
371
4
        let deleted = self.value[self.cursor_pos as usize..end_pos].to_string();
372
4
        self.value = format!("{}{}", &self.value[..self.cursor_pos as usize], &self.value[end_pos..]);
373
4
        deleted
374
5
    }
375
7
    pub fn move_cursor_forward_word(&mut self) {
376
7
        let new_pos = self.find_compound_word_end(self.cursor_pos as usize);
377
7
        self.cursor_pos = u16::try_from(new_pos).unwrap_or(u16::MAX);
378
7
    }
379
380
13
    pub fn move_cursor_backward_word(&mut self) {
381
13
        let new_pos = self.find_prev_word_start(self.cursor_pos as usize);
382
13
        self.cursor_pos = u16::try_from(new_pos).unwrap_or(u16::MAX);
383
13
    }
384
6
    pub fn delete_to_beginning(&mut self) -> String {
385
6
        let deleted = self.value[..self.cursor_pos as usize].to_string();
386
6
        self.value = self.value[self.cursor_pos as usize..].to_string();
387
6
        self.cursor_pos = 0;
388
6
        deleted
389
6
    }
390
2.73k
    pub fn cursor_pos(&self) -> u16 {
391
2.73k
        (display_width(&self.value[..(self.cursor_pos as usize)]) + display_width(&strip_ansi(&self.prompt).0))
392
2.73k
            .try_into()
393
2.73k
            .expect("Failed to fit cursor char into an u16")
394
2.73k
    }
395
7
    pub fn switch_mode(&mut self) {
396
7
        std::mem::swap(&mut self.prompt, &mut self.alternate_prompt);
397
7
        std::mem::swap(&mut self.value, &mut self.alternate_value);
398
7
        std::mem::swap(&mut self.cursor_pos, &mut self.alternate_cursor_pos);
399
7
    }
400
}
401
402
impl SkimWidget for Input {
403
646
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
404
646
        let mut res = Self {
405
646
            theme,
406
646
            border: options.border,
407
646
            info: options.info.clone(),
408
646
            reverse: options.layout == TuiLayout::Reverse,
409
646
            prompt: String::new(),
410
646
            alternate_prompt: String::new(),
411
646
            value: String::new(),
412
646
            alternate_value: String::new(),
413
646
            cursor_pos: 0,
414
646
            alternate_cursor_pos: 0,
415
646
            status_info: None,
416
646
        };
417
646
        if options.interactive {
  Branch (417:12): [True: 30, False: 442]
+
368
1
            return String::new();
369
4
        }
370
4
        let end_pos = self.find_next_word_end(self.cursor_pos as usize);
371
4
        let deleted = self.value[self.cursor_pos as usize..end_pos].to_string();
372
4
        self.value = format!("{}{}", &self.value[..self.cursor_pos as usize], &self.value[end_pos..]);
373
4
        deleted
374
5
    }
375
7
    pub fn move_cursor_forward_word(&mut self) {
376
7
        let new_pos = self.find_compound_word_end(self.cursor_pos as usize);
377
7
        self.cursor_pos = u16::try_from(new_pos).unwrap_or(u16::MAX);
378
7
    }
379
380
13
    pub fn move_cursor_backward_word(&mut self) {
381
13
        let new_pos = self.find_prev_word_start(self.cursor_pos as usize);
382
13
        self.cursor_pos = u16::try_from(new_pos).unwrap_or(u16::MAX);
383
13
    }
384
6
    pub fn delete_to_beginning(&mut self) -> String {
385
6
        let deleted = self.value[..self.cursor_pos as usize].to_string();
386
6
        self.value = self.value[self.cursor_pos as usize..].to_string();
387
6
        self.cursor_pos = 0;
388
6
        deleted
389
6
    }
390
2.73k
    pub fn cursor_pos(&self) -> u16 {
391
2.73k
        (display_width(&self.value[..(self.cursor_pos as usize)]) + display_width(&strip_ansi(&self.prompt).0))
392
2.73k
            .try_into()
393
2.73k
            .expect("Failed to fit cursor char into an u16")
394
2.73k
    }
395
7
    pub fn switch_mode(&mut self) {
396
7
        std::mem::swap(&mut self.prompt, &mut self.alternate_prompt);
397
7
        std::mem::swap(&mut self.value, &mut self.alternate_value);
398
7
        std::mem::swap(&mut self.cursor_pos, &mut self.alternate_cursor_pos);
399
7
    }
400
}
401
402
impl SkimWidget for Input {
403
646
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
404
646
        let mut res = Self {
405
646
            theme,
406
646
            border: options.border,
407
646
            info: options.info.clone(),
408
646
            reverse: options.layout == TuiLayout::Reverse,
409
646
            prompt: String::new(),
410
646
            alternate_prompt: String::new(),
411
646
            value: String::new(),
412
646
            alternate_value: String::new(),
413
646
            cursor_pos: 0,
414
646
            alternate_cursor_pos: 0,
415
646
            status_info: None,
416
646
        };
417
646
        if options.interactive {
  Branch (417:12): [True: 31, False: 441]
 
  Branch (417:12): [True: 1, False: 173]
-
418
31
            res.prompt.clone_from(&options.cmd_prompt);
419
31
            res.alternate_prompt.clone_from(&options.prompt);
420
31
            res.value = options.cmd_query.clone().unwrap_or_default();
421
31
            res.alternate_value = options.query.clone().unwrap_or_default();
422
        } else {
423
615
            res.prompt.clone_from(&options.prompt);
424
615
            res.alternate_prompt.clone_from(&options.cmd_prompt);
425
615
            res.value = options.query.clone().unwrap_or_default();
426
615
            res.alternate_value = options.cmd_query.clone().unwrap_or_default();
427
        }
428
646
        res.cursor_pos = u16::try_from(res.value.len()).unwrap_or(u16::MAX);
429
646
        res.alternate_cursor_pos = u16::try_from(res.alternate_value.len()).unwrap_or(u16::MAX);
430
646
        res
431
646
    }
432
433
2.73k
    fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender {
434
        use ratatui::layout::Alignment;
435
        use ratatui::text::{Line, Span};
436
        use ratatui::widgets::{Block, Borders, Paragraph};
437
438
2.73k
        let mut line = self.prompt.into_text().map_or_else(
439
0
            |_| Line::from(self.prompt.as_str()),
440
2.73k
            |t| t.lines.into_iter().next().unwrap_or_default(),
441
        );
442
2.73k
        style_line(&mut line, self.theme.prompt);
443
2.73k
        line.push_span(Span::styled(&self.value, self.theme.query));
444
445
2.73k
        let mut block = Block::default();
446
447
        // Add borders if enabled
448
2.73k
        if let Some(border_type) = self.border.into_ratatui() {
  Branch (448:16): [True: 63, False: 2.64k]
-
  Branch (448:16): [True: 0, False: 28]
+
418
32
            res.prompt.clone_from(&options.cmd_prompt);
419
32
            res.alternate_prompt.clone_from(&options.prompt);
420
32
            res.value = options.cmd_query.clone().unwrap_or_default();
421
32
            res.alternate_value = options.query.clone().unwrap_or_default();
422
        } else {
423
614
            res.prompt.clone_from(&options.prompt);
424
614
            res.alternate_prompt.clone_from(&options.cmd_prompt);
425
614
            res.value = options.query.clone().unwrap_or_default();
426
614
            res.alternate_value = options.cmd_query.clone().unwrap_or_default();
427
        }
428
646
        res.cursor_pos = u16::try_from(res.value.len()).unwrap_or(u16::MAX);
429
646
        res.alternate_cursor_pos = u16::try_from(res.alternate_value.len()).unwrap_or(u16::MAX);
430
646
        res
431
646
    }
432
433
2.73k
    fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender {
434
        use ratatui::layout::Alignment;
435
        use ratatui::text::{Line, Span};
436
        use ratatui::widgets::{Block, Borders, Paragraph};
437
438
2.73k
        let mut line = self.prompt.into_text().map_or_else(
439
0
            |_| Line::from(self.prompt.as_str()),
440
2.73k
            |t| t.lines.into_iter().next().unwrap_or_default(),
441
        );
442
2.73k
        style_line(&mut line, self.theme.prompt);
443
2.73k
        line.push_span(Span::styled(&self.value, self.theme.query));
444
445
2.73k
        let mut block = Block::default();
446
447
        // Add borders if enabled
448
2.73k
        if let Some(border_type) = self.border.into_ratatui() {
  Branch (448:16): [True: 63, False: 2.64k]
+
  Branch (448:16): [True: 0, False: 29]
 
449
63
            block = block
450
63
                .borders(Borders::ALL)
451
63
                .border_type(border_type)
452
63
                .border_style(self.theme.border);
453
2.67k
        }
454
455
        // Handle different info display modes
456
2.73k
        match self.info.display {
457
            InfoDisplay::Inline | InfoDisplay::InlineRight => {
458
                // Inline mode: render status on the same line as input
459
                // Format: prompt + value + " " + separator_char + " " + status + padding + right_status
460
                // separator_char is spinner when active, '<' otherwise
461
47
                if let Some(ref status) = self.status_info {
  Branch (461:24): [True: 47, False: 0]
 
  Branch (461:24): [True: 0, False: 0]
 
462
47
                    let separator = status.inline_separator_or_spinner();
463
47
                    let inline_status = status.inline_status();
464
47
                    let right_status = status.right_title();
465
466
                    // Calculate available width for padding
467
                    // Format: " X " where X is separator (3 chars total)
468
47
                    let prompt_width = display_width(&self.prompt);
469
47
                    let value_width = display_width(&self.value);
470
47
                    let separator_width = display_width(&separator); // "  X " (2xspace + separator + space)
471
47
                    let inline_status_width = display_width(&inline_status);
472
47
                    let right_status_width = display_width(&right_status);
473
474
                    let used_width =
475
47
                        prompt_width + value_width + separator_width + inline_status_width + right_status_width;
476
47
                    let available_width = u64::from(area.width);
477
47
                    let padding_width = available_width.saturating_sub(used_width);
478
479
47
                    if self.info.display == InfoDisplay::InlineRight {
  Branch (479:24): [True: 14, False: 33]
 
  Branch (479:24): [True: 0, False: 0]
 
480
14
                        line.push_span(Span::raw(" ".repeat(usize::try_from(padding_width).unwrap() - 2)));
481
33
                    }
482
47
                    line.push_span(Span::styled(separator, self.theme.info));
483
47
                    line.push_span(Span::styled(inline_status, self.theme.info));
484
47
                    if self.info.display == InfoDisplay::Inline {
  Branch (484:24): [True: 33, False: 14]
 
  Branch (484:24): [True: 0, False: 0]
-
485
33
                        line.push_span(Span::raw(" ".repeat(padding_width.try_into().unwrap())));
486
                    } else {
487
14
                        line.push_span(Span::raw(" ".repeat(2)));
488
                    }
489
47
                    line.push_span(Span::styled(right_status, self.theme.info));
490
491
47
                    Paragraph::new(line)
492
47
                        .block(block)
493
47
                        .style(self.theme.normal)
494
47
                        .render(area, buf);
495
                } else {
496
                    // No status info, just render input
497
0
                    Paragraph::new(line)
498
0
                        .block(block)
499
0
                        .style(self.theme.normal)
500
0
                        .render(area, buf);
501
                }
502
            }
503
            InfoDisplay::Default | InfoDisplay::Left | InfoDisplay::Right => {
504
                // Default mode: render status as block title (separate line)
505
                // In normal layout: status above input (title_top)
506
                // In reverse layout: status below input (title_bottom)
507
                //
508
                // Left and Right modes pack both titles together at the corresponding edge.
509
2.62k
                if let Some(ref status) = self.status_info {
  Branch (509:24): [True: 2.59k, False: 0]
-
  Branch (509:24): [True: 27, False: 1]
-
510
2.62k
                    let left_title = status.left_title();
511
2.62k
                    let right_title = status.right_title();
512
513
2.62k
                    if matches!(self.info.display, InfoDisplay::Left | InfoDisplay::Right) {
514
17
                        let alignment = if self.info.display == InfoDisplay::Left {
  Branch (514:44): [True: 7, False: 10]
+
485
33
                        line.push_span(Span::raw(" ".repeat(padding_width.try_into().unwrap())));
486
                    } else {
487
14
                        line.push_span(Span::raw(" ".repeat(2)));
488
                    }
489
47
                    line.push_span(Span::styled(right_status, self.theme.info));
490
491
47
                    Paragraph::new(line)
492
47
                        .block(block)
493
47
                        .style(self.theme.normal)
494
47
                        .render(area, buf);
495
                } else {
496
                    // No status info, just render input
497
0
                    Paragraph::new(line)
498
0
                        .block(block)
499
0
                        .style(self.theme.normal)
500
0
                        .render(area, buf);
501
                }
502
            }
503
            InfoDisplay::Default | InfoDisplay::Left | InfoDisplay::Right => {
504
                // Default mode: render status as block title (separate line)
505
                // In normal layout: status above input (title_top)
506
                // In reverse layout: status below input (title_bottom)
507
                //
508
                // Left and Right modes pack both titles together at the corresponding edge.
509
2.63k
                if let Some(ref status) = self.status_info {
  Branch (509:24): [True: 2.60k, False: 0]
+
  Branch (509:24): [True: 28, False: 1]
+
510
2.63k
                    let left_title = status.left_title();
511
2.63k
                    let right_title = status.right_title();
512
513
2.63k
                    if matches!(self.info.display, InfoDisplay::Left | InfoDisplay::Right) {
514
14
                        let alignment = if self.info.display == InfoDisplay::Left {
  Branch (514:44): [True: 7, False: 7]
 
  Branch (514:44): [True: 0, False: 0]
-
515
7
                            Alignment::Left
516
                        } else {
517
10
                            Alignment::Right
518
                        };
519
17
                        let title = Line::from(format!("{left_title}  {right_title}"))
520
17
                            .style(self.theme.info)
521
17
                            .alignment(alignment);
522
17
                        block = if self.reverse {
  Branch (522:36): [True: 0, False: 17]
+
515
7
                            Alignment::Left
516
                        } else {
517
7
                            Alignment::Right
518
                        };
519
14
                        let title = Line::from(format!("{left_title}  {right_title}"))
520
14
                            .style(self.theme.info)
521
14
                            .alignment(alignment);
522
14
                        block = if self.reverse {
  Branch (522:36): [True: 0, False: 14]
 
  Branch (522:36): [True: 0, False: 0]
-
523
0
                            block.title_bottom(title)
524
                        } else {
525
17
                            block.title_top(title)
526
                        };
527
                    } else {
528
2.60k
                        let info_line = Line::from(left_title).style(self.theme.info).alignment(Alignment::Left);
529
2.60k
                        let index_line = Line::from(right_title)
530
2.60k
                            .style(self.theme.info)
531
2.60k
                            .alignment(Alignment::Right);
532
2.60k
                        block = if self.reverse {
  Branch (532:36): [True: 42, False: 2.54k]
-
  Branch (532:36): [True: 0, False: 27]
-
533
42
                            block.title_bottom(info_line).title_bottom(index_line)
534
                        } else {
535
2.56k
                            block.title_top(info_line).title_top(index_line)
536
                        };
537
                    }
538
1
                }
539
540
2.62k
                Paragraph::new(line)
541
2.62k
                    .block(block)
542
2.62k
                    .style(self.theme.normal)
543
2.62k
                    .render(area, buf);
544
            }
545
            InfoDisplay::Hidden => {
546
                // Hidden mode: no status displayed
547
59
                Paragraph::new(line)
548
59
                    .block(block)
549
59
                    .style(self.theme.normal)
550
59
                    .render(area, buf);
551
            }
552
        }
553
554
2.73k
        SkimRender::default()
555
2.73k
    }
556
}
557
558
impl Deref for Input {
559
    type Target = String;
560
561
1.13k
    fn deref(&self) -> &Self::Target {
562
1.13k
        &self.value
563
1.13k
    }
564
}
565
566
impl DerefMut for Input {
567
1
    fn deref_mut(&mut self) -> &mut Self::Target {
568
1
        &mut self.value
569
1
    }
570
}
571
572
#[cfg(test)]
573
#[path = "input_tests.rs"]
574
mod tests;
\ No newline at end of file +
523
0
                            block.title_bottom(title)
524
                        } else {
525
14
                            block.title_top(title)
526
                        };
527
                    } else {
528
2.61k
                        let info_line = Line::from(left_title).style(self.theme.info).alignment(Alignment::Left);
529
2.61k
                        let index_line = Line::from(right_title)
530
2.61k
                            .style(self.theme.info)
531
2.61k
                            .alignment(Alignment::Right);
532
2.61k
                        block = if self.reverse {
  Branch (532:36): [True: 39, False: 2.55k]
+
  Branch (532:36): [True: 0, False: 28]
+
533
39
                            block.title_bottom(info_line).title_bottom(index_line)
534
                        } else {
535
2.57k
                            block.title_top(info_line).title_top(index_line)
536
                        };
537
                    }
538
1
                }
539
540
2.63k
                Paragraph::new(line)
541
2.63k
                    .block(block)
542
2.63k
                    .style(self.theme.normal)
543
2.63k
                    .render(area, buf);
544
            }
545
            InfoDisplay::Hidden => {
546
                // Hidden mode: no status displayed
547
59
                Paragraph::new(line)
548
59
                    .block(block)
549
59
                    .style(self.theme.normal)
550
59
                    .render(area, buf);
551
            }
552
        }
553
554
2.73k
        SkimRender::default()
555
2.73k
    }
556
}
557
558
impl Deref for Input {
559
    type Target = String;
560
561
1.13k
    fn deref(&self) -> &Self::Target {
562
1.13k
        &self.value
563
1.13k
    }
564
}
565
566
impl DerefMut for Input {
567
1
    fn deref_mut(&mut self) -> &mut Self::Target {
568
1
        &mut self.value
569
1
    }
570
}
571
572
#[cfg(test)]
573
#[path = "input_tests.rs"]
574
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/item_list.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/item_list.rs.html index f915a341..b824137d 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/tui/item_list.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/item_list.rs.html @@ -1,8 +1,8 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/item_list.rs
Line
Count
Source
1
use std::rc::Rc;
2
use std::sync::atomic::{AtomicUsize, Ordering};
3
use std::sync::{Arc, Mutex};
4
5
use indexmap::IndexSet;
6
use ratatui::widgets::{
7
    Block, Borders, Clear, List, ListDirection, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState,
8
    StatefulWidget, Widget,
9
};
10
use regex::Regex;
11
12
use crate::item::MatchedItem;
13
use crate::options::feature_flag;
14
use crate::theme::ColorTheme;
15
use crate::tui::BorderType;
16
use crate::tui::item_renderer::ItemRenderer;
17
use crate::tui::options::TuiLayout;
18
use crate::tui::widget::{SkimRender, SkimWidget};
19
use crate::{Selector, SkimOptions};
20
21
/// How to apply processed items to the display list
22
#[derive(Default, Clone, Copy)]
23
pub(crate) enum MergeStrategy {
24
    /// Replace the entire item list (full re-match or first result)
25
    #[default]
26
    Replace,
27
    /// Merge into existing list using sorted merge by rank
28
    SortedMerge,
29
    /// Append to existing list without sorting (for --no-sort)
30
    Append,
31
    /// Prepend to existing list without sorting (for --tac --no-sort)
32
    Prepend,
33
}
34
35
/// Processed items ready for rendering
36
pub(crate) struct ProcessedItems {
37
    pub(crate) items: Vec<MatchedItem>,
38
    pub(crate) merge: MergeStrategy,
39
    pub(crate) generation: usize,
40
}
41
42
impl Default for ProcessedItems {
43
1
    fn default() -> Self {
44
1
        Self {
45
1
            items: Vec::new(),
46
1
            merge: MergeStrategy::Replace,
47
1
            generation: 0,
48
1
        }
49
1
    }
50
}
51
52
/// Widget for displaying and managing the list of filtered items
53
#[allow(clippy::struct_excessive_bools)]
54
pub struct ItemList {
55
    pub(crate) items: Vec<MatchedItem>,
56
    pub(crate) selection: IndexSet<MatchedItem>,
57
    pub(crate) processed_items: Arc<Mutex<Option<ProcessedItems>>>,
58
    pub(crate) matcher_generation: Arc<AtomicUsize>,
59
    pub(crate) direction: ListDirection,
60
    pub(crate) offset: usize,
61
    /// How many leading sub-lines of items[offset] have been scrolled off the top.
62
    /// Only meaningful when multiline is active; always 0 otherwise.
63
    sub_offset: usize,
64
    pub(crate) current: usize,
65
    pub(crate) height: u16,
66
    pub(crate) theme: std::sync::Arc<crate::theme::ColorTheme>,
67
    pub(crate) multi_select: bool,
68
    reserved: usize,
69
    pub(crate) no_hscroll: bool,
70
    pub(crate) ellipsis: String,
71
    pub(crate) keep_right: bool,
72
    pub(crate) skip_to_pattern: Option<Regex>,
73
    pub(crate) tabstop: usize,
74
    selector: Option<Rc<dyn Selector>>,
75
    pre_select_target: usize, // How many items we want to pre-select
76
    no_clear_if_empty: bool,
77
    interactive: bool,              // Whether we're in interactive mode
78
    showing_stale_items: bool,      // True when displaying old items due to no_clear_if_empty
79
    pub(crate) manual_hscroll: i32, // Manual horizontal scroll offset for ScrollLeft/ScrollRight
80
    pub(crate) selector_icon: String,
81
    pub(crate) multi_select_icon: String,
82
    cycle: bool,
83
    pub(crate) wrap: bool,
84
    /// When Some, split item text on this separator and show each part on its own line
85
    pub(crate) multiline: Option<String>,
86
    /// Border type
87
    pub border: BorderType,
88
    /// When true, prepend each item's match score to its display text
89
    pub(crate) show_score: bool,
90
    pub(crate) show_index: bool,
91
    /// When true, highlight the entire current line (not just the matched text)
92
    pub(crate) highlight_line: bool,
93
    /// Scrollbar display configuration
94
    pub(crate) scrollbar_thumb: String,
95
}
96
97
impl Default for ItemList {
98
25
    fn default() -> Self {
99
25
        Self::_default()
100
25
    }
101
}
102
103
impl ItemList {
104
9.77k
    fn cursor(&self) -> usize {
105
9.77k
        self.current
106
9.77k
    }
107
108
    /// Returns the count of items for status display.
109
    ///
110
    /// This may differ from `items.len()` when `no_clear_if_empty` is active and showing stale items
111
    #[must_use]
112
2.67k
    pub fn count(&self) -> usize {
113
2.67k
        if self.showing_stale_items { 
05
} else {
self.items.len()2.66k
}
  Branch (113:12): [True: 4, False: 2.64k]
-
  Branch (113:12): [True: 1, False: 27]
-
114
2.67k
    }
115
116
    /// Returns the currently selected item, if any
117
    #[must_use]
118
9.74k
    pub fn selected(&self) -> Option<MatchedItem> {
119
9.74k
        let item = self.items.get(self.cursor());
120
9.74k
        if item.is_some_and(|i| !
i.item.disabled()6.78k
) {
  Branch (120:12): [True: 6.63k, False: 2.93k]
-
  Branch (120:12): [True: 149, False: 31]
-
121
6.78k
            item.cloned()
122
        } else {
123
2.96k
            None
124
        }
125
9.74k
    }
126
127
    /// Appends new matched items to the list
128
84
    pub fn append(&mut self, items: &mut Vec<MatchedItem>) {
129
84
        self.items.append(items);
130
84
        self.showing_stale_items = false;
131
84
    }
132
133
    /// Prepends a batch while preserving either the head-following behavior or
134
    /// the item currently focused by a user who has moved away from the head.
135
2
    fn prepend(&mut self, mut items: Vec<MatchedItem>) {
136
2
        if items.is_empty() {
  Branch (136:12): [True: 0, False: 0]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/item_list.rs
Line
Count
Source
1
use std::rc::Rc;
2
use std::sync::atomic::{AtomicUsize, Ordering};
3
use std::sync::{Arc, Mutex};
4
5
use indexmap::IndexSet;
6
use ratatui::widgets::{
7
    Block, Borders, Clear, List, ListDirection, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState,
8
    StatefulWidget, Widget,
9
};
10
use regex::Regex;
11
12
use crate::item::MatchedItem;
13
use crate::options::feature_flag;
14
use crate::theme::ColorTheme;
15
use crate::tui::BorderType;
16
use crate::tui::item_renderer::ItemRenderer;
17
use crate::tui::options::TuiLayout;
18
use crate::tui::widget::{SkimRender, SkimWidget};
19
use crate::{Selector, SkimOptions};
20
21
/// How to apply processed items to the display list
22
#[derive(Default, Clone, Copy)]
23
pub(crate) enum MergeStrategy {
24
    /// Replace the entire item list (full re-match or first result)
25
    #[default]
26
    Replace,
27
    /// Merge into existing list using sorted merge by rank
28
    SortedMerge,
29
    /// Append to existing list without sorting (for --no-sort)
30
    Append,
31
    /// Prepend to existing list without sorting (for --tac --no-sort)
32
    Prepend,
33
}
34
35
/// Processed items ready for rendering
36
pub(crate) struct ProcessedItems {
37
    pub(crate) items: Vec<MatchedItem>,
38
    pub(crate) merge: MergeStrategy,
39
    pub(crate) generation: usize,
40
}
41
42
impl Default for ProcessedItems {
43
1
    fn default() -> Self {
44
1
        Self {
45
1
            items: Vec::new(),
46
1
            merge: MergeStrategy::Replace,
47
1
            generation: 0,
48
1
        }
49
1
    }
50
}
51
52
/// Widget for displaying and managing the list of filtered items
53
#[allow(clippy::struct_excessive_bools)]
54
pub struct ItemList {
55
    pub(crate) items: Vec<MatchedItem>,
56
    pub(crate) selection: IndexSet<MatchedItem>,
57
    pub(crate) processed_items: Arc<Mutex<Option<ProcessedItems>>>,
58
    pub(crate) matcher_generation: Arc<AtomicUsize>,
59
    pub(crate) direction: ListDirection,
60
    pub(crate) offset: usize,
61
    /// How many leading sub-lines of items[offset] have been scrolled off the top.
62
    /// Only meaningful when multiline is active; always 0 otherwise.
63
    sub_offset: usize,
64
    pub(crate) current: usize,
65
    pub(crate) height: u16,
66
    pub(crate) theme: std::sync::Arc<crate::theme::ColorTheme>,
67
    pub(crate) multi_select: bool,
68
    reserved: usize,
69
    pub(crate) no_hscroll: bool,
70
    pub(crate) ellipsis: String,
71
    pub(crate) keep_right: bool,
72
    pub(crate) skip_to_pattern: Option<Regex>,
73
    pub(crate) tabstop: usize,
74
    selector: Option<Rc<dyn Selector>>,
75
    pre_select_target: usize, // How many items we want to pre-select
76
    no_clear_if_empty: bool,
77
    interactive: bool,              // Whether we're in interactive mode
78
    showing_stale_items: bool,      // True when displaying old items due to no_clear_if_empty
79
    pub(crate) manual_hscroll: i32, // Manual horizontal scroll offset for ScrollLeft/ScrollRight
80
    pub(crate) selector_icon: String,
81
    pub(crate) multi_select_icon: String,
82
    cycle: bool,
83
    pub(crate) wrap: bool,
84
    /// When Some, split item text on this separator and show each part on its own line
85
    pub(crate) multiline: Option<String>,
86
    /// Border type
87
    pub border: BorderType,
88
    /// When true, prepend each item's match score to its display text
89
    pub(crate) show_score: bool,
90
    pub(crate) show_index: bool,
91
    /// When true, highlight the entire current line (not just the matched text)
92
    pub(crate) highlight_line: bool,
93
    /// Scrollbar display configuration
94
    pub(crate) scrollbar_thumb: String,
95
}
96
97
impl Default for ItemList {
98
25
    fn default() -> Self {
99
25
        Self::_default()
100
25
    }
101
}
102
103
impl ItemList {
104
9.78k
    fn cursor(&self) -> usize {
105
9.78k
        self.current
106
9.78k
    }
107
108
    /// Returns the count of items for status display.
109
    ///
110
    /// This may differ from `items.len()` when `no_clear_if_empty` is active and showing stale items
111
    #[must_use]
112
2.68k
    pub fn count(&self) -> usize {
113
2.68k
        if self.showing_stale_items { 
05
} else {
self.items.len()2.67k
}
  Branch (113:12): [True: 4, False: 2.64k]
+
  Branch (113:12): [True: 1, False: 28]
+
114
2.68k
    }
115
116
    /// Returns the currently selected item, if any
117
    #[must_use]
118
9.76k
    pub fn selected(&self) -> Option<MatchedItem> {
119
9.76k
        let item = self.items.get(self.cursor());
120
9.76k
        if item.is_some_and(|i| !
i.item.disabled()6.81k
) {
  Branch (120:12): [True: 6.66k, False: 2.91k]
+
  Branch (120:12): [True: 149, False: 35]
+
121
6.81k
            item.cloned()
122
        } else {
123
2.94k
            None
124
        }
125
9.76k
    }
126
127
    /// Appends new matched items to the list
128
84
    pub fn append(&mut self, items: &mut Vec<MatchedItem>) {
129
84
        self.items.append(items);
130
84
        self.showing_stale_items = false;
131
84
    }
132
133
    /// Prepends a batch while preserving either the head-following behavior or
134
    /// the item currently focused by a user who has moved away from the head.
135
2
    fn prepend(&mut self, mut items: Vec<MatchedItem>) {
136
2
        if items.is_empty() {
  Branch (136:12): [True: 0, False: 0]
 
  Branch (136:12): [True: 0, False: 2]
 
137
0
            return;
138
2
        }
139
140
2
        let added = items.len();
141
2
        let follows_head = self.current == 0;
142
2
        items.append(&mut self.items);
143
2
        self.items = items;
144
145
2
        if follows_head {
  Branch (145:12): [True: 0, False: 0]
 
  Branch (145:12): [True: 1, False: 1]
@@ -44,83 +44,83 @@
 
  Branch (306:16): [True: 0, False: 12]
 
307
0
                break;
308
13
            }
309
        }
310
0
        None
311
13
    }
312
313
    /// Jump to the last item in the list
314
11
    pub fn jump_to_last(&mut self) {
315
11
        if !self.items.is_empty() {
  Branch (315:12): [True: 8, False: 0]
 
  Branch (315:12): [True: 3, False: 0]
-
316
11
            self.current = self.items.len().saturating_sub(1);
317
11
            self.sub_offset = 0;
318
0
        }
319
11
    }
320
321
    /// Number of terminal rows item at `index` occupies.
322
    ///
323
    /// When `--multiline` is active this is the number of sub-lines produced by
324
    /// splitting on the separator; otherwise every item is exactly 1 row.
325
4.15k
    fn item_row_count(&self, index: usize) -> usize {
326
4.15k
        if let Some(sep) = self.multiline.as_deref()
  Branch (326:16): [True: 689, False: 3.12k]
-
  Branch (326:16): [True: 0, False: 342]
-
327
689
            && let Some(item) = self.items.get(index)
  Branch (327:20): [True: 648, False: 41]
+
316
11
            self.current = self.items.len().saturating_sub(1);
317
11
            self.sub_offset = 0;
318
0
        }
319
11
    }
320
321
    /// Number of terminal rows item at `index` occupies.
322
    ///
323
    /// When `--multiline` is active this is the number of sub-lines produced by
324
    /// splitting on the separator; otherwise every item is exactly 1 row.
325
4.15k
    fn item_row_count(&self, index: usize) -> usize {
326
4.15k
        if let Some(sep) = self.multiline.as_deref()
  Branch (326:16): [True: 695, False: 3.11k]
+
  Branch (326:16): [True: 0, False: 343]
+
327
695
            && let Some(item) = self.items.get(index)
  Branch (327:20): [True: 648, False: 47]
 
  Branch (327:20): [True: 0, False: 0]
-
328
        {
329
648
            item.item.text().split(sep).count().max(1)
330
        } else {
331
3.50k
            1
332
        }
333
4.15k
    }
334
335
    /// How many terminal rows are consumed by items `[from, from + count)`.
336
208
    fn rows_for_range(&self, from: usize, count: usize) -> usize {
337
1.27k
        (
from208
..from + count).map(|i| self.item_row_count(i)).sum()
338
208
    }
339
340
    /// How many rows are consumed by items `[offset..=current]`, with `sub_offset`
341
    /// leading sub-lines of `items[offset]` already scrolled off.
342
2.78k
    fn rows_visible(&self, offset: usize, sub_offset: usize, current: usize) -> usize {
343
2.78k
        if offset > current {
  Branch (343:12): [True: 0, False: 2.73k]
-
  Branch (343:12): [True: 0, False: 51]
-
344
0
            return 0;
345
2.78k
        }
346
2.78k
        let top_rows = self.item_row_count(offset).saturating_sub(sub_offset);
347
2.78k
        if offset == current {
  Branch (347:12): [True: 2.54k, False: 191]
-
  Branch (347:12): [True: 34, False: 17]
-
348
2.57k
            return top_rows;
349
208
        }
350
208
        top_rows + self.rows_for_range(offset + 1, current - offset)
351
2.78k
    }
352
353
    /// Advance `(offset, sub_offset)` one row at a time until `current` fits
354
    /// within `available_rows`.  Returns the new `(offset, sub_offset)`.
355
7
    fn advance_to_fit(&self, current: usize, available_rows: usize) -> (usize, usize) {
356
7
        let mut offset = self.offset;
357
7
        let mut sub_offset = self.sub_offset;
358
45
        while offset < current && self.rows_visible(offset, sub_offset, current) > available_rows {
  Branch (358:15): [True: 29, False: 0]
+
328
        {
329
648
            item.item.text().split(sep).count().max(1)
330
        } else {
331
3.50k
            1
332
        }
333
4.15k
    }
334
335
    /// How many terminal rows are consumed by items `[from, from + count)`.
336
207
    fn rows_for_range(&self, from: usize, count: usize) -> usize {
337
1.26k
        (
from207
..from + count).map(|i| self.item_row_count(i)).sum()
338
207
    }
339
340
    /// How many rows are consumed by items `[offset..=current]`, with `sub_offset`
341
    /// leading sub-lines of `items[offset]` already scrolled off.
342
2.79k
    fn rows_visible(&self, offset: usize, sub_offset: usize, current: usize) -> usize {
343
2.79k
        if offset > current {
  Branch (343:12): [True: 0, False: 2.73k]
+
  Branch (343:12): [True: 0, False: 52]
+
344
0
            return 0;
345
2.79k
        }
346
2.79k
        let top_rows = self.item_row_count(offset).saturating_sub(sub_offset);
347
2.79k
        if offset == current {
  Branch (347:12): [True: 2.54k, False: 190]
+
  Branch (347:12): [True: 35, False: 17]
+
348
2.58k
            return top_rows;
349
207
        }
350
207
        top_rows + self.rows_for_range(offset + 1, current - offset)
351
2.79k
    }
352
353
    /// Advance `(offset, sub_offset)` one row at a time until `current` fits
354
    /// within `available_rows`.  Returns the new `(offset, sub_offset)`.
355
7
    fn advance_to_fit(&self, current: usize, available_rows: usize) -> (usize, usize) {
356
7
        let mut offset = self.offset;
357
7
        let mut sub_offset = self.sub_offset;
358
45
        while offset < current && self.rows_visible(offset, sub_offset, current) > available_rows {
  Branch (358:15): [True: 29, False: 0]
   Branch (358:35): [True: 23, False: 6]
 
  Branch (358:15): [True: 16, False: 0]
   Branch (358:35): [True: 15, False: 1]
 
359
38
            let top_rows = self.item_row_count(offset);
360
38
            if sub_offset + 1 < top_rows {
  Branch (360:16): [True: 8, False: 15]
 
  Branch (360:16): [True: 0, False: 15]
-
361
                // Still more sub-lines in the top item: scroll one sub-line off.
362
8
                sub_offset += 1;
363
            } else {
364
                // Entire top item scrolled off: move to next item.
365
30
                offset += 1;
366
30
                sub_offset = 0;
367
            }
368
        }
369
7
        (offset, sub_offset)
370
7
    }
371
}
372
373
impl SkimWidget for ItemList {
374
567
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
375
        use crate::helper::selector::DefaultSkimSelector;
376
        use crate::util::read_file_lines;
377
378
567
        let skip_to_pattern = options
379
567
            .skip_to_pattern
380
567
            .as_ref()
381
1
            .and_then(|pattern| Regex::new(pattern).ok());
382
383
        // Build the selector from options and calculate pre-select target
384
567
        let (selector, pre_select_target) = if options.pre_select_n > 0
  Branch (384:48): [True: 11, False: 377]
+
361
                // Still more sub-lines in the top item: scroll one sub-line off.
362
8
                sub_offset += 1;
363
            } else {
364
                // Entire top item scrolled off: move to next item.
365
30
                offset += 1;
366
30
                sub_offset = 0;
367
            }
368
        }
369
7
        (offset, sub_offset)
370
7
    }
371
}
372
373
impl SkimWidget for ItemList {
374
566
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
375
        use crate::helper::selector::DefaultSkimSelector;
376
        use crate::util::read_file_lines;
377
378
566
        let skip_to_pattern = options
379
566
            .skip_to_pattern
380
566
            .as_ref()
381
1
            .and_then(|pattern| Regex::new(pattern).ok());
382
383
        // Build the selector from options and calculate pre-select target
384
566
        let (selector, pre_select_target) = if options.pre_select_n > 0
  Branch (384:48): [True: 11, False: 376]
 
  Branch (384:48): [True: 0, False: 179]
-
385
556
            || !options.pre_select_pat.is_empty()
  Branch (385:16): [True: 2, False: 375]
+
385
555
            || !options.pre_select_pat.is_empty()
  Branch (385:16): [True: 2, False: 374]
 
  Branch (385:16): [True: 0, False: 179]
-
386
554
            || !options.pre_select_items.is_empty()
  Branch (386:16): [True: 1, False: 374]
+
386
553
            || !options.pre_select_items.is_empty()
  Branch (386:16): [True: 1, False: 373]
 
  Branch (386:16): [True: 0, False: 179]
-
387
553
            || options.pre_select_file.is_some()
  Branch (387:16): [True: 2, False: 372]
+
387
552
            || options.pre_select_file.is_some()
  Branch (387:16): [True: 1, False: 372]
 
  Branch (387:16): [True: 0, False: 179]
 
388
551
            || options.selector.is_some()
  Branch (388:16): [True: 0, False: 372]
 
  Branch (388:16): [True: 0, False: 179]
-
389
        {
390
16
            if let Some(s) = options.selector.clone() {
  Branch (390:20): [True: 0, False: 16]
+
389
        {
390
15
            if let Some(s) = options.selector.clone() {
  Branch (390:20): [True: 0, False: 15]
 
  Branch (390:20): [True: 0, False: 0]
-
391
                // For custom selectors, use a very large target (pre-select all matching)
392
0
                (Some(s), usize::MAX)
393
            } else {
394
16
                let mut preset_items: Vec<String> = options
395
16
                    .pre_select_items
396
16
                    .split('\n')
397
16
                    .filter(|s| !s.is_empty())
398
16
                    .map(std::string::ToString::to_string)
399
                    .collect();
400
401
16
                if let Some(ref pre_select_file) = options.pre_select_file
  Branch (401:24): [True: 2, False: 14]
+
391
                // For custom selectors, use a very large target (pre-select all matching)
392
0
                (Some(s), usize::MAX)
393
            } else {
394
15
                let mut preset_items: Vec<String> = options
395
15
                    .pre_select_items
396
15
                    .split('\n')
397
15
                    .filter(|s| !s.is_empty())
398
15
                    .map(std::string::ToString::to_string)
399
                    .collect();
400
401
15
                if let Some(ref pre_select_file) = options.pre_select_file
  Branch (401:24): [True: 1, False: 14]
 
  Branch (401:24): [True: 0, False: 0]
-
402
2
                    && let Ok(file_items) = read_file_lines(pre_select_file)
  Branch (402:28): [True: 2, False: 0]
+
402
1
                    && let Ok(file_items) = read_file_lines(pre_select_file)
  Branch (402:28): [True: 1, False: 0]
 
  Branch (402:28): [True: 0, False: 0]
-
403
                {
404
2
                    preset_items.extend(file_items);
405
14
                }
406
407
16
                let selector = DefaultSkimSelector::default()
408
16
                    .first_n(options.pre_select_n)
409
16
                    .regex(&options.pre_select_pat)
410
16
                    .preset(preset_items.clone());
411
412
                // Only use a target for --pre-select-n
413
                // For pattern/items, the selector always returns the same matches regardless of timing
414
16
                let target = if options.pre_select_n > 0 {
  Branch (414:33): [True: 11, False: 5]
+
403
                {
404
1
                    preset_items.extend(file_items);
405
14
                }
406
407
15
                let selector = DefaultSkimSelector::default()
408
15
                    .first_n(options.pre_select_n)
409
15
                    .regex(&options.pre_select_pat)
410
15
                    .preset(preset_items.clone());
411
412
                // Only use a target for --pre-select-n
413
                // For pattern/items, the selector always returns the same matches regardless of timing
414
15
                let target = if options.pre_select_n > 0 {
  Branch (414:33): [True: 11, False: 4]
 
  Branch (414:33): [True: 0, False: 0]
-
415
11
                    options.pre_select_n
416
                } else {
417
5
                    usize::MAX // No target - keep selecting matching items
418
                };
419
420
16
                (Some(Rc::new(selector) as Rc<dyn Selector>), target)
421
            }
422
        } else {
423
551
            (None, 0)
424
        };
425
426
567
        let processed_items = Arc::new(Mutex::new(None));
427
567
        let matcher_generation = Arc::new(AtomicUsize::new(0));
428
429
567
        let interactive = options.interactive;
430
567
        let no_clear_if_empty = options.no_clear_if_empty;
431
567
        let multi_select = options.multi;
432
433
        // Spawn background processing thread with the appropriate configuration
434
        Self {
435
567
            processed_items,
436
567
            matcher_generation,
437
567
            reserved: 0, // header_lines are now displayed in the Header widget, not ItemList
438
567
            direction: match options.layout {
439
546
                TuiLayout::Default => ratatui::widgets::ListDirection::BottomToTop,
440
21
                TuiLayout::Reverse | TuiLayout::ReverseList => ratatui::widgets::ListDirection::TopToBottom,
441
            },
442
567
            current: 0,
443
567
            theme,
444
567
            multi_select,
445
567
            no_hscroll: options.no_hscroll,
446
567
            ellipsis: options.ellipsis.clone(),
447
567
            keep_right: options.keep_right,
448
567
            skip_to_pattern,
449
567
            tabstop: options.tabstop.max(1),
450
567
            selector,
451
567
            pre_select_target,
452
567
            no_clear_if_empty,
453
567
            interactive,
454
567
            showing_stale_items: false,
455
567
            manual_hscroll: 0,
456
567
            items: Default::default(),
457
567
            selection: Default::default(),
458
567
            offset: Default::default(),
459
567
            sub_offset: 0,
460
567
            height: Default::default(),
461
567
            selector_icon: options.selector_icon.clone(),
462
567
            multi_select_icon: options.multi_select_icon.clone(),
463
567
            cycle: options.cycle,
464
567
            wrap: options.wrap_items,
465
567
            multiline: options
466
567
                .multiline
467
567
                .clone()
468
37
                .map(|opt_m| opt_m.unwrap_or(String::from("\\n"))),
469
567
            border: options.border,
470
567
            show_score: feature_flag!(options, ShowScore),
471
567
            show_index: feature_flag!(options, ShowIndex),
472
567
            highlight_line: options.highlight_line,
473
567
            scrollbar_thumb: options.scrollbar.clone(),
474
        }
475
567
    }
476
477
    // The render function handles the full item list rendering pipeline; splitting
478
    // it into smaller helpers would require passing many parameters between them.
479
    #[allow(clippy::too_many_lines)]
480
2.74k
    fn render(&mut self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) -> SkimRender {
481
2.74k
        let this = &mut *self;
482
483
        // Calculate inner area if borders are enabled
484
2.74k
        let inner_area = if this.border.is_some() {
  Branch (484:29): [True: 63, False: 2.64k]
-
  Branch (484:29): [True: 0, False: 35]
-
485
63
            ratatui::layout::Rect {
486
63
                x: area.x + 1,
487
63
                y: area.y + 1,
488
63
                width: area.width.saturating_sub(2),
489
63
                height: area.height.saturating_sub(2),
490
63
            }
491
        } else {
492
2.67k
            area
493
        };
494
495
2.74k
        this.height = inner_area.height;
496
2.74k
        let available_rows = inner_area.height as usize;
497
498
        // Clamp current to valid range after any item list replacement.
499
2.74k
        if this.items.is_empty() {
  Branch (499:12): [True: 931, False: 1.77k]
-
  Branch (499:12): [True: 2, False: 33]
-
500
933
            this.current = 0;
501
933
            this.offset = 0;
502
        } else {
503
1.80k
            this.current = this.current.min(this.items.len() - 1).max(this.reserved);
504
        }
505
506
2.74k
        if this.current < this.offset {
  Branch (506:12): [True: 0, False: 2.70k]
-
  Branch (506:12): [True: 0, False: 35]
-
507
            // Cursor moved above the top item: snap to it with no sub-line offset.
508
0
            this.offset = this.current;
509
0
            this.sub_offset = 0;
510
2.74k
        } else if this.rows_visible(this.offset, this.sub_offset, this.current) > available_rows {
  Branch (510:19): [True: 6, False: 2.69k]
-
  Branch (510:19): [True: 1, False: 34]
-
511
            // Current item is below the visible window: advance one row at a time.
512
7
            (this.offset, this.sub_offset) = this.advance_to_fit(this.current, available_rows);
513
2.73k
        }
514
2.74k
        let initial_current = this.selected();
515
516
        // Check for pre-processed items from background thread (non-blocking).
517
        // Bind the result separately so the lock guard is dropped before a merge
518
        // mutates the item list.
519
2.74k
        let processed = this
520
2.74k
            .processed_items
521
2.74k
            .lock()
522
2.74k
            .unwrap_or_else(std::sync::PoisonError::into_inner)
523
2.74k
            .take();
524
2.74k
        let current_generation = this.matcher_generation.load(Ordering::Acquire);
525
2.74k
        let processed = processed.filter(|result| 
result.generation760
==
current_generation760
);
526
2.74k
        let items_updated = if let Some(processed) = processed {
  Branch (526:36): [True: 753, False: 1.95k]
-
  Branch (526:36): [True: 5, False: 30]
-
527
758
            debug!("Render: Got {} processed items", 
processed.items.len()0
);
528
529
            // Check if items are empty or blank for no_clear_if_empty handling
530
            let items_are_empty_or_blank =
531
758
                processed.items.is_empty() || 
processed.items.iter()573
.all(|item|
item.item.text().trim()573
.is_empty());
  Branch (531:17): [True: 185, False: 568]
-
  Branch (531:17): [True: 0, False: 5]
-
532
533
758
            if this.interactive && 
this.no_clear_if_empty84
&&
items_are_empty_or_blank2
&&
!this.items.is_empty()1
{
  Branch (533:16): [True: 84, False: 669]
-  Branch (533:36): [True: 2, False: 82]
+
415
11
                    options.pre_select_n
416
                } else {
417
4
                    usize::MAX // No target - keep selecting matching items
418
                };
419
420
15
                (Some(Rc::new(selector) as Rc<dyn Selector>), target)
421
            }
422
        } else {
423
551
            (None, 0)
424
        };
425
426
566
        let processed_items = Arc::new(Mutex::new(None));
427
566
        let matcher_generation = Arc::new(AtomicUsize::new(0));
428
429
566
        let interactive = options.interactive;
430
566
        let no_clear_if_empty = options.no_clear_if_empty;
431
566
        let multi_select = options.multi;
432
433
        // Spawn background processing thread with the appropriate configuration
434
        Self {
435
566
            processed_items,
436
566
            matcher_generation,
437
566
            reserved: 0, // header_lines are now displayed in the Header widget, not ItemList
438
566
            direction: match options.layout {
439
547
                TuiLayout::Default => ratatui::widgets::ListDirection::BottomToTop,
440
19
                TuiLayout::Reverse | TuiLayout::ReverseList => ratatui::widgets::ListDirection::TopToBottom,
441
            },
442
566
            current: 0,
443
566
            theme,
444
566
            multi_select,
445
566
            no_hscroll: options.no_hscroll,
446
566
            ellipsis: options.ellipsis.clone(),
447
566
            keep_right: options.keep_right,
448
566
            skip_to_pattern,
449
566
            tabstop: options.tabstop.max(1),
450
566
            selector,
451
566
            pre_select_target,
452
566
            no_clear_if_empty,
453
566
            interactive,
454
566
            showing_stale_items: false,
455
566
            manual_hscroll: 0,
456
566
            items: Default::default(),
457
566
            selection: Default::default(),
458
566
            offset: Default::default(),
459
566
            sub_offset: 0,
460
566
            height: Default::default(),
461
566
            selector_icon: options.selector_icon.clone(),
462
566
            multi_select_icon: options.multi_select_icon.clone(),
463
566
            cycle: options.cycle,
464
566
            wrap: options.wrap_items,
465
566
            multiline: options
466
566
                .multiline
467
566
                .clone()
468
39
                .map(|opt_m| opt_m.unwrap_or(String::from("\\n"))),
469
566
            border: options.border,
470
566
            show_score: feature_flag!(options, ShowScore),
471
566
            show_index: feature_flag!(options, ShowIndex),
472
566
            highlight_line: options.highlight_line,
473
566
            scrollbar_thumb: options.scrollbar.clone(),
474
        }
475
566
    }
476
477
    // The render function handles the full item list rendering pipeline; splitting
478
    // it into smaller helpers would require passing many parameters between them.
479
    #[allow(clippy::too_many_lines)]
480
2.74k
    fn render(&mut self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) -> SkimRender {
481
2.74k
        let this = &mut *self;
482
483
        // Calculate inner area if borders are enabled
484
2.74k
        let inner_area = if this.border.is_some() {
  Branch (484:29): [True: 63, False: 2.64k]
+
  Branch (484:29): [True: 0, False: 36]
+
485
63
            ratatui::layout::Rect {
486
63
                x: area.x + 1,
487
63
                y: area.y + 1,
488
63
                width: area.width.saturating_sub(2),
489
63
                height: area.height.saturating_sub(2),
490
63
            }
491
        } else {
492
2.68k
            area
493
        };
494
495
2.74k
        this.height = inner_area.height;
496
2.74k
        let available_rows = inner_area.height as usize;
497
498
        // Clamp current to valid range after any item list replacement.
499
2.74k
        if this.items.is_empty() {
  Branch (499:12): [True: 927, False: 1.78k]
+
  Branch (499:12): [True: 3, False: 33]
+
500
930
            this.current = 0;
501
930
            this.offset = 0;
502
        } else {
503
1.81k
            this.current = this.current.min(this.items.len() - 1).max(this.reserved);
504
        }
505
506
2.74k
        if this.current < this.offset {
  Branch (506:12): [True: 0, False: 2.71k]
+
  Branch (506:12): [True: 0, False: 36]
+
507
            // Cursor moved above the top item: snap to it with no sub-line offset.
508
0
            this.offset = this.current;
509
0
            this.sub_offset = 0;
510
2.74k
        } else if this.rows_visible(this.offset, this.sub_offset, this.current) > available_rows {
  Branch (510:19): [True: 6, False: 2.70k]
+
  Branch (510:19): [True: 1, False: 35]
+
511
            // Current item is below the visible window: advance one row at a time.
512
7
            (this.offset, this.sub_offset) = this.advance_to_fit(this.current, available_rows);
513
2.73k
        }
514
2.74k
        let initial_current = this.selected();
515
516
        // Check for pre-processed items from background thread (non-blocking).
517
        // Bind the result separately so the lock guard is dropped before a merge
518
        // mutates the item list.
519
2.74k
        let processed = this
520
2.74k
            .processed_items
521
2.74k
            .lock()
522
2.74k
            .unwrap_or_else(std::sync::PoisonError::into_inner)
523
2.74k
            .take();
524
2.74k
        let current_generation = this.matcher_generation.load(Ordering::Acquire);
525
2.74k
        let processed = processed.filter(|result| 
result.generation761
==
current_generation761
);
526
2.74k
        let items_updated = if let Some(processed) = processed {
  Branch (526:36): [True: 753, False: 1.95k]
+
  Branch (526:36): [True: 6, False: 30]
+
527
759
            debug!("Render: Got {} processed items", 
processed.items.len()0
);
528
529
            // Check if items are empty or blank for no_clear_if_empty handling
530
            let items_are_empty_or_blank =
531
759
                processed.items.is_empty() || 
processed.items.iter()576
.all(|item|
item.item.text().trim()576
.is_empty());
  Branch (531:17): [True: 182, False: 571]
+
  Branch (531:17): [True: 1, False: 5]
+
532
533
759
            if this.interactive && 
this.no_clear_if_empty85
&&
items_are_empty_or_blank2
&&
!this.items.is_empty()1
{
  Branch (533:16): [True: 85, False: 668]
+  Branch (533:36): [True: 2, False: 83]
   Branch (533:62): [True: 1, False: 1]
   Branch (533:90): [True: 1, False: 0]
-
  Branch (533:16): [True: 0, False: 5]
+
  Branch (533:16): [True: 0, False: 6]
   Branch (533:36): [True: 0, False: 0]
   Branch (533:62): [True: 0, False: 0]
   Branch (533:90): [True: 0, False: 0]
-
534
1
                debug!(
535
                    "no_clear_if_empty: keeping {} old items for display (new items are empty/blank)",
536
0
                    this.items.len()
537
                );
538
1
                this.showing_stale_items = true;
539
            } else {
540
757
                match processed.merge {
541
                    MergeStrategy::Replace => {
542
737
                        this.items = processed.items;
543
737
                        this.sub_offset = 0;
544
                    }
545
                    MergeStrategy::SortedMerge => {
546
17
                        let existing = std::mem::take(&mut this.items);
547
17
                        this.items = MatchedItem::sorted_merge(existing, processed.items);
548
17
                        this.sub_offset = 0;
549
                    }
550
                    MergeStrategy::Append => {
551
1
                        this.items.extend(processed.items);
552
                    }
553
                    MergeStrategy::Prepend => {
554
2
                        this.prepend(processed.items);
555
                    }
556
                }
557
757
                this.showing_stale_items = false;
558
559
                // Apply pre-selection only when new items arrive and only if we haven't reached target
560
                // This runs once per item batch, not on every render
561
757
                if this.multi_select
  Branch (561:20): [True: 28, False: 724]
-
  Branch (561:20): [True: 0, False: 5]
-
562
28
                    && let Some(selector) = &this.selector
  Branch (562:28): [True: 16, False: 12]
+
534
1
                debug!(
535
                    "no_clear_if_empty: keeping {} old items for display (new items are empty/blank)",
536
0
                    this.items.len()
537
                );
538
1
                this.showing_stale_items = true;
539
            } else {
540
758
                match processed.merge {
541
                    MergeStrategy::Replace => {
542
740
                        this.items = processed.items;
543
740
                        this.sub_offset = 0;
544
                    }
545
                    MergeStrategy::SortedMerge => {
546
15
                        let existing = std::mem::take(&mut this.items);
547
15
                        this.items = MatchedItem::sorted_merge(existing, processed.items);
548
15
                        this.sub_offset = 0;
549
                    }
550
                    MergeStrategy::Append => {
551
1
                        this.items.extend(processed.items);
552
                    }
553
                    MergeStrategy::Prepend => {
554
2
                        this.prepend(processed.items);
555
                    }
556
                }
557
758
                this.showing_stale_items = false;
558
559
                // Apply pre-selection only when new items arrive and only if we haven't reached target
560
                // This runs once per item batch, not on every render
561
758
                if this.multi_select
  Branch (561:20): [True: 27, False: 725]
+
  Branch (561:20): [True: 0, False: 6]
+
562
27
                    && let Some(selector) = &this.selector
  Branch (562:28): [True: 15, False: 12]
 
  Branch (562:28): [True: 0, False: 0]
-
563
16
                    && this.selection.len() < this.pre_select_target
  Branch (563:24): [True: 16, False: 0]
+
563
15
                    && this.selection.len() < this.pre_select_target
  Branch (563:24): [True: 15, False: 0]
 
  Branch (563:24): [True: 0, False: 0]
-
564
                {
565
16
                    debug!(
566
                        "Applying pre-selection to {} items (currently {} selected, target {})",
567
0
                        this.items.len(),
568
0
                        this.selection.len(),
569
0
                        this.pre_select_target
570
                    );
571
16
                    for (index, item) in this.items.iter().enumerate() {
572
45
                        if this.selection.len() >= this.pre_select_target {
  Branch (572:28): [True: 11, False: 34]
+
564
                {
565
15
                    debug!(
566
                        "Applying pre-selection to {} items (currently {} selected, target {})",
567
0
                        this.items.len(),
568
0
                        this.selection.len(),
569
0
                        this.pre_select_target
570
                    );
571
15
                    for (index, item) in this.items.iter().enumerate() {
572
45
                        if this.selection.len() >= this.pre_select_target {
  Branch (572:28): [True: 11, False: 34]
 
  Branch (572:28): [True: 0, False: 0]
 
573
11
                            break;
574
34
                        }
575
34
                        let should_select = selector.should_select(index, item.item.as_ref());
576
34
                        if should_select {
  Branch (576:28): [True: 27, False: 7]
 
  Branch (576:28): [True: 0, False: 0]
-
577
27
                            debug!("Pre-selecting item[{}]: '{}'", 
index0
,
item.item.text()0
);
578
27
                            this.selection.insert(item.clone());
579
7
                        }
580
                    }
581
16
                    debug!("Pre-selected {} items total", 
this.selection.len()0
);
582
741
                }
583
            }
584
585
758
            true
586
        } else {
587
1.98k
            false
588
        };
589
590
2.74k
        let icon_width = this.selector_icon.chars().count() + this.multi_select_icon.chars().count();
591
2.74k
        let container_width = (inner_area.width as usize).saturating_sub(icon_width);
592
2.74k
        let sub_offset = this.sub_offset;
593
594
2.74k
        let renderer = ItemRenderer::new_for(this, container_width);
595
596
2.74k
        let mut flat_rows: Vec<ListItem<'static>> = Vec::with_capacity(available_rows + 1);
597
2.74k
        let mut rows_used = 0usize;
598
599
2.74k
        for (idx, item) in this.items.iter().enumerate().skip(this.offset) {
600
6.55k
            if rows_used >= available_rows {
  Branch (600:16): [True: 76, False: 6.35k]
+
577
27
                            debug!("Pre-selecting item[{}]: '{}'", 
index0
,
item.item.text()0
);
578
27
                            this.selection.insert(item.clone());
579
7
                        }
580
                    }
581
15
                    debug!("Pre-selected {} items total", 
this.selection.len()0
);
582
743
                }
583
            }
584
585
759
            true
586
        } else {
587
1.98k
            false
588
        };
589
590
2.74k
        let icon_width = this.selector_icon.chars().count() + this.multi_select_icon.chars().count();
591
2.74k
        let container_width = (inner_area.width as usize).saturating_sub(icon_width);
592
2.74k
        let sub_offset = this.sub_offset;
593
594
2.74k
        let renderer = ItemRenderer::new_for(this, container_width);
595
596
2.74k
        let mut flat_rows: Vec<ListItem<'static>> = Vec::with_capacity(available_rows + 1);
597
2.74k
        let mut rows_used = 0usize;
598
599
2.74k
        for (idx, item) in this.items.iter().enumerate().skip(this.offset) {
600
6.58k
            if rows_used >= available_rows {
  Branch (600:16): [True: 77, False: 6.38k]
 
  Branch (600:16): [True: 15, False: 107]
-
601
91
                break;
602
6.46k
            }
603
6.46k
            let is_current = idx == this.current;
604
6.46k
            let is_selected = this.selection.contains(item);
605
6.46k
            let skip_subs = if idx == this.offset { 
sub_offset2.09k
} else {
04.36k
};
  Branch (605:32): [True: 2.06k, False: 4.29k]
+
601
92
                break;
602
6.49k
            }
603
6.49k
            let is_current = idx == this.current;
604
6.49k
            let is_selected = this.selection.contains(item);
605
6.49k
            let skip_subs = if idx == this.offset { 
sub_offset2.10k
} else {
04.38k
};
  Branch (605:32): [True: 2.07k, False: 4.30k]
 
  Branch (605:32): [True: 33, False: 74]
-
606
6.46k
            rows_used += renderer.render_item(
607
6.46k
                item,
608
6.46k
                is_current,
609
6.46k
                is_selected,
610
6.46k
                skip_subs,
611
6.46k
                available_rows,
612
6.46k
                rows_used,
613
6.46k
                &mut flat_rows,
614
6.46k
            );
615
        }
616
617
2.74k
        let list = List::new(flat_rows).direction(this.direction).style(this.theme.normal);
618
619
2.74k
        Widget::render(Clear, area, buf);
620
621
        // Render border if enabled
622
2.74k
        if let Some(border_type) = this.border.into_ratatui() {
  Branch (622:16): [True: 63, False: 2.64k]
-
  Branch (622:16): [True: 0, False: 35]
-
623
63
            let block = Block::default()
624
63
                .borders(Borders::ALL)
625
63
                .border_type(border_type)
626
63
                .border_style(this.theme.border);
627
63
            Widget::render(block, area, buf);
628
2.67k
        }
629
630
        // We manage offset and selection styling ourselves, so render as a plain
631
        // Widget — this bypasses ratatui's get_items_bounds which can index out of
632
        // bounds on our pre-sliced flat_rows when the selected row is near the edge.
633
2.74k
        Widget::render(list, inner_area, buf);
634
635
        // Render the scrollbar on top of the rightmost column of inner_area, but only
636
        // when there are more items than fit on screen (nothing to scroll → no bar).
637
2.74k
        if !this.scrollbar_thumb.is_empty() && 
this.items.len() > available_rows2.70k
{
  Branch (637:12): [True: 2.69k, False: 9]
-  Branch (637:48): [True: 50, False: 2.64k]
-
  Branch (637:12): [True: 5, False: 30]
+
606
6.49k
            rows_used += renderer.render_item(
607
6.49k
                item,
608
6.49k
                is_current,
609
6.49k
                is_selected,
610
6.49k
                skip_subs,
611
6.49k
                available_rows,
612
6.49k
                rows_used,
613
6.49k
                &mut flat_rows,
614
6.49k
            );
615
        }
616
617
2.74k
        let list = List::new(flat_rows).direction(this.direction).style(this.theme.normal);
618
619
2.74k
        Widget::render(Clear, area, buf);
620
621
        // Render border if enabled
622
2.74k
        if let Some(border_type) = this.border.into_ratatui() {
  Branch (622:16): [True: 63, False: 2.64k]
+
  Branch (622:16): [True: 0, False: 36]
+
623
63
            let block = Block::default()
624
63
                .borders(Borders::ALL)
625
63
                .border_type(border_type)
626
63
                .border_style(this.theme.border);
627
63
            Widget::render(block, area, buf);
628
2.68k
        }
629
630
        // We manage offset and selection styling ourselves, so render as a plain
631
        // Widget — this bypasses ratatui's get_items_bounds which can index out of
632
        // bounds on our pre-sliced flat_rows when the selected row is near the edge.
633
2.74k
        Widget::render(list, inner_area, buf);
634
635
        // Render the scrollbar on top of the rightmost column of inner_area, but only
636
        // when there are more items than fit on screen (nothing to scroll → no bar).
637
2.74k
        if !this.scrollbar_thumb.is_empty() && 
this.items.len() > available_rows2.70k
{
  Branch (637:12): [True: 2.70k, False: 9]
+  Branch (637:48): [True: 51, False: 2.65k]
+
  Branch (637:12): [True: 5, False: 31]
   Branch (637:48): [True: 5, False: 0]
-
638
            // Use offset directly as the scroll position for all layout directions:
639
            // offset == 0           → thumb at top    (beginning of content)
640
            // offset == max_offset  → thumb at bottom (end of content)
641
            // This matches conventional scrollbar behaviour regardless of list direction.
642
55
            let mut scrollbar_state = ScrollbarState::new(this.items.len()).position(this.current);
643
            // .viewport_content_length(available_rows);
644
645
            // Both Default and Custom use a thumb-only style (no track/begin/end arrows).
646
            // Default uses ▐ (right half-block), which gives a clean minimal look.
647
            // Without an explicit thumb_style the thumb inherits whatever fg/bg the
648
            // row painted underneath it, so it picks up the current-line highlight as
649
            // the cursor scrolls past; the themed `scrollbar` color keeps it uniform.
650
55
            let scrollbar: Scrollbar<'_> = Scrollbar::new(ScrollbarOrientation::VerticalRight)
651
55
                .thumb_symbol(&self.scrollbar_thumb)
652
55
                .thumb_style(self.theme.scrollbar)
653
55
                .track_symbol(None)
654
55
                .begin_symbol(None)
655
55
                .end_symbol(None);
656
55
            StatefulWidget::render(scrollbar, inner_area, buf, &mut scrollbar_state);
657
2.68k
        }
658
659
2.74k
        let run_preview = if let Some(curr) = self.selected()
  Branch (659:34): [True: 2.06k, False: 640]
-
  Branch (659:34): [True: 33, False: 2]
-
660
2.09k
            && let Some(prev) = initial_current
  Branch (660:20): [True: 1.74k, False: 317]
+
638
            // Use offset directly as the scroll position for all layout directions:
639
            // offset == 0           → thumb at top    (beginning of content)
640
            // offset == max_offset  → thumb at bottom (end of content)
641
            // This matches conventional scrollbar behaviour regardless of list direction.
642
56
            let mut scrollbar_state = ScrollbarState::new(this.items.len()).position(this.current);
643
            // .viewport_content_length(available_rows);
644
645
            // Both Default and Custom use a thumb-only style (no track/begin/end arrows).
646
            // Default uses ▐ (right half-block), which gives a clean minimal look.
647
            // Without an explicit thumb_style the thumb inherits whatever fg/bg the
648
            // row painted underneath it, so it picks up the current-line highlight as
649
            // the cursor scrolls past; the themed `scrollbar` color keeps it uniform.
650
56
            let scrollbar: Scrollbar<'_> = Scrollbar::new(ScrollbarOrientation::VerticalRight)
651
56
                .thumb_symbol(&self.scrollbar_thumb)
652
56
                .thumb_style(self.theme.scrollbar)
653
56
                .track_symbol(None)
654
56
                .begin_symbol(None)
655
56
                .end_symbol(None);
656
56
            StatefulWidget::render(scrollbar, inner_area, buf, &mut scrollbar_state);
657
2.69k
        }
658
659
2.74k
        let run_preview = if let Some(curr) = self.selected()
  Branch (659:34): [True: 2.07k, False: 635]
+
  Branch (659:34): [True: 33, False: 3]
+
660
2.10k
            && let Some(prev) = initial_current
  Branch (660:20): [True: 1.75k, False: 318]
 
  Branch (660:20): [True: 33, False: 0]
-
661
        {
662
1.78k
            curr.text() != prev.text()
663
        } else {
664
959
            self.selected().is_some() != initial_current.is_some()
665
        };
666
2.74k
        SkimRender {
667
2.74k
            items_updated,
668
2.74k
            run_preview,
669
2.74k
        }
670
2.74k
    }
671
}
672
673
40
fn toggle_item(sel: &mut IndexSet<MatchedItem>, item: &MatchedItem) {
674
40
    if sel.contains(item) {
  Branch (674:8): [True: 0, False: 12]
+
661
        {
662
1.79k
            curr.text() != prev.text()
663
        } else {
664
956
            self.selected().is_some() != initial_current.is_some()
665
        };
666
2.74k
        SkimRender {
667
2.74k
            items_updated,
668
2.74k
            run_preview,
669
2.74k
        }
670
2.74k
    }
671
}
672
673
40
fn toggle_item(sel: &mut IndexSet<MatchedItem>, item: &MatchedItem) {
674
40
    if sel.contains(item) {
  Branch (674:8): [True: 0, False: 12]
 
  Branch (674:8): [True: 8, False: 20]
 
675
8
        sel.shift_remove(item);
676
32
    } else if !item.disabled() {
  Branch (676:15): [True: 12, False: 0]
 
  Branch (676:15): [True: 19, False: 1]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/item_renderer.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/item_renderer.rs.html
index c89d4bc1..5dcca9d9 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/tui/item_renderer.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/item_renderer.rs.html
@@ -1,127 +1,127 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/item_renderer.rs
Line
Count
Source
1
use ratatui::style::Color;
2
use ratatui::text::{Line, Span};
3
use ratatui::widgets::{ListDirection, ListItem};
4
use unicode_display_width::width as display_width;
5
6
use crate::item::MatchedItem;
7
use crate::theme::ColorTheme;
8
use crate::tui::item_list::ItemList;
9
use crate::tui::util::{char_display_width, clip_line_to_chars, wrap_text};
10
use crate::{DisplayContext, MatchRange};
11
12
#[allow(clippy::struct_excessive_bools)]
13
struct SubLineState {
14
    /// Current item
15
    is_current: bool,
16
    /// Selected (in multi-select)
17
    is_selected: bool,
18
    /// First item on the screen
19
    is_first: bool,
20
    /// First line of the item
21
    is_first_sub_line: bool,
22
    needs_ellipsis: bool,
23
}
24
25
/// Rendering parameters that are constant across all items in one render pass.
26
#[allow(clippy::struct_excessive_bools)]
27
pub(crate) struct ItemRenderer<'a> {
28
    pub theme: &'a ColorTheme,
29
    pub selector_icon: &'a str,
30
    pub multi_select_icon: &'a str,
31
    pub ellipsis: &'a str,
32
    pub container_width: usize,
33
    pub wrap: bool,
34
    pub multiline: Option<&'a str>,
35
    pub show_score: bool,
36
    pub show_index: bool,
37
    pub multi_select: bool,
38
    pub tabstop: usize,
39
    pub no_hscroll: bool,
40
    pub keep_right: bool,
41
    pub manual_hscroll: i32,
42
    pub skip_to_pattern: Option<&'a regex::Regex>,
43
    /// When true, reverse the order of sub-lines within each multiline item
44
    /// before appending to the output. Required for `BottomToTop` list direction
45
    /// so that sub-line 0 appears visually above sub-line 1.
46
    pub reverse_sub_lines: bool,
47
    /// When true, fill the rest of the current line with the `current` background color
48
    pub highlight_line: bool,
49
}
50
51
impl<'a> ItemRenderer<'a> {
52
    /// Build a renderer from an [`ItemList`] and the pre-computed `container_width`
53
    /// (inner area width minus the icon prefix columns).
54
2.74k
    pub fn new_for(list: &'a ItemList, container_width: usize) -> Self {
55
2.74k
        Self {
56
2.74k
            theme: &list.theme,
57
2.74k
            selector_icon: &list.selector_icon,
58
2.74k
            multi_select_icon: &list.multi_select_icon,
59
2.74k
            ellipsis: &list.ellipsis,
60
2.74k
            container_width,
61
2.74k
            wrap: list.wrap,
62
2.74k
            multiline: list.multiline.as_deref(),
63
2.74k
            show_score: list.show_score,
64
2.74k
            show_index: list.show_index,
65
2.74k
            multi_select: list.multi_select,
66
2.74k
            tabstop: list.tabstop,
67
2.74k
            no_hscroll: list.no_hscroll,
68
2.74k
            keep_right: list.keep_right,
69
2.74k
            manual_hscroll: list.manual_hscroll,
70
2.74k
            skip_to_pattern: list.skip_to_pattern.as_ref(),
71
2.74k
            reverse_sub_lines: list.direction == ListDirection::BottomToTop,
72
2.74k
            highlight_line: list.highlight_line,
73
2.74k
        }
74
2.74k
    }
75
76
    /// Render a single `MatchedItem` into one or more flat `ListItem`s (one per
77
    /// visible sub-line), appending them to `out`.
78
    ///
79
    /// `is_current` / `is_selected` control cursor/selection styling.
80
    /// `skip_subs` leading sub-lines are omitted (for partial top-of-screen scroll).
81
    /// `available_rows` is how many rows remain; rendering stops when exhausted.
82
    /// Returns the number of rows appended.
83
    #[allow(clippy::too_many_arguments)]
84
6.46k
    pub fn render_item(
85
6.46k
        &self,
86
6.46k
        item: &MatchedItem,
87
6.46k
        is_current: bool,
88
6.46k
        is_selected: bool,
89
6.46k
        skip_subs: usize,
90
6.46k
        available_rows: usize,
91
6.46k
        rows_used: usize,
92
6.46k
        out: &mut Vec<ListItem<'static>>,
93
6.46k
    ) -> usize {
94
6.46k
        let item_text = item.item.text();
95
        // When fields are hidden (--hide-nth), project the display text and match
96
        // positions into the visible coordinate space so hidden characters are ignored
97
        // for sub-line splitting, highlighting, and horizontal scrolling. `display()`
98
        // performs the same projection, keeping the styled line consistent with these
99
        // positions.
100
        let (display_text, match_start_char, match_end_char): (std::borrow::Cow<'_, str>, usize, usize) =
101
6.46k
            match item.item.hidden_ranges() {
102
45
                Some(hidden) if !hidden.is_empty() => {
  Branch (102:33): [True: 45, False: 0]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/item_renderer.rs
Line
Count
Source
1
use ratatui::style::Color;
2
use ratatui::text::{Line, Span};
3
use ratatui::widgets::{ListDirection, ListItem};
4
use unicode_display_width::width as display_width;
5
6
use crate::item::MatchedItem;
7
use crate::theme::ColorTheme;
8
use crate::tui::item_list::ItemList;
9
use crate::tui::util::{char_display_width, clip_line_to_chars, wrap_text};
10
use crate::{DisplayContext, MatchRange};
11
12
#[allow(clippy::struct_excessive_bools)]
13
struct SubLineState {
14
    /// Current item
15
    is_current: bool,
16
    /// Selected (in multi-select)
17
    is_selected: bool,
18
    /// First item on the screen
19
    is_first: bool,
20
    /// First line of the item
21
    is_first_sub_line: bool,
22
    needs_ellipsis: bool,
23
}
24
25
/// Rendering parameters that are constant across all items in one render pass.
26
#[allow(clippy::struct_excessive_bools)]
27
pub(crate) struct ItemRenderer<'a> {
28
    pub theme: &'a ColorTheme,
29
    pub selector_icon: &'a str,
30
    pub multi_select_icon: &'a str,
31
    pub ellipsis: &'a str,
32
    pub container_width: usize,
33
    pub wrap: bool,
34
    pub multiline: Option<&'a str>,
35
    pub show_score: bool,
36
    pub show_index: bool,
37
    pub multi_select: bool,
38
    pub tabstop: usize,
39
    pub no_hscroll: bool,
40
    pub keep_right: bool,
41
    pub manual_hscroll: i32,
42
    pub skip_to_pattern: Option<&'a regex::Regex>,
43
    /// When true, reverse the order of sub-lines within each multiline item
44
    /// before appending to the output. Required for `BottomToTop` list direction
45
    /// so that sub-line 0 appears visually above sub-line 1.
46
    pub reverse_sub_lines: bool,
47
    /// When true, fill the rest of the current line with the `current` background color
48
    pub highlight_line: bool,
49
}
50
51
impl<'a> ItemRenderer<'a> {
52
    /// Build a renderer from an [`ItemList`] and the pre-computed `container_width`
53
    /// (inner area width minus the icon prefix columns).
54
2.74k
    pub fn new_for(list: &'a ItemList, container_width: usize) -> Self {
55
2.74k
        Self {
56
2.74k
            theme: &list.theme,
57
2.74k
            selector_icon: &list.selector_icon,
58
2.74k
            multi_select_icon: &list.multi_select_icon,
59
2.74k
            ellipsis: &list.ellipsis,
60
2.74k
            container_width,
61
2.74k
            wrap: list.wrap,
62
2.74k
            multiline: list.multiline.as_deref(),
63
2.74k
            show_score: list.show_score,
64
2.74k
            show_index: list.show_index,
65
2.74k
            multi_select: list.multi_select,
66
2.74k
            tabstop: list.tabstop,
67
2.74k
            no_hscroll: list.no_hscroll,
68
2.74k
            keep_right: list.keep_right,
69
2.74k
            manual_hscroll: list.manual_hscroll,
70
2.74k
            skip_to_pattern: list.skip_to_pattern.as_ref(),
71
2.74k
            reverse_sub_lines: list.direction == ListDirection::BottomToTop,
72
2.74k
            highlight_line: list.highlight_line,
73
2.74k
        }
74
2.74k
    }
75
76
    /// Render a single `MatchedItem` into one or more flat `ListItem`s (one per
77
    /// visible sub-line), appending them to `out`.
78
    ///
79
    /// `is_current` / `is_selected` control cursor/selection styling.
80
    /// `skip_subs` leading sub-lines are omitted (for partial top-of-screen scroll).
81
    /// `available_rows` is how many rows remain; rendering stops when exhausted.
82
    /// Returns the number of rows appended.
83
    #[allow(clippy::too_many_arguments)]
84
6.49k
    pub fn render_item(
85
6.49k
        &self,
86
6.49k
        item: &MatchedItem,
87
6.49k
        is_current: bool,
88
6.49k
        is_selected: bool,
89
6.49k
        skip_subs: usize,
90
6.49k
        available_rows: usize,
91
6.49k
        rows_used: usize,
92
6.49k
        out: &mut Vec<ListItem<'static>>,
93
6.49k
    ) -> usize {
94
6.49k
        let item_text = item.item.text();
95
        // When fields are hidden (--hide-nth), project the display text and match
96
        // positions into the visible coordinate space so hidden characters are ignored
97
        // for sub-line splitting, highlighting, and horizontal scrolling. `display()`
98
        // performs the same projection, keeping the styled line consistent with these
99
        // positions.
100
        let (display_text, match_start_char, match_end_char): (std::borrow::Cow<'_, str>, usize, usize) =
101
6.49k
            match item.item.hidden_ranges() {
102
45
                Some(hidden) if !hidden.is_empty() => {
  Branch (102:33): [True: 45, False: 0]
 
  Branch (102:33): [True: 0, False: 0]
-
103
45
                    let (visible, map) = crate::helper::item::project_visible_text(item_text.as_ref(), hidden);
104
45
                    let matches = Self::display_matches(item.matched_range.as_ref());
105
45
                    let indices = crate::helper::item::project_match_indices(item_text.as_ref(), &matches, &map);
106
45
                    let (start, end) = match (indices.first(), indices.last()) {
107
3
                        (Some(first), Some(last)) => (*first, *last + 1),
108
42
                        _ => (0, 0),
109
                    };
110
45
                    (std::borrow::Cow::Owned(visible), start, end)
111
                }
112
                _ => {
113
6.42k
                    let (start, end) = Self::matched_range(item_text.as_ref(), item.matched_range.as_ref());
114
6.42k
                    (item_text, start, end)
115
                }
116
            };
117
6.46k
        let sub_lines = self.split_sub_lines(display_text.as_ref());
118
119
6.46k
        let mut added = 0usize;
120
        // Collect rows for this item into a temporary buffer so we can reverse
121
        // them when rendering in BottomToTop direction.
122
6.46k
        let mut item_rows: Vec<ListItem<'static>> = Vec::new();
123
124
6.46k
        for (sub_idx, sub_text) in sub_lines.iter().enumerate().skip(skip_subs) {
125
6.99k
            if rows_used + added >= available_rows {
  Branch (125:16): [True: 0, False: 6.88k]
+
103
45
                    let (visible, map) = crate::helper::item::project_visible_text(item_text.as_ref(), hidden);
104
45
                    let matches = Self::display_matches(item.matched_range.as_ref());
105
45
                    let indices = crate::helper::item::project_match_indices(item_text.as_ref(), &matches, &map);
106
45
                    let (start, end) = match (indices.first(), indices.last()) {
107
3
                        (Some(first), Some(last)) => (*first, *last + 1),
108
42
                        _ => (0, 0),
109
                    };
110
45
                    (std::borrow::Cow::Owned(visible), start, end)
111
                }
112
                _ => {
113
6.44k
                    let (start, end) = Self::matched_range(item_text.as_ref(), item.matched_range.as_ref());
114
6.44k
                    (item_text, start, end)
115
                }
116
            };
117
6.49k
        let sub_lines = self.split_sub_lines(display_text.as_ref());
118
119
6.49k
        let mut added = 0usize;
120
        // Collect rows for this item into a temporary buffer so we can reverse
121
        // them when rendering in BottomToTop direction.
122
6.49k
        let mut item_rows: Vec<ListItem<'static>> = Vec::new();
123
124
6.49k
        for (sub_idx, sub_text) in sub_lines.iter().enumerate().skip(skip_subs) {
125
7.01k
            if rows_used + added >= available_rows {
  Branch (125:16): [True: 0, False: 6.90k]
 
  Branch (125:16): [True: 0, False: 109]
-
126
0
                break;
127
6.99k
            }
128
129
6.99k
            let is_first = sub_idx == skip_subs;
130
6.99k
            let is_top_cutoff = is_first && 
skip_subs > 06.46k
;
  Branch (130:33): [True: 6.35k, False: 524]
+
126
0
                break;
127
7.01k
            }
128
129
7.01k
            let is_first = sub_idx == skip_subs;
130
7.01k
            let is_top_cutoff = is_first && 
skip_subs > 06.49k
;
  Branch (130:33): [True: 6.38k, False: 524]
 
  Branch (130:33): [True: 108, False: 1]
-
131
6.99k
            let is_bottom_cutoff = rows_used + added + 1 >= available_rows && 
sub_idx + 1 < sub_lines.len()106
;
  Branch (131:36): [True: 90, False: 6.79k]
+
131
7.01k
            let is_bottom_cutoff = rows_used + added + 1 >= available_rows && 
sub_idx + 1 < sub_lines.len()107
;
  Branch (131:36): [True: 91, False: 6.81k]
 
  Branch (131:36): [True: 16, False: 93]
-
132
6.99k
            let list_item = self.render_sub_line(
133
6.99k
                item,
134
6.99k
                sub_text,
135
                &SubLineState {
136
6.99k
                    is_current,
137
6.99k
                    is_selected,
138
6.99k
                    is_first,
139
6.99k
                    is_first_sub_line: sub_idx == 0,
140
6.99k
                    needs_ellipsis: is_top_cutoff || 
is_bottom_cutoff6.99k
,
  Branch (140:37): [True: 0, False: 6.88k]
+
132
7.01k
            let list_item = self.render_sub_line(
133
7.01k
                item,
134
7.01k
                sub_text,
135
                &SubLineState {
136
7.01k
                    is_current,
137
7.01k
                    is_selected,
138
7.01k
                    is_first,
139
7.01k
                    is_first_sub_line: sub_idx == 0,
140
7.01k
                    needs_ellipsis: is_top_cutoff || 
is_bottom_cutoff7.01k
,
  Branch (140:37): [True: 0, False: 6.90k]
 
  Branch (140:37): [True: 1, False: 108]
-
141
                },
142
6.99k
                match_start_char,
143
6.99k
                match_end_char,
144
            );
145
146
6.99k
            item_rows.push(list_item);
147
6.99k
            added += 1;
148
        }
149
150
6.46k
        if self.reverse_sub_lines {
  Branch (150:12): [True: 6.10k, False: 258]
+
141
                },
142
7.01k
                match_start_char,
143
7.01k
                match_end_char,
144
            );
145
146
7.01k
            item_rows.push(list_item);
147
7.01k
            added += 1;
148
        }
149
150
6.49k
        if self.reverse_sub_lines {
  Branch (150:12): [True: 6.12k, False: 258]
 
  Branch (150:12): [True: 107, False: 1]
-
151
6.20k
            item_rows.reverse();
152
259
        }
153
6.46k
        out.extend(item_rows);
154
155
6.46k
        added
156
6.46k
    }
157
158
6.46k
    fn split_sub_lines<'b>(&self, item_text: &'b str) -> Vec<&'b str> {
159
6.46k
        if let Some(sep) = self.multiline {
  Branch (159:16): [True: 665, False: 5.69k]
+
151
6.23k
            item_rows.reverse();
152
259
        }
153
6.49k
        out.extend(item_rows);
154
155
6.49k
        added
156
6.49k
    }
157
158
6.49k
    fn split_sub_lines<'b>(&self, item_text: &'b str) -> Vec<&'b str> {
159
6.49k
        if let Some(sep) = self.multiline {
  Branch (159:16): [True: 665, False: 5.71k]
 
  Branch (159:16): [True: 2, False: 108]
-
160
667
            item_text.split(sep).collect()
161
        } else {
162
5.80k
            vec![item_text]
163
        }
164
6.46k
    }
165
166
6.42k
    fn matched_range(item_text: &str, matched_range: Option<&MatchRange>) -> (usize, usize) {
167
6.42k
        match matched_range {
168
            Some(MatchRange::Chars(indices)) => {
169
1.66k
                if indices.is_empty() {
  Branch (169:20): [True: 6, False: 1.65k]
+
160
667
            item_text.split(sep).collect()
161
        } else {
162
5.82k
            vec![item_text]
163
        }
164
6.49k
    }
165
166
6.45k
    fn matched_range(item_text: &str, matched_range: Option<&MatchRange>) -> (usize, usize) {
167
6.45k
        match matched_range {
168
            Some(MatchRange::Chars(indices)) => {
169
1.67k
                if indices.is_empty() {
  Branch (169:20): [True: 6, False: 1.66k]
 
  Branch (169:20): [True: 1, False: 1]
-
170
7
                    (0, 0)
171
                } else {
172
1.65k
                    (indices[0], indices[indices.len() - 1] + 1)
173
                }
174
            }
175
            Some(MatchRange::ByteRange(start, end)) => {
176
4.65k
                let start_char = item_text[..*start].chars().count();
177
4.65k
                let len = item_text[*start..*end].chars().count();
178
4.65k
                (start_char, start_char + len)
179
            }
180
1
            Some(MatchRange::CharRange(start, end)) => (*start, *end),
181
110
            None => (0, 0),
182
        }
183
6.42k
    }
184
185
6.99k
    fn render_sub_line(
186
6.99k
        &self,
187
6.99k
        item: &MatchedItem,
188
6.99k
        sub_text: &str,
189
6.99k
        state: &SubLineState,
190
6.99k
        match_start_char: usize,
191
6.99k
        match_end_char: usize,
192
6.99k
    ) -> ListItem<'static> {
193
6.99k
        let mut all_spans = self.prefix_spans(item, state);
194
6.99k
        let content_line = self.content_line(item, sub_text, state, match_start_char, match_end_char);
195
196
6.99k
        if state.needs_ellipsis {
  Branch (196:12): [True: 0, False: 6.88k]
+
170
7
                    (0, 0)
171
                } else {
172
1.66k
                    (indices[0], indices[indices.len() - 1] + 1)
173
                }
174
            }
175
            Some(MatchRange::ByteRange(start, end)) => {
176
4.66k
                let start_char = item_text[..*start].chars().count();
177
4.66k
                let len = item_text[*start..*end].chars().count();
178
4.66k
                (start_char, start_char + len)
179
            }
180
1
            Some(MatchRange::CharRange(start, end)) => (*start, *end),
181
110
            None => (0, 0),
182
        }
183
6.45k
    }
184
185
7.01k
    fn render_sub_line(
186
7.01k
        &self,
187
7.01k
        item: &MatchedItem,
188
7.01k
        sub_text: &str,
189
7.01k
        state: &SubLineState,
190
7.01k
        match_start_char: usize,
191
7.01k
        match_end_char: usize,
192
7.01k
    ) -> ListItem<'static> {
193
7.01k
        let mut all_spans = self.prefix_spans(item, state);
194
7.01k
        let content_line = self.content_line(item, sub_text, state, match_start_char, match_end_char);
195
196
7.01k
        if state.needs_ellipsis {
  Branch (196:12): [True: 0, False: 6.90k]
 
  Branch (196:12): [True: 1, False: 108]
-
197
1
            all_spans.extend(self.trim_with_ellipsis(content_line, state.is_current));
198
        } else {
199
6.99k
            all_spans.extend(content_line.spans);
200
        }
201
202
6.99k
        if item.item.disabled() {
  Branch (202:12): [True: 0, False: 6.88k]
+
197
1
            all_spans.extend(self.trim_with_ellipsis(content_line, state.is_current));
198
        } else {
199
7.01k
            all_spans.extend(content_line.spans);
200
        }
201
202
7.01k
        if item.item.disabled() {
  Branch (202:12): [True: 0, False: 6.90k]
 
  Branch (202:12): [True: 0, False: 109]
-
203
0
            for span in &mut all_spans {
204
0
                span.style = span.style.dim();
205
            }
206
6.99k
        }
207
208
6.99k
        self.list_item_from_spans(all_spans, state.is_current)
209
6.99k
    }
210
211
6.99k
    fn prefix_spans(&self, item: &MatchedItem, state: &SubLineState) -> Vec<Span<'static>> {
212
6.99k
        let mut prefix: Vec<Span<'static>> = Vec::with_capacity(4);
213
        // When highlight_line is active for the current item, the line-level style fills the
214
        // entire row with the current background. Reset the bg on prefix spans so the
215
        // selector/marker columns are not highlighted.
216
6.99k
        let prefix_cursor_style = if self.highlight_line && 
state.is_current208
{
  Branch (216:38): [True: 207, False: 6.67k]
+
203
0
            for span in &mut all_spans {
204
0
                span.style = span.style.dim();
205
            }
206
7.01k
        }
207
208
7.01k
        self.list_item_from_spans(all_spans, state.is_current)
209
7.01k
    }
210
211
7.02k
    fn prefix_spans(&self, item: &MatchedItem, state: &SubLineState) -> Vec<Span<'static>> {
212
7.02k
        let mut prefix: Vec<Span<'static>> = Vec::with_capacity(4);
213
        // When highlight_line is active for the current item, the line-level style fills the
214
        // entire row with the current background. Reset the bg on prefix spans so the
215
        // selector/marker columns are not highlighted.
216
7.02k
        let prefix_cursor_style = if self.highlight_line && 
state.is_current208
{
  Branch (216:38): [True: 207, False: 6.70k]
   Branch (216:61): [True: 9, False: 198]
 
  Branch (216:38): [True: 1, False: 111]
   Branch (216:61): [True: 1, False: 0]
-
217
10
            self.theme.cursor.bg(self.theme.cursor.bg.unwrap_or(Color::Reset))
218
        } else {
219
6.98k
            self.theme.cursor
220
        };
221
6.99k
        let prefix_selected_style = if self.highlight_line && 
state.is_current208
{
  Branch (221:40): [True: 207, False: 6.67k]
+
217
10
            self.theme.cursor.bg(self.theme.cursor.bg.unwrap_or(Color::Reset))
218
        } else {
219
7.01k
            self.theme.cursor
220
        };
221
7.02k
        let prefix_selected_style = if self.highlight_line && 
state.is_current208
{
  Branch (221:40): [True: 207, False: 6.70k]
   Branch (221:63): [True: 9, False: 198]
 
  Branch (221:40): [True: 1, False: 111]
   Branch (221:63): [True: 1, False: 0]
-
222
10
            self.theme.selected.bg(self.theme.selected.bg.unwrap_or(Color::Reset))
223
        } else {
224
6.98k
            self.theme.selected
225
        };
226
227
6.99k
        prefix.push(Span::styled(
228
6.99k
            if state.is_first && 
state.is_current6.46k
{
  Branch (228:16): [True: 6.35k, False: 524]
-  Branch (228:34): [True: 2.06k, False: 4.29k]
+
222
10
            self.theme.selected.bg(self.theme.selected.bg.unwrap_or(Color::Reset))
223
        } else {
224
7.01k
            self.theme.selected
225
        };
226
227
7.02k
        prefix.push(Span::styled(
228
7.02k
            if state.is_first && 
state.is_current6.49k
{
  Branch (228:16): [True: 6.38k, False: 524]
+  Branch (228:34): [True: 2.07k, False: 4.30k]
 
  Branch (228:16): [True: 110, False: 2]
   Branch (228:34): [True: 35, False: 75]
-
229
2.10k
                self.selector_icon.to_owned()
230
            } else {
231
4.89k
                str::repeat(" ", self.selector_icon.chars().count())
232
            },
233
6.99k
            prefix_cursor_style,
234
        ));
235
6.99k
        prefix.push(Span::styled(
236
6.99k
            if state.is_first && 
self.multi_select6.46k
&&
state.is_selected406
{
  Branch (236:16): [True: 6.35k, False: 524]
-  Branch (236:34): [True: 404, False: 5.95k]
-  Branch (236:55): [True: 153, False: 251]
+
229
2.11k
                self.selector_icon.to_owned()
230
            } else {
231
4.91k
                str::repeat(" ", self.selector_icon.chars().count())
232
            },
233
7.02k
            prefix_cursor_style,
234
        ));
235
7.02k
        prefix.push(Span::styled(
236
7.02k
            if state.is_first && 
self.multi_select6.49k
&&
state.is_selected403
{
  Branch (236:16): [True: 6.38k, False: 524]
+  Branch (236:34): [True: 401, False: 5.98k]
+  Branch (236:55): [True: 153, False: 248]
 
  Branch (236:16): [True: 110, False: 2]
   Branch (236:34): [True: 2, False: 108]
   Branch (236:55): [True: 2, False: 0]
-
237
155
                self.multi_select_icon.to_owned()
238
            } else {
239
6.84k
                str::repeat(" ", self.multi_select_icon.chars().count())
240
            },
241
6.99k
            prefix_selected_style,
242
        ));
243
6.99k
        if self.show_score {
  Branch (243:12): [True: 0, False: 6.88k]
+
237
155
                self.multi_select_icon.to_owned()
238
            } else {
239
6.86k
                str::repeat(" ", self.multi_select_icon.chars().count())
240
            },
241
7.02k
            prefix_selected_style,
242
        ));
243
7.02k
        if self.show_score {
  Branch (243:12): [True: 0, False: 6.90k]
 
  Branch (243:12): [True: 2, False: 110]
-
244
2
            self.push_first_line_field(&mut prefix, state, item.rank.score);
245
6.99k
        }
246
6.99k
        if self.show_index {
  Branch (246:12): [True: 0, False: 6.88k]
+
244
2
            self.push_first_line_field(&mut prefix, state, item.rank.score);
245
7.01k
        }
246
7.02k
        if self.show_index {
  Branch (246:12): [True: 0, False: 6.90k]
 
  Branch (246:12): [True: 2, False: 110]
-
247
2
            self.push_first_line_field(&mut prefix, state, item.rank.index);
248
6.99k
        }
249
250
6.99k
        prefix
251
6.99k
    }
252
253
4
    fn push_first_line_field(
254
4
        &self,
255
4
        prefix: &mut Vec<Span<'static>>,
256
4
        state: &SubLineState,
257
4
        value: impl std::fmt::Display,
258
4
    ) {
259
4
        let value = format!("[{value}] ");
260
4
        prefix.push(Span::styled(
261
4
            if state.is_first {
  Branch (261:16): [True: 0, False: 0]
+
247
2
            self.push_first_line_field(&mut prefix, state, item.rank.index);
248
7.01k
        }
249
250
7.02k
        prefix
251
7.02k
    }
252
253
4
    fn push_first_line_field(
254
4
        &self,
255
4
        prefix: &mut Vec<Span<'static>>,
256
4
        state: &SubLineState,
257
4
        value: impl std::fmt::Display,
258
4
    ) {
259
4
        let value = format!("[{value}] ");
260
4
        prefix.push(Span::styled(
261
4
            if state.is_first {
  Branch (261:16): [True: 0, False: 0]
 
  Branch (261:16): [True: 2, False: 2]
-
262
2
                value.clone()
263
            } else {
264
2
                str::repeat(" ", value.chars().count())
265
            },
266
4
            self.base_style(state.is_current),
267
        ));
268
4
    }
269
270
6.99k
    fn content_line(
271
6.99k
        &self,
272
6.99k
        item: &MatchedItem,
273
6.99k
        sub_text: &str,
274
6.99k
        state: &SubLineState,
275
6.99k
        match_start_char: usize,
276
6.99k
        match_end_char: usize,
277
6.99k
    ) -> Line<'static> {
278
6.99k
        if state.is_first && 
state.is_first_sub_line6.46k
{
  Branch (278:12): [True: 6.35k, False: 524]
-  Branch (278:30): [True: 6.35k, False: 0]
+
262
2
                value.clone()
263
            } else {
264
2
                str::repeat(" ", value.chars().count())
265
            },
266
4
            self.base_style(state.is_current),
267
        ));
268
4
    }
269
270
7.01k
    fn content_line(
271
7.01k
        &self,
272
7.01k
        item: &MatchedItem,
273
7.01k
        sub_text: &str,
274
7.01k
        state: &SubLineState,
275
7.01k
        match_start_char: usize,
276
7.01k
        match_end_char: usize,
277
7.01k
    ) -> Line<'static> {
278
7.01k
        if state.is_first && 
state.is_first_sub_line6.49k
{
  Branch (278:12): [True: 6.38k, False: 524]
+  Branch (278:30): [True: 6.38k, False: 0]
 
  Branch (278:12): [True: 108, False: 1]
   Branch (278:30): [True: 107, False: 1]
-
279
6.46k
            self.first_sub_line_content(item, sub_text, state.is_current, match_start_char, match_end_char)
280
        } else {
281
526
            self.continuation_sub_line_content(sub_text, state.is_current)
282
        }
283
6.99k
    }
284
285
6.46k
    fn first_sub_line_content(
286
6.46k
        &self,
287
6.46k
        item: &MatchedItem,
288
6.46k
        first_sub_line: &str,
289
6.46k
        is_current: bool,
290
6.46k
        match_start_char: usize,
291
6.46k
        match_end_char: usize,
292
6.46k
    ) -> Line<'static> {
293
6.46k
        let first_sub_char_len = first_sub_line.chars().count();
294
6.46k
        let dl = item.item.display(DisplayContext {
295
6.46k
            score: item.rank.score,
296
6.46k
            matches: Self::display_matches(item.matched_range.as_ref()),
297
6.46k
            container_width: self.container_width,
298
6.46k
            base_style: self.base_style(is_current),
299
6.46k
            matched_style: if is_current {
  Branch (299:31): [True: 2.06k, False: 4.29k]
+
279
6.49k
            self.first_sub_line_content(item, sub_text, state.is_current, match_start_char, match_end_char)
280
        } else {
281
526
            self.continuation_sub_line_content(sub_text, state.is_current)
282
        }
283
7.01k
    }
284
285
6.49k
    fn first_sub_line_content(
286
6.49k
        &self,
287
6.49k
        item: &MatchedItem,
288
6.49k
        first_sub_line: &str,
289
6.49k
        is_current: bool,
290
6.49k
        match_start_char: usize,
291
6.49k
        match_end_char: usize,
292
6.49k
    ) -> Line<'static> {
293
6.49k
        let first_sub_char_len = first_sub_line.chars().count();
294
6.49k
        let dl = item.item.display(DisplayContext {
295
6.49k
            score: item.rank.score,
296
6.49k
            matches: Self::display_matches(item.matched_range.as_ref()),
297
6.49k
            container_width: self.container_width,
298
6.49k
            base_style: self.base_style(is_current),
299
6.49k
            matched_style: if is_current {
  Branch (299:31): [True: 2.07k, False: 4.30k]
 
  Branch (299:31): [True: 33, False: 75]
-
300
2.09k
                self.theme.current_match
301
            } else {
302
4.36k
                self.theme.matched
303
            },
304
        });
305
306
        // In multiline mode the first rendered line must be clipped to the first text sub-line.
307
        // Without multiline, custom display text may legitimately be longer than item.text().
308
6.46k
        let mut line = if self.multiline.is_some() {
  Branch (308:27): [True: 665, False: 5.69k]
+
300
2.10k
                self.theme.current_match
301
            } else {
302
4.38k
                self.theme.matched
303
            },
304
        });
305
306
        // In multiline mode the first rendered line must be clipped to the first text sub-line.
307
        // Without multiline, custom display text may legitimately be longer than item.text().
308
6.49k
        let mut line = if self.multiline.is_some() {
  Branch (308:27): [True: 665, False: 5.71k]
 
  Branch (308:27): [True: 0, False: 108]
-
309
665
            clip_line_to_chars(dl, first_sub_char_len)
310
        } else {
311
5.80k
            dl
312
        };
313
6.46k
        if !self.wrap {
  Branch (313:12): [True: 6.35k, False: 9]
+
309
665
            clip_line_to_chars(dl, first_sub_char_len)
310
        } else {
311
5.82k
            dl
312
        };
313
6.49k
        if !self.wrap {
  Branch (313:12): [True: 6.37k, False: 9]
 
  Branch (313:12): [True: 108, False: 0]
-
314
6.45k
            let (shift, full_width, _, _) = if self.multiline.is_some() {
  Branch (314:48): [True: 659, False: 5.69k]
+
314
6.48k
            let (shift, full_width, _, _) = if self.multiline.is_some() {
  Branch (314:48): [True: 659, False: 5.71k]
 
  Branch (314:48): [True: 0, False: 108]
-
315
659
                self.calc_hscroll(first_sub_line, match_start_char, match_end_char)
316
            } else {
317
5.79k
                self.calc_line_hscroll(&line, first_sub_line, match_start_char, match_end_char)
318
            };
319
6.45k
            line = self.apply_hscroll(line, shift, full_width);
320
9
        }
321
6.46k
        Self::into_static_line(line)
322
6.46k
    }
323
324
527
    fn continuation_sub_line_content(&self, sub_text: &str, is_current: bool) -> Line<'static> {
325
527
        let sub_str = sub_text.to_string();
326
527
        let raw: Line<'static> = Line::from(vec![Span::styled(sub_str, self.base_style(is_current))]);
327
527
        if self.wrap {
  Branch (327:12): [True: 6, False: 518]
+
315
659
                self.calc_hscroll(first_sub_line, match_start_char, match_end_char)
316
            } else {
317
5.82k
                self.calc_line_hscroll(&line, first_sub_line, match_start_char, match_end_char)
318
            };
319
6.48k
            line = self.apply_hscroll(line, shift, full_width);
320
9
        }
321
6.49k
        Self::into_static_line(line)
322
6.49k
    }
323
324
527
    fn continuation_sub_line_content(&self, sub_text: &str, is_current: bool) -> Line<'static> {
325
527
        let sub_str = sub_text.to_string();
326
527
        let raw: Line<'static> = Line::from(vec![Span::styled(sub_str, self.base_style(is_current))]);
327
527
        if self.wrap {
  Branch (327:12): [True: 6, False: 518]
 
  Branch (327:12): [True: 0, False: 3]
-
328
6
            raw
329
        } else {
330
521
            let (shift, full_width, _, _) = self.calc_hscroll(sub_text, 0, 0);
331
521
            let scrolled = self.apply_hscroll(raw, shift, full_width);
332
521
            Self::into_static_line(scrolled)
333
        }
334
527
    }
335
336
6.51k
    fn display_matches(matched_range: Option<&MatchRange>) -> crate::Matches {
337
6.51k
        match matched_range {
338
4.71k
            Some(MatchRange::ByteRange(start, end)) => crate::Matches::ByteRange(*start, *end),
339
1
            Some(MatchRange::CharRange(start, end)) => crate::Matches::CharRange(*start, *end),
340
1.69k
            Some(MatchRange::Chars(chars)) => crate::Matches::CharIndices(chars.clone()),
341
110
            None => crate::Matches::None,
342
        }
343
6.51k
    }
344
345
2
    fn trim_with_ellipsis(&self, content_line: Line<'static>, is_current: bool) -> Vec<Span<'static>> {
346
2
        let ell_width = usize::try_from(display_width(self.ellipsis)).unwrap();
347
2
        let available = self.container_width.saturating_sub(ell_width);
348
2
        let mut trimmed: Vec<Span<'static>> = Vec::new();
349
2
        let mut used = 0usize;
350
351
2
        'trim: for span in content_line.spans {
352
3
            let span_chars: Vec<char> = span.content.chars().collect();
353
3
            for (i, ch) in span_chars.iter().enumerate() {
354
11
                let w = char_display_width(*ch);
355
11
                if used + w > available {
  Branch (355:20): [True: 0, False: 0]
+
328
6
            raw
329
        } else {
330
521
            let (shift, full_width, _, _) = self.calc_hscroll(sub_text, 0, 0);
331
521
            let scrolled = self.apply_hscroll(raw, shift, full_width);
332
521
            Self::into_static_line(scrolled)
333
        }
334
527
    }
335
336
6.54k
    fn display_matches(matched_range: Option<&MatchRange>) -> crate::Matches {
337
6.54k
        match matched_range {
338
4.72k
            Some(MatchRange::ByteRange(start, end)) => crate::Matches::ByteRange(*start, *end),
339
1
            Some(MatchRange::CharRange(start, end)) => crate::Matches::CharRange(*start, *end),
340
1.70k
            Some(MatchRange::Chars(chars)) => crate::Matches::CharIndices(chars.clone()),
341
110
            None => crate::Matches::None,
342
        }
343
6.54k
    }
344
345
2
    fn trim_with_ellipsis(&self, content_line: Line<'static>, is_current: bool) -> Vec<Span<'static>> {
346
2
        let ell_width = usize::try_from(display_width(self.ellipsis)).unwrap();
347
2
        let available = self.container_width.saturating_sub(ell_width);
348
2
        let mut trimmed: Vec<Span<'static>> = Vec::new();
349
2
        let mut used = 0usize;
350
351
2
        'trim: for span in content_line.spans {
352
3
            let span_chars: Vec<char> = span.content.chars().collect();
353
3
            for (i, ch) in span_chars.iter().enumerate() {
354
11
                let w = char_display_width(*ch);
355
11
                if used + w > available {
  Branch (355:20): [True: 0, False: 0]
 
  Branch (355:20): [True: 1, False: 10]
 
356
1
                    let partial: String = span_chars[..i].iter().collect();
357
1
                    if !partial.is_empty() {
  Branch (357:24): [True: 0, False: 0]
 
  Branch (357:24): [True: 1, False: 0]
-
358
1
                        trimmed.push(Span::styled(partial, span.style));
359
0
                    }
360
1
                    break 'trim;
361
10
                }
362
10
                used += w;
363
            }
364
2
            trimmed.push(Span::styled(span.content.into_owned(), span.style));
365
        }
366
2
        trimmed.push(Span::styled(self.ellipsis.to_owned(), self.base_style(is_current)));
367
2
        trimmed
368
2
    }
369
370
6.99k
    fn list_item_from_spans(&self, spans: Vec<Span<'static>>, is_current: bool) -> ListItem<'static> {
371
6.99k
        if self.wrap {
  Branch (371:12): [True: 15, False: 6.86k]
+
358
1
                        trimmed.push(Span::styled(partial, span.style));
359
0
                    }
360
1
                    break 'trim;
361
10
                }
362
10
                used += w;
363
            }
364
2
            trimmed.push(Span::styled(span.content.into_owned(), span.style));
365
        }
366
2
        trimmed.push(Span::styled(self.ellipsis.to_owned(), self.base_style(is_current)));
367
2
        trimmed
368
2
    }
369
370
7.01k
    fn list_item_from_spans(&self, spans: Vec<Span<'static>>, is_current: bool) -> ListItem<'static> {
371
7.01k
        if self.wrap {
  Branch (371:12): [True: 15, False: 6.89k]
 
  Branch (371:12): [True: 0, False: 109]
-
372
15
            wrap_text(
373
15
                ratatui::text::Text::from(Line::from(spans)),
374
15
                self.container_width + self.selector_icon.chars().count() + self.multi_select_icon.chars().count(),
375
15
            )
376
15
            .into()
377
        } else {
378
6.97k
            let mut line = Line::from(spans);
379
            // When highlight_line is enabled, set the line's style to the current theme
380
            // so ratatui fills the entire row width with the current background color.
381
6.97k
            if self.highlight_line && 
is_current207
{
  Branch (381:16): [True: 207, False: 6.66k]
+
372
15
            wrap_text(
373
15
                ratatui::text::Text::from(Line::from(spans)),
374
15
                self.container_width + self.selector_icon.chars().count() + self.multi_select_icon.chars().count(),
375
15
            )
376
15
            .into()
377
        } else {
378
7.00k
            let mut line = Line::from(spans);
379
            // When highlight_line is enabled, set the line's style to the current theme
380
            // so ratatui fills the entire row width with the current background color.
381
7.00k
            if self.highlight_line && 
is_current207
{
  Branch (381:16): [True: 207, False: 6.68k]
   Branch (381:39): [True: 9, False: 198]
 
  Branch (381:16): [True: 0, False: 109]
   Branch (381:39): [True: 0, False: 0]
-
382
9
                line = line.style(self.theme.current);
383
6.96k
            }
384
6.97k
            line.into()
385
        }
386
6.99k
    }
387
388
7.00k
    fn base_style(&self, is_current: bool) -> ratatui::style::Style {
389
7.00k
        if is_current {
  Branch (389:12): [True: 2.18k, False: 4.70k]
+
382
9
                line = line.style(self.theme.current);
383
6.99k
            }
384
7.00k
            line.into()
385
        }
386
7.01k
    }
387
388
7.02k
    fn base_style(&self, is_current: bool) -> ratatui::style::Style {
389
7.02k
        if is_current {
  Branch (389:12): [True: 2.19k, False: 4.71k]
 
  Branch (389:12): [True: 37, False: 80]
-
390
2.22k
            self.theme.current
391
        } else {
392
4.78k
            self.theme.normal
393
        }
394
7.00k
    }
395
396
6.98k
    fn into_static_line(line: Line<'_>) -> Line<'static> {
397
6.98k
        line.spans
398
6.98k
            .into_iter()
399
11.0k
            .map(|span| Span::styled(span.content.into_owned(), span.style))
400
            .collect()
401
6.98k
    }
402
403
    // ── hscroll helpers ──────────────────────────────────────────────────────
404
405
5.25k
    fn calc_skip_width(&self, text: &str) -> usize {
406
5.25k
        if let Some(regex) = self.skip_to_pattern
  Branch (406:16): [True: 7, False: 5.13k]
+
390
2.23k
            self.theme.current
391
        } else {
392
4.79k
            self.theme.normal
393
        }
394
7.02k
    }
395
396
7.01k
    fn into_static_line(line: Line<'_>) -> Line<'static> {
397
7.01k
        line.spans
398
7.01k
            .into_iter()
399
11.1k
            .map(|span| Span::styled(span.content.into_owned(), span.style))
400
            .collect()
401
7.01k
    }
402
403
    // ── hscroll helpers ──────────────────────────────────────────────────────
404
405
5.26k
    fn calc_skip_width(&self, text: &str) -> usize {
406
5.26k
        if let Some(regex) = self.skip_to_pattern
  Branch (406:16): [True: 7, False: 5.14k]
 
  Branch (406:16): [True: 0, False: 112]
 
407
7
            && let Some(mat) = regex.find(text)
  Branch (407:20): [True: 7, False: 0]
 
  Branch (407:20): [True: 0, False: 0]
-
408
        {
409
7
            return usize::try_from(display_width(&text[..mat.start()])).unwrap();
410
5.24k
        }
411
5.24k
        0
412
5.25k
    }
413
414
1.18k
    fn calc_hscroll(&self, text: &str, match_start_char: usize, match_end_char: usize) -> (usize, usize, bool, bool) {
415
1.18k
        let full_width = self.text_display_width(text);
416
417
1.18k
        self.calc_hscroll_for_width(text, match_start_char, match_end_char, full_width)
418
1.18k
    }
419
420
5.79k
    fn calc_line_hscroll(
421
5.79k
        &self,
422
5.79k
        line: &Line<'_>,
423
5.79k
        text: &str,
424
5.79k
        match_start_char: usize,
425
5.79k
        match_end_char: usize,
426
5.79k
    ) -> (usize, usize, bool, bool) {
427
5.79k
        self.calc_hscroll_for_width(text, match_start_char, match_end_char, self.line_display_width(line))
428
5.79k
    }
429
430
6.98k
    fn calc_hscroll_for_width(
431
6.98k
        &self,
432
6.98k
        text: &str,
433
6.98k
        match_start_char: usize,
434
6.98k
        match_end_char: usize,
435
6.98k
        full_width: usize,
436
6.98k
    ) -> (usize, usize, bool, bool) {
437
6.98k
        let ell_w = usize::try_from(display_width(self.ellipsis)).unwrap();
438
6.98k
        let available_width = if self.container_width >= ell_w {
  Branch (438:34): [True: 6.86k, False: 0]
+
408
        {
409
7
            return usize::try_from(display_width(&text[..mat.start()])).unwrap();
410
5.26k
        }
411
5.26k
        0
412
5.26k
    }
413
414
1.18k
    fn calc_hscroll(&self, text: &str, match_start_char: usize, match_end_char: usize) -> (usize, usize, bool, bool) {
415
1.18k
        let full_width = self.text_display_width(text);
416
417
1.18k
        self.calc_hscroll_for_width(text, match_start_char, match_end_char, full_width)
418
1.18k
    }
419
420
5.82k
    fn calc_line_hscroll(
421
5.82k
        &self,
422
5.82k
        line: &Line<'_>,
423
5.82k
        text: &str,
424
5.82k
        match_start_char: usize,
425
5.82k
        match_end_char: usize,
426
5.82k
    ) -> (usize, usize, bool, bool) {
427
5.82k
        self.calc_hscroll_for_width(text, match_start_char, match_end_char, self.line_display_width(line))
428
5.82k
    }
429
430
7.00k
    fn calc_hscroll_for_width(
431
7.00k
        &self,
432
7.00k
        text: &str,
433
7.00k
        match_start_char: usize,
434
7.00k
        match_end_char: usize,
435
7.00k
        full_width: usize,
436
7.00k
    ) -> (usize, usize, bool, bool) {
437
7.00k
        let ell_w = usize::try_from(display_width(self.ellipsis)).unwrap();
438
7.00k
        let available_width = if self.container_width >= ell_w {
  Branch (438:34): [True: 6.89k, False: 0]
 
  Branch (438:34): [True: 113, False: 1]
-
439
6.98k
            self.container_width
440
        } else {
441
1
            return (0, full_width, false, false);
442
        };
443
444
6.98k
        let base_shift = if self.no_hscroll {
  Branch (444:29): [True: 3, False: 6.86k]
+
439
7.00k
            self.container_width
440
        } else {
441
1
            return (0, full_width, false, false);
442
        };
443
444
7.00k
        let base_shift = if self.no_hscroll {
  Branch (444:29): [True: 3, False: 6.89k]
 
  Branch (444:29): [True: 0, False: 113]
-
445
3
            0
446
6.97k
        } else if match_start_char == 0 && 
match_end_char == 06.63k
{
  Branch (446:19): [True: 6.52k, False: 341]
-  Branch (446:44): [True: 5.14k, False: 1.38k]
+
445
3
            0
446
7.00k
        } else if match_start_char == 0 && 
match_end_char == 06.65k
{
  Branch (446:19): [True: 6.53k, False: 351]
+  Branch (446:44): [True: 5.15k, False: 1.38k]
 
  Branch (446:19): [True: 112, False: 1]
   Branch (446:44): [True: 112, False: 0]
-
447
5.25k
            let skip_width = self.calc_skip_width(text);
448
5.25k
            if skip_width > 0 {
  Branch (448:16): [True: 7, False: 5.13k]
+
447
5.26k
            let skip_width = self.calc_skip_width(text);
448
5.26k
            if skip_width > 0 {
  Branch (448:16): [True: 7, False: 5.14k]
 
  Branch (448:16): [True: 0, False: 112]
-
449
7
                skip_width
450
5.24k
            } else if self.keep_right {
  Branch (450:23): [True: 0, False: 5.13k]
+
449
7
                skip_width
450
5.26k
            } else if self.keep_right {
  Branch (450:23): [True: 0, False: 5.14k]
 
  Branch (450:23): [True: 1, False: 111]
-
451
1
                full_width.saturating_sub(available_width)
452
            } else {
453
5.24k
                0
454
            }
455
        } else {
456
1.72k
            let mut match_start_width = 0;
457
1.72k
            let mut match_end_width = 0;
458
1.72k
            let mut current_width = 0;
459
1.72k
            let mut found_start = false;
460
1.72k
            let mut found_end = false;
461
462
1.72k
            for (idx, ch) in text.chars().enumerate() {
463
13.6k
                if idx == match_start_char {
  Branch (463:20): [True: 1.71k, False: 11.8k]
+
451
1
                full_width.saturating_sub(available_width)
452
            } else {
453
5.25k
                0
454
            }
455
        } else {
456
1.73k
            let mut match_start_width = 0;
457
1.73k
            let mut match_end_width = 0;
458
1.73k
            let mut current_width = 0;
459
1.73k
            let mut found_start = false;
460
1.73k
            let mut found_end = false;
461
462
1.73k
            for (idx, ch) in text.chars().enumerate() {
463
13.6k
                if idx == match_start_char {
  Branch (463:20): [True: 1.73k, False: 11.9k]
 
  Branch (463:20): [True: 1, False: 10]
-
464
1.72k
                    match_start_width = current_width;
465
1.72k
                    found_start = true;
466
11.9k
                }
467
13.6k
                if idx == match_end_char {
  Branch (467:20): [True: 1.28k, False: 12.3k]
+
464
1.73k
                    match_start_width = current_width;
465
1.73k
                    found_start = true;
466
11.9k
                }
467
13.6k
                if idx == match_end_char {
  Branch (467:20): [True: 1.29k, False: 12.3k]
 
  Branch (467:20): [True: 1, False: 10]
-
468
1.28k
                    match_end_width = current_width;
469
1.28k
                    found_end = true;
470
1.28k
                    break;
471
12.3k
                }
472
12.3k
                current_width = self.add_char_width(current_width, ch);
473
            }
474
1.72k
            if found_start && 
!found_end1.72k
{
  Branch (474:16): [True: 1.71k, False: 3]
-  Branch (474:31): [True: 433, False: 1.28k]
+
468
1.29k
                    match_end_width = current_width;
469
1.29k
                    found_end = true;
470
1.29k
                    break;
471
12.3k
                }
472
12.3k
                current_width = self.add_char_width(current_width, ch);
473
            }
474
1.73k
            if found_start && 
!found_end1.73k
{
  Branch (474:16): [True: 1.73k, False: 3]
+  Branch (474:31): [True: 435, False: 1.29k]
 
  Branch (474:16): [True: 1, False: 0]
   Branch (474:31): [True: 0, False: 1]
-
475
433
                match_end_width = current_width;
476
1.29k
            }
477
478
1.72k
            let match_width = match_end_width.saturating_sub(match_start_width);
479
1.72k
            if match_width >= available_width {
  Branch (479:16): [True: 0, False: 1.72k]
+
475
435
                match_end_width = current_width;
476
1.30k
            }
477
478
1.73k
            let match_width = match_end_width.saturating_sub(match_start_width);
479
1.73k
            if match_width >= available_width {
  Branch (479:16): [True: 0, False: 1.73k]
 
  Branch (479:16): [True: 1, False: 0]
-
480
1
                match_start_width
481
            } else {
482
1.72k
                let desired = match_start_width.saturating_sub((available_width - match_width) / 2);
483
1.72k
                desired.min(full_width.saturating_sub(available_width))
484
            }
485
        };
486
487
6.98k
        let proposed = (i32::try_from(base_shift).unwrap_or(i32::MAX) + self.manual_hscroll)
488
6.98k
            .max(0)
489
6.98k
            .unsigned_abs() as usize;
490
6.98k
        let shift = if full_width > available_width {
  Branch (490:24): [True: 16, False: 6.85k]
+
480
1
                match_start_width
481
            } else {
482
1.73k
                let desired = match_start_width.saturating_sub((available_width - match_width) / 2);
483
1.73k
                desired.min(full_width.saturating_sub(available_width))
484
            }
485
        };
486
487
7.00k
        let proposed = (i32::try_from(base_shift).unwrap_or(i32::MAX) + self.manual_hscroll)
488
7.00k
            .max(0)
489
7.00k
            .unsigned_abs() as usize;
490
7.00k
        let shift = if full_width > available_width {
  Branch (490:24): [True: 16, False: 6.87k]
 
  Branch (490:24): [True: 4, False: 109]
-
491
20
            proposed.min(full_width.saturating_sub(available_width))
492
        } else {
493
6.96k
            proposed
494
        };
495
496
6.98k
        (shift, full_width, shift > 0, shift + available_width < full_width)
497
6.98k
    }
498
499
1.18k
    fn text_display_width(&self, text: &str) -> usize {
500
2.78k
        
text.chars()1.18k
.fold(
0usize1.18k
, |width, ch| self.add_char_width(width, ch))
501
1.18k
    }
502
503
5.79k
    fn line_display_width(&self, line: &Line<'_>) -> usize {
504
20.9k
        
line.spans.iter()5.79k
.fold(
0usize5.79k
, |width, span| {
505
20.9k
            span.content
506
20.9k
                .chars()
507
41.5k
                .fold(
width20.9k
, |span_width, ch| self.add_char_width(span_width, ch))
508
20.9k
        })
509
5.79k
    }
510
511
98.2k
    fn add_char_width(&self, width: usize, ch: char) -> usize {
512
98.2k
        if ch == '\t' {
  Branch (512:12): [True: 135, False: 97.4k]
+
491
20
            proposed.min(full_width.saturating_sub(available_width))
492
        } else {
493
6.98k
            proposed
494
        };
495
496
7.00k
        (shift, full_width, shift > 0, shift + available_width < full_width)
497
7.00k
    }
498
499
1.18k
    fn text_display_width(&self, text: &str) -> usize {
500
2.78k
        
text.chars()1.18k
.fold(
0usize1.18k
, |width, ch| self.add_char_width(width, ch))
501
1.18k
    }
502
503
5.82k
    fn line_display_width(&self, line: &Line<'_>) -> usize {
504
21.0k
        
line.spans.iter()5.82k
.fold(
0usize5.82k
, |width, span| {
505
21.0k
            span.content
506
21.0k
                .chars()
507
41.7k
                .fold(
width21.0k
, |span_width, ch| self.add_char_width(span_width, ch))
508
21.0k
        })
509
5.82k
    }
510
511
98.8k
    fn add_char_width(&self, width: usize, ch: char) -> usize {
512
98.8k
        if ch == '\t' {
  Branch (512:12): [True: 135, False: 98.0k]
 
  Branch (512:12): [True: 0, False: 663]
-
513
135
            width + self.tabstop - (width % self.tabstop)
514
        } else {
515
98.0k
            width + char_display_width(ch)
516
        }
517
98.2k
    }
518
519
6.97k
    fn apply_hscroll<'b>(&'b self, line: Line<'b>, shift: usize, full_width: usize) -> Line<'b> {
520
6.97k
        let container_width = self.container_width;
521
6.97k
        let has_left = shift > 0;
522
6.97k
        let has_right = shift + container_width < full_width;
523
524
6.97k
        let ell_w = usize::try_from(display_width(self.ellipsis)).unwrap();
525
6.97k
        let left_w = if has_left { 
ell_w18
} else {
06.96k
};
  Branch (525:25): [True: 17, False: 6.85k]
+
513
135
            width + self.tabstop - (width % self.tabstop)
514
        } else {
515
98.6k
            width + char_display_width(ch)
516
        }
517
98.8k
    }
518
519
7.00k
    fn apply_hscroll<'b>(&'b self, line: Line<'b>, shift: usize, full_width: usize) -> Line<'b> {
520
7.00k
        let container_width = self.container_width;
521
7.00k
        let has_left = shift > 0;
522
7.00k
        let has_right = shift + container_width < full_width;
523
524
7.00k
        let ell_w = usize::try_from(display_width(self.ellipsis)).unwrap();
525
7.00k
        let left_w = if has_left { 
ell_w18
} else {
06.98k
};
  Branch (525:25): [True: 17, False: 6.87k]
 
  Branch (525:25): [True: 1, False: 110]
-
526
6.97k
        let right_w = if has_right { 
ell_w14
} else {
06.96k
};
  Branch (526:26): [True: 13, False: 6.85k]
+
526
7.00k
        let right_w = if has_right { 
ell_w14
} else {
06.99k
};
  Branch (526:26): [True: 13, False: 6.88k]
 
  Branch (526:26): [True: 1, False: 110]
-
527
6.97k
        let content_width = container_width.saturating_sub(left_w + right_w);
528
529
6.97k
        let mut result = Line::default();
530
6.97k
        if has_left {
  Branch (530:12): [True: 17, False: 6.85k]
+
527
7.00k
        let content_width = container_width.saturating_sub(left_w + right_w);
528
529
7.00k
        let mut result = Line::default();
530
7.00k
        if has_left {
  Branch (530:12): [True: 17, False: 6.87k]
 
  Branch (530:12): [True: 1, False: 110]
-
531
18
            result.push_span(Span::raw(self.ellipsis));
532
6.96k
        }
533
534
6.97k
        let mut current_char_index = 0;
535
6.97k
        let mut current_width = 0;
536
6.97k
        let shift_char_start = self.char_index_at_width(&line, shift);
537
6.97k
        let shift_char_end = self.char_index_at_width(&line, shift + content_width);
538
539
6.97k
        for span in line.spans {
540
22.2k
            let span_text = span.content.as_ref();
541
22.2k
            let span_chars: Vec<char> = span_text.chars().collect();
542
22.2k
            let span_start = current_char_index;
543
22.2k
            let span_end = current_char_index + span_chars.len();
544
545
22.2k
            if span_end > shift_char_start && 
span_start < shift_char_end12.9k
{
  Branch (545:16): [True: 12.8k, False: 9.22k]
-  Branch (545:47): [True: 12.4k, False: 418]
+
531
18
            result.push_span(Span::raw(self.ellipsis));
532
6.98k
        }
533
534
7.00k
        let mut current_char_index = 0;
535
7.00k
        let mut current_width = 0;
536
7.00k
        let shift_char_start = self.char_index_at_width(&line, shift);
537
7.00k
        let shift_char_end = self.char_index_at_width(&line, shift + content_width);
538
539
7.00k
        for span in line.spans {
540
22.2k
            let span_text = span.content.as_ref();
541
22.2k
            let span_chars: Vec<char> = span_text.chars().collect();
542
22.2k
            let span_start = current_char_index;
543
22.2k
            let span_end = current_char_index + span_chars.len();
544
545
22.2k
            if span_end > shift_char_start && 
span_start < shift_char_end13.0k
{
  Branch (545:16): [True: 12.9k, False: 9.25k]
+  Branch (545:47): [True: 12.5k, False: 420]
 
  Branch (545:16): [True: 111, False: 0]
   Branch (545:47): [True: 111, False: 0]
-
546
12.5k
                let vis_start = shift_char_start.saturating_sub(span_start);
547
12.5k
                let vis_end = if span_end > shift_char_end {
  Branch (547:34): [True: 12, False: 12.4k]
+
546
12.6k
                let vis_start = shift_char_start.saturating_sub(span_start);
547
12.6k
                let vis_end = if span_end > shift_char_end {
  Branch (547:34): [True: 12, False: 12.4k]
 
  Branch (547:34): [True: 2, False: 109]
-
548
14
                    shift_char_end - span_start
549
                } else {
550
12.5k
                    span_chars.len()
551
                };
552
12.5k
                if vis_start < vis_end && 
vis_start < span_chars.len()11.0k
{
  Branch (552:20): [True: 10.9k, False: 1.51k]
+
548
14
                    shift_char_end - span_start
549
                } else {
550
12.6k
                    span_chars.len()
551
                };
552
12.6k
                if vis_start < vis_end && 
vis_start < span_chars.len()11.1k
{
  Branch (552:20): [True: 10.9k, False: 1.51k]
   Branch (552:43): [True: 10.9k, False: 0]
 
  Branch (552:20): [True: 111, False: 0]
   Branch (552:43): [True: 111, False: 0]
-
553
11.0k
                    let visible: String = span_chars[vis_start..vis_end.min(span_chars.len())].iter().collect();
554
11.0k
                    let processed = if visible.contains('\t') {
  Branch (554:40): [True: 45, False: 10.8k]
+
553
11.1k
                    let visible: String = span_chars[vis_start..vis_end.min(span_chars.len())].iter().collect();
554
11.1k
                    let processed = if visible.contains('\t') {
  Branch (554:40): [True: 45, False: 10.9k]
 
  Branch (554:40): [True: 0, False: 111]
-
555
45
                        self.expand_tabs(&visible, current_width)
556
                    } else {
557
11.0k
                        visible
558
                    };
559
11.0k
                    if !processed.is_empty() {
  Branch (559:24): [True: 10.9k, False: 0]
+
555
45
                        self.expand_tabs(&visible, current_width)
556
                    } else {
557
11.0k
                        visible
558
                    };
559
11.1k
                    if !processed.is_empty() {
  Branch (559:24): [True: 10.9k, False: 0]
 
  Branch (559:24): [True: 111, False: 0]
-
560
11.0k
                        result.push_span(Span::styled(processed, span.style));
561
0
                    }
562
1.51k
                }
563
9.64k
            }
564
22.2k
            current_char_index += span_chars.len();
565
22.2k
            current_width += usize::try_from(display_width(span_text)).unwrap();
566
        }
567
568
6.97k
        if has_right {
  Branch (568:12): [True: 13, False: 6.85k]
+
560
11.1k
                        result.push_span(Span::styled(processed, span.style));
561
0
                    }
562
1.51k
                }
563
9.67k
            }
564
22.2k
            current_char_index += span_chars.len();
565
22.2k
            current_width += usize::try_from(display_width(span_text)).unwrap();
566
        }
567
568
7.00k
        if has_right {
  Branch (568:12): [True: 13, False: 6.88k]
 
  Branch (568:12): [True: 1, False: 110]
-
569
14
            result.push_span(Span::raw(self.ellipsis));
570
6.96k
        }
571
6.97k
        result
572
6.97k
    }
573
574
13.9k
    fn char_index_at_width(&self, line: &Line<'_>, target_width: usize) -> usize {
575
13.9k
        let mut current_width = 0;
576
13.9k
        let mut char_index = 0;
577
13.9k
        for span in &line.spans {
578
38.3k
            for ch in span.content.chars() {
579
48.3k
                if current_width >= target_width {
  Branch (579:20): [True: 6.84k, False: 41.0k]
+
569
14
            result.push_span(Span::raw(self.ellipsis));
570
6.99k
        }
571
7.00k
        result
572
7.00k
    }
573
574
14.0k
    fn char_index_at_width(&self, line: &Line<'_>, target_width: usize) -> usize {
575
14.0k
        let mut current_width = 0;
576
14.0k
        let mut char_index = 0;
577
14.0k
        for span in &line.spans {
578
38.4k
            for ch in span.content.chars() {
579
48.6k
                if current_width >= target_width {
  Branch (579:20): [True: 6.86k, False: 41.3k]
 
  Branch (579:20): [True: 113, False: 324]
-
580
6.95k
                    return char_index;
581
41.4k
                }
582
41.4k
                current_width = self.add_char_width(current_width, ch);
583
41.4k
                char_index += 1;
584
            }
585
        }
586
7.00k
        char_index
587
13.9k
    }
588
589
45
    fn expand_tabs(&self, text: &str, start_width: usize) -> String {
590
45
        let mut result = String::new();
591
45
        let mut current_width = start_width;
592
45
        for ch in text.chars() {
593
180
            if ch == '\t' {
  Branch (593:16): [True: 45, False: 135]
+
580
6.98k
                    return char_index;
581
41.6k
                }
582
41.6k
                current_width = self.add_char_width(current_width, ch);
583
41.6k
                char_index += 1;
584
            }
585
        }
586
7.02k
        char_index
587
14.0k
    }
588
589
45
    fn expand_tabs(&self, text: &str, start_width: usize) -> String {
590
45
        let mut result = String::new();
591
45
        let mut current_width = start_width;
592
45
        for ch in text.chars() {
593
180
            if ch == '\t' {
  Branch (593:16): [True: 45, False: 135]
 
  Branch (593:16): [True: 0, False: 0]
 
594
45
                let next_width = self.add_char_width(current_width, ch);
595
45
                let tab_width = next_width - current_width;
596
45
                result.push_str(&" ".repeat(tab_width));
597
45
                current_width = next_width;
598
            } else {
599
135
                result.push(ch);
600
135
                current_width = self.add_char_width(current_width, ch);
601
            }
602
        }
603
45
        result
604
45
    }
605
}
606
607
#[cfg(test)]
608
#[path = "item_renderer_tests.rs"]
609
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/layout.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/layout.rs.html index 4009ed71..42fa83eb 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/tui/layout.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/layout.rs.html @@ -1,39 +1,39 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/layout.rs
Line
Count
Source
1
//! Layout computation for skim's TUI.
2
//!
3
//! The layout logic is split into two phases so that the expensive option-
4
//! inspection work is done only once (at startup and on option changes) while
5
//! the per-frame cost is reduced to a small number of cheap rect splits.
6
//!
7
//! * [`LayoutTemplate`] — built from [`SkimOptions`] and the static header
8
//!   height.  Stores pre-computed constraints and flags; no terminal-size
9
//!   dependency.  Stored on [`App`](super::App) and rebuilt only when options
10
//!   that affect layout change (e.g. [`TogglePreview`]).
11
//!
12
//! * [`AppLayout`] — produced by [`LayoutTemplate::apply`] from a concrete
13
//!   terminal [`Rect`].  Contains the final widget areas for one render frame.
14
//!   Computed in `render()` and cached on `App` so that code between renders
15
//!   (e.g. mouse hit-testing) can read it.
16
17
use ratatui::layout::{Constraint, Direction as RatatuiDirection, Layout, Rect};
18
19
use crate::SkimOptions;
20
use crate::tui::options::TuiLayout;
21
use crate::tui::statusline::InfoDisplay;
22
use crate::tui::{Direction, Size};
23
24
// ---------------------------------------------------------------------------
25
// LayoutTemplate
26
// ---------------------------------------------------------------------------
27
28
/// Orientation of the preview pane relative to the work area.
29
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30
enum PreviewPlacement {
31
    /// Preview splits the full area horizontally (left / right of everything).
32
    Left,
33
    Right,
34
    /// Preview splits the full area vertically (above / below everything).
35
    Up,
36
    Down,
37
    /// No preview.
38
    None,
39
}
40
41
/// Pre-computed layout descriptor built from [`SkimOptions`].
42
///
43
/// Contains everything needed to split a concrete [`Rect`] into widget areas,
44
/// but contains no coordinates itself — those only appear in [`AppLayout`].
45
/// Build with [`LayoutTemplate::from_options`] and store on
46
/// [`App`](super::App); call [`LayoutTemplate::apply`] in every render.
47
#[derive(Debug, Clone)]
48
pub struct LayoutTemplate {
49
    /// Whether the header widget should be rendered at all.
50
    show_header: bool,
51
    /// Where the preview pane is placed relative to the rest of the UI.
52
    preview_placement: PreviewPlacement,
53
    /// Whether the work layout emits slots in reverse order `[input, header,
54
    /// list]` instead of the default `[list, header, input]`.
55
    work_layout_reversed: bool,
56
    /// Pre-built [`Layout`] for carving the preview out of the full area
57
    /// (step 1).  `None` when no preview is visible.
58
    preview_layout: Option<Layout>,
59
    /// Whether adjacent bordered widgets share their touching row or column.
60
    collapse_borders: bool,
61
    /// Pre-built [`Layout`] for splitting the work area into three slots.
62
    ///
63
    /// When `work_layout_reversed` is `false` the slots map to
64
    /// `[list, header, input]`; when `true` they map to
65
    /// `[input, header, list]`.  Applied in step 2 of [`Self::apply`].
66
    work_layout: Layout,
67
}
68
69
impl LayoutTemplate {
70
    /// Build a [`LayoutTemplate`] from [`SkimOptions`] and the static header
71
    /// height (content rows only, excluding any border rows).
72
    ///
73
    /// `header_height` should come from
74
    /// [`Header::height()`](super::header::Header::height), which returns a
75
    /// value fixed at construction time from `options.header_lines` and the
76
    /// line-count of `options.header`.
77
    #[must_use]
78
600
    pub fn from_options(options: &SkimOptions, header_height: u16) -> Self {
79
600
        let has_border = options.border.is_some();
80
600
        let collapse_borders = has_border && 
!options.border_no_collapse32
;
  Branch (80:32): [True: 21, False: 381]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/layout.rs
Line
Count
Source
1
//! Layout computation for skim's TUI.
2
//!
3
//! The layout logic is split into two phases so that the expensive option-
4
//! inspection work is done only once (at startup and on option changes) while
5
//! the per-frame cost is reduced to a small number of cheap rect splits.
6
//!
7
//! * [`LayoutTemplate`] — built from [`SkimOptions`] and the static header
8
//!   height.  Stores pre-computed constraints and flags; no terminal-size
9
//!   dependency.  Stored on [`App`](super::App) and rebuilt only when options
10
//!   that affect layout change (e.g. [`TogglePreview`]).
11
//!
12
//! * [`AppLayout`] — produced by [`LayoutTemplate::apply`] from a concrete
13
//!   terminal [`Rect`].  Contains the final widget areas for one render frame.
14
//!   Computed in `render()` and cached on `App` so that code between renders
15
//!   (e.g. mouse hit-testing) can read it.
16
17
use ratatui::layout::{Constraint, Direction as RatatuiDirection, Layout, Rect};
18
19
use crate::SkimOptions;
20
use crate::tui::options::TuiLayout;
21
use crate::tui::statusline::InfoDisplay;
22
use crate::tui::{Direction, Size};
23
24
// ---------------------------------------------------------------------------
25
// LayoutTemplate
26
// ---------------------------------------------------------------------------
27
28
/// Orientation of the preview pane relative to the work area.
29
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30
enum PreviewPlacement {
31
    /// Preview splits the full area horizontally (left / right of everything).
32
    Left,
33
    Right,
34
    /// Preview splits the full area vertically (above / below everything).
35
    Up,
36
    Down,
37
    /// No preview.
38
    None,
39
}
40
41
/// Pre-computed layout descriptor built from [`SkimOptions`].
42
///
43
/// Contains everything needed to split a concrete [`Rect`] into widget areas,
44
/// but contains no coordinates itself — those only appear in [`AppLayout`].
45
/// Build with [`LayoutTemplate::from_options`] and store on
46
/// [`App`](super::App); call [`LayoutTemplate::apply`] in every render.
47
#[derive(Debug, Clone)]
48
pub struct LayoutTemplate {
49
    /// Whether the header widget should be rendered at all.
50
    show_header: bool,
51
    /// Where the preview pane is placed relative to the rest of the UI.
52
    preview_placement: PreviewPlacement,
53
    /// Whether the work layout emits slots in reverse order `[input, header,
54
    /// list]` instead of the default `[list, header, input]`.
55
    work_layout_reversed: bool,
56
    /// Pre-built [`Layout`] for carving the preview out of the full area
57
    /// (step 1).  `None` when no preview is visible.
58
    preview_layout: Option<Layout>,
59
    /// Whether adjacent bordered widgets share their touching row or column.
60
    collapse_borders: bool,
61
    /// Pre-built [`Layout`] for splitting the work area into three slots.
62
    ///
63
    /// When `work_layout_reversed` is `false` the slots map to
64
    /// `[list, header, input]`; when `true` they map to
65
    /// `[input, header, list]`.  Applied in step 2 of [`Self::apply`].
66
    work_layout: Layout,
67
}
68
69
impl LayoutTemplate {
70
    /// Build a [`LayoutTemplate`] from [`SkimOptions`] and the static header
71
    /// height (content rows only, excluding any border rows).
72
    ///
73
    /// `header_height` should come from
74
    /// [`Header::height()`](super::header::Header::height), which returns a
75
    /// value fixed at construction time from `options.header_lines` and the
76
    /// line-count of `options.header`.
77
    #[must_use]
78
599
    pub fn from_options(options: &SkimOptions, header_height: u16) -> Self {
79
599
        let has_border = options.border.is_some();
80
599
        let collapse_borders = has_border && 
!options.border_no_collapse32
;
  Branch (80:32): [True: 21, False: 380]
 
  Branch (80:32): [True: 11, False: 187]
-
81
600
        let overlap = u16::from(collapse_borders);
82
83
        // Rows consumed by the input widget.
84
600
        let input_rows: u16 = if has_border {
  Branch (84:34): [True: 21, False: 381]
+
81
599
        let overlap = u16::from(collapse_borders);
82
83
        // Rows consumed by the input widget.
84
599
        let input_rows: u16 = if has_border {
  Branch (84:34): [True: 21, False: 380]
 
  Branch (84:34): [True: 11, False: 187]
-
85
32
            3 // 1 content + 2 border rows
86
        } else {
87
568
            1 + u16::from(matches!(
88
568
                options.info.display,
89
568
                InfoDisplay::Default | InfoDisplay::Left | InfoDisplay::Right
90
568
            ))
91
        };
92
93
        // Rows consumed by the header widget.
94
600
        let show_header = options.header.is_some() || 
options.header_lines > 0560
;
  Branch (94:27): [True: 28, False: 374]
+
85
32
            3 // 1 content + 2 border rows
86
        } else {
87
567
            1 + u16::from(matches!(
88
567
                options.info.display,
89
567
                InfoDisplay::Default | InfoDisplay::Left | InfoDisplay::Right
90
567
            ))
91
        };
92
93
        // Rows consumed by the header widget.
94
599
        let show_header = options.header.is_some() || 
options.header_lines > 0558
;
  Branch (94:27): [True: 29, False: 372]
 
  Branch (94:27): [True: 12, False: 186]
-
95
600
        let header_rows: u16 = if show_header {
  Branch (95:35): [True: 38, False: 364]
+
95
599
        let header_rows: u16 = if show_header {
  Branch (95:35): [True: 39, False: 362]
 
  Branch (95:35): [True: 13, False: 185]
-
96
51
            if has_border { 
header_height + 29
} else {
header_height42
}
  Branch (96:16): [True: 6, False: 32]
+
96
52
            if has_border { 
header_height + 29
} else {
header_height43
}
  Branch (96:16): [True: 6, False: 33]
 
  Branch (96:16): [True: 3, False: 10]
-
97
        } else {
98
549
            0
99
        };
100
101
        // Preview placement and layout.
102
600
        let preview_visible = (options.preview.is_some() || 
options.preview_fn.is_some()540
)
  Branch (102:32): [True: 45, False: 357]
-  Branch (102:61): [True: 0, False: 357]
+
97
        } else {
98
547
            0
99
        };
100
101
        // Preview placement and layout.
102
599
        let preview_visible = (options.preview.is_some() || 
options.preview_fn.is_some()543
)
  Branch (102:32): [True: 41, False: 360]
+  Branch (102:61): [True: 0, False: 360]
 
  Branch (102:32): [True: 15, False: 183]
   Branch (102:61): [True: 0, False: 183]
-
103
60
            && !options.preview_window.hidden
  Branch (103:16): [True: 45, False: 0]
+
103
56
            && !options.preview_window.hidden
  Branch (103:16): [True: 41, False: 0]
 
  Branch (103:16): [True: 14, False: 1]
-
104
59
            && !matches!(options.preview_window.size, Size::Fixed(0));
105
106
600
        let (preview_placement, preview_layout) = if preview_visible {
  Branch (106:54): [True: 45, False: 357]
+
104
55
            && !matches!(options.preview_window.size, Size::Fixed(0));
105
106
599
        let (preview_placement, preview_layout) = if preview_visible {
  Branch (106:54): [True: 41, False: 360]
 
  Branch (106:54): [True: 14, False: 184]
-
107
59
            let (preview_c, rest_c) = size_to_constraint(options.preview_window.size);
108
59
            let placement = match options.preview_window.direction {
109
14
                Direction::Left => PreviewPlacement::Left,
110
30
                Direction::Right => PreviewPlacement::Right,
111
7
                Direction::Up => PreviewPlacement::Up,
112
8
                Direction::Down => PreviewPlacement::Down,
113
            };
114
59
            let layout = match placement {
115
14
                PreviewPlacement::Left => Layout::new(RatatuiDirection::Horizontal, [preview_c, rest_c]),
116
30
                PreviewPlacement::Right => Layout::new(RatatuiDirection::Horizontal, [rest_c, preview_c]),
117
7
                PreviewPlacement::Up => Layout::new(RatatuiDirection::Vertical, [preview_c, rest_c]),
118
8
                PreviewPlacement::Down => Layout::new(RatatuiDirection::Vertical, [rest_c, preview_c]),
119
0
                PreviewPlacement::None => unreachable!(),
120
            };
121
59
            (placement, Some(layout))
122
        } else {
123
541
            (PreviewPlacement::None, None)
124
        };
125
126
        // Work-area layout: single 3-way vertical split into [list, header, input].
127
        //
128
        // For Default / ReverseList: slots are [list, header, input] top-to-bottom.
129
        // For Reverse:               slots are [input, header, list] top-to-bottom.
130
600
        let work_layout_reversed = options.layout == TuiLayout::Reverse;
131
600
        let work_layout = if show_header {
  Branch (131:30): [True: 38, False: 364]
+
107
55
            let (preview_c, rest_c) = size_to_constraint(options.preview_window.size);
108
55
            let placement = match options.preview_window.direction {
109
14
                Direction::Left => PreviewPlacement::Left,
110
28
                Direction::Right => PreviewPlacement::Right,
111
7
                Direction::Up => PreviewPlacement::Up,
112
6
                Direction::Down => PreviewPlacement::Down,
113
            };
114
55
            let layout = match placement {
115
14
                PreviewPlacement::Left => Layout::new(RatatuiDirection::Horizontal, [preview_c, rest_c]),
116
28
                PreviewPlacement::Right => Layout::new(RatatuiDirection::Horizontal, [rest_c, preview_c]),
117
7
                PreviewPlacement::Up => Layout::new(RatatuiDirection::Vertical, [preview_c, rest_c]),
118
6
                PreviewPlacement::Down => Layout::new(RatatuiDirection::Vertical, [rest_c, preview_c]),
119
0
                PreviewPlacement::None => unreachable!(),
120
            };
121
55
            (placement, Some(layout))
122
        } else {
123
544
            (PreviewPlacement::None, None)
124
        };
125
126
        // Work-area layout: single 3-way vertical split into [list, header, input].
127
        //
128
        // For Default / ReverseList: slots are [list, header, input] top-to-bottom.
129
        // For Reverse:               slots are [input, header, list] top-to-bottom.
130
599
        let work_layout_reversed = options.layout == TuiLayout::Reverse;
131
599
        let work_layout = if show_header {
  Branch (131:30): [True: 39, False: 362]
 
  Branch (131:30): [True: 13, False: 185]
-
132
51
            match options.layout {
133
42
                TuiLayout::Default | TuiLayout::ReverseList => Layout::vertical([
134
42
                    Constraint::Fill(1),
135
42
                    Constraint::Length(header_rows.saturating_sub(overlap)),
136
42
                    Constraint::Length(input_rows.saturating_sub(overlap)),
137
42
                ]),
138
9
                TuiLayout::Reverse => Layout::vertical([
139
9
                    Constraint::Length(input_rows),
140
9
                    Constraint::Length(header_rows.saturating_sub(overlap)),
141
9
                    Constraint::Fill(1),
142
9
                ]),
143
            }
144
        } else {
145
549
            match options.layout {
146
538
                TuiLayout::Default | TuiLayout::ReverseList => Layout::vertical([
147
538
                    Constraint::Fill(1),
148
538
                    Constraint::Length(0),
149
538
                    Constraint::Length(input_rows.saturating_sub(overlap)),
150
538
                ]),
151
11
                TuiLayout::Reverse => Layout::vertical([
152
11
                    Constraint::Length(input_rows),
153
11
                    Constraint::Length(0),
154
11
                    Constraint::Fill(1),
155
11
                ]),
156
            }
157
        };
158
159
600
        Self {
160
600
            show_header,
161
600
            preview_placement,
162
600
            work_layout_reversed,
163
600
            preview_layout,
164
600
            collapse_borders,
165
600
            work_layout,
166
600
        }
167
600
    }
168
169
    /// Apply this template to a concrete terminal `area`, producing the
170
    /// absolute [`AppLayout`] for one render frame.
171
    #[must_use]
172
3.31k
    pub fn apply(&self, area: Rect) -> AppLayout {
173
        // ── Step 1: carve out the preview from the full area ─────────────────
174
3.31k
        let (work_area, preview_area): (Rect, Option<Rect>) = match &self.preview_layout {
175
            Some(layout) => {
176
306
                let [a, mut b]: [Rect; 2] = layout.areas(area);
177
306
                if self.collapse_borders {
  Branch (177:20): [True: 0, False: 292]
+
132
52
            match options.layout {
133
43
                TuiLayout::Default | TuiLayout::ReverseList => Layout::vertical([
134
43
                    Constraint::Fill(1),
135
43
                    Constraint::Length(header_rows.saturating_sub(overlap)),
136
43
                    Constraint::Length(input_rows.saturating_sub(overlap)),
137
43
                ]),
138
9
                TuiLayout::Reverse => Layout::vertical([
139
9
                    Constraint::Length(input_rows),
140
9
                    Constraint::Length(header_rows.saturating_sub(overlap)),
141
9
                    Constraint::Fill(1),
142
9
                ]),
143
            }
144
        } else {
145
547
            match options.layout {
146
537
                TuiLayout::Default | TuiLayout::ReverseList => Layout::vertical([
147
537
                    Constraint::Fill(1),
148
537
                    Constraint::Length(0),
149
537
                    Constraint::Length(input_rows.saturating_sub(overlap)),
150
537
                ]),
151
10
                TuiLayout::Reverse => Layout::vertical([
152
10
                    Constraint::Length(input_rows),
153
10
                    Constraint::Length(0),
154
10
                    Constraint::Fill(1),
155
10
                ]),
156
            }
157
        };
158
159
599
        Self {
160
599
            show_header,
161
599
            preview_placement,
162
599
            work_layout_reversed,
163
599
            preview_layout,
164
599
            collapse_borders,
165
599
            work_layout,
166
599
        }
167
599
    }
168
169
    /// Apply this template to a concrete terminal `area`, producing the
170
    /// absolute [`AppLayout`] for one render frame.
171
    #[must_use]
172
3.31k
    pub fn apply(&self, area: Rect) -> AppLayout {
173
        // ── Step 1: carve out the preview from the full area ─────────────────
174
3.31k
        let (work_area, preview_area): (Rect, Option<Rect>) = match &self.preview_layout {
175
            Some(layout) => {
176
289
                let [a, mut b]: [Rect; 2] = layout.areas(area);
177
289
                if self.collapse_borders {
  Branch (177:20): [True: 0, False: 275]
 
  Branch (177:20): [True: 2, False: 12]
-
178
2
                    b = match self.preview_placement {
179
2
                        PreviewPlacement::Left | PreviewPlacement::Right => extend_left(b, area.x),
180
0
                        PreviewPlacement::Up | PreviewPlacement::Down => extend_up(b, area.y),
181
0
                        PreviewPlacement::None => unreachable!(),
182
                    };
183
304
                }
184
306
                match self.preview_placement {
185
                    // preview is the first segment for Left / Up
186
85
                    PreviewPlacement::Left | PreviewPlacement::Up => (b, Some(a)),
187
                    // preview is the second segment for Right / Down
188
221
                    _ => (a, Some(b)),
189
                }
190
            }
191
3.00k
            None => (area, None),
192
        };
193
194
        // ── Step 2: split work_area into list / header / input in one pass ───
195
        //
196
        // Slots are [list, header, input] when `work_layout_reversed` is false,
197
        // or [input, header, list] when true (Reverse layout).
198
3.31k
        let [slot0, slot1, slot2]: [Rect; 3] = self.work_layout.areas(work_area);
199
200
3.31k
        let (mut list_area, mut header_slot, mut input_area) = if self.work_layout_reversed {
  Branch (200:67): [True: 66, False: 3.02k]
-
  Branch (200:67): [True: 5, False: 212]
-
201
71
            (slot2, slot1, slot0)
202
        } else {
203
3.23k
            (slot0, slot1, slot2)
204
        };
205
206
3.31k
        if self.collapse_borders {
  Branch (206:12): [True: 72, False: 3.02k]
-
  Branch (206:12): [True: 10, False: 207]
+
178
2
                    b = match self.preview_placement {
179
2
                        PreviewPlacement::Left | PreviewPlacement::Right => extend_left(b, area.x),
180
0
                        PreviewPlacement::Up | PreviewPlacement::Down => extend_up(b, area.y),
181
0
                        PreviewPlacement::None => unreachable!(),
182
                    };
183
287
                }
184
289
                match self.preview_placement {
185
                    // preview is the first segment for Left / Up
186
85
                    PreviewPlacement::Left | PreviewPlacement::Up => (b, Some(a)),
187
                    // preview is the second segment for Right / Down
188
204
                    _ => (a, Some(b)),
189
                }
190
            }
191
3.02k
            None => (area, None),
192
        };
193
194
        // ── Step 2: split work_area into list / header / input in one pass ───
195
        //
196
        // Slots are [list, header, input] when `work_layout_reversed` is false,
197
        // or [input, header, list] when true (Reverse layout).
198
3.31k
        let [slot0, slot1, slot2]: [Rect; 3] = self.work_layout.areas(work_area);
199
200
3.31k
        let (mut list_area, mut header_slot, mut input_area) = if self.work_layout_reversed {
  Branch (200:67): [True: 62, False: 3.03k]
+
  Branch (200:67): [True: 5, False: 213]
+
201
67
            (slot2, slot1, slot0)
202
        } else {
203
3.24k
            (slot0, slot1, slot2)
204
        };
205
206
3.31k
        if self.collapse_borders {
  Branch (206:12): [True: 72, False: 3.02k]
+
  Branch (206:12): [True: 10, False: 208]
 
207
82
            if self.work_layout_reversed {
  Branch (207:16): [True: 8, False: 64]
 
  Branch (207:16): [True: 2, False: 8]
 
208
10
                if self.show_header {
  Branch (208:20): [True: 4, False: 4]
 
  Branch (208:20): [True: 1, False: 1]
 
209
5
                    header_slot = extend_up(header_slot, work_area.y);
210
5
                }
211
10
                list_area = extend_up(list_area, work_area.y);
212
            } else {
213
72
                if self.show_header {
  Branch (213:20): [True: 8, False: 56]
 
  Branch (213:20): [True: 1, False: 7]
-
214
9
                    header_slot = extend_up(header_slot, work_area.y);
215
63
                }
216
72
                input_area = extend_up(input_area, work_area.y);
217
            }
218
3.22k
        }
219
220
3.31k
        let header_area = if self.show_header { 
Some(header_slot)134
} else {
None3.17k
};
  Branch (220:30): [True: 122, False: 2.97k]
-
  Branch (220:30): [True: 12, False: 205]
+
214
9
                    header_slot = extend_up(header_slot, work_area.y);
215
63
                }
216
72
                input_area = extend_up(input_area, work_area.y);
217
            }
218
3.23k
        }
219
220
3.31k
        let header_area = if self.show_header { 
Some(header_slot)138
} else {
None3.17k
};
  Branch (220:30): [True: 126, False: 2.97k]
+
  Branch (220:30): [True: 12, False: 206]
 
221
222
3.31k
        AppLayout {
223
3.31k
            list_area,
224
3.31k
            input_area,
225
3.31k
            header_area,
226
3.31k
            preview_area,
227
3.31k
        }
228
3.31k
    }
229
}
230
231
// ---------------------------------------------------------------------------
232
// AppLayout
233
// ---------------------------------------------------------------------------
234
235
/// Concrete widget areas for one render frame, produced by
236
/// [`LayoutTemplate::apply`].
237
///
238
/// Cached on [`App`](super::App) after each render so that code between frames
239
/// (e.g. mouse hit-testing in `handle_mouse`) can read the last known areas.
240
#[derive(Debug, Clone, PartialEq, Eq)]
241
pub struct AppLayout {
242
    /// Area for the item list widget.
243
    pub list_area: Rect,
244
    /// Area for the input / prompt widget.
245
    pub input_area: Rect,
246
    /// Area for the header widget (`None` when no header is shown).
247
    pub header_area: Option<Rect>,
248
    /// Area for the preview pane (`None` when preview is hidden or disabled).
249
    pub preview_area: Option<Rect>,
250
}
251
252
impl AppLayout {
253
    /// Convenience wrapper: build a [`LayoutTemplate`] from `options` and
254
    /// `header_height`, then immediately apply it to `area`.
255
    ///
256
    /// Prefer storing the [`LayoutTemplate`] and calling
257
    /// [`LayoutTemplate::apply`] directly when the template can be reused
258
    /// across frames.
259
    #[must_use]
260
30
    pub fn compute(area: Rect, options: &SkimOptions, header_height: u16) -> Self {
261
30
        LayoutTemplate::from_options(options, header_height).apply(area)
262
30
    }
263
}
264
265
// ---------------------------------------------------------------------------
266
// Helper
267
// ---------------------------------------------------------------------------
268
269
96
fn extend_up(mut rect: Rect, top: u16) -> Rect {
270
96
    if rect.y > top {
  Branch (270:8): [True: 63, False: 21]
 
  Branch (270:8): [True: 12, False: 0]
 
271
75
        rect.y -= 1;
272
75
        rect.height = rect.height.saturating_add(1);
273
21
    }
274
96
    rect
275
96
}
276
277
2
fn extend_left(mut rect: Rect, left: u16) -> Rect {
278
2
    if rect.x > left {
  Branch (278:8): [True: 0, False: 0]
 
  Branch (278:8): [True: 2, False: 0]
-
279
2
        rect.x -= 1;
280
2
        rect.width = rect.width.saturating_add(1);
281
0
    }
282
2
    rect
283
2
}
284
285
59
fn size_to_constraint(size: Size) -> (Constraint, Constraint) {
286
59
    match size {
287
6
        Size::Fixed(n) => (Constraint::Length(n), Constraint::Fill(1)),
288
48
        Size::Percent(p) => (Constraint::Percentage(p), Constraint::Fill(1)),
289
5
        Size::Neg(n) => (Constraint::Fill(1), Constraint::Length(n)),
290
    }
291
59
}
292
293
// ---------------------------------------------------------------------------
294
// Tests
295
// ---------------------------------------------------------------------------
296
297
#[cfg(test)]
298
#[path = "layout_tests.rs"]
299
mod tests;
\ No newline at end of file +
279
2
        rect.x -= 1;
280
2
        rect.width = rect.width.saturating_add(1);
281
0
    }
282
2
    rect
283
2
}
284
285
55
fn size_to_constraint(size: Size) -> (Constraint, Constraint) {
286
55
    match size {
287
6
        Size::Fixed(n) => (Constraint::Length(n), Constraint::Fill(1)),
288
45
        Size::Percent(p) => (Constraint::Percentage(p), Constraint::Fill(1)),
289
4
        Size::Neg(n) => (Constraint::Fill(1), Constraint::Length(n)),
290
    }
291
55
}
292
293
// ---------------------------------------------------------------------------
294
// Tests
295
// ---------------------------------------------------------------------------
296
297
#[cfg(test)]
298
#[path = "layout_tests.rs"]
299
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/mod.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/mod.rs.html index f8a64016..8ea77ba2 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/tui/mod.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/mod.rs.html @@ -1,14 +1,14 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/mod.rs
Line
Count
Source
1
//! Terminal UI components and rendering.
2
//!
3
//! This module provides the terminal user interface components for skim,
4
//! including the application state, event handling, rendering widgets,
5
//! and layout management.
6
7
use std::num::ParseIntError;
8
9
pub use app::App;
10
pub use event::Event;
11
pub use preview::PreviewCallback;
12
use thiserror::Error;
13
pub use widget::{SkimRender, SkimWidget};
14
mod app;
15
mod backend;
16
pub(crate) mod util;
17
#[cfg(windows)]
18
mod windows;
19
pub use backend::Tui;
20
/// Action definitions, catalog and parsing
21
pub mod actions;
22
/// Event handling
23
pub mod event;
24
/// Header display components
25
pub mod header;
26
mod input;
27
/// Item list display and management
28
pub mod item_list;
29
/// Single item rendering
30
pub(crate) mod item_renderer;
31
/// Pre-computed widget layout areas
32
pub mod layout;
33
/// TUI-specific options and configuration
34
pub mod options;
35
mod preview;
36
/// Status line display
37
pub mod statusline;
38
/// Widget rendering utilities
39
pub mod widget;
40
41
/// Number of heartbeats per second
42
pub const TICK_RATE: u32 = 120;
43
44
/// Represents a size value, either as a percentage or fixed value
45
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46
pub enum Size {
47
    /// Size as a percentage (0-100)
48
    Percent(u16),
49
    /// Fixed size in terminal cells
50
    Fixed(u16),
51
    /// Negative size is substracted from term height
52
    Neg(u16),
53
}
54
55
/// Direction for movement or layout
56
#[derive(PartialEq, Eq, Clone, Debug, Copy)]
57
pub enum Direction {
58
    /// Upward direction
59
    Up,
60
    /// Downward direction
61
    Down,
62
    /// Left direction
63
    Left,
64
    /// Right direction
65
    Right,
66
}
67
68
impl TryFrom<&str> for Direction {
69
    type Error = &'static str;
70
988
    fn try_from(value: &str) -> Result<Self, Self::Error> {
71
988
        match value.to_lowercase().as_str() {
72
11
            "up" => Ok(Self::Up),
73
11
            "down" => Ok(Self::Down),
74
26
            "left" => Ok(Self::Left),
75
461
            "right" => Ok(Self::Right),
76
479
            _ => Err("Unknown direction {value}"),
77
        }
78
988
    }
79
}
80
81
/// Error type for parsing size values
82
#[derive(Error, Debug, PartialEq, Eq)]
83
pub enum SizeParseError {
84
    /// Error parsing the size string
85
    #[error("Error parsing {0}: {1:?}")]
86
    ParseError(String, ParseIntError),
87
    /// Percentage value exceeds 100
88
    #[error("Invalid percentage {0}")]
89
    InvalidPercent(u16),
90
}
91
92
impl TryFrom<&str> for Size {
93
    type Error = SizeParseError;
94
95
1.40k
    fn try_from(value: &str) -> Result<Self, Self::Error> {
96
1.40k
        if value.ends_with('%') {
  Branch (96:12): [True: 827, False: 470]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/mod.rs
Line
Count
Source
1
//! Terminal UI components and rendering.
2
//!
3
//! This module provides the terminal user interface components for skim,
4
//! including the application state, event handling, rendering widgets,
5
//! and layout management.
6
7
use std::num::ParseIntError;
8
9
pub use app::App;
10
pub use event::Event;
11
pub use preview::PreviewCallback;
12
use thiserror::Error;
13
pub use widget::{SkimRender, SkimWidget};
14
mod app;
15
mod backend;
16
pub(crate) mod util;
17
#[cfg(windows)]
18
mod windows;
19
pub use backend::Tui;
20
/// Action definitions, catalog and parsing
21
pub mod actions;
22
/// Event handling
23
pub mod event;
24
/// Header display components
25
pub mod header;
26
mod input;
27
/// Item list display and management
28
pub mod item_list;
29
/// Single item rendering
30
pub(crate) mod item_renderer;
31
/// Pre-computed widget layout areas
32
pub mod layout;
33
/// TUI-specific options and configuration
34
pub mod options;
35
mod preview;
36
/// Status line display
37
pub mod statusline;
38
/// Widget rendering utilities
39
pub mod widget;
40
41
/// Number of heartbeats per second
42
pub const TICK_RATE: u32 = 120;
43
44
/// Represents a size value, either as a percentage or fixed value
45
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46
pub enum Size {
47
    /// Size as a percentage (0-100)
48
    Percent(u16),
49
    /// Fixed size in terminal cells
50
    Fixed(u16),
51
    /// Negative size is substracted from term height
52
    Neg(u16),
53
}
54
55
/// Direction for movement or layout
56
#[derive(PartialEq, Eq, Clone, Debug, Copy)]
57
pub enum Direction {
58
    /// Upward direction
59
    Up,
60
    /// Downward direction
61
    Down,
62
    /// Left direction
63
    Left,
64
    /// Right direction
65
    Right,
66
}
67
68
impl TryFrom<&str> for Direction {
69
    type Error = &'static str;
70
985
    fn try_from(value: &str) -> Result<Self, Self::Error> {
71
985
        match value.to_lowercase().as_str() {
72
11
            "up" => Ok(Self::Up),
73
9
            "down" => Ok(Self::Down),
74
26
            "left" => Ok(Self::Left),
75
461
            "right" => Ok(Self::Right),
76
478
            _ => Err("Unknown direction {value}"),
77
        }
78
985
    }
79
}
80
81
/// Error type for parsing size values
82
#[derive(Error, Debug, PartialEq, Eq)]
83
pub enum SizeParseError {
84
    /// Error parsing the size string
85
    #[error("Error parsing {0}: {1:?}")]
86
    ParseError(String, ParseIntError),
87
    /// Percentage value exceeds 100
88
    #[error("Invalid percentage {0}")]
89
    InvalidPercent(u16),
90
}
91
92
impl TryFrom<&str> for Size {
93
    type Error = SizeParseError;
94
95
1.40k
    fn try_from(value: &str) -> Result<Self, Self::Error> {
96
1.40k
        if value.ends_with('%') {
  Branch (96:12): [True: 826, False: 467]
 
  Branch (96:12): [True: 58, False: 51]
-
97
885
            let percent = value
98
885
                .strip_suffix("%")
99
885
                .unwrap_or_default()
100
885
                .parse::<u16>()
101
3
                .map_err(|e| SizeParseError::ParseError(value.to_string(), e))?;
102
882
            if percent > 100 {
  Branch (102:16): [True: 0, False: 827]
+
97
884
            let percent = value
98
884
                .strip_suffix("%")
99
884
                .unwrap_or_default()
100
884
                .parse::<u16>()
101
3
                .map_err(|e| SizeParseError::ParseError(value.to_string(), e))?;
102
881
            if percent > 100 {
  Branch (102:16): [True: 0, False: 826]
 
  Branch (102:16): [True: 1, False: 54]
-
103
1
                return Err(SizeParseError::InvalidPercent(percent));
104
881
            }
105
881
            Ok(Self::Percent(percent))
106
521
        } else if let Some(neg) = value.strip_prefix('-') {
  Branch (106:23): [True: 5, False: 465]
+
103
1
                return Err(SizeParseError::InvalidPercent(percent));
104
880
            }
105
880
            Ok(Self::Percent(percent))
106
518
        } else if let Some(neg) = value.strip_prefix('-') {
  Branch (106:23): [True: 4, False: 463]
 
  Branch (106:23): [True: 1, False: 50]
-
107
            Ok(Self::Neg(
108
6
                neg.parse::<u16>()
109
0
                    .map_err(|e| SizeParseError::ParseError(value.to_string(), e))?,
110
            ))
111
        } else {
112
            Ok(Self::Fixed(
113
515
                value
114
515
                    .parse::<u16>()
115
506
                    .map_err(|e| SizeParseError::ParseError(value.to_string(), e))?,
116
            ))
117
        }
118
1.40k
    }
119
}
120
121
impl Default for Size {
122
1
    fn default() -> Self {
123
1
        Self::Percent(100)
124
1
    }
125
}
126
127
impl std::fmt::Display for Size {
128
43
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129
43
        match self {
130
39
            Self::Percent(p) => f.write_fmt(format_args!("{p}%")),
131
3
            Self::Fixed(s) => f.write_fmt(format_args!("{s}")),
132
1
            Self::Neg(s) => f.write_fmt(format_args!("-{s}")),
133
        }
134
43
    }
135
}
136
137
/// This mirrors Ratatui's border type
138
///
139
/// We need it so that we can properly use `ValueEnum`
140
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
141
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
142
#[allow(missing_docs)]
143
pub enum BorderType {
144
    /// `ForceOff` disables borders around popups too
145
    /// set with `no_border`
146
    ForceOff,
147
    #[default]
148
    None,
149
    Plain,
150
    Rounded,
151
    Double,
152
    Thick,
153
154
    LightDoubleDashed,
155
    HeavyDoubleDashed,
156
157
    LightTripleDashed,
158
    HeavyTripleDashed,
159
160
    LightQuadrupleDashed,
161
    HeavyQuadrupleDashed,
162
163
    QuadrantInside,
164
    QuadrantOutside,
165
}
166
167
impl BorderType {
168
12.0k
    fn is_none(self) -> bool {
169
12.0k
        matches!(self, BorderType::None | BorderType::ForceOff)
170
12.0k
    }
171
6.20k
    fn is_some(self) -> bool {
172
6.20k
        !self.is_none()
173
6.20k
    }
174
5.82k
    fn into_ratatui(self) -> Option<ratatui::widgets::BorderType> {
175
5.82k
        if self.is_none() {
  Branch (175:12): [True: 5.60k, False: 144]
-
  Branch (175:12): [True: 68, False: 1]
-
176
5.67k
            return None;
177
145
        }
178
179
145
        Some(match self {
180
79
            BorderType::Plain => ratatui::widgets::BorderType::Plain,
181
6
            BorderType::Rounded => ratatui::widgets::BorderType::Rounded,
182
6
            BorderType::Double => ratatui::widgets::BorderType::Double,
183
6
            BorderType::Thick => ratatui::widgets::BorderType::Thick,
184
6
            BorderType::LightDoubleDashed => ratatui::widgets::BorderType::LightDoubleDashed,
185
6
            BorderType::HeavyDoubleDashed => ratatui::widgets::BorderType::HeavyDoubleDashed,
186
6
            BorderType::LightTripleDashed => ratatui::widgets::BorderType::LightTripleDashed,
187
6
            BorderType::HeavyTripleDashed => ratatui::widgets::BorderType::HeavyTripleDashed,
188
6
            BorderType::LightQuadrupleDashed => ratatui::widgets::BorderType::LightQuadrupleDashed,
189
6
            BorderType::HeavyQuadrupleDashed => ratatui::widgets::BorderType::HeavyQuadrupleDashed,
190
6
            BorderType::QuadrantInside => ratatui::widgets::BorderType::QuadrantInside,
191
6
            BorderType::QuadrantOutside => ratatui::widgets::BorderType::QuadrantOutside,
192
0
            BorderType::None | BorderType::ForceOff => unreachable!(),
193
        })
194
5.82k
    }
195
}
196
197
#[cfg(feature = "cli")]
198
impl std::str::FromStr for BorderType {
199
    type Err = String;
200
201
479
    fn from_str(s: &str) -> Result<Self, Self::Err> {
202
        use clap::ValueEnum as _;
203
479
        for variant in Self::value_variants() {
204
1.04k
            if variant.to_possible_value().unwrap().matches(s, false) {
  Branch (204:16): [True: 468, False: 555]
+
107
            Ok(Self::Neg(
108
5
                neg.parse::<u16>()
109
0
                    .map_err(|e| SizeParseError::ParseError(value.to_string(), e))?,
110
            ))
111
        } else {
112
            Ok(Self::Fixed(
113
513
                value
114
513
                    .parse::<u16>()
115
504
                    .map_err(|e| SizeParseError::ParseError(value.to_string(), e))?,
116
            ))
117
        }
118
1.40k
    }
119
}
120
121
impl Default for Size {
122
1
    fn default() -> Self {
123
1
        Self::Percent(100)
124
1
    }
125
}
126
127
impl std::fmt::Display for Size {
128
43
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129
43
        match self {
130
39
            Self::Percent(p) => f.write_fmt(format_args!("{p}%")),
131
3
            Self::Fixed(s) => f.write_fmt(format_args!("{s}")),
132
1
            Self::Neg(s) => f.write_fmt(format_args!("-{s}")),
133
        }
134
43
    }
135
}
136
137
/// This mirrors Ratatui's border type
138
///
139
/// We need it so that we can properly use `ValueEnum`
140
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
141
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
142
#[allow(missing_docs)]
143
pub enum BorderType {
144
    /// `ForceOff` disables borders around popups too
145
    /// set with `no_border`
146
    ForceOff,
147
    #[default]
148
    None,
149
    Plain,
150
    Rounded,
151
    Double,
152
    Thick,
153
154
    LightDoubleDashed,
155
    HeavyDoubleDashed,
156
157
    LightTripleDashed,
158
    HeavyTripleDashed,
159
160
    LightQuadrupleDashed,
161
    HeavyQuadrupleDashed,
162
163
    QuadrantInside,
164
    QuadrantOutside,
165
}
166
167
impl BorderType {
168
12.0k
    fn is_none(self) -> bool {
169
12.0k
        matches!(self, BorderType::None | BorderType::ForceOff)
170
12.0k
    }
171
6.22k
    fn is_some(self) -> bool {
172
6.22k
        !self.is_none()
173
6.22k
    }
174
5.82k
    fn into_ratatui(self) -> Option<ratatui::widgets::BorderType> {
175
5.82k
        if self.is_none() {
  Branch (175:12): [True: 5.60k, False: 144]
+
  Branch (175:12): [True: 70, False: 1]
+
176
5.67k
            return None;
177
145
        }
178
179
145
        Some(match self {
180
79
            BorderType::Plain => ratatui::widgets::BorderType::Plain,
181
6
            BorderType::Rounded => ratatui::widgets::BorderType::Rounded,
182
6
            BorderType::Double => ratatui::widgets::BorderType::Double,
183
6
            BorderType::Thick => ratatui::widgets::BorderType::Thick,
184
6
            BorderType::LightDoubleDashed => ratatui::widgets::BorderType::LightDoubleDashed,
185
6
            BorderType::HeavyDoubleDashed => ratatui::widgets::BorderType::HeavyDoubleDashed,
186
6
            BorderType::LightTripleDashed => ratatui::widgets::BorderType::LightTripleDashed,
187
6
            BorderType::HeavyTripleDashed => ratatui::widgets::BorderType::HeavyTripleDashed,
188
6
            BorderType::LightQuadrupleDashed => ratatui::widgets::BorderType::LightQuadrupleDashed,
189
6
            BorderType::HeavyQuadrupleDashed => ratatui::widgets::BorderType::HeavyQuadrupleDashed,
190
6
            BorderType::QuadrantInside => ratatui::widgets::BorderType::QuadrantInside,
191
6
            BorderType::QuadrantOutside => ratatui::widgets::BorderType::QuadrantOutside,
192
0
            BorderType::None | BorderType::ForceOff => unreachable!(),
193
        })
194
5.82k
    }
195
}
196
197
#[cfg(feature = "cli")]
198
impl std::str::FromStr for BorderType {
199
    type Err = String;
200
201
478
    fn from_str(s: &str) -> Result<Self, Self::Err> {
202
        use clap::ValueEnum as _;
203
478
        for variant in Self::value_variants() {
204
1.04k
            if variant.to_possible_value().unwrap().matches(s, false) {
  Branch (204:16): [True: 467, False: 554]
 
  Branch (204:16): [True: 11, False: 11]
-
205
479
                return Ok(*variant);
206
566
            }
207
        }
208
0
        Ok(Self::Plain)
209
479
    }
210
}
211
#[cfg(test)]
212
mod size_test {
213
    use super::*;
214
    use std::num::IntErrorKind;
215
    #[test]
216
1
    fn fixed_success() {
217
1
        assert_eq!(Size::try_from("10"), Ok(Size::Fixed(10u16)));
218
1
    }
219
    #[test]
220
1
    fn percent_success() {
221
1
        assert_eq!(Size::try_from("10%"), Ok(Size::Percent(10u16)));
222
1
    }
223
    #[test]
224
1
    fn fixed_neg() {
225
1
        assert_eq!(Size::try_from("-10"), Ok(Size::Neg(10)));
226
1
    }
227
    #[test]
228
1
    fn percent_neg() {
229
1
        let SizeParseError::ParseError(err_value, internal_error) = Size::try_from("-10%").unwrap_err() else {
  Branch (229:13): [True: 1, False: 0]
+
205
478
                return Ok(*variant);
206
565
            }
207
        }
208
0
        Ok(Self::Plain)
209
478
    }
210
}
211
#[cfg(test)]
212
mod size_test {
213
    use super::*;
214
    use std::num::IntErrorKind;
215
    #[test]
216
1
    fn fixed_success() {
217
1
        assert_eq!(Size::try_from("10"), Ok(Size::Fixed(10u16)));
218
1
    }
219
    #[test]
220
1
    fn percent_success() {
221
1
        assert_eq!(Size::try_from("10%"), Ok(Size::Percent(10u16)));
222
1
    }
223
    #[test]
224
1
    fn fixed_neg() {
225
1
        assert_eq!(Size::try_from("-10"), Ok(Size::Neg(10)));
226
1
    }
227
    #[test]
228
1
    fn percent_neg() {
229
1
        let SizeParseError::ParseError(err_value, internal_error) = Size::try_from("-10%").unwrap_err() else {
  Branch (229:13): [True: 1, False: 0]
 
230
0
            panic!();
231
        };
232
1
        assert_eq!(internal_error.kind(), &IntErrorKind::InvalidDigit);
233
1
        assert_eq!(err_value, String::from("-10%"));
234
1
    }
235
    #[test]
236
1
    fn percent_over_100() {
237
1
        let SizeParseError::InvalidPercent(internal_error) = Size::try_from("110%").unwrap_err() else {
  Branch (237:13): [True: 1, False: 0]
 
238
0
            panic!();
239
        };
240
1
        assert_eq!(internal_error, 110u16);
241
1
    }
242
    #[test]
243
1
    fn fixed_invalid_char() {
244
1
        let SizeParseError::ParseError(value, internal_error) = Size::try_from("1-0").unwrap_err() else {
  Branch (244:13): [True: 1, False: 0]
 
245
0
            panic!();
246
        };
247
1
        assert_eq!(internal_error.kind(), &IntErrorKind::InvalidDigit);
248
1
        assert_eq!(value, String::from("1-0"));
249
1
    }
250
    #[test]
251
1
    fn percent_invalid_char() {
252
1
        let SizeParseError::ParseError(value, internal_error) = Size::try_from("1-0%").unwrap_err() else {
  Branch (252:13): [True: 1, False: 0]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/options.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/options.rs.html
index 1ee6b932..ce25eeb7 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/tui/options.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/options.rs.html
@@ -1,21 +1,21 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/options.rs
Line
Count
Source
1
use crate::tui::{Direction, Size};
2
3
/// Layout configuration for the TUI
4
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
5
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
6
pub enum TuiLayout {
7
    /// Display from the bottom of the screen
8
    #[default]
9
    Default,
10
    /// Display from the top of the screen
11
    Reverse,
12
    /// Display from the top of the screen, prompt at the bottom
13
    ReverseList,
14
}
15
16
/// Configuration for the preview pane layout
17
#[derive(Debug, Clone)]
18
pub struct PreviewLayout {
19
    /// Direction where preview pane is positioned
20
    pub direction: Direction,
21
    /// Size of the preview pane
22
    pub size: Size,
23
    /// Whether the preview pane is hidden
24
    pub hidden: bool,
25
    /// Optional offset for preview position
26
    pub offset: Option<String>,
27
    /// Whether or not to wrap the preview contents
28
    pub wrap: bool,
29
    /// Whether or not to run the preview in a PTY
30
    pub pty: bool,
31
}
32
33
impl Default for PreviewLayout {
34
913
    fn default() -> Self {
35
913
        Self {
36
913
            direction: Direction::Right,
37
913
            size: Size::Percent(50),
38
913
            hidden: false,
39
913
            offset: None,
40
913
            wrap: false,
41
913
            pty: false,
42
913
        }
43
913
    }
44
}
45
46
impl From<&str> for PreviewLayout {
47
511
    fn from(value: &str) -> Self {
48
511
        let mut res: Self = PreviewLayout::default();
49
        // Parse the remainder which can be: size:offset:hidden, offset:hidden, size:hidden, etc.
50
511
        let parts: Vec<&str> = value.split(':').collect();
51
52
511
        for part in parts {
53
1.02k
            if part.is_empty() {
  Branch (53:16): [True: 5, False: 931]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/options.rs
Line
Count
Source
1
use crate::tui::{Direction, Size};
2
3
/// Layout configuration for the TUI
4
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
5
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
6
pub enum TuiLayout {
7
    /// Display from the bottom of the screen
8
    #[default]
9
    Default,
10
    /// Display from the top of the screen
11
    Reverse,
12
    /// Display from the top of the screen, prompt at the bottom
13
    ReverseList,
14
}
15
16
/// Configuration for the preview pane layout
17
#[derive(Debug, Clone)]
18
pub struct PreviewLayout {
19
    /// Direction where preview pane is positioned
20
    pub direction: Direction,
21
    /// Size of the preview pane
22
    pub size: Size,
23
    /// Whether the preview pane is hidden
24
    pub hidden: bool,
25
    /// Optional offset for preview position
26
    pub offset: Option<String>,
27
    /// Whether or not to wrap the preview contents
28
    pub wrap: bool,
29
    /// Whether or not to run the preview in a PTY
30
    pub pty: bool,
31
}
32
33
impl Default for PreviewLayout {
34
913
    fn default() -> Self {
35
913
        Self {
36
913
            direction: Direction::Right,
37
913
            size: Size::Percent(50),
38
913
            hidden: false,
39
913
            offset: None,
40
913
            wrap: false,
41
913
            pty: false,
42
913
        }
43
913
    }
44
}
45
46
impl From<&str> for PreviewLayout {
47
510
    fn from(value: &str) -> Self {
48
510
        let mut res: Self = PreviewLayout::default();
49
        // Parse the remainder which can be: size:offset:hidden, offset:hidden, size:hidden, etc.
50
510
        let parts: Vec<&str> = value.split(':').collect();
51
52
510
        for part in parts {
53
1.02k
            if part.is_empty() {
  Branch (53:16): [True: 6, False: 929]
 
  Branch (53:16): [True: 1, False: 89]
-
54
6
                continue;
55
1.02k
            }
56
57
1.02k
            if part.starts_with('+') {
  Branch (57:16): [True: 6, False: 925]
+
54
7
                continue;
55
1.01k
            }
56
57
1.01k
            if part.starts_with('+') {
  Branch (57:16): [True: 6, False: 923]
 
  Branch (57:16): [True: 6, False: 83]
-
58
                // This is an offset expression
59
12
                res.offset = Some(part.to_string());
60
1.00k
            } else if part == "hidden" {
  Branch (60:23): [True: 0, False: 925]
+
58
                // This is an offset expression
59
12
                res.offset = Some(part.to_string());
60
1.00k
            } else if part == "hidden" {
  Branch (60:23): [True: 0, False: 923]
 
  Branch (60:23): [True: 4, False: 79]
-
61
4
                res.hidden = true;
62
1.00k
            } else if part == "nohidden" {
  Branch (62:23): [True: 0, False: 925]
+
61
4
                res.hidden = true;
62
1.00k
            } else if part == "nohidden" {
  Branch (62:23): [True: 0, False: 923]
 
  Branch (62:23): [True: 1, False: 78]
-
63
1
                res.hidden = false;
64
1.00k
            } else if part == "wrap" {
  Branch (64:23): [True: 3, False: 922]
+
63
1
                res.hidden = false;
64
1.00k
            } else if part == "wrap" {
  Branch (64:23): [True: 3, False: 920]
 
  Branch (64:23): [True: 1, False: 77]
-
65
4
                res.wrap = true;
66
999
            } else if part == "nowrap" {
  Branch (66:23): [True: 0, False: 922]
+
65
4
                res.wrap = true;
66
997
            } else if part == "nowrap" {
  Branch (66:23): [True: 0, False: 920]
 
  Branch (66:23): [True: 1, False: 76]
-
67
1
                res.wrap = false;
68
998
            } else if part == "pty" {
  Branch (68:23): [True: 13, False: 909]
+
67
1
                res.wrap = false;
68
996
            } else if part == "pty" {
  Branch (68:23): [True: 14, False: 906]
 
  Branch (68:23): [True: 1, False: 75]
-
69
14
                res.pty = true;
70
984
            } else if part == "nopty" {
  Branch (70:23): [True: 0, False: 909]
+
69
15
                res.pty = true;
70
981
            } else if part == "nopty" {
  Branch (70:23): [True: 0, False: 906]
 
  Branch (70:23): [True: 1, False: 74]
-
71
1
                res.pty = false;
72
            } else {
73
                // Try to parse as size
74
983
                if let Ok(size) = part.try_into() {
  Branch (74:24): [True: 446, False: 463]
+
71
1
                res.pty = false;
72
            } else {
73
                // Try to parse as size
74
980
                if let Ok(size) = part.try_into() {
  Branch (74:24): [True: 445, False: 461]
 
  Branch (74:24): [True: 32, False: 42]
-
75
478
                    res.size = size;
76
505
                }
77
983
                if let Ok(dir) = part.try_into() {
  Branch (77:24): [True: 463, False: 446]
+
75
477
                    res.size = size;
76
503
                }
77
980
                if let Ok(dir) = part.try_into() {
  Branch (77:24): [True: 461, False: 445]
 
  Branch (77:24): [True: 42, False: 32]
-
78
505
                    res.direction = dir;
79
478
                }
80
            }
81
        }
82
511
        res
83
511
    }
84
}
85
86
// impl PreviewLayout {
87
88
// }
89
90
#[cfg(test)]
91
#[path = "options_tests.rs"]
92
mod tests;
\ No newline at end of file +
78
503
                    res.direction = dir;
79
477
                }
80
            }
81
        }
82
510
        res
83
510
    }
84
}
85
86
// impl PreviewLayout {
87
88
// }
89
90
#[cfg(test)]
91
#[path = "options_tests.rs"]
92
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/preview.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/preview.rs.html index d99e078e..fcddaeb5 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/tui/preview.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/preview.rs.html @@ -1,51 +1,9 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/preview.rs
Line
Count
Source
1
use ansi_to_tui::IntoText;
2
use eyre::{Result, eyre};
3
use portable_pty::{PtyPair, PtySize, native_pty_system};
4
use ratatui::layout::Alignment;
5
use ratatui::prelude::Backend;
6
use ratatui::style::Stylize;
7
use ratatui::text::{Line, Text};
8
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
9
#[cfg(feature = "image")]
10
use ratatui_image::picker::Picker;
11
#[cfg(feature = "image")]
12
use ratatui_image::protocol::Protocol as ImageProtocol;
13
use tui_term::vt100;
14
use tui_term::widget::PseudoTerminal;
15
16
use std::env;
17
use std::io::Read;
18
use std::process::{Child, Stdio};
19
use std::sync::atomic::{AtomicBool, Ordering};
20
use std::sync::{Arc, Mutex, RwLock, mpsc};
21
use std::thread::JoinHandle;
22
use std::time::{Duration, Instant};
23
24
use super::statusline::spinner_char;
25
use super::util::{find_csi_end, find_osc_end, handle_csi_query, handle_osc_query};
26
use super::widget::{SkimRender, SkimWidget};
27
use super::{BorderType, Direction, Event, Tui};
28
29
use crate::theme::ColorTheme;
30
use crate::{SkimItem, SkimOptions};
31
32
// PreviewCallback for ratatui - returns Vec<String> instead of AnsiString
33
pub type PreviewCallbackFn = dyn Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static;
34
const PREVIEW_MAX_BYTES: usize = 1024 * 1024;
35
const VT_SCROLLBACK: usize = 100_000;
36
type PlainChild = Arc<Mutex<Option<Child>>>;
37
38
46
fn read_bounded(mut reader: impl Read) -> Vec<u8> {
39
46
    read_bounded_with_updates(&mut reader, |_| 
{}2
)
40
46
}
41
42
91
fn read_bounded_with_updates(mut reader: impl Read, mut update: impl FnMut(&[u8])) -> Vec<u8> {
43
    const UPDATE_INTERVAL: Duration = Duration::from_millis(16);
44
45
91
    let mut output = Vec::with_capacity(PREVIEW_MAX_BYTES);
46
91
    let mut buffer = [0; 8192];
47
91
    let mut last_update = None;
48
91
    let mut published_len = 0;
49
    loop {
50
262
        match reader.read(&mut buffer) {
51
91
            Ok(0) | Err(_) => break,
52
            Ok(read) => {
53
171
                let retained = PREVIEW_MAX_BYTES.saturating_sub(output.len()).min(read);
54
171
                output.extend_from_slice(&buffer[..retained]);
55
56
171
                let update_due = last_update.is_none_or(|last: Instant| 
last.elapsed()128
>=
UPDATE_INTERVAL128
);
57
171
                if retained > 0 && (
update_due170
||
output.len() == PREVIEW_MAX_BYTES127
) {
  Branch (57:20): [True: 0, False: 0]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/preview.rs
Line
Count
Source
1
use ansi_to_tui::IntoText;
2
use eyre::{Result, eyre};
3
use portable_pty::{PtyPair, PtySize, native_pty_system};
4
use ratatui::layout::Alignment;
5
use ratatui::prelude::Backend;
6
use ratatui::style::Stylize;
7
use ratatui::text::{Line, Text};
8
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
9
#[cfg(feature = "image")]
10
use ratatui_image::picker::Picker;
11
#[cfg(feature = "image")]
12
use ratatui_image::protocol::Protocol as ImageProtocol;
13
use tui_term::vt100;
14
use tui_term::widget::PseudoTerminal;
15
16
use std::env;
17
use std::io::Read;
18
use std::process::{Child, Stdio};
19
use std::sync::atomic::{AtomicBool, Ordering};
20
use std::sync::{Arc, Mutex, RwLock, mpsc};
21
use std::thread::JoinHandle;
22
use std::time::{Duration, Instant};
23
24
use super::statusline::spinner_char;
25
use super::util::{find_csi_end, find_osc_end, handle_csi_query, handle_osc_query};
26
use super::widget::{SkimRender, SkimWidget};
27
use super::{BorderType, Direction, Event, Tui};
28
29
use crate::theme::ColorTheme;
30
use crate::{SkimItem, SkimOptions};
31
32
// PreviewCallback for ratatui - returns Vec<String> instead of AnsiString
33
pub type PreviewCallbackFn = dyn Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static;
34
const PREVIEW_MAX_BYTES: usize = 1024 * 1024;
35
const VT_SCROLLBACK: usize = 100_000;
36
type PlainChild = Arc<Mutex<Option<Child>>>;
37
38
47
fn read_bounded(mut reader: impl Read) -> Vec<u8> {
39
47
    read_bounded_with_updates(&mut reader, |_| 
{}2
)
40
47
}
41
42
92
fn read_bounded_with_updates(mut reader: impl Read, mut update: impl FnMut(&[u8])) -> Vec<u8> {
43
    const UPDATE_INTERVAL: Duration = Duration::from_millis(16);
44
45
92
    let mut output = Vec::with_capacity(PREVIEW_MAX_BYTES);
46
92
    let mut buffer = [0; 8192];
47
92
    let mut last_update = None;
48
92
    let mut published_len = 0;
49
    loop {
50
262
        match reader.read(&mut buffer) {
51
92
            Ok(0) | Err(_) => break,
52
            Ok(read) => {
53
170
                let retained = PREVIEW_MAX_BYTES.saturating_sub(output.len()).min(read);
54
170
                output.extend_from_slice(&buffer[..retained]);
55
56
170
                let update_due = last_update.is_none_or(|last: Instant| 
last.elapsed()128
>=
UPDATE_INTERVAL128
);
57
170
                if retained > 0 && (
update_due169
||
output.len() == PREVIEW_MAX_BYTES127
) {
  Branch (57:20): [True: 0, False: 0]
   Branch (57:37): [True: 0, False: 0]
   Branch (57:51): [True: 0, False: 0]
 
  Branch (57:20): [True: 0, False: 0]
   Branch (57:37): [True: 0, False: 0]
   Branch (57:51): [True: 0, False: 0]
-
  Branch (57:20): [True: 0, False: 0]
-  Branch (57:37): [True: 0, False: 0]
-  Branch (57:51): [True: 0, False: 0]
-
  Branch (57:20): [True: 0, False: 0]
-  Branch (57:37): [True: 0, False: 0]
-  Branch (57:51): [True: 0, False: 0]
-
  Branch (57:20): [True: 0, False: 0]
-  Branch (57:37): [True: 0, False: 0]
-  Branch (57:51): [True: 0, False: 0]
-
  Branch (57:20): [True: 0, False: 0]
-  Branch (57:37): [True: 0, False: 0]
-  Branch (57:51): [True: 0, False: 0]
-
  Branch (57:20): [True: 0, False: 0]
-  Branch (57:37): [True: 0, False: 0]
-  Branch (57:51): [True: 0, False: 0]
-
  Branch (57:20): [True: 0, False: 0]
-  Branch (57:37): [True: 0, False: 0]
-  Branch (57:51): [True: 0, False: 0]
-
  Branch (57:20): [True: 0, False: 0]
-  Branch (57:37): [True: 0, False: 0]
-  Branch (57:51): [True: 0, False: 0]
-
  Branch (57:20): [True: 0, False: 0]
-  Branch (57:37): [True: 0, False: 0]
-  Branch (57:51): [True: 0, False: 0]
-
  Branch (57:20): [True: 0, False: 0]
-  Branch (57:37): [True: 0, False: 0]
-  Branch (57:51): [True: 0, False: 0]
-
  Branch (57:20): [True: 0, False: 0]
-  Branch (57:37): [True: 0, False: 0]
-  Branch (57:51): [True: 0, False: 0]
-
  Branch (57:20): [True: 0, False: 0]
-  Branch (57:37): [True: 0, False: 0]
-  Branch (57:51): [True: 0, False: 0]
-
  Branch (57:20): [True: 0, False: 0]
-  Branch (57:37): [True: 0, False: 0]
-  Branch (57:51): [True: 0, False: 0]
-
  Branch (57:20): [True: 5, False: 0]
-  Branch (57:37): [True: 5, False: 0]
-  Branch (57:51): [True: 0, False: 0]
-
  Branch (57:20): [True: 0, False: 0]
-  Branch (57:37): [True: 0, False: 0]
-  Branch (57:51): [True: 0, False: 0]
 
  Branch (57:20): [True: 27, False: 0]
   Branch (57:37): [True: 27, False: 0]
   Branch (57:51): [True: 0, False: 0]
@@ -64,9 +22,6 @@
 
  Branch (57:20): [True: 0, False: 0]
   Branch (57:37): [True: 0, False: 0]
   Branch (57:51): [True: 0, False: 0]
-
  Branch (57:20): [True: 5, False: 0]
-  Branch (57:37): [True: 5, False: 0]
-  Branch (57:51): [True: 0, False: 0]
 
  Branch (57:20): [True: 0, False: 0]
   Branch (57:37): [True: 0, False: 0]
   Branch (57:51): [True: 0, False: 0]
@@ -76,12 +31,6 @@
 
  Branch (57:20): [True: 0, False: 0]
   Branch (57:37): [True: 0, False: 0]
   Branch (57:51): [True: 0, False: 0]
-
  Branch (57:20): [True: 3, False: 0]
-  Branch (57:37): [True: 3, False: 0]
-  Branch (57:51): [True: 0, False: 0]
-
  Branch (57:20): [True: 128, False: 1]
-  Branch (57:37): [True: 1, False: 127]
-  Branch (57:51): [True: 1, False: 126]
 
  Branch (57:20): [True: 0, False: 0]
   Branch (57:37): [True: 0, False: 0]
   Branch (57:51): [True: 0, False: 0]
@@ -109,43 +58,94 @@
 
  Branch (57:20): [True: 0, False: 0]
   Branch (57:37): [True: 0, False: 0]
   Branch (57:51): [True: 0, False: 0]
+
  Branch (57:20): [True: 2, False: 0]
+  Branch (57:37): [True: 2, False: 0]
+  Branch (57:51): [True: 0, False: 0]
+
  Branch (57:20): [True: 128, False: 1]
+  Branch (57:37): [True: 1, False: 127]
+  Branch (57:51): [True: 1, False: 126]
 
  Branch (57:20): [True: 0, False: 0]
   Branch (57:37): [True: 0, False: 0]
   Branch (57:51): [True: 0, False: 0]
 
  Branch (57:20): [True: 0, False: 0]
   Branch (57:37): [True: 0, False: 0]
   Branch (57:51): [True: 0, False: 0]
-
58
44
                    update(&output);
59
44
                    published_len = output.len();
60
44
                    last_update = Some(Instant::now());
61
127
                }
62
            }
63
        }
64
    }
65
91
    if output.len() != published_len {
  Branch (65:8): [True: 0, False: 0]
+
  Branch (57:20): [True: 0, False: 0]
+  Branch (57:37): [True: 0, False: 0]
+  Branch (57:51): [True: 0, False: 0]
+
  Branch (57:20): [True: 0, False: 0]
+  Branch (57:37): [True: 0, False: 0]
+  Branch (57:51): [True: 0, False: 0]
+
  Branch (57:20): [True: 0, False: 0]
+  Branch (57:37): [True: 0, False: 0]
+  Branch (57:51): [True: 0, False: 0]
+
  Branch (57:20): [True: 5, False: 0]
+  Branch (57:37): [True: 5, False: 0]
+  Branch (57:51): [True: 0, False: 0]
+
  Branch (57:20): [True: 0, False: 0]
+  Branch (57:37): [True: 0, False: 0]
+  Branch (57:51): [True: 0, False: 0]
+
  Branch (57:20): [True: 5, False: 0]
+  Branch (57:37): [True: 5, False: 0]
+  Branch (57:51): [True: 0, False: 0]
+
  Branch (57:20): [True: 0, False: 0]
+  Branch (57:37): [True: 0, False: 0]
+  Branch (57:51): [True: 0, False: 0]
+
  Branch (57:20): [True: 0, False: 0]
+  Branch (57:37): [True: 0, False: 0]
+  Branch (57:51): [True: 0, False: 0]
+
  Branch (57:20): [True: 0, False: 0]
+  Branch (57:37): [True: 0, False: 0]
+  Branch (57:51): [True: 0, False: 0]
+
  Branch (57:20): [True: 0, False: 0]
+  Branch (57:37): [True: 0, False: 0]
+  Branch (57:51): [True: 0, False: 0]
+
  Branch (57:20): [True: 0, False: 0]
+  Branch (57:37): [True: 0, False: 0]
+  Branch (57:51): [True: 0, False: 0]
+
  Branch (57:20): [True: 0, False: 0]
+  Branch (57:37): [True: 0, False: 0]
+  Branch (57:51): [True: 0, False: 0]
+
  Branch (57:20): [True: 0, False: 0]
+  Branch (57:37): [True: 0, False: 0]
+  Branch (57:51): [True: 0, False: 0]
+
  Branch (57:20): [True: 0, False: 0]
+  Branch (57:37): [True: 0, False: 0]
+  Branch (57:51): [True: 0, False: 0]
+
  Branch (57:20): [True: 0, False: 0]
+  Branch (57:37): [True: 0, False: 0]
+  Branch (57:51): [True: 0, False: 0]
+
58
43
                    update(&output);
59
43
                    published_len = output.len();
60
43
                    last_update = Some(Instant::now());
61
127
                }
62
            }
63
        }
64
    }
65
92
    if output.len() != published_len {
  Branch (65:8): [True: 0, False: 0]
 
  Branch (65:8): [True: 0, False: 0]
-
  Branch (65:8): [True: 0, False: 0]
-
  Branch (65:8): [True: 0, False: 0]
-
  Branch (65:8): [True: 0, False: 0]
-
  Branch (65:8): [True: 0, False: 0]
-
  Branch (65:8): [True: 0, False: 0]
-
  Branch (65:8): [True: 0, False: 0]
-
  Branch (65:8): [True: 0, False: 0]
-
  Branch (65:8): [True: 0, False: 0]
-
  Branch (65:8): [True: 0, False: 0]
-
  Branch (65:8): [True: 0, False: 0]
-
  Branch (65:8): [True: 0, False: 0]
-
  Branch (65:8): [True: 0, False: 0]
-
  Branch (65:8): [True: 0, False: 5]
-
  Branch (65:8): [True: 0, False: 5]
 
  Branch (65:8): [True: 0, False: 27]
 
  Branch (65:8): [True: 0, False: 27]
 
  Branch (65:8): [True: 0, False: 0]
 
  Branch (65:8): [True: 0, False: 0]
 
  Branch (65:8): [True: 0, False: 0]
 
  Branch (65:8): [True: 0, False: 0]
-
  Branch (65:8): [True: 0, False: 5]
-
  Branch (65:8): [True: 0, False: 5]
+
  Branch (65:8): [True: 0, False: 0]
+
  Branch (65:8): [True: 0, False: 0]
+
  Branch (65:8): [True: 0, False: 0]
+
  Branch (65:8): [True: 0, False: 0]
+
  Branch (65:8): [True: 0, False: 2]
+
  Branch (65:8): [True: 0, False: 2]
+
  Branch (65:8): [True: 0, False: 0]
+
  Branch (65:8): [True: 0, False: 0]
+
  Branch (65:8): [True: 0, False: 0]
+
  Branch (65:8): [True: 0, False: 0]
 
  Branch (65:8): [True: 0, False: 0]
 
  Branch (65:8): [True: 0, False: 0]
 
  Branch (65:8): [True: 0, False: 6]
 
  Branch (65:8): [True: 0, False: 1]
-
  Branch (65:8): [True: 0, False: 6]
-
  Branch (65:8): [True: 0, False: 2]
-
  Branch (65:8): [True: 0, False: 2]
+
  Branch (65:8): [True: 0, False: 7]
+
  Branch (65:8): [True: 0, False: 0]
+
  Branch (65:8): [True: 0, False: 0]
+
  Branch (65:8): [True: 0, False: 0]
+
  Branch (65:8): [True: 0, False: 0]
+
  Branch (65:8): [True: 0, False: 5]
+
  Branch (65:8): [True: 0, False: 5]
+
  Branch (65:8): [True: 0, False: 5]
+
  Branch (65:8): [True: 0, False: 5]
 
  Branch (65:8): [True: 0, False: 0]
 
  Branch (65:8): [True: 0, False: 0]
 
  Branch (65:8): [True: 0, False: 0]
@@ -154,27 +154,27 @@
 
  Branch (65:8): [True: 0, False: 0]
 
  Branch (65:8): [True: 0, False: 0]
 
  Branch (65:8): [True: 0, False: 0]
-
66
0
        update(&output);
67
91
    }
68
91
    output
69
91
}
70
71
43
fn update_plain_content(content: &RwLock<PreviewContent>, cancelled: &AtomicBool, output: &[u8]) {
72
43
    let Ok(text) = output.to_vec().into_text() else {
  Branch (72:9): [True: 39, False: 0]
-
  Branch (72:9): [True: 4, False: 0]
-
73
0
        return;
74
    };
75
43
    if let Ok(mut content) = content.write()
  Branch (75:12): [True: 39, False: 0]
-
  Branch (75:12): [True: 4, False: 0]
-
76
43
        && !cancelled.load(Ordering::Acquire)
  Branch (76:12): [True: 39, False: 0]
-
  Branch (76:12): [True: 2, False: 2]
-
77
    {
78
41
        *content = PreviewContent::Text(text);
79
2
    }
80
43
}
81
82
99
fn terminate_plain_child(child: &PlainChild) {
83
99
    let Ok(mut guard) = child.lock() else {
  Branch (83:9): [True: 78, False: 0]
+
66
0
        update(&output);
67
92
    }
68
92
    output
69
92
}
70
71
42
fn update_plain_content(content: &RwLock<PreviewContent>, cancelled: &AtomicBool, output: &[u8]) {
72
42
    let Ok(text) = output.to_vec().into_text() else {
  Branch (72:9): [True: 39, False: 0]
+
  Branch (72:9): [True: 3, False: 0]
+
73
0
        return;
74
    };
75
42
    if let Ok(mut content) = content.write()
  Branch (75:12): [True: 39, False: 0]
+
  Branch (75:12): [True: 3, False: 0]
+
76
42
        && !cancelled.load(Ordering::Acquire)
  Branch (76:12): [True: 39, False: 0]
+
  Branch (76:12): [True: 2, False: 1]
+
77
    {
78
41
        *content = PreviewContent::Text(text);
79
1
    }
80
42
}
81
82
99
fn terminate_plain_child(child: &PlainChild) {
83
99
    let Ok(mut guard) = child.lock() else {
  Branch (83:9): [True: 78, False: 0]
 
  Branch (83:9): [True: 21, False: 0]
 
84
0
        return;
85
    };
86
99
    let Some(child) = guard.as_mut() else {
  Branch (86:9): [True: 39, False: 39]
 
  Branch (86:9): [True: 21, False: 0]
 
87
39
        return;
88
    };
89
90
    #[cfg(unix)]
91
60
    if let Ok(process_group) = i32::try_from(child.id()) {
  Branch (91:12): [True: 39, False: 0]
 
  Branch (91:12): [True: 21, False: 0]
-
92
        use nix::sys::signal::{Signal, killpg};
93
        use nix::unistd::Pid;
94
95
60
        let _ = killpg(Pid::from_raw(process_group), Signal::SIGKILL);
96
0
    }
97
98
    #[cfg(windows)]
99
    {
100
        let _ = std::process::Command::new("taskkill")
101
            .args(["/PID", &child.id().to_string(), "/T", "/F"])
102
            .stdout(Stdio::null())
103
            .stderr(Stdio::null())
104
            .status();
105
    }
106
107
60
    let _ = child.kill();
108
99
}
109
110
/// Preview content options
111
pub(crate) enum PreviewContent {
112
    /// Simple text content (for non-PTY previews and callbacks)
113
    Text(Text<'static>),
114
    /// Terminal screen (for PTY previews with cursor positioning)
115
    Terminal(Arc<RwLock<vt100::Parser>>),
116
    /// Image
117
    #[cfg(feature = "image")]
118
    Image {
119
        source: image::DynamicImage,
120
        protocol: Option<ImageProtocol>,
121
        size: ratatui::layout::Size,
122
    },
123
}
124
125
impl Default for PreviewContent {
126
556
    fn default() -> Self {
127
556
        PreviewContent::Text(Text::default())
128
556
    }
129
}
130
131
/// Callback function for generating preview content
132
#[derive(Clone)]
133
pub struct PreviewCallback {
134
    inner: Arc<PreviewCallbackFn>,
135
}
136
137
impl<F> From<F> for PreviewCallback
138
where
139
    F: Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static,
140
{
141
3
    fn from(func: F) -> Self {
142
3
        Self { inner: Arc::new(func) }
143
3
    }
144
}
145
146
impl std::ops::Deref for PreviewCallback {
147
    type Target = dyn Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static;
148
149
3
    fn deref(&self) -> &Self::Target {
150
3
        &*self.inner
151
3
    }
152
}
153
154
pub struct Preview {
155
    pub(crate) content: Arc<RwLock<PreviewContent>>,
156
    pub cmd: String,
157
    pub rows: u16,
158
    pub cols: u16,
159
    pub scroll_y: usize,
160
    pub scroll_x: usize,
161
    pub thread_handle: Option<JoinHandle<()>>,
162
    /// Channel to signal thread interruption
163
    interrupt_tx: Option<mpsc::Sender<()>>,
164
    plain_child: Option<PlainChild>,
165
    plain_cancelled: Option<Arc<AtomicBool>>,
166
    pub theme: Arc<ColorTheme>,
167
    /// Border type
168
    pub border: BorderType,
169
    pub direction: Direction,
170
    pub wrap: bool,
171
    pty: Option<PtyPair>,
172
    pty_child: Option<Box<dyn portable_pty::Child + Send + Sync>>,
173
    #[cfg(feature = "image")]
174
    image: bool,
175
    #[cfg(feature = "image")]
176
    image_picker: Option<Picker>,
177
    pub total_lines: usize,
178
    loading: bool,
179
    spinner_start: Instant,
180
}
181
182
impl Default for Preview {
183
14
    fn default() -> Self {
184
14
        Self::_default()
185
14
    }
186
}
187
188
impl Preview {
189
    #[cfg(feature = "image")]
190
8
    fn image_protocol(
191
8
        picker: Option<&Picker>,
192
8
        source: image::DynamicImage,
193
8
        size: ratatui::layout::Size,
194
8
    ) -> std::result::Result<ImageProtocol, ratatui_image::errors::Errors> {
195
        let fallback;
196
8
        let picker = if let Some(picker) = picker {
  Branch (196:29): [True: 0, False: 4]
+
92
        use nix::sys::signal::{Signal, killpg};
93
        use nix::unistd::Pid;
94
95
60
        let _ = killpg(Pid::from_raw(process_group), Signal::SIGKILL);
96
0
    }
97
98
    #[cfg(windows)]
99
    {
100
        let _ = std::process::Command::new("taskkill")
101
            .args(["/PID", &child.id().to_string(), "/T", "/F"])
102
            .stdout(Stdio::null())
103
            .stderr(Stdio::null())
104
            .status();
105
    }
106
107
60
    let _ = child.kill();
108
99
}
109
110
/// Preview content options
111
pub(crate) enum PreviewContent {
112
    /// Simple text content (for non-PTY previews and callbacks)
113
    Text(Text<'static>),
114
    /// Terminal screen (for PTY previews with cursor positioning)
115
    Terminal(Arc<RwLock<vt100::Parser>>),
116
    /// Image
117
    #[cfg(feature = "image")]
118
    Image {
119
        source: image::DynamicImage,
120
        protocol: Option<ImageProtocol>,
121
        size: ratatui::layout::Size,
122
    },
123
}
124
125
impl Default for PreviewContent {
126
555
    fn default() -> Self {
127
555
        PreviewContent::Text(Text::default())
128
555
    }
129
}
130
131
/// Callback function for generating preview content
132
#[derive(Clone)]
133
pub struct PreviewCallback {
134
    inner: Arc<PreviewCallbackFn>,
135
}
136
137
impl<F> From<F> for PreviewCallback
138
where
139
    F: Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static,
140
{
141
3
    fn from(func: F) -> Self {
142
3
        Self { inner: Arc::new(func) }
143
3
    }
144
}
145
146
impl std::ops::Deref for PreviewCallback {
147
    type Target = dyn Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static;
148
149
3
    fn deref(&self) -> &Self::Target {
150
3
        &*self.inner
151
3
    }
152
}
153
154
pub struct Preview {
155
    pub(crate) content: Arc<RwLock<PreviewContent>>,
156
    pub cmd: String,
157
    pub rows: u16,
158
    pub cols: u16,
159
    pub scroll_y: usize,
160
    pub scroll_x: usize,
161
    pub thread_handle: Option<JoinHandle<()>>,
162
    /// Channel to signal thread interruption
163
    interrupt_tx: Option<mpsc::Sender<()>>,
164
    plain_child: Option<PlainChild>,
165
    plain_cancelled: Option<Arc<AtomicBool>>,
166
    pub theme: Arc<ColorTheme>,
167
    /// Border type
168
    pub border: BorderType,
169
    pub direction: Direction,
170
    pub wrap: bool,
171
    pty: Option<PtyPair>,
172
    pty_child: Option<Box<dyn portable_pty::Child + Send + Sync>>,
173
    #[cfg(feature = "image")]
174
    image: bool,
175
    #[cfg(feature = "image")]
176
    image_picker: Option<Picker>,
177
    pub total_lines: usize,
178
    loading: bool,
179
    spinner_start: Instant,
180
}
181
182
impl Default for Preview {
183
14
    fn default() -> Self {
184
14
        Self::_default()
185
14
    }
186
}
187
188
impl Preview {
189
    #[cfg(feature = "image")]
190
8
    fn image_protocol(
191
8
        picker: Option<&Picker>,
192
8
        source: image::DynamicImage,
193
8
        size: ratatui::layout::Size,
194
8
    ) -> std::result::Result<ImageProtocol, ratatui_image::errors::Errors> {
195
        let fallback;
196
8
        let picker = if let Some(picker) = picker {
  Branch (196:29): [True: 0, False: 4]
 
  Branch (196:29): [True: 3, False: 1]
 
197
3
            picker
198
        } else {
199
5
            fallback = Picker::halfblocks();
200
5
            &fallback
201
        };
202
203
8
        let font_size = picker.font_size();
204
8
        let max_pixel_width = u128::from(size.width) * u128::from(font_size.width);
205
8
        let max_pixel_height = u128::from(size.height) * u128::from(font_size.height);
206
8
        let image_width = u128::from(source.width());
207
8
        let image_height = u128::from(source.height());
208
209
8
        let size = if image_height * max_pixel_width <= max_pixel_height * image_width {
  Branch (209:23): [True: 3, False: 1]
 
  Branch (209:23): [True: 3, False: 1]
 
210
6
            let pixel_height = image_height * max_pixel_width / image_width;
211
6
            let height = pixel_height.div_ceil(u128::from(font_size.height));
212
213
6
            ratatui::layout::Size::new(size.width, u16::try_from(height).unwrap_or(u16::MAX).max(1))
214
        } else {
215
2
            let pixel_width = image_width * max_pixel_height / image_height;
216
2
            let width = pixel_width.div_ceil(u128::from(font_size.width));
217
218
2
            ratatui::layout::Size::new(u16::try_from(width).unwrap_or(u16::MAX).max(1), size.height)
219
        };
220
221
8
        picker.new_protocol(source, size, ratatui_image::Resize::Scale(None))
222
8
    }
223
224
    #[cfg(feature = "image")]
225
2
    pub(crate) fn set_image_picker(&mut self, picker: Option<Picker>) {
226
2
        self.image_picker = picker;
227
2
    }
228
229
    /// Convert a Size value to an actual offset based on preview dimensions
230
17
    fn size_to_offset(&self, size: super::Size, is_vertical: bool) -> usize {
231
17
        let dimension = if is_vertical { 
self.rows9
} else {
self.cols8
};
  Branch (231:28): [True: 0, False: 0]
 
  Branch (231:28): [True: 9, False: 8]
-
232
17
        match size {
233
9
            super::Size::Fixed(n) => usize::from(n),
234
4
            super::Size::Percent(p) => usize::from(dimension) * usize::from(p) / 100,
235
4
            super::Size::Neg(n) => usize::from(dimension.saturating_sub(n)),
236
        }
237
17
    }
238
239
36
    fn init_pty(&mut self) {
240
36
        let pty_system = native_pty_system();
241
36
        let cols = if self.wrap { 
self.cols2
} else {
102434
};
  Branch (241:23): [True: 2, False: 34]
+
232
17
        match size {
233
9
            super::Size::Fixed(n) => usize::from(n),
234
4
            super::Size::Percent(p) => usize::from(dimension) * usize::from(p) / 100,
235
4
            super::Size::Neg(n) => usize::from(dimension.saturating_sub(n)),
236
        }
237
17
    }
238
239
37
    fn init_pty(&mut self) {
240
37
        let pty_system = native_pty_system();
241
37
        let cols = if self.wrap { 
self.cols2
} else {
102435
};
  Branch (241:23): [True: 2, False: 35]
 
  Branch (241:23): [True: 0, False: 0]
-
242
36
        let pair = pty_system.openpty(PtySize {
243
36
            rows: self.rows,
244
36
            cols,
245
36
            pixel_width: 0,
246
36
            pixel_height: 0,
247
36
        });
248
36
        match pair {
249
36
            Ok(p) => self.pty = Some(p),
250
0
            Err(e) => warn!("failed to init preview pty: {e:?}"),
251
        }
252
36
    }
253
254
    /// Filter out terminal query sequences from the output and respond to them.
255
    /// This prevents programs like delta from waiting for responses and timing out.
256
    /// Returns the filtered output with query sequences removed.
257
33
    fn filter_and_respond_to_queries(data: &[u8], writer: &mut Box<dyn std::io::Write + Send>) -> Vec<u8> {
258
33
        let mut result = Vec::new();
259
33
        let mut i = 0;
260
261
18.2k
        while i < data.len() {
  Branch (261:15): [True: 18.2k, False: 32]
+
242
37
        let pair = pty_system.openpty(PtySize {
243
37
            rows: self.rows,
244
37
            cols,
245
37
            pixel_width: 0,
246
37
            pixel_height: 0,
247
37
        });
248
37
        match pair {
249
37
            Ok(p) => self.pty = Some(p),
250
0
            Err(e) => warn!("failed to init preview pty: {e:?}"),
251
        }
252
37
    }
253
254
    /// Filter out terminal query sequences from the output and respond to them.
255
    /// This prevents programs like delta from waiting for responses and timing out.
256
    /// Returns the filtered output with query sequences removed.
257
36
    fn filter_and_respond_to_queries(data: &[u8], writer: &mut Box<dyn std::io::Write + Send>) -> Vec<u8> {
258
36
        let mut result = Vec::new();
259
36
        let mut i = 0;
260
261
18.2k
        while i < data.len() {
  Branch (261:15): [True: 18.2k, False: 35]
 
  Branch (261:15): [True: 7, False: 1]
 
262
18.2k
            if data[i] == b'\x1b' && 
i + 1 < data.len()1
{
  Branch (262:16): [True: 0, False: 18.2k]
   Branch (262:38): [True: 0, False: 0]
@@ -186,47 +186,40 @@
 
  Branch (275:32): [True: 1, False: 0]
 
276
1
                            let seq = &data[i..i + end];
277
1
                            if handle_csi_query(seq, writer) {
  Branch (277:32): [True: 0, False: 0]
 
  Branch (277:32): [True: 1, False: 0]
-
278
                                // It was a query, filter it out
279
1
                                i += end;
280
1
                                continue;
281
0
                            }
282
                            // Not a query, keep it in output
283
0
                        }
284
                    }
285
0
                    _ => {}
286
                }
287
18.2k
            }
288
18.2k
            result.push(data[i]);
289
18.2k
            i += 1;
290
        }
291
292
33
        result
293
33
    }
294
295
12
    pub fn content(&mut self, content: &[u8]) -> Result<()> {
296
12
        let text = content.to_owned().into_text()
?0
;
297
12
        let Ok(mut content) = self.content.write() else {
  Branch (297:13): [True: 0, False: 0]
+
278
                                // It was a query, filter it out
279
1
                                i += end;
280
1
                                continue;
281
0
                            }
282
                            // Not a query, keep it in output
283
0
                        }
284
                    }
285
0
                    _ => {}
286
                }
287
18.2k
            }
288
18.2k
            result.push(data[i]);
289
18.2k
            i += 1;
290
        }
291
292
36
        result
293
36
    }
294
295
12
    pub fn content(&mut self, content: &[u8]) -> Result<()> {
296
12
        let text = content.to_owned().into_text()
?0
;
297
12
        let Ok(mut content) = self.content.write() else {
  Branch (297:13): [True: 0, False: 0]
 
  Branch (297:13): [True: 12, False: 0]
 
298
0
            return Err(eyre::eyre!("Failed to acquire content for writing"));
299
        };
300
12
        self.total_lines = text.lines.len();
301
12
        *content = PreviewContent::Text(text);
302
12
        self.scroll_y = 0;
303
12
        self.scroll_x = 0;
304
12
        self.loading = false;
305
12
        Ok(())
306
12
    }
307
308
1.77k
    pub(crate) fn is_loading(&self) -> bool {
309
1.77k
        self.loading
310
1.77k
    }
311
312
71
    pub(crate) fn mark_ready(&mut self) {
313
71
        self.loading = false;
314
71
    }
315
316
3
    pub fn content_with_position(&mut self, content: &[u8], position: crate::PreviewPosition) -> Result<()> {
317
3
        self.content(content).map(|()| {
318
            // Apply position offsets
319
3
            let v_scroll = self.size_to_offset(position.v_scroll, true);
320
3
            let v_offset = self.size_to_offset(position.v_offset, true);
321
3
            self.scroll_y = v_scroll.saturating_add(v_offset);
322
323
3
            let h_scroll = self.size_to_offset(position.h_scroll, false);
324
3
            let h_offset = self.size_to_offset(position.h_offset, false);
325
3
            self.scroll_x = h_scroll.saturating_add(h_offset);
326
3
        })
327
3
    }
328
329
5
    pub fn scroll_up(&mut self, lines: u16) {
330
5
        self.scroll_y = self.scroll_y.saturating_sub(usize::from(lines));
331
5
    }
332
333
8
    pub fn scroll_down(&mut self, lines: u16) {
334
8
        trace!(
335
0
            "scrolling down by {lines} lines, ({} total, {} rows)",
336
0
            self.total_lines, self.rows
337
        );
338
8
        if self.total_lines > 0 {
  Branch (338:12): [True: 0, False: 0]
 
  Branch (338:12): [True: 2, False: 6]
-
339
2
            self.scroll_y = self.scroll_y.saturating_add(usize::from(lines)).min(
340
2
                self.total_lines
341
2
                    .saturating_sub(usize::from(self.rows.saturating_sub(1))),
342
2
            );
343
        } else {
344
            // We might not have the actual total_lines value
345
6
            self.scroll_y = self.scroll_y.saturating_add(usize::from(lines));
346
        }
347
8
    }
348
349
3
    pub fn scroll_left(&mut self, cols: u16) {
350
3
        self.scroll_x = self.scroll_x.saturating_sub(usize::from(cols));
351
3
    }
352
353
2
    pub fn scroll_right(&mut self, cols: u16) {
354
2
        self.scroll_x = self.scroll_x.saturating_add(usize::from(cols));
355
2
    }
356
357
8
    pub fn set_offset(&mut self, offset: u16) {
358
8
        self.scroll_y = usize::from(offset.saturating_sub(1)); // -1 because line numbers are 1-indexed
359
8
    }
360
361
2
    pub fn page_up(&mut self) {
362
2
        let page_size = self.rows.saturating_sub(2); // Account for borders
363
2
        self.scroll_up(page_size);
364
2
    }
365
366
2
    pub fn page_down(&mut self) {
367
2
        let page_size = self.rows.saturating_sub(2); // Account for borders
368
2
        self.scroll_down(page_size);
369
2
    }
370
    /// Kill the preview child process and interrupt the reader thread.
371
638
    pub fn kill(&mut self) {
372
638
        if let Some(cancelled) = self.plain_cancelled.take() {
  Branch (372:16): [True: 39, False: 417]
+
339
2
            self.scroll_y = self.scroll_y.saturating_add(usize::from(lines)).min(
340
2
                self.total_lines
341
2
                    .saturating_sub(usize::from(self.rows.saturating_sub(1))),
342
2
            );
343
        } else {
344
            // We might not have the actual total_lines value
345
6
            self.scroll_y = self.scroll_y.saturating_add(usize::from(lines));
346
        }
347
8
    }
348
349
3
    pub fn scroll_left(&mut self, cols: u16) {
350
3
        self.scroll_x = self.scroll_x.saturating_sub(usize::from(cols));
351
3
    }
352
353
2
    pub fn scroll_right(&mut self, cols: u16) {
354
2
        self.scroll_x = self.scroll_x.saturating_add(usize::from(cols));
355
2
    }
356
357
8
    pub fn set_offset(&mut self, offset: u16) {
358
8
        self.scroll_y = usize::from(offset.saturating_sub(1)); // -1 because line numbers are 1-indexed
359
8
    }
360
361
2
    pub fn page_up(&mut self) {
362
2
        let page_size = self.rows.saturating_sub(2); // Account for borders
363
2
        self.scroll_up(page_size);
364
2
    }
365
366
2
    pub fn page_down(&mut self) {
367
2
        let page_size = self.rows.saturating_sub(2); // Account for borders
368
2
        self.scroll_down(page_size);
369
2
    }
370
    /// Kill the preview child process and interrupt the reader thread.
371
637
    pub fn kill(&mut self) {
372
637
        if let Some(cancelled) = self.plain_cancelled.take() {
  Branch (372:16): [True: 39, False: 416]
 
  Branch (372:16): [True: 7, False: 175]
-
373
46
            cancelled.store(true, Ordering::Release);
374
592
        }
375
376
638
        if let Some(tx) = self.interrupt_tx.take() {
  Branch (376:16): [True: 68, False: 388]
+
373
46
            cancelled.store(true, Ordering::Release);
374
591
        }
375
376
637
        if let Some(tx) = self.interrupt_tx.take() {
  Branch (376:16): [True: 68, False: 387]
 
  Branch (376:16): [True: 7, False: 175]
-
377
75
            let _ = tx.send(());
378
563
        }
379
380
638
        if let Some(child) = self.plain_child.take() {
  Branch (380:16): [True: 39, False: 417]
+
377
75
            let _ = tx.send(());
378
562
        }
379
380
637
        if let Some(child) = self.plain_child.take() {
  Branch (380:16): [True: 39, False: 416]
 
  Branch (380:16): [True: 7, False: 175]
-
381
46
            trace!(
"killing plain preview child process group"0
);
382
46
            terminate_plain_child(&child);
383
592
        }
384
385
638
        if let Some(mut child) = self.pty_child.take() {
  Branch (385:16): [True: 23, False: 433]
+
381
46
            trace!(
"killing plain preview child process group"0
);
382
46
            terminate_plain_child(&child);
383
591
        }
384
385
637
        if let Some(mut child) = self.pty_child.take() {
  Branch (385:16): [True: 23, False: 432]
 
  Branch (385:16): [True: 0, False: 182]
 
386
23
            trace!(
"killing pty child process"0
);
387
23
            match child.try_wait() {
388
                Ok(Some(status)) => {
389
23
                    trace!("child already exited with status: {
status0
:?}");
390
                }
391
                Ok(None) => {
392
0
                    trace!("child still running, sending kill signal");
393
0
                    if let Err(e) = child.kill() {
  Branch (393:28): [True: 0, False: 0]
 
  Branch (393:28): [True: 0, False: 0]
-
394
0
                        trace!("failed to kill pty child: {e:?}");
395
0
                    }
396
                }
397
                Err(e) => {
398
0
                    debug!("error checking child status: {e:?}");
399
0
                    let _ = child.kill();
400
                }
401
            }
402
615
        }
403
638
    }
404
405
    #[allow(clippy::too_many_lines)]
406
75
    pub fn spawn<B: Backend>(&mut self, tui: &mut Tui<B>, cmd: &str) -> Result<()>
407
75
    where
408
75
        B::Error: Send + Sync + 'static,
409
    {
410
75
        self.kill();
411
75
        self.cmd = cmd.to_string();
412
75
        self.loading = true;
413
414
        // Reset scroll position and manual_scroll flag for new preview
415
75
        self.scroll_y = 0;
416
75
        self.scroll_x = 0;
417
418
75
        let event_tx_clone = tui.event_tx.clone();
419
75
        let content = self.content.clone();
420
421
        #[cfg(feature = "image")]
422
75
        if self.image {
  Branch (422:12): [True: 0, False: 0]
-
  Branch (422:12): [True: 0, False: 0]
-
  Branch (422:12): [True: 0, False: 0]
-
  Branch (422:12): [True: 0, False: 0]
-
  Branch (422:12): [True: 0, False: 0]
-
  Branch (422:12): [True: 0, False: 0]
-
  Branch (422:12): [True: 0, False: 0]
-
  Branch (422:12): [True: 0, False: 5]
+
394
0
                        trace!("failed to kill pty child: {e:?}");
395
0
                    }
396
                }
397
                Err(e) => {
398
0
                    debug!("error checking child status: {e:?}");
399
0
                    let _ = child.kill();
400
                }
401
            }
402
614
        }
403
637
    }
404
405
    #[allow(clippy::too_many_lines)]
406
75
    pub fn spawn<B: Backend>(&mut self, tui: &mut Tui<B>, cmd: &str) -> Result<()>
407
75
    where
408
75
        B::Error: Send + Sync + 'static,
409
    {
410
75
        self.kill();
411
75
        self.cmd = cmd.to_string();
412
75
        self.loading = true;
413
414
        // Reset scroll position and manual_scroll flag for new preview
415
75
        self.scroll_y = 0;
416
75
        self.scroll_x = 0;
417
418
75
        let event_tx_clone = tui.event_tx.clone();
419
75
        let content = self.content.clone();
420
421
        #[cfg(feature = "image")]
422
75
        if self.image {
  Branch (422:12): [True: 0, False: 0]
 
  Branch (422:12): [True: 6, False: 50]
 
  Branch (422:12): [True: 0, False: 0]
 
  Branch (422:12): [True: 0, False: 0]
-
  Branch (422:12): [True: 0, False: 5]
+
  Branch (422:12): [True: 0, False: 0]
+
  Branch (422:12): [True: 0, False: 0]
+
  Branch (422:12): [True: 0, False: 2]
+
  Branch (422:12): [True: 0, False: 0]
+
  Branch (422:12): [True: 0, False: 0]
 
  Branch (422:12): [True: 0, False: 0]
 
  Branch (422:12): [True: 0, False: 7]
-
  Branch (422:12): [True: 0, False: 2]
+
  Branch (422:12): [True: 0, False: 0]
+
  Branch (422:12): [True: 0, False: 0]
+
  Branch (422:12): [True: 0, False: 5]
+
  Branch (422:12): [True: 0, False: 5]
 
  Branch (422:12): [True: 0, False: 0]
 
  Branch (422:12): [True: 0, False: 0]
 
  Branch (422:12): [True: 0, False: 0]
 
  Branch (422:12): [True: 0, False: 0]
 
423
6
            let cmd = self.cmd.clone();
424
425
6
            let (interrupt_tx, interrupt_rx) = mpsc::channel();
426
6
            self.interrupt_tx = Some(interrupt_tx);
427
428
6
            self.thread_handle = Some(std::thread::spawn(move || {
429
                let res: PreviewContent;
430
6
                if let Ok(Ok(decoded)) = image::ImageReader::open(&cmd).map(image::ImageReader::decode) {
  Branch (430:24): [True: 0, False: 0]
-
  Branch (430:24): [True: 0, False: 0]
-
  Branch (430:24): [True: 0, False: 0]
-
  Branch (430:24): [True: 0, False: 0]
-
  Branch (430:24): [True: 0, False: 0]
-
  Branch (430:24): [True: 0, False: 0]
-
  Branch (430:24): [True: 0, False: 0]
-
  Branch (430:24): [True: 0, False: 0]
 
  Branch (430:24): [True: 4, False: 2]
 
  Branch (430:24): [True: 0, False: 0]
 
  Branch (430:24): [True: 0, False: 0]
@@ -238,14 +231,14 @@
 
  Branch (430:24): [True: 0, False: 0]
 
  Branch (430:24): [True: 0, False: 0]
 
  Branch (430:24): [True: 0, False: 0]
+
  Branch (430:24): [True: 0, False: 0]
+
  Branch (430:24): [True: 0, False: 0]
+
  Branch (430:24): [True: 0, False: 0]
+
  Branch (430:24): [True: 0, False: 0]
+
  Branch (430:24): [True: 0, False: 0]
+
  Branch (430:24): [True: 0, False: 0]
+
  Branch (430:24): [True: 0, False: 0]
 
431
4
                    res = PreviewContent::Image {
432
4
                        source: decoded,
433
4
                        protocol: None,
434
4
                        size: ratatui::layout::Size::default(),
435
4
                    };
436
                } else {
437
2
                    res = PreviewContent::Text(Text::raw(format!("Failed to open {cmd} as image")));
438
                }
439
440
6
                if interrupt_rx.try_recv().is_ok() {
  Branch (440:20): [True: 0, False: 0]
-
  Branch (440:20): [True: 0, False: 0]
-
  Branch (440:20): [True: 0, False: 0]
-
  Branch (440:20): [True: 0, False: 0]
-
  Branch (440:20): [True: 0, False: 0]
-
  Branch (440:20): [True: 0, False: 0]
-
  Branch (440:20): [True: 0, False: 0]
-
  Branch (440:20): [True: 0, False: 0]
 
  Branch (440:20): [True: 0, False: 6]
 
  Branch (440:20): [True: 0, False: 0]
 
  Branch (440:20): [True: 0, False: 0]
@@ -257,14 +250,14 @@
 
  Branch (440:20): [True: 0, False: 0]
 
  Branch (440:20): [True: 0, False: 0]
 
  Branch (440:20): [True: 0, False: 0]
+
  Branch (440:20): [True: 0, False: 0]
+
  Branch (440:20): [True: 0, False: 0]
+
  Branch (440:20): [True: 0, False: 0]
+
  Branch (440:20): [True: 0, False: 0]
+
  Branch (440:20): [True: 0, False: 0]
+
  Branch (440:20): [True: 0, False: 0]
+
  Branch (440:20): [True: 0, False: 0]
 
441
0
                    trace!("interrupt signal received, exiting");
442
0
                    return;
443
6
                }
444
445
6
                if let Ok(mut c) = content.write() {
  Branch (445:24): [True: 0, False: 0]
-
  Branch (445:24): [True: 0, False: 0]
-
  Branch (445:24): [True: 0, False: 0]
-
  Branch (445:24): [True: 0, False: 0]
-
  Branch (445:24): [True: 0, False: 0]
-
  Branch (445:24): [True: 0, False: 0]
-
  Branch (445:24): [True: 0, False: 0]
-
  Branch (445:24): [True: 0, False: 0]
 
  Branch (445:24): [True: 6, False: 0]
 
  Branch (445:24): [True: 0, False: 0]
 
  Branch (445:24): [True: 0, False: 0]
@@ -276,41 +269,34 @@
 
  Branch (445:24): [True: 0, False: 0]
 
  Branch (445:24): [True: 0, False: 0]
 
  Branch (445:24): [True: 0, False: 0]
+
  Branch (445:24): [True: 0, False: 0]
+
  Branch (445:24): [True: 0, False: 0]
+
  Branch (445:24): [True: 0, False: 0]
+
  Branch (445:24): [True: 0, False: 0]
+
  Branch (445:24): [True: 0, False: 0]
+
  Branch (445:24): [True: 0, False: 0]
+
  Branch (445:24): [True: 0, False: 0]
 
446
6
                    *c = res;
447
6
                    let _ = event_tx_clone.blocking_send(Event::PreviewReady);
448
0
                }
449
6
            }));
450
451
6
            return Ok(());
452
69
        }
453
454
69
        if let Some(pty) = self.pty.take() {
  Branch (454:16): [True: 0, False: 0]
-
  Branch (454:16): [True: 0, False: 0]
-
  Branch (454:16): [True: 0, False: 0]
-
  Branch (454:16): [True: 0, False: 0]
-
  Branch (454:16): [True: 0, False: 0]
-
  Branch (454:16): [True: 0, False: 0]
-
  Branch (454:16): [True: 0, False: 0]
-
  Branch (454:16): [True: 0, False: 5]
 
  Branch (454:16): [True: 23, False: 27]
 
  Branch (454:16): [True: 0, False: 0]
 
  Branch (454:16): [True: 0, False: 0]
-
  Branch (454:16): [True: 0, False: 5]
+
  Branch (454:16): [True: 0, False: 0]
+
  Branch (454:16): [True: 0, False: 0]
+
  Branch (454:16): [True: 0, False: 2]
+
  Branch (454:16): [True: 0, False: 0]
+
  Branch (454:16): [True: 0, False: 0]
 
  Branch (454:16): [True: 0, False: 0]
 
  Branch (454:16): [True: 0, False: 7]
-
  Branch (454:16): [True: 0, False: 2]
+
  Branch (454:16): [True: 0, False: 0]
+
  Branch (454:16): [True: 0, False: 0]
+
  Branch (454:16): [True: 0, False: 5]
+
  Branch (454:16): [True: 0, False: 5]
 
  Branch (454:16): [True: 0, False: 0]
 
  Branch (454:16): [True: 0, False: 0]
 
  Branch (454:16): [True: 0, False: 0]
 
  Branch (454:16): [True: 0, False: 0]
 
455
            // Ensure the PTY has the correct display dimensions before spawning.
456
            // init_pty() creates PTYs with 1024 cols for non-wrap mode (for the vt100 parser's
457
            // horizontal scrolling), but the child process needs to see the actual display size.
458
            // render() only resizes when the area changes, so if spawn() is called twice at the
459
            // same area size, the second PTY would still have 1024 cols.
460
23
            if self.rows > 0 && self.cols > 0 {
  Branch (460:16): [True: 0, False: 0]
   Branch (460:33): [True: 0, False: 0]
-
  Branch (460:16): [True: 0, False: 0]
-  Branch (460:33): [True: 0, False: 0]
-
  Branch (460:16): [True: 0, False: 0]
-  Branch (460:33): [True: 0, False: 0]
-
  Branch (460:16): [True: 0, False: 0]
-  Branch (460:33): [True: 0, False: 0]
-
  Branch (460:16): [True: 0, False: 0]
-  Branch (460:33): [True: 0, False: 0]
-
  Branch (460:16): [True: 0, False: 0]
-  Branch (460:33): [True: 0, False: 0]
-
  Branch (460:16): [True: 0, False: 0]
-  Branch (460:33): [True: 0, False: 0]
-
  Branch (460:16): [True: 0, False: 0]
-  Branch (460:33): [True: 0, False: 0]
 
  Branch (460:16): [True: 23, False: 0]
   Branch (460:33): [True: 23, False: 0]
 
  Branch (460:16): [True: 0, False: 0]
@@ -333,14 +319,21 @@
   Branch (460:33): [True: 0, False: 0]
 
  Branch (460:16): [True: 0, False: 0]
   Branch (460:33): [True: 0, False: 0]
+
  Branch (460:16): [True: 0, False: 0]
+  Branch (460:33): [True: 0, False: 0]
+
  Branch (460:16): [True: 0, False: 0]
+  Branch (460:33): [True: 0, False: 0]
+
  Branch (460:16): [True: 0, False: 0]
+  Branch (460:33): [True: 0, False: 0]
+
  Branch (460:16): [True: 0, False: 0]
+  Branch (460:33): [True: 0, False: 0]
+
  Branch (460:16): [True: 0, False: 0]
+  Branch (460:33): [True: 0, False: 0]
+
  Branch (460:16): [True: 0, False: 0]
+  Branch (460:33): [True: 0, False: 0]
+
  Branch (460:16): [True: 0, False: 0]
+  Branch (460:33): [True: 0, False: 0]
 
461
23
                let _ = pty.master.resize(PtySize {
462
23
                    rows: self.rows,
463
23
                    cols: self.cols,
464
23
                    pixel_width: 0,
465
23
                    pixel_height: 0,
466
23
                });
467
0
            }
468
23
            trace!(
469
0
                "spawning preview cmd {cmd} in pty (rows={}, cols={})",
470
0
                self.rows, self.cols
471
            );
472
23
            self.init_pty();
473
23
            trace!(
"initialized pty"0
);
474
            // no PTY on windows, we can keep sh
475
23
            let mut shell_cmd = portable_pty::CommandBuilder::new("/bin/sh");
476
23
            shell_cmd.env("ROWS", self.rows.to_string());
477
23
            shell_cmd.env("COLUMNS", self.cols.to_string());
478
23
            shell_cmd.env("PAGER", "");
479
23
            shell_cmd.arg("-c");
480
23
            if let Ok(cwd) = env::current_dir() {
  Branch (480:20): [True: 0, False: 0]
-
  Branch (480:20): [True: 0, False: 0]
-
  Branch (480:20): [True: 0, False: 0]
-
  Branch (480:20): [True: 0, False: 0]
-
  Branch (480:20): [True: 0, False: 0]
-
  Branch (480:20): [True: 0, False: 0]
-
  Branch (480:20): [True: 0, False: 0]
-
  Branch (480:20): [True: 0, False: 0]
 
  Branch (480:20): [True: 23, False: 0]
 
  Branch (480:20): [True: 0, False: 0]
 
  Branch (480:20): [True: 0, False: 0]
@@ -352,14 +345,14 @@
 
  Branch (480:20): [True: 0, False: 0]
 
  Branch (480:20): [True: 0, False: 0]
 
  Branch (480:20): [True: 0, False: 0]
+
  Branch (480:20): [True: 0, False: 0]
+
  Branch (480:20): [True: 0, False: 0]
+
  Branch (480:20): [True: 0, False: 0]
+
  Branch (480:20): [True: 0, False: 0]
+
  Branch (480:20): [True: 0, False: 0]
+
  Branch (480:20): [True: 0, False: 0]
+
  Branch (480:20): [True: 0, False: 0]
 
481
23
                shell_cmd.cwd(cwd);
482
0
            }
483
23
            shell_cmd.arg(cmd);
484
23
            self.pty_child = Some(pty.slave.spawn_command(shell_cmd).map_err(|e| 
{0
485
0
                warn!("{:#?}", e.backtrace());
486
0
                eyre!(Box::<dyn std::error::Error + Send + Sync + 'static>::from(e))
487
0
            })?);
488
489
23
            let mut reader = pty
490
23
                .master
491
23
                .try_clone_reader()
492
0
                .map_err(|e| eyre!(Box::<dyn std::error::Error + Send + Sync + 'static>::from(e)))?;
493
494
            // Get a writer to respond to terminal queries
495
23
            let mut esc_writer = pty
496
23
                .master
497
23
                .take_writer()
498
0
                .map_err(|e| eyre!(Box::<dyn std::error::Error + Send + Sync + 'static>::from(e)))?;
499
500
23
            let (interrupt_tx, interrupt_rx) = mpsc::channel();
501
23
            self.interrupt_tx = Some(interrupt_tx);
502
503
            // Create vt100 parser for PTY output with large scrollback buffer
504
23
            let cols = if self.wrap { 
self.cols1
} else {
102422
};
  Branch (504:27): [True: 0, False: 0]
-
  Branch (504:27): [True: 0, False: 0]
-
  Branch (504:27): [True: 0, False: 0]
-
  Branch (504:27): [True: 0, False: 0]
-
  Branch (504:27): [True: 0, False: 0]
-
  Branch (504:27): [True: 0, False: 0]
-
  Branch (504:27): [True: 0, False: 0]
-
  Branch (504:27): [True: 0, False: 0]
 
  Branch (504:27): [True: 1, False: 22]
 
  Branch (504:27): [True: 0, False: 0]
 
  Branch (504:27): [True: 0, False: 0]
@@ -371,14 +364,14 @@
 
  Branch (504:27): [True: 0, False: 0]
 
  Branch (504:27): [True: 0, False: 0]
 
  Branch (504:27): [True: 0, False: 0]
+
  Branch (504:27): [True: 0, False: 0]
+
  Branch (504:27): [True: 0, False: 0]
+
  Branch (504:27): [True: 0, False: 0]
+
  Branch (504:27): [True: 0, False: 0]
+
  Branch (504:27): [True: 0, False: 0]
+
  Branch (504:27): [True: 0, False: 0]
+
  Branch (504:27): [True: 0, False: 0]
 
505
23
            let parser = Arc::new(RwLock::new(vt100::Parser::new(self.rows, cols, VT_SCROLLBACK)));
506
507
            // Update content to use the parser
508
23
            if let Ok(mut c) = content.write() {
  Branch (508:20): [True: 0, False: 0]
-
  Branch (508:20): [True: 0, False: 0]
-
  Branch (508:20): [True: 0, False: 0]
-
  Branch (508:20): [True: 0, False: 0]
-
  Branch (508:20): [True: 0, False: 0]
-
  Branch (508:20): [True: 0, False: 0]
-
  Branch (508:20): [True: 0, False: 0]
-
  Branch (508:20): [True: 0, False: 0]
 
  Branch (508:20): [True: 23, False: 0]
 
  Branch (508:20): [True: 0, False: 0]
 
  Branch (508:20): [True: 0, False: 0]
@@ -390,15 +383,15 @@
 
  Branch (508:20): [True: 0, False: 0]
 
  Branch (508:20): [True: 0, False: 0]
 
  Branch (508:20): [True: 0, False: 0]
-
509
23
                *c = PreviewContent::Terminal(parser.clone());
510
0
            }
511
512
23
            self.thread_handle = Some(std::thread::spawn(move || {
513
23
                let mut buf = [0u8; 8192];
514
23
                let mut unprocessed_buf = Vec::new();
515
516
23
                trace!(
"preview reader thread started"0
);
517
518
                loop {
519
55
                    if interrupt_rx.try_recv().is_ok() {
  Branch (519:24): [True: 0, False: 0]
-
  Branch (519:24): [True: 0, False: 0]
-
  Branch (519:24): [True: 0, False: 0]
-
  Branch (519:24): [True: 0, False: 0]
-
  Branch (519:24): [True: 0, False: 0]
-
  Branch (519:24): [True: 0, False: 0]
-
  Branch (519:24): [True: 0, False: 0]
-
  Branch (519:24): [True: 0, False: 0]
-
  Branch (519:24): [True: 0, False: 55]
+
  Branch (508:20): [True: 0, False: 0]
+
  Branch (508:20): [True: 0, False: 0]
+
  Branch (508:20): [True: 0, False: 0]
+
  Branch (508:20): [True: 0, False: 0]
+
  Branch (508:20): [True: 0, False: 0]
+
  Branch (508:20): [True: 0, False: 0]
+
  Branch (508:20): [True: 0, False: 0]
+
509
23
                *c = PreviewContent::Terminal(parser.clone());
510
0
            }
511
512
23
            self.thread_handle = Some(std::thread::spawn(move || {
513
23
                let mut buf = [0u8; 8192];
514
23
                let mut unprocessed_buf = Vec::new();
515
516
23
                trace!(
"preview reader thread started"0
);
517
518
                loop {
519
58
                    if interrupt_rx.try_recv().is_ok() {
  Branch (519:24): [True: 0, False: 0]
+
  Branch (519:24): [True: 0, False: 58]
 
  Branch (519:24): [True: 0, False: 0]
 
  Branch (519:24): [True: 0, False: 0]
 
  Branch (519:24): [True: 0, False: 0]
@@ -409,15 +402,15 @@
 
  Branch (519:24): [True: 0, False: 0]
 
  Branch (519:24): [True: 0, False: 0]
 
  Branch (519:24): [True: 0, False: 0]
-
520
0
                        trace!("interrupt signal received, exiting");
521
0
                        return;
522
55
                    }
523
524
55
                    match reader.read(&mut buf) {
525
                        Ok(0) => {
526
23
                            trace!(
"reached EOF"0
);
527
23
                            break;
528
                        }
529
                        Ok(size) => {
530
32
                            trace!("read {
size0
} bytes");
531
32
                            unprocessed_buf.extend_from_slice(&buf[..size]);
532
533
                            // Filter out terminal query sequences and respond to them
534
                            // This prevents programs like delta from waiting for responses
535
32
                            let filtered = Self::filter_and_respond_to_queries(&unprocessed_buf, &mut esc_writer);
536
537
32
                            if let Ok(mut parser_guard) = parser.write() {
  Branch (537:36): [True: 0, False: 0]
-
  Branch (537:36): [True: 0, False: 0]
-
  Branch (537:36): [True: 0, False: 0]
-
  Branch (537:36): [True: 0, False: 0]
-
  Branch (537:36): [True: 0, False: 0]
-
  Branch (537:36): [True: 0, False: 0]
-
  Branch (537:36): [True: 0, False: 0]
-
  Branch (537:36): [True: 0, False: 0]
-
  Branch (537:36): [True: 32, False: 0]
+
  Branch (519:24): [True: 0, False: 0]
+
  Branch (519:24): [True: 0, False: 0]
+
  Branch (519:24): [True: 0, False: 0]
+
  Branch (519:24): [True: 0, False: 0]
+
  Branch (519:24): [True: 0, False: 0]
+
  Branch (519:24): [True: 0, False: 0]
+
  Branch (519:24): [True: 0, False: 0]
+
520
0
                        trace!("interrupt signal received, exiting");
521
0
                        return;
522
58
                    }
523
524
58
                    match reader.read(&mut buf) {
525
                        Ok(0) => {
526
23
                            trace!(
"reached EOF"0
);
527
23
                            break;
528
                        }
529
                        Ok(size) => {
530
35
                            trace!("read {
size0
} bytes");
531
35
                            unprocessed_buf.extend_from_slice(&buf[..size]);
532
533
                            // Filter out terminal query sequences and respond to them
534
                            // This prevents programs like delta from waiting for responses
535
35
                            let filtered = Self::filter_and_respond_to_queries(&unprocessed_buf, &mut esc_writer);
536
537
35
                            if let Ok(mut parser_guard) = parser.write() {
  Branch (537:36): [True: 0, False: 0]
+
  Branch (537:36): [True: 35, False: 0]
 
  Branch (537:36): [True: 0, False: 0]
 
  Branch (537:36): [True: 0, False: 0]
 
  Branch (537:36): [True: 0, False: 0]
@@ -428,15 +421,15 @@
 
  Branch (537:36): [True: 0, False: 0]
 
  Branch (537:36): [True: 0, False: 0]
 
  Branch (537:36): [True: 0, False: 0]
-
538
32
                                parser_guard.process(&filtered);
539
32
                                parser_guard.screen_mut().set_scrollback(VT_SCROLLBACK);
540
0
                            }
541
542
                            // Clear the processed portion of the buffer
543
32
                            unprocessed_buf.clear();
544
545
                            // Check interrupt after processing
546
32
                            if interrupt_rx.try_recv().is_ok() {
  Branch (546:32): [True: 0, False: 0]
-
  Branch (546:32): [True: 0, False: 0]
-
  Branch (546:32): [True: 0, False: 0]
-
  Branch (546:32): [True: 0, False: 0]
-
  Branch (546:32): [True: 0, False: 0]
-
  Branch (546:32): [True: 0, False: 0]
-
  Branch (546:32): [True: 0, False: 0]
-
  Branch (546:32): [True: 0, False: 0]
-
  Branch (546:32): [True: 0, False: 32]
+
  Branch (537:36): [True: 0, False: 0]
+
  Branch (537:36): [True: 0, False: 0]
+
  Branch (537:36): [True: 0, False: 0]
+
  Branch (537:36): [True: 0, False: 0]
+
  Branch (537:36): [True: 0, False: 0]
+
  Branch (537:36): [True: 0, False: 0]
+
  Branch (537:36): [True: 0, False: 0]
+
538
35
                                parser_guard.process(&filtered);
539
35
                                parser_guard.screen_mut().set_scrollback(VT_SCROLLBACK);
540
0
                            }
541
542
                            // Clear the processed portion of the buffer
543
35
                            unprocessed_buf.clear();
544
545
                            // Check interrupt after processing
546
35
                            if interrupt_rx.try_recv().is_ok() {
  Branch (546:32): [True: 0, False: 0]
+
  Branch (546:32): [True: 0, False: 35]
 
  Branch (546:32): [True: 0, False: 0]
 
  Branch (546:32): [True: 0, False: 0]
 
  Branch (546:32): [True: 0, False: 0]
@@ -447,14 +440,14 @@
 
  Branch (546:32): [True: 0, False: 0]
 
  Branch (546:32): [True: 0, False: 0]
 
  Branch (546:32): [True: 0, False: 0]
-
547
0
                                trace!("interrupt signal received during read block, exiting");
548
0
                                return;
549
32
                            }
550
                        }
551
                        Err(e) => {
552
0
                            trace!("read error {e:?}");
553
0
                            break;
554
                        }
555
                    }
556
                }
557
558
                // Last check, I promise
559
23
                if interrupt_rx.try_recv().is_ok() {
  Branch (559:20): [True: 0, False: 0]
-
  Branch (559:20): [True: 0, False: 0]
-
  Branch (559:20): [True: 0, False: 0]
-
  Branch (559:20): [True: 0, False: 0]
-
  Branch (559:20): [True: 0, False: 0]
-
  Branch (559:20): [True: 0, False: 0]
-
  Branch (559:20): [True: 0, False: 0]
-
  Branch (559:20): [True: 0, False: 0]
+
  Branch (546:32): [True: 0, False: 0]
+
  Branch (546:32): [True: 0, False: 0]
+
  Branch (546:32): [True: 0, False: 0]
+
  Branch (546:32): [True: 0, False: 0]
+
  Branch (546:32): [True: 0, False: 0]
+
  Branch (546:32): [True: 0, False: 0]
+
  Branch (546:32): [True: 0, False: 0]
+
547
0
                                trace!("interrupt signal received during read block, exiting");
548
0
                                return;
549
35
                            }
550
                        }
551
                        Err(e) => {
552
0
                            trace!("read error {e:?}");
553
0
                            break;
554
                        }
555
                    }
556
                }
557
558
                // Last check, I promise
559
23
                if interrupt_rx.try_recv().is_ok() {
  Branch (559:20): [True: 0, False: 0]
 
  Branch (559:20): [True: 0, False: 23]
 
  Branch (559:20): [True: 0, False: 0]
 
  Branch (559:20): [True: 0, False: 0]
@@ -466,184 +459,191 @@
 
  Branch (559:20): [True: 0, False: 0]
 
  Branch (559:20): [True: 0, False: 0]
 
  Branch (559:20): [True: 0, False: 0]
+
  Branch (559:20): [True: 0, False: 0]
+
  Branch (559:20): [True: 0, False: 0]
+
  Branch (559:20): [True: 0, False: 0]
+
  Branch (559:20): [True: 0, False: 0]
+
  Branch (559:20): [True: 0, False: 0]
+
  Branch (559:20): [True: 0, False: 0]
+
  Branch (559:20): [True: 0, False: 0]
 
560
0
                    trace!("interrupt signal received, exiting");
561
0
                    return;
562
23
                }
563
564
23
                trace!(
"read complete"0
);
565
23
                let _ = event_tx_clone.blocking_send(Event::PreviewReady);
566
23
            }));
567
        } else {
568
46
            trace!("spawning preview cmd {
cmd0
}");
569
46
            let mut shell_cmd = crate::shell_cmd(cmd);
570
46
            shell_cmd
571
46
                .env("ROWS", self.rows.to_string())
572
46
                .env("COLUMNS", self.cols.to_string())
573
46
                .env("PAGER", "")
574
46
                .stdout(Stdio::piped())
575
46
                .stderr(Stdio::piped());
576
46
            if let Ok(cwd) = env::current_dir() {
  Branch (576:20): [True: 0, False: 0]
-
  Branch (576:20): [True: 0, False: 0]
-
  Branch (576:20): [True: 0, False: 0]
-
  Branch (576:20): [True: 0, False: 0]
-
  Branch (576:20): [True: 0, False: 0]
-
  Branch (576:20): [True: 0, False: 0]
-
  Branch (576:20): [True: 0, False: 0]
-
  Branch (576:20): [True: 5, False: 0]
 
  Branch (576:20): [True: 27, False: 0]
 
  Branch (576:20): [True: 0, False: 0]
 
  Branch (576:20): [True: 0, False: 0]
-
  Branch (576:20): [True: 5, False: 0]
 
  Branch (576:20): [True: 0, False: 0]
-
  Branch (576:20): [True: 7, False: 0]
+
  Branch (576:20): [True: 0, False: 0]
 
  Branch (576:20): [True: 2, False: 0]
 
  Branch (576:20): [True: 0, False: 0]
 
  Branch (576:20): [True: 0, False: 0]
 
  Branch (576:20): [True: 0, False: 0]
+
  Branch (576:20): [True: 7, False: 0]
 
  Branch (576:20): [True: 0, False: 0]
-
577
46
                shell_cmd.current_dir(cwd);
578
0
            }
579
            #[cfg(unix)]
580
            {
581
                use std::os::unix::process::CommandExt as _;
582
46
                shell_cmd.process_group(0);
583
            }
584
            #[cfg(windows)]
585
            {
586
                use std::os::windows::process::CommandExt as _;
587
                const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
588
                shell_cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
589
            }
590
591
46
            let (interrupt_tx, interrupt_rx) = mpsc::channel();
592
46
            self.interrupt_tx = Some(interrupt_tx);
593
46
            let cancelled = Arc::new(AtomicBool::new(false));
594
46
            self.plain_cancelled = Some(cancelled.clone());
595
596
46
            let mut child = match shell_cmd.spawn() {
597
46
                Ok(child) => child,
598
                Err(error) => {
599
0
                    log::info!("Shell cmd in error: {error:?}");
600
0
                    let _ = event_tx_clone.blocking_send(Event::PreviewReady);
601
0
                    return Ok(());
602
                }
603
            };
604
46
            let stdout = child.stdout.take().expect("stdout was configured as piped");
605
46
            let stderr = child.stderr.take().expect("stderr was configured as piped");
606
46
            let child = Arc::new(Mutex::new(Some(child)));
607
46
            self.plain_child = Some(child.clone());
608
609
46
            self.thread_handle = Some(std::thread::spawn(move || {
610
46
                let streaming_content = content.clone();
611
46
                let streaming_cancelled = cancelled.clone();
612
46
                let stdout_reader = std::thread::spawn(move || 
{45
613
45
                    read_bounded_with_updates(stdout, |output| 
{42
614
42
                        update_plain_content(&streaming_content, &streaming_cancelled, output);
615
42
                    })
616
45
                });
617
46
                let stderr_reader = std::thread::spawn(move || 
read_bounded45
(
stderr45
));
618
619
                let status = loop {
620
47
                    match interrupt_rx.recv_timeout(Duration::from_millis(10)) {
621
                        Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => {
622
7
                            terminate_plain_child(&child);
623
7
                            break None;
624
                        }
625
40
                        Err(mpsc::RecvTimeoutError::Timeout) => {}
626
                    }
627
628
40
                    let wait_result = match child.lock() {
629
40
                        Ok(mut guard) => guard.as_mut().map(Child::try_wait),
630
0
                        Err(_) => break None,
631
                    };
632
40
                    match wait_result {
633
39
                        Some(Ok(Some(status))) => break Some(status),
634
1
                        Some(Ok(None)) => {}
635
                        Some(Err(error)) => {
636
0
                            log::info!("Failed to wait for preview command: {error:?}");
637
0
                            break None;
638
                        }
639
0
                        None => break None,
640
                    }
641
                };
642
643
                // A shell can exit while a background descendant still owns the pipes.
644
                // Terminate the whole process group before joining the drain threads.
645
46
                terminate_plain_child(&child);
646
46
                let stdout = stdout_reader.join().unwrap_or_default();
647
46
                let stderr = stderr_reader.join().unwrap_or_default();
648
46
                if let Ok(mut guard) = child.lock()
  Branch (648:24): [True: 0, False: 0]
-
  Branch (648:24): [True: 0, False: 0]
-
  Branch (648:24): [True: 0, False: 0]
-
  Branch (648:24): [True: 0, False: 0]
-
  Branch (648:24): [True: 0, False: 0]
-
  Branch (648:24): [True: 0, False: 0]
-
  Branch (648:24): [True: 0, False: 0]
-
  Branch (648:24): [True: 5, False: 0]
+
  Branch (576:20): [True: 0, False: 0]
+
  Branch (576:20): [True: 5, False: 0]
+
  Branch (576:20): [True: 5, False: 0]
+
  Branch (576:20): [True: 0, False: 0]
+
  Branch (576:20): [True: 0, False: 0]
+
  Branch (576:20): [True: 0, False: 0]
+
  Branch (576:20): [True: 0, False: 0]
+
577
46
                shell_cmd.current_dir(cwd);
578
0
            }
579
            #[cfg(unix)]
580
            {
581
                use std::os::unix::process::CommandExt as _;
582
46
                shell_cmd.process_group(0);
583
            }
584
            #[cfg(windows)]
585
            {
586
                use std::os::windows::process::CommandExt as _;
587
                const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
588
                shell_cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
589
            }
590
591
46
            let (interrupt_tx, interrupt_rx) = mpsc::channel();
592
46
            self.interrupt_tx = Some(interrupt_tx);
593
46
            let cancelled = Arc::new(AtomicBool::new(false));
594
46
            self.plain_cancelled = Some(cancelled.clone());
595
596
46
            let mut child = match shell_cmd.spawn() {
597
46
                Ok(child) => child,
598
                Err(error) => {
599
0
                    log::info!("Shell cmd in error: {error:?}");
600
0
                    let _ = event_tx_clone.blocking_send(Event::PreviewReady);
601
0
                    return Ok(());
602
                }
603
            };
604
46
            let stdout = child.stdout.take().expect("stdout was configured as piped");
605
46
            let stderr = child.stderr.take().expect("stderr was configured as piped");
606
46
            let child = Arc::new(Mutex::new(Some(child)));
607
46
            self.plain_child = Some(child.clone());
608
609
46
            self.thread_handle = Some(std::thread::spawn(move || {
610
46
                let streaming_content = content.clone();
611
46
                let streaming_cancelled = cancelled.clone();
612
46
                let stdout_reader = std::thread::spawn(move || 
{45
613
45
                    read_bounded_with_updates(stdout, |output| 
{41
614
41
                        update_plain_content(&streaming_content, &streaming_cancelled, output);
615
41
                    })
616
45
                });
617
46
                let stderr_reader = std::thread::spawn(move || read_bounded(stderr));
618
619
                let status = loop {
620
51
                    match interrupt_rx.recv_timeout(Duration::from_millis(10)) {
621
                        Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => {
622
7
                            terminate_plain_child(&child);
623
7
                            break None;
624
                        }
625
44
                        Err(mpsc::RecvTimeoutError::Timeout) => {}
626
                    }
627
628
44
                    let wait_result = match child.lock() {
629
44
                        Ok(mut guard) => guard.as_mut().map(Child::try_wait),
630
0
                        Err(_) => break None,
631
                    };
632
44
                    match wait_result {
633
39
                        Some(Ok(Some(status))) => break Some(status),
634
5
                        Some(Ok(None)) => {}
635
                        Some(Err(error)) => {
636
0
                            log::info!("Failed to wait for preview command: {error:?}");
637
0
                            break None;
638
                        }
639
0
                        None => break None,
640
                    }
641
                };
642
643
                // A shell can exit while a background descendant still owns the pipes.
644
                // Terminate the whole process group before joining the drain threads.
645
46
                terminate_plain_child(&child);
646
46
                let stdout = stdout_reader.join().unwrap_or_default();
647
46
                let stderr = stderr_reader.join().unwrap_or_default();
648
46
                if let Ok(mut guard) = child.lock()
  Branch (648:24): [True: 0, False: 0]
 
  Branch (648:24): [True: 27, False: 0]
 
  Branch (648:24): [True: 0, False: 0]
 
  Branch (648:24): [True: 0, False: 0]
-
  Branch (648:24): [True: 5, False: 0]
+
  Branch (648:24): [True: 0, False: 0]
+
  Branch (648:24): [True: 0, False: 0]
+
  Branch (648:24): [True: 2, False: 0]
+
  Branch (648:24): [True: 0, False: 0]
+
  Branch (648:24): [True: 0, False: 0]
 
  Branch (648:24): [True: 0, False: 0]
 
  Branch (648:24): [True: 7, False: 0]
-
  Branch (648:24): [True: 2, False: 0]
+
  Branch (648:24): [True: 0, False: 0]
+
  Branch (648:24): [True: 0, False: 0]
+
  Branch (648:24): [True: 5, False: 0]
+
  Branch (648:24): [True: 5, False: 0]
 
  Branch (648:24): [True: 0, False: 0]
 
  Branch (648:24): [True: 0, False: 0]
 
  Branch (648:24): [True: 0, False: 0]
 
  Branch (648:24): [True: 0, False: 0]
 
649
46
                    && let Some(mut child) = guard.take()
  Branch (649:28): [True: 0, False: 0]
-
  Branch (649:28): [True: 0, False: 0]
-
  Branch (649:28): [True: 0, False: 0]
-
  Branch (649:28): [True: 0, False: 0]
-
  Branch (649:28): [True: 0, False: 0]
-
  Branch (649:28): [True: 0, False: 0]
-
  Branch (649:28): [True: 0, False: 0]
-
  Branch (649:28): [True: 5, False: 0]
 
  Branch (649:28): [True: 27, False: 0]
 
  Branch (649:28): [True: 0, False: 0]
 
  Branch (649:28): [True: 0, False: 0]
-
  Branch (649:28): [True: 5, False: 0]
+
  Branch (649:28): [True: 0, False: 0]
+
  Branch (649:28): [True: 0, False: 0]
+
  Branch (649:28): [True: 2, False: 0]
+
  Branch (649:28): [True: 0, False: 0]
+
  Branch (649:28): [True: 0, False: 0]
 
  Branch (649:28): [True: 0, False: 0]
 
  Branch (649:28): [True: 7, False: 0]
-
  Branch (649:28): [True: 2, False: 0]
+
  Branch (649:28): [True: 0, False: 0]
+
  Branch (649:28): [True: 0, False: 0]
+
  Branch (649:28): [True: 5, False: 0]
+
  Branch (649:28): [True: 5, False: 0]
 
  Branch (649:28): [True: 0, False: 0]
 
  Branch (649:28): [True: 0, False: 0]
 
  Branch (649:28): [True: 0, False: 0]
 
  Branch (649:28): [True: 0, False: 0]
 
650
46
                    && status.is_none()
  Branch (650:24): [True: 0, False: 0]
-
  Branch (650:24): [True: 0, False: 0]
-
  Branch (650:24): [True: 0, False: 0]
-
  Branch (650:24): [True: 0, False: 0]
-
  Branch (650:24): [True: 0, False: 0]
-
  Branch (650:24): [True: 0, False: 0]
-
  Branch (650:24): [True: 0, False: 0]
-
  Branch (650:24): [True: 0, False: 5]
 
  Branch (650:24): [True: 0, False: 27]
 
  Branch (650:24): [True: 0, False: 0]
 
  Branch (650:24): [True: 0, False: 0]
-
  Branch (650:24): [True: 0, False: 5]
+
  Branch (650:24): [True: 0, False: 0]
+
  Branch (650:24): [True: 0, False: 0]
+
  Branch (650:24): [True: 0, False: 2]
+
  Branch (650:24): [True: 0, False: 0]
+
  Branch (650:24): [True: 0, False: 0]
 
  Branch (650:24): [True: 0, False: 0]
 
  Branch (650:24): [True: 6, False: 1]
-
  Branch (650:24): [True: 0, False: 2]
+
  Branch (650:24): [True: 0, False: 0]
+
  Branch (650:24): [True: 0, False: 0]
+
  Branch (650:24): [True: 0, False: 5]
+
  Branch (650:24): [True: 0, False: 5]
 
  Branch (650:24): [True: 0, False: 0]
 
  Branch (650:24): [True: 0, False: 0]
 
  Branch (650:24): [True: 0, False: 0]
 
  Branch (650:24): [True: 0, False: 0]
 
651
                {
652
6
                    let _ = child.wait();
653
40
                }
654
655
46
                let Some(status) = status else {
  Branch (655:21): [True: 0, False: 0]
-
  Branch (655:21): [True: 0, False: 0]
-
  Branch (655:21): [True: 0, False: 0]
-
  Branch (655:21): [True: 0, False: 0]
-
  Branch (655:21): [True: 0, False: 0]
-
  Branch (655:21): [True: 0, False: 0]
-
  Branch (655:21): [True: 0, False: 0]
-
  Branch (655:21): [True: 5, False: 0]
 
  Branch (655:21): [True: 27, False: 0]
 
  Branch (655:21): [True: 0, False: 0]
 
  Branch (655:21): [True: 0, False: 0]
-
  Branch (655:21): [True: 5, False: 0]
+
  Branch (655:21): [True: 0, False: 0]
+
  Branch (655:21): [True: 0, False: 0]
+
  Branch (655:21): [True: 2, False: 0]
+
  Branch (655:21): [True: 0, False: 0]
+
  Branch (655:21): [True: 0, False: 0]
 
  Branch (655:21): [True: 0, False: 0]
 
  Branch (655:21): [True: 1, False: 6]
-
  Branch (655:21): [True: 2, False: 0]
+
  Branch (655:21): [True: 0, False: 0]
+
  Branch (655:21): [True: 0, False: 0]
+
  Branch (655:21): [True: 5, False: 0]
+
  Branch (655:21): [True: 5, False: 0]
 
  Branch (655:21): [True: 0, False: 0]
 
  Branch (655:21): [True: 0, False: 0]
 
  Branch (655:21): [True: 0, False: 0]
 
  Branch (655:21): [True: 0, False: 0]
 
656
6
                    return;
657
                };
658
40
                if let Ok(mut c) = content.write()
  Branch (658:24): [True: 0, False: 0]
-
  Branch (658:24): [True: 0, False: 0]
-
  Branch (658:24): [True: 0, False: 0]
-
  Branch (658:24): [True: 0, False: 0]
-
  Branch (658:24): [True: 0, False: 0]
-
  Branch (658:24): [True: 0, False: 0]
-
  Branch (658:24): [True: 0, False: 0]
-
  Branch (658:24): [True: 5, False: 0]
 
  Branch (658:24): [True: 27, False: 0]
 
  Branch (658:24): [True: 0, False: 0]
 
  Branch (658:24): [True: 0, False: 0]
-
  Branch (658:24): [True: 5, False: 0]
+
  Branch (658:24): [True: 0, False: 0]
+
  Branch (658:24): [True: 0, False: 0]
+
  Branch (658:24): [True: 2, False: 0]
+
  Branch (658:24): [True: 0, False: 0]
+
  Branch (658:24): [True: 0, False: 0]
 
  Branch (658:24): [True: 0, False: 0]
 
  Branch (658:24): [True: 1, False: 0]
-
  Branch (658:24): [True: 2, False: 0]
+
  Branch (658:24): [True: 0, False: 0]
+
  Branch (658:24): [True: 0, False: 0]
+
  Branch (658:24): [True: 5, False: 0]
+
  Branch (658:24): [True: 5, False: 0]
 
  Branch (658:24): [True: 0, False: 0]
 
  Branch (658:24): [True: 0, False: 0]
 
  Branch (658:24): [True: 0, False: 0]
 
  Branch (658:24): [True: 0, False: 0]
 
659
40
                    && !cancelled.load(Ordering::Acquire)
  Branch (659:24): [True: 0, False: 0]
-
  Branch (659:24): [True: 0, False: 0]
-
  Branch (659:24): [True: 0, False: 0]
-
  Branch (659:24): [True: 0, False: 0]
-
  Branch (659:24): [True: 0, False: 0]
-
  Branch (659:24): [True: 0, False: 0]
-
  Branch (659:24): [True: 0, False: 0]
-
  Branch (659:24): [True: 5, False: 0]
 
  Branch (659:24): [True: 27, False: 0]
 
  Branch (659:24): [True: 0, False: 0]
 
  Branch (659:24): [True: 0, False: 0]
-
  Branch (659:24): [True: 5, False: 0]
+
  Branch (659:24): [True: 0, False: 0]
+
  Branch (659:24): [True: 0, False: 0]
+
  Branch (659:24): [True: 2, False: 0]
+
  Branch (659:24): [True: 0, False: 0]
+
  Branch (659:24): [True: 0, False: 0]
 
  Branch (659:24): [True: 0, False: 0]
 
  Branch (659:24): [True: 1, False: 0]
-
  Branch (659:24): [True: 2, False: 0]
+
  Branch (659:24): [True: 0, False: 0]
+
  Branch (659:24): [True: 0, False: 0]
+
  Branch (659:24): [True: 5, False: 0]
+
  Branch (659:24): [True: 5, False: 0]
 
  Branch (659:24): [True: 0, False: 0]
 
  Branch (659:24): [True: 0, False: 0]
 
  Branch (659:24): [True: 0, False: 0]
 
  Branch (659:24): [True: 0, False: 0]
 
660
                {
661
40
                    let output = if status.success() { 
stdout39
} else {
stderr1
};
  Branch (661:37): [True: 0, False: 0]
-
  Branch (661:37): [True: 0, False: 0]
-
  Branch (661:37): [True: 0, False: 0]
-
  Branch (661:37): [True: 0, False: 0]
-
  Branch (661:37): [True: 0, False: 0]
-
  Branch (661:37): [True: 0, False: 0]
-
  Branch (661:37): [True: 0, False: 0]
-
  Branch (661:37): [True: 5, False: 0]
 
  Branch (661:37): [True: 27, False: 0]
 
  Branch (661:37): [True: 0, False: 0]
 
  Branch (661:37): [True: 0, False: 0]
-
  Branch (661:37): [True: 5, False: 0]
+
  Branch (661:37): [True: 0, False: 0]
+
  Branch (661:37): [True: 0, False: 0]
+
  Branch (661:37): [True: 2, False: 0]
+
  Branch (661:37): [True: 0, False: 0]
+
  Branch (661:37): [True: 0, False: 0]
 
  Branch (661:37): [True: 0, False: 0]
 
  Branch (661:37): [True: 0, False: 1]
-
  Branch (661:37): [True: 2, False: 0]
+
  Branch (661:37): [True: 0, False: 0]
+
  Branch (661:37): [True: 0, False: 0]
+
  Branch (661:37): [True: 5, False: 0]
+
  Branch (661:37): [True: 5, False: 0]
 
  Branch (661:37): [True: 0, False: 0]
 
  Branch (661:37): [True: 0, False: 0]
 
  Branch (661:37): [True: 0, False: 0]
 
  Branch (661:37): [True: 0, False: 0]
 
662
40
                    *c = PreviewContent::Text(output.into_text().unwrap_or_default());
663
0
                }
664
665
40
                if !cancelled.load(Ordering::Acquire) {
  Branch (665:20): [True: 0, False: 0]
-
  Branch (665:20): [True: 0, False: 0]
-
  Branch (665:20): [True: 0, False: 0]
-
  Branch (665:20): [True: 0, False: 0]
-
  Branch (665:20): [True: 0, False: 0]
-
  Branch (665:20): [True: 0, False: 0]
-
  Branch (665:20): [True: 0, False: 0]
-
  Branch (665:20): [True: 5, False: 0]
 
  Branch (665:20): [True: 27, False: 0]
 
  Branch (665:20): [True: 0, False: 0]
 
  Branch (665:20): [True: 0, False: 0]
-
  Branch (665:20): [True: 5, False: 0]
 
  Branch (665:20): [True: 0, False: 0]
-
  Branch (665:20): [True: 1, False: 0]
+
  Branch (665:20): [True: 0, False: 0]
 
  Branch (665:20): [True: 2, False: 0]
 
  Branch (665:20): [True: 0, False: 0]
 
  Branch (665:20): [True: 0, False: 0]
 
  Branch (665:20): [True: 0, False: 0]
+
  Branch (665:20): [True: 1, False: 0]
 
  Branch (665:20): [True: 0, False: 0]
-
666
40
                    trace!(
"sending ready ping"0
);
667
40
                    let _ = event_tx_clone.blocking_send(Event::PreviewReady);
668
0
                }
669
46
            }));
670
        }
671
69
        Ok(())
672
75
    }
673
674
195
    fn render_text(
675
195
        &self,
676
195
        mut outer: Block,
677
195
        area: ratatui::layout::Rect,
678
195
        buf: &mut ratatui::prelude::Buffer,
679
195
        text: &Text,
680
195
    ) -> usize {
681
        // Calculate total lines in content
682
195
        let total_lines = text.lines.len();
683
684
        // Ratatui terminal coordinates are u16. Saturate previews that exceed that range.
685
195
        let scroll_y = u16::try_from(self.scroll_y).unwrap_or(u16::MAX);
686
195
        let scroll_x = u16::try_from(self.scroll_x).unwrap_or(u16::MAX);
687
195
        let mut paragraph = Paragraph::new(text.clone()).scroll((scroll_y, scroll_x));
688
689
        // Enable wrapping if wrap is true
690
195
        if self.wrap {
  Branch (690:12): [True: 10, False: 182]
+
  Branch (665:20): [True: 0, False: 0]
+
  Branch (665:20): [True: 5, False: 0]
+
  Branch (665:20): [True: 5, False: 0]
+
  Branch (665:20): [True: 0, False: 0]
+
  Branch (665:20): [True: 0, False: 0]
+
  Branch (665:20): [True: 0, False: 0]
+
  Branch (665:20): [True: 0, False: 0]
+
666
40
                    trace!(
"sending ready ping"0
);
667
40
                    let _ = event_tx_clone.blocking_send(Event::PreviewReady);
668
0
                }
669
46
            }));
670
        }
671
69
        Ok(())
672
75
    }
673
674
182
    fn render_text(
675
182
        &self,
676
182
        mut outer: Block,
677
182
        area: ratatui::layout::Rect,
678
182
        buf: &mut ratatui::prelude::Buffer,
679
182
        text: &Text,
680
182
    ) -> usize {
681
        // Calculate total lines in content
682
182
        let total_lines = text.lines.len();
683
684
        // Ratatui terminal coordinates are u16. Saturate previews that exceed that range.
685
182
        let scroll_y = u16::try_from(self.scroll_y).unwrap_or(u16::MAX);
686
182
        let scroll_x = u16::try_from(self.scroll_x).unwrap_or(u16::MAX);
687
182
        let mut paragraph = Paragraph::new(text.clone()).scroll((scroll_y, scroll_x));
688
689
        // Enable wrapping if wrap is true
690
182
        if self.wrap {
  Branch (690:12): [True: 10, False: 169]
 
  Branch (690:12): [True: 0, False: 3]
-
691
10
            paragraph = paragraph.wrap(ratatui::widgets::Wrap { trim: false });
692
185
        }
693
694
        // Add scroll position indicator at top-right if scrolled
695
195
        if self.scroll_y > 0 && 
total_lines > 06
{
  Branch (695:12): [True: 6, False: 186]
+
691
10
            paragraph = paragraph.wrap(ratatui::widgets::Wrap { trim: false });
692
172
        }
693
694
        // Add scroll position indicator at top-right if scrolled
695
182
        if self.scroll_y > 0 && 
total_lines > 06
{
  Branch (695:12): [True: 6, False: 173]
   Branch (695:33): [True: 6, False: 0]
 
  Branch (695:12): [True: 0, False: 3]
   Branch (695:33): [True: 0, False: 0]
-
696
6
            let current_line = self.scroll_y.saturating_add(1); // Display line numbers are 1-indexed.
697
6
            let title = format!("{current_line}/{total_lines}");
698
699
6
            outer = outer.title_top(Line::from(title).alignment(Alignment::Right).reversed());
700
189
        }
701
702
195
        paragraph = paragraph.block(outer);
703
195
        paragraph.render(area, buf);
704
195
        total_lines
705
195
    }
706
707
48
    fn render_pty(
708
48
        &self,
709
48
        mut outer: Block,
710
48
        area: ratatui::layout::Rect,
711
48
        buf: &mut ratatui::prelude::Buffer,
712
48
        parser: &std::sync::RwLock<tui_term::vt100::Parser>,
713
48
    ) -> usize {
714
48
        let mut total_lines = 0usize;
715
        // For terminal content, manipulate scrollback to implement scrolling
716
48
        if let Ok(mut parser_guard) = parser.try_write() {
  Branch (716:16): [True: 48, False: 0]
+
696
6
            let current_line = self.scroll_y.saturating_add(1); // Display line numbers are 1-indexed.
697
6
            let title = format!("{current_line}/{total_lines}");
698
699
6
            outer = outer.title_top(Line::from(title).alignment(Alignment::Right).reversed());
700
176
        }
701
702
182
        paragraph = paragraph.block(outer);
703
182
        paragraph.render(area, buf);
704
182
        total_lines
705
182
    }
706
707
48
    fn render_pty(
708
48
        &self,
709
48
        mut outer: Block,
710
48
        area: ratatui::layout::Rect,
711
48
        buf: &mut ratatui::prelude::Buffer,
712
48
        parser: &std::sync::RwLock<tui_term::vt100::Parser>,
713
48
    ) -> usize {
714
48
        let mut total_lines = 0usize;
715
        // For terminal content, manipulate scrollback to implement scrolling
716
48
        if let Ok(mut parser_guard) = parser.try_write() {
  Branch (716:16): [True: 48, False: 0]
 
  Branch (716:16): [True: 0, False: 0]
 
717
48
            let scrollback_len = parser_guard.screen().scrollback();
718
            // Reset scrollback to its full size first
719
48
            parser_guard.screen_mut().set_scrollback(VT_SCROLLBACK);
720
            // If the scrollback is not empty, we seem to be off by one
721
48
            total_lines = scrollback_len.saturating_sub(1) + parser_guard.screen().contents().lines().count();
722
48
            if self.scroll_y > 0 {
  Branch (722:16): [True: 6, False: 42]
 
  Branch (722:16): [True: 0, False: 0]
@@ -667,22 +667,22 @@
   Branch (772:84): [True: 0, False: 0]
 
773
4
            match Self::image_protocol(self.image_picker.as_ref(), source.clone(), image_size) {
774
                Ok(new_protocol) => {
775
4
                    *protocol = Some(new_protocol);
776
4
                    *size = image_size;
777
                }
778
                Err(err) => {
779
0
                    warn!("failed to render image preview: {err:?}");
780
                }
781
            }
782
3
        }
783
7
        if let Some(protocol) = protocol {
  Branch (783:16): [True: 7, False: 0]
 
  Branch (783:16): [True: 0, False: 0]
-
784
7
            let image = ratatui_image::Image::new(protocol).allow_clipping(true);
785
7
            image.render(inner, buf);
786
0
        }
787
7
    }
788
}
789
790
impl Drop for Preview {
791
556
    fn drop(&mut self) {
792
556
        if let Some(pty) = self.pty.take() {
  Branch (792:16): [True: 13, False: 375]
+
784
7
            let image = ratatui_image::Image::new(protocol).allow_clipping(true);
785
7
            image.render(inner, buf);
786
0
        }
787
7
    }
788
}
789
790
impl Drop for Preview {
791
555
    fn drop(&mut self) {
792
555
        if let Some(pty) = self.pty.take() {
  Branch (792:16): [True: 14, False: 373]
 
  Branch (792:16): [True: 0, False: 168]
-
793
13
            let w = pty.master.take_writer();
794
13
            drop(w);
795
543
        }
796
556
        self.kill();
797
556
    }
798
}
799
800
impl SkimWidget for Preview {
801
556
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
802
        #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
803
556
        let mut res = Self {
804
556
            theme,
805
556
            border: options.border,
806
556
            direction: options.preview_window.direction,
807
556
            wrap: options.preview_window.wrap,
808
556
            content: Arc::new(RwLock::new(PreviewContent::default())),
809
556
            cmd: Default::default(),
810
556
            rows: 0,
811
556
            cols: 0,
812
556
            scroll_y: 0,
813
556
            scroll_x: 0,
814
556
            thread_handle: None,
815
556
            interrupt_tx: None,
816
556
            plain_child: None,
817
556
            plain_cancelled: None,
818
556
            pty: None,
819
556
            pty_child: None,
820
556
            #[cfg(feature = "image")]
821
556
            image: options.image.is_some(),
822
556
            #[cfg(feature = "image")]
823
556
            image_picker: options.image_picker.clone(),
824
556
            total_lines: 0,
825
556
            loading: false,
826
556
            spinner_start: Instant::now(),
827
556
        };
828
        #[cfg(target_os = "linux")]
829
556
        if options.preview_window.pty {
  Branch (829:12): [True: 13, False: 375]
+
793
14
            let w = pty.master.take_writer();
794
14
            drop(w);
795
541
        }
796
555
        self.kill();
797
555
    }
798
}
799
800
impl SkimWidget for Preview {
801
555
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
802
        #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
803
555
        let mut res = Self {
804
555
            theme,
805
555
            border: options.border,
806
555
            direction: options.preview_window.direction,
807
555
            wrap: options.preview_window.wrap,
808
555
            content: Arc::new(RwLock::new(PreviewContent::default())),
809
555
            cmd: Default::default(),
810
555
            rows: 0,
811
555
            cols: 0,
812
555
            scroll_y: 0,
813
555
            scroll_x: 0,
814
555
            thread_handle: None,
815
555
            interrupt_tx: None,
816
555
            plain_child: None,
817
555
            plain_cancelled: None,
818
555
            pty: None,
819
555
            pty_child: None,
820
555
            #[cfg(feature = "image")]
821
555
            image: options.image.is_some(),
822
555
            #[cfg(feature = "image")]
823
555
            image_picker: options.image_picker.clone(),
824
555
            total_lines: 0,
825
555
            loading: false,
826
555
            spinner_start: Instant::now(),
827
555
        };
828
        #[cfg(target_os = "linux")]
829
555
        if options.preview_window.pty {
  Branch (829:12): [True: 14, False: 373]
 
  Branch (829:12): [True: 0, False: 168]
-
830
13
            res.init_pty();
831
543
        }
832
556
        res
833
556
    }
834
835
249
    fn render(&mut self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) -> SkimRender {
836
249
        if self.rows != area.height || 
self.cols != area.width202
{
  Branch (836:12): [True: 45, False: 202]
-  Branch (836:40): [True: 0, False: 202]
+
830
14
            res.init_pty();
831
541
        }
832
555
        res
833
555
    }
834
835
236
    fn render(&mut self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) -> SkimRender {
836
236
        if self.rows != area.height || 
self.cols != area.width193
{
  Branch (836:12): [True: 41, False: 193]
+  Branch (836:40): [True: 0, False: 193]
 
  Branch (836:12): [True: 2, False: 0]
   Branch (836:40): [True: 0, False: 0]
-
837
47
            self.rows = area.height;
838
47
            self.cols = area.width;
839
47
            self.pty.as_ref().map(|p| 
{13
840
13
                p.master.resize(PtySize {
841
13
                    rows: self.rows,
842
13
                    cols: self.cols,
843
13
                    ..Default::default()
844
13
                })
845
13
            });
846
202
        }
847
249
        let Ok(mut content) = self.content.try_write() else {
  Branch (847:13): [True: 247, False: 0]
+
837
43
            self.rows = area.height;
838
43
            self.cols = area.width;
839
43
            self.pty.as_ref().map(|p| 
{14
840
14
                p.master.resize(PtySize {
841
14
                    rows: self.rows,
842
14
                    cols: self.cols,
843
14
                    ..Default::default()
844
14
                })
845
14
            });
846
193
        }
847
236
        let Ok(mut content) = self.content.try_write() else {
  Branch (847:13): [True: 234, False: 0]
 
  Branch (847:13): [True: 2, False: 0]
-
848
0
            return SkimRender::default();
849
        };
850
851
249
        let mut block = Block::new().style(self.theme.normal).border_style(self.theme.border);
852
853
        // Add borders based on direction and border setting
854
249
        if let Some(border_type) = self.border.into_ratatui() {
  Branch (854:16): [True: 0, False: 247]
+
848
0
            return SkimRender::default();
849
        };
850
851
236
        let mut block = Block::new().style(self.theme.normal).border_style(self.theme.border);
852
853
        // Add borders based on direction and border setting
854
236
        if let Some(border_type) = self.border.into_ratatui() {
  Branch (854:16): [True: 0, False: 234]
 
  Branch (854:16): [True: 0, False: 2]
-
855
0
            block = block.borders(Borders::ALL).border_type(border_type);
856
        } else {
857
            // No border on preview itself - separator will be drawn between areas
858
249
            match self.direction {
859
20
                Direction::Up => block = block.borders(Borders::BOTTOM),
860
22
                Direction::Down => block = block.borders(Borders::TOP),
861
44
                Direction::Left => block = block.borders(Borders::RIGHT),
862
163
                Direction::Right => block = block.borders(Borders::LEFT),
863
            }
864
        }
865
866
249
        Clear.render(area, buf);
867
249
        let spinner_area = block.inner(area);
868
869
249
        match &mut *content {
870
194
            PreviewContent::Text(text) => self.total_lines = self.render_text(block, area, buf, text),
871
48
            PreviewContent::Terminal(parser) => self.total_lines = self.render_pty(block, area, buf, parser.as_ref()),
872
            #[cfg(feature = "image")]
873
            PreviewContent::Image { source, protocol, size } => {
874
7
                self.render_image(block, area, buf, source, protocol, size);
875
            }
876
        }
877
878
249
        if self.loading && 
spinner_area.width > 010
&&
spinner_area.height > 010
{
  Branch (878:12): [True: 10, False: 237]
-  Branch (878:28): [True: 10, False: 0]
-  Branch (878:54): [True: 10, False: 0]
+
855
0
            block = block.borders(Borders::ALL).border_type(border_type);
856
        } else {
857
            // No border on preview itself - separator will be drawn between areas
858
236
            match self.direction {
859
20
                Direction::Up => block = block.borders(Borders::BOTTOM),
860
16
                Direction::Down => block = block.borders(Borders::TOP),
861
44
                Direction::Left => block = block.borders(Borders::RIGHT),
862
156
                Direction::Right => block = block.borders(Borders::LEFT),
863
            }
864
        }
865
866
236
        Clear.render(area, buf);
867
236
        let spinner_area = block.inner(area);
868
869
236
        match &mut *content {
870
181
            PreviewContent::Text(text) => self.total_lines = self.render_text(block, area, buf, text),
871
48
            PreviewContent::Terminal(parser) => self.total_lines = self.render_pty(block, area, buf, parser.as_ref()),
872
            #[cfg(feature = "image")]
873
            PreviewContent::Image { source, protocol, size } => {
874
7
                self.render_image(block, area, buf, source, protocol, size);
875
            }
876
        }
877
878
236
        if self.loading && 
spinner_area.width > 09
&&
spinner_area.height > 09
{
  Branch (878:12): [True: 9, False: 225]
+  Branch (878:28): [True: 9, False: 0]
+  Branch (878:54): [True: 9, False: 0]
 
  Branch (878:12): [True: 0, False: 2]
   Branch (878:28): [True: 0, False: 0]
   Branch (878:54): [True: 0, False: 0]
-
879
10
            let x = spinner_area.x + spinner_area.width.saturating_sub(1);
880
10
            let y = spinner_area.y + spinner_area.height.saturating_sub(1);
881
10
            buf.set_string(x, y, spinner_char(self.spinner_start).to_string(), self.theme.spinner);
882
239
        }
883
884
249
        SkimRender::default()
885
249
    }
886
}
887
888
#[cfg(test)]
889
#[path = "preview_tests.rs"]
890
mod tests;
\ No newline at end of file +
879
9
            let x = spinner_area.x + spinner_area.width.saturating_sub(1);
880
9
            let y = spinner_area.y + spinner_area.height.saturating_sub(1);
881
9
            buf.set_string(x, y, spinner_char(self.spinner_start).to_string(), self.theme.spinner);
882
227
        }
883
884
236
        SkimRender::default()
885
236
    }
886
}
887
888
#[cfg(test)]
889
#[path = "preview_tests.rs"]
890
mod tests;
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/statusline.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/statusline.rs.html index 60719977..e51485b4 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/tui/statusline.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/statusline.rs.html @@ -1,5 +1,5 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/statusline.rs
Line
Count
Source
1
use std::time::Instant;
2
3
/// Default inline info separator
4
pub const DEFAULT_SEPARATOR: &str = "  < ";
5
pub(crate) const SPINNER_DURATION: u32 = 200;
6
pub(crate) const SPINNERS_UNICODE: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
7
8
67
pub(crate) fn spinner_char(start: Instant) -> char {
9
67
    let spinner_elapsed_ms = start.elapsed().as_millis();
10
67
    let index = ((spinner_elapsed_ms / u128::from(SPINNER_DURATION)) % (SPINNERS_UNICODE.len() as u128)) as usize;
11
67
    SPINNERS_UNICODE[index]
12
67
}
13
14
/// Simplified display mode for the info/status line
15
#[derive(Debug, Clone, Default, Eq, PartialEq)]
16
pub enum InfoDisplay {
17
    /// Display info in a separate line (default)
18
    #[default]
19
    Default,
20
    /// Display all info in a separate line, left-aligned
21
    Left,
22
    /// Display all info in a separate line, right-aligned
23
    Right,
24
    /// Display info inline with the input
25
    Inline,
26
    /// Hide the info display
27
    Hidden,
28
    /// Inline and right-aligned
29
    InlineRight,
30
}
31
impl InfoDisplay {
32
500
    pub(crate) fn is_inline(&self) -> bool {
33
500
        matches!(self, InfoDisplay::Inline | InfoDisplay::InlineRight)
34
500
    }
35
}
36
37
/// Full display mode for the info/status line
38
#[derive(Debug, Clone, Default, Eq, PartialEq)]
39
pub struct Info {
40
    /// The `InfoDisplay`
41
    pub display: InfoDisplay,
42
    /// The separator, specified if the display is inline
43
    pub separator: Option<String>,
44
}
45
46
impl Info {
47
2.68k
    pub(crate) fn separator(&self) -> Option<&str> {
48
2.68k
        self.separator.as_deref()
49
2.68k
    }
50
}
51
52
impl From<InfoDisplay> for Info {
53
6
    fn from(value: InfoDisplay) -> Self {
54
6
        let is_inline = value.is_inline();
55
        Self {
56
6
            display: value,
57
6
            separator: if is_inline {
  Branch (57:27): [True: 0, False: 0]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/statusline.rs
Line
Count
Source
1
use std::time::Instant;
2
3
/// Default inline info separator
4
pub const DEFAULT_SEPARATOR: &str = "  < ";
5
pub(crate) const SPINNER_DURATION: u32 = 200;
6
pub(crate) const SPINNERS_UNICODE: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
7
8
72
pub(crate) fn spinner_char(start: Instant) -> char {
9
72
    let spinner_elapsed_ms = start.elapsed().as_millis();
10
72
    let index = ((spinner_elapsed_ms / u128::from(SPINNER_DURATION)) % (SPINNERS_UNICODE.len() as u128)) as usize;
11
72
    SPINNERS_UNICODE[index]
12
72
}
13
14
/// Simplified display mode for the info/status line
15
#[derive(Debug, Clone, Default, Eq, PartialEq)]
16
pub enum InfoDisplay {
17
    /// Display info in a separate line (default)
18
    #[default]
19
    Default,
20
    /// Display all info in a separate line, left-aligned
21
    Left,
22
    /// Display all info in a separate line, right-aligned
23
    Right,
24
    /// Display info inline with the input
25
    Inline,
26
    /// Hide the info display
27
    Hidden,
28
    /// Inline and right-aligned
29
    InlineRight,
30
}
31
impl InfoDisplay {
32
499
    pub(crate) fn is_inline(&self) -> bool {
33
499
        matches!(self, InfoDisplay::Inline | InfoDisplay::InlineRight)
34
499
    }
35
}
36
37
/// Full display mode for the info/status line
38
#[derive(Debug, Clone, Default, Eq, PartialEq)]
39
pub struct Info {
40
    /// The `InfoDisplay`
41
    pub display: InfoDisplay,
42
    /// The separator, specified if the display is inline
43
    pub separator: Option<String>,
44
}
45
46
impl Info {
47
2.68k
    pub(crate) fn separator(&self) -> Option<&str> {
48
2.68k
        self.separator.as_deref()
49
2.68k
    }
50
}
51
52
impl From<InfoDisplay> for Info {
53
6
    fn from(value: InfoDisplay) -> Self {
54
6
        let is_inline = value.is_inline();
55
        Self {
56
6
            display: value,
57
6
            separator: if is_inline {
  Branch (57:27): [True: 0, False: 0]
 
  Branch (57:27): [True: 2, False: 4]
-
58
2
                Some(String::from(DEFAULT_SEPARATOR))
59
            } else {
60
4
                None
61
            },
62
        }
63
6
    }
64
}
65
66
impl From<&str> for Info {
67
489
    fn from(s: &str) -> Self {
68
        use InfoDisplay::{Default, Hidden, Inline, InlineRight, Left, Right};
69
489
        let mut parts = s.split(':');
70
71
489
        let display = match parts.next() {
72
462
            None | Some("default") => Default,
73
2
            Some("left") => Left,
74
3
            Some("right") => Right,
75
5
            Some("inline") => Inline,
76
3
            Some("inline-right") => InlineRight,
77
13
            Some("hidden") => Hidden,
78
1
            Some(x) => panic!(
79
                "Failed to parse {x} as an InfoDisplay. Possible options are `default`, `left`, `right`, `inline`, `inline-right` or `hidden`"
80
            ),
81
        };
82
488
        let separator = if display.is_inline() {
  Branch (82:28): [True: 4, False: 464]
+
58
2
                Some(String::from(DEFAULT_SEPARATOR))
59
            } else {
60
4
                None
61
            },
62
        }
63
6
    }
64
}
65
66
impl From<&str> for Info {
67
488
    fn from(s: &str) -> Self {
68
        use InfoDisplay::{Default, Hidden, Inline, InlineRight, Left, Right};
69
488
        let mut parts = s.split(':');
70
71
488
        let display = match parts.next() {
72
462
            None | Some("default") => Default,
73
2
            Some("left") => Left,
74
2
            Some("right") => Right,
75
5
            Some("inline") => Inline,
76
3
            Some("inline-right") => InlineRight,
77
13
            Some("hidden") => Hidden,
78
1
            Some(x) => panic!(
79
                "Failed to parse {x} as an InfoDisplay. Possible options are `default`, `left`, `right`, `inline`, `inline-right` or `hidden`"
80
            ),
81
        };
82
487
        let separator = if display.is_inline() {
  Branch (82:28): [True: 4, False: 463]
 
  Branch (82:28): [True: 4, False: 16]
-
83
8
            parts.next().or(Some(DEFAULT_SEPARATOR)).map(String::from)
84
        } else {
85
480
            None
86
        };
87
488
        Self { display, separator }
88
488
    }
89
}
90
91
#[cfg(test)]
92
#[cfg_attr(coverage, coverage(off))]
93
mod tests {
94
    use super::*;
95
96
    #[test]
97
    fn spinner_char_returns_first_frame_immediately() {
98
        // No elapsed time → index 0.
99
        assert_eq!(spinner_char(Instant::now()), SPINNERS_UNICODE[0]);
100
    }
101
102
    #[test]
103
    fn info_display_is_inline() {
104
        assert!(InfoDisplay::Inline.is_inline());
105
        assert!(InfoDisplay::InlineRight.is_inline());
106
        assert!(!InfoDisplay::Default.is_inline());
107
        assert!(!InfoDisplay::Left.is_inline());
108
        assert!(!InfoDisplay::Right.is_inline());
109
        assert!(!InfoDisplay::Hidden.is_inline());
110
    }
111
112
    #[test]
113
    fn info_from_display_sets_separator_only_when_inline() {
114
        let inline = Info::from(InfoDisplay::Inline);
115
        assert_eq!(inline.separator(), Some(DEFAULT_SEPARATOR));
116
117
        let right = Info::from(InfoDisplay::InlineRight);
118
        assert_eq!(right.separator(), Some(DEFAULT_SEPARATOR));
119
120
        let default = Info::from(InfoDisplay::Default);
121
        assert_eq!(default.separator(), None);
122
123
        let left = Info::from(InfoDisplay::Left);
124
        assert_eq!(left.separator(), None);
125
126
        let right = Info::from(InfoDisplay::Right);
127
        assert_eq!(right.separator(), None);
128
129
        let hidden = Info::from(InfoDisplay::Hidden);
130
        assert_eq!(hidden.separator(), None);
131
    }
132
133
    #[test]
134
    fn info_from_str_parses_each_mode() {
135
        assert_eq!(Info::from("default").display, InfoDisplay::Default);
136
        assert_eq!(Info::from("left").display, InfoDisplay::Left);
137
        assert_eq!(Info::from("right").display, InfoDisplay::Right);
138
        assert_eq!(Info::from("inline").display, InfoDisplay::Inline);
139
        assert_eq!(Info::from("inline-right").display, InfoDisplay::InlineRight);
140
        assert_eq!(Info::from("hidden").display, InfoDisplay::Hidden);
141
    }
142
143
    #[test]
144
    fn info_from_str_uses_custom_and_default_separator() {
145
        // Inline with an explicit separator after the colon.
146
        assert_eq!(Info::from("inline: | ").separator(), Some(" | "));
147
        // Inline without a separator falls back to the default.
148
        assert_eq!(Info::from("inline").separator(), Some(DEFAULT_SEPARATOR));
149
        // Non-inline modes never carry a separator.
150
        assert_eq!(Info::from("hidden").separator(), None);
151
    }
152
153
    #[test]
154
    #[should_panic(expected = "Failed to parse")]
155
    fn info_from_str_panics_on_unknown_mode() {
156
        let _ = Info::from("bogus");
157
    }
158
}
\ No newline at end of file +
83
8
            parts.next().or(Some(DEFAULT_SEPARATOR)).map(String::from)
84
        } else {
85
479
            None
86
        };
87
487
        Self { display, separator }
88
487
    }
89
}
90
91
#[cfg(test)]
92
#[cfg_attr(coverage, coverage(off))]
93
mod tests {
94
    use super::*;
95
96
    #[test]
97
    fn spinner_char_returns_first_frame_immediately() {
98
        // No elapsed time → index 0.
99
        assert_eq!(spinner_char(Instant::now()), SPINNERS_UNICODE[0]);
100
    }
101
102
    #[test]
103
    fn info_display_is_inline() {
104
        assert!(InfoDisplay::Inline.is_inline());
105
        assert!(InfoDisplay::InlineRight.is_inline());
106
        assert!(!InfoDisplay::Default.is_inline());
107
        assert!(!InfoDisplay::Left.is_inline());
108
        assert!(!InfoDisplay::Right.is_inline());
109
        assert!(!InfoDisplay::Hidden.is_inline());
110
    }
111
112
    #[test]
113
    fn info_from_display_sets_separator_only_when_inline() {
114
        let inline = Info::from(InfoDisplay::Inline);
115
        assert_eq!(inline.separator(), Some(DEFAULT_SEPARATOR));
116
117
        let right = Info::from(InfoDisplay::InlineRight);
118
        assert_eq!(right.separator(), Some(DEFAULT_SEPARATOR));
119
120
        let default = Info::from(InfoDisplay::Default);
121
        assert_eq!(default.separator(), None);
122
123
        let left = Info::from(InfoDisplay::Left);
124
        assert_eq!(left.separator(), None);
125
126
        let right = Info::from(InfoDisplay::Right);
127
        assert_eq!(right.separator(), None);
128
129
        let hidden = Info::from(InfoDisplay::Hidden);
130
        assert_eq!(hidden.separator(), None);
131
    }
132
133
    #[test]
134
    fn info_from_str_parses_each_mode() {
135
        assert_eq!(Info::from("default").display, InfoDisplay::Default);
136
        assert_eq!(Info::from("left").display, InfoDisplay::Left);
137
        assert_eq!(Info::from("right").display, InfoDisplay::Right);
138
        assert_eq!(Info::from("inline").display, InfoDisplay::Inline);
139
        assert_eq!(Info::from("inline-right").display, InfoDisplay::InlineRight);
140
        assert_eq!(Info::from("hidden").display, InfoDisplay::Hidden);
141
    }
142
143
    #[test]
144
    fn info_from_str_uses_custom_and_default_separator() {
145
        // Inline with an explicit separator after the colon.
146
        assert_eq!(Info::from("inline: | ").separator(), Some(" | "));
147
        // Inline without a separator falls back to the default.
148
        assert_eq!(Info::from("inline").separator(), Some(DEFAULT_SEPARATOR));
149
        // Non-inline modes never carry a separator.
150
        assert_eq!(Info::from("hidden").separator(), None);
151
    }
152
153
    #[test]
154
    #[should_panic(expected = "Failed to parse")]
155
    fn info_from_str_panics_on_unknown_mode() {
156
        let _ = Info::from("bogus");
157
    }
158
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/util.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/util.rs.html index cb8a9123..096a86cb 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/tui/util.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/util.rs.html @@ -1,9 +1,9 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/util.rs
Line
Count
Source
1
use crossterm::terminal;
2
use ratatui::style::Style;
3
use ratatui::text::{Line, Span, Text};
4
use std::io::{self, Write};
5
#[cfg(unix)]
6
use std::{
7
    fs::OpenOptions,
8
    io::Read,
9
    os::fd::{AsFd, AsRawFd},
10
    os::unix::fs::OpenOptionsExt as _,
11
};
12
use unicode_display_width::is_double_width;
13
14
/// Clips a [`Line`] to at most `max_chars` characters, preserving per-span styles.
15
///
16
/// Iterates over the spans in `line` and collects characters until `max_chars`
17
/// is reached, splitting a span at the boundary if necessary.  The returned
18
/// line owns all its string data (`Line<'static>`), so it can be stored or
19
/// passed across lifetimes freely.
20
///
21
/// This is the shared primitive used whenever a fully-displayed item line
22
/// (potentially with ANSI colours or match highlights) must be clipped to the
23
/// character count of a single multiline sub-segment — for example, when
24
/// rendering the first sub-line of a `--multiline` item in both the item list
25
/// and the header-lines area.
26
676
pub(crate) fn clip_line_to_chars(line: Line<'_>, max_chars: usize) -> Line<'static> {
27
676
    let mut chars_seen = 0usize;
28
676
    let mut clipped: Vec<Span<'static>> = Vec::new();
29
676
    for span in line.spans {
30
2.02k
        if chars_seen >= max_chars {
  Branch (30:12): [True: 13, False: 2.00k]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/util.rs
Line
Count
Source
1
use crossterm::terminal;
2
use ratatui::style::Style;
3
use ratatui::text::{Line, Span, Text};
4
use std::io::{self, Write};
5
#[cfg(unix)]
6
use std::{
7
    fs::OpenOptions,
8
    io::Read,
9
    os::fd::{AsFd, AsRawFd},
10
    os::unix::fs::OpenOptionsExt as _,
11
};
12
use unicode_display_width::is_double_width;
13
14
/// Clips a [`Line`] to at most `max_chars` characters, preserving per-span styles.
15
///
16
/// Iterates over the spans in `line` and collects characters until `max_chars`
17
/// is reached, splitting a span at the boundary if necessary.  The returned
18
/// line owns all its string data (`Line<'static>`), so it can be stored or
19
/// passed across lifetimes freely.
20
///
21
/// This is the shared primitive used whenever a fully-displayed item line
22
/// (potentially with ANSI colours or match highlights) must be clipped to the
23
/// character count of a single multiline sub-segment — for example, when
24
/// rendering the first sub-line of a `--multiline` item in both the item list
25
/// and the header-lines area.
26
676
pub(crate) fn clip_line_to_chars(line: Line<'_>, max_chars: usize) -> Line<'static> {
27
676
    let mut chars_seen = 0usize;
28
676
    let mut clipped: Vec<Span<'static>> = Vec::new();
29
676
    for span in line.spans {
30
2.02k
        if chars_seen >= max_chars {
  Branch (30:12): [True: 13, False: 2.00k]
 
  Branch (30:12): [True: 3, False: 8]
 
31
16
            break;
32
2.01k
        }
33
2.01k
        let span_chars: Vec<char> = span.content.chars().collect();
34
2.01k
        let take = (max_chars - chars_seen).min(span_chars.len());
35
2.01k
        let text: String = span_chars[..take].iter().collect();
36
2.01k
        if !text.is_empty() {
  Branch (36:12): [True: 695, False: 1.30k]
 
  Branch (36:12): [True: 8, False: 0]
-
37
703
            clipped.push(Span::styled(text, span.style));
38
1.30k
        }
39
2.01k
        chars_seen += span_chars.len();
40
    }
41
676
    Line::from(clipped)
42
676
}
43
44
// Directly taken from https://docs.rs/unicode-display-width/0.3.0/src/unicode_display_width/lib.rs.html#77-81
45
#[inline]
46
101k
pub fn char_display_width(c: char) -> usize {
47
101k
    if c == '\u{FE0F}' || 
is_double_width(c)101k
{
  Branch (47:8): [True: 0, False: 100k]
-  Branch (47:27): [True: 18, False: 100k]
+
37
703
            clipped.push(Span::styled(text, span.style));
38
1.30k
        }
39
2.01k
        chars_seen += span_chars.len();
40
    }
41
676
    Line::from(clipped)
42
676
}
43
44
// Directly taken from https://docs.rs/unicode-display-width/0.3.0/src/unicode_display_width/lib.rs.html#77-81
45
#[inline]
46
101k
pub fn char_display_width(c: char) -> usize {
47
101k
    if c == '\u{FE0F}' || 
is_double_width(c)101k
{
  Branch (47:8): [True: 0, False: 101k]
+  Branch (47:27): [True: 18, False: 101k]
 
  Branch (47:8): [True: 1, False: 872]
   Branch (47:27): [True: 11, False: 861]
 
48
30
        return 2;
49
101k
    }
50
101k
    1
51
101k
}
52
53
26
pub fn wrap_text(input: Text, width: usize) -> Text {
54
26
    if input.width() <= width {
  Branch (54:8): [True: 3, False: 12]
@@ -16,7 +16,7 @@
 
  Branch (84:16): [True: 11, False: 0]
 
85
47
                curr.content = curr_content.into();
86
47
                current_line.push_span(curr);
87
6
            }
88
        }
89
        // Push remaining line
90
20
        if !current_line.spans.is_empty() {
  Branch (90:12): [True: 12, False: 0]
 
  Branch (90:12): [True: 8, False: 0]
-
91
20
            output.push_line(current_line);
92
0
        }
93
    }
94
95
19
    output
96
26
}
97
98
/// Merges styles from right to left
99
/// left has higher priority
100
/// contrary to ratatui's `Style::patch`, this will override `Reset` with the new style if set
101
3.25k
pub(crate) fn merge_styles(left: Style, right: Style) -> Style {
102
    use ratatui::style::Color::Reset;
103
3.25k
    let mut res = Style::default();
104
    macro_rules! set_field {
105
        ($res:ident, $left:ident, $right:ident, $field:ident) => {
106
            if left.$field == Some(Reset) {
107
                $res.$field = $right.$field;
108
            } else if $right.$field == Some(Reset) {
109
                $res.$field = $left.$field;
110
            } else {
111
                $res.$field = $right.$field.or($left.$field);
112
            }
113
        };
114
    }
115
116
3.25k
    set_field!(res, left, right, fg);
117
3.25k
    set_field!(res, left, right, bg);
118
3.25k
    set_field!(res, left, right, underline_color);
119
3.25k
    res.add_modifier = left.add_modifier | right.add_modifier;
120
121
3.25k
    res
122
3.25k
}
123
124
2.98k
pub(crate) fn style_span(span: &mut Span, style: Style) {
125
2.98k
    span.style = merge_styles(style, span.style);
126
2.98k
}
127
2.97k
pub(crate) fn style_line(line: &mut Line, style: Style) {
128
2.98k
    
line.iter_mut()2.97k
.for_each(|span| style_span(span, style));
129
2.97k
}
130
139
pub(crate) fn style_text(text: &mut Text, style: Style) {
131
151
    
text.iter_mut()139
.for_each(|line| style_line(line, style));
132
139
}
133
134
/// Find the end of an OSC sequence (terminated by ESC \ or BEL)
135
3
pub(crate) fn find_osc_end(data: &[u8]) -> Option<usize> {
136
3
    for i in 2..data.len() {
137
23
        if data[i] == b'\x07' {
  Branch (137:12): [True: 0, False: 0]
+
91
20
            output.push_line(current_line);
92
0
        }
93
    }
94
95
19
    output
96
26
}
97
98
/// Merges styles from right to left
99
/// left has higher priority
100
/// contrary to ratatui's `Style::patch`, this will override `Reset` with the new style if set
101
3.26k
pub(crate) fn merge_styles(left: Style, right: Style) -> Style {
102
    use ratatui::style::Color::Reset;
103
3.26k
    let mut res = Style::default();
104
    macro_rules! set_field {
105
        ($res:ident, $left:ident, $right:ident, $field:ident) => {
106
            if left.$field == Some(Reset) {
107
                $res.$field = $right.$field;
108
            } else if $right.$field == Some(Reset) {
109
                $res.$field = $left.$field;
110
            } else {
111
                $res.$field = $right.$field.or($left.$field);
112
            }
113
        };
114
    }
115
116
3.26k
    set_field!(res, left, right, fg);
117
3.26k
    set_field!(res, left, right, bg);
118
3.26k
    set_field!(res, left, right, underline_color);
119
3.26k
    res.add_modifier = left.add_modifier | right.add_modifier;
120
121
3.26k
    res
122
3.26k
}
123
124
2.99k
pub(crate) fn style_span(span: &mut Span, style: Style) {
125
2.99k
    span.style = merge_styles(style, span.style);
126
2.99k
}
127
2.99k
pub(crate) fn style_line(line: &mut Line, style: Style) {
128
2.99k
    
line.iter_mut()2.99k
.for_each(|span| style_span(span, style));
129
2.99k
}
130
145
pub(crate) fn style_text(text: &mut Text, style: Style) {
131
157
    
text.iter_mut()145
.for_each(|line| style_line(line, style));
132
145
}
133
134
/// Find the end of an OSC sequence (terminated by ESC \ or BEL)
135
3
pub(crate) fn find_osc_end(data: &[u8]) -> Option<usize> {
136
3
    for i in 2..data.len() {
137
23
        if data[i] == b'\x07' {
  Branch (137:12): [True: 0, False: 0]
 
  Branch (137:12): [True: 1, False: 22]
 
138
            // BEL terminator
139
1
            return Some(i + 1);
140
22
        }
141
22
        if i + 1 < data.len() && 
data[i] == b'\x1b'21
&&
data1
[i + 1] == b'\\' {
  Branch (141:12): [True: 0, False: 0]
   Branch (141:34): [True: 0, False: 0]
diff --git a/coverage/coverage/home/runner/work/skim/skim/src/tui/widget.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/tui/widget.rs.html
index 88a6cc47..44eabf1e 100644
--- a/coverage/coverage/home/runner/work/skim/skim/src/tui/widget.rs.html
+++ b/coverage/coverage/home/runner/work/skim/skim/src/tui/widget.rs.html
@@ -1 +1 @@
-

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/widget.rs
Line
Count
Source
1
use ratatui::buffer::Buffer;
2
use ratatui::layout::Rect;
3
use std::ops::BitOrAssign;
4
use std::sync::Arc;
5
6
use crate::options::SkimOptions;
7
use crate::theme::ColorTheme;
8
9
/// Result of rendering a `SkimWidget`
10
#[derive(Debug, Clone, Copy, Default)]
11
pub struct SkimRender {
12
    /// Whether the items in the list have been updated
13
    pub items_updated: bool,
14
    /// Whether or not we need to reload the preview
15
    pub run_preview: bool,
16
}
17
18
impl BitOrAssign for SkimRender {
19
5.80k
    fn bitor_assign(&mut self, rhs: Self) {
20
5.80k
        self.items_updated |= rhs.items_updated;
21
5.80k
        self.run_preview |= rhs.run_preview;
22
5.80k
    }
23
}
24
25
/// Trait for Skim TUI widgets
26
pub trait SkimWidget: Sized {
27
    /// Create a widget from options and theme
28
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self;
29
30
    /// Render the widget to the buffer
31
    fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender;
32
33
    /// Create a widget with default options and theme
34
    ///
35
    /// This is made to be reused from a blanket `std::default::Default` trait impl
36
    #[must_use]
37
146
    fn _default() -> Self {
38
146
        Self::from_options(&SkimOptions::default(), Arc::new(ColorTheme::default()))
39
146
    }
40
}
\ No newline at end of file +

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/tui/widget.rs
Line
Count
Source
1
use ratatui::buffer::Buffer;
2
use ratatui::layout::Rect;
3
use std::ops::BitOrAssign;
4
use std::sync::Arc;
5
6
use crate::options::SkimOptions;
7
use crate::theme::ColorTheme;
8
9
/// Result of rendering a `SkimWidget`
10
#[derive(Debug, Clone, Copy, Default)]
11
pub struct SkimRender {
12
    /// Whether the items in the list have been updated
13
    pub items_updated: bool,
14
    /// Whether or not we need to reload the preview
15
    pub run_preview: bool,
16
}
17
18
impl BitOrAssign for SkimRender {
19
5.81k
    fn bitor_assign(&mut self, rhs: Self) {
20
5.81k
        self.items_updated |= rhs.items_updated;
21
5.81k
        self.run_preview |= rhs.run_preview;
22
5.81k
    }
23
}
24
25
/// Trait for Skim TUI widgets
26
pub trait SkimWidget: Sized {
27
    /// Create a widget from options and theme
28
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self;
29
30
    /// Render the widget to the buffer
31
    fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender;
32
33
    /// Create a widget with default options and theme
34
    ///
35
    /// This is made to be reused from a blanket `std::default::Default` trait impl
36
    #[must_use]
37
147
    fn _default() -> Self {
38
147
        Self::from_options(&SkimOptions::default(), Arc::new(ColorTheme::default()))
39
147
    }
40
}
\ No newline at end of file diff --git a/coverage/coverage/home/runner/work/skim/skim/src/util.rs.html b/coverage/coverage/home/runner/work/skim/skim/src/util.rs.html index 2331fe1a..4d502d95 100644 --- a/coverage/coverage/home/runner/work/skim/skim/src/util.rs.html +++ b/coverage/coverage/home/runner/work/skim/skim/src/util.rs.html @@ -1,85 +1,85 @@ -

Coverage Report

Created: 2026-09-08 12:09

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/util.rs
Line
Count
Source
1
use crate::field::{FieldRange, get_string_by_field};
2
use crate::helper::item::strip_ansi;
3
use crate::item::MatchedItem;
4
use regex::Regex;
5
use std::fmt::Write as _;
6
use std::fs::File;
7
use std::io::{BufRead, BufReader};
8
use std::prelude::v1::*;
9
10
#[cfg(feature = "cli")]
11
/// Unescape a delimiter string to handle escape sequences like \x00, \t, \n, etc.
12
///
13
/// Supported escape sequences:
14
/// - `\x00` - `\xff`: hexadecimal byte values
15
/// - `\t`: tab
16
/// - `\n`: newline
17
/// - `\r`: carriage return
18
/// - `\\`: backslash
19
///
20
/// # Examples
21
///
22
/// ```ignore
23
/// use skim::util::unescape_delimiter;
24
///
25
/// assert_eq!(unescape_delimiter(r"\x00"), "\0");
26
/// assert_eq!(unescape_delimiter(r"\t"), "\t");
27
/// assert_eq!(unescape_delimiter(r"\n"), "\n");
28
/// assert_eq!(unescape_delimiter(r"\\"), "\\");
29
/// ```
30
493
pub fn unescape_delimiter(s: &str) -> String {
31
493
    let mut result = String::new();
32
493
    let mut chars = s.chars();
33
34
3.16k
    while let Some(c) = chars.next() {
  Branch (34:15): [True: 2.58k, False: 470]
+

Coverage Report

Created: 2026-09-08 14:37

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/skim/skim/src/util.rs
Line
Count
Source
1
use crate::field::{FieldRange, get_string_by_field};
2
use crate::helper::item::strip_ansi;
3
use crate::item::MatchedItem;
4
use regex::Regex;
5
use std::fmt::Write as _;
6
use std::fs::File;
7
use std::io::{BufRead, BufReader};
8
use std::prelude::v1::*;
9
10
#[cfg(feature = "cli")]
11
/// Unescape a delimiter string to handle escape sequences like \x00, \t, \n, etc.
12
///
13
/// Supported escape sequences:
14
/// - `\x00` - `\xff`: hexadecimal byte values
15
/// - `\t`: tab
16
/// - `\n`: newline
17
/// - `\r`: carriage return
18
/// - `\\`: backslash
19
///
20
/// # Examples
21
///
22
/// ```ignore
23
/// use skim::util::unescape_delimiter;
24
///
25
/// assert_eq!(unescape_delimiter(r"\x00"), "\0");
26
/// assert_eq!(unescape_delimiter(r"\t"), "\t");
27
/// assert_eq!(unescape_delimiter(r"\n"), "\n");
28
/// assert_eq!(unescape_delimiter(r"\\"), "\\");
29
/// ```
30
492
pub fn unescape_delimiter(s: &str) -> String {
31
492
    let mut result = String::new();
32
492
    let mut chars = s.chars();
33
34
3.17k
    while let Some(c) = chars.next() {
  Branch (34:15): [True: 2.58k, False: 469]
 
  Branch (34:15): [True: 89, False: 23]
-
35
2.67k
        if c == '\\' {
  Branch (35:12): [True: 849, False: 1.73k]
+
35
2.67k
        if c == '\\' {
  Branch (35:12): [True: 851, False: 1.73k]
 
  Branch (35:12): [True: 35, False: 54]
-
36
884
            match chars.next() {
37
                Some('x') => {
38
                    // Handle \xNN hex escape
39
9
                    let hex: String = chars.by_ref().take(2).collect();
40
9
                    if hex.len() == 2 {
  Branch (40:24): [True: 2, False: 0]
+
36
886
            match chars.next() {
37
                Some('x') => {
38
                    // Handle \xNN hex escape
39
9
                    let hex: String = chars.by_ref().take(2).collect();
40
9
                    if hex.len() == 2 {
  Branch (40:24): [True: 2, False: 0]
 
  Branch (40:24): [True: 6, False: 1]
 
41
8
                        if let Ok(byte) = u8::from_str_radix(&hex, 16) {
  Branch (41:32): [True: 2, False: 0]
 
  Branch (41:32): [True: 5, False: 1]
-
42
                            // For null byte and other non-UTF8 safe bytes, we need to handle carefully
43
                            // Regex works with strings, so we push the byte as a char
44
7
                            result.push(byte as char);
45
                        } else {
46
                            // Invalid hex, keep as literal
47
1
                            result.push('\\');
48
1
                            result.push('x');
49
1
                            result.push_str(&hex);
50
                        }
51
                    } else {
52
                        // Not enough hex digits
53
1
                        result.push('\\');
54
1
                        result.push('x');
55
1
                        result.push_str(&hex);
56
                    }
57
                }
58
436
                Some('t') => result.push('\t'),
59
436
                Some('n') => result.push('\n'),
60
1
                Some('r') => result.push('\r'),
61
1
                Some('\\') | None => result.push('\\'),
62
                Some(other) => {
63
                    // Unknown escape, keep both backslash and character
64
1
                    result.push('\\');
65
1
                    result.push(other);
66
                }
67
            }
68
        } else {
69
1.79k
            result.push(c);
70
        }
71
    }
72
73
493
    result
74
493
}
75
76
6
pub fn read_file_lines(filename: &str) -> std::result::Result<Vec<String>, std::io::Error> {
77
6
    let file = File::open(filename)
?1
;
78
5
    BufReader::new(file).lines().collect()
79
6
}
80
81
/// Replace the fields in `pattern` with the items, expanding {...} patterns
82
///
83
/// Replaces:
84
/// - `{}` -> currently selected item
85
/// - `{1..}` etc -> fields of currently selected item, whose index is `selected`
86
/// - `{+}` -> all selected items (multi-select)
87
/// - `{q}` -> current query
88
/// - `{cq}` -> current command query
89
#[allow(clippy::too_many_arguments)]
90
#[allow(clippy::too_many_lines)]
91
184
pub fn printf<'a>(
92
184
    pattern: &str,
93
184
    delimiter: &Regex,
94
184
    replstr: &str,
95
184
    selected: &(impl Iterator<Item = &'a MatchedItem> + std::clone::Clone),
96
184
    current: &Option<MatchedItem>,
97
184
    query: &str,
98
184
    command_query: &str,
99
184
    mut quote_args: bool,
100
184
) -> String {
101
    // Windows uses different shell quoting conventions (double quotes, caret escaping)
102
    // that are incompatible with the Unix-style single-quote escaping implemented here.
103
184
    if cfg!(windows) {
104
0
        quote_args = false;
105
184
    }
106
606
    let escape_arg = |s: &str, quote: bool| {
107
606
        let mut res = s.replace('\0', "\\0").clone();
108
606
        if quote && 
quote_args591
{
  Branch (108:12): [True: 469, False: 0]
-  Branch (108:21): [True: 435, False: 34]
-
  Branch (108:12): [True: 20, False: 2]
+
42
                            // For null byte and other non-UTF8 safe bytes, we need to handle carefully
43
                            // Regex works with strings, so we push the byte as a char
44
7
                            result.push(byte as char);
45
                        } else {
46
                            // Invalid hex, keep as literal
47
1
                            result.push('\\');
48
1
                            result.push('x');
49
1
                            result.push_str(&hex);
50
                        }
51
                    } else {
52
                        // Not enough hex digits
53
1
                        result.push('\\');
54
1
                        result.push('x');
55
1
                        result.push_str(&hex);
56
                    }
57
                }
58
437
                Some('t') => result.push('\t'),
59
437
                Some('n') => result.push('\n'),
60
1
                Some('r') => result.push('\r'),
61
1
                Some('\\') | None => result.push('\\'),
62
                Some(other) => {
63
                    // Unknown escape, keep both backslash and character
64
1
                    result.push('\\');
65
1
                    result.push(other);
66
                }
67
            }
68
        } else {
69
1.79k
            result.push(c);
70
        }
71
    }
72
73
492
    result
74
492
}
75
76
5
pub fn read_file_lines(filename: &str) -> std::result::Result<Vec<String>, std::io::Error> {
77
5
    let file = File::open(filename)
?1
;
78
4
    BufReader::new(file).lines().collect()
79
5
}
80
81
/// Replace the fields in `pattern` with the items, expanding {...} patterns
82
///
83
/// Replaces:
84
/// - `{}` -> currently selected item
85
/// - `{1..}` etc -> fields of currently selected item, whose index is `selected`
86
/// - `{+}` -> all selected items (multi-select)
87
/// - `{q}` -> current query
88
/// - `{cq}` -> current command query
89
#[allow(clippy::too_many_arguments)]
90
#[allow(clippy::too_many_lines)]
91
185
pub fn printf<'a>(
92
185
    pattern: &str,
93
185
    delimiter: &Regex,
94
185
    replstr: &str,
95
185
    selected: &(impl Iterator<Item = &'a MatchedItem> + std::clone::Clone),
96
185
    current: &Option<MatchedItem>,
97
185
    query: &str,
98
185
    command_query: &str,
99
185
    mut quote_args: bool,
100
185
) -> String {
101
    // Windows uses different shell quoting conventions (double quotes, caret escaping)
102
    // that are incompatible with the Unix-style single-quote escaping implemented here.
103
185
    if cfg!(windows) {
104
0
        quote_args = false;
105
185
    }
106
609
    let escape_arg = |s: &str, quote: bool| {
107
609
        let mut res = s.replace('\0', "\\0").clone();
108
609
        if quote && 
quote_args594
{
  Branch (108:12): [True: 20, False: 2]
   Branch (108:21): [True: 0, False: 20]
-
  Branch (108:12): [True: 51, False: 0]
-  Branch (108:21): [True: 45, False: 6]
-  Branch (108:12): [True: 51, False: 13]
+
  Branch (108:12): [True: 472, False: 0]
+  Branch (108:21): [True: 438, False: 34]
+
  Branch (108:12): [True: 51, False: 13]
   Branch (108:21): [True: 24, False: 27]
-
109
504
            res = format!("'{}'", res.replace('\'', "'\\''"));
110
102
        }
111
606
        res
112
606
    };
113
114
184
    let item_text = current.as_ref().map(|s| 
strip_ansi(&s.output())108
.0).unwrap_or_default();
115
184
    let escaped_item = escape_arg(&item_text, true);
116
184
    let escaped_query = escape_arg(query, true);
117
184
    let escaped_cmd_query = escape_arg(command_query, true);
118
119
    // Split on replstr first
120
184
    let replstr_parts = pattern.split(replstr);
121
184
    let mut replaced_parts = Vec::new();
122
123
    // Deal with every part to expand inside
124
125
184
    for part in replstr_parts {
126
218
        let mut sub = part.split('{');
127
218
        let mut replaced = sub.next().unwrap_or_default().to_string();
128
218
        for s in sub {
129
61
            let mut inside = true;
130
61
            let mut content = String::new();
131
61
            for c in s.chars() {
132
206
                if inside {
  Branch (132:20): [True: 73, False: 15]
-
  Branch (132:20): [True: 25, False: 0]
-
  Branch (132:20): [True: 6, False: 0]
+  Branch (108:12): [True: 51, False: 0]
+  Branch (108:21): [True: 45, False: 6]
+
109
507
            res = format!("'{}'", res.replace('\'', "'\\''"));
110
102
        }
111
609
        res
112
609
    };
113
114
185
    let item_text = current.as_ref().map(|s| 
strip_ansi(&s.output())108
.0).unwrap_or_default();
115
185
    let escaped_item = escape_arg(&item_text, true);
116
185
    let escaped_query = escape_arg(query, true);
117
185
    let escaped_cmd_query = escape_arg(command_query, true);
118
119
    // Split on replstr first
120
185
    let replstr_parts = pattern.split(replstr);
121
185
    let mut replaced_parts = Vec::new();
122
123
    // Deal with every part to expand inside
124
125
185
    for part in replstr_parts {
126
219
        let mut sub = part.split('{');
127
219
        let mut replaced = sub.next().unwrap_or_default().to_string();
128
219
        for s in sub {
129
62
            let mut inside = true;
130
62
            let mut content = String::new();
131
62
            for c in s.chars() {
132
209
                if inside {
  Branch (132:20): [True: 25, False: 0]
+
  Branch (132:20): [True: 75, False: 16]
 
  Branch (132:20): [True: 56, False: 31]
-
133
160
                    if c == '}' {
  Branch (133:24): [True: 38, False: 35]
-
  Branch (133:24): [True: 3, False: 22]
-
  Branch (133:24): [True: 3, False: 3]
+
  Branch (132:20): [True: 6, False: 0]
+
133
162
                    if c == '}' {
  Branch (133:24): [True: 3, False: 22]
+
  Branch (133:24): [True: 39, False: 36]
 
  Branch (133:24): [True: 17, False: 39]
-
134
61
                        match content.as_str() {
135
4
                            "" => replaced.push_str("{}"),
136
15
                            "q" => replaced.push_str(&escaped_query),
137
1
                            "cq" => replaced.push_str(&escaped_cmd_query),
138
2
                            "n" if current.as_ref().is_some() => {
  Branch (138:36): [True: 0, False: 0]
-
  Branch (138:36): [True: 0, False: 0]
+
  Branch (133:24): [True: 3, False: 3]
+
134
62
                        match content.as_str() {
135
4
                            "" => replaced.push_str("{}"),
136
16
                            "q" => replaced.push_str(&escaped_query),
137
1
                            "cq" => replaced.push_str(&escaped_cmd_query),
138
2
                            "n" if current.as_ref().is_some() => {
  Branch (138:36): [True: 0, False: 0]
 
  Branch (138:36): [True: 0, False: 0]
 
  Branch (138:36): [True: 1, False: 1]
-
139
1
                                replaced.push_str(
140
1
                                    current
141
1
                                        .as_ref()
142
1
                                        .map(|i| i.rank.index)
143
                                        .unwrap_or_default()
144
                                        .to_string()
145
                                        .as_str(),
146
                                );
147
                            }
148
40
                            s if s == "+n" || 
s.starts_with("+n:")39
||
s == "+"37
||
s.starts_with("+:")15
=> {
  Branch (148:34): [True: 0, False: 24]
-  Branch (148:47): [True: 0, False: 24]
-  Branch (148:71): [True: 18, False: 6]
-  Branch (148:83): [True: 0, False: 6]
-
  Branch (148:34): [True: 0, False: 3]
+
  Branch (138:36): [True: 0, False: 0]
+
139
1
                                replaced.push_str(
140
1
                                    current
141
1
                                        .as_ref()
142
1
                                        .map(|i| i.rank.index)
143
                                        .unwrap_or_default()
144
                                        .to_string()
145
                                        .as_str(),
146
                                );
147
                            }
148
40
                            s if s == "+n" || 
s.starts_with("+n:")39
||
s == "+"37
||
s.starts_with("+:")15
=> {
  Branch (148:34): [True: 0, False: 3]
   Branch (148:47): [True: 0, False: 3]
   Branch (148:71): [True: 1, False: 2]
   Branch (148:83): [True: 0, False: 2]
-
  Branch (148:34): [True: 0, False: 0]
-  Branch (148:47): [True: 0, False: 0]
-  Branch (148:71): [True: 0, False: 0]
-  Branch (148:83): [True: 0, False: 0]
+
  Branch (148:34): [True: 0, False: 24]
+  Branch (148:47): [True: 0, False: 24]
+  Branch (148:71): [True: 18, False: 6]
+  Branch (148:83): [True: 0, False: 6]
 
  Branch (148:34): [True: 1, False: 12]
   Branch (148:47): [True: 2, False: 10]
   Branch (148:71): [True: 3, False: 7]
   Branch (148:83): [True: 1, False: 6]
-
149
26
                                let is_n = s.starts_with("+n");
150
26
                                let accessor = if is_n {
  Branch (150:51): [True: 0, False: 18]
-
  Branch (150:51): [True: 0, False: 1]
-
  Branch (150:51): [True: 0, False: 0]
+
  Branch (148:34): [True: 0, False: 0]
+  Branch (148:47): [True: 0, False: 0]
+  Branch (148:71): [True: 0, False: 0]
+  Branch (148:83): [True: 0, False: 0]
+
149
26
                                let is_n = s.starts_with("+n");
150
26
                                let accessor = if is_n {
  Branch (150:51): [True: 0, False: 1]
+
  Branch (150:51): [True: 0, False: 18]
 
  Branch (150:51): [True: 3, False: 4]
-
151
7
                                    |i: &MatchedItem| i.rank.index.to_string()
152
                                } else {
153
35
                                    |i: &MatchedItem| strip_ansi(&i.output()).0
154
                                };
155
26
                                let mut quote_individually = false;
156
157
26
                                let delim = s.rsplit_once(':').map_or_else(
158
23
                                    || {
159
23
                                        quote_individually = quote_args;
160
23
                                        " "
161
23
                                    },
162
3
                                    |x| x.1,
163
                                );
164
165
26
                                let mut expanded = selected
166
26
                                    .clone()
167
30
                                    .map(|i| escape_arg(&accessor(i), quote_individually))
168
16
                                    .reduce(|a: String, b| a.clone() + delim + b.as_str())
169
                                    .unwrap_or_default();
170
26
                                if expanded.is_empty() {
  Branch (170:36): [True: 10, False: 8]
-
  Branch (170:36): [True: 0, False: 1]
-
  Branch (170:36): [True: 0, False: 0]
+
  Branch (150:51): [True: 0, False: 0]
+
151
7
                                    |i: &MatchedItem| i.rank.index.to_string()
152
                                } else {
153
35
                                    |i: &MatchedItem| strip_ansi(&i.output()).0
154
                                };
155
26
                                let mut quote_individually = false;
156
157
26
                                let delim = s.rsplit_once(':').map_or_else(
158
23
                                    || {
159
23
                                        quote_individually = quote_args;
160
23
                                        " "
161
23
                                    },
162
3
                                    |x| x.1,
163
                                );
164
165
26
                                let mut expanded = selected
166
26
                                    .clone()
167
30
                                    .map(|i| escape_arg(&accessor(i), quote_individually))
168
16
                                    .reduce(|a: String, b| a.clone() + delim + b.as_str())
169
                                    .unwrap_or_default();
170
26
                                if expanded.is_empty() {
  Branch (170:36): [True: 0, False: 1]
+
  Branch (170:36): [True: 10, False: 8]
 
  Branch (170:36): [True: 2, False: 5]
-
171
12
                                    expanded = current
172
12
                                        .as_ref()
173
12
                                        .map(|i| escape_arg(&accessor(i), quote_args))
174
                                        .unwrap_or_default();
175
14
                                }
176
177
26
                                if quote_args && 
!quote_individually23
{
  Branch (177:36): [True: 18, False: 0]
+
  Branch (170:36): [True: 0, False: 0]
+
171
12
                                    expanded = current
172
12
                                        .as_ref()
173
12
                                        .map(|i| escape_arg(&accessor(i), quote_args))
174
                                        .unwrap_or_default();
175
14
                                }
176
177
26
                                if quote_args && 
!quote_individually23
{
  Branch (177:36): [True: 0, False: 1]
+  Branch (177:50): [True: 0, False: 0]
+
  Branch (177:36): [True: 18, False: 0]
   Branch (177:50): [True: 0, False: 18]
-
  Branch (177:36): [True: 0, False: 1]
-  Branch (177:50): [True: 0, False: 0]
-
  Branch (177:36): [True: 0, False: 0]
-  Branch (177:50): [True: 0, False: 0]
 
  Branch (177:36): [True: 5, False: 2]
   Branch (177:50): [True: 2, False: 3]
-
178
2
                                    let _ = write!(replaced, "'{expanded}'");
179
                                } else {
180
24
                                    replaced.push_str(&expanded);
181
                                }
182
                            }
183
                            s => {
184
14
                                let (is_plus, stripped) = match s.strip_prefix('+') {
185
2
                                    Some(x) => (true, x),
186
12
                                    None => (false, s),
187
                                };
188
14
                                if is_plus {
  Branch (188:36): [True: 0, False: 6]
-
  Branch (188:36): [True: 0, False: 2]
-
  Branch (188:36): [True: 0, False: 0]
+
  Branch (177:36): [True: 0, False: 0]
+  Branch (177:50): [True: 0, False: 0]
+
178
2
                                    let _ = write!(replaced, "'{expanded}'");
179
                                } else {
180
24
                                    replaced.push_str(&expanded);
181
                                }
182
                            }
183
                            s => {
184
14
                                let (is_plus, stripped) = match s.strip_prefix('+') {
185
2
                                    Some(x) => (true, x),
186
12
                                    None => (false, s),
187
                                };
188
14
                                if is_plus {
  Branch (188:36): [True: 0, False: 2]
+
  Branch (188:36): [True: 0, False: 6]
 
  Branch (188:36): [True: 2, False: 4]
+
  Branch (188:36): [True: 0, False: 0]
 
189
2
                                    let mut quote_individually = false;
190
191
2
                                    let (stripped, delim) = stripped.rsplit_once(':').unwrap_or_else(|| 
{1
192
1
                                        quote_individually = quote_args;
193
1
                                        (stripped, " ")
194
1
                                    });
195
2
                                    if let Some(range) = FieldRange::from_str(stripped) {
  Branch (195:44): [True: 0, False: 0]
 
  Branch (195:44): [True: 0, False: 0]
-
  Branch (195:44): [True: 0, False: 0]
 
  Branch (195:44): [True: 1, False: 1]
+
  Branch (195:44): [True: 0, False: 0]
 
196
1
                                        let expanded = selected
197
1
                                            .clone()
198
2
                                            .map(|i| {
199
2
                                                escape_arg(
200
2
                                                    get_string_by_field(delimiter, &strip_ansi(&i.output()).0, &range)
201
2
                                                        .unwrap_or_default(),
202
2
                                                    quote_individually,
203
2
                                                )
204
2
                                            })
205
1
                                            .reduce(|a: String, b| a.clone() + delim + b.as_str())
206
                                            .unwrap_or_default();
207
208
1
                                        if quote_args && 
!quote_individually0
{
  Branch (208:44): [True: 0, False: 0]
   Branch (208:58): [True: 0, False: 0]
 
  Branch (208:44): [True: 0, False: 0]
   Branch (208:58): [True: 0, False: 0]
-
  Branch (208:44): [True: 0, False: 0]
-  Branch (208:58): [True: 0, False: 0]
 
  Branch (208:44): [True: 0, False: 1]
   Branch (208:58): [True: 0, False: 0]
-
209
0
                                            let _ = write!(replaced, "'{expanded}'");
210
                                        } else {
211
1
                                            replaced.push_str(&expanded);
212
                                        }
213
                                    } else {
214
1
                                        log::warn!("Failed to build multi-item field range from {
content0
}");
215
1
                                        let _ = write!(replaced, "{{{s}}}");
216
                                    }
217
12
                                } else if let Some(range) = FieldRange::from_str(stripped) {
  Branch (217:47): [True: 6, False: 0]
-
  Branch (217:47): [True: 2, False: 0]
-
  Branch (217:47): [True: 0, False: 0]
+
  Branch (208:44): [True: 0, False: 0]
+  Branch (208:58): [True: 0, False: 0]
+
209
0
                                            let _ = write!(replaced, "'{expanded}'");
210
                                        } else {
211
1
                                            replaced.push_str(&expanded);
212
                                        }
213
                                    } else {
214
1
                                        log::warn!("Failed to build multi-item field range from {
content0
}");
215
1
                                        let _ = write!(replaced, "{{{s}}}");
216
                                    }
217
12
                                } else if let Some(range) = FieldRange::from_str(stripped) {
  Branch (217:47): [True: 2, False: 0]
+
  Branch (217:47): [True: 6, False: 0]
 
  Branch (217:47): [True: 2, False: 2]
-
218
                                    let replacement =
219
10
                                        get_string_by_field(delimiter, &item_text, &range).unwrap_or_default();
220
10
                                    replaced.push_str(&escape_arg(replacement, true));
221
                                } else {
222
2
                                    log::warn!("Failed to build field range from {
content0
}");
223
2
                                    let _ = write!(replaced, "{{{s}}}");
224
                                }
225
                            }
226
                        }
227
228
61
                        content.clear();
229
61
                        inside = false;
230
                    } else {
231
99
                        content.push(c);
232
                    }
233
46
                } else if c == '{' {
  Branch (233:27): [True: 0, False: 15]
-
  Branch (233:27): [True: 0, False: 0]
-
  Branch (233:27): [True: 0, False: 0]
+
  Branch (217:47): [True: 0, False: 0]
+
218
                                    let replacement =
219
10
                                        get_string_by_field(delimiter, &item_text, &range).unwrap_or_default();
220
10
                                    replaced.push_str(&escape_arg(replacement, true));
221
                                } else {
222
2
                                    log::warn!("Failed to build field range from {
content0
}");
223
2
                                    let _ = write!(replaced, "{{{s}}}");
224
                                }
225
                            }
226
                        }
227
228
62
                        content.clear();
229
62
                        inside = false;
230
                    } else {
231
100
                        content.push(c);
232
                    }
233
47
                } else if c == '{' {
  Branch (233:27): [True: 0, False: 0]
+
  Branch (233:27): [True: 0, False: 16]
 
  Branch (233:27): [True: 0, False: 31]
-
234
0
                    inside = true;
235
                } else {
236
46
                    replaced.push(c);
237
                }
238
            }
239
            // }
240
        }
241
218
        replaced_parts.push(replaced);
242
    }
243
244
    // Join back the replstr parts into the res
245
184
    replaced_parts
246
184
        .into_iter()
247
34
        .reduce(|a: String, b| a + &escaped_item + &b)
248
        .unwrap_or_default()
249
184
}
250
251
#[cfg(test)]
252
#[path = "util_tests.rs"]
253
mod test;
\ No newline at end of file +
  Branch (233:27): [True: 0, False: 0]
+
234
0
                    inside = true;
235
                } else {
236
47
                    replaced.push(c);
237
                }
238
            }
239
            // }
240
        }
241
219
        replaced_parts.push(replaced);
242
    }
243
244
    // Join back the replstr parts into the res
245
185
    replaced_parts
246
185
        .into_iter()
247
34
        .reduce(|a: String, b| a + &escaped_item + &b)
248
        .unwrap_or_default()
249
185
}
250
251
#[cfg(test)]
252
#[path = "util_tests.rs"]
253
mod test;
\ No newline at end of file diff --git a/coverage/index.html b/coverage/index.html index 9f2b92ee..fe5b957d 100644 --- a/coverage/index.html +++ b/coverage/index.html @@ -1 +1 @@ -

Coverage Report

Created: 2026-09-08 12:09

Click here for information about interpreting this report.

FilenameFunction CoverageLine CoverageRegion CoverageBranch Coverage
bin/main.rs
 100.00% (8/8)
  90.51% (124/137)
  82.91% (131/158)
  77.78% (28/36)
binds.rs
 100.00% (21/21)
  98.71% (229/232)
  95.86% (394/411)
  97.50% (39/40)
engine/all.rs
 100.00% (5/5)
 100.00% (22/22)
 100.00% (23/23)
- (0/0)
engine/andor.rs
 100.00% (16/16)
 100.00% (80/80)
 100.00% (82/82)
 100.00% (4/4)
engine/exact.rs
 100.00% (6/6)
 100.00% (68/68)
 100.00% (79/79)
 100.00% (18/18)
engine/factory.rs
 100.00% (21/21)
 100.00% (152/152)
 100.00% (139/139)
 100.00% (20/20)
engine/fuzzy.rs
 100.00% (15/15)
 100.00% (113/113)
  96.83% (122/126)
 100.00% (10/10)
engine/normalized.rs
 100.00% (7/7)
 100.00% (35/35)
 100.00% (48/48)
 100.00% (2/2)
engine/regexp.rs
 100.00% (7/7)
 100.00% (50/50)
 100.00% (55/55)
 100.00% (4/4)
engine/split.rs
 100.00% (12/12)
 100.00% (68/68)
  97.33% (73/75)
 100.00% (6/6)
engine/util.rs
 100.00% (8/8)
 100.00% (63/63)
 100.00% (61/61)
 100.00% (16/16)
field.rs
 100.00% (14/14)
  98.95% (94/95)
  99.25% (133/134)
 100.00% (32/32)
fuzzy_matcher/arinae/algo.rs
 100.00% (6/6)
  88.46% (276/312)
  85.89% (359/418)
  72.07% (80/111)
fuzzy_matcher/arinae/atom.rs
  92.31% (12/13)
  93.33% (56/60)
  91.89% (68/74)
 100.00% (10/10)
fuzzy_matcher/arinae/banding.rs
 100.00% (4/4)
  97.56% (40/41)
  85.96% (49/57)
  62.50% (5/8)
fuzzy_matcher/arinae/helpers.rs
 100.00% (4/4)
 100.00% (45/45)
  98.11% (52/53)
 100.00% (4/4)
fuzzy_matcher/arinae/matrix.rs
 100.00% (7/7)
 100.00% (33/33)
 100.00% (35/35)
 100.00% (2/2)
fuzzy_matcher/arinae/mod.rs
 100.00% (21/21)
 100.00% (181/181)
  98.10% (258/263)
 100.00% (54/54)
fuzzy_matcher/arinae/prefilter.rs
 100.00% (5/5)
 100.00% (51/51)
 100.00% (64/64)
  94.44% (17/18)
fuzzy_matcher/clangd.rs
  96.15% (25/26)
  92.36% (254/275)
  91.28% (293/321)
  98.33% (59/60)
fuzzy_matcher/frizbee.rs
 100.00% (15/15)
 100.00% (51/51)
 100.00% (55/55)
- (0/0)
fuzzy_matcher/fzy.rs
 100.00% (37/37)
  98.19% (542/552)
  98.18% (757/771)
  93.16% (177/190)
fuzzy_matcher/mod.rs
 100.00% (3/3)
 100.00% (11/11)
 100.00% (14/14)
- (0/0)
fuzzy_matcher/skim.rs
 100.00% (39/39)
  99.04% (411/415)
  98.74% (472/478)
  98.44% (63/64)
fuzzy_matcher/util.rs
 100.00% (11/11)
 100.00% (82/82)
 100.00% (99/99)
 100.00% (26/26)
helper/item.rs
 100.00% (33/33)
  96.23% (357/371)
  96.48% (411/426)
  88.81% (119/134)
helper/item_reader.rs
  92.68% (38/41)
  90.37% (272/301)
  89.26% (291/326)
  72.00% (36/50)
helper/selector.rs
 100.00% (6/6)
  95.12% (39/41)
  91.11% (41/45)
  88.89% (16/18)
item.rs
  97.22% (35/36)
  97.67% (294/301)
  97.81% (313/320)
  95.00% (38/40)
lib.rs
 100.00% (8/8)
 100.00% (79/79)
 100.00% (94/94)
- (0/0)
manpage.rs
 100.00% (10/10)
  97.63% (206/211)
  93.37% (169/181)
 100.00% (8/8)
matcher.rs
 100.00% (23/23)
  98.80% (246/249)
  97.45% (229/235)
  90.00% (27/30)
options.rs
  86.96% (20/23)
  98.21% (330/336)
  97.55% (318/326)
  92.11% (35/38)
output.rs
 100.00% (2/2)
  96.67% (58/60)
  89.25% (83/93)
 100.00% (22/22)
popup/mod.rs
  63.64% (7/11)
  75.27% (137/182)
  71.76% (155/216)
  70.00% (42/60)
popup/tmux.rs
  85.71% (6/7)
  96.55% (56/58)
  93.94% (62/66)
  75.00% (3/4)
popup/zellij.rs
  81.82% (9/11)
  84.78% (78/92)
  85.71% (90/105)
  83.33% (5/6)
reader.rs
  95.00% (19/20)
  96.64% (115/119)
  94.31% (116/123)
  87.50% (7/8)
shell.rs
 100.00% (2/2)
  96.00% (24/25)
  93.75% (30/32)
 100.00% (4/4)
skim.rs
  82.50% (33/40)
  82.93% (345/416)
  74.62% (341/457)
  71.82% (79/110)
skim_item.rs
 100.00% (9/9)
 100.00% (27/27)
 100.00% (28/28)
- (0/0)
spinlock.rs
 100.00% (6/6)
 100.00% (26/26)
 100.00% (25/25)
 100.00% (2/2)
theme.rs
 100.00% (16/16)
  99.34% (300/302)
  98.55% (339/344)
  97.22% (35/36)
thread_pool.rs
 100.00% (18/18)
  97.81% (179/183)
  96.55% (168/174)
  88.46% (23/26)
tui/actions.rs
  75.00% (24/32)
  90.70% (78/86)
  89.25% (83/93)
 100.00% (2/2)
tui/app.rs
  98.00% (49/50)
  95.00% (893/940)
  90.76% (982/1082)
  86.21% (200/232)
tui/backend.rs
  81.25% (26/32)
  76.11% (188/247)
  68.79% (205/298)
  56.82% (25/44)
tui/header.rs
 100.00% (11/11)
  99.17% (119/120)
  99.19% (122/123)
 100.00% (28/28)
tui/input.rs
  96.88% (31/32)
  96.40% (321/333)
  97.05% (395/407)
  80.36% (90/112)
tui/item_list.rs
  97.30% (36/37)
  95.44% (377/395)
  93.76% (406/433)
  88.10% (111/126)
tui/item_renderer.rs
 100.00% (29/29)
  98.01% (395/403)
  97.92% (470/480)
  89.68% (113/126)
tui/layout.rs
 100.00% (6/6)
  96.77% (120/124)
  96.48% (137/142)
  94.74% (36/38)
tui/mod.rs
  95.65% (22/23)
  93.23% (124/133)
  94.57% (174/184)
  75.00% (15/20)
tui/options.rs
 100.00% (2/2)
 100.00% (39/39)
 100.00% (36/36)
 100.00% (20/20)
tui/preview.rs
  93.33% (42/45)
  87.50% (483/552)
  84.20% (565/671)
  70.27% (104/148)
tui/statusline.rs
 100.00% (5/5)
 100.00% (33/33)
 100.00% (35/35)
 100.00% (4/4)
tui/util.rs
  78.26% (18/23)
  68.66% (184/268)
  64.81% (210/324)
  57.14% (48/84)
tui/widget.rs
 100.00% (2/2)
 100.00% (7/7)
 100.00% (9/9)
- (0/0)
util.rs
 100.00% (17/17)
  96.43% (135/140)
  93.83% (152/162)
  91.30% (42/46)
Totals
  94.99% (949/999)
  93.97% (9795/10423)
  91.89% (11199/12188)
  85.35% (2015/2361)
Generated by llvm-cov -- llvm version 23.1.1-rust-1.100.0-nightly
\ No newline at end of file +

Coverage Report

Created: 2026-09-08 14:37

Click here for information about interpreting this report.

FilenameFunction CoverageLine CoverageRegion CoverageBranch Coverage
bin/main.rs
 100.00% (8/8)
  90.51% (124/137)
  82.91% (131/158)
  77.78% (28/36)
binds.rs
 100.00% (21/21)
  98.71% (229/232)
  95.86% (394/411)
  97.50% (39/40)
engine/all.rs
 100.00% (5/5)
 100.00% (22/22)
 100.00% (23/23)
- (0/0)
engine/andor.rs
 100.00% (16/16)
 100.00% (80/80)
 100.00% (82/82)
 100.00% (4/4)
engine/exact.rs
 100.00% (6/6)
 100.00% (68/68)
 100.00% (79/79)
 100.00% (18/18)
engine/factory.rs
 100.00% (21/21)
 100.00% (152/152)
 100.00% (139/139)
 100.00% (20/20)
engine/fuzzy.rs
 100.00% (15/15)
 100.00% (113/113)
  96.83% (122/126)
 100.00% (10/10)
engine/normalized.rs
 100.00% (7/7)
 100.00% (35/35)
 100.00% (48/48)
 100.00% (2/2)
engine/regexp.rs
 100.00% (7/7)
 100.00% (50/50)
 100.00% (55/55)
 100.00% (4/4)
engine/split.rs
 100.00% (12/12)
 100.00% (68/68)
  97.33% (73/75)
 100.00% (6/6)
engine/util.rs
 100.00% (8/8)
 100.00% (63/63)
 100.00% (61/61)
 100.00% (16/16)
field.rs
 100.00% (14/14)
  98.95% (94/95)
  99.25% (133/134)
 100.00% (32/32)
fuzzy_matcher/arinae/algo.rs
 100.00% (6/6)
  88.46% (276/312)
  85.89% (359/418)
  72.07% (80/111)
fuzzy_matcher/arinae/atom.rs
  92.31% (12/13)
  93.33% (56/60)
  91.89% (68/74)
 100.00% (10/10)
fuzzy_matcher/arinae/banding.rs
 100.00% (4/4)
  97.56% (40/41)
  85.96% (49/57)
  62.50% (5/8)
fuzzy_matcher/arinae/helpers.rs
 100.00% (4/4)
 100.00% (45/45)
  98.11% (52/53)
 100.00% (4/4)
fuzzy_matcher/arinae/matrix.rs
 100.00% (7/7)
 100.00% (33/33)
 100.00% (35/35)
 100.00% (2/2)
fuzzy_matcher/arinae/mod.rs
 100.00% (21/21)
 100.00% (181/181)
  98.10% (258/263)
 100.00% (54/54)
fuzzy_matcher/arinae/prefilter.rs
 100.00% (5/5)
 100.00% (51/51)
 100.00% (64/64)
  94.44% (17/18)
fuzzy_matcher/clangd.rs
  96.15% (25/26)
  92.36% (254/275)
  91.28% (293/321)
  98.33% (59/60)
fuzzy_matcher/frizbee.rs
 100.00% (15/15)
 100.00% (51/51)
 100.00% (55/55)
- (0/0)
fuzzy_matcher/fzy.rs
 100.00% (37/37)
  98.19% (542/552)
  98.18% (757/771)
  93.16% (177/190)
fuzzy_matcher/mod.rs
 100.00% (3/3)
 100.00% (11/11)
 100.00% (14/14)
- (0/0)
fuzzy_matcher/skim.rs
 100.00% (39/39)
  99.04% (411/415)
  98.74% (472/478)
  98.44% (63/64)
fuzzy_matcher/util.rs
 100.00% (11/11)
 100.00% (82/82)
 100.00% (99/99)
 100.00% (26/26)
helper/item.rs
 100.00% (33/33)
  96.23% (357/371)
  96.48% (411/426)
  88.81% (119/134)
helper/item_reader.rs
  92.68% (38/41)
  90.70% (273/301)
  89.26% (291/326)
  72.00% (36/50)
helper/selector.rs
 100.00% (6/6)
  95.12% (39/41)
  91.11% (41/45)
  88.89% (16/18)
item.rs
  97.22% (35/36)
  97.67% (294/301)
  97.81% (313/320)
  95.00% (38/40)
lib.rs
 100.00% (8/8)
 100.00% (79/79)
 100.00% (94/94)
- (0/0)
manpage.rs
 100.00% (10/10)
  97.63% (206/211)
  93.37% (169/181)
 100.00% (8/8)
matcher.rs
 100.00% (23/23)
  98.80% (246/249)
  97.45% (229/235)
  90.00% (27/30)
options.rs
  86.96% (20/23)
  98.21% (330/336)
  97.55% (318/326)
  92.11% (35/38)
output.rs
 100.00% (2/2)
  96.67% (58/60)
  89.25% (83/93)
 100.00% (22/22)
popup/mod.rs
  63.64% (7/11)
  75.27% (137/182)
  71.76% (155/216)
  70.00% (42/60)
popup/tmux.rs
  85.71% (6/7)
  96.55% (56/58)
  93.94% (62/66)
  75.00% (3/4)
popup/zellij.rs
  81.82% (9/11)
  84.78% (78/92)
  85.71% (90/105)
  83.33% (5/6)
reader.rs
  95.00% (19/20)
  96.64% (115/119)
  94.31% (116/123)
  87.50% (7/8)
shell.rs
 100.00% (2/2)
  96.00% (24/25)
  93.75% (30/32)
 100.00% (4/4)
skim.rs
  82.50% (33/40)
  82.93% (345/416)
  74.62% (341/457)
  72.73% (80/110)
skim_item.rs
 100.00% (9/9)
 100.00% (27/27)
 100.00% (28/28)
- (0/0)
spinlock.rs
 100.00% (6/6)
 100.00% (26/26)
 100.00% (25/25)
 100.00% (2/2)
theme.rs
 100.00% (16/16)
  99.34% (300/302)
  98.55% (339/344)
  97.22% (35/36)
thread_pool.rs
 100.00% (18/18)
  97.81% (179/183)
  96.55% (168/174)
  88.46% (23/26)
tui/actions.rs
  75.00% (24/32)
  90.70% (78/86)
  89.25% (83/93)
 100.00% (2/2)
tui/app.rs
  98.00% (49/50)
  95.00% (893/940)
  90.76% (982/1082)
  86.21% (200/232)
tui/backend.rs
  81.25% (26/32)
  76.11% (188/247)
  68.79% (205/298)
  56.82% (25/44)
tui/header.rs
 100.00% (11/11)
  99.17% (119/120)
  99.19% (122/123)
 100.00% (28/28)
tui/input.rs
  96.88% (31/32)
  96.40% (321/333)
  97.05% (395/407)
  81.25% (91/112)
tui/item_list.rs
  97.30% (36/37)
  95.44% (377/395)
  93.76% (406/433)
  88.10% (111/126)
tui/item_renderer.rs
 100.00% (29/29)
  98.01% (395/403)
  97.92% (470/480)
  89.68% (113/126)
tui/layout.rs
 100.00% (6/6)
  96.77% (120/124)
  96.48% (137/142)
  94.74% (36/38)
tui/mod.rs
  95.65% (22/23)
  93.23% (124/133)
  94.57% (174/184)
  75.00% (15/20)
tui/options.rs
 100.00% (2/2)
 100.00% (39/39)
 100.00% (36/36)
 100.00% (20/20)
tui/preview.rs
  93.33% (42/45)
  87.50% (483/552)
  84.20% (565/671)
  70.27% (104/148)
tui/statusline.rs
 100.00% (5/5)
 100.00% (33/33)
 100.00% (35/35)
 100.00% (4/4)
tui/util.rs
  78.26% (18/23)
  68.66% (184/268)
  64.81% (210/324)
  57.14% (48/84)
tui/widget.rs
 100.00% (2/2)
 100.00% (7/7)
 100.00% (9/9)
- (0/0)
util.rs
 100.00% (17/17)
  96.43% (135/140)
  93.83% (152/162)
  91.30% (42/46)
Totals
  94.99% (949/999)
  93.98% (9796/10423)
  91.89% (11199/12188)
  85.43% (2017/2361)
Generated by llvm-cov -- llvm version 23.1.1-rust-1.100.0-nightly
\ No newline at end of file