From ac282169fe1dc237664f7535ee844cfae4e3843a Mon Sep 17 00:00:00 2001 From: LoricAndre Date: Sat, 5 Sep 2026 18:50:27 +0000 Subject: [PATCH] deploy: 17c517fd02ff4419a1bd615e0df571fced2c24bf --- coverage/coverage.svg | 8 +- .../work/skim/skim/src/bin/main.rs.html | 66 +- .../runner/work/skim/skim/src/binds.rs.html | 26 +- .../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 | 2 +- .../work/skim/skim/src/engine/factory.rs.html | 30 +- .../work/skim/skim/src/engine/fuzzy.rs.html | 12 +- .../skim/skim/src/engine/normalized.rs.html | 4 +- .../work/skim/skim/src/engine/regexp.rs.html | 10 +- .../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 | 2 +- .../src/fuzzy_matcher/arinae/algo.rs.html | 44 +- .../src/fuzzy_matcher/arinae/atom.rs.html | 8 +- .../src/fuzzy_matcher/arinae/banding.rs.html | 12 +- .../src/fuzzy_matcher/arinae/helpers.rs.html | 6 +- .../src/fuzzy_matcher/arinae/matrix.rs.html | 4 +- .../skim/src/fuzzy_matcher/arinae/mod.rs.html | 14 +- .../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 | 2 +- .../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 | 76 +- .../skim/skim/src/helper/item_reader.rs.html | 594 +++++------ .../skim/skim/src/helper/selector.rs.html | 8 +- .../runner/work/skim/skim/src/item.rs.html | 46 +- .../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 | 56 +- .../runner/work/skim/skim/src/options.rs.html | 52 +- .../runner/work/skim/skim/src/output.rs.html | 20 +- .../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 | 24 +- .../runner/work/skim/skim/src/shell.rs.html | 6 +- .../runner/work/skim/skim/src/skim.rs.html | 514 +++++----- .../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 | 6 +- .../work/skim/skim/src/thread_pool.rs.html | 52 +- .../work/skim/skim/src/tui/actions.rs.html | 4 +- .../runner/work/skim/skim/src/tui/app.rs.html | 654 ++++++------ .../work/skim/skim/src/tui/backend.rs.html | 112 +-- .../work/skim/skim/src/tui/header.rs.html | 34 +- .../work/skim/skim/src/tui/input.rs.html | 70 +- .../work/skim/skim/src/tui/item_list.rs.html | 254 ++--- .../skim/skim/src/tui/item_renderer.rs.html | 208 ++-- .../work/skim/skim/src/tui/layout.rs.html | 44 +- .../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 | 947 +++++++++--------- .../work/skim/skim/src/tui/statusline.rs.html | 6 +- .../work/skim/skim/src/tui/util.rs.html | 14 +- .../work/skim/skim/src/tui/widget.rs.html | 2 +- .../runner/work/skim/skim/src/util.rs.html | 52 +- coverage/index.html | 2 +- 61 files changed, 2124 insertions(+), 2069 deletions(-) diff --git a/coverage/coverage.svg b/coverage/coverage.svg index 7089c515..eab898f7 100644 --- a/coverage/coverage.svg +++ b/coverage/coverage.svg @@ -1,5 +1,5 @@ - - Coverage: 85.32% + + Coverage: 85.52% @@ -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 aea6d5ec..4055bfbf 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-04 10: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_file1
) = 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
71
    let format = |buf: &mut env_logger::fmt::Formatter, record: &log::Record<'_>| 
{37
39
37
        writeln!(
40
37
            buf,
41
            "[{} {} {} ({}:{})] [{}/{:?}] {}",
42
37
            buf.timestamp_nanos(),
43
37
            record.level().as_str(),
44
37
            record.module_path().unwrap_or("sk"),
45
37
            record.file().unwrap_or_default(),
46
37
            record.line().unwrap_or_default(),
47
37
            std::thread::current().name().unwrap_or("?"),
48
37
            std::thread::current().id(),
49
37
            record.args()
50
        )
51
37
    };
52
53
71
    if let Some(
level0
) = 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
71
    } else {
61
71
        env_logger::builder()
62
71
            .parse_env(env_var)
63
71
            .target(target)
64
71
            .format(format)
65
71
            .init();
66
71
    }
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::args1
());
79
80
    // Shell completion scripts
81
76
    if let Some(
shell7
) = 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(
remote32
) = 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
41
            for act in 
actions39
{
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 {act:?} 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_env5
() {
  Branch (133:8): [Folded - Ignored]
-  Branch (133:32): [Folded - Ignored]
-
  Branch (133:8): [True: 5, False: 31]
+

Coverage Report

Created: 2026-09-05 18:50

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_file1
) = 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
71
    let format = |buf: &mut env_logger::fmt::Formatter, record: &log::Record<'_>| 
{46
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(
level0
) = 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
71
    } else {
61
71
        env_logger::builder()
62
71
            .parse_env(env_var)
63
71
            .target(target)
64
71
            .format(format)
65
71
            .init();
66
71
    }
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::args1
());
79
80
    // Shell completion scripts
81
76
    if let Some(
shell7
) = 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(
remote32
) = 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
41
            for act in 
actions39
{
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 {act:?} 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_env5
() {
  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.cmd0
.
is_some0
()) {
  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.cmd0
.
is_some0
()) {
  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: {result:?}");
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(
file1
) = 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(
file0
) = 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: {result:?}");
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(
file1
) = 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(
file0
) = 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 886a046d..bd12ab3e 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,7 +1,7 @@ -

Coverage Report

Created: 2026-09-04 10: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.52k
    pub const fn key_code(self) -> KeyCode {
50
4.52k
        match self {
51
359
            SkimEvent::Change => KeyCode::F(255),
52
382
            SkimEvent::Start => KeyCode::F(254),
53
399
            SkimEvent::Load => KeyCode::F(253),
54
730
            SkimEvent::Result => KeyCode::F(252),
55
499
            SkimEvent::Focus => KeyCode::F(251),
56
164
            SkimEvent::Zero => KeyCode::F(250),
57
226
            SkimEvent::One => KeyCode::F(249),
58
1.76k
            SkimEvent::DoubleClick => KeyCode::F(248),
59
        }
60
4.52k
    }
61
62
    /// The reserved [`KeyEvent`] used to route this event through the keymap.
63
    #[must_use]
64
4.52k
    pub const fn key_event(self) -> KeyEvent {
65
4.52k
        KeyEvent::new(self.key_code(), KeyModifiers::NONE)
66
4.52k
    }
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
187
            "start" => 
Some(SkimEvent::Start)13
,
75
174
            "load" => 
Some(SkimEvent::Load)7
,
76
167
            "change" => 
Some(SkimEvent::Change)6
,
77
161
            "result" => 
Some(SkimEvent::Result)4
,
78
157
            "focus" => 
Some(SkimEvent::Focus)4
,
79
153
            "zero" => 
Some(SkimEvent::Zero)4
,
80
149
            "one" => 
Some(SkimEvent::One)4
,
81
145
            "double-click" => 
Some(SkimEvent::DoubleClick)3
,
82
142
            _ => None,
83
        }
84
187
    }
85
}
86
87
impl From<SkimEvent> for KeyEvent {
88
2.71k
    fn from(event: SkimEvent) -> Self {
89
2.71k
        event.key_event()
90
2.71k
    }
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.16k
    fn deref(&self) -> &Self::Target {
101
3.16k
        &self.0
102
3.16k
    }
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.41k
    fn default() -> Self {
118
1.41k
        get_default_key_map()
119
1.41k
    }
120
}
121
122
impl KeyMap {
123
    /// Adds keymaps from a comma-separated string.
124
464
    pub(crate) fn add_keymaps_str(&mut self, source: &str) {
125
464
        self.add_keymaps(split_top_level(source, ',').into_iter());
126
464
    }
127
128
    /// Adds keymaps from the source, parsing them using `parse_keymap`
129
468
    pub fn add_keymaps<'a, T>(&mut self, source: T)
130
468
    where
131
468
        T: Iterator<Item = &'a str>,
132
    {
133
479
        for map in 
source468
{
134
479
            if let Ok((
key54
,
action_chain54
)) = parse_keymap(map) {
  Branch (134:20): [True: 35, False: 425]
-
  Branch (134:20): [True: 17, False: 0]
-  Branch (134:20): [True: 2, False: 0]
-
135
54
                self.bind(key, action_chain)
136
54
                    .unwrap_or_else(|err| 
debug!5
("Failed to bind key {map}: {err}"));
137
            } else {
138
425
                debug!("Failed to parse key: {map}");
139
            }
140
        }
141
468
    }
142
54
    fn bind(&mut self, key: &str, action_chain: Vec<Action>) -> Result<()> {
143
54
        let 
key49
= 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.42k
pub fn get_default_key_map() -> KeyMap {
156
1.42k
    let mut ret = HashMap::new();
157
158
1.42k
    ret.insert(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), vec![Action::Down(1)]);
159
1.42k
    ret.insert(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), vec![Action::Up(1)]);
160
1.42k
    ret.insert(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE), vec![Action::PageUp(1)]);
161
1.42k
    ret.insert(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE), vec![Action::PageDown(1)]);
162
1.42k
    ret.insert(KeyEvent::new(KeyCode::End, KeyModifiers::NONE), vec![Action::EndOfLine]);
163
1.42k
    ret.insert(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE), vec![Action::BeginningOfLine]);
164
1.42k
    ret.insert(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE), vec![Action::DeleteChar]);
165
1.42k
    ret.insert(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), vec![Action::Toggle, Action::Down(1)]);
166
1.42k
    ret.insert(KeyEvent::new(KeyCode::BackTab, KeyModifiers::all()), vec![Action::Toggle, Action::Up(1)]);
167
1.42k
    ret.insert(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), vec![Action::Abort]);
168
1.42k
    ret.insert(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), vec![Action::Accept(None)]);
169
1.42k
    ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), vec![Action::BackwardChar]);
170
1.42k
    ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), vec![Action::ForwardChar]);
171
1.42k
    ret.insert(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), vec![Action::BackwardDeleteChar]);
172
1.42k
    ret.insert(SkimEvent::DoubleClick.key_event(), vec![Action::Accept(None)]);
173
174
175
1.42k
    ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT), vec![Action::BackwardWord]);
176
1.42k
    ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT), vec![Action::ForwardWord]);
177
1.42k
    ret.insert(KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT), vec![Action::PreviewUp(1)]);
178
1.42k
    ret.insert(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT), vec![Action::PreviewDown(1)]);
179
1.42k
    ret.insert(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT), vec![Action::Toggle, Action::Up(1)]);
180
1.42k
    ret.insert(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT), vec![Action::Toggle, Action::Up(1)]);
181
1.42k
    ret.insert(KeyEvent::new(KeyCode::Home, KeyModifiers::SHIFT), vec![Action::BeginningOfLine]);
182
183
184
1.42k
    ret.insert(KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL), vec![Action::BackwardWord]);
185
1.42k
    ret.insert(KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL), vec![Action::ForwardWord]);
186
187
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), vec![Action::BeginningOfLine]);
188
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL), vec![Action::BackwardChar]);
189
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), vec![Action::Abort]);
190
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), vec![Action::Abort]);
191
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), vec![Action::EndOfLine]);
192
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), vec![Action::ForwardChar]);
193
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL), vec![Action::Abort]);
194
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), vec![Action::BackwardDeleteChar]);
195
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL), vec![Action::Down(1)]);
196
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), vec![Action::Up(1)]);
197
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL), vec![Action::ClearScreen]);
198
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), vec![Action::Down(1)]);
199
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL), vec![Action::Up(1)]);
200
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL), vec![Action::ToggleInteractive]);
201
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), vec![Action::RotateMode]);
202
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), vec![Action::UnixLineDiscard]);
203
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), vec![Action::UnixWordRubout]);
204
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), vec![Action::Yank]);
205
206
207
1.42k
    ret.insert(KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT), vec![Action::BackwardKillWord]);
208
209
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT), vec![Action::BackwardWord]);
210
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT), vec![Action::KillWord]);
211
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT), vec![Action::ForwardWord]);
212
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::ALT), vec![Action::ScrollLeft(1)]);
213
1.42k
    ret.insert(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::ALT), vec![Action::ScrollRight(1)]);
214
215
1.42k
    KeyMap(ret)
216
1.42k
}
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]
+

Coverage Report

Created: 2026-09-05 18:50

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.61k
    pub const fn key_code(self) -> KeyCode {
50
4.61k
        match self {
51
363
            SkimEvent::Change => KeyCode::F(255),
52
397
            SkimEvent::Start => KeyCode::F(254),
53
400
            SkimEvent::Load => KeyCode::F(253),
54
749
            SkimEvent::Result => KeyCode::F(252),
55
499
            SkimEvent::Focus => KeyCode::F(251),
56
179
            SkimEvent::Zero => KeyCode::F(250),
57
225
            SkimEvent::One => KeyCode::F(249),
58
1.80k
            SkimEvent::DoubleClick => KeyCode::F(248),
59
        }
60
4.61k
    }
61
62
    /// The reserved [`KeyEvent`] used to route this event through the keymap.
63
    #[must_use]
64
4.61k
    pub const fn key_event(self) -> KeyEvent {
65
4.61k
        KeyEvent::new(self.key_code(), KeyModifiers::NONE)
66
4.61k
    }
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
187
            "start" => 
Some(SkimEvent::Start)13
,
75
174
            "load" => 
Some(SkimEvent::Load)7
,
76
167
            "change" => 
Some(SkimEvent::Change)6
,
77
161
            "result" => 
Some(SkimEvent::Result)4
,
78
157
            "focus" => 
Some(SkimEvent::Focus)4
,
79
153
            "zero" => 
Some(SkimEvent::Zero)4
,
80
149
            "one" => 
Some(SkimEvent::One)4
,
81
145
            "double-click" => 
Some(SkimEvent::DoubleClick)3
,
82
142
            _ => None,
83
        }
84
187
    }
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.22k
    fn deref(&self) -> &Self::Target {
101
3.22k
        &self.0
102
3.22k
    }
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
479
    pub(crate) fn add_keymaps_str(&mut self, source: &str) {
125
479
        self.add_keymaps(split_top_level(source, ',').into_iter());
126
479
    }
127
128
    /// Adds keymaps from the source, parsing them using `parse_keymap`
129
483
    pub fn add_keymaps<'a, T>(&mut self, source: T)
130
483
    where
131
483
        T: Iterator<Item = &'a str>,
132
    {
133
494
        for map in 
source483
{
134
494
            if let Ok((
key54
,
action_chain54
)) = parse_keymap(map) {
  Branch (134:20): [True: 35, False: 440]
+
  Branch (134:20): [True: 2, False: 0]
+  Branch (134:20): [True: 17, False: 0]
+
135
54
                self.bind(key, action_chain)
136
54
                    .unwrap_or_else(|err| 
debug!5
("Failed to bind key {map}: {err}"));
137
            } else {
138
440
                debug!("Failed to parse key: {map}");
139
            }
140
        }
141
483
    }
142
54
    fn bind(&mut self, key: &str, action_chain: Vec<Action>) -> Result<()> {
143
54
        let 
key49
= 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]
 
  Branch (227:8): [True: 1, False: 98]
 
228
1
        return Err(eyre!("Cannot parse empty key"));
229
168
    }
230
168
    if let Some(
event37
) = SkimEvent::from_name(key) {
  Branch (230:12): [True: 20, False: 50]
 
  Branch (230:12): [True: 17, False: 81]
@@ -15,26 +15,26 @@
 
  Branch (258:19): [True: 7, False: 34]
 
259
9
        && let Ok(
f_index1
) = f.parse::<u8>()
  Branch (259:16): [True: 0, False: 2]
 
  Branch (259:16): [True: 1, False: 6]
-
260
1
    {
261
1
        // A function key like `f10`. If the suffix isn't numeric (e.g. `focus`,
262
1
        // `first`), fall through to the named-key / event matching below.
263
1
        keycode = KeyCode::F(f_index);
264
1
    } else {
265
44
        keycode = match key.as_str() {
266
44
            "space" => 
KeyCode::Char(' ')1
,
267
43
            "enter" => 
KeyCode::Enter12
,
268
31
            "bspace" | 
"bs"30
=>
KeyCode::Backspace2
,
269
29
            "up" => 
KeyCode::Up7
,
270
22
            "down" => 
KeyCode::Down3
,
271
19
            "left" => 
KeyCode::Left1
,
272
18
            "right" => 
KeyCode::Right1
,
273
17
            "tab" => 
KeyCode::Tab1
,
274
16
            "btab" => 
KeyCode::BackTab1
,
275
15
            "esc" => 
KeyCode::Esc1
,
276
14
            "home" => 
KeyCode::Home1
,
277
13
            "end" => 
KeyCode::End1
,
278
12
            "pgup" => 
KeyCode::PageUp1
,
279
11
            "pgdown" => 
KeyCode::PageDown1
,
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 {keycode:?} and mods {mods:?}");
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.06k
pub(crate) fn split_top_level(value: &str, separator: char) -> Vec<&str> {
303
1.06k
    let mut depth = 0_u32;
304
1.06k
    let mut start = 0;
305
1.06k
    let mut parts = Vec::new();
306
307
3.23k
    for (index, ch) in 
value1.06k
.
char_indices1.06k
() {
308
3.10k
        match ch {
309
66
            '(' => depth += 1,
310
67
            ')' => depth = depth.saturating_sub(1),
311
3.10k
            _ if ch == separator && 
depth39
==
035
=> {
  Branch (311:18): [True: 12, False: 2.13k]
+
260
1
    {
261
1
        // A function key like `f10`. If the suffix isn't numeric (e.g. `focus`,
262
1
        // `first`), fall through to the named-key / event matching below.
263
1
        keycode = KeyCode::F(f_index);
264
1
    } else {
265
44
        keycode = match key.as_str() {
266
44
            "space" => 
KeyCode::Char(' ')1
,
267
43
            "enter" => 
KeyCode::Enter12
,
268
31
            "bspace" | 
"bs"30
=>
KeyCode::Backspace2
,
269
29
            "up" => 
KeyCode::Up7
,
270
22
            "down" => 
KeyCode::Down3
,
271
19
            "left" => 
KeyCode::Left1
,
272
18
            "right" => 
KeyCode::Right1
,
273
17
            "tab" => 
KeyCode::Tab1
,
274
16
            "btab" => 
KeyCode::BackTab1
,
275
15
            "esc" => 
KeyCode::Esc1
,
276
14
            "home" => 
KeyCode::Home1
,
277
13
            "end" => 
KeyCode::End1
,
278
12
            "pgup" => 
KeyCode::PageUp1
,
279
11
            "pgdown" => 
KeyCode::PageDown1
,
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 {keycode:?} and mods {mods:?}");
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
3.23k
    for (index, ch) in 
value1.09k
.
char_indices1.09k
() {
308
3.10k
        match ch {
309
66
            '(' => depth += 1,
310
67
            ')' => depth = depth.saturating_sub(1),
311
3.10k
            _ if ch == separator && 
depth39
==
035
=> {
  Branch (311:18): [True: 12, False: 2.13k]
   Branch (311:37): [True: 12, 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
35
            }
315
3.07k
            _ => {}
316
        }
317
    }
318
1.06k
    parts.push(&value[start..]);
319
1.06k
    parts
320
1.06k
}
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
470
pub fn parse_action_binds<'a, T>(maps: T) -> HashMap<String, Vec<Action>>
336
470
where
337
470
    T: Iterator<Item = &'a str>,
338
{
339
470
    let mut res = HashMap::new();
340
482
    for map in 
maps470
{
341
482
        let Some((
key57
,
chain57
)) = map.split_once(':') else {
  Branch (341:13): [True: 35, False: 425]
-
  Branch (341:13): [True: 10, False: 0]
+
312
35
                parts.push(&value[start..index]);
313
35
                start = index + ch.len_utf8();
314
35
            }
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
485
pub fn parse_action_binds<'a, T>(maps: T) -> HashMap<String, Vec<Action>>
336
485
where
337
485
    T: Iterator<Item = &'a str>,
338
{
339
485
    let mut res = HashMap::new();
340
497
    for map in 
maps485
{
341
497
        let Some((
key57
,
chain57
)) = map.split_once(':') else {
  Branch (341:13): [True: 35, False: 440]
 
  Branch (341:13): [True: 4, False: 0]
 
  Branch (341:13): [True: 3, False: 0]
 
  Branch (341:13): [True: 5, False: 0]
-
342
425
            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]
-
  Branch (345:12): [True: 6, False: 4]
+
  Branch (341:13): [True: 10, False: 0]
+
342
440
            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]
 
  Branch (345:12): [True: 1, False: 3]
 
  Branch (345:12): [True: 0, False: 3]
 
  Branch (345:12): [True: 0, False: 5]
+
  Branch (345:12): [True: 6, False: 4]
 
346
39
            continue;
347
18
        }
348
18
        let Some(
name17
) = 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 `{map}`: `{key}` is neither a key nor an action");
350
1
            continue;
351
        };
352
17
        match parse_action_chain(chain) {
353
16
            Ok(actions) => {
354
16
                res.insert(name.to_string(), actions);
355
16
            }
356
1
            Err(err) => debug!("Ignoring bind `{map}`: invalid action chain `{chain}`: {err}"),
357
        }
358
    }
359
470
    res
360
470
}
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 
action21
= 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 s141
) = split.next().map(String::from) {
  Branch (387:15): [True: 84, False: 78]
+
  Branch (348:13): [True: 4, False: 0]
+
349
1
            debug!("Ignoring bind `{map}`: `{key}` is neither a key nor an action");
350
1
            continue;
351
        };
352
17
        match parse_action_chain(chain) {
353
16
            Ok(actions) => {
354
16
                res.insert(name.to_string(), actions);
355
16
            }
356
1
            Err(err) => debug!("Ignoring bind `{map}`: invalid action chain `{chain}`: {err}"),
357
        }
358
    }
359
485
    res
360
485
}
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 
action21
= 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 s141
) = split.next().map(String::from) {
  Branch (387:15): [True: 84, False: 78]
 
  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]
@@ -46,6 +46,6 @@
 
  Branch (393:16): [True: 54, False: 3]
 
394
138
            actions.push(act);
395
138
        
}3
396
    }
397
127
    if actions.is_empty() {
  Branch (397:8): [True: 0, False: 78]
 
  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
479
pub fn parse_keymap(key_action: &str) -> Result<(&str, Vec<Action>)> {
410
479
    if key_action.is_empty() {
  Branch (410:8): [True: 425, False: 35]
+
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
494
pub fn parse_keymap(key_action: &str) -> Result<(&str, Vec<Action>)> {
410
494
    if key_action.is_empty() {
  Branch (410:8): [True: 440, False: 35]
 
  Branch (410:8): [True: 0, False: 19]
-
411
425
        return Err(eyre!("Got an empty keybind, skipping"));
412
54
    }
413
54
    debug!("got key_action: {key_action:?}");
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: {key:?}: {action_chain:?}");
418
54
    Ok((key, parse_action_chain(action_chain)
?0
))
419
479
}
420
421
#[cfg(test)]
422
#[path = "binds_tests.rs"]
423
mod tests;
\ No newline at end of file +
411
440
        return Err(eyre!("Got an empty keybind, skipping"));
412
54
    }
413
54
    debug!("got key_action: {key_action:?}");
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: {key:?}: {action_chain:?}");
418
54
    Ok((key, parse_action_chain(action_chain)
?0
))
419
494
}
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 d3db9cdf..26296fa0 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-04 10: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
425
    pub fn builder() -> Self {
15
425
        Self {
16
425
            rank_builder: Default::default(),
17
425
        }
18
425
    }
19
20
423
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
21
423
        self.rank_builder = rank_builder;
22
423
        self
23
423
    }
24
25
425
    pub fn build(self) -> Self {
26
425
        self
27
425
    }
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-05 18:50

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
672
    pub fn builder() -> Self {
15
672
        Self {
16
672
            rank_builder: Default::default(),
17
672
        }
18
672
    }
19
20
670
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
21
670
        self.rank_builder = rank_builder;
22
670
        self
23
670
    }
24
25
672
    pub fn build(self) -> Self {
26
672
        self
27
672
    }
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 01a1d489..1eb38cbe 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-04 10: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
            .
map18
(|e| e.match_item(item))
33
32
            .
max_by_key18
(|res| res.as_ref().map(|matched| matched.rank.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
                .
map4
(|e| format!("{e}"))
47
4
                .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
422
    pub fn builder() -> Self {
61
422
        Self { engines: vec![] }
62
422
    }
63
64
421
    pub fn engines(mut self, mut engines: Vec<Box<dyn MatchEngine>>) -> Self {
65
421
        self.engines.append(&mut engines);
66
421
        self
67
421
    }
68
69
422
    pub fn build(self) -> Self {
70
422
        self
71
422
    }
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
6
        for item in 
items3
{
82
6
            match item.matched_range {
83
2
                MatchRange::ByteRange(..) => {
84
2
                    ranges.extend(item.range_char_indices(text));
85
2
                }
86
2
                MatchRange::CharRange(start, end) => {
87
2
                    ranges.extend(start..end);
88
2
                }
89
2
                MatchRange::Chars(vec) => {
90
2
                    ranges.extend(vec.iter().copied());
91
2
                }
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-05 18:50

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
            .
map18
(|e| e.match_item(item))
33
32
            .
max_by_key18
(|res| res.as_ref().map(|matched| matched.rank.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
                .
map4
(|e| format!("{e}"))
47
4
                .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
448
    pub fn builder() -> Self {
61
448
        Self { engines: vec![] }
62
448
    }
63
64
447
    pub fn engines(mut self, mut engines: Vec<Box<dyn MatchEngine>>) -> Self {
65
447
        self.engines.append(&mut engines);
66
447
        self
67
447
    }
68
69
448
    pub fn build(self) -> Self {
70
448
        self
71
448
    }
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
6
        for item in 
items3
{
82
6
            match item.matched_range {
83
2
                MatchRange::ByteRange(..) => {
84
2
                    ranges.extend(item.range_char_indices(text));
85
2
                }
86
2
                MatchRange::CharRange(start, end) => {
87
2
                    ranges.extend(start..end);
88
2
                }
89
2
                MatchRange::Chars(vec) => {
90
2
                    ranges.extend(vec.iter().copied());
91
2
                }
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
52
        for engine in 
&self.engines51
{
116
52
            let 
result3
= 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
6
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
130
6
        write!(
131
6
            f,
132
            "(And: {})",
133
6
            self.engines
134
6
                .iter()
135
11
                .
map6
(|e| format!("{e}"))
136
6
                .collect::<Vec<_>>()
137
6
                .join(", ")
138
        )
139
6
    }
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
                .
map7
(|e| format!("{e}"))
136
7
                .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 3ebb31f8..efae541a 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,4 +1,4 @@ -

Coverage Report

Created: 2026-09-04 10: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
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]
+

Coverage Report

Created: 2026-09-05 18:50

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
56
            query_builder.push_str("(?i)");
41
56
        
}4
42
43
60
        if param.prefix {
  Branch (43:12): [True: 5, False: 12]
 
  Branch (43:12): [True: 6, False: 37]
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 d2f4dd50..5f699880 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-04 10: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
537
    pub fn builder() -> Self {
30
537
        Self {
31
537
            exact_mode: false,
32
537
            fuzzy_algorithm: FuzzyAlgorithm::SkimV2,
33
537
            rank_builder: Default::default(),
34
537
            typos: Typos::Disabled,
35
537
            filter_mode: false,
36
537
            last_match: false,
37
537
        }
38
537
    }
39
40
    /// Sets whether to use exact matching mode
41
    #[must_use]
42
394
    pub fn exact_mode(mut self, exact_mode: bool) -> Self {
43
394
        self.exact_mode = exact_mode;
44
394
        self
45
394
    }
46
47
    /// Sets the fuzzy matching algorithm to use
48
    #[must_use]
49
394
    pub fn fuzzy_algorithm(mut self, fuzzy_algorithm: FuzzyAlgorithm) -> Self {
50
394
        self.fuzzy_algorithm = fuzzy_algorithm;
51
394
        self
52
394
    }
53
54
    /// Sets the rank builder for scoring matches
55
    #[must_use]
56
394
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
57
394
        self.rank_builder = rank_builder;
58
394
        self
59
394
    }
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
394
    pub fn typos(mut self, typos: Typos) -> Self {
68
394
        self.typos = typos;
69
394
        self
70
394
    }
71
72
    /// Sets filter mode (skips per-character match indices for faster matching)
73
    #[must_use]
74
394
    pub fn filter_mode(mut self, filter_mode: bool) -> Self {
75
394
        self.filter_mode = filter_mode;
76
394
        self
77
394
    }
78
79
    /// When true, prefer the last (rightmost) occurrence on tied scores
80
    #[must_use]
81
394
    pub fn last_match(mut self, last_match: bool) -> Self {
82
394
        self.last_match = last_match;
83
394
        self
84
394
    }
85
86
    /// Builds the factory (currently a no-op, returns self)
87
    #[must_use]
88
537
    pub fn build(self) -> Self {
89
537
        self
90
537
    }
91
}
92
93
impl MatchEngineFactory for ExactOrFuzzyEngineFactory {
94
997
    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
997
        let mut query = query;
104
997
        let mut exact = self.exact_mode;
105
997
        let mut param = ExactMatchingParam::default();
106
997
        param.case = case;
107
108
997
        if query.starts_with('\'') {
  Branch (108:12): [True: 5, False: 943]
-
  Branch (108:12): [True: 2, False: 47]
-
109
7
            exact = !exact;
110
7
            query = &query[1..];
111
990
        }
112
113
997
        if query.starts_with('!') {
  Branch (113:12): [True: 8, False: 940]
-
  Branch (113:12): [True: 4, False: 45]
-
114
12
            query = &query[1..];
115
12
            exact = true;
116
12
            param.inverse = true;
117
985
        }
118
119
997
        if query.is_empty() {
  Branch (119:12): [True: 400, False: 548]
-
  Branch (119:12): [True: 22, False: 27]
-
120
            // if only "!" was provided, will still show all items
121
422
            return Box::new(
122
422
                MatchAllEngine::builder()
123
422
                    .rank_builder(self.rank_builder.clone())
124
422
                    .build(),
125
422
            );
126
575
        }
127
128
575
        if query.starts_with('^') {
  Branch (128:12): [True: 5, False: 543]
+

Coverage Report

Created: 2026-09-05 18:50

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
187
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
552
    pub fn builder() -> Self {
30
552
        Self {
31
552
            exact_mode: false,
32
552
            fuzzy_algorithm: FuzzyAlgorithm::SkimV2,
33
552
            rank_builder: Default::default(),
34
552
            typos: Typos::Disabled,
35
552
            filter_mode: false,
36
552
            last_match: false,
37
552
        }
38
552
    }
39
40
    /// Sets whether to use exact matching mode
41
    #[must_use]
42
409
    pub fn exact_mode(mut self, exact_mode: bool) -> Self {
43
409
        self.exact_mode = exact_mode;
44
409
        self
45
409
    }
46
47
    /// Sets the fuzzy matching algorithm to use
48
    #[must_use]
49
409
    pub fn fuzzy_algorithm(mut self, fuzzy_algorithm: FuzzyAlgorithm) -> Self {
50
409
        self.fuzzy_algorithm = fuzzy_algorithm;
51
409
        self
52
409
    }
53
54
    /// Sets the rank builder for scoring matches
55
    #[must_use]
56
409
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
57
409
        self.rank_builder = rank_builder;
58
409
        self
59
409
    }
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
409
    pub fn typos(mut self, typos: Typos) -> Self {
68
409
        self.typos = typos;
69
409
        self
70
409
    }
71
72
    /// Sets filter mode (skips per-character match indices for faster matching)
73
    #[must_use]
74
409
    pub fn filter_mode(mut self, filter_mode: bool) -> Self {
75
409
        self.filter_mode = filter_mode;
76
409
        self
77
409
    }
78
79
    /// When true, prefer the last (rightmost) occurrence on tied scores
80
    #[must_use]
81
409
    pub fn last_match(mut self, last_match: bool) -> Self {
82
409
        self.last_match = last_match;
83
409
        self
84
409
    }
85
86
    /// Builds the factory (currently a no-op, returns self)
87
    #[must_use]
88
552
    pub fn build(self) -> Self {
89
552
        self
90
552
    }
91
}
92
93
impl MatchEngineFactory for ExactOrFuzzyEngineFactory {
94
1.27k
    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.27k
        let mut query = query;
104
1.27k
        let mut exact = self.exact_mode;
105
1.27k
        let mut param = ExactMatchingParam::default();
106
1.27k
        param.case = case;
107
108
1.27k
        if query.starts_with('\'') {
  Branch (108:12): [True: 5, False: 1.21k]
+
  Branch (108:12): [True: 2, False: 55]
+
109
7
            exact = !exact;
110
7
            query = &query[1..];
111
1.26k
        }
112
113
1.27k
        if query.starts_with('!') {
  Branch (113:12): [True: 8, False: 1.20k]
+
  Branch (113:12): [True: 4, False: 53]
+
114
12
            query = &query[1..];
115
12
            exact = true;
116
12
            param.inverse = true;
117
1.26k
        }
118
119
1.27k
        if query.is_empty() {
  Branch (119:12): [True: 639, False: 578]
+
  Branch (119:12): [True: 30, False: 27]
+
120
            // if only "!" was provided, will still show all items
121
669
            return Box::new(
122
669
                MatchAllEngine::builder()
123
669
                    .rank_builder(self.rank_builder.clone())
124
669
                    .build(),
125
669
            );
126
605
        }
127
128
605
        if query.starts_with('^') {
  Branch (128:12): [True: 5, False: 573]
 
  Branch (128:12): [True: 5, False: 22]
-
129
10
            query = &query[1..];
130
10
            exact = true;
131
10
            param.prefix = true;
132
565
        }
133
134
575
        if query.ends_with('$') {
  Branch (134:12): [True: 1, False: 547]
+
129
10
            query = &query[1..];
130
10
            exact = true;
131
10
            param.prefix = true;
132
595
        }
133
134
605
        if query.ends_with('$') {
  Branch (134:12): [True: 1, False: 577]
 
  Branch (134:12): [True: 6, False: 21]
-
135
7
            query = &query[..(query.len() - 1)];
136
7
            exact = true;
137
7
            param.postfix = true;
138
568
        }
139
140
575
        if exact {
  Branch (140:12): [True: 17, False: 531]
+
135
7
            query = &query[..(query.len() - 1)];
136
7
            exact = true;
137
7
            param.postfix = true;
138
598
        }
139
140
605
        if exact {
  Branch (140:12): [True: 17, False: 561]
 
  Branch (140:12): [True: 12, False: 15]
-
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
546
            Box::new(
148
546
                FuzzyEngine::builder()
149
546
                    .query(query)
150
546
                    .algorithm(self.fuzzy_algorithm)
151
546
                    .case(case)
152
546
                    .typos(self.typos)
153
546
                    .filter_mode(self.filter_mode)
154
546
                    .last_match(self.last_match)
155
546
                    .rank_builder(self.rank_builder.clone())
156
546
                    .build(),
157
546
            )
158
        }
159
997
    }
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
397
    pub fn new(factory: impl MatchEngineFactory + 'static) -> Self {
171
397
        Self {
172
397
            inner: Box::new(factory),
173
397
        }
174
397
    }
175
176
820
    fn parse_andor(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
177
820
        if query.trim().is_empty() {
  Branch (177:12): [True: 386, False: 411]
-
  Branch (177:12): [True: 16, False: 7]
-
178
402
            return self.inner.create_engine_with_case(query, case);
179
418
        }
180
418
        let and_engines = RE_OR_WITH_SPACES
181
418
            .replace_all(&Self::mask_escape_space(query), "|")
182
418
            .split(' ')
183
520
            .
filter_map418
(|and_term| {
184
520
                if and_term.is_empty() {
  Branch (184:20): [True: 2, False: 505]
+
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
576
            Box::new(
148
576
                FuzzyEngine::builder()
149
576
                    .query(query)
150
576
                    .algorithm(self.fuzzy_algorithm)
151
576
                    .case(case)
152
576
                    .typos(self.typos)
153
576
                    .filter_mode(self.filter_mode)
154
576
                    .last_match(self.last_match)
155
576
                    .rank_builder(self.rank_builder.clone())
156
576
                    .build(),
157
576
            )
158
        }
159
1.27k
    }
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
412
    pub fn new(factory: impl MatchEngineFactory + 'static) -> Self {
171
412
        Self {
172
412
            inner: Box::new(factory),
173
412
        }
174
412
    }
175
176
1.09k
    fn parse_andor(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
177
1.09k
        if query.trim().is_empty() {
  Branch (177:12): [True: 625, False: 437]
+
  Branch (177:12): [True: 24, False: 7]
+
178
649
            return self.inner.create_engine_with_case(query, case);
179
444
        }
180
444
        let and_engines = RE_OR_WITH_SPACES
181
444
            .replace_all(&Self::mask_escape_space(query), "|")
182
444
            .split(' ')
183
550
            .
filter_map444
(|and_term| {
184
550
                if and_term.is_empty() {
  Branch (184:20): [True: 2, False: 535]
 
  Branch (184:20): [True: 1, False: 12]
-
185
3
                    return None;
186
517
                }
187
517
                let or_engines = and_term
188
517
                    .split('|')
189
573
                    .
filter_map517
(|term| {
190
573
                        if term.is_empty() {
  Branch (190:28): [True: 26, False: 531]
+
185
3
                    return None;
186
547
                }
187
547
                let or_engines = and_term
188
547
                    .split('|')
189
603
                    .
filter_map547
(|term| {
190
603
                        if term.is_empty() {
  Branch (190:28): [True: 26, False: 561]
 
  Branch (190:28): [True: 1, False: 15]
-
191
27
                            return None;
192
546
                        }
193
546
                        debug!("Creating Or engine for {term}");
194
546
                        Some(
195
546
                            self.inner
196
546
                                .create_engine_with_case(&Self::unmask_escape_space(term), case),
197
546
                        )
198
573
                    })
199
517
                    .collect::<Vec<_>>();
200
517
                debug!("Building or matcher engine from Ors");
201
517
                if or_engines.len() == 1 {
  Branch (201:20): [True: 482, False: 23]
+
191
27
                            return None;
192
576
                        }
193
576
                        debug!("Creating Or engine for {term}");
194
576
                        Some(
195
576
                            self.inner
196
576
                                .create_engine_with_case(&Self::unmask_escape_space(term), case),
197
576
                        )
198
603
                    })
199
547
                    .collect::<Vec<_>>();
200
547
                debug!("Building or matcher engine from Ors");
201
547
                if or_engines.len() == 1 {
  Branch (201:20): [True: 512, False: 23]
 
  Branch (201:20): [True: 9, False: 3]
-
202
491
                    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
520
            })
206
418
            .collect();
207
418
        debug!("Creating and matcher engine from Ors");
208
418
        Box::new(AndEngine::builder().engines(and_engines).build())
209
820
    }
210
211
418
    fn mask_escape_space(string: &str) -> String {
212
418
        string.replace("\\ ", "\0")
213
418
    }
214
215
546
    fn unmask_escape_space(string: &str) -> String {
216
546
        string.replace('\0', " ")
217
546
    }
218
}
219
220
impl MatchEngineFactory for AndOrEngineFactory {
221
820
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
222
820
        self.parse_andor(query, case)
223
820
    }
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
5
    pub fn builder() -> Self {
236
5
        Self {
237
5
            rank_builder: Default::default(),
238
5
        }
239
5
    }
240
241
    /// Sets the rank builder for scoring matches
242
    #[must_use]
243
1
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
244
1
        self.rank_builder = rank_builder;
245
1
        self
246
1
    }
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
5
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
257
5
        Box::new(
258
5
            RegexEngine::builder(query, case)
259
5
                .rank_builder(self.rank_builder.clone())
260
5
                .build(),
261
5
        )
262
5
    }
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 +
202
521
                    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
550
            })
206
444
            .collect();
207
444
        debug!("Creating and matcher engine from Ors");
208
444
        Box::new(AndEngine::builder().engines(and_engines).build())
209
1.09k
    }
210
211
444
    fn mask_escape_space(string: &str) -> String {
212
444
        string.replace("\\ ", "\0")
213
444
    }
214
215
576
    fn unmask_escape_space(string: &str) -> String {
216
576
        string.replace('\0', " ")
217
576
    }
218
}
219
220
impl MatchEngineFactory for AndOrEngineFactory {
221
1.09k
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
222
1.09k
        self.parse_andor(query, case)
223
1.09k
    }
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 0d36f8d7..198e1075 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-04 10: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
571
    pub fn query(mut self, query: &str) -> Self {
58
571
        self.query = query.to_string();
59
571
        self
60
571
    }
61
62
558
    pub fn case(mut self, case: CaseMatching) -> Self {
63
558
        self.case = case;
64
558
        self
65
558
    }
66
67
561
    pub fn algorithm(mut self, algorithm: FuzzyAlgorithm) -> Self {
68
561
        self.algorithm = algorithm;
69
561
        self
70
561
    }
71
72
546
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
73
546
        self.rank_builder = rank_builder;
74
546
        self
75
546
    }
76
77
551
    pub fn typos(mut self, typos: Typos) -> Self {
78
551
        self.typos = typos;
79
551
        self
80
551
    }
81
82
    /// No-op: `fuzzy_match_range` is now always used (`ByteRange`).
83
    /// Kept for API backward compatibility.
84
546
    pub fn filter_mode(self, _filter_mode: bool) -> Self {
85
546
        self
86
546
    }
87
88
546
    pub fn last_match(mut self, last_match: bool) -> Self {
89
546
        self.last_match = last_match;
90
546
        self
91
546
    }
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
571
    fn effective_max_typos(&self) -> Option<usize> {
99
571
        match self.typos {
100
565
            Typos::Disabled => None,
101
4
            Typos::Smart => Some(self.query.chars().count().saturating_div(4)),
102
2
            Typos::Fixed(n) => Some(n),
103
        }
104
571
    }
105
106
    #[allow(deprecated)]
107
568
    pub fn build(self) -> FuzzyEngine {
108
        #[allow(unused_mut)]
109
568
        let mut algorithm = self.algorithm;
110
568
        let max_typos = self.effective_max_typos();
111
568
        let matcher: Box<dyn FuzzyMatcher> = match algorithm {
112
            FuzzyAlgorithm::SkimV2 => {
113
16
                let matcher = SkimMatcherV2::default().element_limit(BYTES_1M);
114
16
                let matcher = match self.case {
115
1
                    CaseMatching::Respect => matcher.respect_case(),
116
1
                    CaseMatching::Ignore => matcher.ignore_case(),
117
14
                    CaseMatching::Smart => matcher.smart_case(),
118
                };
119
16
                debug!("Initialized SkimV2 algorithm");
120
16
                Box::new(matcher)
121
            }
122
            FuzzyAlgorithm::Clangd => {
123
4
                let matcher = ClangdMatcher::default();
124
4
                let matcher = match self.case {
125
1
                    CaseMatching::Respect => matcher.respect_case(),
126
1
                    CaseMatching::Ignore => matcher.ignore_case(),
127
2
                    CaseMatching::Smart => matcher.smart_case(),
128
                };
129
4
                debug!("Initialized Clangd algorithm");
130
4
                Box::new(matcher)
131
            }
132
            #[cfg(feature = "frizbee")]
133
3
            FuzzyAlgorithm::Frizbee => Box::new(FrizbeeMatcher::default().case(self.case).max_typos(max_typos)),
134
            FuzzyAlgorithm::Fzy => {
135
5
                let matcher = FzyMatcher::default().max_typos(max_typos);
136
5
                let matcher = match self.case {
137
1
                    CaseMatching::Respect => matcher.respect_case(),
138
1
                    CaseMatching::Ignore => matcher.ignore_case(),
139
3
                    CaseMatching::Smart => matcher.smart_case(),
140
                };
141
5
                debug!("Initialized Fzy algorithm (max_typos: {max_typos:?})");
142
5
                Box::new(matcher)
143
            }
144
            FuzzyAlgorithm::Arinae => {
145
540
                let matcher = ArinaeMatcher::new(self.case, !
matches!2
(self.typos, Typos::Disabled), self.last_match);
146
540
                debug!("Initialized Arinae algorithm");
147
540
                Box::new(matcher)
148
            }
149
        };
150
151
568
        FuzzyEngine {
152
568
            matcher,
153
568
            query: self.query,
154
568
            rank_builder: self.rank_builder,
155
568
        }
156
568
    }
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
571
    pub fn builder() -> FuzzyEngineBuilder {
170
571
        FuzzyEngineBuilder::default()
171
571
    }
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 &(
start51.5k
,
end51.5k
) 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-05 18:50

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
601
    pub fn query(mut self, query: &str) -> Self {
58
601
        self.query = query.to_string();
59
601
        self
60
601
    }
61
62
588
    pub fn case(mut self, case: CaseMatching) -> Self {
63
588
        self.case = case;
64
588
        self
65
588
    }
66
67
591
    pub fn algorithm(mut self, algorithm: FuzzyAlgorithm) -> Self {
68
591
        self.algorithm = algorithm;
69
591
        self
70
591
    }
71
72
576
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
73
576
        self.rank_builder = rank_builder;
74
576
        self
75
576
    }
76
77
581
    pub fn typos(mut self, typos: Typos) -> Self {
78
581
        self.typos = typos;
79
581
        self
80
581
    }
81
82
    /// No-op: `fuzzy_match_range` is now always used (`ByteRange`).
83
    /// Kept for API backward compatibility.
84
576
    pub fn filter_mode(self, _filter_mode: bool) -> Self {
85
576
        self
86
576
    }
87
88
576
    pub fn last_match(mut self, last_match: bool) -> Self {
89
576
        self.last_match = last_match;
90
576
        self
91
576
    }
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
601
    fn effective_max_typos(&self) -> Option<usize> {
99
601
        match self.typos {
100
593
            Typos::Disabled => None,
101
6
            Typos::Smart => Some(self.query.chars().count().saturating_div(4)),
102
2
            Typos::Fixed(n) => Some(n),
103
        }
104
601
    }
105
106
    #[allow(deprecated)]
107
598
    pub fn build(self) -> FuzzyEngine {
108
        #[allow(unused_mut)]
109
598
        let mut algorithm = self.algorithm;
110
598
        let max_typos = self.effective_max_typos();
111
598
        let matcher: Box<dyn FuzzyMatcher> = match algorithm {
112
            FuzzyAlgorithm::SkimV2 => {
113
16
                let matcher = SkimMatcherV2::default().element_limit(BYTES_1M);
114
16
                let matcher = match self.case {
115
1
                    CaseMatching::Respect => matcher.respect_case(),
116
1
                    CaseMatching::Ignore => matcher.ignore_case(),
117
14
                    CaseMatching::Smart => matcher.smart_case(),
118
                };
119
16
                debug!("Initialized SkimV2 algorithm");
120
16
                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");
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
5
                let matcher = FzyMatcher::default().max_typos(max_typos);
136
5
                let matcher = match self.case {
137
1
                    CaseMatching::Respect => matcher.respect_case(),
138
1
                    CaseMatching::Ignore => matcher.ignore_case(),
139
3
                    CaseMatching::Smart => matcher.smart_case(),
140
                };
141
5
                debug!("Initialized Fzy algorithm (max_typos: {max_typos:?})");
142
5
                Box::new(matcher)
143
            }
144
            FuzzyAlgorithm::Arinae => {
145
567
                let matcher = ArinaeMatcher::new(self.case, !
matches!3
(self.typos, Typos::Disabled), self.last_match);
146
567
                debug!("Initialized Arinae algorithm");
147
567
                Box::new(matcher)
148
            }
149
        };
150
151
598
        FuzzyEngine {
152
598
            matcher,
153
598
            query: self.query,
154
598
            rank_builder: self.rank_builder,
155
598
        }
156
598
    }
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
601
    pub fn builder() -> FuzzyEngineBuilder {
170
601
        FuzzyEngineBuilder::default()
171
601
    }
172
}
173
174
impl MatchEngine for FuzzyEngine {
175
51.6k
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
176
51.6k
        let item_text = item.text();
177
51.6k
        let default_range = [(0, item_text.len())];
178
179
51.6k
        let mut best: Option<(i64, Vec<usize>)> = None;
180
51.6k
        for &(
start51.6k
,
end51.6k
) in item.get_matching_ranges().unwrap_or(&default_range) {
181
51.6k
            let start = min(start, item_text.len());
182
51.6k
            let end = min(end, item_text.len());
183
184
51.6k
            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]
+
185
1
                Some((0i64, vec![]))
186
51.5k
            } else if item_text[start..end].is_empty() {
  Branch (186:23): [True: 64, False: 51.5k]
 
  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
51.5k
                    .map(|(s, indices)| 
{50.6k
192
50.6k
                        let offset = if start != 0 {
  Branch (192:41): [True: 10, False: 50.5k]
+
187
67
                None
188
            } else {
189
51.5k
                self.matcher
190
51.5k
                    .fuzzy_indices(&item_text[start..end], &self.query)
191
51.5k
                    .map(|(s, indices)| 
{50.6k
192
50.6k
                        let offset = if start != 0 {
  Branch (192:41): [True: 10, False: 50.6k]
 
  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 
indices50.6k
=
indices50.6k
.
into_iter50.6k
().
map50.6k
(|i| i + offset).
collect50.6k
();
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 
indices50.6k
=
indices50.6k
.
into_iter50.6k
().
map50.6k
(|i| i + offset).
collect50.6k
();
198
50.6k
                        (s, indices)
199
50.6k
                    })
200
            };
201
202
51.6k
            if result.is_some() {
  Branch (202:16): [True: 50.6k, False: 954]
 
  Branch (202:16): [True: 28, False: 7]
-
203
50.6k
                best = result;
204
50.6k
                break;
205
961
            }
206
        }
207
208
51.5k
        let (
score50.6k
,
indices50.6k
) = 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
+ 1);
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
961
            }
206
        }
207
208
51.6k
        let (
score50.6k
,
indices50.6k
) = 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
+ 1);
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
10
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
229
10
        write!(f, "(Fuzzy: {})", self.query)
230
10
    }
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 +
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.6k
    }
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 bbed787a..0aa18261 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-04 10: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
92
    pub fn new(inner: Box<dyn MatchEngine>) -> Self {
23
92
        Self { inner }
24
92
    }
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 result200
= 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
2
            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-05 18:50

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
103
    pub fn new(inner: Box<dyn MatchEngine>) -> Self {
23
103
        Self { inner }
24
103
    }
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 result200
= 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
2
            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
85
            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
86
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
95
        // Normalize the query
96
86
        let (normalized_query, _) = normalize_with_char_mapping(query);
97
98
        // Create the inner engine with the normalized query
99
86
        let inner_engine = self.inner.create_engine_with_case(&normalized_query, case);
100
101
        // Wrap it in a NormalizedEngine
102
86
        Box::new(NormalizedEngine::new(inner_engine))
103
86
    }
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 +
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
85
            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
14
    pub fn new(inner: impl MatchEngineFactory + 'static) -> Self {
89
14
        Self { inner: Box::new(inner) }
90
14
    }
91
}
92
93
impl MatchEngineFactory for NormalizedEngineFactory {
94
97
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
95
        // Normalize the query
96
97
        let (normalized_query, _) = normalize_with_char_mapping(query);
97
98
        // Create the inner engine with the normalized query
99
97
        let inner_engine = self.inner.create_engine_with_case(&normalized_query, case);
100
101
        // Wrap it in a NormalizedEngine
102
97
        Box::new(NormalizedEngine::new(inner_engine))
103
97
    }
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 fb764b3b..39d9e352 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,5 +1,5 @@ -

Coverage Report

Created: 2026-09-04 10: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
12
    pub fn builder(query: &str, case: CaseMatching) -> Self {
21
12
        let mut query_builder = String::new();
22
23
12
        match case {
24
1
            CaseMatching::Ignore => query_builder.push_str("(?i)"),
25
11
            CaseMatching::Respect | CaseMatching::Smart => {}
26
        }
27
28
12
        query_builder.push_str(query);
29
30
12
        RegexEngine {
31
12
            query_regex: Regex::new(&query_builder).ok(),
32
12
            rank_builder: Default::default(),
33
12
        }
34
12
    }
35
36
5
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
37
5
        self.rank_builder = rank_builder;
38
5
        self
39
5
    }
40
41
12
    pub fn build(self) -> Self {
42
12
        self
43
12
    }
44
}
45
46
impl MatchEngine for RegexEngine {
47
9
    fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
48
9
        let mut matched_result = None;
49
9
        let item_text = item.text();
50
9
        let default_range = [(0, item_text.len())];
51
9
        for &(start, end) in item.get_matching_ranges().unwrap_or(&default_range) {
52
9
            let start = min(start, item_text.len());
53
9
            let end = min(end, item_text.len());
54
9
            if self.query_regex.is_none() {
  Branch (54:16): [True: 0, False: 1]
-
  Branch (54:16): [True: 1, False: 7]
-
55
1
                matched_result = Some((0, 0));
56
1
                break;
57
8
            }
58
59
            matched_result =
60
8
                regex_match(&item_text[start..end], self.query_regex.as_ref()).map(|(s, e)| (
s + start6
,
e + start6
));
61
62
8
            if matched_result.is_some() {
  Branch (62:16): [True: 1, False: 0]
-
  Branch (62:16): [True: 5, False: 2]
-
63
6
                break;
64
2
            }
65
        }
66
67
9
        let (
begin7
,
end7
) = matched_result
?2
;
68
7
        let score = i32::try_from(end - begin).unwrap_or(i32::MAX);
69
70
7
        Some(MatchResult {
71
7
            rank: self.rank_builder.build_rank(score, begin, end, &item_text),
72
7
            matched_range: MatchRange::ByteRange(begin, end),
73
7
        })
74
9
    }
75
}
76
77
impl Display for RegexEngine {
78
3
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
79
3
        write!(
80
3
            f,
81
            "(Regex: {})",
82
3
            self.query_regex
83
3
                .as_ref()
84
3
                .map_or(String::new(), |re| re.as_str().to_string())
85
        )
86
3
    }
87
}
88
89
#[cfg(test)]
90
#[cfg_attr(coverage, coverage(off))]
91
mod tests {
92
    use super::*;
93
94
    fn engine(query: &str, case: CaseMatching) -> RegexEngine {
95
        RegexEngine::builder(query, case).build()
96
    }
97
98
    #[test]
99
    fn matches_regex_pattern() {
100
        let e = engine("ba.", CaseMatching::Respect);
101
        let result = e.match_item(&"foobar".to_string()).unwrap();
102
        assert_eq!(result.matched_range, MatchRange::ByteRange(3, 6));
103
    }
104
105
    #[test]
106
    fn no_match_returns_none() {
107
        let e = engine("xyz", CaseMatching::Respect);
108
        assert!(e.match_item(&"foobar".to_string()).is_none());
109
    }
110
111
    #[test]
112
    fn ignore_case_matches_insensitively() {
113
        let e = engine("foo", CaseMatching::Ignore);
114
        assert!(e.match_item(&"FOOBAR".to_string()).is_some());
115
    }
116
117
    #[test]
118
    fn smart_case_is_sensitive() {
119
        let e = engine("foo", CaseMatching::Smart);
120
        assert!(e.match_item(&"foobar".to_string()).is_some());
121
        assert!(e.match_item(&"FOOBAR".to_string()).is_none());
122
    }
123
124
    #[test]
125
    fn empty_query_matches_everything() {
126
        // An empty pattern produces a regex that matches at position 0.
127
        let e = engine("", CaseMatching::Respect);
128
        assert!(e.match_item(&"anything".to_string()).is_some());
129
    }
130
131
    #[test]
132
    fn invalid_regex_yields_no_regex_and_matches_all() {
133
        // An unparsable pattern leaves `query_regex` as None, which short-circuits
134
        // to a zero-length match for every item.
135
        let e = engine("(", CaseMatching::Respect);
136
        let result = e.match_item(&"abc".to_string()).unwrap();
137
        assert_eq!(result.matched_range, MatchRange::ByteRange(0, 0));
138
    }
139
140
    #[test]
141
    fn display_shows_pattern() {
142
        let e = engine("ba.", CaseMatching::Respect);
143
        assert_eq!(format!("{e}"), "(Regex: ba.)");
144
    }
145
}
\ No newline at end of file +

Coverage Report

Created: 2026-09-05 18:50

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
            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]
+
63
9
                break;
64
2
            }
65
        }
66
67
12
        let (
begin10
,
end10
) = matched_result
?2
;
68
10
        let score = i32::try_from(end - begin).unwrap_or(i32::MAX);
69
70
10
        Some(MatchResult {
71
10
            rank: self.rank_builder.build_rank(score, begin, end, &item_text),
72
10
            matched_range: MatchRange::ByteRange(begin, end),
73
10
        })
74
12
    }
75
}
76
77
impl Display for RegexEngine {
78
3
    fn fmt(&self, f: &mut Formatter) -> Result<(), Error> {
79
3
        write!(
80
3
            f,
81
            "(Regex: {})",
82
3
            self.query_regex
83
3
                .as_ref()
84
3
                .map_or(String::new(), |re| re.as_str().to_string())
85
        )
86
3
    }
87
}
88
89
#[cfg(test)]
90
#[cfg_attr(coverage, coverage(off))]
91
mod tests {
92
    use super::*;
93
94
    fn engine(query: &str, case: CaseMatching) -> RegexEngine {
95
        RegexEngine::builder(query, case).build()
96
    }
97
98
    #[test]
99
    fn matches_regex_pattern() {
100
        let e = engine("ba.", CaseMatching::Respect);
101
        let result = e.match_item(&"foobar".to_string()).unwrap();
102
        assert_eq!(result.matched_range, MatchRange::ByteRange(3, 6));
103
    }
104
105
    #[test]
106
    fn no_match_returns_none() {
107
        let e = engine("xyz", CaseMatching::Respect);
108
        assert!(e.match_item(&"foobar".to_string()).is_none());
109
    }
110
111
    #[test]
112
    fn ignore_case_matches_insensitively() {
113
        let e = engine("foo", CaseMatching::Ignore);
114
        assert!(e.match_item(&"FOOBAR".to_string()).is_some());
115
    }
116
117
    #[test]
118
    fn smart_case_is_sensitive() {
119
        let e = engine("foo", CaseMatching::Smart);
120
        assert!(e.match_item(&"foobar".to_string()).is_some());
121
        assert!(e.match_item(&"FOOBAR".to_string()).is_none());
122
    }
123
124
    #[test]
125
    fn empty_query_matches_everything() {
126
        // An empty pattern produces a regex that matches at position 0.
127
        let e = engine("", CaseMatching::Respect);
128
        assert!(e.match_item(&"anything".to_string()).is_some());
129
    }
130
131
    #[test]
132
    fn invalid_regex_yields_no_regex_and_matches_all() {
133
        // An unparsable pattern leaves `query_regex` as None, which short-circuits
134
        // to a zero-length match for every item.
135
        let e = engine("(", CaseMatching::Respect);
136
        let result = e.match_item(&"abc".to_string()).unwrap();
137
        assert_eq!(result.matched_range, MatchRange::ByteRange(0, 0));
138
    }
139
140
    #[test]
141
    fn display_shows_pattern() {
142
        let e = engine("ba.", CaseMatching::Respect);
143
        assert_eq!(format!("{e}"), "(Regex: ba.)");
144
    }
145
}
\ No newline at end of file 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 94f11497..cce5baa5 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-04 10: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_idx79
=
text.chars()88
.
position88
(|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_result47
= self.before_engine.match_item(before_item)
?32
;
50
47
        let 
after_result42
= 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
11
            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
                    .
filter11
(|(_, (byte_idx, _))| *byte_idx >= start &&
*byte_idx < end30
)
  Branch (63:50): [True: 24, False: 0]
+

Coverage Report

Created: 2026-09-05 18:50

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_idx79
=
text.chars()88
.
position88
(|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_result47
= self.before_engine.match_item(before_item)
?32
;
50
47
        let 
after_result42
= 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
11
            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
                    .
filter11
(|(_, (byte_idx, _))| *byte_idx >= start &&
*byte_idx < end30
)
  Branch (63:50): [True: 24, False: 0]
 
  Branch (63:50): [True: 6, False: 1]
 
64
11
                    .map(|(char_idx, _)| char_idx)
65
11
                    .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(
indices27
) =>
indices27
.
into_iter27
().
map27
(|i| i + offset).
collect27
(),
74
2
            MatchRange::CharRange(
start1
,
end1
) => (
start..end1
).
map1
(|i| i + offset).
collect1
(),
75
14
            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
                    .
filter14
(|(_, (byte_idx, _))| *byte_idx >= start &&
*byte_idx < end39
)
  Branch (80:50): [True: 33, False: 0]
 
  Branch (80:50): [True: 6, False: 1]
-
81
14
                    .map(|(char_idx, _)| 
char_idx5
+
offset5
)
82
14
                    .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
            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
63
    fn create_engine_with_case(&self, query: &str, case: crate::CaseMatching) -> Box<dyn MatchEngine> {
137
        // Check if the query contains the delimiter
138
63
        if let Some(
delimiter_pos32
) = query.find(self.delimiter) {
  Branch (138:16): [True: 31, False: 30]
+
81
14
                    .map(|(char_idx, _)| 
char_idx5
+
offset5
)
82
14
                    .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
            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_pos32
) = query.find(self.delimiter) {
  Branch (138:16): [True: 31, False: 41]
 
  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
31
            self.inner.create_engine_with_case(query, case)
150
        }
151
63
    }
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
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 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 3c09000d..18a4e885 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,6 +1,6 @@ -

Coverage Report

Created: 2026-09-04 10: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
379
pub fn normalize_with_char_mapping(s: &str) -> (String, Vec<usize>) {
10
379
    let mut normalized = String::new();
11
379
    let mut mapping = Vec::new();
12
13
1.78k
    for (orig_char_idx, orig_char) in 
s379
.
chars379
().
enumerate379
() {
14
        // Decompose this character into NFD form
15
1.93k
        for decomposed_char in 
orig_char1.78k
.
nfd1.78k
() {
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-05 18:50

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
390
pub fn normalize_with_char_mapping(s: &str) -> (String, Vec<usize>) {
10
390
    let mut normalized = String::new();
11
390
    let mut mapping = Vec::new();
12
13
1.78k
    for (orig_char_idx, orig_char) in 
s390
.
chars390
().
enumerate390
() {
14
        // Decompose this character into NFD form
15
1.93k
        for decomposed_char in 
orig_char1.78k
.
nfd1.78k
() {
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
1.78k
            
}152
20
        }
21
    }
22
23
379
    (normalized, mapping)
24
379
}
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_map113
(|&idx| char_mapping.get(idx).copied())
34
113
        .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
1.56k
    for (orig_byte_pos, orig_char) in 
s293
.
char_indices293
() {
46
        // Decompose this character into NFD form
47
1.71k
        for decomposed_char in 
orig_char1.56k
.
nfd1.56k
() {
48
1.71k
            if !unicode_normalization::char::is_combining_mark(decomposed_char) {
  Branch (48:16): [True: 1.53k, False: 145]
+
17
1.78k
                normalized.push(decomposed_char);
18
1.78k
                mapping.push(orig_char_idx);
19
1.78k
            
}152
20
        }
21
    }
22
23
390
    (normalized, mapping)
24
390
}
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_map113
(|&idx| char_mapping.get(idx).copied())
34
113
        .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
1.56k
    for (orig_byte_pos, orig_char) in 
s293
.
char_indices293
() {
46
        // Decompose this character into NFD form
47
1.71k
        for decomposed_char in 
orig_char1.56k
.
nfd1.56k
() {
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]
 
49
1.56k
                let char_start = normalized.len();
50
1.56k
                normalized.push(decomposed_char);
51
                // Map each byte of the decomposed char to the original byte position
52
1.63k
                for _ in 
char_start1.56k
..normalized.len() {
53
1.63k
                    byte_mapping.push(orig_byte_pos);
54
1.63k
                }
55
151
            }
56
        }
57
    }
58
59
293
    (normalized, byte_mapping)
60
293
}
61
62
/// Map a byte range from normalized string back to original string.
63
///
64
/// Given a (start, end) byte range in a normalized string and the byte mapping,
65
/// returns the corresponding (start, end) byte range in the original string.
66
90
pub fn map_byte_range_to_original(
67
90
    normalized_start: usize,
68
90
    normalized_end: usize,
69
90
    byte_mapping: &[usize],
70
90
    original_str: &str,
71
90
) -> (usize, usize) {
72
90
    if byte_mapping.is_empty() || 
normalized_start89
>= byte_mapping.len() {
  Branch (72:8): [True: 0, False: 83]
   Branch (72:35): [True: 0, False: 83]
@@ -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
92
pub fn regex_match(choice: &str, pattern: Option<&Regex>) -> Option<(usize, usize)> {
97
92
    let 
pat91
= pattern
?1
;
98
91
    let 
mat58
= pat.find(choice)
?33
;
99
58
    Some((mat.start(), mat.end()))
100
92
}
101
102
55
pub fn contains_upper(string: &str) -> bool {
103
148
    for ch in 
string55
.
chars55
() {
104
148
        if ch.is_uppercase() {
  Branch (104:12): [True: 0, False: 47]
+
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 
pat94
= pattern
?1
;
98
94
    let 
mat61
= pat.find(choice)
?33
;
99
61
    Some((mat.start(), mat.end()))
100
95
}
101
102
55
pub fn contains_upper(string: &str) -> bool {
103
148
    for ch in 
string55
.
chars55
() {
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
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 c2a5c98e..2fae6d27 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,4 +1,4 @@ -

Coverage Report

Created: 2026-09-04 10: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
64
    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
95
fn parse_index(s: &str) -> i32 {
30
95
    s.parse()
31
95
        .unwrap_or(if s.starts_with('-') { 
i32::MIN22
} else {
i32::MAX73
})
  Branch (31:23): [True: 12, False: 52]
+

Coverage Report

Created: 2026-09-05 18:50

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
64
    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
95
fn parse_index(s: &str) -> i32 {
30
95
    s.parse()
31
95
        .unwrap_or(if s.starts_with('-') { 
i32::MIN22
} else {
i32::MAX73
})
  Branch (31:23): [True: 12, False: 52]
 
  Branch (31:23): [True: 10, False: 21]
 
32
95
}
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
95
    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
95
        let opt_caps = FIELD_RANGE.captures(range);
42
95
        if let Some(
caps88
) = opt_caps {
  Branch (42:16): [True: 59, False: 0]
 
  Branch (42:16): [True: 29, False: 7]
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 7782fc13..08adcbe4 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,12 +1,12 @@
-

Coverage Report

Created: 2026-09-04 10: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-05 18:50

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.6k
fn compute_cell<const ALLOW_TYPOS: bool>(
31
63.6k
    is_match: bool,
32
63.6k
    is_first: bool,
33
63.6k
    bonus_j: Score,
34
63.6k
    diag_score: Score,
35
63.6k
    diag_was_diag: bool,
36
63.6k
    up_score: Score,
37
63.6k
    left_score: Score,
38
63.6k
    left_was_diag: bool,
39
63.6k
) -> (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.6k
    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.6k
    let match_val = (diag_score + MATCH_BONUS + bonus) * Score::from(is_match);
49
63.6k
    let mismatch_val = if ALLOW_TYPOS {
  Branch (49:27): [True: 0, False: 55.9k]
 
  Branch (49:27): [True: 2.94k, False: 0]
 
  Branch (49:27): [True: 0, False: 3.18k]
 
  Branch (49:27): [True: 1.58k, False: 0]
-
50
4.52k
        (diag_score - MISMATCH_PENALTY) * Score::from(!is_match)
51
    } else {
52
59.0k
        0
53
    };
54
63.5k
    let diag_val = match_val + mismatch_val;
55
56
    // --- UP (skip pattern char, typos only — const-generic elides entirely) ---
57
63.5k
    let up_val = if ALLOW_TYPOS { 
up_score - TYPO_PENALTY4.52k
} else {
059.0k
};
  Branch (57:21): [True: 0, False: 55.8k]
+
50
4.52k
        (diag_score - MISMATCH_PENALTY) * Score::from(!is_match)
51
    } else {
52
59.1k
        0
53
    };
54
63.6k
    let diag_val = match_val + mismatch_val;
55
56
    // --- UP (skip pattern char, typos only — const-generic elides entirely) ---
57
63.6k
    let up_val = if ALLOW_TYPOS { 
up_score - TYPO_PENALTY4.52k
} else {
059.1k
};
  Branch (57:21): [True: 0, False: 55.9k]
 
  Branch (57:21): [True: 2.94k, False: 0]
 
  Branch (57:21): [True: 0, False: 3.18k]
 
  Branch (57:21): [True: 1.58k, False: 0]
-
58
59
    // --- LEFT (skip choice char, branchless gap penalty) ---
60
    // GAP_OPEN when left_was_diag, GAP_EXTEND otherwise.
61
    // pen = GAP_EXTEND + (GAP_OPEN - GAP_EXTEND) * left_was_diag
62
63.5k
    let left_val = left_score - (GAP_EXTEND + (GAP_OPEN - GAP_EXTEND) * Score::from(left_was_diag));
63
64
    // --- Best score (branchless max chain) ---
65
63.5k
    let best = diag_val.max(up_val).max(left_val);
66
67
    // --- Direction (branchless select) ---
68
    // We encode direction as a u8 and build it without branches.
69
    // Priority: Diag > Up > Left > None (when best <= 0).
70
    //
71
    // Start with Left (2), override with Up if up wins, override with Diag
72
    // if diag wins, override with None if best <= 0.
73
    // For exact mode (ALLOW_TYPOS=false), Diag is only valid when is_match.
74
63.5k
    let diag_wins = if ALLOW_TYPOS {
  Branch (74:24): [True: 0, False: 55.8k]
+
58
59
    // --- LEFT (skip choice char, branchless gap penalty) ---
60
    // GAP_OPEN when left_was_diag, GAP_EXTEND otherwise.
61
    // pen = GAP_EXTEND + (GAP_OPEN - GAP_EXTEND) * left_was_diag
62
63.6k
    let left_val = left_score - (GAP_EXTEND + (GAP_OPEN - GAP_EXTEND) * Score::from(left_was_diag));
63
64
    // --- Best score (branchless max chain) ---
65
63.6k
    let best = diag_val.max(up_val).max(left_val);
66
67
    // --- Direction (branchless select) ---
68
    // We encode direction as a u8 and build it without branches.
69
    // Priority: Diag > Up > Left > None (when best <= 0).
70
    //
71
    // Start with Left (2), override with Up if up wins, override with Diag
72
    // if diag wins, override with None if best <= 0.
73
    // For exact mode (ALLOW_TYPOS=false), Diag is only valid when is_match.
74
63.6k
    let diag_wins = if ALLOW_TYPOS {
  Branch (74:24): [True: 0, False: 55.9k]
 
  Branch (74:24): [True: 2.94k, False: 0]
 
  Branch (74:24): [True: 0, False: 3.18k]
 
  Branch (74:24): [True: 1.58k, False: 0]
@@ -14,11 +14,11 @@
 
  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.1k
        is_match && 
diag_val >= left_val51.6k
  Branch (77:9): [True: 51.2k, False: 4.70k]
 
  Branch (77:9): [True: 0, False: 0]
 
  Branch (77:9): [True: 403, False: 2.77k]
 
  Branch (77:9): [True: 0, False: 0]
-
78
    };
79
63.5k
    let up_wins = ALLOW_TYPOS && 
!diag_wins4.52k
&&
up_val >= left_val4.12k
;
  Branch (79:19): [True: 0, False: 55.8k]
+
78
    };
79
63.6k
    let up_wins = ALLOW_TYPOS && 
!diag_wins4.52k
&&
up_val >= left_val4.12k
;
  Branch (79:19): [True: 0, False: 55.9k]
   Branch (79:34): [True: 0, False: 0]
 
  Branch (79:19): [True: 2.94k, False: 0]
   Branch (79:34): [True: 2.76k, False: 178]
@@ -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
50.6k
        .get_or(|| 
RefCell::new353
(
SWMatrix::zero353
(
n + 1353
,
mcols353
)))
138
50.6k
        .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
51.5k
        for i in 
1..=n50.6k
{
153
51.5k
            *base_ptr.add(i * cols) = CELL_ZERO;
154
51.5k
        }
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.6k
    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.6k
    let positive = best > 0;
87
    // When positive: dir_bits; when not: 0 (Dir::None).
88
63.6k
    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.6k
    let dir: Dir = unsafe { std::mem::transmute(dir_val) };
92
93
63.6k
    (best, dir)
94
63.6k
}
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
50.6k
        .get_or(|| 
RefCell::new359
(
SWMatrix::zero359
(
n + 1359
,
mcols359
)))
138
50.6k
        .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
51.5k
        for i in 
1..=n50.6k
{
153
51.5k
            *base_ptr.add(i * cols) = CELL_ZERO;
154
51.5k
        }
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]
@@ -48,8 +48,8 @@
   Branch (179:27): [Folded, False: 0]
 
  Branch (179:12): [True: 0, False: 104]
   Branch (179:27): [Folded, False: 104]
-
  Branch (179:12): [True: 0, False: 50.8k]
-  Branch (179:27): [Folded, False: 50.8k]
+
  Branch (179:12): [True: 0, False: 50.9k]
+  Branch (179:27): [Folded, False: 50.9k]
 
  Branch (179:12): [True: 0, False: 0]
   Branch (179:27): [Folded, False: 0]
 
  Branch (179:12): [True: 0, False: 164]
@@ -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
4
                        for k in 
zero_lo..=zero_hi2
{
200
4
                            *row_ptr.add(k) = CELL_ZERO;
201
4
                        }
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: 425, 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,10 +154,10 @@
 
  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
656
                *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]
+
  Branch (222:16): [True: 0, False: 50.9k]
 
  Branch (222:16): [True: 0, False: 0]
 
  Branch (222:16): [True: 0, False: 164]
 
  Branch (222:16): [True: 0, False: 0]
@@ -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
51.5k
        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
51.5k
        };
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
63.0k
        for j in 
j_lo..=j_hi51.5k
{
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
51.5k
        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
51.5k
        };
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
63.1k
        for j in 
j_lo..=j_hi51.5k
{
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
63.0k
            unsafe {
269
63.0k
                *cur_ptr.add(jm) = Cell::new(best, dir);
270
63.0k
            }
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
59.0k
                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
63.1k
            unsafe {
269
63.1k
                *cur_ptr.add(jm) = Cell::new(best, dir);
270
63.1k
            }
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
51.8k
            for j in 
last_j_lo..=last_j_hi50.6k
{
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
51.8k
            for j in 
last_j_lo..=last_j_hi50.6k
{
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.8k
} 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: 171]
 
  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.8k
} 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: 171]
 
  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::new321
()));
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::new327
(
Vec::new327
()));
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.6k
{
  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]
@@ -362,10 +362,10 @@
   Branch (324:24): [True: 0, False: 0]
 
  Branch (324:15): [True: 0, False: 0]
   Branch (324:24): [True: 0, False: 0]
-
325
51.5k
            let jm = j - col_off;
326
            // SAFETY: jm and i are within the matrix bounds established above.
327
51.5k
            let cell_val = unsafe { *base_ptr.add(i * cols).add(jm) };
328
51.5k
            match cell_val.dir() {
329
                Dir::Diag => {
330
51.2k
                    if pat[i - 1].eq(cho[j - 1], respect_case) {
  Branch (330:24): [True: 0, False: 0]
+
325
51.6k
            let jm = j - col_off;
326
            // SAFETY: jm and i are within the matrix bounds established above.
327
51.6k
            let cell_val = unsafe { *base_ptr.add(i * cols).add(jm) };
328
51.6k
            match cell_val.dir() {
329
                Dir::Diag => {
330
51.2k
                    if pat[i - 1].eq(cho[j - 1], respect_case) {
  Branch (330:24): [True: 0, False: 0]
 
  Branch (330:24): [True: 0, False: 0]
 
  Branch (330:24): [True: 104, False: 0]
-
  Branch (330:24): [True: 50.8k, False: 0]
+
  Branch (330:24): [True: 50.9k, False: 0]
 
  Branch (330:24): [True: 0, False: 0]
 
  Branch (330:24): [True: 99, False: 0]
 
  Branch (330:24): [True: 0, False: 0]
@@ -394,7 +394,7 @@
 
  Branch (347:12): [True: 2, False: 13]
 
  Branch (347:12): [True: 0, False: 0]
 
  Branch (347:12): [True: 0, False: 0]
-
348
11
            return None;
349
50.5k
        }
350
351
        // Traceback produces indices in reverse order; reverse is O(n)
352
        // vs sort_unstable's O(n log n).
353
50.5k
        indices_ref.reverse();
354
355
        // Move ownership out of the thread-local buffer by cloning the vec's
356
        // contents into a fresh Vec (cheap since MatchIndices is Vec<usize>),
357
        // but avoid an extra clone by using `to_vec()` which reallocates once.
358
50.5k
        let out = indices_ref.to_vec();
359
50.5k
        Some((best_score, out))
360
    } else {
361
45
        Some((best_score, Vec::default()))
362
    }
363
50.6k
}
364
365
// ---------------------------------------------------------------------------
366
// Range DP — full matrix, minimal traceback (begin + end only)
367
// ---------------------------------------------------------------------------
368
369
/// Full matrix DP followed by a traceback that only records the first and
370
/// last matched positions (not every index). Used by `fuzzy_match_range` to
371
/// avoid allocating and populating the full index vec when only the span is
372
/// needed.
373
#[allow(clippy::too_many_lines)]
374
29
pub(super) fn range_dp<const ALLOW_TYPOS: bool, C: Atom>(
375
29
    cho: &[C],
376
29
    pat: &[C],
377
29
    bonuses: &[Score],
378
29
    respect_case: bool,
379
29
    full_buf: &ThreadLocal<RefCell<SWMatrix>>,
380
29
    use_last_match: bool,
381
29
    banding: &BandingInfo,
382
29
) -> Option<(Score, usize, usize)> {
383
29
    let n = pat.len();
384
29
    let m = cho.len();
385
386
29
    let j_start = banding.j_first;
387
29
    let col_off = j_start - 1;
388
29
    let mcols = m - col_off + 1;
389
390
29
    let mut buf = full_buf
391
29
        .get_or(|| 
RefCell::new13
(
SWMatrix::zero13
(
n + 113
,
mcols13
)))
392
29
        .borrow_mut();
393
29
    buf.resize(n + 1, mcols);
394
395
29
    let base_ptr = buf.data.as_mut_ptr();
396
29
    let cols = buf.cols;
397
398
    // Initialize row 0 to CELL_ZERO (all-zero bytes: score=0, dir=None=0).
399
    // Column 0 of each subsequent row is also CELL_ZERO.
400
    // SAFETY: base_ptr points to a valid allocation of (n+1)*cols Cells.
401
    unsafe {
402
29
        std::ptr::write_bytes(base_ptr, 0, mcols);
403
92
        for i in 
1..=n29
{
404
92
            *base_ptr.add(i * cols) = CELL_ZERO;
405
92
        }
406
    }
407
408
29
    let (row_lo_arr, row_hi_arr) = if ALLOW_TYPOS {
  Branch (408:39): [True: 0, False: 0]
+
348
11
            return None;
349
50.6k
        }
350
351
        // Traceback produces indices in reverse order; reverse is O(n)
352
        // vs sort_unstable's O(n log n).
353
50.6k
        indices_ref.reverse();
354
355
        // Move ownership out of the thread-local buffer by cloning the vec's
356
        // contents into a fresh Vec (cheap since MatchIndices is Vec<usize>),
357
        // but avoid an extra clone by using `to_vec()` which reallocates once.
358
50.6k
        let out = indices_ref.to_vec();
359
50.6k
        Some((best_score, out))
360
    } else {
361
45
        Some((best_score, Vec::default()))
362
    }
363
50.6k
}
364
365
// ---------------------------------------------------------------------------
366
// Range DP — full matrix, minimal traceback (begin + end only)
367
// ---------------------------------------------------------------------------
368
369
/// Full matrix DP followed by a traceback that only records the first and
370
/// last matched positions (not every index). Used by `fuzzy_match_range` to
371
/// avoid allocating and populating the full index vec when only the span is
372
/// needed.
373
#[allow(clippy::too_many_lines)]
374
29
pub(super) fn range_dp<const ALLOW_TYPOS: bool, C: Atom>(
375
29
    cho: &[C],
376
29
    pat: &[C],
377
29
    bonuses: &[Score],
378
29
    respect_case: bool,
379
29
    full_buf: &ThreadLocal<RefCell<SWMatrix>>,
380
29
    use_last_match: bool,
381
29
    banding: &BandingInfo,
382
29
) -> Option<(Score, usize, usize)> {
383
29
    let n = pat.len();
384
29
    let m = cho.len();
385
386
29
    let j_start = banding.j_first;
387
29
    let col_off = j_start - 1;
388
29
    let mcols = m - col_off + 1;
389
390
29
    let mut buf = full_buf
391
29
        .get_or(|| 
RefCell::new13
(
SWMatrix::zero13
(
n + 113
,
mcols13
)))
392
29
        .borrow_mut();
393
29
    buf.resize(n + 1, mcols);
394
395
29
    let base_ptr = buf.data.as_mut_ptr();
396
29
    let cols = buf.cols;
397
398
    // Initialize row 0 to CELL_ZERO (all-zero bytes: score=0, dir=None=0).
399
    // Column 0 of each subsequent row is also CELL_ZERO.
400
    // SAFETY: base_ptr points to a valid allocation of (n+1)*cols Cells.
401
    unsafe {
402
29
        std::ptr::write_bytes(base_ptr, 0, mcols);
403
92
        for i in 
1..=n29
{
404
92
            *base_ptr.add(i * cols) = CELL_ZERO;
405
92
        }
406
    }
407
408
29
    let (row_lo_arr, row_hi_arr) = if ALLOW_TYPOS {
  Branch (408:39): [True: 0, False: 0]
 
  Branch (408:39): [True: 0, False: 0]
 
  Branch (408:39): [True: 0, False: 0]
 
  Branch (408:39): [True: 0, False: 0]
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 081a336e..64360d87 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,11 +1,11 @@
-

Coverage Report

Created: 2026-09-04 10: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-05 18:50

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
.
position18
(|&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
.
rposition131
(|&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
.
position18
(|&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
.
rposition131
(|&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]
-
78
                // No case distinction for this byte (digit, symbol, etc.).
79
2
                memchr(lo, haystack)
80
            } else {
81
                // Check both variants and return the earliest occurrence.
82
166
                let p_lo = memchr(lo, haystack);
83
166
                let p_hi = memchr(hi, haystack);
84
166
                match (p_lo, p_hi) {
85
166
                    (None, 
x12
) | (
x154
, None) => x,
86
0
                    (Some(a), Some(b)) => Some(a.min(b)),
87
                }
88
            }
89
        }
90
170
    }
91
92
    /// Case-sensitive backward search uses SIMD-backed `memrchr`.
93
    #[inline(always)]
94
51.2k
    fn find_last_in(self, haystack: &[Self], respect_case: bool) -> Option<usize> {
95
51.2k
        if respect_case {
  Branch (95:12): [True: 97, False: 50.7k]
+
78
                // No case distinction for this byte (digit, symbol, etc.).
79
2
                memchr(lo, haystack)
80
            } else {
81
                // Check both variants and return the earliest occurrence.
82
166
                let p_lo = memchr(lo, haystack);
83
166
                let p_hi = memchr(hi, haystack);
84
166
                match (p_lo, p_hi) {
85
166
                    (None, 
x12
) | (
x154
, None) => x,
86
0
                    (Some(a), Some(b)) => Some(a.min(b)),
87
                }
88
            }
89
        }
90
170
    }
91
92
    /// Case-sensitive backward search uses SIMD-backed `memrchr`.
93
    #[inline(always)]
94
51.2k
    fn find_last_in(self, haystack: &[Self], respect_case: bool) -> Option<usize> {
95
51.2k
        if respect_case {
  Branch (95:12): [True: 97, False: 50.8k]
 
  Branch (95:12): [True: 13, False: 354]
-
96
110
            memrchr(self, haystack)
97
        } else {
98
51.1k
            let lo = self.to_ascii_lowercase();
99
51.1k
            let hi = self.to_ascii_uppercase();
100
51.1k
            if lo == hi {
  Branch (100:16): [True: 0, False: 50.7k]
+
96
110
            memrchr(self, haystack)
97
        } else {
98
51.1k
            let lo = self.to_ascii_lowercase();
99
51.1k
            let hi = self.to_ascii_uppercase();
100
51.1k
            if lo == hi {
  Branch (100:16): [True: 0, False: 50.8k]
 
  Branch (100:16): [True: 1, False: 353]
 
101
1
                memrchr(lo, haystack)
102
            } else {
103
                // Return the rightmost occurrence across both case variants.
104
51.1k
                let p_lo = memrchr(lo, haystack);
105
51.1k
                let p_hi = memrchr(hi, haystack);
106
51.1k
                match (p_lo, p_hi) {
107
51.1k
                    (None, 
x30
) | (
x51.1k
, None) => x,
108
1
                    (Some(a), Some(b)) => Some(a.max(b)),
109
                }
110
            }
111
        }
112
51.2k
    }
113
}
114
impl Atom for char {
115
    #[inline(always)]
116
1.61k
    fn eq(self, other: Self, respect_case: bool) -> bool {
117
1.61k
        char_equal(self, other, respect_case)
118
1.61k
    }
119
120
    #[inline(always)]
121
0
    fn eq_ignore_case(self, b: Self) -> bool {
122
0
        char_equal(self, b, false)
123
0
    }
124
125
    #[inline(always)]
126
890
    fn is_lowercase(self) -> bool {
127
890
        self.is_lowercase()
128
890
    }
129
}
130
131
#[cfg(test)]
132
#[cfg_attr(coverage, coverage(off))]
133
mod tests {
134
    use super::*;
135
136
    #[test]
137
    fn u8_find_first_case_sensitive() {
138
        let hay = b"abcABC";
139
        // Case-sensitive uses memchr directly.
140
        assert_eq!(b'A'.find_first_in(hay, true), Some(3));
141
        assert_eq!(b'z'.find_first_in(hay, true), None);
142
    }
143
144
    #[test]
145
    fn u8_find_first_case_insensitive_letter() {
146
        let hay = b"xxABCxx";
147
        // Case-insensitive letter checks both variants and returns the earliest.
148
        assert_eq!(b'a'.find_first_in(hay, false), Some(2));
149
        assert_eq!(b'C'.find_first_in(hay, false), Some(4));
150
    }
151
152
    #[test]
153
    fn u8_find_first_case_insensitive_digit() {
154
        let hay = b"a1b2";
155
        // Digits have no case distinction → single memchr branch.
156
        assert_eq!(b'2'.find_first_in(hay, false), Some(3));
157
        assert_eq!(b'9'.find_first_in(hay, false), None);
158
    }
159
160
    #[test]
161
    fn u8_find_first_only_one_case_present() {
162
        let hay = b"hello"; // only lowercase present
163
        // Uppercase query, only lowercase in haystack → (Some, None) arm.
164
        assert_eq!(b'L'.find_first_in(hay, false), Some(2));
165
    }
166
167
    #[test]
168
    fn u8_find_last_case_variants() {
169
        let hay = b"aAbA";
170
        // Case-sensitive backward search.
171
        assert_eq!(b'A'.find_last_in(hay, true), Some(3));
172
        // Case-insensitive returns the rightmost across both variants.
173
        assert_eq!(b'a'.find_last_in(hay, false), Some(3));
174
    }
175
176
    #[test]
177
    fn u8_find_last_case_insensitive_digit_and_single_case() {
178
        let hay = b"1a1";
179
        // Digit: no case distinction.
180
        assert_eq!(b'1'.find_last_in(hay, false), Some(2));
181
        // Only lowercase present, uppercase query → (None, Some)/(Some, None) arm.
182
        assert_eq!(b'A'.find_last_in(hay, false), Some(1));
183
    }
184
185
    #[test]
186
    fn char_atom_eq_and_case() {
187
        assert!('a'.eq('A', false));
188
        assert!(!'a'.eq('A', true));
189
        assert!('a'.eq('a', true));
190
        assert!('A'.eq('a', false));
191
        assert!('a'.is_lowercase());
192
        assert!(!'A'.is_lowercase());
193
        // Default (non-SIMD) find impls for char.
194
        let hay: Vec<char> = "abAB".chars().collect();
195
        assert_eq!('A'.find_first_in(&hay, true), Some(2));
196
        assert_eq!('a'.find_last_in(&hay, false), Some(2));
197
    }
198
}
\ No newline at end of file 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 e9140c21..f296693e 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-04 10: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_first50.6k
,
row_bounds50.6k
) = if ALLOW_TYPOS {
  Branch (32:36): [True: 0, False: 55]
-
  Branch (32:36): [True: 0, False: 51.0k]
+

Coverage Report

Created: 2026-09-05 18:50

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_first50.6k
,
row_bounds50.6k
) = 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 
fm50.5k
= 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 
fm50.6k
= compute_first_match_cols(pat, cho, respect_case)
?648
;
36
50.6k
        let lm = compute_last_match_cols(pat, cho, respect_case)
?0
;
37
50.6k
        (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.6k
};
  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]
@@ -14,7 +14,7 @@
 
  Branch (40:24): [True: 0, False: 66]
 
  Branch (40:24): [True: 8, False: 0]
 
  Branch (40:24): [True: 35, False: 0]
-
41
50.6k
    let min_true_matches = if ALLOW_TYPOS { 
n84
.
div_ceil84
(2) } else {
050.5k
};
  Branch (41:31): [True: 0, False: 38]
+
41
50.6k
    let min_true_matches = if ALLOW_TYPOS { 
n84
.
div_ceil84
(2) } else {
050.6k
};
  Branch (41:31): [True: 0, False: 38]
 
  Branch (41:31): [True: 0, False: 50.4k]
 
  Branch (41:31): [True: 0, False: 0]
 
  Branch (41:31): [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
52.1k
    for i in 
0..n51.2k
{
79
608k
        let 
found52.1k
=
cho[start..].iter()52.1k
.
position52.1k
(|&c| pat[i].eq(c, respect_case));
80
        {
81
52.1k
            let 
pos51.4k
= 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
52.1k
    for i in 
0..n51.2k
{
79
608k
        let 
found52.1k
=
cho[start..].iter()52.1k
.
position52.1k
(|&c| pat[i].eq(c, respect_case));
80
        {
81
52.1k
            let 
pos51.5k
= found
?645
;
82
51.5k
            first[i] = start + pos + 1; // 1-indexed column
83
51.5k
            start = start + pos + 1; // next char must be strictly after
84
        }
85
    }
86
50.6k
    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 f70f7cf4..15b59ae0 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-04 10: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-05 18:50

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.6k
pub(super) fn compute_last_match_cols<C: Atom>(
19
50.6k
    pat: &[C],
20
50.6k
    cho: &[C],
21
50.6k
    respect_case: bool,
22
50.6k
) -> Option<[usize; MAX_PAT_LEN]> {
23
50.6k
    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.6k
    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
51.3k
    for i in (
0..n50.5k
).
rev50.5k
() {
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 
i660
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.6k
    }
29
50.6k
    let m = cho.len();
30
50.6k
    let mut last = [0usize; MAX_PAT_LEN];
31
50.6k
    let mut end = m; // search up to this choice index (exclusive)
32
51.4k
    for i in (
0..n50.6k
).
rev50.6k
() {
33
51.4k
        let found = pat[i].find_last_in(&cho[..end], respect_case);
34
        {
35
51.4k
            let pos = found
?0
;
36
51.4k
            last[i] = pos + 1; // 1-indexed column
37
51.4k
            end = pos; // previous char must be strictly before
38
        }
39
    }
40
50.6k
    Some(last)
41
50.6k
}
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.6k
pub(super) fn compute_row_col_bounds(
52
50.6k
    n: usize,
53
50.6k
    m: usize,
54
50.6k
    first_match: &[usize; MAX_PAT_LEN],
55
50.6k
    last_match: &[usize; MAX_PAT_LEN],
56
50.6k
) -> ([usize; MAX_PAT_LEN], [usize; MAX_PAT_LEN]) {
57
50.6k
    let mut lo = [0usize; MAX_PAT_LEN];
58
50.6k
    let mut hi = [0usize; MAX_PAT_LEN];
59
60
    // Start with the raw first/last match bounds.
61
50.6k
    lo[..n].copy_from_slice(&first_match[..n]);
62
50.6k
    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.6k
    for 
i678
in 0..n.saturating_sub(1) {
69
678
        let next_lo = lo[i + 1];
70
678
        if next_lo > 1 {
  Branch (70:12): [True: 491, False: 0]
 
  Branch (70:12): [True: 186, False: 1]
-
71
659
            hi[i] = hi[i].max(next_lo - 1);
72
659
        
}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 
i660
in 1..n {
79
660
        lo[i] = lo[i].min(hi[i - 1] + 1);
80
660
    }
81
82
    // Clamp to valid range.
83
51.2k
    for i in 
0..n50.5k
{
84
51.2k
        lo[i] = lo[i].max(1).min(m);
85
51.2k
        hi[i] = hi[i].max(lo[i]).min(m);
86
51.2k
    }
87
88
50.5k
    (lo, hi)
89
50.5k
}
\ No newline at end of file +
71
677
            hi[i] = hi[i].max(next_lo - 1);
72
677
        
}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.6k
    for 
i678
in 1..n {
79
678
        lo[i] = lo[i].min(hi[i - 1] + 1);
80
678
    }
81
82
    // Clamp to valid range.
83
51.2k
    for i in 
0..n50.6k
{
84
51.2k
        lo[i] = lo[i].max(1).min(m);
85
51.2k
        hi[i] = hi[i].max(lo[i]).min(m);
86
51.2k
    }
87
88
50.6k
    (lo, hi)
89
50.6k
}
\ 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 1021997a..34374302 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-04 10: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-05 18:50

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
373
    pub fn zero(rows: usize, cols: usize) -> Self {
78
373
        let mut res = SWMatrix::default();
79
373
        res.resize(rows, cols);
80
373
        res
81
373
    }
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: 347, 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
429
            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 e35ca72c..dbc8b923 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-04 10: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_iter50.6k
=
std::iter::once50.6k
(START_OF_STRING_BONUS).
chain50.6k
(
cho50.6k
.
windows50.6k
(2).
map50.6k
(|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]
-  Branch (64:65): [True: 206k, False: 350k]
+

Coverage Report

Created: 2026-09-05 18:50

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_iter50.6k
=
std::iter::once50.6k
(START_OF_STRING_BONUS).
chain50.6k
(
cho50.6k
.
windows50.6k
(2).
map50.6k
(|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: 207k, 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
541
    pub fn new(case: CaseMatching, allow_typos: bool, use_last_match: bool) -> Self {
88
541
        Self {
89
541
            case,
90
541
            allow_typos,
91
541
            use_last_match,
92
541
            ..Default::default()
93
541
        }
94
541
    }
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
568
    pub fn new(case: CaseMatching, allow_typos: bool, use_last_match: bool) -> Self {
88
568
        Self {
89
568
            case,
90
568
            allow_typos,
91
568
            use_last_match,
92
568
            ..Default::default()
93
568
        }
94
568
    }
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 
banding50.6k
= 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 
banding50.6k
= 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::new326
())).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)
?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::new332
(
Vec::new332
())).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() && 
pattern51.2k
.
is_ascii51.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 11cd5292..134dfefb 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-04 10: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-05 18:50

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 c74942c2..ed6b5531 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-04 10: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
19
    fn default() -> Self {
49
19
        Self {
50
19
            case: CaseMatching::Ignore,
51
19
            use_cache: true,
52
19
            c_cache: ThreadLocal::new(),
53
19
            p_cache: ThreadLocal::new(),
54
19
        }
55
19
    }
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
3
    pub fn smart_case(mut self) -> Self {
69
3
        self.case = CaseMatching::Smart;
70
3
        self
71
3
    }
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
203
        for ch in 
string53
.
chars53
() {
89
203
            if ch.is_uppercase() {
  Branch (89:16): [True: 0, False: 196]
+

Coverage Report

Created: 2026-09-05 18:50

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
203
        for ch in 
string53
.
chars53
() {
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::new11
())).borrow_mut();
111
68
        let mut pattern_chars = self.p_cache.get_or(|| 
RefCell::new11
(
Vec::new11
())).borrow_mut();
112
113
68
        choice_chars.clear();
114
943
        for char in 
choice68
.
chars68
() {
115
943
            choice_chars.push(char);
116
943
        }
117
118
68
        pattern_chars.clear();
119
245
        for char in 
pattern68
.
chars68
() {
120
245
            pattern_chars.push(char);
121
245
        }
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 bd0d7bbf..90f97ca3 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-04 10: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
12
    fn default() -> Self {
22
12
        Self {
23
12
            config: Config {
24
12
                casing: CaseMatching::Respect.into(),
25
12
                ..Config::default()
26
12
            },
27
12
        }
28
12
    }
29
}
30
31
impl FrizbeeMatcher {
32
    /// Set the max typos to use
33
    #[must_use]
34
4
    pub fn max_typos(mut self, typos: Option<usize>) -> Self {
35
4
        self.config.max_typos = Some(typos.map_or(0, |x| 
u16::try_from2
(
x2
).
unwrap_or2
(u16::MAX)));
36
4
        self
37
4
    }
38
39
    /// Set the case matching strategy
40
    #[must_use]
41
9
    pub fn case(mut self, case: CaseMatching) -> Self {
42
9
        self.config.casing = case.into();
43
9
        self
44
9
    }
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
("",
&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.indices32
.
into_iter32
().
map32
(|index| index as usize).
collect32
(),
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
21
    fn from(case: CaseMatching) -> Self {
80
21
        match case {
81
14
            CaseMatching::Respect => frizbee::CaseMatching::Respect,
82
2
            CaseMatching::Ignore => frizbee::CaseMatching::Ignore,
83
5
            CaseMatching::Smart => frizbee::CaseMatching::Smart,
84
        }
85
21
    }
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-05 18:50

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_from3
(
x3
).
unwrap_or3
(u16::MAX)));
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
("",
&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.indices32
.
into_iter32
().
map32
(|index| index as usize).
collect32
(),
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 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 205d0a9c..aea7e7af 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-04 10: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-05 18:50

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() || 
ch104
.
is_ascii_digit104
())
  Branch (86:21): [True: 344, False: 74]
 
  Branch (86:21): [True: 429, False: 30]
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 943e2ff3..455f8268 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-04 10: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-05 18:50

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 2324a97c..67c1215d 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-04 10: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
38
    fn default() -> Self {
75
38
        let score_match = 16;
76
38
        let gap_start = -3;
77
38
        let gap_extension = -1;
78
38
        let bonus_first_char_multiplier = 2;
79
80
38
        Self {
81
38
            score_match,
82
38
            gap_start,
83
38
            gap_extension,
84
38
            bonus_first_char_multiplier,
85
38
            bonus_head: score_match / 2,
86
38
            bonus_break: score_match / 2 + gap_extension,
87
38
            bonus_camel: score_match / 2 + 2 * gap_extension,
88
38
            bonus_consecutive: -(gap_start + gap_extension),
89
38
            penalty_case_mismatch: gap_extension * 2,
90
38
        }
91
38
    }
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
15
        for row in 
0..self.rows5
{
184
114
            for col in 
0..self.cols15
{
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-05 18:50

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
38
    fn default() -> Self {
75
38
        let score_match = 16;
76
38
        let gap_start = -3;
77
38
        let gap_extension = -1;
78
38
        let bonus_first_char_multiplier = 2;
79
80
38
        Self {
81
38
            score_match,
82
38
            gap_start,
83
38
            gap_extension,
84
38
            bonus_first_char_multiplier,
85
38
            bonus_head: score_match / 2,
86
38
            bonus_break: score_match / 2 + gap_extension,
87
38
            bonus_camel: score_match / 2 + 2 * gap_extension,
88
38
            bonus_consecutive: -(gap_start + gap_extension),
89
38
            penalty_case_mismatch: gap_extension * 2,
90
38
        }
91
38
    }
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
15
        for row in 
0..self.rows5
{
184
114
            for col in 
0..self.cols15
{
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
15
        for row in 
0..self.rows5
{
205
114
            for col in 
0..self.cols15
{
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 1c6d6b7e..e71a917e 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-04 10: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
47.8k
    for (idx, &c) in 
choice10.5k
.
iter10.5k
().
enumerate10.5k
() {
7
47.8k
        match pattern_iter.peek() {
8
47.6k
            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-05 18:50

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
47.8k
    for (idx, &c) in 
choice10.5k
.
iter10.5k
().
enumerate10.5k
() {
7
47.8k
        match pattern_iter.peek() {
8
47.6k
            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 3924a7fd..e0c404e6 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,16 +1,16 @@
-

Coverage Report

Created: 2026-09-04 10: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
21
                let transformed = parse_transform_fields(delimiter, &escape_ansi(orig_text), trans_fields);
91
21
                (Some(orig_text.into()), Box::from(transformed))
92
            }
93
3
            (false, false) if contains_ansi => (None, escape_ansi(orig_text).into()),
  Branch (93:31): [True: 2, False: 52.0k]
+

Coverage Report

Created: 2026-09-05 18:50

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
21
                let transformed = parse_transform_fields(delimiter, &escape_ansi(orig_text), trans_fields);
91
21
                (Some(orig_text.into()), Box::from(transformed))
92
            }
93
3
            (false, false) if contains_ansi => (None, escape_ansi(orig_text).into()),
  Branch (93:31): [True: 2, False: 51.9k]
 
  Branch (93:31): [True: 1, False: 39]
-
94
52.0k
            (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_text6
.
is_none6
() {
  Branch (101:12): [True: 5, False: 52.0k]
+
94
52.0k
            (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_text6
.
is_none6
() {
  Branch (101:12): [True: 5, False: 51.9k]
   Branch (101:30): [True: 4, False: 1]
 
  Branch (101:12): [True: 1, False: 53]
   Branch (101:30): [True: 1, False: 0]
-
102
5
            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: 5, False: 52.0k]
+
102
5
            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: 5, False: 51.9k]
 
  Branch (108:12): [True: 1, False: 53]
-
109
6
            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: 52.0k]
-  Branch (112:61): [True: 8, False: 3]
+
109
6
            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_ansi27
{
  Branch (112:45): [True: 14, False: 51.9k]
+  Branch (112:61): [True: 10, False: 4]
 
  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
52.0k
            (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: 52.0k, False: 23]
+
113
23
            let (stripped, info) = strip_ansi(&temp_text);
114
23
            (Some(stripped), Some(info))
115
        } else {
116
52.0k
            (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: 23]
 
  Branch (121:34): [True: 52, False: 2]
 
122
52.0k
            None
123
        } else {
124
            // Use stripped text for matching ranges when ANSI is enabled
125
25
            let text_for_matching = if let Some(
stripped1
) = stripped_text.as_ref() {
  Branch (125:44): [True: 0, False: 23]
 
  Branch (125:44): [True: 1, False: 1]
@@ -22,55 +22,55 @@
 
  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
2
                        
}0
156
0
                    }
157
                }
158
2
                Some(adjusted_ranges)
159
            } else {
160
23
                Some(parse_matching_fields(delimiter, text_for_matching, matching_fields))
161
            }
162
        };
163
164
52.0k
        let metadata =
165
52.0k
            if orig_text.is_some() || 
stripped_text52.0k
.
is_some52.0k
() ||
ansi_info52.0k
.
is_some52.0k
() ||
matching_ranges52.0k
.
is_some52.0k
() {
  Branch (165:16): [True: 24, False: 52.0k]
-  Branch (165:39): [True: 8, False: 52.0k]
-  Branch (165:66): [True: 0, False: 52.0k]
+
154
2
                            adjusted_ranges.push((pos, pos + cleaned_field.len()));
155
2
                        
}0
156
0
                    }
157
                }
158
2
                Some(adjusted_ranges)
159
            } else {
160
23
                Some(parse_matching_fields(delimiter, text_for_matching, matching_fields))
161
            }
162
        };
163
164
52.0k
        let metadata =
165
52.0k
            if orig_text.is_some() || 
stripped_text52.0k
.
is_some52.0k
() ||
ansi_info52.0k
.
is_some52.0k
() ||
matching_ranges52.0k
.
is_some52.0k
() {
  Branch (165:16): [True: 24, False: 51.9k]
+  Branch (165:39): [True: 10, False: 51.9k]
+  Branch (165:66): [True: 0, False: 51.9k]
   Branch (165:89): [True: 22, False: 51.9k]
 
  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
69
                Some(Box::new(DefaultSkimItemMetadata {
167
69
                    orig_text: orig_text.map(std::string::String::into_boxed_str),
168
69
                    stripped_text: stripped_text.map(std::string::String::into_boxed_str),
169
69
                    ansi_info,
170
69
                    matching_ranges,
171
69
                    hidden_ranges: None,
172
69
                    disabled: false,
173
69
                }))
174
            } else {
175
52.0k
                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: 52.0k, 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
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]
 
  Branch (195:12): [True: 36, False: 5]
 
196
52.0k
            return self;
197
14
        }
198
        // Resolve the ranges before touching `self.metadata`; the `text()` borrow must
199
        // end before the mutable borrow below.
200
14
        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]
 
  Branch (204:12): [True: 5, False: 0]
-
205
14
            self.metadata.get_or_insert_default().hidden_ranges = Some(ranges);
206
14
        
}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(
meta1.24k
) = &self.metadata
  Branch (222:16): [True: 1.22k, False: 63.6k]
+
205
14
            self.metadata.get_or_insert_default().hidden_ranges = Some(ranges);
206
14
        
}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
65.0k
    pub fn stripped_text(&self) -> Option<&str> {
222
65.0k
        if let Some(
meta1.30k
) = &self.metadata
  Branch (222:16): [True: 1.28k, False: 63.6k]
 
  Branch (222:16): [True: 20, False: 24]
-
223
1.24k
            && let Some(
stripped_text315
) = &meta.stripped_text
  Branch (223:20): [True: 300, False: 926]
+
223
1.30k
            && let Some(
stripped_text373
) = &meta.stripped_text
  Branch (223:20): [True: 358, False: 926]
 
  Branch (223:20): [True: 15, False: 5]
-
224
        {
225
315
            Some(stripped_text.as_ref())
226
        } else {
227
64.5k
            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(
meta7
) = &self.metadata
  Branch (234:16): [True: 7, False: 50.1k]
+
224
        {
225
373
            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(
meta7
) = &self.metadata
  Branch (234:16): [True: 7, False: 50.1k]
 
  Branch (234:16): [True: 0, False: 0]
 
235
7
            && let Some(
orig5
) = &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.44k
    pub fn ansi_info(&self) -> Option<&Vec<(usize, usize)>> {
246
6.44k
        if let Some(
meta388
) = &self.metadata
  Branch (246:16): [True: 376, False: 6.05k]
+
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.49k
    pub fn ansi_info(&self) -> Option<&Vec<(usize, usize)>> {
246
6.49k
        if let Some(
meta412
) = &self.metadata
  Branch (246:16): [True: 400, False: 6.08k]
 
  Branch (246:16): [True: 12, False: 0]
-
247
388
            && let Some(
info102
) = &meta.ansi_info
  Branch (247:20): [True: 92, False: 284]
+
247
412
            && let Some(
info126
) = &meta.ansi_info
  Branch (247:20): [True: 116, False: 284]
 
  Branch (247:20): [True: 10, False: 2]
-
248
        {
249
102
            Some(info)
250
        } else {
251
6.34k
            None
252
        }
253
6.44k
    }
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(
meta73
) = &self.metadata {
  Branch (258:16): [True: 70, False: 51.2k]
+
248
        {
249
126
            Some(info)
250
        } else {
251
6.36k
            None
252
        }
253
6.49k
    }
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(
meta81
) = &self.metadata {
  Branch (258:16): [True: 78, 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)]39
)
260
        } else {
261
51.2k
            None
262
        }
263
51.2k
    }
264
265
    /// Getter for `hidden_ranges` stored in metadata
266
    #[must_use]
267
12.7k
    pub fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
268
12.7k
        if let Some(
meta763
) = &self.metadata {
  Branch (268:16): [True: 752, False: 12.0k]
+
259
81
            meta.matching_ranges.as_ref().map(|v| 
v.as_ref()39
as
&[(usize, usize)]39
)
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(
meta811
) = &self.metadata {
  Branch (268:16): [True: 800, False: 12.0k]
 
  Branch (268:16): [True: 11, False: 0]
-
269
763
            meta.hidden_ranges.as_ref().map(|v| 
v.as_ref()95
as
&[(usize, usize)]95
)
270
        } else {
271
12.0k
            None
272
        }
273
12.7k
    }
274
}
275
276
impl DefaultSkimItem {
277
    /// Get the display text (with ANSI codes if present) for rendering purposes
278
    #[inline]
279
    #[allow(dead_code)]
280
    #[must_use]
281
1
    pub fn get_display_text(&self) -> &str {
282
1
        &self.text
283
1
    }
284
}
285
286
impl From<String> for DefaultSkimItem {
287
1
    fn from(value: String) -> Self {
288
1
        Self {
289
1
            text: Box::from(value),
290
1
            metadata: None,
291
1
        }
292
1
    }
293
}
294
295
impl SkimItem for DefaultSkimItem {
296
    #[inline]
297
64.8k
    fn text(&self) -> Cow<'_, str> {
298
        // Return stripped text for matching when ANSI is enabled
299
64.8k
        if let Some(
stripped288
) = self.stripped_text() {
  Branch (299:16): [True: 276, False: 64.5k]
+
269
811
            meta.hidden_ranges.as_ref().map(|v| 
v.as_ref()95
as
&[(usize, usize)]95
)
270
        } else {
271
12.0k
            None
272
        }
273
12.8k
    }
274
}
275
276
impl DefaultSkimItem {
277
    /// Get the display text (with ANSI codes if present) for rendering purposes
278
    #[inline]
279
    #[allow(dead_code)]
280
    #[must_use]
281
1
    pub fn get_display_text(&self) -> &str {
282
1
        &self.text
283
1
    }
284
}
285
286
impl From<String> for DefaultSkimItem {
287
1
    fn from(value: String) -> Self {
288
1
        Self {
289
1
            text: Box::from(value),
290
1
            metadata: None,
291
1
        }
292
1
    }
293
}
294
295
impl SkimItem for DefaultSkimItem {
296
    #[inline]
297
64.9k
    fn text(&self) -> Cow<'_, str> {
298
        // Return stripped text for matching when ANSI is enabled
299
64.9k
        if let Some(
stripped346
) = self.stripped_text() {
  Branch (299:16): [True: 334, False: 64.6k]
 
  Branch (299:16): [True: 12, False: 29]
-
300
288
            Cow::Borrowed(stripped)
301
        } else {
302
64.5k
            Cow::Borrowed(&self.text)
303
        }
304
64.8k
    }
305
306
50.1k
    fn output(&self) -> Cow<'_, str> {
307
50.1k
        if let Some(
orig5
) = self.orig_text() {
  Branch (307:16): [True: 5, False: 50.1k]
+
300
346
            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(
orig5
) = 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.33k
    fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
320
6.33k
        self.hidden_ranges()
321
6.33k
    }
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.44k
    fn display(&self, context: DisplayContext) -> Line<'_> {
327
        // If we have ANSI info, we need to handle ANSI codes properly and map matches
328
6.44k
        if self.ansi_info().is_some() {
  Branch (328:12): [True: 92, False: 6.33k]
+
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.49k
    fn display(&self, context: DisplayContext) -> Line<'_> {
327
        // If we have ANSI info, we need to handle ANSI codes properly and map matches
328
6.49k
        if self.ansi_info().is_some() {
  Branch (328:12): [True: 116, False: 6.36k]
 
  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]
+
329
            // Parse the ANSI text using ansi-to-tui to get proper styled spans
330
124
            let text_bytes = self.text.as_bytes().to_vec();
331
124
            let Ok(parsed_text) = text_bytes.into_text() else {
  Branch (331:17): [True: 116, False: 0]
 
  Branch (331:17): [True: 8, False: 0]
-
332
                // Fallback to plain text if parsing fails
333
0
                return context.to_line(Cow::Borrowed(&self.text));
334
            };
335
336
            // Extract all spans from the parsed text (should be a single line)
337
100
            let all_spans: Vec<Span> = parsed_text.lines.into_iter().flat_map(|line| line.spans).collect();
338
339
            // When fields are hidden (--hide-nth), drop the hidden characters from the
340
            // parsed spans while preserving their ANSI styling, and remap the match
341
            // positions into the resulting visible coordinate space. The remaining
342
            // highlighting logic then runs unchanged on visible-coordinate CharIndices.
343
100
            let (all_spans, matches) = if let Some(
hidden19
) = self.hidden_ranges() {
  Branch (343:47): [True: 17, False: 75]
+
332
                // Fallback to plain text if parsing fails
333
0
                return context.to_line(Cow::Borrowed(&self.text));
334
            };
335
336
            // Extract all spans from the parsed text (should be a single line)
337
124
            let all_spans: Vec<Span> = parsed_text.lines.into_iter().flat_map(|line| line.spans).collect();
338
339
            // When fields are hidden (--hide-nth), drop the hidden characters from the
340
            // parsed spans while preserving their ANSI styling, and remap the match
341
            // positions into the resulting visible coordinate space. The remaining
342
            // highlighting logic then runs unchanged on visible-coordinate CharIndices.
343
124
            let (all_spans, matches) = if let Some(
hidden19
) = self.hidden_ranges() {
  Branch (343:47): [True: 17, False: 99]
 
  Branch (343:47): [True: 2, False: 6]
-
344
19
                let stripped = self.text();
345
19
                let (_, map) = project_visible_text(stripped.as_ref(), hidden);
346
19
                let visible_spans = retain_visible_spans(all_spans, &map);
347
19
                let indices = project_match_indices(stripped.as_ref(), &context.matches, &map);
348
19
                (visible_spans, Matches::CharIndices(indices))
349
            } else {
350
81
                (all_spans, context.matches.clone())
351
            };
352
353
            // Now apply highlighting based on matched positions
354
            // We need to map match positions from stripped text to original text
355
100
            match matches {
356
72
                crate::Matches::CharIndices(ref indices) => {
357
                    // Indices are already in stripped text coordinates (same as parsed ANSI text)
358
                    // No need to remap since both matching and ANSI parsing strip the codes
359
72
                    let highlight_positions: std::collections::HashSet<usize> = indices.iter().copied().collect();
360
361
                    // Apply highlighting to characters at those positions
362
72
                    let mut new_spans = Vec::new();
363
72
                    let mut char_idx = 0;
364
365
150
                    for span in 
all_spans72
{
366
150
                        let mut current_content = String::new();
367
150
                        let mut highlighted_content = String::new();
368
150
                        let base_style = span.style;
369
370
629
                        for ch in 
span.content.chars()150
{
371
629
                            if highlight_positions.contains(&char_idx) {
  Branch (371:32): [True: 129, False: 484]
+
344
19
                let stripped = self.text();
345
19
                let (_, map) = project_visible_text(stripped.as_ref(), hidden);
346
19
                let visible_spans = retain_visible_spans(all_spans, &map);
347
19
                let indices = project_match_indices(stripped.as_ref(), &context.matches, &map);
348
19
                (visible_spans, Matches::CharIndices(indices))
349
            } else {
350
105
                (all_spans, context.matches.clone())
351
            };
352
353
            // Now apply highlighting based on matched positions
354
            // We need to map match positions from stripped text to original text
355
124
            match matches {
356
96
                crate::Matches::CharIndices(ref indices) => {
357
                    // Indices are already in stripped text coordinates (same as parsed ANSI text)
358
                    // No need to remap since both matching and ANSI parsing strip the codes
359
96
                    let highlight_positions: std::collections::HashSet<usize> = indices.iter().copied().collect();
360
361
                    // Apply highlighting to characters at those positions
362
96
                    let mut new_spans = Vec::new();
363
96
                    let mut char_idx = 0;
364
365
198
                    for span in 
all_spans96
{
366
198
                        let mut current_content = String::new();
367
198
                        let mut highlighted_content = String::new();
368
198
                        let base_style = span.style;
369
370
845
                        for ch in 
span.content.chars()198
{
371
845
                            if highlight_positions.contains(&char_idx) {
  Branch (371:32): [True: 189, False: 640]
 
  Branch (371:32): [True: 6, False: 10]
-
372
                                // Flush normal content if any
373
135
                                if !current_content.is_empty() {
  Branch (373:36): [True: 36, False: 93]
+
372
                                // Flush normal content if any
373
195
                                if !current_content.is_empty() {
  Branch (373:36): [True: 60, False: 129]
 
  Branch (373:36): [True: 0, False: 6]
-
374
36
                                    // Combine ANSI style with context base_style
375
36
                                    new_spans.push(Span::styled(
376
36
                                        current_content.clone(),
377
36
                                        merge_styles(context.base_style, base_style),
378
36
                                    ));
379
36
                                    current_content.clear();
380
99
                                }
381
135
                                highlighted_content.push(ch);
382
                            } else {
383
                                // Flush highlighted content if any
384
494
                                if !highlighted_content.is_empty() {
  Branch (384:36): [True: 30, False: 454]
+
374
60
                                    // Combine ANSI style with context base_style
375
60
                                    new_spans.push(Span::styled(
376
60
                                        current_content.clone(),
377
60
                                        merge_styles(context.base_style, base_style),
378
60
                                    ));
379
60
                                    current_content.clear();
380
135
                                }
381
195
                                highlighted_content.push(ch);
382
                            } else {
383
                                // Flush highlighted content if any
384
650
                                if !highlighted_content.is_empty() {
  Branch (384:36): [True: 48, False: 592]
 
  Branch (384:36): [True: 2, False: 8]
-
385
32
                                    // Combine styles: use highlight bg, preserve ANSI fg and modifiers
386
32
                                    new_spans.push(Span::styled(
387
32
                                        highlighted_content.clone(),
388
32
                                        merge_styles(base_style, context.matched_style),
389
32
                                    ));
390
32
                                    highlighted_content.clear();
391
462
                                }
392
494
                                current_content.push(ch);
393
                            }
394
629
                            char_idx += 1;
395
                        }
396
397
                        // Flush remaining content
398
150
                        if !current_content.is_empty() {
  Branch (398:28): [True: 124, False: 21]
+
385
50
                                    // Combine styles: use highlight bg, preserve ANSI fg and modifiers
386
50
                                    new_spans.push(Span::styled(
387
50
                                        highlighted_content.clone(),
388
50
                                        merge_styles(base_style, context.matched_style),
389
50
                                    ));
390
50
                                    highlighted_content.clear();
391
600
                                }
392
650
                                current_content.push(ch);
393
                            }
394
845
                            char_idx += 1;
395
                        }
396
397
                        // Flush remaining content
398
198
                        if !current_content.is_empty() {
  Branch (398:28): [True: 166, False: 27]
 
  Branch (398:28): [True: 4, False: 1]
-
399
128
                            // Combine ANSI style with context base_style
400
128
                            new_spans.push(Span::styled(
401
128
                                current_content,
402
128
                                merge_styles(context.base_style, base_style),
403
128
                            ));
404
128
                        
}22
405
150
                        if !highlighted_content.is_empty() {
  Branch (405:28): [True: 21, False: 124]
+
399
170
                            // Combine ANSI style with context base_style
400
170
                            new_spans.push(Span::styled(
401
170
                                current_content,
402
170
                                merge_styles(context.base_style, base_style),
403
170
                            ));
404
170
                        
}28
405
198
                        if !highlighted_content.is_empty() {
  Branch (405:28): [True: 27, False: 166]
 
  Branch (405:28): [True: 1, False: 4]
-
406
22
                            // Combine styles: use highlight bg, preserve ANSI fg and modifiers
407
22
                            new_spans.push(Span::styled(
408
22
                                highlighted_content,
409
22
                                merge_styles(base_style, context.matched_style),
410
22
                            ));
411
128
                        }
412
                    }
413
414
72
                    Line::from(new_spans)
415
                }
416
2
                crate::Matches::CharRange(start, end) => {
417
                    // Positions are already in stripped text coordinates (same as parsed ANSI text)
418
                    // No need to remap since both matching and ANSI parsing strip the codes
419
420
                    // Apply highlighting to the range
421
2
                    let mut new_spans = Vec::new();
422
2
                    let mut char_idx = 0;
423
424
3
                    for span in 
all_spans2
{
425
3
                        let mut before = String::new();
426
3
                        let mut highlighted = String::new();
427
3
                        let mut after = String::new();
428
3
                        let base_style = span.style;
429
430
15
                        for ch in 
span.content.chars()3
{
431
15
                            if char_idx < start {
  Branch (431:32): [True: 0, False: 0]
+
406
28
                            // Combine styles: use highlight bg, preserve ANSI fg and modifiers
407
28
                            new_spans.push(Span::styled(
408
28
                                highlighted_content,
409
28
                                merge_styles(base_style, context.matched_style),
410
28
                            ));
411
170
                        }
412
                    }
413
414
96
                    Line::from(new_spans)
415
                }
416
2
                crate::Matches::CharRange(start, end) => {
417
                    // Positions are already in stripped text coordinates (same as parsed ANSI text)
418
                    // No need to remap since both matching and ANSI parsing strip the codes
419
420
                    // Apply highlighting to the range
421
2
                    let mut new_spans = Vec::new();
422
2
                    let mut char_idx = 0;
423
424
3
                    for span in 
all_spans2
{
425
3
                        let mut before = String::new();
426
3
                        let mut highlighted = String::new();
427
3
                        let mut after = String::new();
428
3
                        let base_style = span.style;
429
430
15
                        for ch in 
span.content.chars()3
{
431
15
                            if char_idx < start {
  Branch (431:32): [True: 0, False: 0]
 
  Branch (431:32): [True: 7, False: 8]
 
432
7
                                before.push(ch);
433
8
                            } else if char_idx < end {
  Branch (433:39): [True: 0, False: 0]
 
  Branch (433:39): [True: 6, False: 2]
@@ -90,19 +90,19 @@
 
  Branch (493:28): [True: 1, False: 0]
 
494
1
                            // 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
37
                            // Combine ANSI style with context base_style
502
37
                            new_spans.push(Span::styled(after, merge_styles(context.base_style, base_style)));
503
37
                        
}0
504
                    }
505
506
25
                    Line::from(new_spans)
507
                }
508
1
                crate::Matches::None => Line::from(all_spans),
509
            }
510
6.34k
        } else if let Some(
hidden30
) = self.hidden_ranges() {
  Branch (510:23): [True: 28, False: 6.31k]
+
501
37
                            // Combine ANSI style with context base_style
502
37
                            new_spans.push(Span::styled(after, merge_styles(context.base_style, base_style)));
503
37
                        
}0
504
                    }
505
506
25
                    Line::from(new_spans)
507
                }
508
1
                crate::Matches::None => Line::from(all_spans),
509
            }
510
6.36k
        } else if let Some(
hidden30
) = self.hidden_ranges() {
  Branch (510:23): [True: 28, False: 6.33k]
 
  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.31k
            context.to_line(Cow::Borrowed(&self.text))
526
        }
527
6.44k
    }
528
529
63.5k
    fn disabled(&self) -> bool {
530
63.5k
        self.metadata.as_ref().is_some_and(|x| x.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.85k
pub fn strip_ansi(text: &str) -> (String, Vec<(usize, usize)>) {
555
2.85k
    let mut result = String::with_capacity(text.len());
556
2.85k
    let mut index_mapping = Vec::new();
557
2.85k
    let mut chars = text.char_indices().peekable();
558
2.85k
    let mut char_idx = 0;
559
560
9.50k
    while let Some((
byte_pos6.64k
,
ch6.64k
)) = chars.next() {
  Branch (560:15): [True: 6.25k, False: 2.76k]
-
  Branch (560:15): [True: 390, False: 90]
-
561
6.64k
        if ch == '\x1b' {
  Branch (561:12): [True: 28, False: 6.23k]
-
  Branch (561:12): [True: 54, False: 336]
-
562
            // ESC sequence detected
563
82
            if let Some(&(_, 
next_ch81
)) = chars.peek() {
  Branch (563:20): [True: 28, 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.33k
            context.to_line(Cow::Borrowed(&self.text))
526
        }
527
6.49k
    }
528
529
63.6k
    fn disabled(&self) -> bool {
530
63.6k
        self.metadata.as_ref().is_some_and(|x| x.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.93k
pub fn strip_ansi(text: &str) -> (String, Vec<(usize, usize)>) {
555
2.93k
    let mut result = String::with_capacity(text.len());
556
2.93k
    let mut index_mapping = Vec::new();
557
2.93k
    let mut chars = text.char_indices().peekable();
558
2.93k
    let mut char_idx = 0;
559
560
9.76k
    while let Some((
byte_pos6.83k
,
ch6.83k
)) = chars.next() {
  Branch (560:15): [True: 6.45k, False: 2.84k]
+
  Branch (560:15): [True: 388, False: 89]
+
561
6.83k
        if ch == '\x1b' {
  Branch (561:12): [True: 38, False: 6.41k]
+
  Branch (561:12): [True: 54, False: 334]
+
562
            // ESC sequence detected
563
92
            if let Some(&(_, 
next_ch91
)) = chars.peek() {
  Branch (563:20): [True: 38, 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]
+
564
91
                match next_ch {
565
                    '[' => {
566
                        // CSI sequence: ESC [ ... (ending with a letter)
567
86
                        chars.next(); // consume '['
568
86
                        char_idx += 1;
569
244
                        while let Some(&(_, c)) = chars.peek() {
  Branch (569:35): [True: 109, False: 0]
 
  Branch (569:35): [True: 135, False: 0]
-
570
213
                            chars.next();
571
213
                            char_idx += 1;
572
213
                            if c.is_ascii_alphabetic() {
  Branch (572:32): [True: 28, False: 50]
+
570
244
                            chars.next();
571
244
                            char_idx += 1;
572
244
                            if c.is_ascii_alphabetic() {
  Branch (572:32): [True: 38, False: 71]
 
  Branch (572:32): [True: 48, False: 87]
-
573
76
                                break;
574
137
                            }
575
                        }
576
                    }
577
                    ']' => {
578
                        // OSC sequence: ESC ] ... (ending with BEL or ESC \)
579
2
                        chars.next(); // consume ']'
580
2
                        char_idx += 1;
581
30
                        while let Some((_, c)) = chars.next() {
  Branch (581:35): [True: 0, False: 0]
+
573
86
                                break;
574
158
                            }
575
                        }
576
                    }
577
                    ']' => {
578
                        // OSC sequence: ESC ] ... (ending with BEL or ESC \)
579
2
                        chars.next(); // consume ']'
580
2
                        char_idx += 1;
581
30
                        while let Some((_, c)) = chars.next() {
  Branch (581:35): [True: 0, False: 0]
 
  Branch (581:35): [True: 30, False: 0]
 
582
30
                            char_idx += 1;
583
30
                            if c == '\x07' {
  Branch (583:32): [True: 0, False: 0]
 
  Branch (583:32): [True: 1, False: 29]
@@ -110,7 +110,7 @@
 
  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
2
                    '(' | ')' | '#' | '%' => {
597
2
                        // 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
2
                    }
603
1
                    _ => {
604
1
                        // Unknown escape sequence, consume next char
605
1
                        chars.next();
606
1
                        char_idx += 1;
607
1
                    }
608
                }
609
1
            }
610
6.56k
        } else {
611
6.56k
            result.push(ch);
612
6.56k
            index_mapping.push((byte_pos, char_idx));
613
6.56k
        }
614
6.64k
        char_idx += 1;
615
    }
616
617
2.85k
    (result, index_mapping)
618
2.85k
}
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
24
fn escape_ansi(raw: &str) -> String {
625
254
    unsafe { 
String::from_utf8_unchecked24
(
raw24
.
bytes24
().
map24
(|b| if b == 27 {
b'?'6
} else {
b248
}).
collect24
()) }
  Branch (625:65): [True: 4, False: 222]
+
589
                            {
590
1
                                chars.next(); // consume '\'
591
1
                                char_idx += 1;
592
1
                                break;
593
28
                            }
594
                        }
595
                    }
596
2
                    '(' | ')' | '#' | '%' => {
597
2
                        // 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
2
                    }
603
1
                    _ => {
604
1
                        // Unknown escape sequence, consume next char
605
1
                        chars.next();
606
1
                        char_idx += 1;
607
1
                    }
608
                }
609
1
            }
610
6.74k
        } else {
611
6.74k
            result.push(ch);
612
6.74k
            index_mapping.push((byte_pos, char_idx));
613
6.74k
        }
614
6.83k
        char_idx += 1;
615
    }
616
617
2.93k
    (result, index_mapping)
618
2.93k
}
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
24
fn escape_ansi(raw: &str) -> String {
625
254
    unsafe { 
String::from_utf8_unchecked24
(
raw24
.
bytes24
().
map24
(|b| if b == 27 {
b'?'6
} else {
b248
}).
collect24
()) }
  Branch (625:65): [True: 4, False: 222]
 
  Branch (625:65): [True: 2, False: 26]
 
626
24
}
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 sorted19
:
Vec<(usize, usize)>19
=
ranges19
.
iter19
().
copied19
().
filter19
(|(s, e)| e > s).
collect19
();
635
19
    sorted.sort_unstable();
636
637
19
    let mut merged: Vec<(usize, usize)> = Vec::with_capacity(sorted.len());
638
22
    for (start, end) in 
sorted19
{
639
22
        if let Some(
last4
) = merged.last_mut()
  Branch (639:16): [True: 1, False: 9]
 
  Branch (639:16): [True: 3, False: 9]
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 f4b47d14..c5817cb0 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,289 +1,315 @@
-

Coverage Report

Created: 2026-09-04 10: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
886
    fn default() -> Self {
42
886
        Self {
43
886
            buf_size: READ_BUFFER_SIZE,
44
886
            line_ending: b'\n',
45
886
            use_ansi_color: false,
46
886
            transform_fields: Vec::new(),
47
886
            matching_fields: Vec::new(),
48
886
            hidden_fields: Vec::new(),
49
886
            delimiter: Regex::new(DELIMITER_STR).unwrap(),
50
886
            show_error: false,
51
886
            disable_pattern: None,
52
886
        }
53
886
    }
54
}
55
56
impl SkimItemReaderOption {
57
    /// Creates reader options from skim options
58
    #[must_use]
59
355
    pub fn from_options(options: &SkimOptions) -> Self {
60
        Self {
61
            buf_size: READ_BUFFER_SIZE,
62
355
            line_ending: if options.read0 { 
b'\0'4
} else {
b'\n'351
},
  Branch (62:29): [True: 3, False: 350]
+

Coverage Report

Created: 2026-09-05 18:50

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
906
    fn default() -> Self {
42
906
        Self {
43
906
            buf_size: READ_BUFFER_SIZE,
44
906
            line_ending: b'\n',
45
906
            use_ansi_color: false,
46
906
            transform_fields: Vec::new(),
47
906
            matching_fields: Vec::new(),
48
906
            hidden_fields: Vec::new(),
49
906
            delimiter: Regex::new(DELIMITER_STR).unwrap(),
50
906
            show_error: false,
51
906
            disable_pattern: None,
52
906
        }
53
906
    }
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
            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]
 
  Branch (62:29): [True: 1, False: 1]
-
63
355
            use_ansi_color: options.ansi,
64
355
            transform_fields: options
65
355
                .with_nth
66
355
                .iter()
67
355
                .filter_map(|f| if 
f354
.
is_empty354
() {
None335
} else {
FieldRange::from_str19
(
f19
)
}354
)
  Branch (67:36): [True: 335, False: 19]
+
63
369
            use_ansi_color: options.ansi,
64
369
            transform_fields: options
65
369
                .with_nth
66
369
                .iter()
67
369
                .filter_map(|f| if 
f368
.
is_empty368
() {
None349
} else {
FieldRange::from_str19
(
f19
)
}368
)
  Branch (67:36): [True: 349, False: 19]
 
  Branch (67:36): [True: 0, False: 0]
-
68
355
                .collect(),
69
355
            matching_fields: options
70
355
                .nth
71
355
                .iter()
72
356
                .
filter_map355
(|f| if f.is_empty() {
None334
} else {
FieldRange::from_str22
(
f22
) })
  Branch (72:36): [True: 334, False: 22]
+
68
369
                .collect(),
69
369
            matching_fields: options
70
369
                .nth
71
369
                .iter()
72
370
                .
filter_map369
(|f| if f.is_empty() {
None348
} else {
FieldRange::from_str22
(
f22
) })
  Branch (72:36): [True: 348, False: 22]
 
  Branch (72:36): [True: 0, False: 0]
-
73
355
                .collect(),
74
355
            hidden_fields: options
75
355
                .hide_nth
76
355
                .iter()
77
355
                .filter_map(|f| if 
f354
.
is_empty354
() {
None344
} else {
FieldRange::from_str10
(
f10
)
}354
)
  Branch (77:36): [True: 344, False: 10]
+
73
369
                .collect(),
74
369
            hidden_fields: options
75
369
                .hide_nth
76
369
                .iter()
77
369
                .filter_map(|f| if 
f368
.
is_empty368
() {
None358
} else {
FieldRange::from_str10
(
f10
)
}368
)
  Branch (77:36): [True: 358, False: 10]
 
  Branch (77:36): [True: 0, False: 0]
-
78
355
                .collect(),
79
355
            delimiter: options.delimiter.clone(),
80
355
            show_error: options.show_cmd_error,
81
355
            disable_pattern: options.disable_pattern.clone(),
82
        }
83
355
    }
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
369
                .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]
 
  Branch (167:12): [True: 3, False: 1]
-
168
3
            self.line_ending = b'\0';
169
3
        } else {
170
1
            self.line_ending = b'\n';
171
1
        }
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.23k
fn default_thread_pool() -> Arc<ThreadPool> {
202
1.23k
    let n = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
203
1.23k
    let (reader_threads, _) = crate::thread_pool::partition_threads(n);
204
1.23k
    Arc::new(ThreadPool::new(reader_threads))
205
1.23k
}
206
207
impl Default for SkimItemReader {
208
878
    fn default() -> Self {
209
878
        Self {
210
878
            option: Arc::new(Default::default()),
211
878
            thread_pool: default_thread_pool(),
212
878
        }
213
878
    }
214
}
215
216
impl SkimItemReader {
217
    /// Creates a new item reader with the given options
218
    #[must_use]
219
359
    pub fn new(option: SkimItemReaderOption) -> Self {
220
359
        Self {
221
359
            option: Arc::new(option),
222
359
            thread_pool: default_thread_pool(),
223
359
        }
224
359
    }
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
363
    pub fn of_bufread(&self, source: impl BufRead + Send + 'static) -> SkimItemReceiver {
252
363
        self.parallel_bufread(source, None, &Arc::new(AtomicUsize::new(0))).0
253
363
    }
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. **Dispatcher thread** (dedicated, lightweight) — drains that channel
269
    ///    and submits one pool job per chunk.  The bounded channel provides
270
    ///    natural back-pressure on the I/O thread when the pool is busy.
271
    /// 3. **Pool jobs** — parse lines, validate UTF-8, apply ANSI stripping and
272
    ///    field transforms, and create `DefaultSkimItem` + `Arc` per line.
273
    ///    Because these jobs share the same pool as the matcher, reader and
274
    ///    matcher compete for the same thread budget rather than over-subscribing
275
    ///    available CPU cores.
276
    /// 4. **Reorder thread** (dedicated) — collects `(seq, items)` from pool
277
    ///    jobs and emits them in sequence order so downstream index assignment
278
    ///    and `--tac` behaviour are correct.
279
    ///
280
    /// When `child` is `Some`, a **killer thread** is also spawned.  It waits
281
    /// on `rx_interrupt` and kills the child process on request (or when the
282
    /// reader is dropped).  This thread participates in `components_to_stop`
283
    /// accounting so that [`ReaderControl::kill`] waits for it to finish.
284
    ///
285
    /// Returns `(rx_item, tx_interrupt)`.  The caller must send on `tx_interrupt`
286
    /// to signal shutdown; the killer thread (if any) will then kill the child.
287
435
    fn parallel_bufread(
288
435
        &self,
289
435
        source: impl BufRead + Send + 'static,
290
435
        child: Option<Child>,
291
435
        components_to_stop: &Arc<AtomicUsize>,
292
435
    ) -> (SkimItemReceiver, crate::prelude::Sender<i32>) {
293
435
        let (tx_item, rx_item): (SkimItemSender, SkimItemReceiver) = kanal::bounded(1024 * 1024);
294
435
        let option = self.option.clone();
295
435
        let pool = Arc::clone(&self.thread_pool);
296
297
435
        let num_threads = pool.num_threads();
298
435
        let (tx_chunks, rx_chunks) = kanal::bounded::<(usize, Vec<u8>)>(num_threads * 4);
299
435
        let (tx_results, rx_results) = kanal::bounded::<(usize, Vec<Arc<dyn SkimItem>>)>(num_threads * 4);
300
301
435
        let line_ending = option.line_ending;
302
303
        // Stage 1: I/O thread.
304
435
        Self::spawn_io_reader(source, tx_chunks, line_ending);
305
306
        // Stage 2: dispatcher thread — bridges the bounded channel to the pool.
307
435
        thread::spawn(move || {
308
846
            while let Ok((
seq411
,
chunk411
)) = rx_chunks.recv() {
  Branch (308:23): [True: 19, False: 68]
-
  Branch (308:23): [True: 32, False: 32]
-
  Branch (308:23): [True: 2, False: 2]
-
  Branch (308:23): [True: 24, False: 24]
-
  Branch (308:23): [True: 12, False: 12]
-
  Branch (308:23): [True: 2, False: 2]
-
  Branch (308:23): [True: 9, False: 9]
-
  Branch (308:23): [True: 1, False: 1]
-
  Branch (308:23): [True: 6, False: 6]
-
  Branch (308:23): [True: 34, False: 34]
-
  Branch (308:23): [True: 2, False: 1]
-
  Branch (308:23): [True: 1, False: 1]
-
  Branch (308:23): [True: 10, False: 10]
-
  Branch (308:23): [True: 54, False: 31]
-
  Branch (308:23): [True: 4, False: 4]
-
  Branch (308:23): [True: 5, False: 5]
-
  Branch (308:23): [True: 4, False: 4]
-
  Branch (308:23): [True: 13, False: 13]
-
  Branch (308:23): [True: 1, False: 1]
-
  Branch (308:23): [True: 11, False: 11]
-
  Branch (308:23): [True: 5, False: 5]
-
  Branch (308:23): [True: 113, False: 113]
-
  Branch (308:23): [True: 4, False: 3]
-
  Branch (308:23): [True: 9, False: 9]
-
  Branch (308:23): [True: 22, False: 22]
-
  Branch (308:23): [True: 12, False: 12]
-
309
411
                let tx = tx_results.clone();
310
411
                let opt = option.clone();
311
411
                pool.spawn(move || {
312
411
                    let result = Self::process_chunk(seq, &chunk, &opt);
313
411
                    let _ = tx.send(result);
314
411
                });
315
            }
316
            // rx_chunks closed → all chunks dispatched; tx_results dropped here
317
            // so the reorder thread exits once the last pool job finishes.
318
435
        });
319
320
        // A zero-capacity channel used as a completion signal: the reorder
321
        // thread drops its sender when it exits, closing the channel.  The
322
        // killer thread waits on either this signal (natural EOF) or on the
323
        // external interrupt (early termination request).
324
435
        let (tx_pipeline_done, rx_pipeline_done) = kanal::bounded::<()>(0);
325
326
        // Stage 4: reorder thread.
327
435
        Self::spawn_reorder_thread(rx_results, tx_item.clone(), tx_pipeline_done);
328
329
        // Killer thread: exits when the pipeline drains naturally (child process
330
        // reached EOF) OR when it receives an explicit interrupt signal.
331
        //
332
        // This thread participates in `components_to_stop` accounting so that
333
        // [`ReaderControl::is_done`] correctly waits for cleanup to complete.
334
435
        let (tx_interrupt, rx_interrupt) = crate::prelude::bounded::<i32>(8);
335
435
        let components_to_stop_killer = components_to_stop.clone();
336
435
        components_to_stop.fetch_add(1, Ordering::SeqCst);
337
435
        thread::spawn(move || 
{434
338
434
            debug!("parallel reader: killer thread start");
339
340
            // Wait for either a kill request or the pipeline finishing naturally.
341
            // kanal doesn't have a multi-channel select, so we poll with a short
342
            // timeout.  Both channels are bounded so this never busy-spins in
343
            // practice: the kill path is rare, and the done path fires quickly.
344
            loop {
345
558
                if rx_interrupt.try_recv().is_ok_and(|v| 
v101
.
is_some101
()) {
  Branch (345:20): [True: 0, False: 86]
-
  Branch (345:20): [True: 0, False: 44]
-
  Branch (345:20): [True: 0, False: 2]
-
  Branch (345:20): [True: 0, False: 24]
-
  Branch (345:20): [True: 0, False: 14]
-
  Branch (345:20): [True: 0, False: 3]
-
  Branch (345:20): [True: 0, False: 12]
-
  Branch (345:20): [True: 0, False: 1]
-
  Branch (345:20): [True: 0, False: 6]
-
  Branch (345:20): [True: 0, False: 41]
-
  Branch (345:20): [True: 0, False: 1]
-
  Branch (345:20): [True: 0, False: 1]
-
  Branch (345:20): [True: 0, False: 11]
-
  Branch (345:20): [True: 0, False: 39]
-
  Branch (345:20): [True: 0, False: 4]
-
  Branch (345:20): [True: 0, False: 10]
-
  Branch (345:20): [True: 0, False: 4]
-
  Branch (345:20): [True: 0, False: 24]
-
  Branch (345:20): [True: 0, False: 1]
-
  Branch (345:20): [True: 0, False: 11]
-
  Branch (345:20): [True: 0, False: 6]
-
  Branch (345:20): [True: 0, False: 158]
-
  Branch (345:20): [True: 0, False: 3]
-
  Branch (345:20): [True: 0, False: 13]
-
  Branch (345:20): [True: 0, False: 27]
-
  Branch (345:20): [True: 0, False: 12]
-
346
                    // Explicit kill: terminate the child immediately.
347
0
                    if let Some(mut c) = child {
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
  Branch (347:28): [True: 0, False: 0]
-
348
0
                        let _ = c.kill();
349
0
                        let _ = c.wait();
350
0
                    }
351
0
                    break;
352
558
                }
353
                // Channel closed = reorder thread exited = pipeline drained.
354
558
                match rx_pipeline_done.recv_timeout(std::time::Duration::from_millis(1)) {
355
0
                    Ok(()) => break,
356
                    Err(kanal::ReceiveErrorTimeout::Closed | kanal::ReceiveErrorTimeout::SendClosed) => {
357
                        // Natural EOF: child already exited; just reap if present.
358
434
                        if let Some(
mut c71
) = child {
  Branch (358:32): [True: 68, False: 0]
-
  Branch (358:32): [True: 0, False: 32]
-
  Branch (358:32): [True: 0, False: 2]
-
  Branch (358:32): [True: 0, False: 24]
-
  Branch (358:32): [True: 0, False: 12]
-
  Branch (358:32): [True: 0, False: 2]
-
  Branch (358:32): [True: 0, False: 9]
-
  Branch (358:32): [True: 0, False: 1]
-
  Branch (358:32): [True: 0, False: 6]
-
  Branch (358:32): [True: 0, False: 34]
-
  Branch (358:32): [True: 0, False: 1]
-
  Branch (358:32): [True: 0, False: 1]
-
  Branch (358:32): [True: 0, False: 10]
-
  Branch (358:32): [True: 0, False: 31]
-
  Branch (358:32): [True: 0, False: 4]
-
  Branch (358:32): [True: 0, False: 5]
-
  Branch (358:32): [True: 3, False: 0]
-
  Branch (358:32): [True: 0, False: 13]
-
  Branch (358:32): [True: 0, False: 1]
-
  Branch (358:32): [True: 0, False: 11]
-
  Branch (358:32): [True: 0, False: 5]
-
  Branch (358:32): [True: 0, False: 113]
-
  Branch (358:32): [True: 0, False: 3]
-
  Branch (358:32): [True: 0, False: 9]
-
  Branch (358:32): [True: 0, False: 22]
-
  Branch (358:32): [True: 0, False: 12]
-
359
71
                            let _ = c.wait();
360
363
                        }
361
434
                        break;
362
                    }
363
124
                    Err(kanal::ReceiveErrorTimeout::Timeout) => {
364
124
                        // Neither signal yet — loop.
365
124
                    }
366
                }
367
            }
368
369
434
            components_to_stop_killer.fetch_sub(1, Ordering::SeqCst);
370
434
            debug!("parallel reader: killer thread stop");
371
434
        });
372
373
435
        (rx_item, tx_interrupt)
374
435
    }
375
376
    /// Stage 1 of the parallel reader: reads large byte chunks from `source`,
377
    /// splitting on line boundaries, and sends them to workers.
378
435
    fn spawn_io_reader(
379
435
        source: impl BufRead + Send + 'static,
380
435
        tx_chunks: kanal::Sender<(usize, Vec<u8>)>,
381
435
        line_ending: u8,
382
435
    ) {
383
435
        thread::spawn(move || {
384
435
            debug!("parallel reader: I/O thread start");
385
386
435
            let mut source = source;
387
435
            let mut leftover: Vec<u8> = Vec::new();
388
435
            let mut seq = 0usize;
389
435
            let mut read_buf = vec![0u8; PARALLEL_READ_BUF_SIZE];
390
391
            loop {
392
826
                let 
n391
= match std::io::Read::read(&mut source, &mut read_buf) {
393
                    Ok(0) => {
394
                        // EOF — flush any remaining leftover as the final chunk.
395
435
                        if !leftover.is_empty() {
  Branch (395:28): [True: 5, False: 63]
-
  Branch (395:28): [True: 0, False: 32]
-
  Branch (395:28): [True: 0, False: 2]
-
  Branch (395:28): [True: 0, False: 24]
-
  Branch (395:28): [True: 0, False: 12]
-
  Branch (395:28): [True: 0, False: 2]
-
  Branch (395:28): [True: 0, False: 9]
-
  Branch (395:28): [True: 0, False: 1]
-
  Branch (395:28): [True: 0, False: 6]
-
  Branch (395:28): [True: 0, False: 34]
-
  Branch (395:28): [True: 1, False: 0]
-
  Branch (395:28): [True: 0, False: 1]
-
  Branch (395:28): [True: 0, False: 10]
-
  Branch (395:28): [True: 20, False: 11]
-
  Branch (395:28): [True: 0, False: 4]
-
  Branch (395:28): [True: 0, False: 5]
-
  Branch (395:28): [True: 0, False: 4]
-
  Branch (395:28): [True: 0, False: 13]
-
  Branch (395:28): [True: 0, False: 1]
-
  Branch (395:28): [True: 0, False: 11]
-
  Branch (395:28): [True: 0, False: 5]
-
  Branch (395:28): [True: 0, False: 113]
-
  Branch (395:28): [True: 3, False: 0]
-
  Branch (395:28): [True: 0, False: 9]
-
  Branch (395:28): [True: 0, False: 22]
-
  Branch (395:28): [True: 0, False: 12]
-
396
29
                            let _ = tx_chunks.send((seq, std::mem::take(&mut leftover)));
397
406
                        }
398
435
                        break;
399
                    }
400
391
                    Ok(n) => n,
401
0
                    Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
  Branch (401:35): [True: 0, False: 0]
-
402
                    Err(_) => {
403
                        // Flush any accumulated data before exiting on error.
404
0
                        if !leftover.is_empty() {
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
  Branch (404:28): [True: 0, False: 0]
-
405
0
                            let _ = tx_chunks.send((seq, std::mem::take(&mut leftover)));
406
0
                        }
407
0
                        break;
408
                    }
409
                };
410
411
                // Combine leftover from previous iteration with fresh data.
412
391
                let data = if leftover.is_empty() {
  Branch (412:31): [True: 15, False: 0]
-
  Branch (412:31): [True: 32, False: 0]
-
  Branch (412:31): [True: 2, False: 0]
-
  Branch (412:31): [True: 24, False: 0]
-
  Branch (412:31): [True: 12, False: 0]
-
  Branch (412:31): [True: 2, False: 0]
-
  Branch (412:31): [True: 9, False: 0]
-
  Branch (412:31): [True: 1, False: 0]
-
  Branch (412:31): [True: 6, False: 0]
-
  Branch (412:31): [True: 34, False: 0]
-
  Branch (412:31): [True: 1, False: 0]
-
  Branch (412:31): [True: 1, False: 0]
-
  Branch (412:31): [True: 10, False: 0]
-
  Branch (412:31): [True: 31, False: 9]
-
  Branch (412:31): [True: 4, False: 0]
-
  Branch (412:31): [True: 5, False: 0]
-
  Branch (412:31): [True: 4, False: 0]
-
  Branch (412:31): [True: 13, False: 0]
-
  Branch (412:31): [True: 1, False: 0]
-
  Branch (412:31): [True: 11, False: 0]
-
  Branch (412:31): [True: 5, False: 0]
-
  Branch (412:31): [True: 113, False: 0]
-
  Branch (412:31): [True: 3, False: 0]
-
  Branch (412:31): [True: 9, False: 0]
-
  Branch (412:31): [True: 22, False: 0]
-
  Branch (412:31): [True: 12, False: 0]
-
413
382
                    read_buf[..n].to_vec()
414
                } else {
415
9
                    let mut combined = std::mem::take(&mut leftover);
416
9
                    combined.extend_from_slice(&read_buf[..n]);
417
9
                    combined
418
                };
419
420
                // Split at the last newline: everything up to it forms a
421
                // complete-line chunk; the remainder carries over.
422
391
                if let Some(
last_nl382
) = memchr::memrchr(line_ending, &data) {
  Branch (422:24): [True: 14, False: 1]
-
  Branch (422:24): [True: 32, False: 0]
-
  Branch (422:24): [True: 2, False: 0]
-
  Branch (422:24): [True: 24, False: 0]
-
  Branch (422:24): [True: 12, False: 0]
-
  Branch (422:24): [True: 2, False: 0]
-
  Branch (422:24): [True: 9, False: 0]
-
  Branch (422:24): [True: 1, False: 0]
-
  Branch (422:24): [True: 6, False: 0]
-
  Branch (422:24): [True: 34, False: 0]
-
  Branch (422:24): [True: 1, False: 0]
-
  Branch (422:24): [True: 1, False: 0]
-
  Branch (422:24): [True: 10, False: 0]
-
  Branch (422:24): [True: 34, False: 6]
-
  Branch (422:24): [True: 4, False: 0]
-
  Branch (422:24): [True: 5, False: 0]
-
  Branch (422:24): [True: 4, False: 0]
-
  Branch (422:24): [True: 13, False: 0]
-
  Branch (422:24): [True: 1, False: 0]
-
  Branch (422:24): [True: 11, False: 0]
-
  Branch (422:24): [True: 5, False: 0]
-
  Branch (422:24): [True: 113, False: 0]
-
  Branch (422:24): [True: 1, False: 2]
-
  Branch (422:24): [True: 9, False: 0]
-
  Branch (422:24): [True: 22, False: 0]
-
  Branch (422:24): [True: 12, False: 0]
-
423
382
                    leftover = data[last_nl + 1..].to_vec();
424
382
                    let mut chunk = data;
425
382
                    chunk.truncate(last_nl + 1);
426
382
                    if tx_chunks.send((seq, chunk)).is_err() {
  Branch (426:24): [True: 0, False: 14]
-
  Branch (426:24): [True: 0, False: 32]
-
  Branch (426:24): [True: 0, False: 2]
-
  Branch (426:24): [True: 0, False: 24]
-
  Branch (426:24): [True: 0, False: 12]
-
  Branch (426:24): [True: 0, False: 2]
-
  Branch (426:24): [True: 0, False: 9]
-
  Branch (426:24): [True: 0, False: 1]
-
  Branch (426:24): [True: 0, False: 6]
-
  Branch (426:24): [True: 0, False: 34]
-
  Branch (426:24): [True: 0, False: 1]
-
  Branch (426:24): [True: 0, False: 1]
-
  Branch (426:24): [True: 0, False: 10]
-
  Branch (426:24): [True: 0, False: 34]
-
  Branch (426:24): [True: 0, False: 4]
-
  Branch (426:24): [True: 0, False: 5]
-
  Branch (426:24): [True: 0, False: 4]
-
  Branch (426:24): [True: 0, False: 13]
-
  Branch (426:24): [True: 0, False: 1]
-
  Branch (426:24): [True: 0, False: 11]
-
  Branch (426:24): [True: 0, False: 5]
-
  Branch (426:24): [True: 0, False: 113]
-
  Branch (426:24): [True: 0, False: 1]
-
  Branch (426:24): [True: 0, False: 9]
-
  Branch (426:24): [True: 0, False: 22]
-
  Branch (426:24): [True: 0, False: 12]
-
427
0
                        break;
428
382
                    }
429
382
                    seq += 1;
430
9
                } else {
431
9
                    // No newline at all — accumulate for the next read.
432
9
                    leftover = data;
433
9
                }
434
            }
435
436
435
            debug!("parallel reader: I/O thread stop (sent {seq} chunks)");
437
435
        });
438
435
    }
439
440
    /// Parses a raw byte chunk into a tagged batch of items.
441
411
    fn process_chunk(seq: usize, chunk: &[u8], opt: &SkimItemReaderOption) -> (usize, Vec<Arc<dyn SkimItem>>) {
442
411
        let mut items = Vec::new();
443
411
        let line_ending = opt.line_ending;
444
445
        // Chunks produced by the I/O thread end with the line-ending delimiter
446
        // (except possibly the final leftover at EOF).  `split()` would produce
447
        // a spurious trailing empty segment in that case, so we trim the
448
        // trailing delimiter first.  After trimming, every segment — including
449
        // empty ones — maps 1:1 to an input line.
450
411
        let chunk_trimmed: &[u8] = if chunk.last() == Some(&line_ending) {
  Branch (450:39): [True: 364, False: 29]
-
  Branch (450:39): [True: 18, False: 0]
-
451
382
            &chunk[..chunk.len() - 1]
452
        } else {
453
29
            chunk
454
        };
455
456
672k
        for 
line_bytes52.0k
in
chunk_trimmed411
.
split411
(|&b: &u8| b == line_ending) {
457
            // Strip optional \r for \r\n endings.
458
52.0k
            let line_bytes: &[u8] = line_bytes.strip_suffix(b"\r").unwrap_or(line_bytes);
459
52.0k
            let Ok(
line52.0k
) = std::str::from_utf8(line_bytes) else {
  Branch (459:17): [True: 52.0k, False: 0]
-
  Branch (459:17): [True: 36, False: 1]
-
460
1
                continue;
461
            };
462
52.0k
            let mut item = DefaultSkimItem::new(
463
52.0k
                line,
464
52.0k
                opt.use_ansi_color,
465
52.0k
                &opt.transform_fields,
466
52.0k
                &opt.matching_fields,
467
52.0k
                &opt.delimiter,
468
            )
469
52.0k
            .hidden_fields(&opt.hidden_fields, &opt.delimiter);
470
52.0k
            if opt.disable_pattern.as_ref().is_some_and(|re| 
re2
.
is_match2
(
line2
)) {
  Branch (470:16): [True: 0, False: 52.0k]
-
  Branch (470:16): [True: 1, False: 35]
-
471
1
                item.disable();
472
52.0k
            }
473
52.0k
            items.push(Arc::new(item) as Arc<dyn SkimItem>);
474
        }
475
476
411
        (seq, items)
477
411
    }
478
479
    /// Stage 4: receives item batches from workers and emits them through the
480
    /// downstream channel in the original sequence order.  Drops
481
    /// `tx_pipeline_done` on exit to signal the killer thread that the
482
    /// pipeline has drained naturally.
483
435
    fn spawn_reorder_thread(
484
435
        rx_results: kanal::Receiver<(usize, Vec<Arc<dyn SkimItem>>)>,
485
435
        tx_item: SkimItemSender,
486
435
        tx_pipeline_done: kanal::Sender<()>,
487
435
    ) {
488
435
        thread::spawn(move || {
489
435
            debug!("parallel reader: reorder thread start");
490
435
            let mut expected = 0usize;
491
435
            let mut pending: BTreeMap<usize, Vec<Arc<dyn SkimItem>>> = BTreeMap::new();
492
493
846
            while let Ok((
seq411
,
items411
)) = rx_results.recv() {
  Branch (493:23): [True: 393, False: 417]
-
  Branch (493:23): [True: 18, False: 18]
-
494
411
                pending.insert(seq, items);
495
                // Flush consecutive batches starting from the expected seq.
496
822
                while let Some(
batch411
) = pending.remove(&expected) {
  Branch (496:27): [True: 393, False: 393]
-
  Branch (496:27): [True: 18, False: 18]
-
497
411
                    if tx_item.send(batch).is_err() {
  Branch (497:24): [True: 0, False: 393]
-
  Branch (497:24): [True: 0, False: 18]
-
498
0
                        return;
499
411
                    }
500
411
                    expected += 1;
501
                }
502
            }
503
            // Drain anything left (shouldn't normally happen).
504
435
            while let Some((&
seq0
, _)) = pending.first_key_value() {
  Branch (504:23): [True: 0, False: 417]
-
  Branch (504:23): [True: 0, False: 18]
-
505
0
                if pending.remove(&seq).is_some_and(|batch| tx_item.send(batch).is_err()) {
  Branch (505:20): [True: 0, False: 0]
-
  Branch (505:20): [True: 0, False: 0]
-
506
0
                    return;
507
0
                }
508
            }
509
            // Dropping tx_pipeline_done closes the channel, waking the killer
510
            // thread so it can decrement components_to_stop.
511
435
            drop(tx_pipeline_done);
512
435
            debug!("parallel reader: reorder thread stop");
513
435
        });
514
435
    }
515
}
516
517
impl CommandCollector for SkimItemReader {
518
72
    fn invoke(
519
72
        &mut self,
520
72
        cmd: &str,
521
72
        components_to_stop: Arc<AtomicUsize>,
522
72
    ) -> (SkimItemReceiver, crate::prelude::Sender<i32>) {
523
72
        let send_error = self.option.show_error;
524
72
        let (child, source) = get_command_output(cmd, send_error).expect("command not found");
525
72
        self.parallel_bufread(source, child, &components_to_stop)
526
72
    }
527
528
390
    fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
529
390
        self.thread_pool = pool;
530
390
    }
531
}
532
533
type CommandOutput = (Option<Child>, Box<dyn BufRead + Send>);
534
535
72
fn get_command_output(cmd: &str, send_error: bool) -> Result<CommandOutput, Box<dyn Error>> {
536
72
    let (reader, writer) = std::io::pipe()
?0
;
537
72
    let mut command = crate::shell_cmd(cmd);
538
72
    command.stdout(writer.try_clone()
?0
);
539
72
    if send_error {
  Branch (539:8): [True: 0, False: 68]
-
  Branch (539:8): [True: 1, False: 3]
-
540
1
        trace!("redirecting stderr to the output");
541
1
        command.stderr(writer);
542
71
    } else {
543
71
        command.stderr(Stdio::null());
544
71
    }
545
546
72
    Ok((command.spawn().ok(), Box::new(BufReader::new(reader))))
547
72
}
548
549
#[cfg(test)]
550
#[path = "item_reader_tests.rs"]
551
mod tests;
\ No newline at end of file +
168
3
            self.line_ending = b'\0';
169
3
        } else {
170
1
            self.line_ending = b'\n';
171
1
        }
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
898
    fn default() -> Self {
209
898
        Self {
210
898
            option: Arc::new(Default::default()),
211
898
            thread_pool: default_thread_pool(),
212
898
        }
213
898
    }
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
450
    fn parallel_bufread(
285
450
        &self,
286
450
        source: impl BufRead + Send + 'static,
287
450
        child: Option<Child>,
288
450
        components_to_stop: &Arc<AtomicUsize>,
289
450
    ) -> (SkimItemReceiver, crate::prelude::Sender<i32>) {
290
450
        let (tx_item, rx_item): (SkimItemSender, SkimItemReceiver) = kanal::bounded(1024 * 1024);
291
450
        let option = self.option.clone();
292
450
        let pool = Arc::clone(&self.thread_pool);
293
294
450
        let num_threads = pool.num_threads();
295
450
        let (tx_chunks, rx_chunks) = kanal::bounded::<(usize, Vec<u8>)>(num_threads * 4);
296
450
        let (tx_results, rx_results) = kanal::bounded::<(usize, Vec<Arc<dyn SkimItem>>)>(num_threads * 4);
297
298
450
        let line_ending = option.line_ending;
299
300
        // Stage 1: I/O thread.
301
450
        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
450
        let max_in_flight = num_threads * 4;
306
450
        let (tx_permits, rx_permits) = std::sync::mpsc::sync_channel(max_in_flight);
307
3.60k
        for _ in 
0..max_in_flight450
{
308
3.60k
            tx_permits.send(()).expect("permit receiver is alive");
309
3.60k
        }
310
450
        thread::spawn(move || {
311
876
            while let Ok((
seq426
,
chunk426
)) = rx_chunks.recv() {
  Branch (311:23): [True: 20, False: 69]
+
  Branch (311:23): [True: 1, False: 1]
+
  Branch (311:23): [True: 23, False: 23]
+
  Branch (311:23): [True: 119, False: 119]
+
  Branch (311:23): [True: 4, False: 3]
+
  Branch (311:23): [True: 36, False: 36]
+
  Branch (311:23): [True: 5, False: 5]
+
  Branch (311:23): [True: 9, False: 9]
+
  Branch (311:23): [True: 2, False: 2]
+
  Branch (311:23): [True: 7, False: 7]
+
  Branch (311:23): [True: 13, False: 13]
+
  Branch (311:23): [True: 11, False: 11]
+
  Branch (311:23): [True: 2, False: 2]
+
  Branch (311:23): [True: 34, False: 34]
+
  Branch (311:23): [True: 2, False: 2]
+
  Branch (311:23): [True: 24, False: 24]
+
  Branch (311:23): [True: 2, False: 1]
+
  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: 5, False: 5]
+
  Branch (311:23): [True: 54, False: 31]
+
  Branch (311:23): [True: 12, False: 12]
+
  Branch (311:23): [True: 11, False: 11]
+
  Branch (311:23): [True: 3, False: 3]
+
  Branch (311:23): [True: 9, False: 9]
+
312
426
                if rx_permits.recv().is_err() {
  Branch (312:20): [True: 0, False: 20]
+
  Branch (312:20): [True: 0, False: 1]
+
  Branch (312:20): [True: 0, False: 23]
+
  Branch (312:20): [True: 0, False: 119]
+
  Branch (312:20): [True: 0, False: 4]
+
  Branch (312:20): [True: 0, False: 36]
+
  Branch (312:20): [True: 0, False: 5]
+
  Branch (312:20): [True: 0, False: 9]
+
  Branch (312:20): [True: 0, False: 2]
+
  Branch (312:20): [True: 0, False: 7]
+
  Branch (312:20): [True: 0, False: 13]
+
  Branch (312:20): [True: 0, False: 11]
+
  Branch (312:20): [True: 0, False: 2]
+
  Branch (312:20): [True: 0, False: 34]
+
  Branch (312:20): [True: 0, False: 2]
+
  Branch (312:20): [True: 0, False: 24]
+
  Branch (312:20): [True: 0, False: 2]
+
  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: 5]
+
  Branch (312:20): [True: 0, False: 54]
+
  Branch (312:20): [True: 0, False: 12]
+
  Branch (312:20): [True: 0, False: 11]
+
  Branch (312:20): [True: 0, False: 3]
+
  Branch (312:20): [True: 0, False: 9]
+
313
0
                    break;
314
426
                }
315
426
                let tx = tx_results.clone();
316
426
                let return_permit = tx_permits.clone();
317
426
                let opt = option.clone();
318
426
                pool.spawn(move || {
319
426
                    let result = Self::process_chunk(seq, &chunk, &opt);
320
426
                    let _ = tx.send(result);
321
426
                    let _ = return_permit.send(());
322
426
                });
323
            }
324
450
        });
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
450
        let (tx_pipeline_done, rx_pipeline_done) = kanal::bounded::<()>(0);
331
332
        // Stage 4: reorder thread.
333
450
        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
450
        let (tx_interrupt, rx_interrupt) = crate::prelude::bounded::<i32>(8);
341
450
        let components_to_stop_killer = components_to_stop.clone();
342
450
        components_to_stop.fetch_add(1, Ordering::SeqCst);
343
450
        thread::spawn(move || {
344
450
            debug!("parallel reader: killer thread start");
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
542
                if rx_interrupt.try_recv().is_ok_and(|v| 
v92
.
is_some92
()) {
  Branch (351:20): [True: 0, False: 80]
+
  Branch (351:20): [True: 0, False: 1]
+
  Branch (351:20): [True: 0, False: 31]
+
  Branch (351:20): [True: 0, False: 144]
+
  Branch (351:20): [True: 0, False: 3]
+
  Branch (351:20): [True: 0, False: 47]
+
  Branch (351:20): [True: 0, False: 5]
+
  Branch (351:20): [True: 0, False: 12]
+
  Branch (351:20): [True: 0, False: 2]
+
  Branch (351:20): [True: 0, False: 9]
+
  Branch (351:20): [True: 0, False: 14]
+
  Branch (351:20): [True: 0, False: 12]
+
  Branch (351:20): [True: 0, False: 2]
+
  Branch (351:20): [True: 0, False: 42]
+
  Branch (351:20): [True: 0, False: 3]
+
  Branch (351:20): [True: 0, False: 24]
+
  Branch (351:20): [True: 0, False: 1]
+
  Branch (351:20): [True: 0, False: 5]
+
  Branch (351:20): [True: 0, False: 18]
+
  Branch (351:20): [True: 0, False: 1]
+
  Branch (351:20): [True: 0, False: 5]
+
  Branch (351:20): [True: 0, False: 44]
+
  Branch (351:20): [True: 0, False: 13]
+
  Branch (351:20): [True: 0, False: 11]
+
  Branch (351:20): [True: 0, False: 3]
+
  Branch (351:20): [True: 0, False: 10]
+
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]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  Branch (353:28): [True: 0, False: 0]
+
  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
542
                }
359
                // Channel closed = reorder thread exited = pipeline drained.
360
542
                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
450
                        if let Some(
mut c73
) = child {
  Branch (364:32): [True: 69, False: 0]
+
  Branch (364:32): [True: 0, False: 1]
+
  Branch (364:32): [True: 0, False: 23]
+
  Branch (364:32): [True: 0, False: 119]
+
  Branch (364:32): [True: 0, False: 3]
+
  Branch (364:32): [True: 0, False: 36]
+
  Branch (364:32): [True: 0, False: 5]
+
  Branch (364:32): [True: 0, False: 9]
+
  Branch (364:32): [True: 0, False: 2]
+
  Branch (364:32): [True: 0, False: 7]
+
  Branch (364:32): [True: 0, False: 13]
+
  Branch (364:32): [True: 0, False: 11]
+
  Branch (364:32): [True: 0, False: 2]
+
  Branch (364:32): [True: 0, False: 34]
+
  Branch (364:32): [True: 0, False: 2]
+
  Branch (364:32): [True: 0, False: 24]
+
  Branch (364:32): [True: 0, False: 1]
+
  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: 5]
+
  Branch (364:32): [True: 0, False: 31]
+
  Branch (364:32): [True: 0, False: 12]
+
  Branch (364:32): [True: 0, False: 11]
+
  Branch (364:32): [True: 0, False: 3]
+
  Branch (364:32): [True: 0, False: 9]
+
365
73
                            let _ = c.wait();
366
377
                        }
367
450
                        break;
368
                    }
369
92
                    Err(kanal::ReceiveErrorTimeout::Timeout) => {
370
92
                        // Neither signal yet — loop.
371
92
                    }
372
                }
373
            }
374
375
450
            components_to_stop_killer.fetch_sub(1, Ordering::SeqCst);
376
450
            debug!("parallel reader: killer thread stop");
377
450
        });
378
379
450
        (rx_item, tx_interrupt)
380
450
    }
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
450
    fn spawn_io_reader(
385
450
        source: impl BufRead + Send + 'static,
386
450
        tx_chunks: kanal::Sender<(usize, Vec<u8>)>,
387
450
        line_ending: u8,
388
450
    ) {
389
450
        thread::spawn(move || {
390
450
            debug!("parallel reader: I/O thread start");
391
392
450
            let mut source = source;
393
450
            let mut leftover: Vec<u8> = Vec::new();
394
450
            let mut seq = 0usize;
395
450
            let mut read_buf = vec![0u8; PARALLEL_READ_BUF_SIZE];
396
397
            loop {
398
856
                let 
n406
= match std::io::Read::read(&mut source, &mut read_buf) {
399
                    Ok(0) => {
400
                        // EOF — flush any remaining leftover as the final chunk.
401
450
                        if !leftover.is_empty() {
  Branch (401:28): [True: 5, False: 64]
+
  Branch (401:28): [True: 0, False: 1]
+
  Branch (401:28): [True: 0, False: 23]
+
  Branch (401:28): [True: 0, False: 119]
+
  Branch (401:28): [True: 3, False: 0]
+
  Branch (401:28): [True: 0, False: 36]
+
  Branch (401:28): [True: 0, False: 5]
+
  Branch (401:28): [True: 0, False: 9]
+
  Branch (401:28): [True: 0, False: 2]
+
  Branch (401:28): [True: 0, False: 7]
+
  Branch (401:28): [True: 0, False: 13]
+
  Branch (401:28): [True: 0, False: 11]
+
  Branch (401:28): [True: 0, False: 2]
+
  Branch (401:28): [True: 0, False: 34]
+
  Branch (401:28): [True: 0, False: 2]
+
  Branch (401:28): [True: 0, False: 24]
+
  Branch (401:28): [True: 1, False: 0]
+
  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: 0, False: 5]
+
  Branch (401:28): [True: 20, False: 11]
+
  Branch (401:28): [True: 0, False: 12]
+
  Branch (401:28): [True: 0, False: 11]
+
  Branch (401:28): [True: 0, False: 3]
+
  Branch (401:28): [True: 0, False: 9]
+
402
29
                            let _ = tx_chunks.send((seq, std::mem::take(&mut leftover)));
403
421
                        }
404
450
                        break;
405
                    }
406
406
                    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]
+
  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]
+
  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]
+
  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]
+
  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]
+
  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]
+
  Branch (407:35): [True: 0, False: 0]
+
  Branch (407:35): [True: 0, False: 0]
+
408
                    Err(_) => {
409
                        // Flush any accumulated data before exiting on error.
410
0
                        if !leftover.is_empty() {
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  Branch (410:28): [True: 0, False: 0]
+
  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
406
                let data = if leftover.is_empty() {
  Branch (418:31): [True: 16, False: 0]
+
  Branch (418:31): [True: 1, False: 0]
+
  Branch (418:31): [True: 23, False: 0]
+
  Branch (418:31): [True: 119, False: 0]
+
  Branch (418:31): [True: 3, False: 0]
+
  Branch (418:31): [True: 36, False: 0]
+
  Branch (418:31): [True: 5, False: 0]
+
  Branch (418:31): [True: 9, False: 0]
+
  Branch (418:31): [True: 2, False: 0]
+
  Branch (418:31): [True: 7, False: 0]
+
  Branch (418:31): [True: 13, False: 0]
+
  Branch (418:31): [True: 11, False: 0]
+
  Branch (418:31): [True: 2, False: 0]
+
  Branch (418:31): [True: 34, False: 0]
+
  Branch (418:31): [True: 2, False: 0]
+
  Branch (418:31): [True: 24, False: 0]
+
  Branch (418:31): [True: 1, 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: 5, False: 0]
+
  Branch (418:31): [True: 31, False: 9]
+
  Branch (418:31): [True: 12, False: 0]
+
  Branch (418:31): [True: 11, False: 0]
+
  Branch (418:31): [True: 3, False: 0]
+
  Branch (418:31): [True: 9, False: 0]
+
419
397
                    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
406
                if let Some(
last_nl397
) = memchr::memrchr(line_ending, &data) {
  Branch (428:24): [True: 15, False: 1]
+
  Branch (428:24): [True: 1, False: 0]
+
  Branch (428:24): [True: 23, False: 0]
+
  Branch (428:24): [True: 119, False: 0]
+
  Branch (428:24): [True: 1, False: 2]
+
  Branch (428:24): [True: 36, False: 0]
+
  Branch (428:24): [True: 5, False: 0]
+
  Branch (428:24): [True: 9, False: 0]
+
  Branch (428:24): [True: 2, False: 0]
+
  Branch (428:24): [True: 7, False: 0]
+
  Branch (428:24): [True: 13, False: 0]
+
  Branch (428:24): [True: 11, False: 0]
+
  Branch (428:24): [True: 2, False: 0]
+
  Branch (428:24): [True: 34, False: 0]
+
  Branch (428:24): [True: 2, False: 0]
+
  Branch (428:24): [True: 24, False: 0]
+
  Branch (428:24): [True: 1, 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: 5, False: 0]
+
  Branch (428:24): [True: 34, False: 6]
+
  Branch (428:24): [True: 12, False: 0]
+
  Branch (428:24): [True: 11, False: 0]
+
  Branch (428:24): [True: 3, False: 0]
+
  Branch (428:24): [True: 9, False: 0]
+
429
397
                    leftover = data[last_nl + 1..].to_vec();
430
397
                    let mut chunk = data;
431
397
                    chunk.truncate(last_nl + 1);
432
397
                    if tx_chunks.send((seq, chunk)).is_err() {
  Branch (432:24): [True: 0, False: 15]
+
  Branch (432:24): [True: 0, False: 1]
+
  Branch (432:24): [True: 0, False: 23]
+
  Branch (432:24): [True: 0, False: 119]
+
  Branch (432:24): [True: 0, False: 1]
+
  Branch (432:24): [True: 0, False: 36]
+
  Branch (432:24): [True: 0, False: 5]
+
  Branch (432:24): [True: 0, False: 9]
+
  Branch (432:24): [True: 0, False: 2]
+
  Branch (432:24): [True: 0, False: 7]
+
  Branch (432:24): [True: 0, False: 13]
+
  Branch (432:24): [True: 0, False: 11]
+
  Branch (432:24): [True: 0, False: 2]
+
  Branch (432:24): [True: 0, False: 34]
+
  Branch (432:24): [True: 0, False: 2]
+
  Branch (432:24): [True: 0, False: 24]
+
  Branch (432:24): [True: 0, False: 1]
+
  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: 5]
+
  Branch (432:24): [True: 0, False: 34]
+
  Branch (432:24): [True: 0, False: 12]
+
  Branch (432:24): [True: 0, False: 11]
+
  Branch (432:24): [True: 0, False: 3]
+
  Branch (432:24): [True: 0, False: 9]
+
433
0
                        break;
434
397
                    }
435
397
                    seq += 1;
436
9
                } else {
437
9
                    // No newline at all — accumulate for the next read.
438
9
                    leftover = data;
439
9
                }
440
            }
441
442
450
            debug!("parallel reader: I/O thread stop (sent {seq} chunks)");
443
450
        });
444
450
    }
445
446
    /// Parses a raw byte chunk into a tagged batch of items.
447
426
    fn process_chunk(seq: usize, chunk: &[u8], opt: &SkimItemReaderOption) -> (usize, Vec<Arc<dyn SkimItem>>) {
448
426
        let mut items = Vec::new();
449
426
        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
426
        let chunk_trimmed: &[u8] = if chunk.last() == Some(&line_ending) {
  Branch (456:39): [True: 379, False: 29]
+
  Branch (456:39): [True: 18, False: 0]
+
457
397
            &chunk[..chunk.len() - 1]
458
        } else {
459
29
            chunk
460
        };
461
462
673k
        for 
line_bytes52.0k
in
chunk_trimmed426
.
split426
(|&b: &u8| b == line_ending) {
463
            // Strip optional \r for \r\n endings.
464
52.0k
            let line_bytes: &[u8] = line_bytes.strip_suffix(b"\r").unwrap_or(line_bytes);
465
52.0k
            let Ok(
line52.0k
) = std::str::from_utf8(line_bytes) else {
  Branch (465:17): [True: 52.0k, False: 0]
+
  Branch (465:17): [True: 36, False: 1]
+
466
1
                continue;
467
            };
468
52.0k
            let mut item = DefaultSkimItem::new(
469
52.0k
                line,
470
52.0k
                opt.use_ansi_color,
471
52.0k
                &opt.transform_fields,
472
52.0k
                &opt.matching_fields,
473
52.0k
                &opt.delimiter,
474
            )
475
52.0k
            .hidden_fields(&opt.hidden_fields, &opt.delimiter);
476
52.0k
            if opt.disable_pattern.as_ref().is_some_and(|re| 
re2
.
is_match2
(
line2
)) {
  Branch (476:16): [True: 0, False: 52.0k]
+
  Branch (476:16): [True: 1, False: 35]
+
477
1
                item.disable();
478
52.0k
            }
479
52.0k
            items.push(Arc::new(item) as Arc<dyn SkimItem>);
480
        }
481
482
426
        (seq, items)
483
426
    }
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
450
    fn spawn_reorder_thread(
490
450
        rx_results: kanal::Receiver<(usize, Vec<Arc<dyn SkimItem>>)>,
491
450
        tx_item: SkimItemSender,
492
450
        tx_pipeline_done: kanal::Sender<()>,
493
450
    ) {
494
450
        thread::spawn(move || {
495
450
            debug!("parallel reader: reorder thread start");
496
450
            let mut expected = 0usize;
497
450
            let mut pending: BTreeMap<usize, Vec<Arc<dyn SkimItem>>> = BTreeMap::new();
498
499
875
            while let Ok((
seq426
,
items426
)) = rx_results.recv() {
  Branch (499:23): [True: 408, False: 432]
+
  Branch (499:23): [True: 18, False: 17]
+
500
426
                pending.insert(seq, items);
501
                // Flush consecutive batches starting from the expected seq.
502
851
                while let Some(
batch426
) = pending.remove(&expected) {
  Branch (502:27): [True: 408, False: 408]
+
  Branch (502:27): [True: 18, False: 17]
+
503
426
                    if tx_item.send(batch).is_err() {
  Branch (503:24): [True: 0, False: 408]
+
  Branch (503:24): [True: 1, False: 17]
+
504
1
                        return;
505
425
                    }
506
425
                    expected += 1;
507
                }
508
            }
509
            // Drain anything left (shouldn't normally happen).
510
449
            while let Some((&
seq0
, _)) = pending.first_key_value() {
  Branch (510:23): [True: 0, False: 432]
+
  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
449
            drop(tx_pipeline_done);
518
449
            debug!("parallel reader: reorder thread stop");
519
450
        });
520
450
    }
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
405
    fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
535
405
        self.thread_pool = pool;
536
405
    }
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");
547
1
        command.stderr(writer);
548
72
    } else {
549
72
        command.stderr(Stdio::null());
550
72
    }
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 e338187c..26777538 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-04 10: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_n}");
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-05 18:50

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
20
    pub fn first_n(mut self, first_n: usize) -> Self {
19
20
        trace!("select first_n: {first_n}");
20
20
        self.first_n = first_n;
21
20
        self
22
20
    }
23
24
    /// Selects items whose text matches any of the preset strings
25
    #[must_use]
26
19
    pub fn preset(mut self, preset: impl IntoIterator<Item = String>) -> Self {
27
19
        if self.preset.is_none() {
  Branch (27:12): [True: 17, False: 0]
 
  Branch (27:12): [True: 2, False: 0]
-
28
18
            self.preset = Some(HashSet::new());
29
18
        
}0
30
31
18
        if let Some(set) = self.preset.as_mut() {
  Branch (31:16): [True: 16, False: 0]
+
28
19
            self.preset = Some(HashSet::new());
29
19
        
}0
30
31
19
        if let Some(set) = self.preset.as_mut() {
  Branch (31:16): [True: 17, False: 0]
 
  Branch (31:16): [True: 2, False: 0]
-
32
18
            set.extend(preset);
33
18
        
}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: {regex}");
41
18
        if !regex.is_empty() {
  Branch (41:12): [True: 2, False: 14]
+
32
19
            set.extend(preset);
33
19
        
}0
34
19
        self
35
19
    }
36
37
    /// Selects items whose text matches the given regex pattern
38
    #[must_use]
39
19
    pub fn regex(mut self, regex: &str) -> Self {
40
19
        trace!("select regex: {regex}");
41
19
        if !regex.is_empty() {
  Branch (41:12): [True: 3, False: 14]
 
  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
5
            self.regex = Regex::new(regex).ok();
43
14
        }
44
19
        self
45
19
    }
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 89596ae3..78409488 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,30 +1,30 @@
-

Coverage Report

Created: 2026-09-04 10: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
3.85k
    fn default() -> Self {
32
3.85k
        Self {
33
3.85k
            criterion: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End],
34
3.85k
            tac: false,
35
3.85k
        }
36
3.85k
    }
37
}
38
39
impl RankBuilder {
40
    /// Creates a new rank builder with the given criteria
41
    #[must_use]
42
399
    pub fn new(mut criterion: Vec<RankCriteria>) -> Self {
43
399
        if !criterion.contains(&RankCriteria::Score) && 
!6
criterion6
.contains(&RankCriteria::NegScore) {
  Branch (43:12): [True: 1, False: 372]
+

Coverage Report

Created: 2026-09-05 18:50

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.16k
    fn default() -> Self {
32
4.16k
        Self {
33
4.16k
            criterion: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End],
34
4.16k
            tac: false,
35
4.16k
        }
36
4.16k
    }
37
}
38
39
impl RankBuilder {
40
    /// Creates a new rank builder with the given criteria
41
    #[must_use]
42
418
    pub fn new(mut criterion: Vec<RankCriteria>) -> Self {
43
418
        if !criterion.contains(&RankCriteria::Score) && 
!7
criterion7
.contains(&RankCriteria::NegScore) {
  Branch (43:12): [True: 1, False: 388]
   Branch (43:57): [True: 0, False: 1]
-
  Branch (43:12): [True: 5, False: 21]
-  Branch (43:57): [True: 4, False: 1]
-
44
4
            criterion.insert(0, RankCriteria::Score);
45
395
        }
46
47
399
        criterion.dedup();
48
399
        Self { criterion, tac: false }
49
399
    }
50
51
    #[must_use]
52
400
    pub(crate) fn tac(mut self, tac: bool) -> Self {
53
400
        self.tac = tac;
54
400
        self
55
400
    }
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.72k]
+
  Branch (43:12): [True: 6, False: 23]
+  Branch (43:57): [True: 5, False: 1]
+
44
5
            criterion.insert(0, RankCriteria::Score);
45
413
        }
46
47
418
        criterion.dedup();
48
418
        Self { criterion, tac: false }
49
418
    }
50
51
    #[must_use]
52
416
    pub(crate) fn tac(mut self, tac: bool) -> Self {
53
416
        self.tac = tac;
54
416
        self
55
416
    }
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
49
            for (priority, criterion) in 
self.criterion.iter()17
.
take17
(5).
enumerate17
() {
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.72k]
-
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
160k
        for (priority, criterion) in 
criteria53.6k
.
iter53.6k
().
take53.6k
(5).
enumerate53.6k
() {
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
10
                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
274
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
178
274
        state.write_i32(self.rank.index);
179
274
        self.text().hash(state);
180
274
    }
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
17
    pub fn sorted_merge(existing: Vec<MatchedItem>, incoming: Vec<MatchedItem>) -> Vec<MatchedItem> {
212
17
        if existing.is_empty() {
  Branch (212:12): [True: 7, False: 1]
+
  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
160k
        for (priority, criterion) in 
criteria53.6k
.
iter53.6k
().
take53.6k
(5).
enumerate53.6k
() {
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]
 
  Branch (212:12): [True: 1, False: 8]
-
213
8
            return incoming;
214
9
        }
215
9
        if incoming.is_empty() {
  Branch (215:12): [True: 0, False: 1]
+
213
17
            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
8
        }
218
219
        // Fast path: if all existing <= all incoming, we can append without merging.
220
        #[allow(clippy::missing_panics_doc)]
221
8
        if existing.last().unwrap() <= incoming.first().unwrap() {
  Branch (221:12): [True: 1, False: 0]
+
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]
-
222
3
            let mut out = existing;
223
3
            out.extend(incoming);
224
3
            return out;
225
5
        }
226
227
        // Fast path: if all incoming <= all existing, prepend without complex merge.
228
        #[allow(clippy::missing_panics_doc)]
229
5
        if incoming.last().unwrap() <= existing.first().unwrap() {
  Branch (229:12): [True: 0, False: 0]
+
222
2
            let mut out = existing;
223
2
            out.extend(incoming);
224
2
            return out;
225
5
        }
226
227
        // Fast path: if all incoming <= all existing, prepend without complex merge.
228
        #[allow(clippy::missing_panics_doc)]
229
5
        if incoming.last().unwrap() <= existing.first().unwrap() {
  Branch (229:12): [True: 0, False: 0]
 
  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
5
                (Some(av), Some(bv)) => {
242
5
                    if av <= bv {
  Branch (242:24): [True: 0, False: 0]
 
  Branch (242:24): [True: 3, False: 2]
-
243
3
                        #[allow(clippy::missing_panics_doc)]
244
3
                        merged.push(a.next().unwrap());
245
3
                    } else {
246
2
                        #[allow(clippy::missing_panics_doc)]
247
2
                        merged.push(b.next().unwrap());
248
2
                    }
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
17
    }
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
45
    pub fn merge_into_sorted(existing: &mut Vec<MatchedItem>, incoming: Vec<MatchedItem>) {
279
        const SMALL_INSERT_THRESHOLD: usize = 256;
280
281
45
        if incoming.is_empty() {
  Branch (281:12): [True: 3, False: 39]
-
  Branch (281:12): [True: 0, False: 3]
-
282
3
            return;
283
42
        }
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
42
        if existing.is_empty() {
  Branch (287:12): [True: 38, False: 1]
-
  Branch (287:12): [True: 0, False: 3]
-
288
38
            existing.extend(incoming);
289
38
            return;
290
4
        }
291
292
        // Fast path: all existing ≤ first incoming — just append.
293
        #[allow(clippy::missing_panics_doc)]
294
4
        if existing.last().unwrap() <= incoming.first().unwrap() {
  Branch (294:12): [True: 1, False: 0]
+
243
3
                        #[allow(clippy::missing_panics_doc)]
244
3
                        merged.push(a.next().unwrap());
245
3
                    } else {
246
2
                        #[allow(clippy::missing_panics_doc)]
247
2
                        merged.push(b.next().unwrap());
248
2
                    }
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
286
    pub fn merge_into_sorted(existing: &mut Vec<MatchedItem>, incoming: Vec<MatchedItem>) {
279
        const SMALL_INSERT_THRESHOLD: usize = 256;
280
281
286
        if incoming.is_empty() {
  Branch (281:12): [True: 6, False: 272]
+
  Branch (281:12): [True: 0, False: 8]
+
282
6
            return;
283
280
        }
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
280
        if existing.is_empty() {
  Branch (287:12): [True: 272, False: 0]
+
  Branch (287:12): [True: 5, False: 3]
+
288
277
            existing.extend(incoming);
289
277
            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
1
            existing.extend(incoming);
296
1
            return;
297
3
        }
298
299
3
        if incoming.len() <= SMALL_INSERT_THRESHOLD {
  Branch (299:12): [True: 0, False: 0]
+
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]
-
300
1
            for item in incoming {
301
2
                let 
pos1
=
existing1
.
binary_search_by1
(|e| e.cmp(&item)).
unwrap_or_else1
(|p| p);
302
1
                existing.insert(pos, item);
303
            }
304
2
        } else {
305
2
            Self::merge_backwards(existing, incoming);
306
2
        }
307
45
    }
308
309
    /// Merges `incoming` into `existing` in-place using a right-to-left merge.
310
    ///
311
    /// Both inputs must already be sorted.  After `existing.reserve(b_len)`,
312
    /// the buffer has room for all elements.  We then merge from the rightmost
313
    /// end of each run, writing the larger element at the write cursor which
314
    /// starts at `new_len - 1` and moves left.
315
    ///
316
    /// **Key invariant**: the write position is always strictly greater than
317
    /// the read position in `existing` while both runs have remaining elements
318
    /// (because `write - ai == remaining B elements > 0`), so
319
    /// `copy_nonoverlapping` never aliases.  Each element is moved exactly
320
    /// once.
321
    ///
322
    /// # Safety (internal)
323
    ///
324
    /// Uses `unsafe` for raw-pointer moves.  `MatchedItem::cmp` compares plain
325
    /// integer fields and cannot panic, so no element is leaked or
326
    /// double-dropped.  `incoming`'s backing allocation is freed with length 0
327
    /// after all its elements have been moved out.
328
2
    fn merge_backwards(existing: &mut Vec<MatchedItem>, incoming: Vec<MatchedItem>) {
329
2
        let a_len = existing.len();
330
2
        let b_len = incoming.len();
331
2
        let new_len = a_len + b_len;
332
333
2
        existing.reserve(b_len);
334
335
        // Decompose `incoming` so we can move elements out via raw pointers
336
        // and free the allocation separately.
337
2
        let (b_ptr, b_cap) = {
338
2
            let mut v = std::mem::ManuallyDrop::new(incoming);
339
2
            (v.as_mut_ptr(), v.capacity())
340
2
        };
341
342
        // SAFETY: see doc-comment above for the aliasing / move proof.
343
        unsafe {
344
2
            let a_ptr = existing.as_mut_ptr();
345
2
            let mut write = new_len;
346
2
            let mut ai = a_len;
347
2
            let mut bi = b_len;
348
349
901
            while ai > 0 && 
bi > 0900
{
  Branch (349:19): [True: 0, False: 0]
+
300
1
            for item in incoming {
301
2
                let 
pos1
=
existing1
.
binary_search_by1
(|e| e.cmp(&item)).
unwrap_or_else1
(|p| p);
302
1
                existing.insert(pos, item);
303
            }
304
2
        } else {
305
2
            Self::merge_backwards(existing, incoming);
306
2
        }
307
286
    }
308
309
    /// Merges `incoming` into `existing` in-place using a right-to-left merge.
310
    ///
311
    /// Both inputs must already be sorted.  After `existing.reserve(b_len)`,
312
    /// the buffer has room for all elements.  We then merge from the rightmost
313
    /// end of each run, writing the larger element at the write cursor which
314
    /// starts at `new_len - 1` and moves left.
315
    ///
316
    /// **Key invariant**: the write position is always strictly greater than
317
    /// the read position in `existing` while both runs have remaining elements
318
    /// (because `write - ai == remaining B elements > 0`), so
319
    /// `copy_nonoverlapping` never aliases.  Each element is moved exactly
320
    /// once.
321
    ///
322
    /// # Safety (internal)
323
    ///
324
    /// Uses `unsafe` for raw-pointer moves.  `MatchedItem::cmp` compares plain
325
    /// integer fields and cannot panic, so no element is leaked or
326
    /// double-dropped.  `incoming`'s backing allocation is freed with length 0
327
    /// after all its elements have been moved out.
328
2
    fn merge_backwards(existing: &mut Vec<MatchedItem>, incoming: Vec<MatchedItem>) {
329
2
        let a_len = existing.len();
330
2
        let b_len = incoming.len();
331
2
        let new_len = a_len + b_len;
332
333
2
        existing.reserve(b_len);
334
335
        // Decompose `incoming` so we can move elements out via raw pointers
336
        // and free the allocation separately.
337
2
        let (b_ptr, b_cap) = {
338
2
            let mut v = std::mem::ManuallyDrop::new(incoming);
339
2
            (v.as_mut_ptr(), v.capacity())
340
2
        };
341
342
        // SAFETY: see doc-comment above for the aliasing / move proof.
343
        unsafe {
344
2
            let a_ptr = existing.as_mut_ptr();
345
2
            let mut write = new_len;
346
2
            let mut ai = a_len;
347
2
            let mut bi = b_len;
348
349
901
            while ai > 0 && 
bi > 0900
{
  Branch (349:19): [True: 0, False: 0]
   Branch (349:29): [True: 0, False: 0]
 
  Branch (349:19): [True: 900, False: 1]
   Branch (349:29): [True: 899, False: 1]
@@ -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
599
                } else {
355
300
                    bi -= 1;
356
300
                    std::ptr::copy_nonoverlapping(b_ptr.add(bi), a_ptr.add(write), 1);
357
300
                }
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
302
    fn eq(&self, other: &Self) -> bool {
386
302
        self.text().eq(&other.text()) && 
self.rank.index178
.
eq178
(
&other.rank.index178
)
  Branch (386:9): [True: 160, False: 118]
+
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.index181
.
eq181
(
&other.rank.index181
)
  Branch (386:9): [True: 163, False: 120]
 
  Branch (386:9): [True: 18, False: 6]
-
387
302
    }
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
393
    pub fn from_options(options: &crate::SkimOptions) -> Self {
455
393
        Self {
456
393
            length: AtomicUsize::new(0),
457
393
            pool: SpinLock::new(Vec::with_capacity(ITEM_POOL_CAPACITY)),
458
393
            taken: AtomicUsize::new(0),
459
393
            reserved_items: SpinLock::new(Vec::new()),
460
393
            lines_to_reserve: options.header_lines,
461
393
            tac: options.tac,
462
393
            items_available: Arc::new(Notify::new()),
463
393
        }
464
393
    }
465
466
    /// Returns the total number of items in the pool
467
2.63k
    pub fn len(&self) -> usize {
468
2.63k
        self.length.load(Ordering::SeqCst)
469
2.63k
    }
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.88k
    pub fn num_not_taken(&self) -> usize {
478
2.88k
        self.length.load(Ordering::SeqCst) - self.taken.load(Ordering::SeqCst)
479
2.88k
    }
480
481
    /// Returns the number of items that have been taken
482
825
    pub fn num_taken(&self) -> usize {
483
825
        self.taken.load(Ordering::SeqCst)
484
825
    }
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
770
    pub fn reset(&self) {
498
        // lock to ensure consistency
499
770
        let _items = self.pool.lock();
500
501
770
        self.taken.store(0, Ordering::SeqCst);
502
770
    }
503
504
    /// append the items and return the `new_size` of the pool
505
427
    pub fn append(&self, mut items: Vec<Arc<dyn SkimItem>>) -> usize {
506
427
        let len = items.len();
507
427
        trace!("item pool, append {len} items");
508
427
        let mut pool = self.pool.lock();
509
427
        let mut header_items = self.reserved_items.lock();
510
511
427
        let to_reserve = self.lines_to_reserve - header_items.len();
512
427
        if to_reserve > 0 {
  Branch (512:12): [True: 18, False: 376]
-
  Branch (512:12): [True: 1, False: 32]
-
513
19
            let to_reserve = min(to_reserve, items.len());
514
19
            // Split items: first part goes to header, rest to main pool
515
19
            let remaining = items.split_off(to_reserve);
516
19
517
19
            // Header items are always in input order, regardless of tac
518
19
            header_items.extend(items);
519
19
520
19
            pool.extend(remaining);
521
408
        } else {
522
408
            pool.extend(items);
523
408
        }
524
427
        self.length.store(pool.len(), Ordering::SeqCst);
525
427
        trace!("item pool, done append {len} items, total: {}", 
pool.len()2
);
526
427
        let new_len = pool.len();
527
427
        drop(pool);
528
427
        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
427
        self.items_available.notify_one();
533
534
427
        new_len
535
427
    }
536
537
    /// Takes items from the pool, copying new items since last take and releasing lock immediately
538
828
    pub fn take(&self) -> Vec<Arc<dyn SkimItem>> {
539
828
        let guard = self.pool.lock();
540
828
        let taken = self.taken.swap(guard.len(), Ordering::SeqCst);
541
        // Copy the new items out so we can release the lock immediately
542
828
        let mut items = guard[taken..].to_vec();
543
828
        if self.tac {
  Branch (543:12): [True: 5, False: 793]
-
  Branch (543:12): [True: 1, False: 29]
-
544
6
            items.reverse();
545
822
        }
546
828
        drop(guard); // Explicitly release lock
547
828
        items
548
828
    }
549
550
    /// Returns a copy of the reserved header items
551
2.67k
    pub fn reserved(&self) -> Vec<Arc<dyn SkimItem>> {
552
2.67k
        let guard = self.reserved_items.lock();
553
2.67k
        guard.clone()
554
2.67k
    }
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.40k
    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.40k
        &[
608
1.40k
            Score,
609
1.40k
            NegScore,
610
1.40k
            Begin,
611
1.40k
            NegBegin,
612
1.40k
            End,
613
1.40k
            NegEnd,
614
1.40k
            Length,
615
1.40k
            NegLength,
616
1.40k
            Index,
617
1.40k
            NegIndex,
618
1.40k
            PathName,
619
1.40k
            NegPathName,
620
1.40k
        ]
621
1.40k
    }
622
623
4.39k
    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.39k
        Some(match self {
628
1.40k
            Score => PossibleValue::new("score"),
629
936
            Begin => PossibleValue::new("begin"),
630
480
            End => PossibleValue::new("end"),
631
937
            NegScore => PossibleValue::new("-score"),
632
481
            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.39k
    }
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 +
387
307
    }
388
}
389
390
impl std::cmp::Eq for MatchedItem {}
391
392
impl PartialOrd for MatchedItem {
393
5.22k
    fn partial_cmp(&self, other: &Self) -> Option<CmpOrd> {
394
5.22k
        Some(self.cmp(other))
395
5.22k
    }
396
}
397
398
impl Ord for MatchedItem {
399
5.22k
    fn cmp(&self, other: &Self) -> CmpOrd {
400
5.22k
        self.sort_key.cmp(&other.sort_key)
401
5.22k
    }
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
408
    pub fn from_options(options: &crate::SkimOptions) -> Self {
455
408
        Self {
456
408
            length: AtomicUsize::new(0),
457
408
            pool: SpinLock::new(Vec::with_capacity(ITEM_POOL_CAPACITY)),
458
408
            taken: AtomicUsize::new(0),
459
408
            reserved_items: SpinLock::new(Vec::new()),
460
408
            lines_to_reserve: options.header_lines,
461
408
            tac: options.tac,
462
408
            items_available: Arc::new(Notify::new()),
463
408
        }
464
408
    }
465
466
    /// Returns the total number of items in the pool
467
2.70k
    pub fn len(&self) -> usize {
468
2.70k
        self.length.load(Ordering::SeqCst)
469
2.70k
    }
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.78k
    pub fn num_not_taken(&self) -> usize {
478
2.78k
        self.length.load(Ordering::SeqCst) - self.taken.load(Ordering::SeqCst)
479
2.78k
    }
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
789
    pub fn reset(&self) {
498
        // lock to ensure consistency
499
789
        let _items = self.pool.lock();
500
501
789
        self.taken.store(0, Ordering::SeqCst);
502
789
    }
503
504
    /// append the items and return the `new_size` of the pool
505
441
    pub fn append(&self, mut items: Vec<Arc<dyn SkimItem>>) -> usize {
506
441
        let len = items.len();
507
441
        trace!("item pool, append {len} items");
508
441
        let mut pool = self.pool.lock();
509
441
        let mut header_items = self.reserved_items.lock();
510
511
441
        let to_reserve = self.lines_to_reserve - header_items.len();
512
441
        if to_reserve > 0 {
  Branch (512:12): [True: 18, False: 391]
+
  Branch (512:12): [True: 1, False: 31]
+
513
19
            let to_reserve = min(to_reserve, items.len());
514
19
            // Split items: first part goes to header, rest to main pool
515
19
            let remaining = items.split_off(to_reserve);
516
19
517
19
            // Header items are always in input order, regardless of tac
518
19
            header_items.extend(items);
519
19
520
19
            pool.extend(remaining);
521
422
        } else {
522
422
            pool.extend(items);
523
422
        }
524
441
        self.length.store(pool.len(), Ordering::SeqCst);
525
441
        trace!("item pool, done append {len} items, total: {}", 
pool.len()2
);
526
441
        let new_len = pool.len();
527
441
        drop(pool);
528
441
        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
441
        self.items_available.notify_one();
533
534
441
        new_len
535
441
    }
536
537
    /// Takes items from the pool, copying new items since last take and releasing lock immediately
538
1.10k
    pub fn take(&self) -> Vec<Arc<dyn SkimItem>> {
539
1.10k
        let guard = self.pool.lock();
540
1.10k
        let taken = self.taken.swap(guard.len(), Ordering::SeqCst);
541
        // Copy the new items out so we can release the lock immediately
542
1.10k
        let mut items = guard[taken..].to_vec();
543
1.10k
        if self.tac {
  Branch (543:12): [True: 6, False: 1.05k]
+
  Branch (543:12): [True: 1, False: 37]
+
544
7
            items.reverse();
545
1.09k
        }
546
1.10k
        drop(guard); // Explicitly release lock
547
1.10k
        items
548
1.10k
    }
549
550
    /// Returns a copy of the reserved header items
551
2.74k
    pub fn reserved(&self) -> Vec<Arc<dyn SkimItem>> {
552
2.74k
        let guard = self.reserved_items.lock();
553
2.74k
        guard.clone()
554
2.74k
    }
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.53k
    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.53k
        Some(match self {
628
1.44k
            Score => PossibleValue::new("score"),
629
966
            Begin => PossibleValue::new("begin"),
630
495
            End => PossibleValue::new("end"),
631
967
            NegScore => PossibleValue::new("-score"),
632
496
            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.53k
    }
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 2fddce73..0f0a8e45 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-04 10: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
116
fn shell_cmd(cmd: &str) -> Command {
84
116
    let mut c = Command::new("sh");
85
116
    c.arg("-c").arg(cmd);
86
116
    c
87
116
}
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.45k
    pub fn to_line(self, cow: Cow<str>) -> Line {
158
6.45k
        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.45k
        match &self.matches {
163
1.64k
            Matches::CharIndices(indices) => {
164
1.64k
                let mut res = Line::default();
165
1.64k
                let mut chars = text.chars();
166
1.64k
                let mut prev_index = 0;
167
3.58k
                for &index in 
indices1.64k
{
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
3.58k
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
3.58k
                }
178
1.64k
                res.push_span(Span::styled(chars.collect::<String>(), self.base_style));
179
1.64k
                res
180
            }
181
            // AnsiString::from((context.text, indices, context.highlight_attr)),
182
            #[allow(clippy::cast_possible_truncation)]
183
1
            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
                ));
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
                ));
196
1
                res.push_span(Span::styled(chars.collect::<String>(), self.base_style));
197
1
                res
198
            }
199
4.61k
            Matches::ByteRange(start, end) => {
200
4.61k
                let mut bytes = text.bytes();
201
4.61k
                let mut res = Line::default();
202
4.61k
                res.push_span(Span::styled(
203
4.61k
                    String::from_utf8(bytes.by_ref().take(*start).collect()).unwrap(),
204
4.61k
                    self.base_style,
205
                ));
206
4.61k
                let highlighted_bytes = bytes.by_ref().take(*end - *start).collect();
207
4.61k
                let highlighted_text = String::from_utf8(highlighted_bytes).unwrap();
208
209
4.61k
                res.push_span(Span::styled(
210
4.61k
                    highlighted_text,
211
4.61k
                    self.base_style.patch(self.matched_style),
212
                ));
213
4.61k
                res.push_span(Span::styled(
214
4.61k
                    String::from_utf8(bytes.collect()).unwrap(),
215
4.61k
                    self.base_style,
216
                ));
217
4.61k
                res
218
            }
219
204
            Matches::None => Line::from(vec![Span::styled(text, self.base_style)]),
220
        }
221
6.45k
    }
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
3
            &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
17
    fn create_engine(&self, query: &str) -> Box<dyn MatchEngine> {
392
17
        self.create_engine_with_case(query, CaseMatching::default())
393
17
    }
394
}
395
396
impl MatchEngineFactory for Box<dyn MatchEngineFactory> {
397
3.05k
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
398
3.05k
        (**self).create_engine_with_case(query, case)
399
3.05k
    }
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-05 18:50

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
118
fn shell_cmd(cmd: &str) -> Command {
84
118
    let mut c = Command::new("sh");
85
118
    c.arg("-c").arg(cmd);
86
118
    c
87
118
}
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.48k
    pub fn to_line(self, cow: Cow<str>) -> Line {
158
6.48k
        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.48k
        match &self.matches {
163
1.64k
            Matches::CharIndices(indices) => {
164
1.64k
                let mut res = Line::default();
165
1.64k
                let mut chars = text.chars();
166
1.64k
                let mut prev_index = 0;
167
3.61k
                for &index in 
indices1.64k
{
168
3.61k
                    let span_content = chars.by_ref().take(index - prev_index);
169
3.61k
                    res.push_span(Span::styled(span_content.collect::<String>(), self.base_style));
170
3.61k
                    let highlighted_char = chars.next().unwrap_or_default().to_string();
171
3.61k
172
3.61k
                    res.push_span(Span::styled(
173
3.61k
                        highlighted_char,
174
3.61k
                        self.base_style.patch(self.matched_style),
175
3.61k
                    ));
176
3.61k
                    prev_index = index + 1;
177
3.61k
                }
178
1.64k
                res.push_span(Span::styled(chars.collect::<String>(), self.base_style));
179
1.64k
                res
180
            }
181
            // AnsiString::from((context.text, indices, context.highlight_attr)),
182
            #[allow(clippy::cast_possible_truncation)]
183
1
            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
                ));
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
                ));
196
1
                res.push_span(Span::styled(chars.collect::<String>(), self.base_style));
197
1
                res
198
            }
199
4.63k
            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
                ));
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
                ));
213
4.63k
                res.push_span(Span::styled(
214
4.63k
                    String::from_utf8(bytes.collect()).unwrap(),
215
4.63k
                    self.base_style,
216
                ));
217
4.63k
                res
218
            }
219
203
            Matches::None => Line::from(vec![Span::styled(text, self.base_style)]),
220
        }
221
6.48k
    }
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
3
            &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.92k
    fn create_engine_with_case(&self, query: &str, case: CaseMatching) -> Box<dyn MatchEngine> {
398
3.92k
        (**self).create_engine_with_case(query, case)
399
3.92k
    }
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 a6ded19e..63348b1f 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-04 10: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 
action504
in
ACTION_CATALOG7
.
iter7
().
filter7
(|action| action.is_bindable()) {
170
504
        let _ = writeln!(res, "* {}: {}", action.display_name(), action.summary());
171
504
    }
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
        .
map7
(|(key, actions)| {
181
336
            let actions = actions.iter().map(Action::name).collect::<Vec<_>>().join("+");
182
336
            (key_name(key), actions)
183
336
        })
184
7
        .collect::<Vec<_>>();
185
1.82k
    
bindings7
.
sort_unstable_by7
(|left, right| left.0.cmp(&right.0));
186
187
7
    let mut res = String::from("\n");
188
336
    for (key, actions) in 
bindings7
{
189
336
        let _ = writeln!(res, "* {key}: {actions}");
190
336
    }
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-05 18:50

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 
action504
in
ACTION_CATALOG7
.
iter7
().
filter7
(|action| action.is_bindable()) {
170
504
        let _ = writeln!(res, "* {}: {}", action.display_name(), action.summary());
171
504
    }
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
        .
map7
(|(key, actions)| {
181
336
            let actions = actions.iter().map(Action::name).collect::<Vec<_>>().join("+");
182
336
            (key_name(key), actions)
183
336
        })
184
7
        .collect::<Vec<_>>();
185
1.88k
    
bindings7
.
sort_unstable_by7
(|left, right| left.0.cmp(&right.0));
186
187
7
    let mut res = String::from("\n");
188
336
    for (key, actions) in 
bindings7
{
189
336
        let _ = writeln!(res, "* {key}: {actions}");
190
336
    }
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 d3217361..480c8131 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,25 +1,31 @@
-

Coverage Report

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

Coverage Report

Created: 2026-09-05 18:50

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: 22]
+
30
13
        start + batch_len - 1 - batch_index
31
    } else {
32
51.9k
        start + batch_index
33
    }
34
51.9k
}
35
36
1.09k
fn merge_worker_results(
37
1.09k
    worker_results: Vec<Vec<MatchedItem>>,
38
1.09k
    no_sort: bool,
39
1.09k
    processed_items: &Mutex<Option<ProcessedItems>>,
40
1.09k
    merge_strategy: MergeStrategy,
41
1.09k
    generation: usize,
42
1.09k
    current_generation: &AtomicUsize,
43
1.09k
    needs_render: &AtomicBool,
44
1.09k
) {
45
1.09k
    if current_generation.load(Ordering::Acquire) != generation {
  Branch (45:8): [True: 0, False: 1.06k]
+
  Branch (45:8): [True: 1, False: 28]
+
46
1
        return;
47
1.09k
    }
48
49
1.09k
    let total_len: usize = worker_results.iter().map(Vec::len).sum();
50
1.09k
    let mut items = Vec::with_capacity(total_len);
51
1.41k
    for chunk in 
worker_results1.09k
{
52
1.41k
        items.extend(chunk);
53
1.41k
    }
54
55
1.09k
    if !no_sort {
  Branch (55:8): [True: 1.05k, False: 10]
+
  Branch (55:8): [True: 23, False: 5]
+
56
1.07k
        // Each worker's sub-list is already sorted by `prepare`, so stable
57
1.07k
        // sort detects the pre-existing runs and merges them efficiently.
58
1.07k
        items.sort();
59
1.07k
    
}15
60
61
1.09k
    trace!("matcher stop, total matched: {}", 
items2
.
len2
());
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.09k
    let mut guard = processed_items
66
1.09k
        .lock()
67
1.09k
        .unwrap_or_else(std::sync::PoisonError::into_inner);
68
1.09k
    if current_generation.load(Ordering::Acquire) != generation {
  Branch (68:8): [True: 0, False: 1.06k]
+
  Branch (68:8): [True: 0, False: 28]
+
69
0
        return;
70
1.09k
    }
71
1.09k
    if 
matches!310
(merge_strategy, MergeStrategy::Replace) {
72
782
        *guard = Some(ProcessedItems {
73
782
            items,
74
782
            merge: MergeStrategy::Replace,
75
782
            generation,
76
782
        });
77
782
        drop(guard);
78
782
        needs_render.store(true, Ordering::Relaxed);
79
782
        return;
80
310
    }
81
310
    match &mut *guard {
82
291
        Some(existing) if existing.generation != generatio
n1
=> {
  Branch (82:27): [True: 0, False: 283]
+
  Branch (82:27): [True: 1, False: 7]
+
83
1
            *guard = Some(ProcessedItems {
84
1
                items,
85
1
                merge: MergeStrategy::Replace,
86
1
                generation,
87
1
            });
88
1
        }
89
290
        Some(existing) => {
90
290
            if no_sort {
  Branch (90:16): [True: 5, False: 278]
+
  Branch (90:16): [True: 2, False: 5]
+
91
7
                if 
matches!6
(merge_strategy, MergeStrategy::Prepend) {
92
1
                    items.append(&mut existing.items);
93
1
                    existing.items = items;
94
6
                } else {
95
6
                    existing.items.extend(items);
96
6
                }
97
283
            } else {
98
283
                // Both sides are fully sorted — one O(n+m) merge.
99
283
                MatchedItem::merge_into_sorted(&mut existing.items, items);
100
283
            }
101
        }
102
19
        None => {
103
19
            *guard = Some(ProcessedItems {
104
19
                items,
105
19
                merge: merge_strategy,
106
19
                generation,
107
19
            });
108
19
        }
109
    }
110
    // Guard is dropped here, releasing the lock before we signal the render flag.
111
112
310
    needs_render.store(true, Ordering::Relaxed);
113
1.09k
}
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
543
    fn default() -> Self {
128
543
        Self {
129
543
            stopped: Arc::new(AtomicBool::new(true)),
130
543
            interrupt: Arc::new(AtomicBool::new(false)),
131
543
            processed: Default::default(),
132
543
            matched: Default::default(),
133
543
        }
134
543
    }
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
783
    pub fn get_num_matched(&self) -> usize {
147
783
        self.matched.load(Ordering::Relaxed)
148
783
    }
149
150
    /// Signals the matcher to stop processing.
151
2.74k
    pub fn kill(&mut self) {
152
2.74k
        self.interrupt.store(true, Ordering::Relaxed);
153
2.74k
    }
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.63k
    fn drop(&mut self) {
164
1.63k
        self.kill();
165
1.63k
    }
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
547
    pub fn builder(engine_factory: Rc<dyn MatchEngineFactory>) -> Self {
180
547
        Self {
181
547
            engine_factory,
182
547
            case_matching: CaseMatching::default(),
183
547
            rank_builder: Arc::new(RankBuilder::default()),
184
547
        }
185
547
    }
186
187
    /// Sets the case matching mode (smart, ignore, or respect).
188
    #[must_use]
189
547
    pub fn case(mut self, case_matching: CaseMatching) -> Self {
190
547
        self.case_matching = case_matching;
191
547
        self
192
547
    }
193
194
    /// Sets the rank builder (carries tiebreak criteria).
195
    #[must_use]
196
410
    pub fn rank_builder(mut self, rank_builder: Arc<RankBuilder>) -> Self {
197
410
        self.rank_builder = rank_builder;
198
410
        self
199
410
    }
200
201
    /// Finalizes the builder and returns the configured Matcher.
202
    #[must_use]
203
547
    pub fn build(self) -> Self {
204
547
        self
205
547
    }
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
413
    pub fn create_engine_factory_with_builder(options: &SkimOptions) -> (Rc<dyn MatchEngineFactory>, Arc<RankBuilder>) {
222
413
        if options.regex {
  Branch (222:12): [True: 1, False: 388]
+
  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
409
            let rank_builder = Arc::new(RankBuilder::new(options.tiebreak.clone()).tac(options.tac));
233
409
            log::debug!("Creating matcher for algo {:?}", options.algorithm);
234
409
            let fuzzy_engine_factory = ExactOrFuzzyEngineFactory::builder()
235
409
                .fuzzy_algorithm(options.algorithm)
236
409
                .exact_mode(options.exact)
237
409
                .typos(options.typos)
238
409
                .filter_mode(options.filter.is_some())
239
409
                .last_match(options.last_match)
240
409
                .rank_builder(rank_builder.clone())
241
409
                .build();
242
243
409
            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
409
            if let Some(
delimiter11
) = options.split_match {
  Branch (246:20): [True: 11, False: 377]
+
  Branch (246:20): [True: 0, False: 21]
+
247
11
                factory = Box::new(SplitMatchEngineFactory::new(factory, delimiter));
248
398
            }
249
250
            // Wrap with AndOrEngineFactory so that queries like "foo:bar baz:qux" work
251
409
            factory = Box::new(AndOrEngineFactory::new(factory));
252
253
            // Wrap with NormalizedEngineFactory if normalization is requested
254
409
            if options.normalize {
  Branch (254:16): [True: 12, False: 376]
+
  Branch (254:16): [True: 0, False: 21]
+
255
12
                factory = Box::new(NormalizedEngineFactory::new(factory));
256
397
            }
257
258
409
            let factory: Rc<dyn MatchEngineFactory> = Rc::new(factory);
259
409
            (factory, rank_builder)
260
        }
261
413
    }
262
263
    /// Creates a Matcher configured from the given `SkimOptions`.
264
    #[must_use]
265
410
    pub fn from_options(options: &SkimOptions) -> Self {
266
410
        let (engine_factory, rank_builder) = Self::create_engine_factory_with_builder(options);
267
410
        Matcher::builder(engine_factory)
268
410
            .case(options.case)
269
410
            .rank_builder(rank_builder)
270
410
            .build()
271
410
    }
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.09k
    pub(crate) fn run(
295
1.09k
        &self,
296
1.09k
        query: &str,
297
1.09k
        item_pool: &Arc<ItemPool>,
298
1.09k
        thread_pool: &Arc<ThreadPool>,
299
1.09k
        processed_items: Arc<Mutex<Option<ProcessedItems>>>,
300
1.09k
        merge_strategy: MergeStrategy,
301
1.09k
        no_sort: bool,
302
1.09k
        tac: bool,
303
1.09k
        generation: usize,
304
1.09k
        current_generation: Arc<AtomicUsize>,
305
1.09k
        needs_render: Arc<AtomicBool>,
306
1.09k
    ) -> MatcherControl {
307
1.09k
        let matcher_engine = self.engine_factory.create_engine_with_case(query, self.case_matching);
308
1.09k
        debug!("engine: {matcher_engine}");
309
1.09k
        let stopped = Arc::new(AtomicBool::new(false));
310
1.09k
        let stopped_clone = stopped.clone();
311
1.09k
        let interrupt = Arc::new(AtomicBool::new(false));
312
1.09k
        let interrupt_clone = interrupt.clone();
313
1.09k
        let processed = Arc::new(AtomicUsize::new(0));
314
1.09k
        let processed_clone = processed.clone();
315
1.09k
        let matched = Arc::new(AtomicUsize::new(0));
316
1.09k
        let matched_clone = matched.clone();
317
1.09k
        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.09k
        let start = item_pool.num_taken();
324
1.09k
        let items = item_pool.take();
325
1.09k
        let total = items.len();
326
1.09k
        trace!("matcher start, total: {total}");
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.09k
        let num_workers = thread_pool.num_threads();
332
1.09k
        let pool_for_work = Arc::clone(thread_pool);
333
334
1.09k
        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.09k
            let shared_items: Arc<[Arc<dyn SkimItem>]> = items.into();
347
348
            // Clones for the process_chunk closure.
349
1.09k
            let matcher_engine: Arc<dyn crate::MatchEngine> = Arc::from(matcher_engine);
350
1.09k
            let interrupt_for_work = Arc::clone(&interrupt);
351
1.09k
            let processed_for_work = Arc::clone(&processed);
352
1.09k
            let matched_for_work = Arc::clone(&matched);
353
1.09k
            let rank_builder_for_work = Arc::clone(&rank_builder);
354
355
1.09k
            thread_pool::parallel_work_queue(
356
1.09k
                &pool_for_work,
357
1.09k
                num_workers,
358
1.09k
                &shared_items,
359
                CHUNK_SIZE,
360
1.09k
                no_sort,
361
                // identity – seed value for each worker's local accumulator
362
                Vec::<MatchedItem>::new,
363
                // process_chunk – called for each chunk; returns a Vec of matches
364
713
                move |chunk_start, chunk: &[Arc<dyn crate::SkimItem>]| {
365
                    // Check interrupt before processing this chunk.
366
713
                    if interrupt_for_work.load(Ordering::Relaxed) {
  Branch (366:24): [True: 0, False: 700]
+
  Branch (366:24): [True: 2, False: 11]
+
367
2
                        return Vec::new();
368
711
                    }
369
370
711
                    let mut local_matches = Vec::new();
371
711
                    let mut chunk_matched: usize = 0;
372
373
52.9k
                    for (i, item) in 
chunk711
.
iter711
().
enumerate711
() {
374
52.9k
                        if let Some(
match_result51.9k
) = matcher_engine.match_item(item.as_ref()) {
  Branch (374:32): [True: 51.9k, False: 968]
+
  Branch (374:32): [True: 20, 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
51.9k
                            // `take()` reverses each tac batch, so recover the
379
51.9k
                            // 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
51.9k
                        
}969
389
                    }
390
391
                    // Flush counters for this chunk so the UI sees progress.
392
711
                    processed_for_work.fetch_add(chunk.len(), Ordering::Relaxed);
393
711
                    if chunk_matched > 0 {
  Branch (393:24): [True: 606, False: 94]
+
  Branch (393:24): [True: 11, False: 0]
+
394
617
                        matched_for_work.fetch_add(chunk_matched, Ordering::Relaxed);
395
617
                    
}94
396
397
711
                    local_matches
398
713
                },
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: 588]
+
  Branch (402:24): [True: 2, False: 11]
+
403
96
                        acc.extend(partial);
404
599
                    } else {
405
599
                        partial.append(acc);
406
599
                        *acc = partial;
407
599
                    }
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.09k
                |worker_results: Vec<Vec<MatchedItem>>| {
423
1.09k
                    if interrupt.load(Ordering::SeqCst) {
  Branch (423:24): [True: 0, False: 1.06k]
+
  Branch (423:24): [True: 11, False: 21]
+
424
11
                        return;
425
1.08k
                    }
426
427
1.08k
                    merge_worker_results(
428
1.08k
                        worker_results,
429
1.08k
                        no_sort,
430
1.08k
                        &processed_items,
431
1.08k
                        merge_strategy,
432
1.08k
                        generation,
433
1.08k
                        &current_generation,
434
1.08k
                        &needs_render,
435
                    );
436
1.09k
                },
437
            );
438
1.09k
            stopped.store(true, Ordering::Relaxed);
439
1.09k
        });
440
441
1.09k
        MatcherControl {
442
1.09k
            stopped: stopped_clone,
443
1.09k
            interrupt: interrupt_clone,
444
1.09k
            matched: matched_clone,
445
1.09k
            processed: processed_clone,
446
1.09k
        }
447
1.09k
    }
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 2a424d66..cd7b06b2 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-04 10: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
482
pub(crate) fn parse_min_height(s: &str) -> Result<u16, String> {
29
482
    s.parse().map_err(|_| 
MIN_HEIGHT_PARSE_ERROR5
.
to_string5
())
30
482
}
31
32
#[cfg(feature = "cli")]
33
468
fn parse_min_height_value(s: &str) -> Result<String, String> {
34
468
    parse_min_height(s).map(|_| 
s466
.
to_string466
())
35
468
}
36
37
#[cfg(feature = "cli")]
38
/// Custom value parser for delimiter that handles escape sequences
39
467
fn parse_delimiter_value(s: &str) -> Result<Regex, String> {
40
467
    let unescaped = crate::util::unescape_delimiter(s);
41
467
    Regex::new(&unescaped).map_err(|e| 
format!0
("Invalid regex delimiter: {e}"))
42
467
}
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
465
    fn parse_ref(
60
465
        &self,
61
465
        _cmd: &clap::Command,
62
465
        _arg: Option<&clap::Arg>,
63
465
        value: &std::ffi::OsStr,
64
465
    ) -> Result<Self::Value, clap::Error> {
65
        // `FromStr for BorderType` is infallible: unknown values map to `BorderType::Plain`.
66
465
        Ok(value.to_string_lossy().parse().unwrap_or(BorderType::Plain))
67
465
    }
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
465
fn parse_typos(s: &str) -> Result<Typos, String> {
85
465
    if s.eq_ignore_ascii_case("smart") {
  Branch (85:8): [True: 3, False: 451]
+

Coverage Report

Created: 2026-09-05 18:50

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
497
pub(crate) fn parse_min_height(s: &str) -> Result<u16, String> {
29
497
    s.parse().map_err(|_| 
MIN_HEIGHT_PARSE_ERROR5
.
to_string5
())
30
497
}
31
32
#[cfg(feature = "cli")]
33
483
fn parse_min_height_value(s: &str) -> Result<String, String> {
34
483
    parse_min_height(s).map(|_| 
s481
.
to_string481
())
35
483
}
36
37
#[cfg(feature = "cli")]
38
/// Custom value parser for delimiter that handles escape sequences
39
482
fn parse_delimiter_value(s: &str) -> Result<Regex, String> {
40
482
    let unescaped = crate::util::unescape_delimiter(s);
41
482
    Regex::new(&unescaped).map_err(|e| 
format!0
("Invalid regex delimiter: {e}"))
42
482
}
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
480
    fn parse_ref(
60
480
        &self,
61
480
        _cmd: &clap::Command,
62
480
        _arg: Option<&clap::Arg>,
63
480
        value: &std::ffi::OsStr,
64
480
    ) -> Result<Self::Value, clap::Error> {
65
        // `FromStr for BorderType` is infallible: unknown values map to `BorderType::Plain`.
66
480
        Ok(value.to_string_lossy().parse().unwrap_or(BorderType::Plain))
67
480
    }
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
480
fn parse_typos(s: &str) -> Result<Typos, String> {
85
480
    if s.eq_ignore_ascii_case("smart") {
  Branch (85:8): [True: 3, False: 466]
 
  Branch (85:8): [True: 0, False: 11]
-
86
3
        Ok(Typos::Smart)
87
462
    } else if s.eq_ignore_ascii_case("disabled") {
  Branch (87:15): [True: 451, False: 0]
+
86
3
        Ok(Typos::Smart)
87
477
    } else if s.eq_ignore_ascii_case("disabled") {
  Branch (87:15): [True: 466, False: 0]
 
  Branch (87:15): [True: 11, False: 0]
-
88
462
        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
465
}
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
395
    fn default() -> Self {
1156
395
        Self {
1157
395
            split_match: None,
1158
395
            no_strip_ansi: false,
1159
395
            wrap_items: false,
1160
395
            multiline: None,
1161
395
            #[cfg(feature = "listen")]
1162
395
            listen: None,
1163
395
            #[cfg(feature = "listen")]
1164
395
            remote: None,
1165
395
            print_header: false,
1166
395
            print_current: false,
1167
395
            disabled: false,
1168
395
            disable_pattern: None,
1169
395
            tac: Default::default(),
1170
395
            min_query_length: Default::default(),
1171
395
            no_sort: Default::default(),
1172
395
            tiebreak: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End],
1173
395
            nth: Default::default(),
1174
395
            with_nth: Default::default(),
1175
395
            hide_nth: Default::default(),
1176
395
            delimiter: Regex::new(r"[\t\n ]+").unwrap(),
1177
395
            exact: Default::default(),
1178
395
            regex: Default::default(),
1179
395
            algorithm: Default::default(),
1180
395
            case: Default::default(),
1181
395
            typos: Typos::Disabled,
1182
395
            no_typos: false,
1183
395
            normalize: false,
1184
395
            last_match: false,
1185
395
            bind: Default::default(),
1186
395
            multi: Default::default(),
1187
395
            no_multi: Default::default(),
1188
395
            no_mouse: Default::default(),
1189
395
            cmd: Default::default(),
1190
395
            interactive: Default::default(),
1191
395
            replstr: String::from("{}"),
1192
395
            color: Default::default(),
1193
395
            no_hscroll: Default::default(),
1194
395
            keep_right: Default::default(),
1195
395
            skip_to_pattern: Default::default(),
1196
395
            no_clear_if_empty: Default::default(),
1197
395
            no_clear_start: Default::default(),
1198
395
            no_clear: Default::default(),
1199
395
            show_cmd_error: Default::default(),
1200
395
            layout: TuiLayout::default(),
1201
395
            reverse: Default::default(),
1202
395
            height: String::from("100%"),
1203
395
            no_height: Default::default(),
1204
395
            min_height: String::from("10"),
1205
395
            margin: Default::default(),
1206
395
            prompt: String::from("> "),
1207
395
            cmd_prompt: String::from("c> "),
1208
395
            selector_icon: String::from(">"),
1209
395
            multi_select_icon: String::from(">"),
1210
395
            ansi: Default::default(),
1211
395
            tabstop: 8,
1212
395
            info: Default::default(),
1213
395
            no_info: Default::default(),
1214
395
            inline_info: Default::default(),
1215
395
            header: Default::default(),
1216
395
            header_lines: Default::default(),
1217
395
            history_file: Default::default(),
1218
395
            history_size: 1000,
1219
395
            cmd_history_file: Default::default(),
1220
395
            cmd_history_size: 1000,
1221
395
            preview: Default::default(),
1222
395
            preview_window: PreviewLayout::default(),
1223
395
            #[cfg(feature = "image")]
1224
395
            image: None,
1225
395
            #[cfg(feature = "image")]
1226
395
            image_picker: None,
1227
395
            query: Default::default(),
1228
395
            cmd_query: Default::default(),
1229
395
            read0: Default::default(),
1230
395
            print0: Default::default(),
1231
395
            print_query: Default::default(),
1232
395
            print_cmd: Default::default(),
1233
395
            print_score: Default::default(),
1234
395
            output_format: Default::default(),
1235
395
            select_1: Default::default(),
1236
395
            exit_0: Default::default(),
1237
395
            sync: Default::default(),
1238
395
            pre_select_n: Default::default(),
1239
395
            pre_select_pat: Default::default(),
1240
395
            pre_select_items: Default::default(),
1241
395
            pre_select_file: Default::default(),
1242
395
            filter: Default::default(),
1243
395
            popup: Default::default(),
1244
395
            log_file: Default::default(),
1245
395
            extended: Default::default(),
1246
395
            literal: Default::default(),
1247
395
            cycle: Default::default(),
1248
395
            hscroll_off: 10,
1249
395
            filepath_word: Default::default(),
1250
395
            jump_labels: String::from("abcdefghijklmnopqrstuvwxyz"),
1251
395
            border: Default::default(),
1252
395
            border_no_collapse: Default::default(),
1253
395
            no_bold: Default::default(),
1254
395
            phony: Default::default(),
1255
395
            scheme: Default::default(),
1256
395
            tail: Default::default(),
1257
395
            style: Default::default(),
1258
395
            no_color: Default::default(),
1259
395
            padding: Default::default(),
1260
395
            border_label: Default::default(),
1261
395
            border_label_pos: Default::default(),
1262
395
            highlight_line: Default::default(),
1263
395
            wrap_sign: Default::default(),
1264
395
            no_multi_line: Default::default(),
1265
395
            raw: Default::default(),
1266
395
            track: Default::default(),
1267
395
            gap: Default::default(),
1268
395
            gap_line: Default::default(),
1269
395
            freeze_left: Default::default(),
1270
395
            freeze_right: Default::default(),
1271
395
            scroll_off: Default::default(),
1272
395
            gutter: Default::default(),
1273
395
            gutter_raw: Default::default(),
1274
395
            marker_multi_line: Default::default(),
1275
395
            ellipsis: Default::default(),
1276
395
            scrollbar: Default::default(),
1277
395
            no_scrollbar: Default::default(),
1278
395
            list_border: Default::default(),
1279
395
            list_label: Default::default(),
1280
395
            list_label_pos: Default::default(),
1281
395
            no_input: Default::default(),
1282
395
            info_command: Default::default(),
1283
395
            separator: Default::default(),
1284
395
            no_separator: Default::default(),
1285
395
            ghost: Default::default(),
1286
395
            input_border: Default::default(),
1287
395
            input_label: Default::default(),
1288
395
            input_label_pos: Default::default(),
1289
395
            preview_label: Default::default(),
1290
395
            preview_label_pos: Default::default(),
1291
395
            header_first: Default::default(),
1292
395
            header_border: Default::default(),
1293
395
            header_lines_border: Default::default(),
1294
395
            footer: Default::default(),
1295
395
            footer_border: Default::default(),
1296
395
            footer_label: Default::default(),
1297
395
            footer_label_pos: Default::default(),
1298
395
            with_shell: Default::default(),
1299
395
            expect: Default::default(),
1300
395
            cmd_collector: Rc::new(RefCell::new(SkimItemReader::default())) as Rc<RefCell<dyn CommandCollector>>,
1301
395
            query_history: Default::default(),
1302
395
            cmd_history: Default::default(),
1303
395
            selector: Default::default(),
1304
395
            preview_fn: Default::default(),
1305
395
            keymap: Default::default(),
1306
395
            action_binds: Default::default(),
1307
395
            #[cfg(feature = "cli")]
1308
395
            shell: Default::default(),
1309
395
            #[cfg(feature = "cli")]
1310
395
            man: false,
1311
395
            #[cfg(feature = "cli")]
1312
395
            shell_bindings: false,
1313
395
            flags: Default::default(),
1314
395
            log_level: Default::default(),
1315
395
            no_border: false,
1316
395
        }
1317
395
    }
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
66
    pub fn build(&mut self) -> Result<SkimOptions, SkimOptionsBuilderError> {
1327
66
        self.final_build().map(SkimOptions::build)
1328
66
    }
1329
}
1330
1331
impl SkimOptions {
1332
    /// Finalizes the options by applying defaults and initializing components
1333
    #[must_use]
1334
551
    pub fn build(mut self) -> Self {
1335
551
        if self.no_height {
  Branch (1335:12): [True: 0, False: 455]
-
  Branch (1335:12): [True: 1, False: 95]
-
1336
1
            self.height = String::from("100%");
1337
550
        }
1338
1339
40
        if let Some(None) = self.multiline {
  Branch (1339:16): [True: 34, False: 421]
-
  Branch (1339:16): [True: 2, False: 94]
-
1340
36
            if self.read0 {
  Branch (1340:16): [True: 2, False: 32]
+
88
477
        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
480
}
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
400
    fn default() -> Self {
1156
400
        Self {
1157
400
            split_match: None,
1158
400
            no_strip_ansi: false,
1159
400
            wrap_items: false,
1160
400
            multiline: None,
1161
400
            #[cfg(feature = "listen")]
1162
400
            listen: None,
1163
400
            #[cfg(feature = "listen")]
1164
400
            remote: None,
1165
400
            print_header: false,
1166
400
            print_current: false,
1167
400
            disabled: false,
1168
400
            disable_pattern: None,
1169
400
            tac: Default::default(),
1170
400
            min_query_length: Default::default(),
1171
400
            no_sort: Default::default(),
1172
400
            tiebreak: vec![RankCriteria::Score, RankCriteria::Begin, RankCriteria::End],
1173
400
            nth: Default::default(),
1174
400
            with_nth: Default::default(),
1175
400
            hide_nth: Default::default(),
1176
400
            delimiter: Regex::new(r"[\t\n ]+").unwrap(),
1177
400
            exact: Default::default(),
1178
400
            regex: Default::default(),
1179
400
            algorithm: Default::default(),
1180
400
            case: Default::default(),
1181
400
            typos: Typos::Disabled,
1182
400
            no_typos: false,
1183
400
            normalize: false,
1184
400
            last_match: false,
1185
400
            bind: Default::default(),
1186
400
            multi: Default::default(),
1187
400
            no_multi: Default::default(),
1188
400
            no_mouse: Default::default(),
1189
400
            cmd: Default::default(),
1190
400
            interactive: Default::default(),
1191
400
            replstr: String::from("{}"),
1192
400
            color: Default::default(),
1193
400
            no_hscroll: Default::default(),
1194
400
            keep_right: Default::default(),
1195
400
            skip_to_pattern: Default::default(),
1196
400
            no_clear_if_empty: Default::default(),
1197
400
            no_clear_start: Default::default(),
1198
400
            no_clear: Default::default(),
1199
400
            show_cmd_error: Default::default(),
1200
400
            layout: TuiLayout::default(),
1201
400
            reverse: Default::default(),
1202
400
            height: String::from("100%"),
1203
400
            no_height: Default::default(),
1204
400
            min_height: String::from("10"),
1205
400
            margin: Default::default(),
1206
400
            prompt: String::from("> "),
1207
400
            cmd_prompt: String::from("c> "),
1208
400
            selector_icon: String::from(">"),
1209
400
            multi_select_icon: String::from(">"),
1210
400
            ansi: Default::default(),
1211
400
            tabstop: 8,
1212
400
            info: Default::default(),
1213
400
            no_info: Default::default(),
1214
400
            inline_info: Default::default(),
1215
400
            header: Default::default(),
1216
400
            header_lines: Default::default(),
1217
400
            history_file: Default::default(),
1218
400
            history_size: 1000,
1219
400
            cmd_history_file: Default::default(),
1220
400
            cmd_history_size: 1000,
1221
400
            preview: Default::default(),
1222
400
            preview_window: PreviewLayout::default(),
1223
400
            #[cfg(feature = "image")]
1224
400
            image: None,
1225
400
            #[cfg(feature = "image")]
1226
400
            image_picker: None,
1227
400
            query: Default::default(),
1228
400
            cmd_query: Default::default(),
1229
400
            read0: Default::default(),
1230
400
            print0: Default::default(),
1231
400
            print_query: Default::default(),
1232
400
            print_cmd: Default::default(),
1233
400
            print_score: Default::default(),
1234
400
            output_format: Default::default(),
1235
400
            select_1: Default::default(),
1236
400
            exit_0: Default::default(),
1237
400
            sync: Default::default(),
1238
400
            pre_select_n: Default::default(),
1239
400
            pre_select_pat: Default::default(),
1240
400
            pre_select_items: Default::default(),
1241
400
            pre_select_file: Default::default(),
1242
400
            filter: Default::default(),
1243
400
            popup: Default::default(),
1244
400
            log_file: Default::default(),
1245
400
            extended: Default::default(),
1246
400
            literal: Default::default(),
1247
400
            cycle: Default::default(),
1248
400
            hscroll_off: 10,
1249
400
            filepath_word: Default::default(),
1250
400
            jump_labels: String::from("abcdefghijklmnopqrstuvwxyz"),
1251
400
            border: Default::default(),
1252
400
            border_no_collapse: Default::default(),
1253
400
            no_bold: Default::default(),
1254
400
            phony: Default::default(),
1255
400
            scheme: Default::default(),
1256
400
            tail: Default::default(),
1257
400
            style: Default::default(),
1258
400
            no_color: Default::default(),
1259
400
            padding: Default::default(),
1260
400
            border_label: Default::default(),
1261
400
            border_label_pos: Default::default(),
1262
400
            highlight_line: Default::default(),
1263
400
            wrap_sign: Default::default(),
1264
400
            no_multi_line: Default::default(),
1265
400
            raw: Default::default(),
1266
400
            track: Default::default(),
1267
400
            gap: Default::default(),
1268
400
            gap_line: Default::default(),
1269
400
            freeze_left: Default::default(),
1270
400
            freeze_right: Default::default(),
1271
400
            scroll_off: Default::default(),
1272
400
            gutter: Default::default(),
1273
400
            gutter_raw: Default::default(),
1274
400
            marker_multi_line: Default::default(),
1275
400
            ellipsis: Default::default(),
1276
400
            scrollbar: Default::default(),
1277
400
            no_scrollbar: Default::default(),
1278
400
            list_border: Default::default(),
1279
400
            list_label: Default::default(),
1280
400
            list_label_pos: Default::default(),
1281
400
            no_input: Default::default(),
1282
400
            info_command: Default::default(),
1283
400
            separator: Default::default(),
1284
400
            no_separator: Default::default(),
1285
400
            ghost: Default::default(),
1286
400
            input_border: Default::default(),
1287
400
            input_label: Default::default(),
1288
400
            input_label_pos: Default::default(),
1289
400
            preview_label: Default::default(),
1290
400
            preview_label_pos: Default::default(),
1291
400
            header_first: Default::default(),
1292
400
            header_border: Default::default(),
1293
400
            header_lines_border: Default::default(),
1294
400
            footer: Default::default(),
1295
400
            footer_border: Default::default(),
1296
400
            footer_label: Default::default(),
1297
400
            footer_label_pos: Default::default(),
1298
400
            with_shell: Default::default(),
1299
400
            expect: Default::default(),
1300
400
            cmd_collector: Rc::new(RefCell::new(SkimItemReader::default())) as Rc<RefCell<dyn CommandCollector>>,
1301
400
            query_history: Default::default(),
1302
400
            cmd_history: Default::default(),
1303
400
            selector: Default::default(),
1304
400
            preview_fn: Default::default(),
1305
400
            keymap: Default::default(),
1306
400
            action_binds: Default::default(),
1307
400
            #[cfg(feature = "cli")]
1308
400
            shell: Default::default(),
1309
400
            #[cfg(feature = "cli")]
1310
400
            man: false,
1311
400
            #[cfg(feature = "cli")]
1312
400
            shell_bindings: false,
1313
400
            flags: Default::default(),
1314
400
            log_level: Default::default(),
1315
400
            no_border: false,
1316
400
        }
1317
400
    }
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
567
    pub fn build(mut self) -> Self {
1335
567
        if self.no_height {
  Branch (1335:12): [True: 0, False: 470]
+
  Branch (1335:12): [True: 1, False: 96]
+
1336
1
            self.height = String::from("100%");
1337
566
        }
1338
1339
43
        if let Some(None) = self.multiline {
  Branch (1339:16): [True: 38, False: 432]
+
  Branch (1339:16): [True: 2, False: 95]
+
1340
40
            if self.read0 {
  Branch (1340:16): [True: 2, False: 36]
 
  Branch (1340:16): [True: 1, False: 1]
-
1341
3
                self.multiline = Some(Some(String::from("\n")));
1342
33
            } else {
1343
33
                self.multiline = Some(Some(String::from("\\n")));
1344
33
            }
1345
515
        }
1346
1347
551
        self.keymap = self.bind.iter().fold(KeyMap::default(), |mut res, part| 
{459
1348
459
            res.add_keymaps_str(part);
1349
459
            res
1350
459
        });
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
551
        self.action_binds = self
1355
551
            .bind
1356
551
            .iter()
1357
551
            .flat_map(|part| 
crate::binds::parse_action_binds459
(
crate::binds::split_top_level459
(
part459
, ',').
into_iter459
()))
1358
551
            .collect();
1359
1360
551
        if self.reverse {
  Branch (1360:12): [True: 6, False: 449]
-
  Branch (1360:12): [True: 1, False: 95]
-
1361
7
            self.layout = TuiLayout::Reverse;
1362
544
        }
1363
551
        if self.history_file.is_some() || 
self.cmd_history_file549
.
is_some549
() {
  Branch (1363:12): [True: 1, False: 454]
-  Branch (1363:43): [True: 0, False: 454]
-
  Branch (1363:12): [True: 1, False: 95]
-  Branch (1363:43): [True: 0, False: 95]
-
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
549
        }
1374
551
        if self.no_scrollbar {
  Branch (1374:12): [True: 1, False: 454]
-
  Branch (1374:12): [True: 1, False: 95]
-
1375
2
            self.scrollbar = String::new();
1376
549
        }
1377
551
        if self.inline_info {
  Branch (1377:12): [True: 6, False: 449]
-
  Branch (1377:12): [True: 4, False: 92]
-
1378
10
            self.info = Info {
1379
10
                display: InfoDisplay::Inline,
1380
10
                separator: Some(String::from(crate::tui::statusline::DEFAULT_SEPARATOR)),
1381
10
            };
1382
541
        }
1383
551
        if self.no_info {
  Branch (1383:12): [True: 1, False: 454]
-
  Branch (1383:12): [True: 2, False: 94]
-
1384
3
            self.info = Info {
1385
3
                display: InfoDisplay::Hidden,
1386
3
                separator: None,
1387
3
            };
1388
548
        }
1389
551
        if self.no_typos {
  Branch (1389:12): [True: 3, False: 452]
-
  Branch (1389:12): [True: 1, False: 95]
-
1390
4
            self.typos = Typos::Disabled;
1391
547
        }
1392
551
        if self.no_border {
  Branch (1392:12): [True: 0, False: 455]
-
  Branch (1392:12): [True: 2, False: 94]
-
1393
2
            self.border = BorderType::ForceOff;
1394
549
        }
1395
1396
551
        if let Some(
ref filter_query20
) = self.filter
  Branch (1396:16): [True: 15, False: 440]
-
  Branch (1396:16): [True: 5, False: 91]
+
1341
3
                self.multiline = Some(Some(String::from("\n")));
1342
37
            } else {
1343
37
                self.multiline = Some(Some(String::from("\\n")));
1344
37
            }
1345
527
        }
1346
1347
567
        self.keymap = self.bind.iter().fold(KeyMap::default(), |mut res, part| 
{474
1348
474
            res.add_keymaps_str(part);
1349
474
            res
1350
474
        });
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
567
        self.action_binds = self
1355
567
            .bind
1356
567
            .iter()
1357
567
            .flat_map(|part| 
crate::binds::parse_action_binds474
(
crate::binds::split_top_level474
(
part474
, ',').
into_iter474
()))
1358
567
            .collect();
1359
1360
567
        if self.reverse {
  Branch (1360:12): [True: 6, False: 464]
+
  Branch (1360:12): [True: 1, False: 96]
+
1361
7
            self.layout = TuiLayout::Reverse;
1362
560
        }
1363
567
        if self.history_file.is_some() || 
self.cmd_history_file565
.
is_some565
() {
  Branch (1363:12): [True: 1, False: 469]
+  Branch (1363:43): [True: 0, False: 469]
+
  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
565
        }
1374
567
        if self.no_scrollbar {
  Branch (1374:12): [True: 1, False: 469]
+
  Branch (1374:12): [True: 1, False: 96]
+
1375
2
            self.scrollbar = String::new();
1376
565
        }
1377
567
        if self.inline_info {
  Branch (1377:12): [True: 7, False: 463]
+
  Branch (1377:12): [True: 4, False: 93]
+
1378
11
            self.info = Info {
1379
11
                display: InfoDisplay::Inline,
1380
11
                separator: Some(String::from(crate::tui::statusline::DEFAULT_SEPARATOR)),
1381
11
            };
1382
556
        }
1383
567
        if self.no_info {
  Branch (1383:12): [True: 1, False: 469]
+
  Branch (1383:12): [True: 2, False: 95]
+
1384
3
            self.info = Info {
1385
3
                display: InfoDisplay::Hidden,
1386
3
                separator: None,
1387
3
            };
1388
564
        }
1389
567
        if self.no_typos {
  Branch (1389:12): [True: 3, False: 467]
+
  Branch (1389:12): [True: 1, False: 96]
+
1390
4
            self.typos = Typos::Disabled;
1391
563
        }
1392
567
        if self.no_border {
  Branch (1392:12): [True: 0, False: 470]
+
  Branch (1392:12): [True: 2, False: 95]
+
1393
2
            self.border = BorderType::ForceOff;
1394
565
        }
1395
1396
567
        if let Some(
ref filter_query20
) = self.filter
  Branch (1396:16): [True: 15, False: 455]
+
  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
19
        {
1399
19
            self.query = Some(filter_query.clone());
1400
532
        }
1401
1402
456
        match self.scheme {
1403
547
            None | Some(MatchScheme::Default) => (),
1404
3
            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
3
            }
1409
1
            Some(MatchScheme::History) => self.tiebreak.insert(0, RankCriteria::Index),
1410
        }
1411
1412
551
        self
1413
551
    }
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
19
        {
1399
19
            self.query = Some(filter_query.clone());
1400
548
        }
1401
1402
471
        match self.scheme {
1403
563
            None | Some(MatchScheme::Default) => (),
1404
3
            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
3
            }
1409
1
            Some(MatchScheme::History) => self.tiebreak.insert(0, RankCriteria::Index),
1410
        }
1411
1412
567
        self
1413
567
    }
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
3
        
}0
1419
1420
3
        if let Some(
cmd_histfile1
) = &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 7df131e4..36f64f9d 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,30 +1,30 @@
-

Coverage Report

Created: 2026-09-04 10: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_format7
) = opts.output_format {
  Branch (52:16): [Folded - Ignored]
-
  Branch (52:16): [True: 6, False: 23]
+

Coverage Report

Created: 2026-09-05 18:50

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_format7
) = opts.output_format {
  Branch (52:16): [Folded - Ignored]
 
  Branch (52:16): [True: 1, False: 11]
+
  Branch (52:16): [True: 6, False: 23]
 
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
                    false
65
                ),
66
                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]
 
  Branch (71:12): [True: 1, False: 10]
+
  Branch (71:12): [True: 2, False: 21]
 
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]
 
  Branch (75:12): [True: 1, False: 10]
+
  Branch (75: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): [Folded - Ignored]
-
  Branch (79:12): [True: 1, False: 22]
 
  Branch (79:12): [True: 1, False: 10]
+
  Branch (79: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): [Folded - Ignored]
-
  Branch (83:12): [True: 1, False: 22]
 
  Branch (83:12): [True: 2, False: 9]
+
  Branch (83:12): [True: 1, False: 22]
 
84
3
            if let Some(
ref current2
) = self.current {
  Branch (84:20): [Folded - Ignored]
-
  Branch (84:20): [True: 1, False: 0]
 
  Branch (84:20): [True: 1, False: 1]
+
  Branch (84:20): [True: 1, False: 0]
 
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
33
        if let Event::Action(Action::Accept(Some(
accept_key2
))) = &self.final_event {
  Branch (91:16): [Folded - Ignored]
-
  Branch (91:16): [True: 1, False: 22]
 
  Branch (91:16): [True: 1, False: 10]
+
  Branch (91:16): [True: 1, False: 22]
 
92
2
            write!(out, "{}{}", accept_key, opts.output_ending)
?0
;
93
32
        }
94
95
50.0k
        for item in 
&self.selected_items34
{
96
50.0k
            if opts.strip_ansi {
  Branch (96:16): [Folded - Ignored]
-
  Branch (96:16): [True: 0, False: 50.0k]
 
  Branch (96:16): [True: 2, False: 13]
+
  Branch (96:16): [True: 0, False: 50.0k]
 
97
2
                write!(
98
2
                    out,
99
                    "{}{}",
100
2
                    crate::helper::item::strip_ansi(&item.output()).0,
101
                    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]
 
  Branch (106:16): [True: 2, False: 13]
+
  Branch (106:16): [True: 1, False: 50.0k]
 
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]
 
146
52
            strip_ansi: opts.ansi && 
!opts.no_strip_ansi3
,
  Branch (146:25): [True: 0, False: 36]
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 47bbfd31..44cbfac4 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-04 10: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
37
            "top" => 
Top4
,
43
33
            "bottom" => 
Bottom3
,
44
30
            "left" => 
Left3
,
45
27
            "right" => 
Right3
,
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_available6
() ||
zellij::is_available1
())
  Branch (73:5): [True: 5, False: 0]
+

Coverage Report

Created: 2026-09-05 18:50

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
37
            "top" => 
Top4
,
43
33
            "bottom" => 
Bottom3
,
44
30
            "left" => 
Left3
,
45
27
            "right" => 
Right3
,
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_available6
() ||
zellij::is_available1
())
  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 a1113b3c..3aba746a 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-04 10: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")10
.
is_ok10
()
  Branch (7:19): [True: 10, False: 0]
+

Coverage Report

Created: 2026-09-05 18:50

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")10
.
is_ok10
()
  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
.
into0
()));
21
19
        cmd.arg("display-popup").arg("-E").args([
22
            "-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
19
        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((
lhs2
,
rhs2
)) = 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 2e55b32e..2050d0eb 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-04 10: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")0
.
is_ok0
()
  Branch (9:5): [True: 0, False: 5]
+

Coverage Report

Created: 2026-09-05 18:50

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")0
.
is_ok0
()
  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
2
        Size::Fixed(cells) => Size::Fixed(
21
2
            std::env::var(var)
22
2
                .map_or(80u16, |s| 
s.parse()1
.
unwrap_or1
(80))
23
2
                .saturating_sub(cells)
24
                / 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
1
        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
.
into0
()));
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
10
            .args([
55
                "--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 8e7088d3..5951a3e0 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,13 +1,11 @@
-

Coverage Report

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

Coverage Report

Created: 2026-09-05 18:50

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
537
    pub fn kill(&mut self) {
51
537
        debug!(
52
            "kill reader, components before: {}",
53
2
            self.components_to_stop.load(Ordering::SeqCst)
54
        );
55
56
537
        let _ = self.tx_interrupt_cmd.clone().map(|tx| 
tx114
.
send114
(1));
57
537
        let _ = self.tx_interrupt.send(1);
58
537
        if let Some(
handle455
) = self.collector_handle.take() {
  Branch (58:16): [True: 432, False: 75]
+
  Branch (58:16): [True: 23, False: 7]
+
59
455
            let _ = handle.join();
60
455
        
}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
537
        while self.components_to_stop.load(Ordering::Acquire) != 0 {
  Branch (63:15): [True: 0, False: 507]
+
  Branch (63:15): [True: 0, False: 30]
+
64
0
            std::thread::sleep(Duration::from_millis(1));
65
0
        }
66
537
    }
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: 236]
+
  Branch (81:9): [True: 21, False: 13]
+
82
10.7k
    }
83
}
84
85
impl Drop for ReaderControl {
86
455
    fn drop(&mut self) {
87
455
        self.kill();
88
455
    }
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
405
    pub fn from_options(options: &SkimOptions) -> Self {
101
405
        Self {
102
405
            cmd_collector: options.cmd_collector.clone(),
103
405
            rx_item: None,
104
405
        }
105
405
    }
106
107
    /// Sets the item source (if None, will use command collector)
108
    #[must_use]
109
410
    pub fn source(mut self, rx_item: Option<SkimItemReceiver>) -> Self {
110
410
        self.rx_item = rx_item;
111
410
        self
112
410
    }
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
405
    pub fn set_thread_pool(&mut self, pool: Arc<ThreadPool>) {
118
405
        self.cmd_collector.borrow_mut().set_thread_pool(pool);
119
405
    }
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
2
        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
453
    pub fn collect(&mut self, item_pool: Arc<ItemPool>, cmd: &str) -> ReaderControl {
151
453
        let components_to_stop: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
152
453
        let items = Arc::new(SpinLock::new(Vec::new()));
153
154
453
        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
383
            |rx| (rx, None),
161
        );
162
163
453
        let components_to_stop_clone = components_to_stop.clone();
164
453
        let (tx_interrupt, collector_handle) = collect_items(components_to_stop_clone, rx_item, move |items| 
{428
165
428
            item_pool.append(items);
166
428
        });
167
453
        debug!("collect: started ({components_to_stop:?} components)");
168
169
453
        ReaderControl {
170
453
            tx_interrupt,
171
453
            tx_interrupt_cmd,
172
453
            components_to_stop,
173
453
            collector_handle: Some(collector_handle),
174
453
            items,
175
453
        }
176
453
    }
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
455
fn collect_items<F>(
189
455
    components_to_stop: Arc<AtomicUsize>,
190
455
    rx_item: SkimItemReceiver,
191
455
    callback: F,
192
455
) -> (Sender<i32>, JoinHandle<()>)
193
455
where
194
455
    F: Fn(Vec<Arc<dyn SkimItem>>) + Send + 'static,
195
{
196
455
    let (tx_interrupt, rx_interrupt) = crate::prelude::bounded(8);
197
198
455
    components_to_stop.fetch_add(1, Ordering::AcqRel);
199
455
    let handle = std::thread::spawn(move || {
200
455
        debug!("collect_item start");
201
202
        loop {
203
940
            if let Ok(Some(
msg1
)) = rx_interrupt.try_recv() {
  Branch (203:20): [True: 0, False: 0]
+  Branch (203:20): [True: 0, False: 892]
+
  Branch (203:20): [True: 0, False: 4]
+  Branch (203:20): [True: 1, False: 43]
+
204
1
                debug!("interrupt: {msg}");
205
1
                break;
206
939
            }
207
939
            match rx_item.recv_timeout(std::time::Duration::from_millis(1)) {
208
430
                Ok(items) => {
209
430
                    trace!("collect_item: got {} items", 
items2
.
len2
());
210
430
                    callback(items);
211
                }
212
55
                Err(kanal::ReceiveErrorTimeout::Timeout) => {
213
55
                    // No items within the timeout — loop back to check the
214
55
                    // interrupt channel before blocking again.
215
55
                }
216
                Err(kanal::ReceiveErrorTimeout::Closed | kanal::ReceiveErrorTimeout::SendClosed) => {
217
454
                    break;
218
                }
219
            }
220
        }
221
222
455
        components_to_stop.fetch_sub(1, Ordering::AcqRel);
223
455
        debug!("collect_item stop");
224
455
    });
225
226
455
    (tx_interrupt, handle)
227
455
}
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 9b3f95e9..38e0e0ca 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-04 10: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-05 18:50

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: 1, False: 5]
+
  Branch (31:8): [True: 0, False: 2]
 
32
1
        clap_complete::generate(clap_complete_nushell::Nushell, cmd, bin_name, output);
33
1
    } 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]
 
  Branch (57:8): [True: 3, False: 3]
+
  Branch (57:8): [True: 1, False: 0]
 
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 d081ad91..57fb6ee6 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-04 10: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");
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-05 18:50

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");
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(
handle0
) = Handle::try_current() {
  Branch (86:20): [True: 0, False: 10]
 
  Branch (86:20): [Folded - Ignored]
@@ -6,140 +6,147 @@
 
  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
390
    pub fn init(options: SkimOptions, source: Option<SkimItemReceiver>) -> Result<Self> {
160
390
        let height = Size::try_from(options.height.as_str())
?0
;
161
162
        // application state
163
        // Initialize theme from options
164
390
        let theme = Arc::new(crate::theme::ColorTheme::init_from_options(&options));
165
390
        let mut reader = Reader::from_options(&options).source(source);
166
390
        let cmd = options.cmd.clone().unwrap_or_default();
167
168
390
        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
390
        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
390
        let initial_cmd = if app.options.interactive && 
app.options.cmd31
.
is_some31
() {
  Branch (178:30): [True: 0, False: 32]
-  Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 0, False: 34]
-  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: 12]
-  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: 9]
-  Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 0, False: 7]
-  Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 0, False: 34]
+
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
405
    pub fn init(options: SkimOptions, source: Option<SkimItemReceiver>) -> Result<Self> {
160
405
        let height = Size::try_from(options.height.as_str())
?0
;
161
162
        // application state
163
        // Initialize theme from options
164
405
        let theme = Arc::new(crate::theme::ColorTheme::init_from_options(&options));
165
405
        let mut reader = Reader::from_options(&options).source(source);
166
405
        let cmd = options.cmd.clone().unwrap_or_default();
167
168
405
        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
405
        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
405
        let initial_cmd = if app.options.interactive && 
app.options.cmd32
.
is_some32
() {
  Branch (178:30): [True: 0, False: 32]
   Branch (178:57): [True: 0, False: 0]
 
  Branch (178:30): [True: 0, False: 1]
   Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 0, False: 10]
+
  Branch (178:30): [True: 0, False: 23]
+  Branch (178:57): [True: 0, False: 0]
+
  Branch (178:30): [True: 6, False: 122]
+  Branch (178:57): [True: 6, False: 0]
+
  Branch (178:30): [True: 0, False: 36]
   Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 1, False: 4]
-  Branch (178:57): [True: 1, False: 0]
 
  Branch (178:30): [True: 22, False: 0]
   Branch (178:57): [True: 17, False: 5]
-
  Branch (178:30): [True: 1, False: 15]
-  Branch (178:57): [True: 0, False: 1]
-
  Branch (178:30): [True: 0, False: 11]
-  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: 5, False: 116]
-  Branch (178:57): [True: 5, False: 0]
 
  Branch (178:30): [True: 0, False: 9]
   Branch (178:57): [True: 0, False: 0]
-
  Branch (178:30): [True: 0, False: 22]
+
  Branch (178:30): [True: 0, False: 9]
+  Branch (178:57): [True: 0, False: 0]
+
  Branch (178:30): [True: 0, False: 13]
+  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: 2]
+  Branch (178:57): [True: 0, False: 0]
+
  Branch (178:30): [True: 0, False: 36]
+  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: 1, False: 15]
+  Branch (178:57): [True: 0, False: 1]
+
  Branch (178:30): [True: 0, False: 5]
   Branch (178:57): [True: 0, False: 0]
 
  Branch (178:30): [True: 0, False: 12]
   Branch (178:57): [True: 0, False: 0]
-
179
25
            let expanded = app.expand_cmd(&cmd, true);
180
25
            log::debug!("Interactive mode: initial_cmd = {expanded:?} (from template {cmd:?})");
181
25
            expanded
182
        } else {
183
365
            cmd.clone()
184
        };
185
390
        Ok(Self {
186
390
            app,
187
390
            height,
188
390
            reader,
189
390
            reader_done: false,
190
390
            initial_cmd,
191
390
            tui: None,
192
390
            reader_control: None,
193
390
            matcher_interval: None,
194
390
            #[cfg(feature = "listen")]
195
390
            listener: None,
196
390
            final_event: Event::Quit,
197
390
            final_key: KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
198
390
            start_fired: false,
199
390
        })
200
390
    }
201
202
    /// Start the reader and matcher, but do not enter the TUI yet
203
390
    pub fn start(&mut self) {
204
390
        debug!("Starting reader with initial_cmd: {:?}", self.initial_cmd);
205
390
        self.reader_control = Some(self.reader.collect(self.app.item_pool.clone(), &self.initial_cmd));
206
390
        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
390
        self.fire_start_event();
211
390
    }
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
547
    fn fire_start_event(&mut self) {
218
547
        if self.start_fired {
  Branch (218:12): [True: 141, False: 42]
-
  Branch (218:12): [True: 0, False: 34]
-
  Branch (218:12): [True: 0, False: 26]
-
  Branch (218:12): [True: 0, False: 12]
-
  Branch (218:12): [True: 0, False: 2]
-
  Branch (218:12): [True: 0, False: 9]
-
  Branch (218:12): [True: 0, False: 7]
-
  Branch (218:12): [True: 0, False: 34]
+
  Branch (178:30): [True: 0, False: 11]
+  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: 9]
+  Branch (178:57): [True: 0, False: 0]
+
179
26
            let expanded = app.expand_cmd(&cmd, true);
180
26
            log::debug!("Interactive mode: initial_cmd = {expanded:?} (from template {cmd:?})");
181
26
            expanded
182
        } else {
183
379
            cmd.clone()
184
        };
185
405
        Ok(Self {
186
405
            app,
187
405
            height,
188
405
            reader,
189
405
            reader_done: false,
190
405
            initial_cmd,
191
405
            tui: None,
192
405
            reader_control: None,
193
405
            matcher_interval: None,
194
405
            #[cfg(feature = "listen")]
195
405
            listener: None,
196
405
            final_event: Event::Quit,
197
405
            final_key: KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
198
405
            start_fired: false,
199
405
        })
200
405
    }
201
202
    /// Start the reader and matcher, but do not enter the TUI yet
203
405
    pub fn start(&mut self) {
204
405
        debug!("Starting reader with initial_cmd: {:?}", self.initial_cmd);
205
405
        self.reader_control = Some(self.reader.collect(self.app.item_pool.clone(), &self.initial_cmd));
206
405
        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
405
        self.fire_start_event();
211
405
    }
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
577
    fn fire_start_event(&mut self) {
218
577
        if self.start_fired {
  Branch (218:12): [True: 157, False: 42]
 
  Branch (218:12): [True: 0, False: 1]
-
  Branch (218:12): [True: 0, False: 10]
-
  Branch (218:12): [True: 0, False: 5]
+
  Branch (218:12): [True: 0, False: 23]
+
  Branch (218:12): [True: 0, False: 128]
+
  Branch (218:12): [True: 0, False: 36]
 
  Branch (218:12): [True: 0, False: 22]
-
  Branch (218:12): [True: 6, False: 16]
-
  Branch (218:12): [True: 0, False: 11]
-
  Branch (218:12): [True: 0, False: 5]
-
  Branch (218:12): [True: 0, False: 121]
 
  Branch (218:12): [True: 0, False: 9]
-
  Branch (218:12): [True: 0, False: 22]
+
  Branch (218:12): [True: 0, False: 9]
+
  Branch (218:12): [True: 0, False: 13]
+
  Branch (218:12): [True: 0, False: 11]
+
  Branch (218:12): [True: 0, False: 2]
+
  Branch (218:12): [True: 0, False: 36]
+
  Branch (218:12): [True: 0, False: 26]
+
  Branch (218:12): [True: 5, False: 16]
+
  Branch (218:12): [True: 0, False: 5]
 
  Branch (218:12): [True: 0, False: 12]
-
219
147
            return;
220
400
        }
221
400
        if let Some(
tui368
) = self.tui.as_ref() {
  Branch (221:16): [True: 10, False: 32]
-
  Branch (221:16): [True: 34, False: 0]
-
  Branch (221:16): [True: 26, False: 0]
-
  Branch (221:16): [True: 12, False: 0]
-
  Branch (221:16): [True: 2, False: 0]
-
  Branch (221:16): [True: 9, False: 0]
-
  Branch (221:16): [True: 7, False: 0]
-
  Branch (221:16): [True: 34, False: 0]
+
  Branch (218:12): [True: 0, False: 11]
+
  Branch (218:12): [True: 0, False: 4]
+
  Branch (218:12): [True: 0, False: 9]
+
219
162
            return;
220
415
        }
221
415
        if let Some(
tui383
) = self.tui.as_ref() {
  Branch (221:16): [True: 10, False: 32]
 
  Branch (221:16): [True: 1, False: 0]
-
  Branch (221:16): [True: 10, False: 0]
-
  Branch (221:16): [True: 5, False: 0]
+
  Branch (221:16): [True: 23, False: 0]
+
  Branch (221:16): [True: 128, False: 0]
+
  Branch (221:16): [True: 36, False: 0]
 
  Branch (221:16): [True: 22, False: 0]
-
  Branch (221:16): [True: 16, False: 0]
-
  Branch (221:16): [True: 11, False: 0]
-
  Branch (221:16): [True: 5, False: 0]
-
  Branch (221:16): [True: 121, False: 0]
 
  Branch (221:16): [True: 9, False: 0]
-
  Branch (221:16): [True: 22, False: 0]
+
  Branch (221:16): [True: 9, False: 0]
+
  Branch (221:16): [True: 13, False: 0]
+
  Branch (221:16): [True: 11, False: 0]
+
  Branch (221:16): [True: 2, False: 0]
+
  Branch (221:16): [True: 36, False: 0]
+
  Branch (221:16): [True: 26, False: 0]
+
  Branch (221:16): [True: 16, False: 0]
+
  Branch (221:16): [True: 5, False: 0]
 
  Branch (221:16): [True: 12, False: 0]
-
222
368
            if self.app.options.sync && 
tui.event_tx2
.try_send(Event::Render).
is_err2
() {
  Branch (222:16): [True: 0, False: 10]
-  Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 34]
-  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: 12]
-  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: 9]
-  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: 34]
+
  Branch (221:16): [True: 11, False: 0]
+
  Branch (221:16): [True: 4, False: 0]
+
  Branch (221:16): [True: 9, False: 0]
+
222
383
            if self.app.options.sync && 
tui.event_tx2
.try_send(Event::Render).
is_err2
() {
  Branch (222:16): [True: 0, False: 10]
   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: 10]
+
  Branch (222:16): [True: 0, False: 23]
   Branch (222:41): [True: 0, False: 0]
-
  Branch (222:16): [True: 0, False: 5]
+
  Branch (222:16): [True: 0, False: 128]
+  Branch (222:41): [True: 0, False: 0]
+
  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: 1, False: 15]
-  Branch (222:41): [True: 0, False: 1]
-
  Branch (222:16): [True: 0, False: 11]
-  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: 121]
-  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: 22]
+
  Branch (222:16): [True: 0, False: 9]
+  Branch (222:41): [True: 0, False: 0]
+
  Branch (222:16): [True: 0, False: 13]
+  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: 2]
+  Branch (222:41): [True: 0, False: 0]
+
  Branch (222:16): [True: 0, False: 36]
+  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: 1, False: 15]
+  Branch (222:41): [True: 0, False: 1]
+
  Branch (222:16): [True: 0, False: 5]
   Branch (222:41): [True: 0, False: 0]
 
  Branch (222:16): [True: 0, False: 12]
   Branch (222:41): [True: 0, False: 0]
-
223
0
                return;
224
368
            }
225
368
            if tui.event_tx.try_send(Event::Key(SkimEvent::Start.into())).is_ok() {
  Branch (225:16): [True: 10, False: 0]
-
  Branch (225:16): [True: 34, False: 0]
-
  Branch (225:16): [True: 26, False: 0]
-
  Branch (225:16): [True: 12, False: 0]
-
  Branch (225:16): [True: 2, False: 0]
-
  Branch (225:16): [True: 9, False: 0]
-
  Branch (225:16): [True: 7, False: 0]
-
  Branch (225:16): [True: 34, False: 0]
+
  Branch (222:16): [True: 0, False: 11]
+  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: 9]
+  Branch (222:41): [True: 0, False: 0]
+
223
0
                return;
224
383
            }
225
383
            if tui.event_tx.try_send(Event::Key(SkimEvent::Start.into())).is_ok() {
  Branch (225:16): [True: 10, False: 0]
 
  Branch (225:16): [True: 1, False: 0]
-
  Branch (225:16): [True: 10, False: 0]
-
  Branch (225:16): [True: 5, False: 0]
+
  Branch (225:16): [True: 23, False: 0]
+
  Branch (225:16): [True: 128, False: 0]
+
  Branch (225:16): [True: 36, False: 0]
 
  Branch (225:16): [True: 22, False: 0]
-
  Branch (225:16): [True: 16, False: 0]
-
  Branch (225:16): [True: 11, False: 0]
-
  Branch (225:16): [True: 5, False: 0]
-
  Branch (225:16): [True: 121, False: 0]
 
  Branch (225:16): [True: 9, False: 0]
-
  Branch (225:16): [True: 22, False: 0]
+
  Branch (225:16): [True: 9, False: 0]
+
  Branch (225:16): [True: 13, False: 0]
+
  Branch (225:16): [True: 11, False: 0]
+
  Branch (225:16): [True: 2, False: 0]
+
  Branch (225:16): [True: 36, False: 0]
+
  Branch (225:16): [True: 26, False: 0]
+
  Branch (225:16): [True: 16, False: 0]
+
  Branch (225:16): [True: 5, False: 0]
 
  Branch (225:16): [True: 12, False: 0]
-
226
368
                self.start_fired = true;
227
368
            
}0
228
32
        }
229
547
    }
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_cmd}");
237
        // Kill the current reader
238
43
        if let Some(rc) = self.reader_control.as_mut() {
  Branch (238:16): [True: 0, False: 0]
+
  Branch (225:16): [True: 11, False: 0]
+
  Branch (225:16): [True: 4, False: 0]
+
  Branch (225:16): [True: 9, False: 0]
+
226
383
                self.start_fired = true;
227
383
            
}0
228
32
        }
229
577
    }
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_cmd}");
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: 0, False: 0]
+
  Branch (238:16): [True: 4, 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: 0, False: 0]
 
  Branch (238:16): [True: 0, False: 0]
 
  Branch (238:16): [True: 0, False: 0]
@@ -150,17 +157,17 @@
 
  Branch (238:16): [True: 0, False: 0]
 
  Branch (238:16): [True: 0, False: 0]
 
  Branch (238:16): [True: 6, 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: 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]
 
239
43
            rc.kill();
240
43
        
}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: 0, False: 0]
+
  Branch (244:12): [True: 3, False: 1]
+
  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: 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]
@@ -169,110 +176,103 @@
 
  Branch (244:12): [True: 0, False: 0]
 
  Branch (244:12): [True: 0, False: 0]
 
  Branch (244:12): [True: 6, 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: 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]
-
245
42
            self.app.item_list.clear();
246
42
        
}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.45k
    pub fn check_reader(&mut self) -> bool {
264
9.45k
        if self
  Branch (264:12): [True: 127, False: 0]
-
  Branch (264:12): [True: 547, False: 4]
-
  Branch (264:12): [True: 735, False: 1]
-
  Branch (264:12): [True: 865, False: 1]
+
245
42
            self.app.item_list.clear();
246
42
        
}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.74k
    pub fn check_reader(&mut self) -> bool {
264
9.74k
        if self
  Branch (264:12): [True: 132, False: 1]
+
  Branch (264:12): [True: 24, False: 0]
+
  Branch (264:12): [True: 798, False: 7]
+
  Branch (264:12): [True: 2.48k, False: 46]
+
  Branch (264:12): [True: 719, False: 11]
+
  Branch (264:12): [True: 753, False: 104]
+
  Branch (264:12): [True: 99, False: 2]
+
  Branch (264:12): [True: 332, False: 3]
+
  Branch (264:12): [True: 875, False: 3]
+
  Branch (264:12): [True: 604, False: 6]
 
  Branch (264:12): [True: 75, False: 0]
-
  Branch (264:12): [True: 99, False: 0]
-
  Branch (264:12): [True: 274, False: 1]
-
  Branch (264:12): [True: 694, False: 6]
-
  Branch (264:12): [True: 25, False: 1]
-
  Branch (264:12): [True: 109, False: 1]
-
  Branch (264:12): [True: 181, False: 13]
-
  Branch (264:12): [True: 751, False: 70]
-
  Branch (264:12): [True: 10, False: 0]
-
  Branch (264:12): [True: 548, False: 0]
+
  Branch (264:12): [True: 567, False: 7]
+
  Branch (264:12): [True: 737, False: 0]
+
  Branch (264:12): [True: 7, False: 6]
 
  Branch (264:12): [True: 112, False: 0]
-
  Branch (264:12): [True: 2.41k, False: 24]
-
  Branch (264:12): [True: 584, False: 0]
-
  Branch (264:12): [True: 788, False: 1]
-
  Branch (264:12): [True: 394, False: 0]
-
265
9.45k
            .reader_control
266
9.45k
            .as_ref()
267
9.45k
            .is_some_and(super::reader::ReaderControl::is_done)
268
9.32k
            && !self.reader_done
  Branch (268:16): [True: 10, False: 117]
-
  Branch (268:16): [True: 34, False: 513]
-
  Branch (268:16): [True: 26, False: 709]
-
  Branch (268:16): [True: 12, False: 853]
-
  Branch (268:16): [True: 2, False: 73]
+
  Branch (264:12): [True: 394, False: 1]
+
  Branch (264:12): [True: 548, False: 0]
+
  Branch (264:12): [True: 169, False: 25]
+
  Branch (264:12): [True: 99, False: 2]
+
265
9.74k
            .reader_control
266
9.74k
            .as_ref()
267
9.74k
            .is_some_and(super::reader::ReaderControl::is_done)
268
9.52k
            && !self.reader_done
  Branch (268:16): [True: 10, False: 122]
+
  Branch (268:16): [True: 1, False: 23]
+
  Branch (268:16): [True: 23, False: 775]
+
  Branch (268:16): [True: 132, False: 2.34k]
+
  Branch (268:16): [True: 36, False: 683]
+
  Branch (268:16): [True: 55, False: 698]
 
  Branch (268:16): [True: 9, False: 90]
-
  Branch (268:16): [True: 7, False: 267]
-
  Branch (268:16): [True: 34, False: 660]
-
  Branch (268:16): [True: 1, False: 24]
-
  Branch (268:16): [True: 10, False: 99]
-
  Branch (268:16): [True: 11, False: 170]
-
  Branch (268:16): [True: 55, False: 696]
-
  Branch (268:16): [True: 7, False: 3]
-
  Branch (268:16): [True: 11, False: 537]
+
  Branch (268:16): [True: 9, False: 323]
+
  Branch (268:16): [True: 13, False: 862]
+
  Branch (268:16): [True: 11, False: 593]
+
  Branch (268:16): [True: 2, False: 73]
+
  Branch (268:16): [True: 36, False: 531]
+
  Branch (268:16): [True: 26, False: 711]
+
  Branch (268:16): [True: 6, False: 1]
 
  Branch (268:16): [True: 5, False: 107]
-
  Branch (268:16): [True: 125, False: 2.28k]
-
  Branch (268:16): [True: 9, False: 575]
-
  Branch (268:16): [True: 22, False: 766]
 
  Branch (268:16): [True: 12, False: 382]
-
269
        {
270
402
            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
402
            self.app.reader_done = true;
276
402
            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
402
            if self.app.matcher_control.stopped() && 
self.app.item_pool.num_not_taken() == 0308
{
  Branch (279:16): [True: 9, False: 1]
-  Branch (279:54): [True: 9, False: 0]
-
  Branch (279:16): [True: 22, False: 12]
-  Branch (279:54): [True: 22, False: 0]
-
  Branch (279:16): [True: 26, False: 0]
-  Branch (279:54): [True: 26, False: 0]
-
  Branch (279:16): [True: 9, False: 3]
-  Branch (279:54): [True: 9, False: 0]
-
  Branch (279:16): [True: 2, False: 0]
-  Branch (279:54): [True: 2, False: 0]
-
  Branch (279:16): [True: 4, False: 5]
-  Branch (279:54): [True: 4, False: 0]
-
  Branch (279:16): [True: 6, False: 1]
-  Branch (279:54): [True: 6, False: 0]
-
  Branch (279:16): [True: 23, False: 11]
-  Branch (279:54): [True: 23, False: 0]
+
  Branch (268:16): [True: 11, False: 537]
+
  Branch (268:16): [True: 10, False: 159]
+
  Branch (268:16): [True: 9, False: 90]
+
269
        {
270
416
            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
416
            self.app.reader_done = true;
276
416
            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
416
            if self.app.matcher_control.stopped() && 
self.app.item_pool.num_not_taken() == 099
{
  Branch (279:16): [True: 1, False: 9]
+  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: 7, False: 3]
+
  Branch (279:16): [True: 12, False: 11]
+  Branch (279:54): [True: 12, False: 0]
+
  Branch (279:16): [True: 16, False: 116]
+  Branch (279:54): [True: 12, False: 4]
+
  Branch (279:16): [True: 0, False: 36]
+  Branch (279:54): [True: 0, False: 0]
+
  Branch (279:16): [True: 51, False: 4]
+  Branch (279:54): [True: 51, False: 0]
+
  Branch (279:16): [True: 7, False: 2]
   Branch (279:54): [True: 7, False: 0]
-
  Branch (279:16): [True: 4, False: 7]
+
  Branch (279:16): [True: 0, False: 9]
+  Branch (279:54): [True: 0, False: 0]
+
  Branch (279:16): [True: 0, False: 13]
+  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: 2]
+  Branch (279:54): [True: 0, False: 0]
+
  Branch (279:16): [True: 1, False: 35]
+  Branch (279:54): [True: 1, False: 0]
+
  Branch (279:16): [True: 3, False: 23]
+  Branch (279:54): [True: 3, False: 0]
+
  Branch (279:16): [True: 0, False: 6]
+  Branch (279:54): [True: 0, False: 0]
+
  Branch (279:16): [True: 3, False: 2]
+  Branch (279:54): [True: 3, False: 0]
+
  Branch (279:16): [True: 0, False: 12]
+  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: 1, False: 9]
+  Branch (279:54): [True: 1, False: 0]
+
  Branch (279:16): [True: 4, False: 5]
   Branch (279:54): [True: 4, False: 0]
-
  Branch (279:16): [True: 53, False: 2]
-  Branch (279:54): [True: 53, False: 0]
-
  Branch (279:16): [True: 2, False: 5]
-  Branch (279:54): [True: 2, False: 0]
-
  Branch (279:16): [True: 11, False: 0]
-  Branch (279:54): [True: 11, False: 0]
-
  Branch (279:16): [True: 5, False: 0]
-  Branch (279:54): [True: 5, False: 0]
-
  Branch (279:16): [True: 89, False: 36]
-  Branch (279:54): [True: 85, False: 4]
-
  Branch (279:16): [True: 8, False: 1]
-  Branch (279:54): [True: 8, False: 0]
-
  Branch (279:16): [True: 18, False: 4]
-  Branch (279:54): [True: 18, False: 0]
-
  Branch (279:16): [True: 10, False: 2]
-  Branch (279:54): [True: 10, False: 0]
-
280
304
                trace!("matcher interval stopped in check_reader: all items consumed");
281
304
                self.matcher_interval = None;
282
98
            }
283
402
            true
284
        } else {
285
9.05k
            false
286
        }
287
9.45k
    }
288
289
    /// Returns `true` if the reader is done (has finished producing items).
290
1.30k
    pub fn reader_done(&self) -> bool {
291
1.30k
        self.reader_done
  Branch (291:9): [Folded - Ignored]
-  Branch (291:9): [True: 51, False: 38]
-
  Branch (291:9): [True: 67, False: 27]
-
  Branch (291:9): [True: 88, False: 13]
+
280
95
                trace!("matcher interval stopped in check_reader: all items consumed");
281
95
                self.matcher_interval = None;
282
321
            }
283
416
            true
284
        } else {
285
9.33k
            false
286
        }
287
9.74k
    }
288
289
    /// Returns `true` if the reader is done (has finished producing items).
290
1.43k
    pub fn reader_done(&self) -> bool {
291
1.43k
        self.reader_done
  Branch (291:9): [Folded - Ignored]
+  Branch (291:9): [True: 1, False: 1]
+
  Branch (291:9): [True: 83, False: 30]
+
  Branch (291:9): [True: 222, False: 174]
+
  Branch (291:9): [True: 47, False: 47]
+
  Branch (291:9): [True: 81, False: 126]
+
  Branch (291:9): [True: 9, False: 11]
+
  Branch (291:9): [True: 31, False: 12]
+
  Branch (291:9): [True: 89, False: 16]
+
  Branch (291:9): [True: 60, False: 17]
 
  Branch (291:9): [True: 7, False: 2]
-
  Branch (291:9): [True: 9, False: 9]
-
  Branch (291:9): [True: 25, False: 8]
-
  Branch (291:9): [True: 45, False: 40]
-
  Branch (291:9): [True: 1, False: 2]
-
  Branch (291:9): [True: 10, False: 11]
-
  Branch (291:9): [True: 16, False: 14]
-
  Branch (291:9): [True: 81, False: 90]
+
  Branch (291:9): [True: 53, False: 43]
+
  Branch (291:9): [True: 67, False: 26]
 
  Branch (291:9): [True: 1, False: 0]
-
  Branch (291:9): [True: 50, False: 11]
 
  Branch (291:9): [True: 10, False: 5]
-
  Branch (291:9): [True: 215, False: 145]
-
  Branch (291:9): [True: 58, False: 9]
-
  Branch (291:9): [True: 82, False: 23]
-
  Branch (291:9): [True: 32, False: 12]
-
292
848
            && self
293
848
                .reader_control
294
848
                .as_ref()
295
848
                .is_none_or(super::reader::ReaderControl::is_done)
296
1.30k
    }
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
358
    pub fn init_tui_with(&mut self, tui: Tui<Backend>) {
308
358
        self.tui = Some(tui);
309
358
    }
310
311
    /// Returns a shared reference to the application state.
312
13.8k
    pub fn app(&self) -> &App {
313
13.8k
        &self.app
314
13.8k
    }
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
598
    pub fn tui_ref(&self) -> &Tui<Backend> {
327
598
        self.tui.as_ref().expect("TUI needs to be initialized before access")
328
598
    }
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.5k
    pub fn tui_mut(&mut self) -> &mut Tui<Backend> {
336
20.5k
        self.tui.as_mut().expect("TUI needs to be initialized before access")
337
20.5k
    }
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.47k
    pub fn app_and_tui(&mut self) -> (&mut App, &mut Tui<Backend>) {
349
8.47k
        (
350
8.47k
            &mut self.app,
351
8.47k
            self.tui.as_mut().expect("TUI needs to be initialized before access"),
352
8.47k
        )
353
8.47k
    }
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");
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: 32, False: 13]
+
  Branch (291:9): [True: 50, False: 11]
+
  Branch (291:9): [True: 15, False: 23]
+
  Branch (291:9): [True: 9, False: 11]
+
292
867
            && self
293
867
                .reader_control
294
867
                .as_ref()
295
867
                .is_none_or(super::reader::ReaderControl::is_done)
296
1.43k
    }
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
373
    pub fn init_tui_with(&mut self, tui: Tui<Backend>) {
308
373
        self.tui = Some(tui);
309
373
    }
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
613
    pub fn tui_ref(&self) -> &Tui<Backend> {
327
613
        self.tui.as_ref().expect("TUI needs to be initialized before access")
328
613
    }
329
330
    /// Returns a mutable reference to the TUI.
331
    ///
332
    /// # Panics
333
    ///
334
    /// Panics if the TUI has not been initialized yet.
335
21.0k
    pub fn tui_mut(&mut self) -> &mut Tui<Backend> {
336
21.0k
        self.tui.as_mut().expect("TUI needs to be initialized before access")
337
21.0k
    }
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.63k
    pub fn app_and_tui(&mut self) -> (&mut App, &mut Tui<Backend>) {
349
8.63k
        (
350
8.63k
            &mut self.app,
351
8.63k
            self.tui.as_mut().expect("TUI needs to be initialized before access"),
352
8.63k
        )
353
8.63k
    }
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");
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]
@@ -286,66 +286,66 @@
 
  Branch (452:20): [True: 2, False: 4]
 
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");
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
83
                }
461
83
                let matcher_stopped = app.matcher_control.stopped();
462
83
                let reader_done = reader_control.is_done();
463
83
                if matcher_stopped && 
reader_done19
&&
app.item_pool.num_not_taken() == 017
{
  Branch (463:20): [True: 17, False: 62]
-  Branch (463:39): [True: 15, False: 2]
+
454
1
                        debug!("filter mode: query shorter than --min-query-length, no results");
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
83
                }
461
83
                let matcher_stopped = app.matcher_control.stopped();
462
83
                let reader_done = reader_control.is_done();
463
83
                if matcher_stopped && 
reader_done18
&&
app.item_pool.num_not_taken() == 017
{
  Branch (463:20): [True: 16, False: 63]
+  Branch (463:39): [True: 15, False: 1]
   Branch (463:54): [True: 15, False: 0]
 
  Branch (463:20): [True: 2, False: 2]
   Branch (463:39): [True: 2, False: 0]
   Branch (463:54): [True: 2, False: 0]
-
464
17
                    break;
465
66
                }
466
66
                std::thread::sleep(Duration::from_millis(1));
467
66
                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
                .take()
474
17
                .unwrap_or_default()
475
17
                .items
476
17
                .into_iter()
477
50.0k
                .
filter17
(|i| !i.item.disabled())
478
17
                .collect();
479
17
            debug!("filter mode: matched {} items", 
app.item_list.items0
.
len0
());
480
17
            return false;
481
20
        }
482
483
        // Deal with read-0 / select-1
484
20
        let min_items_before_enter = if app.options.exit_0 {
  Branch (484:41): [True: 0, False: 17]
-
  Branch (484:41): [True: 1, False: 2]
-
485
1
            1
486
19
        } else if app.options.select_1 {
  Branch (486:19): [True: 7, False: 10]
-
  Branch (486:19): [True: 1, False: 1]
-
487
8
            2
488
11
        } else if app.options.sync {
  Branch (488:19): [True: 0, False: 10]
-
  Branch (488:19): [True: 1, False: 0]
-
489
1
            usize::MAX
490
        } else {
491
10
            0
492
        };
493
20
        if min_items_before_enter > 0 || 
app.options.sync10
{
  Branch (493:12): [True: 7, False: 10]
-  Branch (493:42): [True: 0, False: 10]
-
  Branch (493:12): [True: 3, False: 0]
-  Branch (493:42): [True: 0, False: 0]
-
494
10
            trace!(
495
                "checking matcher, stopped: {}, processed: {}, matched: {}/{}, pool: {}, query: {}, reader_control_done: {}",
496
1
                app.matcher_control.stopped(),
497
1
                app.matcher_control.get_num_processed(),
498
1
                app.matcher_control.get_num_matched(),
499
                min_items_before_enter,
500
1
                app.item_pool.num_not_taken(),
501
                app.input.value,
502
1
                reader_control.is_done()
503
            );
504
22
            while app.matcher_control.get_num_matched() < min_items_before_enter
  Branch (504:19): [True: 16, False: 0]
-
  Branch (504:19): [True: 5, False: 1]
-
505
21
                && (!app.matcher_control.stopped() || 
!reader_control.is_done()9
)
  Branch (505:21): [True: 9, False: 7]
-  Branch (505:55): [True: 0, False: 7]
-
  Branch (505:21): [True: 3, False: 2]
-  Branch (505:55): [True: 0, False: 2]
-
506
            {
507
12
                trace!("still waiting");
508
12
                std::thread::sleep(Duration::from_millis(1));
509
12
                app.restart_matcher(false);
510
            }
511
10
            trace!(
512
                "checked matcher, stopped: {}, processed: {}, pool: {}, query: {}, reader_control_done: {}",
513
1
                app.matcher_control.stopped(),
514
1
                app.matcher_control.get_num_processed(),
515
1
                app.item_pool.num_not_taken(),
516
                app.input.value,
517
1
                reader_control.is_done()
518
            );
519
10
            trace!(
520
                "checking for matched item count before entering: {}/{min_items_before_enter}",
521
1
                app.matcher_control.get_num_matched()
522
            );
523
10
            if app.matcher_control.get_num_matched() == min_items_before_enter - 1 {
  Branch (523:16): [True: 7, False: 0]
-
  Branch (523:16): [True: 1, False: 2]
-
524
8
                app.item_list.items = app.item_list.processed_items.lock().take().unwrap_or_default().items;
525
8
                debug!("early exit, result: {:?}", 
app1
.
results1
());
526
8
                return false;
527
2
            }
528
10
        }
529
12
        true
530
38
    }
531
532
    /// Initialize the IPC socket listener
533
    /// This needs to be called from an async context despite being sync
534
    #[cfg_attr(not(feature = "listen"), allow(clippy::unnecessary_wraps, clippy::unused_self))]
535
10
    fn init_listener(&mut self) -> Result<()> {
536
        #[cfg(feature = "listen")]
537
10
        if let Some(
socket_name3
) = &self.app.options.listen {
  Branch (537:16): [True: 3, False: 7]
-
  Branch (537:16): [Folded - Ignored]
-
538
3
            self.listener = Some(
539
3
                interprocess::local_socket::ListenerOptions::new()
540
3
                    .name(interprocess::local_socket::ToNsName::to_ns_name::<
541
3
                        interprocess::local_socket::GenericNamespaced,
542
3
                    >(socket_name.to_owned())
?0
)
543
3
                    .create_tokio()
?0
,
544
            );
545
7
        }
546
10
        Ok(())
547
10
    }
548
549
    /// Capture `self` and extract the output
550
    /// This will perform cleanup
551
    ///
552
    /// # Panics
553
    ///
554
    /// Panics if the selected items or current item cannot be retrieved.
555
38
    pub fn output(mut self) -> SkimOutput {
556
38
        if let Some(mut rc) = self.reader_control.take() {
  Branch (556:16): [True: 32, False: 0]
-
  Branch (556:16): [True: 6, False: 0]
-
557
38
            rc.kill();
558
38
        
}0
559
560
        // Extract final_key and is_abort from final_event
561
38
        let is_abort = !
matches!12
(
&self.final_event34
, Event::Action(Action::Accept(_)));
562
563
38
        let selected_items = self.app.results();
564
565
38
        let cmd = if self.app.options.interactive {
  Branch (565:22): [True: 0, False: 32]
-
  Branch (565:22): [True: 1, False: 5]
-
566
1
            self.app.input.to_string()
567
37
        } else if self.app.options.cmd_query.is_some() {
  Branch (567:19): [True: 0, False: 32]
-
  Branch (567:19): [True: 1, False: 4]
-
568
1
            self.app.options.cmd_query.clone().unwrap()
569
        } else {
570
36
            self.initial_cmd.clone()
571
        };
572
38
        let query = self.app.input.to_string();
573
38
        let current = self.app.item_list.selected();
574
38
        let header = self.app.header.header.clone();
575
38
        let final_event = self.final_event.clone();
576
38
        let final_key = self.final_key;
577
578
38
        drop(self);
579
580
38
        SkimOutput {
581
38
            final_event,
582
38
            is_abort,
583
38
            final_key,
584
38
            query,
585
38
            cmd,
586
38
            selected_items,
587
38
            current,
588
38
            header,
589
38
        }
590
38
    }
591
592
    /// Returns true if skim has finished (the user accepted or aborted)
593
1
    pub fn should_quit(&self) -> bool {
594
1
        self.app.should_quit
595
1
    }
596
597
    /// If `needs_render` has been set (e.g. by the matcher thread), immediately
598
    /// send a `Render` event to the TUI so the screen updates without waiting
599
    /// for the next heartbeat tick.  Respects the 30 FPS frame-rate cap.
600
14
    fn try_flush_render(&mut self) {
601
        use std::sync::atomic::Ordering;
602
14
        if self.app.needs_render.load(Ordering::Relaxed)
  Branch (602:12): [True: 11, False: 0]
-
  Branch (602:12): [True: 2, False: 1]
-
603
13
            && self.app.last_render_timer.elapsed().as_millis() > 1000 / u128::from(TICK_RATE)
  Branch (603:16): [True: 10, False: 1]
-
  Branch (603:16): [True: 2, False: 0]
-
604
        {
605
12
            self.app.needs_render.store(false, Ordering::Relaxed);
606
12
            self.app.last_render_timer = std::time::Instant::now();
607
12
            if let Some(tui) = self.tui.as_ref() {
  Branch (607:20): [True: 10, False: 0]
-
  Branch (607:20): [True: 2, False: 0]
-
608
12
                let _ = tui.event_tx.try_send(Event::Render);
609
12
            
}0
610
2
        }
611
14
    }
612
613
    /// Process a single event loop iteration.
614
    ///
615
    /// This awaits the next event from the TUI, matcher, or IPC listener,
616
    /// processes it, and returns. Use this in your own event loop when you
617
    /// need fine-grained control over the application lifecycle.
618
    ///
619
    /// Returns `Ok(true)` if skim should quit, `Ok(false)` to continue.
620
    ///
621
    /// # Errors
622
    ///
623
    /// Returns an error if the TUI cannot produce the next event or if event handling fails.
624
    ///
625
    /// # Panics
626
    ///
627
    /// Panics if `init_tui` has not been called before this method.
628
    ///
629
    /// # Example
630
    ///
631
    /// ```ignore
632
    /// while !skim.tick().await? {
633
    ///     // do your own work between ticks
634
    /// }
635
    /// ```
636
147
    pub async fn tick(&mut self) -> Result<bool> {
637
        // Retry the one-shot `start` event until the (bounded) event channel
638
        // accepts it. `start()`/`enter()` fire it eagerly, but if the channel was
639
        // momentarily full there, this guarantees it is not lost. Idempotent: the
640
        // `start_fired` guard makes every call after the first a no-op.
641
147
        self.fire_start_event();
642
147
        let matcher_interval = &mut self.matcher_interval;
643
147
        let items_available = self.app.item_pool.items_available.clone();
644
147
        select! {
645
147
            
event132
= self.tui.as_mut().expect("TUI should be initialized before the event loop can start").next() => {
646
132
                let evt = event.ok_or_eyre("Could not acquire next event")
?0
;
647
648
132
                if let Event::Key(
k50
) = &evt {
  Branch (648:24): [True: 48, False: 79]
-
  Branch (648:24): [True: 2, False: 3]
-
649
50
                  self.final_key.clone_from(k);
650
82
                } else {
651
82
                  self.final_event = evt.clone();
652
82
                }
653
654
655
                // Handle reload event separately
656
132
                if let Event::Reload(
new_cmd0
) = &evt {
  Branch (656:24): [True: 0, False: 127]
-
  Branch (656:24): [True: 0, False: 5]
-
657
0
                    self.handle_reload(&new_cmd.clone());
658
0
                } else {
659
132
                    self.app.handle_event(self.tui.as_mut().expect("TUI should be initialized before handling events"), &evt)
?0
;
660
                }
661
662
132
                if let Some(
action12
) = self.app.final_action.take() {
  Branch (662:24): [True: 10, False: 117]
-
  Branch (662:24): [True: 2, False: 3]
-
663
12
                    self.final_event = Event::Action(action);
664
120
                }
665
666
                // Check reader status and update
667
132
                self.check_reader();
668
            }
669
82
            () = async {
670
82
                match matcher_interval {
671
9
                    Some(interval) => { interval.tick().await; },
672
73
                    None => std::future::pending::<()>().await,
673
                }
674
1
            } => {
675
              // Check for a pending debounced restart (e.g. the user typed
676
              // while a match-all was running).  Checking here (every 10ms)
677
              // rather than only on the heartbeat (83ms) eliminates most of
678
              // the latency between query change and matcher restart.
679
1
              if self.app.pending_matcher_restart {
  Branch (679:18): [True: 0, False: 1]
-
  Branch (679:18): [True: 0, False: 0]
-
680
0
                  self.app.restart_matcher(true);
681
1
              } else {
682
1
                  self.app.restart_matcher(false);
683
1
              }
684
              // Check if the matcher (or reader) has set the render flag and
685
              // flush a render event immediately instead of waiting for the
686
              // next heartbeat tick.  This can shave up to ~80ms of latency
687
              // when the heartbeat runs at 12 Hz.
688
1
              self.try_flush_render();
689
              // Once the reader has finished and the matcher has consumed all
690
              // items, stop the periodic interval — it can only produce empty
691
              // ticks from this point.  The `items_available` Notify branch
692
              // still handles the (rare) case of late-arriving items.
693
1
              if self.reader_done
  Branch (693:18): [True: 1, False: 0]
-
  Branch (693:18): [True: 0, False: 0]
-
694
1
                  && self.app.matcher_control.stopped()
  Branch (694:22): [True: 1, False: 0]
-
  Branch (694:22): [True: 0, False: 0]
-
695
1
                  && self.app.item_pool.num_not_taken() == 0
  Branch (695:22): [True: 1, False: 0]
-
  Branch (695:22): [True: 0, False: 0]
-
696
              {
697
1
                  trace!("matcher interval stopped: reader done, matcher idle, no pending items");
698
1
                  self.matcher_interval = None;
699
0
              }
700
            }
701
            // Wake immediately when new items arrive in the pool so the matcher
702
            // can pick them up without waiting for the next periodic interval.
703
147
            () = items_available.notified() => {
704
11
                self.app.restart_matcher(false);
705
11
                self.try_flush_render();
706
11
            }
707
            // IPC listener branch. `tokio::select!` does not allow `#[cfg]` on a
708
            // branch, so the branch stays but its body is gated: with the `listen`
709
            // feature off the future is a never-resolving `pending()` and the handler
710
            // is unreachable (`RemoteStream` is uninhabited).
711
113
            Ok(
stream3
) = async {
712
                #[cfg(feature = "listen")]
713
113
                if let Some(
l35
) = self.listener.as_ref() {
  Branch (713:24): [True: 35, False: 73]
-
  Branch (713:24): [True: 0, False: 5]
-
714
35
                    return interprocess::local_socket::traits::tokio::Listener::accept(l).await;
715
78
                }
716
78
                std::future::pending::<std::io::Result<RemoteStream>>().await
717
3
            } => {
718
                #[cfg(feature = "listen")]
719
                {
720
3
                    debug!("Listener accepted a connection");
721
3
                    let event_tx_clone_ipc = self.tui.as_ref().expect("TUI should be initialized before listening").event_tx.clone();
722
3
                    tokio::spawn(async move {
723
                        use tokio::io::AsyncBufReadExt;
724
3
                        let reader = tokio::io::BufReader::new(stream);
725
3
                        let mut lines = reader.lines();
726
6
                        while let Ok(Some(
line3
)) = lines.next_line().await {
  Branch (726:35): [True: 3, False: 0]
-
  Branch (726:35): [True: 0, False: 0]
-
727
3
                            debug!("listener: got {line}");
728
3
                            if let Ok(act) = ron::from_str::<Action>(&line) {
  Branch (728:36): [True: 3, False: 0]
-
  Branch (728:36): [True: 0, False: 0]
-
729
3
                                debug!("listener: parsed into action {act:?}");
730
3
                                if let Err(
e0
) = event_tx_clone_ipc.try_send(Event::Action(act)) {
  Branch (730:40): [True: 0, False: 3]
-
  Branch (730:40): [True: 0, False: 0]
-
731
0
                                    warn!("listener: failed to send action to backend: {e:?}");
732
3
                                }
733
0
                            }
734
                        }
735
0
                    });
736
                }
737
                #[cfg(not(feature = "listen"))]
738
                match stream {}
739
            }
740
        }
741
742
147
        Ok(self.app.should_quit)
743
147
    }
744
745
    /// Run the event loop on the current task until skim quits.
746
    ///
747
    /// This is a convenience wrapper around [`tick()`](Self::tick) that loops
748
    /// until the user accepts or aborts. Use `tick()` directly if you need
749
    /// to interleave your own logic between iterations.
750
    ///
751
    /// # Errors
752
    ///
753
    /// Returns an error if any tick in the event loop fails.
754
12
    pub async fn run(&mut self) -> Result<()> {
755
12
        self.matcher_interval = Some(tokio::time::interval(Duration::from_millis(10)));
756
12
        trace!("Starting event loop");
757
        loop {
758
147
            if self.tick().await
?0
{
  Branch (758:16): [True: 10, False: 131]
-
  Branch (758:16): [True: 2, False: 4]
-
759
12
                break Ok(());
760
135
            }
761
        }
762
12
    }
763
764
    /// Spawn the event loop and run a user-provided future concurrently.
765
    ///
766
    /// This consumes `self`, spawns the event loop as a local task, and runs
767
    /// `user_task` alongside it. When the user accepts or aborts in the TUI,
768
    /// the event loop completes and the [`SkimOutput`] is returned — regardless
769
    /// of whether `user_task` has finished.
770
    ///
771
    /// Use this when you need to send items or do other work concurrently
772
    /// while the TUI is running.
773
    ///
774
    /// # Errors
775
    ///
776
    /// Returns an error if the event loop or the task join fails.
777
    ///
778
    /// # Example
779
    ///
780
    /// ```ignore
781
    /// let output = skim.run_until(async {
782
    ///     for i in 1..=10 {
783
    ///         tx.send(vec![Arc::new(format!("item {i}"))]);
784
    ///         tokio::time::sleep(Duration::from_millis(100)).await;
785
    ///     }
786
    /// }).await?;
787
    /// ```
788
0
    pub async fn run_until<F: Future + 'static>(mut self, user_task: F) -> Result<SkimOutput> {
789
0
        let local = tokio::task::LocalSet::new();
790
0
        local
791
0
            .run_until(async {
792
0
                let handle = tokio::task::spawn_local(async move {
793
0
                    self.run().await?;
794
0
                    Ok(self.output())
795
0
                });
796
0
                tokio::task::spawn_local(user_task);
797
0
                handle.await?
798
0
            })
799
0
            .await
800
0
    }
801
}
802
803
#[cfg(test)]
804
#[path = "skim_tests.rs"]
805
mod tests;
\ No newline at end of file +
464
17
                    break;
465
66
                }
466
66
                std::thread::sleep(Duration::from_millis(1));
467
66
                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
                .
filter17
(|i| !i.item.disabled())
479
17
                .collect();
480
17
            debug!("filter mode: matched {} items", 
app.item_list.items0
.
len0
());
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]
+
488
8
            2
489
11
        } else if app.options.sync {
  Branch (489:19): [True: 0, False: 10]
+
  Branch (489:19): [True: 1, False: 0]
+
490
1
            usize::MAX
491
        } else {
492
10
            0
493
        };
494
20
        if min_items_before_enter > 0 || 
app.options.sync10
{
  Branch (494:12): [True: 7, False: 10]
+  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
                min_items_before_enter,
501
1
                app.item_pool.num_not_taken(),
502
                app.input.value,
503
1
                reader_control.is_done()
504
            );
505
28
            while app.matcher_control.get_num_matched() < min_items_before_enter
  Branch (505:19): [True: 19, False: 0]
+
  Branch (505:19): [True: 8, False: 1]
+
506
27
                && (!app.matcher_control.stopped() || 
!reader_control.is_done()11
)
  Branch (506:21): [True: 10, False: 9]
+  Branch (506:55): [True: 2, False: 7]
+
  Branch (506:21): [True: 6, False: 2]
+  Branch (506:55): [True: 0, False: 2]
+
507
            {
508
18
                trace!("still waiting");
509
18
                std::thread::sleep(Duration::from_millis(1));
510
18
                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
                app.input.value,
518
1
                reader_control.is_done()
519
            );
520
10
            trace!(
521
                "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: {:?}", 
app1
.
results1
());
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_name3
) = &self.app.options.listen {
  Branch (545:16): [True: 3, False: 7]
+
  Branch (545:16): [Folded - Ignored]
+
546
3
            self.listener = Some(
547
3
                interprocess::local_socket::ListenerOptions::new()
548
3
                    .name(interprocess::local_socket::ToNsName::to_ns_name::<
549
3
                        interprocess::local_socket::GenericNamespaced,
550
3
                    >(socket_name.to_owned())
?0
)
551
3
                    .create_tokio()
?0
,
552
            );
553
7
        }
554
10
        Ok(())
555
10
    }
556
557
    /// Capture `self` and extract the output
558
    /// This will perform cleanup
559
    ///
560
    /// # Panics
561
    ///
562
    /// Panics if the selected items or current item cannot be retrieved.
563
38
    pub fn output(mut self) -> SkimOutput {
564
38
        if let Some(mut rc) = self.reader_control.take() {
  Branch (564:16): [True: 32, False: 0]
+
  Branch (564:16): [True: 6, False: 0]
+
565
38
            rc.kill();
566
38
        
}0
567
568
        // Extract final_key and is_abort from final_event
569
38
        let is_abort = !
matches!12
(
&self.final_event34
, Event::Action(Action::Accept(_)));
570
571
38
        let selected_items = self.app.results();
572
573
38
        let cmd = if self.app.options.interactive {
  Branch (573:22): [True: 0, False: 32]
+
  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
24
    fn try_flush_render(&mut self) {
609
        use std::sync::atomic::Ordering;
610
24
        if self.app.needs_render.load(Ordering::Relaxed)
  Branch (610:12): [True: 20, False: 1]
+
  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
12
            
}0
618
12
        }
619
24
    }
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
162
    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
162
        self.fire_start_event();
650
162
        let matcher_interval = &mut self.matcher_interval;
651
162
        let items_available = self.app.item_pool.items_available.clone();
652
162
        select! {
653
162
            
event137
= self.tui.as_mut().expect("TUI should be initialized before the event loop can start").next() => {
654
137
                let evt = event.ok_or_eyre("Could not acquire next event")
?0
;
655
656
137
                if let Event::Key(
k49
) = &evt {
  Branch (656:24): [True: 47, False: 86]
+
  Branch (656:24): [True: 2, False: 2]
+
657
49
                  self.final_key.clone_from(k);
658
88
                } else {
659
88
                  self.final_event = evt.clone();
660
88
                }
661
662
663
                // Handle reload event separately
664
137
                if let Event::Reload(
new_cmd0
) = &evt {
  Branch (664:24): [True: 0, False: 133]
+
  Branch (664:24): [True: 0, False: 4]
+
665
0
                    self.handle_reload(&new_cmd.clone());
666
0
                } else {
667
137
                    self.app.handle_event(self.tui.as_mut().expect("TUI should be initialized before handling events"), &evt)
?0
;
668
                }
669
670
137
                if let Some(
action12
) = self.app.final_action.take() {
  Branch (670:24): [True: 10, False: 123]
+
  Branch (670:24): [True: 2, False: 2]
+
671
12
                    self.final_event = Event::Action(action);
672
125
                }
673
674
                // Check reader status and update
675
137
                self.check_reader();
676
            }
677
89
            () = async {
678
89
                match matcher_interval {
679
26
                    Some(interval) => { interval.tick().await; },
680
63
                    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]
+
  Branch (687:18): [True: 0, False: 0]
+
688
0
                  self.app.restart_matcher(true);
689
10
              } else {
690
10
                  self.app.restart_matcher(false);
691
10
              }
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]
+
  Branch (701:18): [True: 0, False: 0]
+
702
10
                  && self.app.matcher_control.stopped()
  Branch (702:22): [True: 9, False: 1]
+
  Branch (702:22): [True: 0, False: 0]
+
703
9
                  && self.app.item_pool.num_not_taken() == 0
  Branch (703:22): [True: 9, False: 0]
+
  Branch (703:22): [True: 0, False: 0]
+
704
              {
705
9
                  trace!("matcher interval stopped: reader done, matcher idle, no pending items");
706
9
                  self.matcher_interval = None;
707
1
              }
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
162
            () = items_available.notified() => {
712
12
                self.app.restart_matcher(false);
713
12
                self.try_flush_render();
714
12
            }
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(
stream3
) = async {
720
                #[cfg(feature = "listen")]
721
131
                if let Some(
l38
) = self.listener.as_ref() {
  Branch (721:24): [True: 38, False: 90]
+
  Branch (721:24): [True: 0, False: 3]
+
722
38
                    return interprocess::local_socket::traits::tokio::Listener::accept(l).await;
723
93
                }
724
93
                std::future::pending::<std::io::Result<RemoteStream>>().await
725
3
            } => {
726
                #[cfg(feature = "listen")]
727
                {
728
3
                    debug!("Listener accepted a connection");
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(
line3
)) = lines.next_line().await {
  Branch (734:35): [True: 3, False: 0]
+
  Branch (734:35): [True: 0, False: 0]
+
735
3
                            debug!("listener: got {line}");
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 {act:?}");
738
3
                                if let Err(
e0
) = 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
162
        Ok(self.app.should_quit)
751
162
    }
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");
765
        loop {
766
162
            if self.tick().await
?0
{
  Branch (766:16): [True: 10, False: 147]
+
  Branch (766:16): [True: 2, False: 3]
+
767
12
                break Ok(());
768
150
            }
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 c1b22c5e..9e647405 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-04 10: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
110
    fn display(&self, context: DisplayContext) -> Line<'_> {
53
110
        context.to_line(self.text())
54
110
    }
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
394
    fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> {
74
394
        None
75
394
    }
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
111
    fn hidden_ranges(&self) -> Option<&[(usize, usize)]> {
85
111
        None
86
111
    }
87
88
    /// Returns true if the item should be disabled
89
    /// Disabled items cannot be selected
90
345
    fn disabled(&self) -> bool {
91
345
        false
92
345
    }
93
}
94
95
//------------------------------------------------------------------------------
96
// Implement SkimItem for raw strings
97
98
impl<T: AsRef<str> + Send + Sync + 'static> SkimItem for T {
99
589
    fn text(&self) -> Cow<'_, str> {
100
589
        Cow::Borrowed(self.as_ref())
101
589
    }
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-05 18:50

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
601
    fn text(&self) -> Cow<'_, str> {
100
601
        Cow::Borrowed(self.as_ref())
101
601
    }
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 aa12d373..40d32c44 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,8 +1,6 @@ -

Coverage Report

Created: 2026-09-04 10: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 new guard for the given lock
30
25.1k
    pub fn new(pool: &'a SpinLock<T>) -> SpinLockGuard<'a, T> {
31
25.1k
        Self { __lock: pool }
32
25.1k
    }
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
2.07k
    pub fn new(t: T) -> SpinLock<T> {
40
2.07k
        Self {
41
2.07k
            locked: AtomicBool::new(false),
42
2.07k
            data: UnsafeCell::new(t),
43
2.07k
        }
44
2.07k
    }
45
}
46
47
impl<T: ?Sized> SpinLock<T> {
48
    /// Acquires the lock, blocking the current thread until it succeeds
49
25.1k
    pub fn lock(&self) -> SpinLockGuard<'_, T> {
50
40.4k
        while self
  Branch (50:15): [True: 15.2k, False: 15.4k]
-  Branch (50:15): [True: 0, False: 3.46k]
+

Coverage Report

Created: 2026-09-05 18:50

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.3k
    fn new(lock: &'a SpinLock<T>) -> SpinLockGuard<'a, T> {
31
22.3k
        Self { __lock: lock }
32
22.3k
    }
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.3k
    pub fn lock(&self) -> SpinLockGuard<'_, T> {
50
35.4k
        while self
  Branch (50:15): [True: 13.1k, False: 16.1k]
   Branch (50:15): [True: 0, False: 2]
-  Branch (50:15): [True: 17, False: 182]
-  Branch (50:15): [True: 0, False: 66]
+  Branch (50:15): [True: 0, False: 193]
   Branch (50:15): [True: 0, False: 6.00k]
   Branch (50:15): [True: 0, False: 2]
-
51
40.4k
            .locked
52
40.4k
            .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed)
53
40.4k
            .is_err()
54
15.2k
        {
55
15.2k
            core::hint::spin_loop();
56
15.2k
        }
57
25.1k
        SpinLockGuard::new(self)
58
25.1k
    }
59
}
60
61
impl<T: ?Sized> Deref for SpinLockGuard<'_, T> {
62
    type Target = T;
63
64
15.8k
    fn deref(&self) -> &T {
65
15.8k
        unsafe { &*self.__lock.data.get() }
66
15.8k
    }
67
}
68
69
impl<T: ?Sized> DerefMut for SpinLockGuard<'_, T> {
70
10.0k
    fn deref_mut(&mut self) -> &mut T {
71
10.0k
        unsafe { &mut *self.__lock.data.get() }
72
10.0k
    }
73
}
74
75
impl<T: ?Sized> Drop for SpinLockGuard<'_, T> {
76
    #[inline]
77
25.1k
    fn drop(&mut self) {
78
25.1k
        self.__lock.locked.store(false, Ordering::Release);
79
25.1k
    }
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
35.4k
            .locked
52
35.4k
            .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed)
53
35.4k
            .is_err()
54
13.1k
        {
55
13.1k
            core::hint::spin_loop();
56
13.1k
        }
57
22.3k
        SpinLockGuard::new(self)
58
22.3k
    }
59
}
60
61
impl<T: ?Sized> Deref for SpinLockGuard<'_, T> {
62
    type Target = T;
63
64
16.7k
    fn deref(&self) -> &T {
65
16.7k
        unsafe { &*self.__lock.data.get() }
66
16.7k
    }
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.3k
    fn drop(&mut self) {
78
22.3k
        self.__lock.locked.store(false, Ordering::Release);
79
22.3k
    }
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 bdddb619..02131b50 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,8 +1,8 @@ -

Coverage Report

Created: 2026-09-04 10: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
293
    fn default() -> Self {
50
293
        ColorTheme::dark256()
51
293
    }
52
}
53
54
#[allow(dead_code)]
55
impl ColorTheme {
56
    /// Setup the theme from the skim options
57
    #[must_use]
58
393
    pub fn init_from_options(options: &SkimOptions) -> ColorTheme {
59
        // register
60
393
        if let Some(
color6
) = options.color.clone() {
  Branch (60:16): [True: 5, False: 369]
+

Coverage Report

Created: 2026-09-05 18:50

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
297
    fn default() -> Self {
50
297
        ColorTheme::dark256()
51
297
    }
52
}
53
54
#[allow(dead_code)]
55
impl ColorTheme {
56
    /// Setup the theme from the skim options
57
    #[must_use]
58
408
    pub fn init_from_options(options: &SkimOptions) -> ColorTheme {
59
        // register
60
408
        if let Some(
color6
) = options.color.clone() {
  Branch (60:16): [True: 5, False: 384]
 
  Branch (60:16): [True: 1, False: 18]
-
61
6
            ColorTheme::from_options(&color)
62
        } else {
63
            // Check for NO_COLOR environment variable
64
387
            match std::env::var_os("NO_COLOR") {
65
2
                Some(
no_color1
) if !no_color.is_empty(
)1
=>
ColorTheme::none1
(),
  Branch (65:35): [True: 0, False: 0]
+
61
6
            ColorTheme::from_options(&color)
62
        } else {
63
            // Check for NO_COLOR environment variable
64
402
            match std::env::var_os("NO_COLOR") {
65
2
                Some(
no_color1
) if !no_color.is_empty(
)1
=>
ColorTheme::none1
(),
  Branch (65:35): [True: 0, False: 0]
 
  Branch (65:35): [True: 1, False: 1]
-
66
386
                _ => ColorTheme::dark256(),
67
            }
68
        }
69
393
    }
70
71
800
    fn none() -> Self {
72
800
        let def = Style::default();
73
800
        Self {
74
800
            spinner: Style::default().bold(),
75
800
            normal: def,
76
800
            matched: def,
77
800
            current: def,
78
800
            current_match: def,
79
800
            query: def,
80
800
            info: def,
81
800
            prompt: def,
82
800
            cursor: def,
83
800
            selected: def,
84
800
            header: def,
85
800
            border: def,
86
800
            scrollbar: def,
87
800
        }
88
800
    }
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
773
    fn dark256() -> Self {
119
773
        let base = ColorTheme::none();
120
773
        ColorTheme {
121
773
            matched: base.matched.fg(Color::Indexed(108)).bg(Color::Indexed(0)),
122
773
            current: base.current.bg(Color::Indexed(236)),
123
773
            current_match: base.current_match.fg(Color::Indexed(151)).bg(Color::Indexed(236)),
124
773
            spinner: base.spinner.fg(Color::Indexed(148)),
125
773
            info: base.info.fg(Color::Indexed(144)),
126
773
            prompt: base.prompt.fg(Color::Indexed(110)),
127
773
            cursor: base.cursor.fg(Color::Indexed(161)),
128
773
            selected: base.selected.fg(Color::Indexed(168)),
129
773
            header: base.header.fg(Color::Indexed(109)),
130
773
            border: base.border.fg(Color::Indexed(59)),
131
773
            scrollbar: base.scrollbar.fg(Color::Indexed(59)),
132
773
            ..base
133
773
        }
134
773
    }
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 
part23
in spec_parts.iter().skip(1) {
291
23
            if 
matches!2
(*part, "x" |
"regular"22
) {
292
2
                modifier = Modifier::empty();
293
2
            } else {
294
21
                modifier |= match *part {
295
21
                    "b" | 
"bold"20
=>
Modifier::BOLD7
,
296
14
                    "u" | 
"underlined"13
=>
Modifier::UNDERLINED7
,
297
7
                    "c" | 
"crossed-out"6
=>
Modifier::CROSSED_OUT1
,
298
6
                    "d" | 
"dim"5
=>
Modifier::DIM1
,
299
5
                    "i" | 
"italic"4
=>
Modifier::ITALIC2
,
300
3
                    "r" | 
"reverse"2
=>
Modifier::REVERSED2
,
301
1
                    m => {
302
1
                        debug!("Unknown modifier '{m}'");
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") || 
name76
.
ends_with76
("-fg") {
  Branch (309:42): [True: 0, False: 10]
+
66
401
                _ => ColorTheme::dark256(),
67
            }
68
        }
69
408
    }
70
71
819
    fn none() -> Self {
72
819
        let def = Style::default();
73
819
        Self {
74
819
            spinner: Style::default().bold(),
75
819
            normal: def,
76
819
            matched: def,
77
819
            current: def,
78
819
            current_match: def,
79
819
            query: def,
80
819
            info: def,
81
819
            prompt: def,
82
819
            cursor: def,
83
819
            selected: def,
84
819
            header: def,
85
819
            border: def,
86
819
            scrollbar: def,
87
819
        }
88
819
    }
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
792
    fn dark256() -> Self {
119
792
        let base = ColorTheme::none();
120
792
        ColorTheme {
121
792
            matched: base.matched.fg(Color::Indexed(108)).bg(Color::Indexed(0)),
122
792
            current: base.current.bg(Color::Indexed(236)),
123
792
            current_match: base.current_match.fg(Color::Indexed(151)).bg(Color::Indexed(236)),
124
792
            spinner: base.spinner.fg(Color::Indexed(148)),
125
792
            info: base.info.fg(Color::Indexed(144)),
126
792
            prompt: base.prompt.fg(Color::Indexed(110)),
127
792
            cursor: base.cursor.fg(Color::Indexed(161)),
128
792
            selected: base.selected.fg(Color::Indexed(168)),
129
792
            header: base.header.fg(Color::Indexed(109)),
130
792
            border: base.border.fg(Color::Indexed(59)),
131
792
            scrollbar: base.scrollbar.fg(Color::Indexed(59)),
132
792
            ..base
133
792
        }
134
792
    }
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 
part23
in spec_parts.iter().skip(1) {
291
23
            if 
matches!2
(*part, "x" |
"regular"22
) {
292
2
                modifier = Modifier::empty();
293
2
            } else {
294
21
                modifier |= match *part {
295
21
                    "b" | 
"bold"20
=>
Modifier::BOLD7
,
296
14
                    "u" | 
"underlined"13
=>
Modifier::UNDERLINED7
,
297
7
                    "c" | 
"crossed-out"6
=>
Modifier::CROSSED_OUT1
,
298
6
                    "d" | 
"dim"5
=>
Modifier::DIM1
,
299
5
                    "i" | 
"italic"4
=>
Modifier::ITALIC2
,
300
3
                    "r" | 
"reverse"2
=>
Modifier::REVERSED2
,
301
1
                    m => {
302
1
                        debug!("Unknown modifier '{m}'");
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") || 
name76
.
ends_with76
("-fg") {
  Branch (309:42): [True: 0, False: 10]
   Branch (309:67): [True: 0, False: 10]
 
  Branch (309:42): [True: 1, False: 66]
   Branch (309:67): [True: 1, False: 65]
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 855682dc..647d8cfb 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,34 +1,34 @@
-

Coverage Report

Created: 2026-09-04 10: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.83k
pub fn partition_threads(n: usize) -> (usize, usize) {
26
1.83k
    let reader = n.div_ceil(3); // ⌈n/3⌉
27
1.83k
    let matcher = (2 * n) / 3; // ⌊2n/3⌋
28
1.83k
    (reader.max(1), matcher.max(1))
29
1.83k
}
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.30k
    pub fn new(num_threads: usize) -> Self {
77
2.30k
        assert!(num_threads > 0, "ThreadPool requires at least 1 thread");
78
79
2.30k
        let shared = Arc::new(SharedState {
80
2.30k
            queue: Mutex::new(QueueInner {
81
2.30k
                jobs: VecDeque::new(),
82
2.30k
                shutdown: false,
83
2.30k
            }),
84
2.30k
            job_available: Condvar::new(),
85
2.30k
        });
86
87
2.30k
        let mut workers = Vec::with_capacity(num_threads);
88
89
2.30k
        for _ in 0..num_threads {
90
4.61k
            let worker_shared = Arc::clone(&shared);
91
4.61k
            workers.push(thread::spawn(move || worker_loop(&worker_shared)));
92
        }
93
94
2.30k
        Self {
95
2.30k
            shared,
96
2.30k
            workers,
97
2.30k
            num_threads,
98
2.30k
        }
99
2.30k
    }
100
101
    /// Returns the number of worker threads in the pool.
102
    #[inline]
103
    #[must_use]
104
1.25k
    pub fn num_threads(&self) -> usize {
105
1.25k
        self.num_threads
106
1.25k
    }
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
413
    pub fn spawn<F>(&self, f: F)
117
413
    where
118
413
        F: FnOnce() + Send + 'static,
119
    {
120
413
        {
121
413
            let mut queue = self.shared.queue.lock().unwrap();
122
413
            queue.jobs.push_back(Box::new(f));
123
413
        } // lock dropped before notify
124
413
        self.shared.job_available.notify_one();
125
413
    }
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
709
    pub fn spawn_batch<I>(&self, jobs: I)
136
709
    where
137
709
        I: IntoIterator<Item = Box<dyn FnOnce() + Send + 'static>>,
138
    {
139
709
        let count = {
140
709
            let mut queue = self.shared.queue.lock().unwrap();
141
709
            let before = queue.jobs.len();
142
1.43k
            for job in 
jobs709
{
143
1.43k
                queue.jobs.push_back(job);
144
1.43k
            }
145
709
            queue.jobs.len() - before
146
        }; // lock dropped before notify
147
709
        if count >= self.num_threads {
  Branch (147:12): [True: 686, False: 0]
-
  Branch (147:12): [True: 23, False: 0]
-
148
709
            self.shared.job_available.notify_all();
149
709
        } else {
150
0
            for _ in 0..count {
151
0
                self.shared.job_available.notify_one();
152
0
            }
153
        }
154
709
    }
155
}
156
157
impl Drop for ThreadPool {
158
2.30k
    fn drop(&mut self) {
159
        // Signal shutdown.
160
2.30k
        {
161
2.30k
            let mut queue = self.shared.queue.lock().unwrap();
162
2.30k
            queue.shutdown = true;
163
2.30k
        }
164
2.30k
        self.shared.job_available.notify_all();
165
166
        // Join all workers (ignore panics from individual threads).
167
4.61k
        for handle in 
self.workers2.30k
.
drain2.30k
(
..2.30k
) {
168
4.61k
            let _ = handle.join();
169
4.61k
        }
170
2.30k
    }
171
}
172
173
// ---------------------------------------------------------------------------
174
// Worker loop
175
// ---------------------------------------------------------------------------
176
177
4.61k
fn worker_loop(shared: &SharedState) {
178
    loop {
179
6.46k
        let next_job = {
180
6.46k
            let mut queue = shared.queue.lock().unwrap();
181
            loop {
182
12.1k
                if let Some(
ready1.84k
) = queue.jobs.pop_front() {
  Branch (182:24): [True: 1.76k, False: 8.13k]
-
  Branch (182:24): [True: 82, False: 2.21k]
-
183
1.84k
                    break Some(ready);
184
10.3k
                }
185
10.3k
                if queue.shutdown {
  Branch (185:20): [True: 3.28k, False: 4.85k]
-
  Branch (185:20): [True: 1.33k, False: 882]
-
186
4.61k
                    break None;
187
5.73k
                }
188
5.73k
                queue = shared.job_available.wait(queue).unwrap();
189
            }
190
        };
191
192
6.46k
        match next_job {
193
1.84k
            Some(runnable) => runnable(),
194
4.61k
            None => return, // shutdown
195
        }
196
    }
197
4.61k
}
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
828
pub fn parallel_work_queue<S, T, R, P, M, I, W, G>(
311
828
    pool: &ThreadPool,
312
828
    num_workers: usize,
313
828
    items: &Arc<S>,
314
828
    chunk_size: usize,
315
828
    preserve_chunk_order: bool,
316
828
    identity: I,
317
828
    process_chunk: P,
318
828
    reduce: M,
319
828
    prepare: W,
320
828
    merge: G,
321
828
) where
322
828
    S: AsRef<[T]> + Send + Sync + ?Sized + 'static,
323
828
    T: Send + Sync + 'static,
324
828
    R: Send + 'static,
325
828
    P: Fn(usize, &[T]) -> R + Send + Sync + 'static,
326
828
    M: Fn(&mut R, R) + Send + Sync + 'static,
327
828
    I: Fn() -> R + Send + Sync + 'static,
328
828
    W: Fn(&mut R) + Send + Sync + 'static,
329
828
    G: FnOnce(Vec<R>),
330
{
331
828
    let items_slice: &[T] = AsRef::<[T]>::as_ref(&**items);
332
828
    let total = items_slice.len();
333
828
    if total == 0 {
  Branch (333:8): [True: 112, False: 686]
-
  Branch (333:8): [True: 7, False: 17]
+

Coverage Report

Created: 2026-09-05 18:50

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.54k
    pub fn num_threads(&self) -> usize {
105
1.54k
        self.num_threads
106
1.54k
    }
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
428
    pub fn spawn<F>(&self, f: F)
117
428
    where
118
428
        F: FnOnce() + Send + 'static,
119
    {
120
428
        {
121
428
            let mut queue = self.shared.queue.lock().unwrap();
122
428
            queue.jobs.push_back(Box::new(f));
123
428
        } // lock dropped before notify
124
428
        self.shared.job_available.notify_one();
125
428
    }
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
708
    pub fn spawn_batch<I>(&self, jobs: I)
136
708
    where
137
708
        I: IntoIterator<Item = Box<dyn FnOnce() + Send + 'static>>,
138
    {
139
708
        let count = {
140
708
            let mut queue = self.shared.queue.lock().unwrap();
141
708
            let before = queue.jobs.len();
142
1.43k
            for job in 
jobs708
{
143
1.43k
                queue.jobs.push_back(job);
144
1.43k
            }
145
708
            queue.jobs.len() - before
146
        }; // lock dropped before notify
147
708
        if count >= self.num_threads {
  Branch (147:12): [True: 689, False: 0]
+
  Branch (147:12): [True: 19, False: 0]
+
148
708
            self.shared.job_available.notify_all();
149
708
        } else {
150
0
            for _ in 0..count {
151
0
                self.shared.job_available.notify_one();
152
0
            }
153
        }
154
708
    }
155
}
156
157
impl Drop for ThreadPool {
158
2.36k
    fn drop(&mut self) {
159
        // Signal shutdown.
160
2.36k
        {
161
2.36k
            let mut queue = self.shared.queue.lock().unwrap();
162
2.36k
            queue.shutdown = true;
163
2.36k
        }
164
2.36k
        self.shared.job_available.notify_all();
165
166
        // Join all workers (ignore panics from individual threads).
167
4.73k
        for handle in 
self.workers2.36k
.
drain2.36k
(
..2.36k
) {
168
4.73k
            let _ = handle.join();
169
4.73k
        }
170
2.36k
    }
171
}
172
173
// ---------------------------------------------------------------------------
174
// Worker loop
175
// ---------------------------------------------------------------------------
176
177
4.74k
fn worker_loop(shared: &SharedState) {
178
    loop {
179
6.60k
        let next_job = {
180
6.60k
            let mut queue = shared.queue.lock().unwrap();
181
            loop {
182
12.5k
                if let Some(
ready1.86k
) = queue.jobs.pop_front() {
  Branch (182:24): [True: 1.78k, False: 8.39k]
+
  Branch (182:24): [True: 74, False: 2.25k]
+
183
1.86k
                    break Some(ready);
184
10.6k
                }
185
10.6k
                if queue.shutdown {
  Branch (185:20): [True: 3.40k, False: 4.99k]
+
  Branch (185:20): [True: 1.34k, False: 911]
+
186
4.74k
                    break None;
187
5.90k
                }
188
5.90k
                queue = shared.job_available.wait(queue).unwrap();
189
            }
190
        };
191
192
6.60k
        match next_job {
193
1.86k
            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.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.10k
pub fn parallel_work_queue<S, T, R, P, M, I, W, G>(
311
1.10k
    pool: &ThreadPool,
312
1.10k
    num_workers: usize,
313
1.10k
    items: &Arc<S>,
314
1.10k
    chunk_size: usize,
315
1.10k
    preserve_chunk_order: bool,
316
1.10k
    identity: I,
317
1.10k
    process_chunk: P,
318
1.10k
    reduce: M,
319
1.10k
    prepare: W,
320
1.10k
    merge: G,
321
1.10k
) where
322
1.10k
    S: AsRef<[T]> + Send + Sync + ?Sized + 'static,
323
1.10k
    T: Send + Sync + 'static,
324
1.10k
    R: Send + 'static,
325
1.10k
    P: Fn(usize, &[T]) -> R + Send + Sync + 'static,
326
1.10k
    M: Fn(&mut R, R) + Send + Sync + 'static,
327
1.10k
    I: Fn() -> R + Send + Sync + 'static,
328
1.10k
    W: Fn(&mut R) + Send + Sync + 'static,
329
1.10k
    G: FnOnce(Vec<R>),
330
{
331
1.10k
    let items_slice: &[T] = AsRef::<[T]>::as_ref(&**items);
332
1.10k
    let total = items_slice.len();
333
1.10k
    if total == 0 {
  Branch (333:8): [True: 375, False: 689]
+
  Branch (333:8): [True: 19, False: 13]
 
  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
120
        merge(Vec::new());
335
120
        return;
336
708
    }
337
338
708
    let num_chunks = total.div_ceil(chunk_size);
339
340
    // Shared atomic counter – workers fetch-add to grab the next chunk index.
341
708
    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
708
    let num_slots = if preserve_chunk_order { 
num_chunks7
} else {
num_workers701
};
  Branch (345:24): [True: 6, False: 680]
-
  Branch (345:24): [True: 0, False: 17]
+
334
395
        merge(Vec::new());
335
395
        return;
336
707
    }
337
338
707
    let num_chunks = total.div_ceil(chunk_size);
339
340
    // Shared atomic counter – workers fetch-add to grab the next chunk index.
341
707
    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
707
    let num_slots = if preserve_chunk_order { 
num_chunks8
} else {
num_workers699
};
  Branch (345:24): [True: 7, False: 682]
+
  Branch (345:24): [True: 0, False: 13]
 
  Branch (345:24): [True: 1, False: 0]
 
  Branch (345:24): [True: 0, False: 1]
 
  Branch (345:24): [True: 0, False: 0]
 
  Branch (345:24): [True: 0, False: 1]
 
  Branch (345:24): [True: 0, False: 1]
 
  Branch (345:24): [True: 0, False: 1]
-
346
1.42k
    let 
slots708
:
Arc<Vec<Slot<R>>>708
=
Arc::new708
((
0..num_slots708
).
map708
(|_| Slot::new()).
collect708
());
347
348
    // Barrier: we wait until all workers have finished.
349
708
    let remaining = Arc::new(AtomicCounter::new(num_workers));
350
    // Register the coordinator thread so workers can unpark it.
351
708
    remaining.set_waiter();
352
353
708
    let process_chunk = Arc::new(process_chunk);
354
708
    let reduce = Arc::new(reduce);
355
708
    let prepare = Arc::new(prepare);
356
708
    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
708
    let jobs: Vec<Box<dyn FnOnce() + Send + 'static>> = (0..num_workers)
361
1.42k
        .
map708
(|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: 34]
+
346
1.42k
    let 
slots707
:
Arc<Vec<Slot<R>>>707
=
Arc::new707
((
0..num_slots707
).
map707
(|_| Slot::new()).
collect707
());
347
348
    // Barrier: we wait until all workers have finished.
349
707
    let remaining = Arc::new(AtomicCounter::new(num_workers));
350
    // Register the coordinator thread so workers can unpark it.
351
707
    remaining.set_waiter();
352
353
707
    let process_chunk = Arc::new(process_chunk);
354
707
    let reduce = Arc::new(reduce);
355
707
    let prepare = Arc::new(prepare);
356
707
    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
707
    let jobs: Vec<Box<dyn FnOnce() + Send + 'static>> = (0..num_workers)
361
1.42k
        .
map707
(|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: 14, False: 1.36k]
+
  Branch (375:36): [True: 0, False: 26]
 
  Branch (375:36): [True: 4, False: 0]
 
  Branch (375:36): [True: 0, False: 1]
 
  Branch (375:36): [True: 0, False: 0]
 
  Branch (375:36): [True: 0, False: 1]
 
  Branch (375:36): [True: 0, False: 4]
 
  Branch (375:36): [True: 0, False: 8]
-
376
                    loop {
377
37
                        let chunk_idx = w_next_chunk.fetch_add(1, Ordering::Relaxed);
378
37
                        if chunk_idx >= num_chunks {
  Branch (378:28): [True: 12, False: 17]
+
376
                    loop {
377
40
                        let chunk_idx = w_next_chunk.fetch_add(1, Ordering::Relaxed);
378
40
                        if chunk_idx >= num_chunks {
  Branch (378:28): [True: 14, False: 18]
 
  Branch (378:28): [True: 0, False: 0]
 
  Branch (378:28): [True: 4, False: 4]
 
  Branch (378:28): [True: 0, False: 0]
@@ -36,34 +36,34 @@
 
  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.40k
                    let mut local_acc = w_identity();
394
                    loop {
395
2.14k
                        let chunk_idx = w_next_chunk.fetch_add(1, Ordering::Relaxed);
396
2.14k
                        if chunk_idx >= num_chunks {
  Branch (396:28): [True: 1.36k, False: 680]
-
  Branch (396:28): [True: 34, False: 17]
+
379
18
                            break;
380
22
                        }
381
382
22
                        let start = chunk_idx * chunk_size;
383
22
                        let end = total.min(start + chunk_size);
384
22
                        let slice: &[T] = AsRef::<[T]>::as_ref(&*w_items);
385
22
                        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
22
                        unsafe { *w_slots[chunk_idx].value.get() = Some(partial) };
390
                    }
391
18
                    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: 682]
+
  Branch (396:28): [True: 26, False: 13]
 
  Branch (396:28): [True: 0, False: 0]
 
  Branch (396:28): [True: 1, False: 10]
 
  Branch (396:28): [True: 0, False: 0]
 
  Branch (396:28): [True: 1, False: 10]
 
  Branch (396:28): [True: 4, False: 16]
 
  Branch (396:28): [True: 8, False: 2]
-
397
1.40k
                            break;
398
735
                        }
399
400
735
                        let start = chunk_idx * chunk_size;
401
735
                        let end = total.min(start + chunk_size);
402
735
                        let slice: &[T] = AsRef::<[T]>::as_ref(&*w_items);
403
735
                        let partial = w_process_chunk(start, &slice[start..end]);
404
735
                        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_acc1.40k
) = local_acc {
  Branch (413:24): [True: 1.36k, False: 12]
-
  Branch (413:24): [True: 34, False: 0]
+
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_acc1.40k
) = local_acc {
  Branch (413:24): [True: 1.36k, False: 14]
+
  Branch (413:24): [True: 26, False: 0]
 
  Branch (413:24): [True: 0, False: 4]
 
  Branch (413:24): [True: 1, False: 0]
 
  Branch (413:24): [True: 0, False: 0]
 
  Branch (413:24): [True: 1, False: 0]
 
  Branch (413:24): [True: 4, False: 0]
 
  Branch (413:24): [True: 8, False: 0]
-
414
1.40k
                    // SAFETY: each worker_id is unique; no other thread writes to
415
1.40k
                    // this slot, and the coordinator reads only after the barrier.
416
1.40k
                    unsafe { *w_slots[worker_id].value.get() = Some(local_acc) };
417
1.40k
                
}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
708
        .collect();
433
434
708
    pool.spawn_batch(jobs);
435
436
    // Block until all workers are done.
437
708
    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
708
    if let Some(slots) = Arc::into_inner(slots) {
  Branch (442:12): [True: 686, False: 0]
-
  Branch (442:12): [True: 17, False: 0]
+
414
1.40k
                    // SAFETY: each worker_id is unique; no other thread writes to
415
1.40k
                    // this slot, and the coordinator reads only after the barrier.
416
1.40k
                    unsafe { *w_slots[worker_id].value.get() = Some(local_acc) };
417
1.40k
                
}18
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
707
        .collect();
433
434
707
    pool.spawn_batch(jobs);
435
436
    // Block until all workers are done.
437
707
    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
707
    if let Some(slots) = Arc::into_inner(slots) {
  Branch (442:12): [True: 689, False: 0]
+
  Branch (442:12): [True: 13, False: 0]
 
  Branch (442:12): [True: 1, False: 0]
 
  Branch (442:12): [True: 1, False: 0]
 
  Branch (442:12): [True: 0, False: 0]
 
  Branch (442:12): [True: 1, False: 0]
 
  Branch (442:12): [True: 1, False: 0]
 
  Branch (442:12): [True: 1, False: 0]
-
443
1.42k
        let 
results708
:
Vec<R>708
=
slots708
.
into_iter708
().
filter_map708
(|slot| slot.value.into_inner()).
collect708
();
444
445
708
        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
828
}
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
708
    fn new(n: usize) -> Self {
473
708
        Self {
474
708
            count: AtomicUsize::new(n),
475
708
            waiter: UnsafeCell::new(None),
476
708
        }
477
708
    }
478
479
    /// Register the current thread as the waiter.
480
    ///
481
    /// Must be called exactly once, before any worker calls `dec_and_notify`.
482
708
    fn set_waiter(&self) {
483
        // SAFETY: called once by the coordinator before workers start.
484
708
        unsafe { *self.waiter.get() = Some(thread::current()) };
485
708
    }
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: 686, False: 686]
-
  Branch (496:12): [True: 22, False: 30]
-
497
            // We just decremented from 1 → 0.
498
            // SAFETY: waiter was set before workers were dispatched.
499
708
            if let Some(t) = unsafe { &*self.waiter.get() } {
  Branch (499:20): [True: 686, False: 0]
-
  Branch (499:20): [True: 22, False: 0]
-
500
708
                t.unpark();
501
708
            
}0
502
716
        }
503
1.42k
    }
504
505
    /// Blocks until the counter reaches zero.
506
708
    fn wait_for_zero(&self) {
507
1.30k
        while self.count.load(Ordering::Acquire) > 0 {
  Branch (507:15): [True: 577, False: 686]
-
  Branch (507:15): [True: 16, False: 22]
-
508
593
            thread::park();
509
593
        }
510
708
    }
511
}
512
513
#[cfg(test)]
514
#[path = "thread_pool_tests.rs"]
515
mod tests;
\ No newline at end of file +
443
1.42k
        let 
results707
:
Vec<R>707
=
slots707
.
into_iter707
().
filter_map707
(|slot| slot.value.into_inner()).
collect707
();
444
445
707
        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.10k
}
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
707
    fn new(n: usize) -> Self {
473
707
        Self {
474
707
            count: AtomicUsize::new(n),
475
707
            waiter: UnsafeCell::new(None),
476
707
        }
477
707
    }
478
479
    /// Register the current thread as the waiter.
480
    ///
481
    /// Must be called exactly once, before any worker calls `dec_and_notify`.
482
707
    fn set_waiter(&self) {
483
        // SAFETY: called once by the coordinator before workers start.
484
707
        unsafe { *self.waiter.get() = Some(thread::current()) };
485
707
    }
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: 689, False: 689]
+
  Branch (496:12): [True: 18, False: 26]
+
497
            // We just decremented from 1 → 0.
498
            // SAFETY: waiter was set before workers were dispatched.
499
707
            if let Some(t) = unsafe { &*self.waiter.get() } {
  Branch (499:20): [True: 689, False: 0]
+
  Branch (499:20): [True: 18, False: 0]
+
500
707
                t.unpark();
501
707
            
}0
502
715
        }
503
1.42k
    }
504
505
    /// Blocks until the counter reaches zero.
506
707
    fn wait_for_zero(&self) {
507
1.33k
        while self.count.load(Ordering::Acquire) > 0 {
  Branch (507:15): [True: 613, False: 689]
+
  Branch (507:15): [True: 16, False: 18]
+
508
629
            thread::park();
509
629
        }
510
707
    }
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 8d7320f8..122f645c 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-04 10: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 
arg40
= arg
?30
;
120
40
    let (then, otherwise) = match arg.split_once('+') {
121
3
        Some((
then1
, "")) =>
(then, None)1
,
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-05 18:50

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 
arg40
= arg
?30
;
120
40
    let (then, otherwise) = match arg.split_once('+') {
121
3
        Some((
then1
, "")) =>
(then, None)1
,
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
            .
filter651
(|line| !line.is_empty())
160
651
            .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
658
            .or_else(|| 
parse_action109
(
&format!("{}()", self.name)109
))
174
658
            .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
                    $(Self::$variant { .. } => $name),+
257
                }
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
227
        Some((
act16
, "")) =>
action = act16
,
462
211
        Some((act, a)) => {
463
211
            action = act;
464
211
            arg = Some(a.trim_end_matches(')').to_string());
465
211
        }
466
    }
467
1.16k
    debug!("parse_action: action={action}, arg={arg:?}");
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
            .
filter651
(|line| !line.is_empty())
160
651
            .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
658
            .or_else(|| 
parse_action109
(
&format!("{}()", self.name)109
))
174
658
            .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
                    $(Self::$variant { .. } => $name),+
257
                }
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
935
        None => action = raw_action,
461
227
        Some((
act16
, "")) =>
action = act16
,
462
211
        Some((act, a)) => {
463
211
            action = act;
464
211
            arg = Some(a.trim_end_matches(')').to_string());
465
211
        }
466
    }
467
1.16k
    debug!("parse_action: action={action}, arg={arg:?}");
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 eef90a5e..8acf3717 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-04 10: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
519
static NUM_THREADS: LazyLock<usize> = LazyLock::new(|| {
39
519
    std::thread::available_parallelism()
40
519
        .ok()
41
519
        .map_or_else(|| 0, std::num::NonZero::get)
42
519
});
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.67k
    fn render(self, area: Rect, buf: &mut Buffer) {
153
2.67k
        let mut res = SkimRender::default();
154
2.67k
        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.67k
        self.header.set_header_lines(self.item_pool.reserved());
162
2.67k
        let current_header_height = self.header.height();
163
2.67k
        if current_header_height != self.last_header_height {
  Branch (163:12): [True: 7, False: 2.63k]
-
  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.66k
        }
167
2.67k
        self.layout = self.layout_template.apply(area);
168
169
2.67k
        if let Some(
header_area99
) = self.layout.header_area {
  Branch (169:16): [True: 99, False: 2.54k]
-
  Branch (169:16): [True: 0, False: 28]
-
170
99
            res |= self.header.render(header_area, buf);
171
2.57k
        }
172
173
2.67k
        if let Some(
preview_area223
) = self.layout.preview_area {
  Branch (173:16): [True: 221, False: 2.42k]
-
  Branch (173:16): [True: 2, False: 26]
-
174
223
            res |= self.preview.render(preview_area, buf);
175
2.45k
        }
176
177
2.67k
        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.67k
        self.input.status_info = if self.options.info.display == InfoDisplay::Hidden {
  Branch (180:37): [True: 59, False: 2.58k]
-
  Branch (180:37): [True: 1, False: 27]
-
181
60
            None
182
        } else {
183
            Some(StatusInfo {
184
2.61k
                total: self.item_pool.len(),
185
2.61k
                matched: self.item_list.count(),
186
2.61k
                processed: self.matcher_control.get_num_processed(),
187
2.61k
                show_spinner: self.show_spinner,
188
2.61k
                matcher_mode: if self.options.regex {
  Branch (188:34): [True: 3, False: 2.58k]
-
  Branch (188:34): [True: 1, False: 26]
-
189
4
                    "RE".to_string()
190
                } else {
191
2.61k
                    String::new()
192
                },
193
2.61k
                multi_selection: self.options.multi,
194
2.61k
                selected: self.item_list.selection.len(),
195
2.61k
                current_item_idx: self.item_list.current,
196
2.61k
                hscroll_offset: i64::from(self.item_list.manual_hscroll),
197
2.61k
                start: Some(self.spinner_start),
198
2.61k
                inline_separator: self
199
2.61k
                    .options
200
2.61k
                    .info
201
2.61k
                    .separator()
202
2.61k
                    .unwrap_or(super::statusline::DEFAULT_SEPARATOR)
203
2.61k
                    .to_string(),
204
            })
205
        };
206
2.67k
        res |= self.input.render(self.layout.input_area, buf);
207
208
        // Cursor position needs to account for input border and title.
209
        self.cursor_pos = (
210
2.67k
            self.layout.input_area.x + self.input.cursor_pos() + u16::from(has_border),
211
2.67k
            self.layout.input_area.y
212
2.67k
                + u16::from(!(self.options.layout == TuiLayout::Reverse && 
self.options.border48
.
is_none48
())),
  Branch (212:31): [True: 47, False: 2.59k]
-
  Branch (212:31): [True: 1, False: 27]
-
213
        );
214
2.67k
        if res.run_preview {
  Branch (214:12): [True: 373, False: 2.27k]
-
  Branch (214:12): [True: 1, False: 27]
-
215
374
            self.pending_preview_run = true;
216
2.30k
        }
217
2.67k
    }
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
391
    pub fn from_options(options: SkimOptions, theme: Arc<crate::theme::ColorTheme>, cmd: String) -> Self {
292
391
        let header = Header::from_options(&options, theme.clone());
293
391
        let initial_header_height = header.height();
294
391
        let layout_template = LayoutTemplate::from_options(&options, initial_header_height);
295
391
        let layout = layout_template.apply(Rect::default());
296
391
        let (mut reader_threads, mut matcher_threads) = thread_pool::partition_threads(*NUM_THREADS);
297
391
        if options.flags.contains(&crate::options::FeatureFlag::SingleReader) {
  Branch (297:12): [True: 0, False: 374]
+

Coverage Report

Created: 2026-09-05 18:50

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
534
static NUM_THREADS: LazyLock<usize> = LazyLock::new(|| {
39
534
    std::thread::available_parallelism()
40
534
        .ok()
41
534
        .map_or_else(|| 0, std::num::NonZero::get)
42
534
});
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.74k
    fn render(self, area: Rect, buf: &mut Buffer) {
153
2.74k
        let mut res = SkimRender::default();
154
2.74k
        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.74k
        self.header.set_header_lines(self.item_pool.reserved());
162
2.74k
        let current_header_height = self.header.height();
163
2.74k
        if current_header_height != self.last_header_height {
  Branch (163:12): [True: 7, False: 2.71k]
+
  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.73k
        }
167
2.74k
        self.layout = self.layout_template.apply(area);
168
169
2.74k
        if let Some(
header_area99
) = self.layout.header_area {
  Branch (169:16): [True: 99, False: 2.62k]
+
  Branch (169:16): [True: 0, False: 27]
+
170
99
            res |= self.header.render(header_area, buf);
171
2.64k
        }
172
173
2.74k
        if let Some(
preview_area236
) = self.layout.preview_area {
  Branch (173:16): [True: 234, False: 2.48k]
+
  Branch (173:16): [True: 2, False: 25]
+
174
236
            res |= self.preview.render(preview_area, buf);
175
2.51k
        }
176
177
2.74k
        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.74k
        self.input.status_info = if self.options.info.display == InfoDisplay::Hidden {
  Branch (180:37): [True: 59, False: 2.66k]
+
  Branch (180:37): [True: 1, False: 26]
+
181
60
            None
182
        } else {
183
            Some(StatusInfo {
184
2.68k
                total: self.item_pool.len(),
185
2.68k
                matched: self.item_list.count(),
186
2.68k
                processed: self.matcher_control.get_num_processed(),
187
2.68k
                show_spinner: self.show_spinner,
188
2.68k
                matcher_mode: if self.options.regex {
  Branch (188:34): [True: 3, False: 2.65k]
+
  Branch (188:34): [True: 1, False: 25]
+
189
4
                    "RE".to_string()
190
                } else {
191
2.68k
                    String::new()
192
                },
193
2.68k
                multi_selection: self.options.multi,
194
2.68k
                selected: self.item_list.selection.len(),
195
2.68k
                current_item_idx: self.item_list.current,
196
2.68k
                hscroll_offset: i64::from(self.item_list.manual_hscroll),
197
2.68k
                start: Some(self.spinner_start),
198
2.68k
                inline_separator: self
199
2.68k
                    .options
200
2.68k
                    .info
201
2.68k
                    .separator()
202
2.68k
                    .unwrap_or(super::statusline::DEFAULT_SEPARATOR)
203
2.68k
                    .to_string(),
204
            })
205
        };
206
2.74k
        res |= self.input.render(self.layout.input_area, buf);
207
208
        // Cursor position needs to account for input border and title.
209
        self.cursor_pos = (
210
2.74k
            self.layout.input_area.x + self.input.cursor_pos() + u16::from(has_border),
211
2.74k
            self.layout.input_area.y
212
2.74k
                + u16::from(!(self.options.layout == TuiLayout::Reverse && 
self.options.border49
.
is_none49
())),
  Branch (212:31): [True: 48, False: 2.67k]
+
  Branch (212:31): [True: 1, False: 26]
+
213
        );
214
2.74k
        if res.run_preview {
  Branch (214:12): [True: 374, False: 2.34k]
+
  Branch (214:12): [True: 0, False: 27]
+
215
374
            self.pending_preview_run = true;
216
2.37k
        }
217
2.74k
    }
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
406
    pub fn from_options(options: SkimOptions, theme: Arc<crate::theme::ColorTheme>, cmd: String) -> Self {
292
406
        let header = Header::from_options(&options, theme.clone());
293
406
        let initial_header_height = header.height();
294
406
        let layout_template = LayoutTemplate::from_options(&options, initial_header_height);
295
406
        let layout = layout_template.apply(Rect::default());
296
406
        let (mut reader_threads, mut matcher_threads) = thread_pool::partition_threads(*NUM_THREADS);
297
406
        if options.flags.contains(&crate::options::FeatureFlag::SingleReader) {
  Branch (297:12): [True: 0, False: 389]
 
  Branch (297:12): [True: 1, False: 16]
-
298
1
            reader_threads = 1;
299
390
        }
300
391
        if options.flags.contains(&crate::options::FeatureFlag::SingleMatcher) {
  Branch (300:12): [True: 0, False: 374]
+
298
1
            reader_threads = 1;
299
405
        }
300
406
        if options.flags.contains(&crate::options::FeatureFlag::SingleMatcher) {
  Branch (300:12): [True: 0, False: 389]
 
  Branch (300:12): [True: 1, False: 16]
-
301
1
            matcher_threads = 1;
302
390
        }
303
391
        Self {
304
391
            input: Input::from_options(&options, theme.clone()),
305
391
            preview: Preview::from_options(&options, theme.clone()),
306
391
            header,
307
391
            matcher_pool: Arc::new(ThreadPool::new(matcher_threads)),
308
391
            reader_pool: Arc::new(ThreadPool::new(reader_threads)),
309
391
            item_pool: Arc::new(ItemPool::from_options(&options)),
310
391
            item_list: ItemList::from_options(&options, theme.clone()),
311
391
            theme,
312
391
            should_quit: false,
313
391
            final_action: None,
314
391
            cursor_pos: (0, 0),
315
391
            matcher: Matcher::from_options(&options),
316
391
            yank_register: String::new(),
317
391
            matcher_control: MatcherControl::default(),
318
391
            reader_timer: std::time::Instant::now(),
319
391
            matcher_timer: std::time::Instant::now(),
320
391
            last_matcher_restart: std::time::Instant::now(),
321
391
            pending_matcher_restart: false,
322
391
            needs_render: Arc::new(AtomicBool::new(true)),
323
391
            last_render_timer: std::time::Instant::now()
324
391
                .checked_sub(std::time::Duration::from_secs(1))
325
391
                .unwrap(),
326
391
            // spinner initial state
327
391
            spinner_last_change: std::time::Instant::now(),
328
391
            show_spinner: false,
329
391
            spinner_start: std::time::Instant::now(),
330
391
            items_just_updated: false,
331
391
            query_history: options.query_history.clone(),
332
391
            history_index: None,
333
391
            saved_input: String::new(),
334
391
            cmd_history: options.cmd_history.clone(),
335
391
            cmd_history_index: None,
336
391
            saved_cmd_input: String::new(),
337
391
            options,
338
391
            cmd,
339
391
            last_header_height: initial_header_height,
340
391
            layout_template,
341
391
            layout,
342
391
            last_preview_spawn: std::time::Instant::now()
343
391
                .checked_sub(std::time::Duration::from_secs(1))
344
391
                .unwrap(),
345
391
            pending_preview_run: false,
346
391
            currently_scrolling: false,
347
391
            last_left_click: std::time::Instant::now()
348
391
                .checked_sub(DOUBLE_CLICK_INTERVAL * 2)
349
391
                .unwrap(),
350
391
            reader_done: false,
351
391
            load_event_fired: false,
352
391
            result_pending: false,
353
391
            last_focused: None,
354
391
        }
355
391
    }
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((
left4
,
right4
)) = substituted.split_once('-') {
  Branch (376:16): [True: 2, False: 4]
+
301
1
            matcher_threads = 1;
302
405
        }
303
406
        Self {
304
406
            input: Input::from_options(&options, theme.clone()),
305
406
            preview: Preview::from_options(&options, theme.clone()),
306
406
            header,
307
406
            matcher_pool: Arc::new(ThreadPool::new(matcher_threads)),
308
406
            reader_pool: Arc::new(ThreadPool::new(reader_threads)),
309
406
            item_pool: Arc::new(ItemPool::from_options(&options)),
310
406
            item_list: ItemList::from_options(&options, theme.clone()),
311
406
            theme,
312
406
            should_quit: false,
313
406
            final_action: None,
314
406
            cursor_pos: (0, 0),
315
406
            matcher: Matcher::from_options(&options),
316
406
            yank_register: String::new(),
317
406
            matcher_control: MatcherControl::default(),
318
406
            reader_timer: std::time::Instant::now(),
319
406
            matcher_timer: std::time::Instant::now(),
320
406
            last_matcher_restart: std::time::Instant::now(),
321
406
            pending_matcher_restart: false,
322
406
            needs_render: Arc::new(AtomicBool::new(true)),
323
406
            last_render_timer: std::time::Instant::now()
324
406
                .checked_sub(std::time::Duration::from_secs(1))
325
406
                .unwrap(),
326
406
            // spinner initial state
327
406
            spinner_last_change: std::time::Instant::now(),
328
406
            show_spinner: false,
329
406
            spinner_start: std::time::Instant::now(),
330
406
            items_just_updated: false,
331
406
            query_history: options.query_history.clone(),
332
406
            history_index: None,
333
406
            saved_input: String::new(),
334
406
            cmd_history: options.cmd_history.clone(),
335
406
            cmd_history_index: None,
336
406
            saved_cmd_input: String::new(),
337
406
            options,
338
406
            cmd,
339
406
            last_header_height: initial_header_height,
340
406
            layout_template,
341
406
            layout,
342
406
            last_preview_spawn: std::time::Instant::now()
343
406
                .checked_sub(std::time::Duration::from_secs(1))
344
406
                .unwrap(),
345
406
            pending_preview_run: false,
346
406
            currently_scrolling: false,
347
406
            last_left_click: std::time::Instant::now()
348
406
                .checked_sub(DOUBLE_CLICK_INTERVAL * 2)
349
406
                .unwrap(),
350
406
            reader_done: false,
351
406
            load_event_fired: false,
352
406
            result_pending: false,
353
406
            last_focused: None,
354
406
        }
355
406
    }
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((
left4
,
right4
)) = substituted.split_once('-') {
  Branch (376:16): [True: 2, False: 4]
 
  Branch (376:16): [True: 2, False: 2]
 
377
11
            let 
left_val4
=
left4
.
trim_matches4
(|x: char| !x.is_numeric()).
parse4
::<u16>().
unwrap_or4
(0);
378
4
            let right_val = right
379
4
                .trim_matches(|x: char| !x.is_numeric())
380
4
                .parse::<u16>()
381
4
                .unwrap_or(0);
382
4
            left_val.saturating_sub(right_val)
383
6
        } else if let Some((
left1
,
right1
)) = 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
1
                .parse::<u16>()
388
1
                .unwrap_or(0);
389
1
            left_val.saturating_add(right_val)
390
        } else {
391
5
            substituted
392
14
                .
trim_matches5
(|x: char| !x.is_numeric())
393
5
                .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.80k
    fn take_focus_event(&mut self) -> Option<Event> {
424
2.80k
        let focused = self.item_list.selected().map(|m| m.item);
425
2.80k
        let changed = match (&self.last_focused, &focused) {
426
1.87k
            (Some(prev), Some(curr)) => !Arc::ptr_eq(prev, curr),
427
558
            (None, None) => false,
428
376
            _ => true,
429
        };
430
2.80k
        if changed {
  Branch (430:12): [True: 450, False: 2.29k]
-
  Branch (430:12): [True: 45, False: 14]
-
431
495
            self.last_focused = focused;
432
495
            Some(Event::Key(SkimEvent::Focus.into()))
433
        } else {
434
2.30k
            None
435
        }
436
2.80k
    }
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.74k
    fn poll_completion_events(&mut self) -> Vec<Event> {
446
1.74k
        let mut events = Vec::new();
447
448
1.74k
        if self.reader_done
  Branch (448:12): [True: 1.74k, 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
1
                .parse::<u16>()
388
1
                .unwrap_or(0);
389
1
            left_val.saturating_add(right_val)
390
        } else {
391
5
            substituted
392
14
                .
trim_matches5
(|x: char| !x.is_numeric())
393
5
                .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.87k
    fn take_focus_event(&mut self) -> Option<Event> {
424
2.87k
        let focused = self.item_list.selected().map(|m| m.item);
425
2.87k
        let changed = match (&self.last_focused, &focused) {
426
1.88k
            (Some(prev), Some(curr)) => !Arc::ptr_eq(prev, curr),
427
615
            (None, None) => false,
428
376
            _ => true,
429
        };
430
2.87k
        if changed {
  Branch (430:12): [True: 451, False: 2.36k]
+
  Branch (430:12): [True: 44, False: 14]
+
431
495
            self.last_focused = focused;
432
495
            Some(Event::Key(SkimEvent::Focus.into()))
433
        } else {
434
2.38k
            None
435
        }
436
2.87k
    }
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.77k, False: 0]
 
  Branch (448:12): [True: 4, False: 2]
-
449
1.74k
            && !self.load_event_fired
  Branch (449:16): [True: 406, False: 1.33k]
+
449
1.77k
            && !self.load_event_fired
  Branch (449:16): [True: 437, False: 1.33k]
 
  Branch (449:16): [True: 3, False: 1]
-
450
409
            && self.matcher_control.stopped()
  Branch (450:16): [True: 406, False: 0]
+
450
440
            && self.matcher_control.stopped()
  Branch (450:16): [True: 436, False: 1]
 
  Branch (450:16): [True: 3, False: 0]
-
451
409
            && self.item_pool.num_not_taken() == 0
  Branch (451:16): [True: 388, False: 18]
+
451
439
            && self.item_pool.num_not_taken() == 0
  Branch (451:16): [True: 389, False: 47]
 
  Branch (451:16): [True: 2, False: 1]
-
452
390
        {
453
390
            self.load_event_fired = true;
454
390
            events.push(Event::Key(SkimEvent::Load.into()));
455
1.35k
        }
456
457
1.74k
        if self.result_pending && 
self.matcher_control727
.
stopped727
() {
  Branch (457:12): [True: 724, False: 1.01k]
-  Branch (457:35): [True: 723, False: 1]
+
452
391
        {
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_control747
.
stopped747
() {
  Branch (457:12): [True: 744, False: 1.02k]
+  Branch (457:35): [True: 742, False: 2]
 
  Branch (457:12): [True: 3, False: 3]
   Branch (457:35): [True: 2, False: 1]
-
458
725
            self.result_pending = false;
459
725
            events.push(Event::Key(SkimEvent::Result.into()));
460
725
            if self.reader_done {
  Branch (460:16): [True: 723, False: 0]
+
458
744
            self.result_pending = false;
459
744
            events.push(Event::Key(SkimEvent::Result.into()));
460
744
            if self.reader_done {
  Branch (460:16): [True: 742, False: 0]
 
  Branch (460:16): [True: 1, False: 1]
-
461
724
                match self.matcher_control.get_num_matched() {
462
156
                    0 => events.push(Event::Key(SkimEvent::Zero.into())),
463
221
                    1 => events.push(Event::Key(SkimEvent::One.into())),
464
347
                    _ => {}
465
                }
466
1
            }
467
1.02k
        }
468
469
1.74k
        events
470
1.74k
    }
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.cmd46
.
is_some46
() {
  Branch (475:12): [True: 45, False: 329]
+
461
743
                match self.matcher_control.get_num_matched() {
462
171
                    0 => events.push(Event::Key(SkimEvent::Zero.into())),
463
220
                    1 => events.push(Event::Key(SkimEvent::One.into())),
464
352
                    _ => {}
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
398
    fn on_query_changed(&mut self) -> Vec<Event> {
474
        // In interactive mode with --cmd, execute the command with {} substitution
475
398
        if self.options.interactive && 
self.options.cmd46
.
is_some46
() {
  Branch (475:12): [True: 45, False: 333]
   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.74k
    fn update_spinner(&mut self) {
487
1.74k
        let matcher_running = !self.matcher_control.stopped();
488
1.74k
        let time_since_match = self.matcher_timer.elapsed();
489
1.74k
        let reading = self.item_pool.num_not_taken() != 0;
490
491
1.74k
        let should_show_spinner = reading || (
matcher_running1.72k
&&
time_since_match.as_millis() > MATCHER_DEBOUNCE_MS0
);
  Branch (491:35): [True: 18, False: 1.72k]
-  Branch (491:47): [True: 0, False: 1.72k]
+
476
43
            let expanded_cmd = self.expand_cmd(&self.cmd, true);
477
43
            return vec![Event::Reload(expanded_cmd)];
478
355
        }
479
355
        self.restart_matcher_debounced();
480
355
        vec![
481
355
            Event::Key(crate::binds::SkimEvent::Change.into()), // fire the `change` event binding
482
355
            Event::RunPreview,
483
        ]
484
398
    }
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_MS1
);
  Branch (491:35): [True: 47, False: 1.72k]
+  Branch (491:47): [True: 1, False: 1.72k]
 
  Branch (491:35): [True: 2, False: 1]
   Branch (491:47): [True: 0, False: 1]
-
492
493
1.74k
        if should_show_spinner && 
!self.show_spinner20
{
  Branch (493:12): [True: 18, False: 1.72k]
-  Branch (493:35): [True: 6, False: 12]
+
492
493
1.77k
        if should_show_spinner && 
!self.show_spinner49
{
  Branch (493:12): [True: 47, False: 1.72k]
+  Branch (493:35): [True: 21, False: 26]
 
  Branch (493:12): [True: 2, False: 1]
   Branch (493:35): [True: 2, False: 0]
-
494
8
            self.toggle_spinner();
495
1.73k
        } else if !should_show_spinner && 
self.show_spinner1.72k
{
  Branch (495:19): [True: 1.72k, False: 12]
-  Branch (495:43): [True: 4, False: 1.72k]
+
494
23
            self.toggle_spinner();
495
1.75k
        } else if !should_show_spinner && 
self.show_spinner1.72k
{
  Branch (495:19): [True: 1.72k, False: 26]
+  Branch (495:43): [True: 9, 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
10
            if self.spinner_last_change.elapsed().as_millis() >= HIDE_GRACE_MS {
  Branch (497:16): [True: 0, False: 9]
 
  Branch (497:16): [True: 1, False: 0]
-
498
1
                self.toggle_spinner();
499
4
            }
500
1.73k
        }
501
1.74k
        if self.show_spinner {
  Branch (501:12): [True: 22, False: 1.72k]
+
498
1
                self.toggle_spinner();
499
9
            }
500
1.74k
        }
501
1.77k
        if self.show_spinner {
  Branch (501:12): [True: 56, False: 1.71k]
 
  Branch (501:12): [True: 2, False: 1]
-
502
24
            self.needs_render.store(true, Ordering::Relaxed);
503
1.72k
        }
504
1.74k
    }
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]
+
502
58
            self.needs_render.store(true, Ordering::Relaxed);
503
1.71k
        }
504
1.77k
    }
505
506
    #[allow(clippy::too_many_lines)]
507
1.38k
    fn run_preview<B: Backend>(&mut self, tui: &mut Tui<B>) -> Result<()>
508
1.38k
    where
509
1.38k
        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.38k
        let now = std::time::Instant::now();
514
1.38k
        let elapsed = now.duration_since(self.last_preview_spawn);
515
516
1.38k
        if elapsed.as_millis() < u128::from(DEBOUNCE_MS) {
  Branch (516:12): [True: 0, False: 10]
+
  Branch (516:12): [True: 2, False: 2]
+
  Branch (516:12): [True: 19, False: 48]
+
  Branch (516:12): [True: 171, False: 223]
+
  Branch (516:12): [True: 67, False: 56]
+
  Branch (516:12): [True: 6, False: 10]
+
  Branch (516:12): [True: 0, False: 9]
+
  Branch (516:12): [True: 0, False: 26]
+
  Branch (516:12): [True: 81, False: 89]
+
  Branch (516:12): [True: 49, False: 58]
+
  Branch (516:12): [True: 0, False: 5]
 
  Branch (516:12): [True: 29, False: 50]
 
  Branch (516:12): [True: 51, False: 61]
-
  Branch (516:12): [True: 81, False: 89]
-
  Branch (516:12): [True: 0, False: 5]
-
  Branch (516:12): [True: 0, False: 9]
-
  Branch (516:12): [True: 0, False: 22]
-
  Branch (516:12): [True: 67, False: 56]
-
  Branch (516:12): [True: 3, False: 2]
-
  Branch (516:12): [True: 0, False: 9]
-
  Branch (516:12): [True: 10, False: 12]
-
  Branch (516:12): [True: 6, False: 10]
 
  Branch (516:12): [True: 4, False: 9]
-
  Branch (516:12): [True: 44, False: 51]
 
  Branch (516:12): [True: 4, False: 9]
-
  Branch (516:12): [True: 171, False: 223]
-
  Branch (516:12): [True: 49, False: 58]
-
  Branch (516:12): [True: 19, False: 48]
 
  Branch (516:12): [True: 65, False: 41]
-
517
            // Mark that we have a pending preview to run after the debounce period
518
603
            self.pending_preview_run = true;
519
603
            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_opt74
) = &self.options.preview
  Branch (525:16): [True: 0, False: 10]
+
  Branch (516:12): [True: 44, False: 51]
+
  Branch (516:12): [True: 10, False: 12]
+
  Branch (516:12): [True: 0, False: 9]
+
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
778
        }
521
522
778
        self.pending_preview_run = false;
523
778
        self.last_preview_spawn = now;
524
525
778
        if let Some(
preview_opt74
) = &self.options.preview
  Branch (525:16): [True: 0, False: 10]
+
  Branch (525:16): [True: 2, False: 0]
+
  Branch (525:16): [True: 0, False: 48]
+
  Branch (525:16): [True: 5, False: 218]
+
  Branch (525:16): [True: 56, False: 0]
+
  Branch (525:16): [True: 0, False: 10]
+
  Branch (525:16): [True: 0, False: 9]
+
  Branch (525:16): [True: 0, False: 26]
+
  Branch (525:16): [True: 0, False: 89]
+
  Branch (525:16): [True: 0, False: 58]
+
  Branch (525:16): [True: 0, False: 5]
 
  Branch (525:16): [True: 0, False: 50]
 
  Branch (525:16): [True: 5, False: 56]
-
  Branch (525:16): [True: 0, False: 89]
-
  Branch (525:16): [True: 0, False: 5]
-
  Branch (525:16): [True: 0, False: 9]
-
  Branch (525:16): [True: 0, False: 22]
-
  Branch (525:16): [True: 56, False: 0]
-
  Branch (525:16): [True: 2, False: 0]
-
  Branch (525:16): [True: 0, False: 9]
-
  Branch (525:16): [True: 0, False: 12]
-
  Branch (525:16): [True: 0, False: 10]
 
  Branch (525:16): [True: 6, False: 3]
-
  Branch (525:16): [True: 0, False: 51]
 
  Branch (525:16): [True: 0, False: 9]
-
  Branch (525:16): [True: 5, False: 218]
-
  Branch (525:16): [True: 0, False: 58]
-
  Branch (525:16): [True: 0, False: 48]
 
  Branch (525:16): [True: 0, False: 41]
+
  Branch (525:16): [True: 0, False: 51]
+
  Branch (525:16): [True: 0, False: 12]
+
  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: 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]
-
  Branch (526:20): [True: 56, False: 0]
 
  Branch (526:20): [True: 2, 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: 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: 6, 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: 0, False: 0]
 
527
        {
528
74
            let selection: Vec<_> = self.item_list.selection.iter().map(|i| 
i.text()12
.
into_owned12
()).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
74
            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: 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]
-
  Branch (533:31): [True: 0, False: 56]
 
  Branch (533:31): [True: 0, False: 2]
 
  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: 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: 6]
 
  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: 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
74
                    .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
74
                    .map(|v| 
v.rank.index12
.
unsigned_abs12
() as usize)
550
74
                    .collect::<Vec<_>>(),
551
74
                selections: &selection_str,
552
            };
553
74
            let preview = item.preview(ctx);
554
74
            let preview_ready = !
matches!3
(
555
74
                preview,
556
                ItemPreview::Global | ItemPreview::Command(_) | ItemPreview::CommandWithPos(_, _)
557
            );
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
1
                ItemPreview::Text(t) | ItemPreview::AnsiText(
t0
) => {
565
1
                    self.preview.content(&t.bytes().collect::<Vec<_>>())
?0
;
566
                }
567
2
                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
1
                        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
1
                        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 = 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
1
                        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
0
                        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 = h_scroll.saturating_add(h_offset);
603
                }
604
2
                ItemPreview::TextWithPos(t, preview_position) | ItemPreview::AnsiWithPos(
t0
,
preview_position0
) => 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: 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]
-
  Branch (609:16): [True: 0, False: 56]
+
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
74
                    .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
74
                    .map(|v| 
v.rank.index12
.
unsigned_abs12
() as usize)
550
74
                    .collect::<Vec<_>>(),
551
74
                selections: &selection_str,
552
            };
553
74
            let preview = item.preview(ctx);
554
74
            let preview_ready = !
matches!3
(
555
74
                preview,
556
                ItemPreview::Global | ItemPreview::Command(_) | ItemPreview::CommandWithPos(_, _)
557
            );
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
1
                ItemPreview::Text(t) | ItemPreview::AnsiText(
t0
) => {
565
1
                    self.preview.content(&t.bytes().collect::<Vec<_>>())
?0
;
566
                }
567
2
                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
1
                        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
1
                        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
1
                        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
0
                        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(
t0
,
preview_position0
) => 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: 2]
 
  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: 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: 3, False: 3]
 
  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: 0, False: 0]
 
  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(
cb3
) = &self.options.preview_fn {
  Branch (612:23): [True: 0, False: 10]
+
610
3
                let _ = tui.event_tx.try_send(Event::PreviewReady);
611
71
            }
612
704
        } else if let Some(
cb3
) = &self.options.preview_fn {
  Branch (612:23): [True: 0, False: 10]
+
  Branch (612:23): [True: 0, False: 0]
+
  Branch (612:23): [True: 0, False: 48]
+
  Branch (612:23): [True: 0, False: 218]
+
  Branch (612:23): [True: 0, False: 0]
+
  Branch (612:23): [True: 0, False: 10]
+
  Branch (612:23): [True: 0, False: 9]
+
  Branch (612:23): [True: 0, False: 26]
+
  Branch (612:23): [True: 0, False: 89]
+
  Branch (612:23): [True: 0, False: 58]
+
  Branch (612:23): [True: 0, False: 5]
 
  Branch (612:23): [True: 0, False: 50]
 
  Branch (612:23): [True: 0, False: 56]
-
  Branch (612:23): [True: 0, False: 89]
-
  Branch (612:23): [True: 0, False: 5]
-
  Branch (612:23): [True: 0, False: 9]
-
  Branch (612:23): [True: 0, False: 22]
-
  Branch (612:23): [True: 0, False: 0]
-
  Branch (612:23): [True: 0, False: 0]
-
  Branch (612:23): [True: 0, False: 9]
-
  Branch (612:23): [True: 0, False: 12]
-
  Branch (612:23): [True: 0, False: 10]
 
  Branch (612:23): [True: 3, False: 0]
-
  Branch (612:23): [True: 0, False: 51]
 
  Branch (612:23): [True: 0, False: 9]
-
  Branch (612:23): [True: 0, False: 218]
-
  Branch (612:23): [True: 0, False: 58]
-
  Branch (612:23): [True: 0, False: 48]
 
  Branch (612:23): [True: 0, False: 41]
+
  Branch (612:23): [True: 0, False: 51]
+
  Branch (612:23): [True: 0, False: 12]
+
  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]
@@ -182,13 +182,13 @@
 
  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]
 
615
1
                selection = self.item_list.selection.iter().map(|i| i.item.clone()).collect();
616
2
            } else if let Some(
sel1
) = 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]
@@ -201,204 +201,205 @@
 
  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]
-
  Branch (616:27): [True: 0, False: 0]
-
617
1
                selection = vec![sel.item];
618
1
            } else {
619
1
                selection = Vec::new();
620
1
            }
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.63k
    pub fn handle_event<B: Backend>(&mut self, tui: &mut Tui<B>, event: &Event) -> Result<()>
633
8.63k
    where
634
8.63k
        B::Error: Send + Sync + 'static,
635
    {
636
8.63k
        trace!("handling event {event:?}");
637
8.63k
        match event {
638
            Event::Render => {
639
                // Always render to avoid freezing, but the render function itself can optimize
640
2.65k
                tui.get_frame();
641
2.65k
                tui.draw(|f| {
642
2.65k
                    f.render_widget(&mut *self, f.area());
643
2.65k
                    f.set_cursor_position(self.cursor_pos);
644
2.65k
                })
?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.65k
                if let Some(
event382
) = self.take_focus_event() {
  Branch (647:24): [True: 10, False: 11]
-
  Branch (647:24): [True: 33, False: 113]
-
  Branch (647:24): [True: 28, False: 181]
-
  Branch (647:24): [True: 18, False: 261]
-
  Branch (647:24): [True: 3, False: 20]
-
  Branch (647:24): [True: 9, False: 18]
-
  Branch (647:24): [True: 12, False: 71]
-
  Branch (647:24): [True: 34, False: 150]
+
617
1
                selection = vec![sel.item];
618
1
            } else {
619
1
                selection = Vec::new();
620
1
            }
621
3
            self.preview.content(&cb(selection).join("\n").into_bytes())
?0
;
622
701
        }
623
778
        Ok(())
624
1.38k
    }
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.80k
    pub fn handle_event<B: Backend>(&mut self, tui: &mut Tui<B>, event: &Event) -> Result<()>
633
8.80k
    where
634
8.80k
        B::Error: Send + Sync + 'static,
635
    {
636
8.80k
        trace!("handling event {event:?}");
637
8.80k
        match event {
638
            Event::Render => {
639
                // Always render to avoid freezing, but the render function itself can optimize
640
2.72k
                tui.get_frame();
641
2.72k
                tui.draw(|f| {
642
2.72k
                    f.render_widget(&mut *self, f.area());
643
2.72k
                    f.set_cursor_position(self.cursor_pos);
644
2.72k
                })
?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.72k
                if let Some(
event382
) = self.take_focus_event() {
  Branch (647:24): [True: 10, False: 20]
 
  Branch (647:24): [True: 1, False: 6]
-
  Branch (647:24): [True: 9, False: 21]
-
  Branch (647:24): [True: 15, False: 34]
+
  Branch (647:24): [True: 10, False: 224]
+
  Branch (647:24): [True: 137, False: 531]
+
  Branch (647:24): [True: 34, False: 161]
 
  Branch (647:24): [True: 5, False: 220]
-
  Branch (647:24): [True: 3, False: 1]
-
  Branch (647:24): [True: 16, False: 146]
+
  Branch (647:24): [True: 9, False: 18]
+
  Branch (647:24): [True: 13, False: 88]
+
  Branch (647:24): [True: 18, False: 264]
+
  Branch (647:24): [True: 10, False: 179]
+
  Branch (647:24): [True: 3, False: 20]
+
  Branch (647:24): [True: 33, False: 119]
+
  Branch (647:24): [True: 28, False: 183]
+
  Branch (647:24): [True: 2, False: 1]
 
  Branch (647:24): [True: 4, False: 28]
-
  Branch (647:24): [True: 137, False: 510]
-
  Branch (647:24): [True: 10, False: 173]
-
  Branch (647:24): [True: 10, False: 221]
 
  Branch (647:24): [True: 25, False: 83]
-
648
382
                    tui.event_tx.try_send(event)
?0
;
649
2.26k
                }
650
            }
651
            Event::Heartbeat | Event::Tick => {
652
                // Heartbeat is used for periodic UI updates
653
1.74k
                self.update_spinner();
654
1.74k
                if self.preview.is_loading() {
  Branch (654:20): [True: 0, False: 48]
-
  Branch (654:20): [True: 0, False: 112]
-
  Branch (654:20): [True: 0, False: 145]
-
  Branch (654:20): [True: 0, False: 120]
-
  Branch (654:20): [True: 0, False: 11]
-
  Branch (654:20): [True: 0, False: 18]
-
  Branch (654:20): [True: 0, False: 40]
-
  Branch (654:20): [True: 0, False: 142]
-
  Branch (654:20): [True: 0, False: 6]
-
  Branch (654:20): [True: 0, False: 20]
-
  Branch (654:20): [True: 0, False: 36]
+
  Branch (647:24): [True: 16, False: 146]
+
  Branch (647:24): [True: 15, False: 31]
+
  Branch (647:24): [True: 9, False: 18]
+
648
382
                    tui.event_tx.try_send(event)
?0
;
649
2.34k
                }
650
            }
651
            Event::Heartbeat | Event::Tick => {
652
                // Heartbeat is used for periodic UI updates
653
1.77k
                self.update_spinner();
654
1.77k
                if self.preview.is_loading() {
  Branch (654:20): [True: 0, False: 46]
+
  Branch (654:20): [True: 0, False: 5]
+
  Branch (654:20): [True: 0, False: 148]
+
  Branch (654:20): [True: 0, False: 507]
+
  Branch (654:20): [True: 5, False: 141]
 
  Branch (654:20): [True: 0, False: 133]
+
  Branch (654:20): [True: 0, False: 18]
+
  Branch (654:20): [True: 0, False: 48]
+
  Branch (654:20): [True: 0, False: 122]
+
  Branch (654:20): [True: 0, False: 80]
+
  Branch (654:20): [True: 0, False: 11]
+
  Branch (654:20): [True: 0, False: 116]
+
  Branch (654:20): [True: 2, False: 143]
 
  Branch (654:20): [True: 0, False: 1]
-
  Branch (654:20): [True: 0, False: 78]
 
  Branch (654:20): [True: 0, False: 17]
-
  Branch (654:20): [True: 0, False: 493]
-
  Branch (654:20): [True: 0, False: 76]
-
  Branch (654:20): [True: 0, False: 146]
 
  Branch (654:20): [True: 0, False: 101]
-
655
0
                    self.needs_render.store(true, Ordering::Relaxed);
656
1.74k
                }
657
658
1.74k
                if self.pending_matcher_restart {
  Branch (658:20): [True: 0, False: 48]
-
  Branch (658:20): [True: 0, False: 112]
-
  Branch (658:20): [True: 1, False: 144]
-
  Branch (658:20): [True: 0, False: 120]
-
  Branch (658:20): [True: 0, False: 11]
-
  Branch (658:20): [True: 0, False: 18]
-
  Branch (658:20): [True: 0, False: 40]
-
  Branch (658:20): [True: 0, False: 142]
-
  Branch (658:20): [True: 0, False: 6]
-
  Branch (658:20): [True: 0, False: 20]
-
  Branch (658:20): [True: 0, False: 36]
-
  Branch (658:20): [True: 0, False: 133]
-
  Branch (658:20): [True: 1, False: 0]
-
  Branch (658:20): [True: 0, False: 78]
-
  Branch (658:20): [True: 0, False: 17]
-
  Branch (658:20): [True: 0, False: 493]
-
  Branch (658:20): [True: 0, False: 76]
+
  Branch (654:20): [True: 0, False: 78]
+
  Branch (654:20): [True: 0, False: 34]
+
  Branch (654:20): [True: 0, False: 18]
+
655
7
                    self.needs_render.store(true, Ordering::Relaxed);
656
1.76k
                }
657
658
1.77k
                if self.pending_matcher_restart {
  Branch (658:20): [True: 0, False: 46]
+
  Branch (658:20): [True: 0, False: 5]
+
  Branch (658:20): [True: 0, False: 148]
+
  Branch (658:20): [True: 0, False: 507]
 
  Branch (658:20): [True: 0, False: 146]
+
  Branch (658:20): [True: 0, False: 133]
+
  Branch (658:20): [True: 0, False: 18]
+
  Branch (658:20): [True: 0, False: 48]
+
  Branch (658:20): [True: 0, False: 122]
+
  Branch (658:20): [True: 0, False: 80]
+
  Branch (658:20): [True: 0, False: 11]
+
  Branch (658:20): [True: 0, False: 116]
+
  Branch (658:20): [True: 1, False: 144]
+
  Branch (658:20): [True: 1, False: 0]
+
  Branch (658:20): [True: 0, False: 17]
 
  Branch (658:20): [True: 0, False: 101]
-
659
2
                    self.restart_matcher(true);
660
1.74k
                }
661
1.74k
                if self.needs_render.load(Ordering::Relaxed)
  Branch (661:20): [True: 2, False: 46]
-
  Branch (661:20): [True: 40, False: 72]
-
  Branch (661:20): [True: 55, False: 90]
-
  Branch (661:20): [True: 88, False: 32]
-
  Branch (661:20): [True: 7, False: 4]
-
  Branch (661:20): [True: 9, False: 9]
-
  Branch (661:20): [True: 25, False: 15]
-
  Branch (661:20): [True: 90, False: 52]
-
  Branch (661:20): [True: 4, False: 2]
-
  Branch (661:20): [True: 11, False: 9]
-
  Branch (661:20): [True: 15, False: 21]
+
  Branch (658:20): [True: 0, False: 78]
+
  Branch (658:20): [True: 0, False: 34]
+
  Branch (658:20): [True: 0, False: 18]
+
659
2
                    self.restart_matcher(true);
660
1.77k
                }
661
1.77k
                if self.needs_render.load(Ordering::Relaxed)
  Branch (661:20): [True: 21, False: 25]
+
  Branch (661:20): [True: 4, False: 1]
+
  Branch (661:20): [True: 60, False: 88]
+
  Branch (661:20): [True: 204, False: 303]
+
  Branch (661:20): [True: 99, False: 47]
 
  Branch (661:20): [True: 57, False: 76]
+
  Branch (661:20): [True: 9, False: 9]
+
  Branch (661:20): [True: 33, False: 15]
+
  Branch (661:20): [True: 90, False: 32]
+
  Branch (661:20): [True: 62, False: 18]
+
  Branch (661:20): [True: 7, False: 4]
+
  Branch (661:20): [True: 44, False: 72]
+
  Branch (661:20): [True: 57, False: 88]
 
  Branch (661:20): [True: 1, False: 0]
-
  Branch (661:20): [True: 50, False: 28]
 
  Branch (661:20): [True: 10, False: 7]
-
  Branch (661:20): [True: 190, False: 303]
-
  Branch (661:20): [True: 58, False: 18]
-
  Branch (661:20): [True: 58, False: 88]
 
  Branch (661:20): [True: 32, False: 69]
-
662
802
                    && self.last_render_timer.elapsed().as_millis() > 1000 / u128::from(TICK_RATE)
  Branch (662:24): [True: 1, False: 1]
-
  Branch (662:24): [True: 38, False: 2]
-
  Branch (662:24): [True: 54, False: 1]
-
  Branch (662:24): [True: 88, False: 0]
-
  Branch (662:24): [True: 7, False: 0]
-
  Branch (662:24): [True: 9, False: 0]
-
  Branch (662:24): [True: 25, False: 0]
-
  Branch (662:24): [True: 90, False: 0]
+
  Branch (661:20): [True: 50, False: 28]
+
  Branch (661:20): [True: 13, False: 21]
+
  Branch (661:20): [True: 9, False: 9]
+
662
862
                    && self.last_render_timer.elapsed().as_millis() > 1000 / u128::from(TICK_RATE)
  Branch (662:24): [True: 10, False: 11]
 
  Branch (662:24): [True: 4, False: 0]
-
  Branch (662:24): [True: 10, False: 1]
-
  Branch (662:24): [True: 14, False: 1]
+
  Branch (662:24): [True: 59, False: 1]
+
  Branch (662:24): [True: 191, False: 13]
+
  Branch (662:24): [True: 97, False: 2]
 
  Branch (662:24): [True: 57, False: 0]
+
  Branch (662:24): [True: 9, False: 0]
+
  Branch (662:24): [True: 31, False: 2]
+
  Branch (662:24): [True: 89, False: 1]
+
  Branch (662:24): [True: 60, False: 2]
+
  Branch (662:24): [True: 7, False: 0]
+
  Branch (662:24): [True: 40, False: 4]
+
  Branch (662:24): [True: 56, False: 1]
 
  Branch (662:24): [True: 1, False: 0]
-
  Branch (662:24): [True: 50, False: 0]
 
  Branch (662:24): [True: 10, False: 0]
-
  Branch (662:24): [True: 184, False: 6]
-
  Branch (662:24): [True: 58, False: 0]
-
  Branch (662:24): [True: 58, False: 0]
 
  Branch (662:24): [True: 32, False: 0]
-
663
                {
664
790
                    debug!("Triggering render");
665
790
                    self.needs_render.store(false, Ordering::Relaxed);
666
790
                    self.last_render_timer = std::time::Instant::now();
667
790
                    tui.event_tx.try_send(Event::Render)
?0
;
668
953
                }
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.74k
                let completion_events = self.poll_completion_events();
676
1.74k
                if !completion_events.is_empty() {
  Branch (676:20): [True: 10, False: 38]
-
  Branch (676:20): [True: 38, False: 74]
-
  Branch (676:20): [True: 49, False: 96]
-
  Branch (676:20): [True: 88, False: 32]
-
  Branch (676:20): [True: 7, False: 4]
-
  Branch (676:20): [True: 9, False: 9]
-
  Branch (676:20): [True: 25, False: 15]
-
  Branch (676:20): [True: 34, False: 108]
-
  Branch (676:20): [True: 1, False: 5]
-
  Branch (676:20): [True: 10, False: 10]
-
  Branch (676:20): [True: 14, False: 22]
+
  Branch (662:24): [True: 50, False: 0]
+
  Branch (662:24): [True: 13, False: 0]
+
  Branch (662:24): [True: 9, False: 0]
+
663
                {
664
825
                    debug!("Triggering render");
665
825
                    self.needs_render.store(false, Ordering::Relaxed);
666
825
                    self.last_render_timer = std::time::Instant::now();
667
825
                    tui.event_tx.try_send(Event::Render)
?0
;
668
949
                }
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.77k
                let completion_events = self.poll_completion_events();
676
1.77k
                if !completion_events.is_empty() {
  Branch (676:20): [True: 10, False: 36]
+
  Branch (676:20): [True: 1, False: 4]
+
  Branch (676:20): [True: 59, False: 89]
+
  Branch (676:20): [True: 180, False: 327]
+
  Branch (676:20): [True: 36, False: 110]
 
  Branch (676:20): [True: 57, False: 76]
+
  Branch (676:20): [True: 9, False: 9]
+
  Branch (676:20): [True: 31, False: 17]
+
  Branch (676:20): [True: 89, False: 33]
+
  Branch (676:20): [True: 60, False: 20]
+
  Branch (676:20): [True: 7, False: 4]
+
  Branch (676:20): [True: 40, False: 76]
+
  Branch (676:20): [True: 49, False: 96]
 
  Branch (676:20): [True: 0, False: 1]
-
  Branch (676:20): [True: 50, False: 28]
 
  Branch (676:20): [True: 10, False: 7]
-
  Branch (676:20): [True: 173, False: 320]
-
  Branch (676:20): [True: 58, False: 18]
-
  Branch (676:20): [True: 58, False: 88]
 
  Branch (676:20): [True: 32, False: 69]
-
677
723
                    tui.event_tx.try_send(Event::Render)
?0
;
678
1.48k
                    for evt in 
completion_events723
{
679
1.48k
                        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.74k
                if self.pending_preview_run
  Branch (684:20): [True: 10, False: 38]
-
  Branch (684:20): [True: 60, False: 52]
-
  Branch (684:20): [True: 67, False: 78]
-
  Branch (684:20): [True: 94, False: 26]
-
  Branch (684:20): [True: 0, False: 11]
-
  Branch (684:20): [True: 9, False: 9]
-
  Branch (684:20): [True: 4, False: 36]
-
  Branch (684:20): [True: 63, False: 79]
-
  Branch (684:20): [True: 3, False: 3]
-
  Branch (684:20): [True: 9, False: 11]
-
  Branch (684:20): [True: 19, False: 17]
+
  Branch (676:20): [True: 50, False: 28]
+
  Branch (676:20): [True: 13, False: 21]
+
  Branch (676:20): [True: 9, False: 9]
+
677
742
                    tui.event_tx.try_send(Event::Render)
?0
;
678
1.52k
                    for evt in 
completion_events742
{
679
1.52k
                        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.77k
                if self.pending_preview_run
  Branch (684:20): [True: 10, False: 36]
+
  Branch (684:20): [True: 2, False: 3]
+
  Branch (684:20): [True: 25, False: 123]
+
  Branch (684:20): [True: 299, False: 208]
+
  Branch (684:20): [True: 63, False: 83]
 
  Branch (684:20): [True: 10, False: 123]
+
  Branch (684:20): [True: 9, False: 9]
+
  Branch (684:20): [True: 4, False: 44]
+
  Branch (684:20): [True: 94, False: 28]
+
  Branch (684:20): [True: 58, False: 22]
+
  Branch (684:20): [True: 0, False: 11]
+
  Branch (684:20): [True: 60, False: 56]
+
  Branch (684:20): [True: 67, False: 78]
 
  Branch (684:20): [True: 0, False: 1]
-
  Branch (684:20): [True: 56, False: 22]
 
  Branch (684:20): [True: 8, False: 9]
-
  Branch (684:20): [True: 299, False: 194]
-
  Branch (684:20): [True: 58, False: 18]
-
  Branch (684:20): [True: 25, False: 121]
 
  Branch (684:20): [True: 86, False: 15]
-
685
880
                    && let Err(
e0
) = self.run_preview(tui)
  Branch (685:28): [True: 0, False: 10]
-
  Branch (685:28): [True: 0, False: 60]
-
  Branch (685:28): [True: 0, False: 67]
-
  Branch (685:28): [True: 0, False: 94]
-
  Branch (685:28): [True: 0, False: 0]
+
  Branch (684:20): [True: 56, False: 22]
+
  Branch (684:20): [True: 19, False: 15]
+
  Branch (684:20): [True: 9, False: 9]
+
685
879
                    && let Err(
e0
) = self.run_preview(tui)
  Branch (685:28): [True: 0, False: 10]
+
  Branch (685:28): [True: 0, False: 2]
+
  Branch (685:28): [True: 0, False: 25]
+
  Branch (685:28): [True: 0, False: 299]
+
  Branch (685:28): [True: 0, False: 63]
+
  Branch (685:28): [True: 0, False: 10]
 
  Branch (685:28): [True: 0, False: 9]
 
  Branch (685:28): [True: 0, False: 4]
-
  Branch (685:28): [True: 0, False: 63]
-
  Branch (685:28): [True: 0, False: 3]
-
  Branch (685:28): [True: 0, False: 9]
-
  Branch (685:28): [True: 0, False: 19]
-
  Branch (685:28): [True: 0, False: 10]
-
  Branch (685:28): [True: 0, False: 0]
-
  Branch (685:28): [True: 0, False: 56]
-
  Branch (685:28): [True: 0, False: 8]
-
  Branch (685:28): [True: 0, False: 299]
+
  Branch (685:28): [True: 0, False: 94]
 
  Branch (685:28): [True: 0, False: 58]
-
  Branch (685:28): [True: 0, False: 25]
+
  Branch (685:28): [True: 0, False: 0]
+
  Branch (685:28): [True: 0, False: 60]
+
  Branch (685:28): [True: 0, False: 67]
+
  Branch (685:28): [True: 0, False: 0]
+
  Branch (685:28): [True: 0, False: 8]
 
  Branch (685:28): [True: 0, False: 86]
-
686
                {
687
0
                    warn!("Heartbeat RunPreview: error {e:?}");
688
1.74k
                }
689
            }
690
            Event::RunPreview => {
691
485
                if let Err(
e0
) = self.run_preview(tui) {
  Branch (691:24): [True: 0, False: 0]
+
  Branch (685:28): [True: 0, False: 56]
+
  Branch (685:28): [True: 0, False: 19]
+
  Branch (685:28): [True: 0, False: 9]
+
686
                {
687
0
                    warn!("Heartbeat RunPreview: error {e:?}");
688
1.77k
                }
689
            }
690
            Event::RunPreview => {
691
489
                if let Err(
e0
) = self.run_preview(tui) {
  Branch (691:24): [True: 0, False: 0]
+
  Branch (691:24): [True: 0, False: 2]
+
  Branch (691:24): [True: 0, False: 42]
+
  Branch (691:24): [True: 0, False: 95]
+
  Branch (691:24): [True: 0, False: 60]
+
  Branch (691:24): [True: 0, False: 6]
+
  Branch (691:24): [True: 0, False: 0]
+
  Branch (691:24): [True: 0, False: 22]
+
  Branch (691:24): [True: 0, False: 76]
+
  Branch (691:24): [True: 0, False: 49]
+
  Branch (691:24): [True: 0, False: 5]
 
  Branch (691:24): [True: 0, False: 19]
 
  Branch (691:24): [True: 0, False: 45]
-
  Branch (691:24): [True: 0, False: 76]
-
  Branch (691:24): [True: 0, False: 5]
-
  Branch (691:24): [True: 0, False: 0]
-
  Branch (691:24): [True: 0, False: 18]
-
  Branch (691:24): [True: 0, False: 60]
-
  Branch (691:24): [True: 0, False: 2]
-
  Branch (691:24): [True: 0, False: 0]
-
  Branch (691:24): [True: 0, False: 3]
-
  Branch (691:24): [True: 0, False: 6]
 
  Branch (691:24): [True: 0, False: 1]
-
  Branch (691:24): [True: 0, False: 39]
 
  Branch (691:24): [True: 0, False: 5]
-
  Branch (691:24): [True: 0, False: 95]
-
  Branch (691:24): [True: 0, False: 49]
-
  Branch (691:24): [True: 0, False: 42]
 
  Branch (691:24): [True: 0, False: 20]
-
692
0
                    warn!("RunPreview: error {e:?}");
693
485
                }
694
            }
695
1
            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_expr6
) = &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: 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]
-
  Branch (722:24): [True: 6, False: 50]
+
  Branch (691:24): [True: 0, False: 39]
+
  Branch (691:24): [True: 0, False: 3]
+
  Branch (691:24): [True: 0, False: 0]
+
692
0
                    warn!("RunPreview: error {e:?}");
693
489
                }
694
            }
695
1
            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_expr6
) = &self.options.preview_window.offset {
  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: 5]
+
  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: 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]
 
  Branch (722:24): [True: 0, False: 1]
 
  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: 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
1
            Event::Error(msg) => {
729
1
                tui.exit()
?0
;
730
1
                bail!(msg.to_owned());
731
            }
732
539
            Event::Action(act) => {
733
539
                let events = self.handle_action(act)
?0
;
734
877
                for evt in 
events539
{
735
877
                    tui.event_tx.try_send(evt)
?0
;
736
                }
737
539
                tui.event_tx.try_send(Event::Render)
?0
;
738
            }
739
3.12k
            Event::Key(key) => {
740
3.12k
                let events = self.handle_key(key);
741
3.12k
                for 
evt525
in events {
742
525
                    tui.event_tx.try_send(evt)
?0
;
743
                }
744
            }
745
2
            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 
cleaned2
:
String2
=
text.chars()2
.
filter2
(|c| *c != '\n' &&
*c != '\r'8
).
collect2
();
  Branch (748:63): [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
1
            Event::Error(msg) => {
729
1
                tui.exit()
?0
;
730
1
                bail!(msg.to_owned());
731
            }
732
543
            Event::Action(act) => {
733
543
                let events = self.handle_action(act)
?0
;
734
885
                for evt in 
events543
{
735
885
                    tui.event_tx.try_send(evt)
?0
;
736
                }
737
543
                tui.event_tx.try_send(Event::Render)
?0
;
738
            }
739
3.18k
            Event::Key(key) => {
740
3.18k
                let events = self.handle_key(key);
741
3.18k
                for 
evt529
in events {
742
529
                    tui.event_tx.try_send(evt)
?0
;
743
                }
744
            }
745
2
            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 
cleaned2
:
String2
=
text.chars()2
.
filter2
(|c| *c != '\n' &&
*c != '\r'8
).
collect2
();
  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]
@@ -416,7 +417,6 @@
 
  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]
@@ -429,13 +429,13 @@
 
  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]
 
750
1
                    self.input.insert_str(&cleaned);
751
1
                    let events = self.on_query_changed();
752
2
                    for evt in 
events1
{
753
2
                        tui.event_tx.try_send(evt)
?0
;
754
                    }
755
1
                }
756
            }
757
2
            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]
@@ -448,13 +448,13 @@
 
  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]
 
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(
e0
) = 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]
@@ -467,28 +467,28 @@
 
  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]
-
  Branch (769:24): [True: 0, False: 0]
-
770
0
                    warn!("error while rerunnig preview after resize: {e}");
771
2
                }
772
            }
773
13
            Event::Mouse(mouse_event) => {
774
13
                let events = self.handle_mouse(*mouse_event)
?0
;
775
16
                for evt in 
events13
{
776
16
                    tui.event_tx.try_send(evt)
?0
;
777
                }
778
            }
779
            Event::InvalidInput => {
780
1
                warn!("Received invalid input");
781
            }
782
1
            Event::ClearItems => {
783
1
                self.item_pool.clear();
784
1
                self.restart_matcher(true);
785
1
            }
786
1
            Event::AppendItems(items) => {
787
1
                self.item_pool.append(items.to_owned());
788
1
                self.restart_matcher(false);
789
1
            }
790
            Event::Reload(_) => {
791
0
                unreachable!("Reload is handled by the TUI event loop in lib.rs")
792
            }
793
        }
794
795
8.63k
        Ok(())
796
8.63k
    }
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.13k
    fn handle_key(&mut self, key: &KeyEvent) -> Vec<Event> {
804
3.13k
        let normalized_key = KeyEvent::new(key.code, key.modifiers);
805
3.13k
        debug!("key event: {key:?}, normalized: {normalized_key:?}");
806
807
3.13k
        if let Some(
act183
) = &self.options.keymap.get(&normalized_key) {
  Branch (807:16): [True: 179, False: 2.94k]
+
770
0
                    warn!("error while rerunnig preview after resize: {e}");
771
2
                }
772
            }
773
13
            Event::Mouse(mouse_event) => {
774
13
                let events = self.handle_mouse(*mouse_event)
?0
;
775
16
                for evt in 
events13
{
776
16
                    tui.event_tx.try_send(evt)
?0
;
777
                }
778
            }
779
            Event::InvalidInput => {
780
1
                warn!("Received invalid input");
781
            }
782
1
            Event::ClearItems => {
783
1
                self.item_pool.clear();
784
1
                self.restart_matcher(true);
785
1
            }
786
1
            Event::AppendItems(items) => {
787
1
                self.item_pool.append(items.to_owned());
788
1
                self.restart_matcher(false);
789
1
            }
790
            Event::Reload(_) => {
791
0
                unreachable!("Reload is handled by the TUI event loop in lib.rs")
792
            }
793
        }
794
795
8.80k
        Ok(())
796
8.80k
    }
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.19k
    fn handle_key(&mut self, key: &KeyEvent) -> Vec<Event> {
804
3.19k
        let normalized_key = KeyEvent::new(key.code, key.modifiers);
805
3.19k
        debug!("key event: {key:?}, normalized: {normalized_key:?}");
806
807
3.19k
        if let Some(
act183
) = &self.options.keymap.get(&normalized_key) {
  Branch (807:16): [True: 179, False: 3.00k]
 
  Branch (807:16): [True: 4, False: 8]
-
808
183
            debug!("{act:?}");
809
195
            return 
act.iter()183
.
map183
(|a| Event::Action(a.clone())).
collect183
();
810
2.95k
        }
811
2.95k
        match key.modifiers {
812
            KeyModifiers::CONTROL => {
813
3
                if let Char('c') = key.code {
  Branch (813:24): [True: 0, False: 0]
+
808
183
            debug!("{act:?}");
809
195
            return 
act.iter()183
.
map183
(|a| Event::Action(a.clone())).
collect183
();
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.94k
                if let Char(
c333
) = key.code {
  Branch (818:24): [True: 331, False: 2.61k]
+
814
1
                    return vec![Event::Quit];
815
2
                }
816
            }
817
            KeyModifiers::NONE => {
818
3.00k
                if let Char(
c337
) = key.code {
  Branch (818:24): [True: 335, False: 2.66k]
 
  Branch (818:24): [True: 2, False: 0]
-
819
333
                    return vec![Event::Action(Action::AddChar(c))];
820
2.61k
                }
821
            }
822
            KeyModifiers::SHIFT => {
823
2
                if let Char(
c1
) = key.code {
  Branch (823:24): [True: 0, False: 0]
+
819
337
                    return vec![Event::Action(Action::AddChar(c))];
820
2.66k
                }
821
            }
822
            KeyModifiers::SHIFT => {
823
2
                if let Char(
c1
) = 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.61k
        vec![]
830
3.13k
    }
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
678
            .is_some_and(|chain| 
chain.iter()5
.
any5
(|a| matches!(
a5
, 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.19k
    }
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
682
    fn handle_action(&mut self, act: &Action) -> Result<Vec<Event>> {
838
682
        let follow = self.options.action_binds.get(act.name()).cloned();
839
682
        let suppress_default = follow
840
682
            .as_ref()
841
682
            .is_some_and(|chain| 
chain.iter()5
.
any5
(|a| matches!(
a5
, Action::Suppress)));
842
843
682
        let mut events = if suppress_default {
  Branch (843:29): [True: 1, False: 539]
 
  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(
chain5
) = follow {
  Branch (848:16): [True: 3, False: 533]
+
844
2
            Vec::new()
845
        } else {
846
680
            self.dispatch_action(act)
?0
847
        };
848
682
        if let Some(
chain5
) = follow {
  Branch (848:16): [True: 3, False: 537]
 
  Branch (848:16): [True: 2, False: 140]
-
849
8
            for 
action6
in
chain.iter()5
.
filter5
(|a| !
matches!6
(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(
chain10
) = condition.then_some(then).or(otherwise) else {
  Branch (857:13): [True: 1, False: 1]
+
849
8
            for 
action6
in
chain.iter()5
.
filter5
(|a| !
matches!6
(a, Action::Suppress)) {
850
6
                events.extend(self.dispatch_action(action)
?0
);
851
            }
852
677
        }
853
682
        Ok(events)
854
682
    }
855
856
14
    fn dispatch_conditional(&mut self, condition: bool, then: &str, otherwise: Option<&str>) -> Result<Vec<Event>> {
857
14
        let Some(
chain10
) = 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 
actions9
= match crate::binds::parse_action_chain(chain) {
865
9
            Ok(actions) => actions,
866
1
            Err(err) => {
867
1
                warn!("Ignoring conditional action chain `{chain}`: {err}");
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
2
        match act {
884
25
            Abort | Accept(_) => {
885
25
                self.should_quit = true;
886
25
                self.final_action = Some(act.clone());
887
25
            }
888
335
            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
6
            BackwardChar => {
911
6
                self.input.move_cursor(-1);
912
6
            }
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 
actions9
= match crate::binds::parse_action_chain(chain) {
865
9
            Ok(actions) => actions,
866
1
            Err(err) => {
867
1
                warn!("Ignoring conditional action chain `{chain}`: {err}");
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
695
    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
2
        match act {
884
25
            Abort | Accept(_) => {
885
25
                self.should_quit = true;
886
25
                self.final_action = Some(act.clone());
887
25
            }
888
339
            AddChar(c) => {
889
339
                self.input.insert(*c);
890
339
                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
6
            BackwardChar => {
911
6
                self.input.move_cursor(-1);
912
6
            }
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,74 +532,76 @@
 
  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
64
            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
2
            Custom(cb) => {
1341
2
                return cb.call(self).map_err(|e| 
eyre::eyre!0
("{}", e));
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
64
            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
2
            Custom(cb) => {
1341
2
                return cb.call(self).map_err(|e| 
eyre::eyre!0
("{}", e));
1342
            }
1343
        }
1344
119
        Ok(Vec::default())
1345
695
    }
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]
 
  Branch (1352:19): [True: 2, False: 6]
   Branch (1352:41): [True: 2, False: 0]
-
1353
2
            self.item_list.selection.clone().into_iter().collect()
1354
24
        } else if let Some(
sel20
) = self.item_list.selected() {
  Branch (1354:23): [True: 18, False: 0]
-
  Branch (1354:23): [True: 2, False: 4]
-
1355
20
            vec![sel]
1356
        } else {
1357
4
            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.35k
    pub fn query_below_min_length(&self) -> bool {
1368
1.35k
        self.options
1369
1.35k
            .min_query_length
1370
1.35k
            .is_some_and(|min| 
!self.options.disabled21
&&
self.input.value.chars()21
.count() < min)
  Branch (1370:32): [True: 13, False: 0]
+
1353
2
            self.item_list.selection.clone().into_iter().collect()
1354
24
        } else if let Some(
sel19
) = 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.40k
    pub fn query_below_min_length(&self) -> bool {
1368
1.40k
        self.options
1369
1.40k
            .min_query_length
1370
1.40k
            .is_some_and(|min| 
!self.options.disabled21
&&
self.input.value.chars()21
.count() < min)
  Branch (1370:32): [True: 13, False: 0]
 
  Branch (1370:32): [True: 8, False: 0]
-
1371
1.35k
    }
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.27k
    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.27k
        if self.query_below_min_length() {
  Branch (1381:12): [True: 10, False: 1.22k]
-
  Branch (1381:12): [True: 2, False: 38]
-
1382
            // Query is too short, clear items and don't run matcher
1383
12
            self.matcher_control.kill();
1384
12
            self.item_list.items.clear();
1385
12
            self.item_list.current = 0;
1386
12
            self.item_list.offset = 0;
1387
12
            return;
1388
1.25k
        }
1389
1390
1.25k
        let matcher_stopped = self.matcher_control.stopped();
1391
1.25k
        if force || (
matcher_stopped490
&&
self.item_pool.num_not_taken() > 0398
) {
  Branch (1391:12): [True: 746, False: 475]
-  Branch (1391:22): [True: 388, False: 87]
-  Branch (1391:41): [True: 52, False: 336]
-
  Branch (1391:12): [True: 23, False: 15]
-  Branch (1391:22): [True: 10, False: 5]
-  Branch (1391:41): [True: 1, False: 9]
-
1392
822
            trace!("restarting matcher, force={force}");
1393
            // Reset debounce timer on any restart to prevent interference
1394
822
            self.last_matcher_restart = std::time::Instant::now();
1395
822
            self.pending_matcher_restart = false;
1396
822
            self.matcher_control.kill();
1397
            // record matcher start time for statusline spinner/progress
1398
822
            self.matcher_timer = std::time::Instant::now();
1399
            // In interactive mode, use empty query so all items are shown
1400
            // The input contains the command to execute, not a filter query
1401
822
            let query = if self.options.disabled {
  Branch (1401:28): [True: 1, False: 797]
-
  Branch (1401:28): [True: 0, False: 24]
-
1402
1
                ""
1403
821
            } else if self.options.interactive {
  Branch (1403:23): [True: 83, False: 714]
-
  Branch (1403:23): [True: 2, False: 22]
-
1404
85
                &input::Input::default()
1405
            } else {
1406
736
                &self.input
1407
            };
1408
822
            let item_pool = self.item_pool.clone();
1409
822
            let thread_pool = &self.matcher_pool;
1410
822
            let no_sort = self.options.no_sort;
1411
1412
822
            if force {
  Branch (1412:16): [True: 746, False: 52]
-
  Branch (1412:16): [True: 23, False: 1]
-
1413
769
                self.item_pool.reset();
1414
769
            
}53
1415
1416
822
            let merge_strategy = if force {
  Branch (1416:37): [True: 746, False: 52]
-
  Branch (1416:37): [True: 23, False: 1]
-
1417
769
                MergeStrategy::Replace
1418
53
            } else if no_sort && 
self.options.tac2
{
  Branch (1418:23): [True: 2, False: 50]
-  Branch (1418:34): [True: 0, False: 2]
-
  Branch (1418:23): [True: 0, False: 1]
-  Branch (1418:34): [True: 0, False: 0]
-
1419
0
                MergeStrategy::Prepend
1420
53
            } else if no_sort {
  Branch (1420:23): [True: 2, False: 50]
-
  Branch (1420:23): [True: 0, False: 1]
-
1421
2
                MergeStrategy::Append
1422
            } else {
1423
51
                MergeStrategy::SortedMerge
1424
            };
1425
1426
822
            self.matcher_control = self.matcher.run(
1427
822
                query,
1428
822
                &item_pool,
1429
822
                thread_pool,
1430
822
                self.item_list.processed_items.clone(),
1431
822
                merge_strategy,
1432
822
                no_sort,
1433
822
                self.options.tac,
1434
822
                self.needs_render.clone(),
1435
            );
1436
            // A new search is in flight; arm the `result`/`zero`/`one` events to
1437
            // fire once it completes and its results are rendered.
1438
822
            self.result_pending = true;
1439
437
        }
1440
1.27k
    }
1441
1442
8
    fn yank(&mut self, contents: String) {
1443
8
        self.yank_register = contents;
1444
8
    }
1445
1446
    /// Expand placeholders in a command string with current app state.
1447
    /// Replaces {}, {q}, {cq}, {n}, {+}, {+n}, and field patterns.
1448
    ///
1449
    /// Note: in command mode, the replstr is replaced by the current query
1450
    #[must_use]
1451
164
    pub fn expand_cmd(&self, cmd: &str, quote_args: bool) -> String {
1452
164
        util::printf(
1453
164
            cmd,
1454
164
            &self.options.delimiter,
1455
164
            &self.options.replstr,
1456
164
            &self.item_list.selection.iter(),
1457
164
            &self.item_list.selected(),
1458
164
            &self.input.value,
1459
164
            &self.input.value,
1460
164
            quote_args,
1461
        )
1462
164
    }
1463
1464
    /// Restart matcher with debouncing to avoid excessive restarts during rapid typing
1465
353
    fn restart_matcher_debounced(&mut self) {
1466
        const DEBOUNCE_MS: u64 = 10;
1467
1468
353
        if self.options.disabled {
  Branch (1468:12): [True: 1, False: 331]
-
  Branch (1468:12): [True: 0, False: 21]
-
1469
1
            return;
1470
352
        }
1471
1472
        // If enough time has passed since last restart, restart immediately
1473
352
        if self.last_matcher_restart.elapsed().as_millis() > u128::from(DEBOUNCE_MS) {
  Branch (1473:12): [True: 330, False: 1]
-
  Branch (1473:12): [True: 0, False: 21]
-
1474
330
            debug!("restart_matcher_debounced: true");
1475
330
            self.restart_matcher(true);
1476
        } else {
1477
22
            debug!("restart_matcher_debounced: false");
1478
22
            self.pending_matcher_restart = true;
1479
        }
1480
353
    }
1481
1482
    /// Returns the border-adjusted inner rect of the list area.
1483
36
    fn list_inner_area(&self) -> ratatui::layout::Rect {
1484
36
        let list_area = self.layout.list_area;
1485
36
        if self.options.border.is_some() {
  Branch (1485:12): [True: 0, False: 2]
-
  Branch (1485:12): [True: 12, False: 22]
-
1486
12
            ratatui::layout::Rect {
1487
12
                x: list_area.x + 1,
1488
12
                y: list_area.y + 1,
1489
12
                width: list_area.width.saturating_sub(2),
1490
12
                height: list_area.height.saturating_sub(2),
1491
12
            }
1492
        } else {
1493
24
            list_area
1494
        }
1495
36
    }
1496
1497
    /// Returns the inner rect of the list area and the x column of the scrollbar, but only
1498
    /// when the scrollbar is actually rendered (config enabled and items overflow the area).
1499
14
    fn scrollbar_column(&self) -> Option<(ratatui::layout::Rect, u16)> {
1500
14
        let inner = self.list_inner_area();
1501
14
        let available_rows = inner.height as usize;
1502
14
        if inner.width == 0 || self.item_list.scrollbar_thumb.is_empty() || 
self.item_list.items7
.len() <= available_rows
  Branch (1502:12): [True: 0, False: 1]
-  Branch (1502:32): [True: 0, False: 1]
-  Branch (1502:77): [True: 1, False: 0]
-
  Branch (1502:12): [True: 0, False: 13]
-  Branch (1502:32): [True: 7, False: 6]
-  Branch (1502:77): [True: 0, False: 6]
-
1503
        {
1504
8
            return None;
1505
6
        }
1506
6
        Some((inner, inner.x + inner.width - 1))
1507
14
    }
1508
1509
    /// Scroll based on a mouse position
1510
6
    fn scroll(&mut self, mouse_pos: ratatui::layout::Position) {
1511
6
        let inner = self.list_inner_area();
1512
6
        let track_height = inner.height as usize;
1513
6
        let total = self.item_list.items.len();
1514
6
        let click_row = usize::from(mouse_pos.y).saturating_sub(inner.y.into());
1515
6
        self.item_list.current = click_row * total / track_height;
1516
6
    }
1517
1518
    /// Handle mouse events
1519
18
    fn handle_mouse(&mut self, mouse_event: MouseEvent) -> Result<Vec<Event>> {
1520
18
        let mouse_pos = ratatui::layout::Position {
1521
18
            x: mouse_event.column,
1522
18
            y: mouse_event.row,
1523
18
        };
1524
18
        trace!("Got mouse event {mouse_event:?}");
1525
1526
18
        let old_current = self.item_list.current;
1527
18
        let mut double_click = false;
1528
1529
10
        match mouse_event.kind {
1530
            MouseEventKind::ScrollUp => {
1531
                // Check if mouse is over preview area
1532
2
                if let Some(
preview_area1
) = self.layout.preview_area
  Branch (1532:24): [True: 0, False: 0]
-
  Branch (1532:24): [True: 1, False: 1]
-
1533
1
                    && preview_area.contains(mouse_pos)
  Branch (1533:24): [True: 0, False: 0]
-
  Branch (1533:24): [True: 1, False: 0]
-
1534
                {
1535
                    // Scroll preview up
1536
1
                    return self.handle_action(&Action::PreviewUp(3));
1537
1
                }
1538
                // Otherwise scroll item list up
1539
1
                return self.handle_action(&Action::Up(1));
1540
            }
1541
            MouseEventKind::ScrollDown => {
1542
                // Check if mouse is over preview area
1543
2
                if let Some(
preview_area1
) = self.layout.preview_area
  Branch (1543:24): [True: 0, False: 0]
-
  Branch (1543:24): [True: 1, False: 1]
-
1544
1
                    && preview_area.contains(mouse_pos)
  Branch (1544:24): [True: 0, False: 0]
-
  Branch (1544:24): [True: 1, False: 0]
-
1545
                {
1546
                    // Scroll preview down
1547
1
                    return self.handle_action(&Action::PreviewDown(3));
1548
1
                }
1549
                // Otherwise scroll item list down
1550
1
                return self.handle_action(&Action::Down(1));
1551
            }
1552
            MouseEventKind::Down(MouseButton::Left) => {
1553
10
                let now = std::time::Instant::now();
1554
10
                double_click = now.duration_since(self.last_left_click) <= DOUBLE_CLICK_INTERVAL;
1555
10
                self.last_left_click = now;
1556
1557
10
                if let Some((
inner3
,
scrollbar_col3
)) = self.scrollbar_column()
  Branch (1557:24): [True: 0, False: 1]
-
  Branch (1557:24): [True: 3, False: 6]
-
1558
3
                    && mouse_pos.x == scrollbar_col
  Branch (1558:24): [True: 0, False: 0]
-
  Branch (1558:24): [True: 2, False: 1]
-
1559
2
                    && inner.contains(mouse_pos)
  Branch (1559:24): [True: 0, False: 0]
-
  Branch (1559:24): [True: 2, False: 0]
-
1560
2
                {
1561
2
                    // Click landed on the scrollbar — start a scrub session.
1562
2
                    self.scroll(mouse_pos);
1563
2
                    self.currently_scrolling = true;
1564
2
                } else {
1565
                    // Click landed on the item list — move the cursor to that item.
1566
8
                    self.currently_scrolling = false;
1567
8
                    let inner = self.list_inner_area();
1568
8
                    if inner.contains(mouse_pos) {
  Branch (1568:24): [True: 1, False: 0]
-
  Branch (1568:24): [True: 7, False: 0]
-
1569
8
                        let row = (mouse_pos.y - inner.y) as usize;
1570
8
                        if let Some(idx) = self.item_list.item_at_visual_row(row) {
  Branch (1570:32): [True: 1, False: 0]
-
  Branch (1570:32): [True: 7, False: 0]
-
1571
8
                            self.item_list.current = idx;
1572
8
                        
}0
1573
0
                    }
1574
                }
1575
            }
1576
            MouseEventKind::Drag(MouseButton::Left) => {
1577
2
                if self.currently_scrolling {
  Branch (1577:20): [True: 0, False: 0]
-
  Branch (1577:20): [True: 2, False: 0]
-
1578
2
                    self.scroll(mouse_pos);
1579
2
                
}0
1580
            }
1581
            MouseEventKind::Up(MouseButton::Left) => {
1582
1
                if self.currently_scrolling {
  Branch (1582:20): [True: 0, False: 0]
-
  Branch (1582:20): [True: 1, False: 0]
-
1583
1
                    self.currently_scrolling = false;
1584
1
                
}0
1585
            }
1586
1
            _ => {
1587
1
                // Ignore other mouse events for now
1588
1
            }
1589
        }
1590
1591
14
        self.needs_render();
1592
1593
14
        let mut events = if self.item_list.current == old_current {
  Branch (1593:29): [True: 0, False: 1]
-
  Branch (1593:29): [True: 6, False: 7]
-
1594
6
            Vec::new()
1595
        } else {
1596
8
            self.on_selection_changed()
1597
        };
1598
14
        if double_click {
  Branch (1598:12): [True: 0, False: 1]
-
  Branch (1598:12): [True: 1, False: 12]
-
1599
1
            events.push(Event::Key(SkimEvent::DoubleClick.into()));
1600
13
        }
1601
14
        Ok(events)
1602
18
    }
1603
10
    fn toggle_spinner(&mut self) {
1604
10
        self.show_spinner = !self.show_spinner;
1605
10
        self.spinner_last_change = std::time::Instant::now();
1606
10
        self.needs_render.store(true, Ordering::Relaxed);
1607
10
    }
1608
}
\ No newline at end of file +
1371
1.40k
    }
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.32k
    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.32k
        if self.query_below_min_length() {
  Branch (1381:12): [True: 10, False: 1.26k]
+
  Branch (1381:12): [True: 2, False: 40]
+
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_stopped520
&&
self.item_pool.num_not_taken() > 0442
) {
  Branch (1397:12): [True: 765, False: 503]
+  Branch (1397:22): [True: 428, False: 75]
+  Branch (1397:41): [True: 299, False: 129]
+
  Branch (1397:12): [True: 23, False: 17]
+  Branch (1397:22): [True: 14, False: 3]
+  Branch (1397:41): [True: 9, False: 5]
+
1398
1.09k
            trace!("restarting matcher, force={force}");
1399
            // Reset debounce timer on any restart to prevent interference
1400
1.09k
            self.last_matcher_restart = std::time::Instant::now();
1401
1.09k
            self.pending_matcher_restart = false;
1402
1.09k
            self.matcher_control.kill();
1403
            // record matcher start time for statusline spinner/progress
1404
1.09k
            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.09k
            let query = if self.options.disabled {
  Branch (1407:28): [True: 2, False: 1.06k]
+
  Branch (1407:28): [True: 0, False: 32]
+
1408
2
                ""
1409
1.09k
            } else if self.options.interactive {
  Branch (1409:23): [True: 84, False: 978]
+
  Branch (1409:23): [True: 2, False: 30]
+
1410
86
                &input::Input::default()
1411
            } else {
1412
1.00k
                &self.input
1413
            };
1414
1.09k
            let item_pool = self.item_pool.clone();
1415
1.09k
            let thread_pool = &self.matcher_pool;
1416
1.09k
            let no_sort = self.options.no_sort;
1417
1418
1.09k
            if force {
  Branch (1418:16): [True: 765, False: 299]
+
  Branch (1418:16): [True: 23, False: 9]
+
1419
788
                self.item_pool.reset();
1420
788
            
}308
1421
1422
1.09k
            let generation = if force {
  Branch (1422:33): [True: 765, False: 299]
+
  Branch (1422:33): [True: 23, False: 9]
+
1423
788
                self.item_list.matcher_generation.fetch_add(1, Ordering::AcqRel) + 1
1424
            } else {
1425
308
                self.item_list.matcher_generation.load(Ordering::Acquire)
1426
            };
1427
1428
1.09k
            let merge_strategy = if force {
  Branch (1428:37): [True: 765, False: 299]
+
  Branch (1428:37): [True: 23, False: 9]
+
1429
788
                MergeStrategy::Replace
1430
308
            } else if no_sort && 
self.options.tac5
{
  Branch (1430:23): [True: 5, False: 294]
+  Branch (1430:34): [True: 0, False: 5]
+
  Branch (1430:23): [True: 0, False: 9]
+  Branch (1430:34): [True: 0, False: 0]
+
1431
0
                MergeStrategy::Prepend
1432
308
            } else if no_sort {
  Branch (1432:23): [True: 5, False: 294]
+
  Branch (1432:23): [True: 0, False: 9]
+
1433
5
                MergeStrategy::Append
1434
            } else {
1435
303
                MergeStrategy::SortedMerge
1436
            };
1437
1438
1.09k
            self.matcher_control = self.matcher.run(
1439
1.09k
                query,
1440
1.09k
                &item_pool,
1441
1.09k
                thread_pool,
1442
1.09k
                self.item_list.processed_items.clone(),
1443
1.09k
                merge_strategy,
1444
1.09k
                no_sort,
1445
1.09k
                self.options.tac,
1446
1.09k
                generation,
1447
1.09k
                self.item_list.matcher_generation.clone(),
1448
1.09k
                self.needs_render.clone(),
1449
            );
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.09k
            self.result_pending = true;
1453
212
        }
1454
1.32k
    }
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
        )
1476
165
    }
1477
1478
    /// Restart matcher with debouncing to avoid excessive restarts during rapid typing
1479
357
    fn restart_matcher_debounced(&mut self) {
1480
        const DEBOUNCE_MS: u64 = 10;
1481
1482
357
        if self.options.disabled {
  Branch (1482:12): [True: 1, False: 335]
+
  Branch (1482:12): [True: 0, False: 21]
+
1483
1
            return;
1484
356
        }
1485
1486
        // If enough time has passed since last restart, restart immediately
1487
356
        if self.last_matcher_restart.elapsed().as_millis() > u128::from(DEBOUNCE_MS) {
  Branch (1487:12): [True: 334, False: 1]
+
  Branch (1487:12): [True: 0, False: 21]
+
1488
334
            debug!("restart_matcher_debounced: true");
1489
334
            self.restart_matcher(true);
1490
        } else {
1491
22
            debug!("restart_matcher_debounced: false");
1492
22
            self.pending_matcher_restart = true;
1493
        }
1494
357
    }
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.items7
.len() <= available_rows
  Branch (1516:12): [True: 0, False: 1]
+  Branch (1516:32): [True: 0, False: 1]
+  Branch (1516:77): [True: 1, False: 0]
+
  Branch (1516:12): [True: 0, False: 13]
+  Branch (1516:32): [True: 7, False: 6]
+  Branch (1516:77): [True: 0, False: 6]
+
1517
        {
1518
8
            return None;
1519
6
        }
1520
6
        Some((inner, inner.x + inner.width - 1))
1521
14
    }
1522
1523
    /// Scroll based on a mouse position
1524
6
    fn scroll(&mut self, mouse_pos: ratatui::layout::Position) {
1525
6
        let inner = self.list_inner_area();
1526
6
        let track_height = inner.height as usize;
1527
6
        let total = self.item_list.items.len();
1528
6
        let click_row = usize::from(mouse_pos.y).saturating_sub(inner.y.into());
1529
6
        self.item_list.current = click_row * total / track_height;
1530
6
    }
1531
1532
    /// Handle mouse events
1533
18
    fn handle_mouse(&mut self, mouse_event: MouseEvent) -> Result<Vec<Event>> {
1534
18
        let mouse_pos = ratatui::layout::Position {
1535
18
            x: mouse_event.column,
1536
18
            y: mouse_event.row,
1537
18
        };
1538
18
        trace!("Got mouse event {mouse_event:?}");
1539
1540
18
        let old_current = self.item_list.current;
1541
18
        let mut double_click = false;
1542
1543
10
        match mouse_event.kind {
1544
            MouseEventKind::ScrollUp => {
1545
                // Check if mouse is over preview area
1546
2
                if let Some(
preview_area1
) = self.layout.preview_area
  Branch (1546:24): [True: 0, False: 0]
+
  Branch (1546:24): [True: 1, False: 1]
+
1547
1
                    && preview_area.contains(mouse_pos)
  Branch (1547:24): [True: 0, False: 0]
+
  Branch (1547:24): [True: 1, False: 0]
+
1548
                {
1549
                    // Scroll preview up
1550
1
                    return self.handle_action(&Action::PreviewUp(3));
1551
1
                }
1552
                // Otherwise scroll item list up
1553
1
                return self.handle_action(&Action::Up(1));
1554
            }
1555
            MouseEventKind::ScrollDown => {
1556
                // Check if mouse is over preview area
1557
2
                if let Some(
preview_area1
) = self.layout.preview_area
  Branch (1557:24): [True: 0, False: 0]
+
  Branch (1557:24): [True: 1, False: 1]
+
1558
1
                    && preview_area.contains(mouse_pos)
  Branch (1558:24): [True: 0, False: 0]
+
  Branch (1558:24): [True: 1, False: 0]
+
1559
                {
1560
                    // Scroll preview down
1561
1
                    return self.handle_action(&Action::PreviewDown(3));
1562
1
                }
1563
                // Otherwise scroll item list down
1564
1
                return self.handle_action(&Action::Down(1));
1565
            }
1566
            MouseEventKind::Down(MouseButton::Left) => {
1567
10
                let now = std::time::Instant::now();
1568
10
                double_click = now.duration_since(self.last_left_click) <= DOUBLE_CLICK_INTERVAL;
1569
10
                self.last_left_click = now;
1570
1571
10
                if let Some((
inner3
,
scrollbar_col3
)) = self.scrollbar_column()
  Branch (1571:24): [True: 0, False: 1]
+
  Branch (1571:24): [True: 3, False: 6]
+
1572
3
                    && mouse_pos.x == scrollbar_col
  Branch (1572:24): [True: 0, False: 0]
+
  Branch (1572:24): [True: 2, False: 1]
+
1573
2
                    && inner.contains(mouse_pos)
  Branch (1573:24): [True: 0, False: 0]
+
  Branch (1573:24): [True: 2, False: 0]
+
1574
2
                {
1575
2
                    // Click landed on the scrollbar — start a scrub session.
1576
2
                    self.scroll(mouse_pos);
1577
2
                    self.currently_scrolling = true;
1578
2
                } else {
1579
                    // Click landed on the item list — move the cursor to that item.
1580
8
                    self.currently_scrolling = false;
1581
8
                    let inner = self.list_inner_area();
1582
8
                    if inner.contains(mouse_pos) {
  Branch (1582:24): [True: 1, False: 0]
+
  Branch (1582:24): [True: 7, False: 0]
+
1583
8
                        let row = (mouse_pos.y - inner.y) as usize;
1584
8
                        if let Some(idx) = self.item_list.item_at_visual_row(row) {
  Branch (1584:32): [True: 1, False: 0]
+
  Branch (1584:32): [True: 7, False: 0]
+
1585
8
                            self.item_list.current = idx;
1586
8
                        
}0
1587
0
                    }
1588
                }
1589
            }
1590
            MouseEventKind::Drag(MouseButton::Left) => {
1591
2
                if self.currently_scrolling {
  Branch (1591:20): [True: 0, False: 0]
+
  Branch (1591:20): [True: 2, False: 0]
+
1592
2
                    self.scroll(mouse_pos);
1593
2
                
}0
1594
            }
1595
            MouseEventKind::Up(MouseButton::Left) => {
1596
1
                if self.currently_scrolling {
  Branch (1596:20): [True: 0, False: 0]
+
  Branch (1596:20): [True: 1, False: 0]
+
1597
1
                    self.currently_scrolling = false;
1598
1
                
}0
1599
            }
1600
1
            _ => {
1601
1
                // Ignore other mouse events for now
1602
1
            }
1603
        }
1604
1605
14
        self.needs_render();
1606
1607
14
        let mut events = if self.item_list.current == old_current {
  Branch (1607:29): [True: 0, False: 1]
+
  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
25
    fn toggle_spinner(&mut self) {
1618
25
        self.show_spinner = !self.show_spinner;
1619
25
        self.spinner_last_change = std::time::Instant::now();
1620
25
        self.needs_render.store(true, Ordering::Relaxed);
1621
25
    }
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 7a31bc28..1bd7d3fd 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,22 +1,22 @@ -

Coverage Report

Created: 2026-09-04 10: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
407
    pub fn new_with_height_and_backend(backend: B, height: Size) -> Result<Self> {
82
407
        let event_channel = channel(1024 * 1024);
83
84
407
        let term_height = backend.size().expect("Failed to get terminal height").height;
85
407
        let lines = match height {
86
400
            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
407
        let viewport = if let Some(
mut height7
) = lines {
  Branch (93:31): [True: 7, False: 3]
-
  Branch (93:31): [True: 0, False: 34]
-
  Branch (93:31): [True: 0, False: 26]
-
  Branch (93:31): [True: 0, False: 12]
-
  Branch (93:31): [True: 0, False: 2]
-
  Branch (93:31): [True: 0, False: 9]
-
  Branch (93:31): [True: 0, False: 7]
-
  Branch (93:31): [True: 0, False: 34]
+

Coverage Report

Created: 2026-09-05 18:50

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 height7
) = lines {
  Branch (93:31): [True: 7, False: 3]
 
  Branch (93:31): [True: 0, False: 1]
-
  Branch (93:31): [True: 0, False: 10]
-
  Branch (93:31): [True: 0, False: 5]
+
  Branch (93:31): [True: 0, False: 23]
+
  Branch (93:31): [True: 0, False: 128]
+
  Branch (93:31): [True: 0, False: 36]
 
  Branch (93:31): [True: 0, False: 22]
-
  Branch (93:31): [True: 0, False: 55]
-
  Branch (93:31): [True: 0, False: 11]
-
  Branch (93:31): [True: 0, False: 5]
-
  Branch (93:31): [True: 0, False: 121]
 
  Branch (93:31): [True: 0, False: 9]
-
  Branch (93:31): [True: 0, False: 22]
+
  Branch (93:31): [True: 0, False: 9]
+
  Branch (93:31): [True: 0, False: 13]
+
  Branch (93:31): [True: 0, False: 11]
+
  Branch (93:31): [True: 0, False: 2]
+
  Branch (93:31): [True: 0, False: 36]
+
  Branch (93:31): [True: 0, False: 26]
+
  Branch (93:31): [True: 0, False: 56]
+
  Branch (93:31): [True: 0, False: 5]
 
  Branch (93:31): [True: 0, False: 12]
+
  Branch (93:31): [True: 0, False: 11]
+
  Branch (93:31): [True: 0, False: 4]
+
  Branch (93:31): [True: 0, False: 9]
 
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]
@@ -36,26 +36,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
400
            rect = None;
112
400
            Viewport::Fullscreen
113
        };
114
115
407
        set_panic_hook();
116
        Ok(Self {
117
407
            terminal: ratatui::Terminal::with_options(backend, TerminalOptions { viewport })
?0
,
118
407
            task: None,
119
407
            rect,
120
407
            event_rx: event_channel.1,
121
407
            event_tx: event_channel.0,
122
407
            tick_rate: f64::from(TICK_RATE),
123
407
            cancellation_token: CancellationToken::default(),
124
407
            is_fullscreen: lines.is_none(),
125
            enable_mouse: true,
126
        })
127
407
    }
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
408
    pub fn exit(&mut self) -> Result<()> {
165
408
        self.stop();
166
408
        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
408
        if !self.is_fullscreen {
  Branch (172:12): [True: 7, False: 3]
-
  Branch (172:12): [True: 0, False: 34]
-
  Branch (172:12): [True: 0, False: 26]
-
  Branch (172:12): [True: 0, False: 12]
-
  Branch (172:12): [True: 0, False: 2]
-
  Branch (172:12): [True: 0, False: 9]
-
  Branch (172:12): [True: 0, False: 7]
-
  Branch (172:12): [True: 0, False: 34]
+
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
            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: 1]
-
  Branch (172:12): [True: 0, False: 10]
-
  Branch (172:12): [True: 0, False: 5]
+
  Branch (172:12): [True: 0, False: 23]
+
  Branch (172:12): [True: 0, False: 128]
+
  Branch (172:12): [True: 0, False: 36]
 
  Branch (172:12): [True: 0, False: 22]
-
  Branch (172:12): [True: 2, False: 54]
-
  Branch (172:12): [True: 0, False: 11]
-
  Branch (172:12): [True: 0, False: 5]
-
  Branch (172:12): [True: 0, False: 121]
 
  Branch (172:12): [True: 0, False: 9]
-
  Branch (172:12): [True: 0, False: 22]
+
  Branch (172:12): [True: 0, False: 9]
+
  Branch (172:12): [True: 0, False: 13]
+
  Branch (172:12): [True: 0, False: 11]
+
  Branch (172:12): [True: 0, False: 2]
+
  Branch (172:12): [True: 0, False: 36]
+
  Branch (172:12): [True: 0, False: 26]
+
  Branch (172:12): [True: 2, False: 55]
+
  Branch (172:12): [True: 0, False: 5]
 
  Branch (172:12): [True: 0, False: 12]
-
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
399
        }
182
408
        Ok(())
183
408
    }
184
    /// Stops the TUI event loop
185
    /// Equivalent to `self.cancel()`
186
409
    pub fn stop(&self) {
187
409
        self.cancel();
188
409
    }
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(
task0
) = self.task.take() {
  Branch (223:16): [True: 0, False: 0]
+
  Branch (172:12): [True: 0, False: 11]
+
  Branch (172:12): [True: 0, False: 4]
+
  Branch (172:12): [True: 0, False: 9]
+
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(
task0
) = 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]
 
  Branch (223:16): [True: 0, False: 0]
@@ -73,8 +74,8 @@
 
  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]
-
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
412
    pub fn cancel(&self) {
232
412
        self.cancellation_token.cancel();
233
412
    }
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]
+
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]
 
  Branch (239:12): [True: 0, False: 0]
@@ -92,8 +93,7 @@
 
  Branch (239:12): [True: 0, 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
65
                let tick_delay = tick_interval.tick();
254
65
                let crossterm_event = reader.next().fuse();
255
65
                tokio::select! {
256
65
                    () = cancellation_token_clone.cancelled() => {
257
1
                        break;
258
                    }
259
65
                    
maybe_event6
= crossterm_event => {
260
7
                      match maybe_event {
261
7
                        Some(Ok(crossterm::event::Event::Key(key))) => {
262
7
                          if key.kind == KeyEventKind::Press {
  Branch (262:30): [True: 7, 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
63
                let tick_delay = tick_interval.tick();
254
63
                let crossterm_event = reader.next().fuse();
255
63
                tokio::select! {
256
63
                    () = cancellation_token_clone.cancelled() => {
257
1
                        break;
258
                    }
259
63
                    
maybe_event6
= crossterm_event => {
260
7
                      match maybe_event {
261
7
                        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,7 +112,7 @@
 
  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
7
                          
}0
265
                        }
266
0
                        Some(Ok(crossterm::event::Event::Paste(text))) => {
267
0
                          _ = event_tx_clone.try_send(Event::Paste(text));
268
0
                        }
269
0
                        Some(Ok(crossterm::event::Event::Mouse(mouse))) => {
270
0
                          _ = event_tx_clone.try_send(Event::Mouse(mouse));
271
0
                        }
272
0
                        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
0
                        }
276
0
                        Some(Err(e)) => {
277
0
                          _ = event_tx_clone.try_send(Event::Error(e.to_string()));
278
0
                        }
279
18.4E
                        None | Some(Ok(_)) => {},
280
                      }
281
                    },
282
65
                    _ = tick_delay => {
283
48
                        _ = event_tx_clone.try_send(Event::Heartbeat);
284
48
                    },
285
                }
286
            }
287
1
        }));
288
11
    }
289
290
    /// Gets the next event from the event queue
291
147
    pub async fn next(&mut self) -> Option<Event> 
{139
292
139
        self.event_rx.recv().await
293
132
    }
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]
+
263
7
                            _ = event_tx_clone.try_send(Event::Key(key));
264
7
                          
}0
265
                        }
266
0
                        Some(Ok(crossterm::event::Event::Paste(text))) => {
267
0
                          _ = event_tx_clone.try_send(Event::Paste(text));
268
0
                        }
269
0
                        Some(Ok(crossterm::event::Event::Mouse(mouse))) => {
270
0
                          _ = event_tx_clone.try_send(Event::Mouse(mouse));
271
0
                        }
272
0
                        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
0
                        }
276
0
                        Some(Err(e)) => {
277
0
                          _ = event_tx_clone.try_send(Event::Error(e.to_string()));
278
0
                        }
279
18.4E
                        None | Some(Ok(_)) => {},
280
                      }
281
                    },
282
63
                    _ = tick_delay => {
283
46
                        _ = event_tx_clone.try_send(Event::Heartbeat);
284
46
                    },
285
                }
286
            }
287
1
        }));
288
11
    }
289
290
    /// Gets the next event from the event queue
291
162
    pub async fn next(&mut self) -> Option<Event> 
{153
292
153
        self.event_rx.recv().await
293
137
    }
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]
@@ -276,8 +276,8 @@
 
  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: 1]
 
  Branch (373:12): [True: 0, False: 0]
 
  Branch (373:12): [True: 0, False: 0]
 
  Branch (373:12): [True: 0, False: 0]
@@ -295,8 +295,8 @@
 
  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: 1]
 
  Branch (382:12): [True: 0, False: 0]
 
  Branch (382:12): [True: 0, False: 0]
 
  Branch (382:12): [True: 0, 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 {rect:?}");
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
606
    fn deref(&self) -> &Self::Target {
439
606
        &self.terminal
440
606
    }
441
}
442
443
impl<B: Backend> DerefMut for Tui<B>
444
where
445
    B::Error: Send + Sync + 'static,
446
{
447
5.32k
    fn deref_mut(&mut self) -> &mut Self::Target {
448
5.32k
        &mut self.terminal
449
5.32k
    }
450
}
451
452
impl<B: Backend> Drop for Tui<B>
453
where
454
    B::Error: Send + Sync + 'static,
455
{
456
407
    fn drop(&mut self) {
457
407
        if let Some(
t11
) = self.task.take() {
  Branch (457:16): [True: 10, False: 0]
-
  Branch (457:16): [True: 0, False: 34]
-
  Branch (457:16): [True: 0, False: 26]
-
  Branch (457:16): [True: 0, False: 12]
-
  Branch (457:16): [True: 0, False: 2]
-
  Branch (457:16): [True: 0, False: 9]
-
  Branch (457:16): [True: 0, False: 7]
-
  Branch (457:16): [True: 0, False: 34]
+
415
3
            crossterm::execute!(stderr(), crossterm::terminal::ScrollUp(to_scroll))
?0
;
416
1
        }
417
4
        debug!("min_height: resizing TUI to {rect:?}");
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
621
    fn deref(&self) -> &Self::Target {
439
621
        &self.terminal
440
621
    }
441
}
442
443
impl<B: Backend> DerefMut for Tui<B>
444
where
445
    B::Error: Send + Sync + 'static,
446
{
447
5.47k
    fn deref_mut(&mut self) -> &mut Self::Target {
448
5.47k
        &mut self.terminal
449
5.47k
    }
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(
t11
) = self.task.take() {
  Branch (457:16): [True: 10, False: 0]
 
  Branch (457:16): [True: 0, False: 1]
-
  Branch (457:16): [True: 0, False: 10]
-
  Branch (457:16): [True: 0, False: 5]
+
  Branch (457:16): [True: 0, False: 23]
+
  Branch (457:16): [True: 0, False: 128]
+
  Branch (457:16): [True: 0, False: 36]
 
  Branch (457:16): [True: 0, False: 22]
-
  Branch (457:16): [True: 1, False: 54]
-
  Branch (457:16): [True: 0, False: 11]
-
  Branch (457:16): [True: 0, False: 5]
-
  Branch (457:16): [True: 0, False: 121]
 
  Branch (457:16): [True: 0, False: 9]
-
  Branch (457:16): [True: 0, False: 22]
+
  Branch (457:16): [True: 0, False: 9]
+
  Branch (457:16): [True: 0, False: 13]
+
  Branch (457:16): [True: 0, False: 11]
+
  Branch (457:16): [True: 0, False: 2]
+
  Branch (457:16): [True: 0, False: 36]
+
  Branch (457:16): [True: 0, False: 26]
+
  Branch (457:16): [True: 1, False: 55]
+
  Branch (457:16): [True: 0, False: 5]
 
  Branch (457:16): [True: 0, False: 12]
-
458
11
            t.abort();
459
396
        }
460
407
        let _ = self.exit();
461
407
    }
462
}
463
464
407
fn set_panic_hook() {
465
407
    PANIC_HOOK_SET.call_once(|| 
{406
466
406
        let hook = std::panic::take_hook();
467
406
        std::panic::set_hook(Box::new(move |panic_info| 
{5
468
5
            let _ = cleanup_terminal();
469
            #[cfg(windows)]
470
            super::windows::uninstall_ctrl_c_handler();
471
5
            hook(panic_info);
472
5
        }));
473
406
    });
474
407
}
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
413
pub(crate) fn cleanup_terminal() -> std::io::Result<()> {
483
413
    if let Err(
e0
) = crossterm::execute!(stderr(), PopKeyboardEnhancementFlags) {
  Branch (483:12): [True: 0, False: 356]
-
  Branch (483:12): [True: 0, False: 57]
-
484
0
        warn!("Failed to remove keyboard enhancement flags: {e}");
485
413
    }
486
413
    crossterm::execute!(
487
413
        stderr(),
488
        DisableMouseCapture,
489
        DisableBracketedPaste,
490
        LeaveAlternateScreen,
491
        cursor::Show
492
0
    )?;
493
413
    crossterm::terminal::disable_raw_mode()
?0
;
494
413
    Ok(())
495
413
}
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 +
  Branch (457:16): [True: 0, False: 11]
+
  Branch (457:16): [True: 0, False: 4]
+
  Branch (457:16): [True: 0, False: 9]
+
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| 
{20
468
20
            let _ = cleanup_terminal();
469
            #[cfg(windows)]
470
            super::windows::uninstall_ctrl_c_handler();
471
20
            hook(panic_info);
472
20
        }));
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
444
pub(crate) fn cleanup_terminal() -> std::io::Result<()> {
483
444
    if let Err(
e0
) = crossterm::execute!(stderr(), PopKeyboardEnhancementFlags) {
  Branch (483:12): [True: 0, False: 386]
+
  Branch (483:12): [True: 0, False: 58]
+
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 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 e64b9378..58ad68b9 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-04 10: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.22k
    pub fn height(&self) -> u16 {
67
3.22k
        let static_lines = if self.header.is_empty() {
  Branch (67:31): [True: 2.93k, False: 96]
-
  Branch (67:31): [True: 198, False: 2]
-
68
3.12k
            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.22k
        let dynamic_lines = if self.header_lines.is_empty() {
  Branch (75:32): [True: 2.96k, False: 58]
-
  Branch (75:32): [True: 199, False: 1]
-
76
3.16k
            self.header_lines_count
77
59
        } else if let Some(
sep3
) = self.multiline.as_deref() {
  Branch (77:23): [True: 3, False: 55]
+

Coverage Report

Created: 2026-09-05 18:50

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.31k
    pub fn height(&self) -> u16 {
67
3.31k
        let static_lines = if self.header.is_empty() {
  Branch (67:31): [True: 3.02k, False: 92]
+
  Branch (67:31): [True: 197, False: 2]
+
68
3.22k
            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.31k
        let dynamic_lines = if self.header_lines.is_empty() {
  Branch (75:32): [True: 3.05k, False: 58]
+
  Branch (75:32): [True: 198, False: 1]
+
76
3.25k
            self.header_lines_count
77
59
        } else if let Some(
sep3
) = self.multiline.as_deref() {
  Branch (77:23): [True: 3, False: 55]
 
  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
3
                .sum()
82
        } else {
83
56
            u16::try_from(self.header_lines.len()).unwrap_or(u16::MAX)
84
        };
85
3.22k
        static_lines + dynamic_lines
86
3.22k
    }
87
88
    /// Sets the dynamic header lines from input (--header-lines)
89
2.67k
    pub fn set_header_lines(&mut self, items: Vec<Arc<dyn SkimItem>>) {
90
2.67k
        self.header_lines = items;
91
2.67k
        if self.reverse_lines {
  Branch (91:12): [True: 2.58k, False: 66]
-
  Branch (91:12): [True: 29, False: 0]
-
92
2.60k
            self.header_lines.reverse();
93
2.60k
        
}66
94
2.67k
    }
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
547
fn apply_tabstop(text: &str, tabstop: usize) -> String {
104
547
    let mut result = String::new();
105
547
    let mut current_width = 0;
106
107
547
    for 
ch223
in text.chars() {
108
223
        if ch == '\t' {
  Branch (108:12): [True: 0, False: 176]
+
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
3
                .sum()
82
        } else {
83
56
            u16::try_from(self.header_lines.len()).unwrap_or(u16::MAX)
84
        };
85
3.31k
        static_lines + dynamic_lines
86
3.31k
    }
87
88
    /// Sets the dynamic header lines from input (--header-lines)
89
2.74k
    pub fn set_header_lines(&mut self, items: Vec<Arc<dyn SkimItem>>) {
90
2.74k
        self.header_lines = items;
91
2.74k
        if self.reverse_lines {
  Branch (91:12): [True: 2.65k, False: 65]
+
  Branch (91:12): [True: 28, False: 0]
+
92
2.68k
            self.header_lines.reverse();
93
2.68k
        
}65
94
2.74k
    }
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
562
fn apply_tabstop(text: &str, tabstop: usize) -> String {
104
562
    let mut result = String::new();
105
562
    let mut current_width = 0;
106
107
562
    for 
ch217
in text.chars() {
108
217
        if ch == '\t' {
  Branch (108:12): [True: 0, False: 170]
 
  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
220
        } else {
113
220
            result.push(ch);
114
220
            current_width += char_display_width(ch);
115
220
        }
116
    }
117
118
547
    result
119
547
}
120
121
impl SkimWidget for Header {
122
543
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
123
543
        let tabstop = max(1, options.tabstop);
124
543
        let header = options.header.clone().unwrap_or_default();
125
126
        // Expand tabs once during initialization
127
543
        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
543
        let reverse_lines = options.layout == TuiLayout::Default;
132
133
543
        Self {
134
543
            header: expanded_header,
135
543
            header_lines: Vec::new(),
136
543
            header_lines_count: options
137
543
                .header_lines
138
543
                .try_into()
139
543
                .expect("header_lines count overflows u16"),
140
543
            multiline: options.multiline.as_ref().and_then(std::clone::Clone::clone),
141
543
            indent_size: (options.selector_icon.chars().count() + options.multi_select_icon.chars().count())
142
543
                .try_into()
143
543
                .expect("Failed to fit selector lens into an u16"),
144
543
            theme,
145
543
            border: options.border,
146
543
            reverse_lines,
147
543
            reverse: options.layout == TuiLayout::Reverse,
148
543
        }
149
543
    }
150
151
101
    fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender {
152
101
        let block = if let Some(
border_type21
) = self.border.into_ratatui() {
  Branch (152:28): [True: 21, 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
214
        } else {
113
214
            result.push(ch);
114
214
            current_width += char_display_width(ch);
115
214
        }
116
    }
117
118
562
    result
119
562
}
120
121
impl SkimWidget for Header {
122
558
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
123
558
        let tabstop = max(1, options.tabstop);
124
558
        let header = options.header.clone().unwrap_or_default();
125
126
        // Expand tabs once during initialization
127
558
        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
558
        let reverse_lines = options.layout == TuiLayout::Default;
132
133
558
        Self {
134
558
            header: expanded_header,
135
558
            header_lines: Vec::new(),
136
558
            header_lines_count: options
137
558
                .header_lines
138
558
                .try_into()
139
558
                .expect("header_lines count overflows u16"),
140
558
            multiline: options.multiline.as_ref().and_then(std::clone::Clone::clone),
141
558
            indent_size: (options.selector_icon.chars().count() + options.multi_select_icon.chars().count())
142
558
                .try_into()
143
558
                .expect("Failed to fit selector lens into an u16"),
144
558
            theme,
145
558
            border: options.border,
146
558
            reverse_lines,
147
558
            reverse: options.layout == TuiLayout::Reverse,
148
558
        }
149
558
    }
150
151
101
    fn render(&mut self, area: Rect, buf: &mut Buffer) -> SkimRender {
152
101
        let block = if let Some(
border_type18
) = self.border.into_ratatui() {
  Branch (152:28): [True: 18, False: 81]
 
  Branch (152:28): [True: 0, False: 2]
-
153
21
            Block::default()
154
21
                .borders(Borders::ALL)
155
21
                .border_type(border_type)
156
21
                .border_style(self.theme.border)
157
        } else {
158
80
            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]
+
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: 31, False: 68]
 
  Branch (162:33): [True: 1, False: 1]
-
163
29
            0
164
        } else {
165
72
            self.header_text().lines.len()
166
101
        } + if let Some(
sep9
) = self.multiline.as_deref() {
  Branch (166:20): [True: 9, False: 90]
+
163
32
            0
164
        } else {
165
69
            self.header_text().lines.len()
166
101
        } + if let Some(
sep9
) = self.multiline.as_deref() {
  Branch (166:20): [True: 9, False: 90]
 
  Branch (166:20): [True: 0, False: 2]
-
167
9
            self.header_lines
168
9
                .iter()
169
9
                .map(|item| 
item.text()3
.split(sep).
count3
().
max3
(1))
170
9
                .sum()
171
        } else {
172
92
            self.header_lines.len()
173
        };
174
175
101
        let container_height = if self.border.is_some() {
  Branch (175:35): [True: 21, False: 78]
+
167
9
            self.header_lines
168
9
                .iter()
169
9
                .map(|item| 
item.text()3
.split(sep).
count3
().
max3
(1))
170
9
                .sum()
171
        } else {
172
92
            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
21
            area.height - 2
177
        } else {
178
80
            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]
+
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
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
5
            }
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 
item97
in &self.header_lines {
198
97
            if let Some(
sep3
) = self.multiline.as_deref() {
  Branch (198:20): [True: 3, False: 94]
+
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
5
            }
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 
item94
in &self.header_lines {
198
94
            if let Some(
sep3
) = self.multiline.as_deref() {
  Branch (198:20): [True: 3, False: 91]
 
  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
3
                }
216
94
            } else {
217
94
                let mut line = item.display(display_context.clone());
218
94
                style_line(&mut line, self.theme.header);
219
94
                combined_header.push_line(line);
220
94
            }
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]
+
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
3
                }
216
91
            } else {
217
91
                let mut line = item.display(display_context.clone());
218
91
                style_line(&mut line, self.theme.header);
219
91
                combined_header.push_line(line);
220
91
            }
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: 53, False: 25]
 
  Branch (224:12): [True: 2, False: 0]
   Branch (224:29): [True: 1, False: 1]
-
225
57
            combined_header += self.header_text();
226
57
        
}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 +
225
54
            combined_header += self.header_text();
226
54
        
}47
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 72657ac8..2a8cbf88 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,34 +1,34 @@ -

Coverage Report

Created: 2026-09-04 10: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.57k
    pub fn left_title(&self) -> String {
51
2.57k
        let mut parts = String::new();
52
53
        // Spinner
54
2.57k
        if self.show_spinner
  Branch (54:12): [True: 12, False: 2.52k]
-
  Branch (54:12): [True: 1, False: 29]
-
55
13
            && let Some(start) = self.start
  Branch (55:20): [True: 12, False: 0]
+

Coverage Report

Created: 2026-09-05 18:50

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: 63, False: 2.54k]
+
  Branch (54:12): [True: 1, False: 28]
+
55
64
            && let Some(start) = self.start
  Branch (55:20): [True: 63, False: 0]
 
  Branch (55:20): [True: 1, False: 0]
-
56
13
        {
57
13
            parts.push(spinner_char(start));
58
13
            parts.push(' ');
59
2.55k
        } else {
60
2.55k
            parts.push_str("  ");
61
2.55k
        }
62
63
        // Matched/total
64
2.57k
        let _ = write!(parts, "{}/{}", self.matched, self.total);
65
66
        // Matcher mode
67
2.57k
        if !self.matcher_mode.is_empty() {
  Branch (67:12): [True: 3, False: 2.53k]
-
  Branch (67:12): [True: 2, False: 28]
-
68
5
            let _ = write!(parts, "/{}", self.matcher_mode);
69
2.56k
        }
70
71
        // Progress percentage
72
2.57k
        if self.show_spinner && 
self.total > 013
&&
self.processed != self.total13
{
  Branch (72:12): [True: 12, False: 2.52k]
-  Branch (72:33): [True: 12, False: 0]
-  Branch (72:51): [True: 12, False: 0]
-
  Branch (72:12): [True: 1, False: 29]
+
56
64
        {
57
64
            parts.push(spinner_char(start));
58
64
            parts.push(' ');
59
2.57k
        } else {
60
2.57k
            parts.push_str("  ");
61
2.57k
        }
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: 27]
+
68
5
            let _ = write!(parts, "/{}", self.matcher_mode);
69
2.63k
        }
70
71
        // Progress percentage
72
2.63k
        if self.show_spinner && 
self.total > 064
&&
self.processed != self.total64
{
  Branch (72:12): [True: 63, False: 2.54k]
+  Branch (72:33): [True: 63, False: 0]
+  Branch (72:51): [True: 54, False: 9]
+
  Branch (72:12): [True: 1, False: 28]
   Branch (72:33): [True: 1, False: 0]
   Branch (72:51): [True: 1, False: 0]
-
73
13
            let pct = self.processed.saturating_mul(100) / self.total;
74
13
            let _ = write!(parts, " ({pct}%)");
75
2.55k
        }
76
77
        // Selection count
78
2.57k
        if self.multi_selection && 
self.selected > 0145
{
  Branch (78:12): [True: 144, False: 2.39k]
-  Branch (78:36): [True: 87, False: 57]
-
  Branch (78:12): [True: 1, False: 29]
+
73
55
            let pct = self.processed.saturating_mul(100) / self.total;
74
55
            let _ = write!(parts, " ({pct}%)");
75
2.58k
        }
76
77
        // Selection count
78
2.63k
        if self.multi_selection && 
self.selected > 0152
{
  Branch (78:12): [True: 151, False: 2.45k]
+  Branch (78:36): [True: 89, False: 62]
+
  Branch (78:12): [True: 1, False: 28]
   Branch (78:36): [True: 1, False: 0]
-
79
88
            let _ = write!(parts, " [{}]", self.selected);
80
2.48k
        }
81
82
2.57k
        parts
83
2.57k
    }
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
55
    pub fn inline_separator_or_spinner(&self) -> String {
88
55
        if self.show_spinner
  Branch (88:12): [True: 6, False: 47]
 
  Branch (88:12): [True: 1, False: 1]
-
89
1
            && let Some(start) = self.start
  Branch (89:20): [True: 0, False: 0]
+
89
7
            && let Some(start) = self.start
  Branch (89:20): [True: 6, False: 0]
 
  Branch (89:20): [True: 1, False: 0]
-
90
        {
91
1
            format!(
92
                "{}{}",
93
1
                spinner_char(start),
94
1
                " ".repeat(display_width(&self.inline_separator).try_into().unwrap())
95
            )
96
        } else {
97
48
            self.inline_separator.clone()
98
        }
99
49
    }
100
101
    /// Build the inline status string (matched/total, mode, progress, selection)
102
    /// Used for Inline info display mode - does NOT include spinner prefix
103
48
    pub fn inline_status(&self) -> String {
104
48
        let mut parts = String::new();
105
106
        // Matched/total
107
48
        let _ = write!(parts, "{}/{}", self.matched, self.total);
108
109
        // Matcher mode
110
48
        if !self.matcher_mode.is_empty() {
  Branch (110:12): [True: 0, False: 47]
+
90
        {
91
7
            format!(
92
                "{}{}",
93
7
                spinner_char(start),
94
7
                " ".repeat(display_width(&self.inline_separator).try_into().unwrap())
95
            )
96
        } else {
97
48
            self.inline_separator.clone()
98
        }
99
55
    }
100
101
    /// Build the inline status string (matched/total, mode, progress, selection)
102
    /// Used for Inline info display mode - does NOT include spinner prefix
103
54
    pub fn inline_status(&self) -> String {
104
54
        let mut parts = String::new();
105
106
        // Matched/total
107
54
        let _ = write!(parts, "{}/{}", self.matched, self.total);
108
109
        // Matcher mode
110
54
        if !self.matcher_mode.is_empty() {
  Branch (110:12): [True: 0, False: 53]
 
  Branch (110:12): [True: 1, False: 0]
-
111
1
            let _ = write!(parts, "/{}", self.matcher_mode);
112
47
        }
113
114
        // Progress percentage
115
48
        if self.show_spinner && 
self.total > 01
{
  Branch (115:12): [True: 0, False: 47]
-  Branch (115:33): [True: 0, False: 0]
+
111
1
            let _ = write!(parts, "/{}", self.matcher_mode);
112
53
        }
113
114
        // Progress percentage
115
54
        if self.show_spinner && 
self.total > 07
{
  Branch (115:12): [True: 6, False: 47]
+  Branch (115:33): [True: 6, False: 0]
 
  Branch (115:12): [True: 1, False: 0]
   Branch (115:33): [True: 1, False: 0]
-
116
1
            let pct = self.processed.saturating_mul(100) / self.total;
117
1
            let _ = write!(parts, " ({pct}%)");
118
47
        }
119
120
        // Selection count
121
48
        if self.multi_selection && 
self.selected > 01
{
  Branch (121:12): [True: 0, False: 47]
+
116
7
            let pct = self.processed.saturating_mul(100) / self.total;
117
7
            let _ = write!(parts, " ({pct}%)");
118
47
        }
119
120
        // Selection count
121
54
        if self.multi_selection && 
self.selected > 01
{
  Branch (121:12): [True: 0, False: 53]
   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.61k
    pub fn right_title(&self) -> String {
130
2.61k
        format!("{}/{}", self.current_item_idx, self.hscroll_offset)
131
2.61k
    }
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
103
    fn default() -> Self {
156
103
        Self::_default()
157
103
    }
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
53
        }
124
125
54
        parts
126
54
    }
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
104
    fn default() -> Self {
156
104
        Self::_default()
157
104
    }
158
}
159
160
impl Input {
161
341
    pub fn insert(&mut self, c: char) {
162
341
        self.value.insert(self.cursor_pos.into(), c);
163
        // unwrap: len_utf8 < 4
164
341
        self.move_cursor(c.len_utf8().try_into().unwrap());
165
341
    }
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
8
        
}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
8
        
}2
189
10
        Some(ch)
190
13
    }
191
386
    pub fn move_cursor(&mut self, offset: i32) {
192
386
        if offset == 0 {
  Branch (192:12): [True: 0, False: 356]
 
  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
385
        }
195
385
        if offset < 0 {
  Branch (195:12): [True: 11, False: 345]
 
  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
366
        } 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
366
        }
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
370
        } else {
199
370
            let new_pos = (i32::from(self.cursor_pos) + offset).unsigned_abs() as usize;
200
370
            self.move_cursor_to(u16::try_from(self.value.ceil_char_boundary(new_pos)).unwrap_or(u16::MAX));
201
370
        }
202
386
    }
203
431
    pub fn move_cursor_to(&mut self, pos: u16) {
204
431
        if self.value.is_char_boundary(pos as usize) {
  Branch (204:12): [True: 373, 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
427
        } 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
        );
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
431
            self.cursor_pos = u16::clamp(pos, 0, u16::try_from(self.value.len()).unwrap_or(u16::MAX));
206
431
        } else {
207
0
            warn!("Invalid cursor pos");
208
        }
209
431
    }
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
        );
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
62
        }
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.67k
    pub fn cursor_pos(&self) -> u16 {
391
2.67k
        (display_width(&self.value[..(self.cursor_pos as usize)]) + display_width(&strip_ansi(&self.prompt).0))
392
2.67k
            .try_into()
393
2.67k
            .expect("Failed to fit cursor char into an u16")
394
2.67k
    }
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
631
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
404
631
        let mut res = Self {
405
631
            theme,
406
631
            border: options.border,
407
631
            info: options.info.clone(),
408
631
            reverse: options.layout == TuiLayout::Reverse,
409
631
            prompt: String::new(),
410
631
            alternate_prompt: String::new(),
411
631
            value: String::new(),
412
631
            alternate_value: String::new(),
413
631
            cursor_pos: 0,
414
631
            alternate_cursor_pos: 0,
415
631
            status_info: None,
416
631
        };
417
631
        if options.interactive {
  Branch (417:12): [True: 30, False: 427]
+
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.74k
    pub fn cursor_pos(&self) -> u16 {
391
2.74k
        (display_width(&self.value[..(self.cursor_pos as usize)]) + display_width(&strip_ansi(&self.prompt).0))
392
2.74k
            .try_into()
393
2.74k
            .expect("Failed to fit cursor char into an u16")
394
2.74k
    }
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
647
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
404
647
        let mut res = Self {
405
647
            theme,
406
647
            border: options.border,
407
647
            info: options.info.clone(),
408
647
            reverse: options.layout == TuiLayout::Reverse,
409
647
            prompt: String::new(),
410
647
            alternate_prompt: String::new(),
411
647
            value: String::new(),
412
647
            alternate_value: String::new(),
413
647
            cursor_pos: 0,
414
647
            alternate_cursor_pos: 0,
415
647
            status_info: None,
416
647
        };
417
647
        if options.interactive {
  Branch (417:12): [True: 31, False: 442]
 
  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
600
        } else {
423
600
            res.prompt.clone_from(&options.prompt);
424
600
            res.alternate_prompt.clone_from(&options.cmd_prompt);
425
600
            res.value = options.query.clone().unwrap_or_default();
426
600
            res.alternate_value = options.cmd_query.clone().unwrap_or_default();
427
600
        }
428
631
        res.cursor_pos = u16::try_from(res.value.len()).unwrap_or(u16::MAX);
429
631
        res.alternate_cursor_pos = u16::try_from(res.alternate_value.len()).unwrap_or(u16::MAX);
430
631
        res
431
631
    }
432
433
2.67k
    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.67k
        let mut line = self.prompt.into_text().map_or_else(
439
0
            |_| Line::from(self.prompt.as_str()),
440
2.67k
            |t| t.lines.into_iter().next().unwrap_or_default(),
441
        );
442
2.67k
        style_line(&mut line, self.theme.prompt);
443
2.67k
        line.push_span(Span::styled(&self.value, self.theme.query));
444
445
2.67k
        let mut block = Block::default();
446
447
        // Add borders if enabled
448
2.67k
        if let Some(
border_type66
) = self.border.into_ratatui() {
  Branch (448:16): [True: 66, False: 2.58k]
-
  Branch (448:16): [True: 0, False: 29]
-
449
66
            block = block
450
66
                .borders(Borders::ALL)
451
66
                .border_type(border_type)
452
66
                .border_style(self.theme.border);
453
2.60k
        }
454
455
        // Handle different info display modes
456
2.67k
        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]
+
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
615
        } 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
615
        }
428
647
        res.cursor_pos = u16::try_from(res.value.len()).unwrap_or(u16::MAX);
429
647
        res.alternate_cursor_pos = u16::try_from(res.alternate_value.len()).unwrap_or(u16::MAX);
430
647
        res
431
647
    }
432
433
2.74k
    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.74k
        let mut line = self.prompt.into_text().map_or_else(
439
0
            |_| Line::from(self.prompt.as_str()),
440
2.74k
            |t| t.lines.into_iter().next().unwrap_or_default(),
441
        );
442
2.74k
        style_line(&mut line, self.theme.prompt);
443
2.74k
        line.push_span(Span::styled(&self.value, self.theme.query));
444
445
2.74k
        let mut block = Block::default();
446
447
        // Add borders if enabled
448
2.74k
        if let Some(
border_type66
) = self.border.into_ratatui() {
  Branch (448:16): [True: 66, False: 2.65k]
+
  Branch (448:16): [True: 0, False: 28]
+
449
66
            block = block
450
66
                .borders(Borders::ALL)
451
66
                .border_type(border_type)
452
66
                .border_style(self.theme.border);
453
2.68k
        }
454
455
        // Handle different info display modes
456
2.74k
        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
53
                if let Some(ref status) = self.status_info {
  Branch (461:24): [True: 53, 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
47
                    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]
+
462
53
                    let separator = status.inline_separator_or_spinner();
463
53
                    let inline_status = status.inline_status();
464
53
                    let right_status = status.right_title();
465
466
                    // Calculate available width for padding
467
                    // Format: " X " where X is separator (3 chars total)
468
53
                    let prompt_width = display_width(&self.prompt);
469
53
                    let value_width = display_width(&self.value);
470
53
                    let separator_width = display_width(&separator); // "  X " (2xspace + separator + space)
471
53
                    let inline_status_width = display_width(&inline_status);
472
53
                    let right_status_width = display_width(&right_status);
473
474
53
                    let used_width =
475
53
                        prompt_width + value_width + separator_width + inline_status_width + right_status_width;
476
53
                    let available_width = u64::from(area.width);
477
53
                    let padding_width = available_width.saturating_sub(used_width);
478
479
53
                    if self.info.display == InfoDisplay::InlineRight {
  Branch (479:24): [True: 14, False: 39]
 
  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]
+
480
14
                        line.push_span(Span::raw(" ".repeat(usize::try_from(padding_width).unwrap() - 2)));
481
39
                    }
482
53
                    line.push_span(Span::styled(separator, self.theme.info));
483
53
                    line.push_span(Span::styled(inline_status, self.theme.info));
484
53
                    if self.info.display == InfoDisplay::Inline {
  Branch (484:24): [True: 39, False: 14]
 
  Branch (484:24): [True: 0, False: 0]
-
485
33
                        line.push_span(Span::raw(" ".repeat(padding_width.try_into().unwrap())));
486
33
                    } else {
487
14
                        line.push_span(Span::raw(" ".repeat(2)));
488
14
                    }
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
0
                } else {
496
0
                    // 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
0
                }
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.56k
                if let Some(
ref status2.56k
) = self.status_info {
  Branch (509:24): [True: 2.54k, False: 0]
-
  Branch (509:24): [True: 28, False: 1]
-
510
2.56k
                    let left_title = status.left_title();
511
2.56k
                    let right_title = status.right_title();
512
513
2.56k
                    if 
matches!2.55k
(self.info.display, InfoDisplay::Left | InfoDisplay::Right) {
514
14
                        let alignment = if self.info.display == InfoDisplay::Left {
  Branch (514:44): [True: 7, False: 7]
+
485
39
                        line.push_span(Span::raw(" ".repeat(padding_width.try_into().unwrap())));
486
39
                    } else {
487
14
                        line.push_span(Span::raw(" ".repeat(2)));
488
14
                    }
489
53
                    line.push_span(Span::styled(right_status, self.theme.info));
490
491
53
                    Paragraph::new(line)
492
53
                        .block(block)
493
53
                        .style(self.theme.normal)
494
53
                        .render(area, buf);
495
0
                } else {
496
0
                    // 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
0
                }
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 status2.63k
) = self.status_info {
  Branch (509:24): [True: 2.60k, False: 0]
+
  Branch (509:24): [True: 27, 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!2.62k
(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
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
14
                            block.title_top(title)
526
                        };
527
                    } else {
528
2.55k
                        let info_line = Line::from(left_title).style(self.theme.info).alignment(Alignment::Left);
529
2.55k
                        let index_line = Line::from(right_title)
530
2.55k
                            .style(self.theme.info)
531
2.55k
                            .alignment(Alignment::Right);
532
2.55k
                        block = if self.reverse {
  Branch (532:36): [True: 38, False: 2.48k]
-
  Branch (532:36): [True: 0, False: 28]
-
533
38
                            block.title_bottom(info_line).title_bottom(index_line)
534
                        } else {
535
2.51k
                            block.title_top(info_line).title_top(index_line)
536
                        };
537
                    }
538
1
                }
539
540
2.56k
                Paragraph::new(line)
541
2.56k
                    .block(block)
542
2.56k
                    .style(self.theme.normal)
543
2.56k
                    .render(area, buf);
544
            }
545
59
            InfoDisplay::Hidden => {
546
59
                // 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
59
            }
552
        }
553
554
2.67k
        SkimRender::default()
555
2.67k
    }
556
}
557
558
impl Deref for Input {
559
    type Target = String;
560
561
873
    fn deref(&self) -> &Self::Target {
562
873
        &self.value
563
873
    }
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.62k
                        let info_line = Line::from(left_title).style(self.theme.info).alignment(Alignment::Left);
529
2.62k
                        let index_line = Line::from(right_title)
530
2.62k
                            .style(self.theme.info)
531
2.62k
                            .alignment(Alignment::Right);
532
2.62k
                        block = if self.reverse {
  Branch (532:36): [True: 39, False: 2.55k]
+
  Branch (532:36): [True: 0, False: 27]
+
533
39
                            block.title_bottom(info_line).title_bottom(index_line)
534
                        } else {
535
2.58k
                            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
59
            InfoDisplay::Hidden => {
546
59
                // 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
59
            }
552
        }
553
554
2.74k
        SkimRender::default()
555
2.74k
    }
556
}
557
558
impl Deref for Input {
559
    type Target = String;
560
561
1.14k
    fn deref(&self) -> &Self::Target {
562
1.14k
        &self.value
563
1.14k
    }
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 db5aa6fc..6bf757ea 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,127 +1,127 @@ -

Coverage Report

Created: 2026-09-04 10: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::Arc;
3
4
use indexmap::IndexSet;
5
use ratatui::widgets::{
6
    Block, Borders, Clear, List, ListDirection, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState,
7
    StatefulWidget, Widget,
8
};
9
use regex::Regex;
10
11
use crate::item::MatchedItem;
12
use crate::options::feature_flag;
13
use crate::spinlock::SpinLock;
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
}
40
41
impl Default for ProcessedItems {
42
1
    fn default() -> Self {
43
1
        Self {
44
1
            items: Vec::new(),
45
1
            merge: MergeStrategy::Replace,
46
1
        }
47
1
    }
48
}
49
50
/// Widget for displaying and managing the list of filtered items
51
#[allow(clippy::struct_excessive_bools)]
52
pub struct ItemList {
53
    pub(crate) items: Vec<MatchedItem>,
54
    pub(crate) selection: IndexSet<MatchedItem>,
55
    pub(crate) processed_items: Arc<SpinLock<Option<ProcessedItems>>>,
56
    pub(crate) direction: ListDirection,
57
    pub(crate) offset: usize,
58
    /// How many leading sub-lines of items[offset] have been scrolled off the top.
59
    /// Only meaningful when multiline is active; always 0 otherwise.
60
    sub_offset: usize,
61
    pub(crate) current: usize,
62
    pub(crate) height: u16,
63
    pub(crate) theme: std::sync::Arc<crate::theme::ColorTheme>,
64
    pub(crate) multi_select: bool,
65
    reserved: usize,
66
    pub(crate) no_hscroll: bool,
67
    pub(crate) ellipsis: String,
68
    pub(crate) keep_right: bool,
69
    pub(crate) skip_to_pattern: Option<Regex>,
70
    pub(crate) tabstop: usize,
71
    selector: Option<Rc<dyn Selector>>,
72
    pre_select_target: usize, // How many items we want to pre-select
73
    no_clear_if_empty: bool,
74
    interactive: bool,              // Whether we're in interactive mode
75
    showing_stale_items: bool,      // True when displaying old items due to no_clear_if_empty
76
    pub(crate) manual_hscroll: i32, // Manual horizontal scroll offset for ScrollLeft/ScrollRight
77
    pub(crate) selector_icon: String,
78
    pub(crate) multi_select_icon: String,
79
    cycle: bool,
80
    pub(crate) wrap: bool,
81
    /// When Some, split item text on this separator and show each part on its own line
82
    pub(crate) multiline: Option<String>,
83
    /// Border type
84
    pub border: BorderType,
85
    /// When true, prepend each item's match score to its display text
86
    pub(crate) show_score: bool,
87
    pub(crate) show_index: bool,
88
    /// When true, highlight the entire current line (not just the matched text)
89
    pub(crate) highlight_line: bool,
90
    /// Scrollbar display configuration
91
    pub(crate) scrollbar_thumb: String,
92
}
93
94
impl Default for ItemList {
95
24
    fn default() -> Self {
96
24
        Self::_default()
97
24
    }
98
}
99
100
impl ItemList {
101
9.54k
    fn cursor(&self) -> usize {
102
9.54k
        self.current
103
9.54k
    }
104
105
    /// Returns the count of items for status display.
106
    ///
107
    /// This may differ from `items.len()` when `no_clear_if_empty` is active and showing stale items
108
    #[must_use]
109
2.61k
    pub fn count(&self) -> usize {
110
2.61k
        if self.showing_stale_items { 
05
} else {
self.items2.61k
.
len2.61k
() }
  Branch (110:12): [True: 4, False: 2.58k]
-
  Branch (110:12): [True: 1, False: 28]
-
111
2.61k
    }
112
113
    /// Returns the currently selected item, if any
114
    #[must_use]
115
9.51k
    pub fn selected(&self) -> Option<MatchedItem> {
116
9.51k
        let item = self.items.get(self.cursor());
117
9.51k
        if item.is_some_and(|i| !
i.item.disabled()6.76k
) {
  Branch (117:12): [True: 6.61k, False: 2.72k]
-
  Branch (117:12): [True: 153, False: 29]
-
118
6.76k
            item.cloned()
119
        } else {
120
2.74k
            None
121
        }
122
9.51k
    }
123
124
    /// Appends new matched items to the list
125
83
    pub fn append(&mut self, items: &mut Vec<MatchedItem>) {
126
83
        self.items.append(items);
127
83
        self.showing_stale_items = false;
128
83
    }
129
130
    /// Prepends a batch while preserving either the head-following behavior or
131
    /// the item currently focused by a user who has moved away from the head.
132
2
    fn prepend(&mut self, mut items: Vec<MatchedItem>) {
133
2
        if items.is_empty() {
  Branch (133:12): [True: 0, False: 0]
-
  Branch (133:12): [True: 0, False: 2]
-
134
0
            return;
135
2
        }
136
137
2
        let added = items.len();
138
2
        let follows_head = self.current == 0;
139
2
        items.append(&mut self.items);
140
2
        self.items = items;
141
142
2
        if follows_head {
  Branch (142:12): [True: 0, False: 0]
-
  Branch (142:12): [True: 1, False: 1]
-
143
1
            self.offset = 0;
144
1
            self.sub_offset = 0;
145
1
        } else {
146
1
            self.current = self.current.saturating_add(added);
147
1
            self.offset = self.offset.saturating_add(added);
148
1
        }
149
2
    }
150
151
    /// Toggles the selection state of the item at the given index
152
26
    pub fn toggle_at(&mut self, index: usize) {
153
26
        if self.items.is_empty() {
  Branch (153:12): [True: 0, False: 12]
-
  Branch (153:12): [True: 1, False: 13]
-
154
1
            return;
155
25
        }
156
25
        let item = &self.items[index];
157
25
        trace!("Toggled item {} at index {}", 
item.text()0
, index);
158
25
        toggle_item(&mut self.selection, item);
159
25
        trace!(
160
            "Selection is now {:#?}",
161
0
            self.selection.iter().map(|item| item.item.text()).collect::<Vec<_>>()
162
        );
163
26
    }
164
    /// Toggles the selection state of the currently selected item
165
21
    pub fn toggle(&mut self) {
166
21
        self.toggle_at(self.cursor());
167
21
    }
168
    /// Toggles the selection state of all items
169
5
    pub fn toggle_all(&mut self) {
170
15
        for item in 
&self.items5
{
171
15
            toggle_item(&mut self.selection, item);
172
15
        }
173
5
    }
174
175
    /// Add row at cursor to selection
176
4
    pub fn select(&mut self) {
177
4
        debug!("{}", 
self0
.
cursor0
());
178
4
        self.select_row(self.cursor());
179
4
    }
180
181
    /// Add row to selection
182
9
    pub fn select_row(&mut self, index: usize) {
183
9
        let item = self.items[index].clone();
184
9
        if !item.disabled() {
  Branch (184:12): [True: 1, False: 0]
-
  Branch (184:12): [True: 7, False: 1]
-
185
8
            self.selection.insert(item);
186
8
        
}1
187
9
    }
188
    /// Selects all items
189
8
    pub fn select_all(&mut self) {
190
20
        for item in 
self.items8
.
clone8
() {
191
20
            if !item.disabled() {
  Branch (191:16): [True: 3, False: 0]
-
  Branch (191:16): [True: 17, False: 0]
-
192
20
                self.selection.insert(item.clone());
193
20
            
}0
194
        }
195
8
    }
196
    /// Clears all selections
197
7
    pub fn clear_selection(&mut self) {
198
7
        self.selection.clear();
199
7
    }
200
    /// Clears all items from the list
201
43
    pub fn clear(&mut self) {
202
43
        self.items.clear();
203
43
        self.selection.clear();
204
43
        self.current = 0;
205
43
        self.offset = 0;
206
43
        self.sub_offset = 0;
207
43
        self.showing_stale_items = false;
208
43
    }
209
    /// Scrolls the list by `rows` terminal rows, counting each item's sub-lines.
210
    /// Positive = toward higher indices (up the screen in default layout).
211
15
    pub fn scroll_by_rows(&mut self, rows: i32) {
212
15
        if self.reserved >= self.items.len() || rows == 0 {
  Branch (212:12): [True: 0, False: 0]
-  Branch (212:49): [True: 0, False: 0]
-
  Branch (212:12): [True: 0, False: 15]
-  Branch (212:49): [True: 1, False: 14]
-
213
1
            return;
214
14
        }
215
14
        let total = self.items.len();
216
14
        let reserved = self.reserved;
217
218
14
        if rows > 0 {
  Branch (218:12): [True: 0, False: 0]
-
  Branch (218:12): [True: 7, False: 7]
-
219
7
            let mut remaining = rows.unsigned_abs() as usize;
220
7
            let mut idx = self.current;
221
28
            while remaining > 0 && 
idx + 1 < total23
{
  Branch (221:19): [True: 0, False: 0]
-  Branch (221:36): [True: 0, False: 0]
-
  Branch (221:19): [True: 23, False: 5]
-  Branch (221:36): [True: 21, False: 2]
-
222
21
                idx += 1;
223
21
                let row_count = self.item_row_count(idx);
224
21
                remaining = remaining.saturating_sub(row_count);
225
21
            }
226
7
            self.current = idx;
227
        } else {
228
7
            let mut remaining = rows.unsigned_abs() as usize;
229
7
            let mut idx = self.current;
230
20
            while remaining > 0 && 
idx > reserved16
{
  Branch (230:19): [True: 0, False: 0]
-  Branch (230:36): [True: 0, False: 0]
-
  Branch (230:19): [True: 16, False: 4]
-  Branch (230:36): [True: 13, False: 3]
-
231
13
                let row_count = self.item_row_count(idx);
232
13
                remaining = remaining.saturating_sub(row_count);
233
13
                idx -= 1;
234
13
            }
235
7
            self.current = idx.max(reserved);
236
        }
237
15
    }
238
239
    /// Scrolls the list by the given offset
240
90
    pub fn scroll_by(&mut self, offset: i32) {
241
90
        if self.reserved >= self.items.len() {
  Branch (241:12): [True: 0, False: 68]
-
  Branch (241:12): [True: 0, False: 22]
-
242
0
            return;
243
90
        }
244
90
        let reserved = i32::try_from(self.reserved).unwrap_or(0);
245
90
        let total = i32::try_from(self.items.len()).unwrap_or(i32::MAX);
246
90
        let mut new = i32::try_from(self.current).unwrap_or(0) + offset;
247
90
        if self.cycle {
  Branch (247:12): [True: 6, False: 62]
-
  Branch (247:12): [True: 2, False: 20]
-
248
8
            let n = total - reserved;
249
8
            new = reserved + (new + n - reserved) % n;
250
82
        } else {
251
82
            new = new.min(total - 1).max(reserved);
252
82
        }
253
90
        self.current = new.max(0).unsigned_abs() as usize;
254
90
        debug!("Scrolled to {}", self.current);
255
90
        debug!("Selection: {:?}", self.selection);
256
90
    }
257
    /// Selects the previous item in the list
258
4
    pub fn select_previous(&mut self) {
259
4
        self.scroll_by(-1);
260
4
    }
261
    /// Selects the next item in the list
262
4
    pub fn select_next(&mut self) {
263
4
        self.scroll_by(1);
264
4
    }
265
    /// Jump to the first selectable item (respecting reserved header lines)
266
10
    pub fn jump_to_first(&mut self) {
267
10
        if self.items.len() > self.reserved {
  Branch (267:12): [True: 5, False: 0]
-
  Branch (267:12): [True: 5, False: 0]
-
268
10
            self.current = self.reserved;
269
10
            self.offset = self.reserved;
270
10
            self.sub_offset = 0;
271
10
        
}0
272
10
    }
273
    /// Given a terminal row within the list's rendered inner area (0 = topmost row),
274
    /// return the index of the item that occupies that row, or `None` if the row is
275
    /// empty (e.g. fewer items than the available height).
276
    ///
277
    /// Respects `direction`: in `BottomToTop` layouts item `offset` occupies the
278
    /// bottom row, so row 0 (top) maps to the highest-indexed visible item.
279
    #[must_use]
280
13
    pub fn item_at_visual_row(&self, row: usize) -> Option<usize> {
281
13
        let available_rows = self.height as usize;
282
13
        if row >= available_rows || 
self.items11
.
is_empty11
() {
  Branch (282:12): [True: 0, False: 1]
-  Branch (282:37): [True: 0, False: 1]
-
  Branch (282:12): [True: 2, False: 10]
-  Branch (282:37): [True: 0, False: 10]
-
283
2
            return None;
284
11
        }
285
        // In BottomToTop, flat_rows[0] is rendered at the BOTTOM terminal row.
286
        // Terminal row `r` from the top = flat_row index `available_rows - 1 - r`.
287
11
        let flat_row = match self.direction {
288
8
            ListDirection::BottomToTop => available_rows - 1 - row,
289
3
            ListDirection::TopToBottom => row,
290
        };
291
        // Walk items from `offset`, counting sub-lines, until we cover `flat_row`.
292
11
        let mut rows_counted = 0;
293
24
        for idx in 
self.offset11
..self.items.len() {
294
24
            let item_rows = if idx == self.offset {
  Branch (294:32): [True: 1, False: 1]
-
  Branch (294:32): [True: 10, False: 12]
-
295
11
                self.item_row_count(idx).saturating_sub(self.sub_offset)
296
            } else {
297
13
                self.item_row_count(idx)
298
            };
299
24
            rows_counted += item_rows;
300
24
            if rows_counted > flat_row {
  Branch (300:16): [True: 1, False: 1]
-
  Branch (300:16): [True: 10, False: 12]
-
301
11
                return Some(idx);
302
13
            }
303
13
            if rows_counted >= available_rows {
  Branch (303:16): [True: 0, False: 1]
-
  Branch (303:16): [True: 0, False: 12]
-
304
0
                break;
305
13
            }
306
        }
307
0
        None
308
13
    }
309
310
    /// Jump to the last item in the list
311
11
    pub fn jump_to_last(&mut self) {
312
11
        if !self.items.is_empty() {
  Branch (312:12): [True: 8, False: 0]
-
  Branch (312:12): [True: 3, False: 0]
-
313
11
            self.current = self.items.len().saturating_sub(1);
314
11
            self.sub_offset = 0;
315
11
        
}0
316
11
    }
317
318
    /// Number of terminal rows item at `index` occupies.
319
    ///
320
    /// When `--multiline` is active this is the number of sub-lines produced by
321
    /// splitting on the separator; otherwise every item is exactly 1 row.
322
4.08k
    fn item_row_count(&self, index: usize) -> usize {
323
4.08k
        if let Some(
sep692
) = self.multiline.as_deref()
  Branch (323:16): [True: 692, False: 3.05k]
-
  Branch (323:16): [True: 0, False: 342]
-
324
692
            && let Some(
item648
) = self.items.get(index)
  Branch (324:20): [True: 648, False: 44]
-
  Branch (324:20): [True: 0, False: 0]
-
325
        {
326
648
            item.item.text().split(sep).count().max(1)
327
        } else {
328
3.43k
            1
329
        }
330
4.08k
    }
331
332
    /// How many terminal rows are consumed by items `[from, from + count)`.
333
202
    fn rows_for_range(&self, from: usize, count: usize) -> usize {
334
1.26k
        (
from202
..from + count).
map202
(|i| self.item_row_count(i)).
sum202
()
335
202
    }
336
337
    /// How many rows are consumed by items `[offset..=current]`, with `sub_offset`
338
    /// leading sub-lines of `items[offset]` already scrolled off.
339
2.72k
    fn rows_visible(&self, offset: usize, sub_offset: usize, current: usize) -> usize {
340
2.72k
        if offset > current {
  Branch (340:12): [True: 0, False: 2.67k]
-
  Branch (340:12): [True: 0, False: 51]
-
341
0
            return 0;
342
2.72k
        }
343
2.72k
        let top_rows = self.item_row_count(offset).saturating_sub(sub_offset);
344
2.72k
        if offset == current {
  Branch (344:12): [True: 2.49k, False: 185]
-
  Branch (344:12): [True: 34, False: 17]
-
345
2.52k
            return top_rows;
346
202
        }
347
202
        top_rows + self.rows_for_range(offset + 1, current - offset)
348
2.72k
    }
349
350
    /// Advance `(offset, sub_offset)` one row at a time until `current` fits
351
    /// within `available_rows`.  Returns the new `(offset, sub_offset)`.
352
7
    fn advance_to_fit(&self, current: usize, available_rows: usize) -> (usize, usize) {
353
7
        let mut offset = self.offset;
354
7
        let mut sub_offset = self.sub_offset;
355
45
        while offset < current && self.rows_visible(offset, sub_offset, current) > available_rows {
  Branch (355:15): [True: 29, False: 0]
-  Branch (355:35): [True: 23, False: 6]
-
  Branch (355:15): [True: 16, False: 0]
-  Branch (355:35): [True: 15, False: 1]
-
356
38
            let top_rows = self.item_row_count(offset);
357
38
            if sub_offset + 1 < top_rows {
  Branch (357:16): [True: 8, False: 15]
-
  Branch (357:16): [True: 0, False: 15]
-
358
8
                // Still more sub-lines in the top item: scroll one sub-line off.
359
8
                sub_offset += 1;
360
30
            } else {
361
30
                // Entire top item scrolled off: move to next item.
362
30
                offset += 1;
363
30
                sub_offset = 0;
364
30
            }
365
        }
366
7
        (offset, sub_offset)
367
7
    }
368
}
369
370
impl SkimWidget for ItemList {
371
552
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
372
        use crate::helper::selector::DefaultSkimSelector;
373
        use crate::util::read_file_lines;
374
375
552
        let skip_to_pattern = options
376
552
            .skip_to_pattern
377
552
            .as_ref()
378
552
            .and_then(|pattern| 
Regex::new1
(
pattern1
).
ok1
());
379
380
        // Build the selector from options and calculate pre-select target
381
552
        let (selector, pre_select_target) = if options.pre_select_n > 0
  Branch (381:48): [True: 12, False: 362]
-
  Branch (381:48): [True: 0, False: 178]
-
382
540
            || !options.pre_select_pat.is_empty()
  Branch (382:16): [True: 2, False: 360]
-
  Branch (382:16): [True: 0, False: 178]
-
383
538
            || !options.pre_select_items.is_empty()
  Branch (383:16): [True: 1, False: 359]
-
  Branch (383:16): [True: 0, False: 178]
-
384
537
            || options.pre_select_file.is_some()
  Branch (384:16): [True: 1, False: 358]
-
  Branch (384:16): [True: 0, False: 178]
-
385
536
            || options.selector.is_some()
  Branch (385:16): [True: 0, False: 358]
-
  Branch (385:16): [True: 0, False: 178]
-
386
        {
387
16
            if let Some(
s0
) = options.selector.clone() {
  Branch (387:20): [True: 0, False: 16]
-
  Branch (387:20): [True: 0, False: 0]
-
388
                // For custom selectors, use a very large target (pre-select all matching)
389
0
                (Some(s), usize::MAX)
390
            } else {
391
16
                let mut preset_items: Vec<String> = options
392
16
                    .pre_select_items
393
16
                    .split('\n')
394
16
                    .filter(|s| !s.is_empty())
395
16
                    .map(std::string::ToString::to_string)
396
16
                    .collect();
397
398
16
                if let Some(
ref pre_select_file1
) = options.pre_select_file
  Branch (398:24): [True: 1, False: 15]
-
  Branch (398:24): [True: 0, False: 0]
-
399
1
                    && let Ok(file_items) = read_file_lines(pre_select_file)
  Branch (399:28): [True: 1, False: 0]
-
  Branch (399:28): [True: 0, False: 0]
-
400
1
                {
401
1
                    preset_items.extend(file_items);
402
15
                }
403
404
16
                let selector = DefaultSkimSelector::default()
405
16
                    .first_n(options.pre_select_n)
406
16
                    .regex(&options.pre_select_pat)
407
16
                    .preset(preset_items.clone());
408
409
                // Only use a target for --pre-select-n
410
                // For pattern/items, the selector always returns the same matches regardless of timing
411
16
                let target = if options.pre_select_n > 0 {
  Branch (411:33): [True: 12, False: 4]
-
  Branch (411:33): [True: 0, False: 0]
-
412
12
                    options.pre_select_n
413
                } else {
414
4
                    usize::MAX // No target - keep selecting matching items
415
                };
416
417
16
                (Some(Rc::new(selector) as Rc<dyn Selector>), target)
418
            }
419
        } else {
420
536
            (None, 0)
421
        };
422
423
552
        let processed_items = Arc::new(SpinLock::new(None));
424
425
552
        let interactive = options.interactive;
426
552
        let no_clear_if_empty = options.no_clear_if_empty;
427
552
        let multi_select = options.multi;
428
429
        // Spawn background processing thread with the appropriate configuration
430
        Self {
431
552
            processed_items,
432
            reserved: 0, // header_lines are now displayed in the Header widget, not ItemList
433
552
            direction: match options.layout {
434
532
                TuiLayout::Default => ratatui::widgets::ListDirection::BottomToTop,
435
20
                TuiLayout::Reverse | TuiLayout::ReverseList => ratatui::widgets::ListDirection::TopToBottom,
436
            },
437
            current: 0,
438
552
            theme,
439
552
            multi_select,
440
552
            no_hscroll: options.no_hscroll,
441
552
            ellipsis: options.ellipsis.clone(),
442
552
            keep_right: options.keep_right,
443
552
            skip_to_pattern,
444
552
            tabstop: options.tabstop.max(1),
445
552
            selector,
446
552
            pre_select_target,
447
552
            no_clear_if_empty,
448
552
            interactive,
449
            showing_stale_items: false,
450
            manual_hscroll: 0,
451
552
            items: Default::default(),
452
552
            selection: Default::default(),
453
552
            offset: Default::default(),
454
            sub_offset: 0,
455
552
            height: Default::default(),
456
552
            selector_icon: options.selector_icon.clone(),
457
552
            multi_select_icon: options.multi_select_icon.clone(),
458
552
            cycle: options.cycle,
459
552
            wrap: options.wrap_items,
460
552
            multiline: options
461
552
                .multiline
462
552
                .clone()
463
552
                .map(|opt_m| 
opt_m38
.
unwrap_or38
(
String::from38
("\\n"))),
464
552
            border: options.border,
465
552
            show_score: feature_flag!(options, ShowScore),
466
552
            show_index: feature_flag!(options, ShowIndex),
467
552
            highlight_line: options.highlight_line,
468
552
            scrollbar_thumb: options.scrollbar.clone(),
469
        }
470
552
    }
471
472
    // The render function handles the full item list rendering pipeline; splitting
473
    // it into smaller helpers would require passing many parameters between them.
474
    #[allow(clippy::too_many_lines)]
475
2.68k
    fn render(&mut self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) -> SkimRender {
476
2.68k
        let this = &mut *self;
477
478
        // Calculate inner area if borders are enabled
479
2.68k
        let inner_area = if this.border.is_some() {
  Branch (479:29): [True: 66, False: 2.58k]
-
  Branch (479:29): [True: 0, False: 35]
-
480
66
            ratatui::layout::Rect {
481
66
                x: area.x + 1,
482
66
                y: area.y + 1,
483
66
                width: area.width.saturating_sub(2),
484
66
                height: area.height.saturating_sub(2),
485
66
            }
486
        } else {
487
2.61k
            area
488
        };
489
490
2.68k
        this.height = inner_area.height;
491
2.68k
        let available_rows = inner_area.height as usize;
492
493
        // Clamp current to valid range after any item list replacement.
494
2.68k
        if this.items.is_empty() {
  Branch (494:12): [True: 879, False: 1.76k]
-
  Branch (494:12): [True: 3, False: 32]
-
495
882
            this.current = 0;
496
882
            this.offset = 0;
497
1.79k
        } else {
498
1.79k
            this.current = this.current.min(this.items.len() - 1).max(this.reserved);
499
1.79k
        }
500
501
2.68k
        if this.current < this.offset {
  Branch (501:12): [True: 0, False: 2.64k]
-
  Branch (501:12): [True: 0, False: 35]
-
502
0
            // Cursor moved above the top item: snap to it with no sub-line offset.
503
0
            this.offset = this.current;
504
0
            this.sub_offset = 0;
505
2.68k
        } else if this.rows_visible(this.offset, this.sub_offset, this.current) > available_rows {
  Branch (505:19): [True: 6, False: 2.64k]
-
  Branch (505:19): [True: 1, False: 34]
-
506
7
            // Current item is below the visible window: advance one row at a time.
507
7
            (this.offset, this.sub_offset) = this.advance_to_fit(this.current, available_rows);
508
2.67k
        }
509
2.68k
        let initial_current = this.selected();
510
511
        // Check for pre-processed items from background thread (non-blocking).
512
        // Bind the result separately so the lock guard is dropped before a merge
513
        // mutates the item list.
514
2.68k
        let processed = this.processed_items.lock().take();
515
2.68k
        let items_updated = if let Some(
processed738
) = processed {
  Branch (515:36): [True: 732, False: 1.91k]
-
  Branch (515:36): [True: 6, False: 29]
-
516
738
            debug!("Render: Got {} processed items", 
processed.items0
.
len0
());
517
518
            // Check if items are empty or blank for no_clear_if_empty handling
519
738
            let items_are_empty_or_blank =
520
738
                processed.items.is_empty() || 
processed.items.iter()576
.
all576
(|item|
item.item.text().trim()576
.
is_empty576
());
  Branch (520:17): [True: 162, False: 570]
-
  Branch (520:17): [True: 0, False: 6]
-
521
522
738
            if this.interactive && 
this.no_clear_if_empty84
&&
items_are_empty_or_blank2
&&
!this.items.is_empty()1
{
  Branch (522:16): [True: 84, False: 648]
-  Branch (522:36): [True: 2, False: 82]
-  Branch (522:62): [True: 1, False: 1]
-  Branch (522:90): [True: 1, False: 0]
-
  Branch (522:16): [True: 0, False: 6]
-  Branch (522:36): [True: 0, False: 0]
-  Branch (522:62): [True: 0, False: 0]
-  Branch (522:90): [True: 0, False: 0]
-
523
1
                debug!(
524
                    "no_clear_if_empty: keeping {} old items for display (new items are empty/blank)",
525
0
                    this.items.len()
526
                );
527
1
                this.showing_stale_items = true;
528
            } else {
529
737
                match processed.merge {
530
725
                    MergeStrategy::Replace => {
531
725
                        this.items = processed.items;
532
725
                        this.sub_offset = 0;
533
725
                    }
534
9
                    MergeStrategy::SortedMerge => {
535
9
                        let existing = std::mem::take(&mut this.items);
536
9
                        this.items = MatchedItem::sorted_merge(existing, processed.items);
537
9
                        this.sub_offset = 0;
538
9
                    }
539
1
                    MergeStrategy::Append => {
540
1
                        this.items.extend(processed.items);
541
1
                    }
542
2
                    MergeStrategy::Prepend => {
543
2
                        this.prepend(processed.items);
544
2
                    }
545
                }
546
737
                this.showing_stale_items = false;
547
548
                // Apply pre-selection only when new items arrive and only if we haven't reached target
549
                // This runs once per item batch, not on every render
550
737
                if this.multi_select
  Branch (550:20): [True: 28, False: 703]
-
  Branch (550:20): [True: 0, False: 6]
-
551
28
                    && let Some(
selector16
) = &this.selector
  Branch (551:28): [True: 16, False: 12]
-
  Branch (551:28): [True: 0, False: 0]
-
552
16
                    && this.selection.len() < this.pre_select_target
  Branch (552:24): [True: 16, False: 0]
-
  Branch (552:24): [True: 0, False: 0]
-
553
                {
554
16
                    debug!(
555
                        "Applying pre-selection to {} items (currently {} selected, target {})",
556
0
                        this.items.len(),
557
0
                        this.selection.len(),
558
                        this.pre_select_target
559
                    );
560
45
                    for (index, item) in 
this.items.iter()16
.
enumerate16
() {
561
45
                        if this.selection.len() >= this.pre_select_target {
  Branch (561:28): [True: 11, False: 34]
-
  Branch (561:28): [True: 0, False: 0]
-
562
11
                            break;
563
34
                        }
564
34
                        let should_select = selector.should_select(index, item.item.as_ref());
565
34
                        if should_select {
  Branch (565:28): [True: 27, False: 7]
-
  Branch (565:28): [True: 0, False: 0]
-
566
27
                            debug!("Pre-selecting item[{}]: '{}'", index, 
item.item.text()0
);
567
27
                            this.selection.insert(item.clone());
568
7
                        }
569
                    }
570
16
                    debug!("Pre-selected {} items total", 
this.selection0
.
len0
());
571
721
                }
572
            }
573
574
738
            true
575
        } else {
576
1.94k
            false
577
        };
578
579
2.68k
        let icon_width = this.selector_icon.chars().count() + this.multi_select_icon.chars().count();
580
2.68k
        let container_width = (inner_area.width as usize).saturating_sub(icon_width);
581
2.68k
        let sub_offset = this.sub_offset;
582
583
2.68k
        let renderer = ItemRenderer::new_for(this, container_width);
584
585
2.68k
        let mut flat_rows: Vec<ListItem<'static>> = Vec::with_capacity(available_rows + 1);
586
2.68k
        let mut rows_used = 0usize;
587
588
6.53k
        for (idx, item) in 
this.items.iter()2.68k
.
enumerate2.68k
().
skip2.68k
(
this.offset2.68k
) {
589
6.53k
            if rows_used >= available_rows {
  Branch (589:16): [True: 76, False: 6.33k]
-
  Branch (589:16): [True: 15, False: 106]
-
590
91
                break;
591
6.44k
            }
592
6.44k
            let is_current = idx == this.current;
593
6.44k
            let is_selected = this.selection.contains(item);
594
6.44k
            let skip_subs = if idx == this.offset { 
sub_offset2.09k
} else {
04.35k
};
  Branch (594:32): [True: 2.05k, False: 4.28k]
-
  Branch (594:32): [True: 33, False: 73]
-
595
6.44k
            rows_used += renderer.render_item(
596
6.44k
                item,
597
6.44k
                is_current,
598
6.44k
                is_selected,
599
6.44k
                skip_subs,
600
6.44k
                available_rows,
601
6.44k
                rows_used,
602
6.44k
                &mut flat_rows,
603
6.44k
            );
604
        }
605
606
2.68k
        let list = List::new(flat_rows).direction(this.direction).style(this.theme.normal);
607
608
2.68k
        Widget::render(Clear, area, buf);
609
610
        // Render border if enabled
611
2.68k
        if let Some(
border_type66
) = this.border.into_ratatui() {
  Branch (611:16): [True: 66, False: 2.58k]
-
  Branch (611:16): [True: 0, False: 35]
-
612
66
            let block = Block::default()
613
66
                .borders(Borders::ALL)
614
66
                .border_type(border_type)
615
66
                .border_style(this.theme.border);
616
66
            Widget::render(block, area, buf);
617
2.61k
        }
618
619
        // We manage offset and selection styling ourselves, so render as a plain
620
        // Widget — this bypasses ratatui's get_items_bounds which can index out of
621
        // bounds on our pre-sliced flat_rows when the selected row is near the edge.
622
2.68k
        Widget::render(list, inner_area, buf);
623
624
        // Render the scrollbar on top of the rightmost column of inner_area, but only
625
        // when there are more items than fit on screen (nothing to scroll → no bar).
626
2.68k
        if !this.scrollbar_thumb.is_empty() && 
this.items2.64k
.len() > available_rows {
  Branch (626:12): [True: 2.63k, False: 8]
-  Branch (626:48): [True: 50, False: 2.58k]
-
  Branch (626:12): [True: 5, False: 30]
-  Branch (626:48): [True: 5, False: 0]
-
627
55
            // Use offset directly as the scroll position for all layout directions:
628
55
            // offset == 0           → thumb at top    (beginning of content)
629
55
            // offset == max_offset  → thumb at bottom (end of content)
630
55
            // This matches conventional scrollbar behaviour regardless of list direction.
631
55
            let mut scrollbar_state = ScrollbarState::new(this.items.len()).position(this.current);
632
55
            // .viewport_content_length(available_rows);
633
55
634
55
            // Both Default and Custom use a thumb-only style (no track/begin/end arrows).
635
55
            // Default uses ▐ (right half-block), which gives a clean minimal look.
636
55
            // Without an explicit thumb_style the thumb inherits whatever fg/bg the
637
55
            // row painted underneath it, so it picks up the current-line highlight as
638
55
            // the cursor scrolls past; the themed `scrollbar` color keeps it uniform.
639
55
            let scrollbar: Scrollbar<'_> = Scrollbar::new(ScrollbarOrientation::VerticalRight)
640
55
                .thumb_symbol(&self.scrollbar_thumb)
641
55
                .thumb_style(self.theme.scrollbar)
642
55
                .track_symbol(None)
643
55
                .begin_symbol(None)
644
55
                .end_symbol(None);
645
55
            StatefulWidget::render(scrollbar, inner_area, buf, &mut scrollbar_state);
646
2.62k
        }
647
648
2.68k
        let run_preview = if let Some(
curr2.09k
) = self.selected()
  Branch (648:34): [True: 2.05k, False: 588]
-
  Branch (648:34): [True: 33, False: 2]
-
649
2.09k
            && let Some(
prev1.77k
) = initial_current
  Branch (649:20): [True: 1.74k, False: 317]
-
  Branch (649:20): [True: 32, False: 1]
-
650
        {
651
1.77k
            curr.text() != prev.text()
652
        } else {
653
908
            self.selected().is_some() != initial_current.is_some()
654
        };
655
2.68k
        SkimRender {
656
2.68k
            items_updated,
657
2.68k
            run_preview,
658
2.68k
        }
659
2.68k
    }
660
}
661
662
40
fn toggle_item(sel: &mut IndexSet<MatchedItem>, item: &MatchedItem) {
663
40
    if sel.contains(item) {
  Branch (663:8): [True: 0, False: 12]
-
  Branch (663:8): [True: 8, False: 20]
-
664
8
        sel.shift_remove(item);
665
32
    } else if !item.disabled() {
  Branch (665:15): [True: 12, False: 0]
-
  Branch (665:15): [True: 19, False: 1]
-
666
31
        sel.insert(item.clone());
667
31
    
}1
668
40
}
669
670
#[cfg(test)]
671
#[path = "item_list_tests.rs"]
672
mod tests;
\ No newline at end of file +

Coverage Report

Created: 2026-09-05 18:50

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.81k
    fn cursor(&self) -> usize {
105
9.81k
        self.current
106
9.81k
    }
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.items2.68k
.
len2.68k
() }
  Branch (113:12): [True: 4, False: 2.65k]
+
  Branch (113:12): [True: 1, False: 27]
+
114
2.68k
    }
115
116
    /// Returns the currently selected item, if any
117
    #[must_use]
118
9.79k
    pub fn selected(&self) -> Option<MatchedItem> {
119
9.79k
        let item = self.items.get(self.cursor());
120
9.79k
        if item.is_some_and(|i| !
i.item.disabled()6.81k
) {
  Branch (120:12): [True: 6.66k, False: 2.94k]
+
  Branch (120:12): [True: 149, False: 31]
+
121
6.81k
            item.cloned()
122
        } else {
123
2.97k
            None
124
        }
125
9.79k
    }
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]
+
146
1
            self.offset = 0;
147
1
            self.sub_offset = 0;
148
1
        } else {
149
1
            self.current = self.current.saturating_add(added);
150
1
            self.offset = self.offset.saturating_add(added);
151
1
        }
152
2
    }
153
154
    /// Toggles the selection state of the item at the given index
155
26
    pub fn toggle_at(&mut self, index: usize) {
156
26
        if self.items.is_empty() {
  Branch (156:12): [True: 0, False: 12]
+
  Branch (156:12): [True: 1, False: 13]
+
157
1
            return;
158
25
        }
159
25
        let item = &self.items[index];
160
25
        trace!("Toggled item {} at index {}", 
item.text()0
, index);
161
25
        toggle_item(&mut self.selection, item);
162
25
        trace!(
163
            "Selection is now {:#?}",
164
0
            self.selection.iter().map(|item| item.item.text()).collect::<Vec<_>>()
165
        );
166
26
    }
167
    /// Toggles the selection state of the currently selected item
168
21
    pub fn toggle(&mut self) {
169
21
        self.toggle_at(self.cursor());
170
21
    }
171
    /// Toggles the selection state of all items
172
5
    pub fn toggle_all(&mut self) {
173
15
        for item in 
&self.items5
{
174
15
            toggle_item(&mut self.selection, item);
175
15
        }
176
5
    }
177
178
    /// Add row at cursor to selection
179
4
    pub fn select(&mut self) {
180
4
        debug!("{}", 
self0
.
cursor0
());
181
4
        self.select_row(self.cursor());
182
4
    }
183
184
    /// Add row to selection
185
9
    pub fn select_row(&mut self, index: usize) {
186
9
        let item = self.items[index].clone();
187
9
        if !item.disabled() {
  Branch (187:12): [True: 1, False: 0]
+
  Branch (187:12): [True: 7, False: 1]
+
188
8
            self.selection.insert(item);
189
8
        
}1
190
9
    }
191
    /// Selects all items
192
8
    pub fn select_all(&mut self) {
193
20
        for item in 
self.items8
.
clone8
() {
194
20
            if !item.disabled() {
  Branch (194:16): [True: 3, False: 0]
+
  Branch (194:16): [True: 17, False: 0]
+
195
20
                self.selection.insert(item.clone());
196
20
            
}0
197
        }
198
8
    }
199
    /// Clears all selections
200
7
    pub fn clear_selection(&mut self) {
201
7
        self.selection.clear();
202
7
    }
203
    /// Clears all items from the list
204
43
    pub fn clear(&mut self) {
205
43
        self.items.clear();
206
43
        self.selection.clear();
207
43
        self.current = 0;
208
43
        self.offset = 0;
209
43
        self.sub_offset = 0;
210
43
        self.showing_stale_items = false;
211
43
    }
212
    /// Scrolls the list by `rows` terminal rows, counting each item's sub-lines.
213
    /// Positive = toward higher indices (up the screen in default layout).
214
15
    pub fn scroll_by_rows(&mut self, rows: i32) {
215
15
        if self.reserved >= self.items.len() || rows == 0 {
  Branch (215:12): [True: 0, False: 0]
+  Branch (215:49): [True: 0, False: 0]
+
  Branch (215:12): [True: 0, False: 15]
+  Branch (215:49): [True: 1, False: 14]
+
216
1
            return;
217
14
        }
218
14
        let total = self.items.len();
219
14
        let reserved = self.reserved;
220
221
14
        if rows > 0 {
  Branch (221:12): [True: 0, False: 0]
+
  Branch (221:12): [True: 7, False: 7]
+
222
7
            let mut remaining = rows.unsigned_abs() as usize;
223
7
            let mut idx = self.current;
224
28
            while remaining > 0 && 
idx + 1 < total23
{
  Branch (224:19): [True: 0, False: 0]
+  Branch (224:36): [True: 0, False: 0]
+
  Branch (224:19): [True: 23, False: 5]
+  Branch (224:36): [True: 21, False: 2]
+
225
21
                idx += 1;
226
21
                let row_count = self.item_row_count(idx);
227
21
                remaining = remaining.saturating_sub(row_count);
228
21
            }
229
7
            self.current = idx;
230
        } else {
231
7
            let mut remaining = rows.unsigned_abs() as usize;
232
7
            let mut idx = self.current;
233
20
            while remaining > 0 && 
idx > reserved16
{
  Branch (233:19): [True: 0, False: 0]
+  Branch (233:36): [True: 0, False: 0]
+
  Branch (233:19): [True: 16, False: 4]
+  Branch (233:36): [True: 13, False: 3]
+
234
13
                let row_count = self.item_row_count(idx);
235
13
                remaining = remaining.saturating_sub(row_count);
236
13
                idx -= 1;
237
13
            }
238
7
            self.current = idx.max(reserved);
239
        }
240
15
    }
241
242
    /// Scrolls the list by the given offset
243
90
    pub fn scroll_by(&mut self, offset: i32) {
244
90
        if self.reserved >= self.items.len() {
  Branch (244:12): [True: 0, False: 68]
+
  Branch (244:12): [True: 0, False: 22]
+
245
0
            return;
246
90
        }
247
90
        let reserved = i32::try_from(self.reserved).unwrap_or(0);
248
90
        let total = i32::try_from(self.items.len()).unwrap_or(i32::MAX);
249
90
        let mut new = i32::try_from(self.current).unwrap_or(0) + offset;
250
90
        if self.cycle {
  Branch (250:12): [True: 6, False: 62]
+
  Branch (250:12): [True: 2, False: 20]
+
251
8
            let n = total - reserved;
252
8
            new = reserved + (new + n - reserved) % n;
253
82
        } else {
254
82
            new = new.min(total - 1).max(reserved);
255
82
        }
256
90
        self.current = new.max(0).unsigned_abs() as usize;
257
90
        debug!("Scrolled to {}", self.current);
258
90
        debug!("Selection: {:?}", self.selection);
259
90
    }
260
    /// Selects the previous item in the list
261
4
    pub fn select_previous(&mut self) {
262
4
        self.scroll_by(-1);
263
4
    }
264
    /// Selects the next item in the list
265
4
    pub fn select_next(&mut self) {
266
4
        self.scroll_by(1);
267
4
    }
268
    /// Jump to the first selectable item (respecting reserved header lines)
269
10
    pub fn jump_to_first(&mut self) {
270
10
        if self.items.len() > self.reserved {
  Branch (270:12): [True: 5, False: 0]
+
  Branch (270:12): [True: 4, False: 1]
+
271
9
            self.current = self.reserved;
272
9
            self.offset = self.reserved;
273
9
            self.sub_offset = 0;
274
9
        
}1
275
10
    }
276
    /// Given a terminal row within the list's rendered inner area (0 = topmost row),
277
    /// return the index of the item that occupies that row, or `None` if the row is
278
    /// empty (e.g. fewer items than the available height).
279
    ///
280
    /// Respects `direction`: in `BottomToTop` layouts item `offset` occupies the
281
    /// bottom row, so row 0 (top) maps to the highest-indexed visible item.
282
    #[must_use]
283
13
    pub fn item_at_visual_row(&self, row: usize) -> Option<usize> {
284
13
        let available_rows = self.height as usize;
285
13
        if row >= available_rows || 
self.items11
.
is_empty11
() {
  Branch (285:12): [True: 0, False: 1]
+  Branch (285:37): [True: 0, False: 1]
+
  Branch (285:12): [True: 2, False: 10]
+  Branch (285:37): [True: 0, False: 10]
+
286
2
            return None;
287
11
        }
288
        // In BottomToTop, flat_rows[0] is rendered at the BOTTOM terminal row.
289
        // Terminal row `r` from the top = flat_row index `available_rows - 1 - r`.
290
11
        let flat_row = match self.direction {
291
8
            ListDirection::BottomToTop => available_rows - 1 - row,
292
3
            ListDirection::TopToBottom => row,
293
        };
294
        // Walk items from `offset`, counting sub-lines, until we cover `flat_row`.
295
11
        let mut rows_counted = 0;
296
24
        for idx in 
self.offset11
..self.items.len() {
297
24
            let item_rows = if idx == self.offset {
  Branch (297:32): [True: 1, False: 1]
+
  Branch (297:32): [True: 10, False: 12]
+
298
11
                self.item_row_count(idx).saturating_sub(self.sub_offset)
299
            } else {
300
13
                self.item_row_count(idx)
301
            };
302
24
            rows_counted += item_rows;
303
24
            if rows_counted > flat_row {
  Branch (303:16): [True: 1, False: 1]
+
  Branch (303:16): [True: 10, False: 12]
+
304
11
                return Some(idx);
305
13
            }
306
13
            if rows_counted >= available_rows {
  Branch (306:16): [True: 0, False: 1]
+
  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
11
        
}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.16k
    fn item_row_count(&self, index: usize) -> usize {
326
4.16k
        if let Some(
sep701
) = self.multiline.as_deref()
  Branch (326:16): [True: 701, False: 3.12k]
+
  Branch (326:16): [True: 0, False: 342]
+
327
701
            && let Some(
item648
) = self.items.get(index)
  Branch (327:20): [True: 648, False: 53]
+
  Branch (327:20): [True: 0, False: 0]
+
328
        {
329
648
            item.item.text().split(sep).count().max(1)
330
        } else {
331
3.51k
            1
332
        }
333
4.16k
    }
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).
map208
(|i| self.item_row_count(i)).
sum208
()
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.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.74k]
+
  Branch (343:12): [True: 0, False: 51]
+
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.55k, False: 191]
+
  Branch (347:12): [True: 34, False: 17]
+
348
2.59k
            return top_rows;
349
208
        }
350
208
        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
8
                // Still more sub-lines in the top item: scroll one sub-line off.
362
8
                sub_offset += 1;
363
30
            } else {
364
30
                // Entire top item scrolled off: move to next item.
365
30
                offset += 1;
366
30
                sub_offset = 0;
367
30
            }
368
        }
369
7
        (offset, sub_offset)
370
7
    }
371
}
372
373
impl SkimWidget for ItemList {
374
568
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
375
        use crate::helper::selector::DefaultSkimSelector;
376
        use crate::util::read_file_lines;
377
378
568
        let skip_to_pattern = options
379
568
            .skip_to_pattern
380
568
            .as_ref()
381
568
            .and_then(|pattern| 
Regex::new1
(
pattern1
).
ok1
());
382
383
        // Build the selector from options and calculate pre-select target
384
568
        let (selector, pre_select_target) = if options.pre_select_n > 0
  Branch (384:48): [True: 11, False: 378]
+
  Branch (384:48): [True: 0, False: 179]
+
385
557
            || !options.pre_select_pat.is_empty()
  Branch (385:16): [True: 3, False: 375]
+
  Branch (385:16): [True: 0, False: 179]
+
386
554
            || !options.pre_select_items.is_empty()
  Branch (386:16): [True: 2, False: 373]
+
  Branch (386:16): [True: 0, False: 179]
+
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
17
            if let Some(
s0
) = options.selector.clone() {
  Branch (390:20): [True: 0, False: 17]
+
  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
17
                let mut preset_items: Vec<String> = options
395
17
                    .pre_select_items
396
17
                    .split('\n')
397
17
                    .filter(|s| !s.is_empty())
398
17
                    .map(std::string::ToString::to_string)
399
17
                    .collect();
400
401
17
                if let Some(
ref pre_select_file1
) = options.pre_select_file
  Branch (401:24): [True: 1, False: 16]
+
  Branch (401:24): [True: 0, 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
1
                {
404
1
                    preset_items.extend(file_items);
405
16
                }
406
407
17
                let selector = DefaultSkimSelector::default()
408
17
                    .first_n(options.pre_select_n)
409
17
                    .regex(&options.pre_select_pat)
410
17
                    .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
17
                let target = if options.pre_select_n > 0 {
  Branch (414:33): [True: 11, False: 6]
+
  Branch (414:33): [True: 0, False: 0]
+
415
11
                    options.pre_select_n
416
                } else {
417
6
                    usize::MAX // No target - keep selecting matching items
418
                };
419
420
17
                (Some(Rc::new(selector) as Rc<dyn Selector>), target)
421
            }
422
        } else {
423
551
            (None, 0)
424
        };
425
426
568
        let processed_items = Arc::new(Mutex::new(None));
427
568
        let matcher_generation = Arc::new(AtomicUsize::new(0));
428
429
568
        let interactive = options.interactive;
430
568
        let no_clear_if_empty = options.no_clear_if_empty;
431
568
        let multi_select = options.multi;
432
433
        // Spawn background processing thread with the appropriate configuration
434
        Self {
435
568
            processed_items,
436
568
            matcher_generation,
437
            reserved: 0, // header_lines are now displayed in the Header widget, not ItemList
438
568
            direction: match options.layout {
439
549
                TuiLayout::Default => ratatui::widgets::ListDirection::BottomToTop,
440
19
                TuiLayout::Reverse | TuiLayout::ReverseList => ratatui::widgets::ListDirection::TopToBottom,
441
            },
442
            current: 0,
443
568
            theme,
444
568
            multi_select,
445
568
            no_hscroll: options.no_hscroll,
446
568
            ellipsis: options.ellipsis.clone(),
447
568
            keep_right: options.keep_right,
448
568
            skip_to_pattern,
449
568
            tabstop: options.tabstop.max(1),
450
568
            selector,
451
568
            pre_select_target,
452
568
            no_clear_if_empty,
453
568
            interactive,
454
            showing_stale_items: false,
455
            manual_hscroll: 0,
456
568
            items: Default::default(),
457
568
            selection: Default::default(),
458
568
            offset: Default::default(),
459
            sub_offset: 0,
460
568
            height: Default::default(),
461
568
            selector_icon: options.selector_icon.clone(),
462
568
            multi_select_icon: options.multi_select_icon.clone(),
463
568
            cycle: options.cycle,
464
568
            wrap: options.wrap_items,
465
568
            multiline: options
466
568
                .multiline
467
568
                .clone()
468
568
                .map(|opt_m| 
opt_m41
.
unwrap_or41
(
String::from41
("\\n"))),
469
568
            border: options.border,
470
568
            show_score: feature_flag!(options, ShowScore),
471
568
            show_index: feature_flag!(options, ShowIndex),
472
568
            highlight_line: options.highlight_line,
473
568
            scrollbar_thumb: options.scrollbar.clone(),
474
        }
475
568
    }
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.75k
    fn render(&mut self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) -> SkimRender {
481
2.75k
        let this = &mut *self;
482
483
        // Calculate inner area if borders are enabled
484
2.75k
        let inner_area = if this.border.is_some() {
  Branch (484:29): [True: 66, False: 2.65k]
+
  Branch (484:29): [True: 0, False: 35]
+
485
66
            ratatui::layout::Rect {
486
66
                x: area.x + 1,
487
66
                y: area.y + 1,
488
66
                width: area.width.saturating_sub(2),
489
66
                height: area.height.saturating_sub(2),
490
66
            }
491
        } else {
492
2.68k
            area
493
        };
494
495
2.75k
        this.height = inner_area.height;
496
2.75k
        let available_rows = inner_area.height as usize;
497
498
        // Clamp current to valid range after any item list replacement.
499
2.75k
        if this.items.is_empty() {
  Branch (499:12): [True: 936, False: 1.78k]
+
  Branch (499:12): [True: 2, False: 33]
+
500
938
            this.current = 0;
501
938
            this.offset = 0;
502
1.81k
        } else {
503
1.81k
            this.current = this.current.min(this.items.len() - 1).max(this.reserved);
504
1.81k
        }
505
506
2.75k
        if this.current < this.offset {
  Branch (506:12): [True: 0, False: 2.71k]
+
  Branch (506:12): [True: 0, False: 35]
+
507
0
            // 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.75k
        } else if this.rows_visible(this.offset, this.sub_offset, this.current) > available_rows {
  Branch (510:19): [True: 6, False: 2.71k]
+
  Branch (510:19): [True: 1, False: 34]
+
511
7
            // 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.74k
        }
514
2.75k
        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.75k
        let processed = this
520
2.75k
            .processed_items
521
2.75k
            .lock()
522
2.75k
            .unwrap_or_else(std::sync::PoisonError::into_inner)
523
2.75k
            .take();
524
2.75k
        let current_generation = this.matcher_generation.load(Ordering::Acquire);
525
2.75k
        let processed = processed.filter(|result| 
result.generation765
==
current_generation765
);
526
2.75k
        let items_updated = if let Some(
processed763
) = processed {
  Branch (526:36): [True: 758, False: 1.96k]
+
  Branch (526:36): [True: 5, False: 30]
+
527
763
            debug!("Render: Got {} processed items", 
processed.items0
.
len0
());
528
529
            // Check if items are empty or blank for no_clear_if_empty handling
530
763
            let items_are_empty_or_blank =
531
763
                processed.items.is_empty() || 
processed.items.iter()577
.
all577
(|item|
item.item.text().trim()577
.
is_empty577
());
  Branch (531:17): [True: 186, False: 572]
+
  Branch (531:17): [True: 0, False: 5]
+
532
533
763
            if this.interactive && 
this.no_clear_if_empty85
&&
items_are_empty_or_blank2
&&
!this.items.is_empty()1
{
  Branch (533:16): [True: 85, False: 673]
+  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: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
762
                match processed.merge {
541
742
                    MergeStrategy::Replace => {
542
742
                        this.items = processed.items;
543
742
                        this.sub_offset = 0;
544
742
                    }
545
17
                    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
17
                    }
550
1
                    MergeStrategy::Append => {
551
1
                        this.items.extend(processed.items);
552
1
                    }
553
2
                    MergeStrategy::Prepend => {
554
2
                        this.prepend(processed.items);
555
2
                    }
556
                }
557
762
                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
762
                if this.multi_select
  Branch (561:20): [True: 29, False: 728]
+
  Branch (561:20): [True: 0, False: 5]
+
562
29
                    && let Some(
selector17
) = &this.selector
  Branch (562:28): [True: 17, False: 12]
+
  Branch (562:28): [True: 0, False: 0]
+
563
17
                    && this.selection.len() < this.pre_select_target
  Branch (563:24): [True: 17, False: 0]
+
  Branch (563:24): [True: 0, False: 0]
+
564
                {
565
17
                    debug!(
566
                        "Applying pre-selection to {} items (currently {} selected, target {})",
567
0
                        this.items.len(),
568
0
                        this.selection.len(),
569
                        this.pre_select_target
570
                    );
571
45
                    for (index, item) in 
this.items.iter()17
.
enumerate17
() {
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[{}]: '{}'", index, 
item.item.text()0
);
578
27
                            this.selection.insert(item.clone());
579
7
                        }
580
                    }
581
17
                    debug!("Pre-selected {} items total", 
this.selection0
.
len0
());
582
745
                }
583
            }
584
585
763
            true
586
        } else {
587
1.99k
            false
588
        };
589
590
2.75k
        let icon_width = this.selector_icon.chars().count() + this.multi_select_icon.chars().count();
591
2.75k
        let container_width = (inner_area.width as usize).saturating_sub(icon_width);
592
2.75k
        let sub_offset = this.sub_offset;
593
594
2.75k
        let renderer = ItemRenderer::new_for(this, container_width);
595
596
2.75k
        let mut flat_rows: Vec<ListItem<'static>> = Vec::with_capacity(available_rows + 1);
597
2.75k
        let mut rows_used = 0usize;
598
599
6.59k
        for (idx, item) in 
this.items.iter()2.75k
.
enumerate2.75k
().
skip2.75k
(
this.offset2.75k
) {
600
6.59k
            if rows_used >= available_rows {
  Branch (600:16): [True: 76, False: 6.39k]
+
  Branch (600:16): [True: 15, False: 107]
+
601
91
                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.39k
};
  Branch (605:32): [True: 2.07k, False: 4.31k]
+
  Branch (605:32): [True: 33, False: 74]
+
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.75k
        let list = List::new(flat_rows).direction(this.direction).style(this.theme.normal);
618
619
2.75k
        Widget::render(Clear, area, buf);
620
621
        // Render border if enabled
622
2.75k
        if let Some(
border_type66
) = this.border.into_ratatui() {
  Branch (622:16): [True: 66, False: 2.65k]
+
  Branch (622:16): [True: 0, False: 35]
+
623
66
            let block = Block::default()
624
66
                .borders(Borders::ALL)
625
66
                .border_type(border_type)
626
66
                .border_style(this.theme.border);
627
66
            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.75k
        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.75k
        if !this.scrollbar_thumb.is_empty() && 
this.items2.71k
.len() > available_rows {
  Branch (637:12): [True: 2.71k, False: 9]
+  Branch (637:48): [True: 50, False: 2.66k]
+
  Branch (637:12): [True: 5, False: 30]
+  Branch (637:48): [True: 5, False: 0]
+
638
55
            // Use offset directly as the scroll position for all layout directions:
639
55
            // offset == 0           → thumb at top    (beginning of content)
640
55
            // offset == max_offset  → thumb at bottom (end of content)
641
55
            // This matches conventional scrollbar behaviour regardless of list direction.
642
55
            let mut scrollbar_state = ScrollbarState::new(this.items.len()).position(this.current);
643
55
            // .viewport_content_length(available_rows);
644
55
645
55
            // Both Default and Custom use a thumb-only style (no track/begin/end arrows).
646
55
            // Default uses ▐ (right half-block), which gives a clean minimal look.
647
55
            // Without an explicit thumb_style the thumb inherits whatever fg/bg the
648
55
            // row painted underneath it, so it picks up the current-line highlight as
649
55
            // 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.69k
        }
658
659
2.75k
        let run_preview = if let Some(
curr2.10k
) = self.selected()
  Branch (659:34): [True: 2.07k, False: 644]
+
  Branch (659:34): [True: 33, False: 2]
+
660
2.10k
            && let Some(
prev1.79k
) = initial_current
  Branch (660:20): [True: 1.75k, False: 318]
+
  Branch (660:20): [True: 33, False: 0]
+
661
        {
662
1.79k
            curr.text() != prev.text()
663
        } else {
664
964
            self.selected().is_some() != initial_current.is_some()
665
        };
666
2.75k
        SkimRender {
667
2.75k
            items_updated,
668
2.75k
            run_preview,
669
2.75k
        }
670
2.75k
    }
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]
+
677
31
        sel.insert(item.clone());
678
31
    
}1
679
40
}
680
681
#[cfg(test)]
682
#[path = "item_list_tests.rs"]
683
mod tests;
\ No newline at end of file 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 4dbc9438..00ac048b 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-04 10: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.68k
    pub fn new_for(list: &'a ItemList, container_width: usize) -> Self {
55
2.68k
        Self {
56
2.68k
            theme: &list.theme,
57
2.68k
            selector_icon: &list.selector_icon,
58
2.68k
            multi_select_icon: &list.multi_select_icon,
59
2.68k
            ellipsis: &list.ellipsis,
60
2.68k
            container_width,
61
2.68k
            wrap: list.wrap,
62
2.68k
            multiline: list.multiline.as_deref(),
63
2.68k
            show_score: list.show_score,
64
2.68k
            show_index: list.show_index,
65
2.68k
            multi_select: list.multi_select,
66
2.68k
            tabstop: list.tabstop,
67
2.68k
            no_hscroll: list.no_hscroll,
68
2.68k
            keep_right: list.keep_right,
69
2.68k
            manual_hscroll: list.manual_hscroll,
70
2.68k
            skip_to_pattern: list.skip_to_pattern.as_ref(),
71
2.68k
            reverse_sub_lines: list.direction == ListDirection::BottomToTop,
72
2.68k
            highlight_line: list.highlight_line,
73
2.68k
        }
74
2.68k
    }
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.44k
    pub fn render_item(
85
6.44k
        &self,
86
6.44k
        item: &MatchedItem,
87
6.44k
        is_current: bool,
88
6.44k
        is_selected: bool,
89
6.44k
        skip_subs: usize,
90
6.44k
        available_rows: usize,
91
6.44k
        rows_used: usize,
92
6.44k
        out: &mut Vec<ListItem<'static>>,
93
6.44k
    ) -> usize {
94
6.44k
        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
6.44k
        let (display_text, match_start_char, match_end_char): (std::borrow::Cow<'_, str>, usize, usize) =
101
6.44k
            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-05 18:50

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.75k
    pub fn new_for(list: &'a ItemList, container_width: usize) -> Self {
55
2.75k
        Self {
56
2.75k
            theme: &list.theme,
57
2.75k
            selector_icon: &list.selector_icon,
58
2.75k
            multi_select_icon: &list.multi_select_icon,
59
2.75k
            ellipsis: &list.ellipsis,
60
2.75k
            container_width,
61
2.75k
            wrap: list.wrap,
62
2.75k
            multiline: list.multiline.as_deref(),
63
2.75k
            show_score: list.show_score,
64
2.75k
            show_index: list.show_index,
65
2.75k
            multi_select: list.multi_select,
66
2.75k
            tabstop: list.tabstop,
67
2.75k
            no_hscroll: list.no_hscroll,
68
2.75k
            keep_right: list.keep_right,
69
2.75k
            manual_hscroll: list.manual_hscroll,
70
2.75k
            skip_to_pattern: list.skip_to_pattern.as_ref(),
71
2.75k
            reverse_sub_lines: list.direction == ListDirection::BottomToTop,
72
2.75k
            highlight_line: list.highlight_line,
73
2.75k
        }
74
2.75k
    }
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.50k
    pub fn render_item(
85
6.50k
        &self,
86
6.50k
        item: &MatchedItem,
87
6.50k
        is_current: bool,
88
6.50k
        is_selected: bool,
89
6.50k
        skip_subs: usize,
90
6.50k
        available_rows: usize,
91
6.50k
        rows_used: usize,
92
6.50k
        out: &mut Vec<ListItem<'static>>,
93
6.50k
    ) -> usize {
94
6.50k
        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
6.50k
        let (display_text, match_start_char, match_end_char): (std::borrow::Cow<'_, str>, usize, usize) =
101
6.50k
            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.40k
                    let (start, end) = Self::matched_range(item_text.as_ref(), item.matched_range.as_ref());
114
6.40k
                    (item_text, start, end)
115
                }
116
            };
117
6.44k
        let sub_lines = self.split_sub_lines(display_text.as_ref());
118
119
6.44k
        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.44k
        let mut item_rows: Vec<ListItem<'static>> = Vec::new();
123
124
6.97k
        for (sub_idx, sub_text) in 
sub_lines.iter()6.44k
.
enumerate6.44k
().
skip6.44k
(
skip_subs6.44k
) {
125
6.97k
            if rows_used + added >= available_rows {
  Branch (125:16): [True: 0, False: 6.86k]
-
  Branch (125:16): [True: 0, False: 108]
-
126
0
                break;
127
6.97k
            }
128
129
6.97k
            let is_first = sub_idx == skip_subs;
130
6.97k
            let is_top_cutoff = is_first && 
skip_subs > 06.44k
;
  Branch (130:33): [True: 6.33k, False: 524]
-
  Branch (130:33): [True: 107, False: 1]
-
131
6.97k
            let is_bottom_cutoff = rows_used + added + 1 >= available_rows && 
sub_idx + 1106
< sub_lines.len();
  Branch (131:36): [True: 90, False: 6.77k]
-
  Branch (131:36): [True: 16, False: 92]
-
132
6.97k
            let list_item = self.render_sub_line(
133
6.97k
                item,
134
6.97k
                sub_text,
135
                &SubLineState {
136
6.97k
                    is_current,
137
6.97k
                    is_selected,
138
6.97k
                    is_first,
139
6.97k
                    is_first_sub_line: sub_idx == 0,
140
6.97k
                    needs_ellipsis: is_top_cutoff || 
is_bottom_cutoff6.96k
,
  Branch (140:37): [True: 0, False: 6.86k]
-
  Branch (140:37): [True: 1, False: 107]
-
141
                },
142
6.97k
                match_start_char,
143
6.97k
                match_end_char,
144
            );
145
146
6.97k
            item_rows.push(list_item);
147
6.97k
            added += 1;
148
        }
149
150
6.44k
        if self.reverse_sub_lines {
  Branch (150:12): [True: 6.08k, False: 258]
-
  Branch (150:12): [True: 106, False: 1]
-
151
6.18k
            item_rows.reverse();
152
6.18k
        
}259
153
6.44k
        out.extend(item_rows);
154
155
6.44k
        added
156
6.44k
    }
157
158
6.44k
    fn split_sub_lines<'b>(&self, item_text: &'b str) -> Vec<&'b str> {
159
6.44k
        if let Some(
sep667
) = self.multiline {
  Branch (159:16): [True: 665, False: 5.67k]
-
  Branch (159:16): [True: 2, False: 107]
-
160
667
            item_text.split(sep).collect()
161
        } else {
162
5.78k
            vec![item_text]
163
        }
164
6.44k
    }
165
166
6.40k
    fn matched_range(item_text: &str, matched_range: Option<&MatchRange>) -> (usize, usize) {
167
6.29k
        match matched_range {
168
1.66k
            Some(MatchRange::Chars(indices)) => {
169
1.66k
                if indices.is_empty() {
  Branch (169:20): [True: 6, False: 1.65k]
+
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.45k
                    let (start, end) = Self::matched_range(item_text.as_ref(), item.matched_range.as_ref());
114
6.45k
                    (item_text, start, end)
115
                }
116
            };
117
6.50k
        let sub_lines = self.split_sub_lines(display_text.as_ref());
118
119
6.50k
        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.50k
        let mut item_rows: Vec<ListItem<'static>> = Vec::new();
123
124
7.02k
        for (sub_idx, sub_text) in 
sub_lines.iter()6.50k
.
enumerate6.50k
().
skip6.50k
(
skip_subs6.50k
) {
125
7.02k
            if rows_used + added >= available_rows {
  Branch (125:16): [True: 0, False: 6.91k]
+
  Branch (125:16): [True: 0, False: 109]
+
126
0
                break;
127
7.02k
            }
128
129
7.02k
            let is_first = sub_idx == skip_subs;
130
7.02k
            let is_top_cutoff = is_first && 
skip_subs > 06.50k
;
  Branch (130:33): [True: 6.39k, False: 524]
+
  Branch (130:33): [True: 108, False: 1]
+
131
7.02k
            let is_bottom_cutoff = rows_used + added + 1 >= available_rows && 
sub_idx + 1106
< sub_lines.len();
  Branch (131:36): [True: 90, False: 6.82k]
+
  Branch (131:36): [True: 16, False: 93]
+
132
7.02k
            let list_item = self.render_sub_line(
133
7.02k
                item,
134
7.02k
                sub_text,
135
                &SubLineState {
136
7.02k
                    is_current,
137
7.02k
                    is_selected,
138
7.02k
                    is_first,
139
7.02k
                    is_first_sub_line: sub_idx == 0,
140
7.02k
                    needs_ellipsis: is_top_cutoff || 
is_bottom_cutoff7.02k
,
  Branch (140:37): [True: 0, False: 6.91k]
+
  Branch (140:37): [True: 1, False: 108]
+
141
                },
142
7.02k
                match_start_char,
143
7.02k
                match_end_char,
144
            );
145
146
7.02k
            item_rows.push(list_item);
147
7.02k
            added += 1;
148
        }
149
150
6.50k
        if self.reverse_sub_lines {
  Branch (150:12): [True: 6.13k, False: 258]
+
  Branch (150:12): [True: 107, False: 1]
+
151
6.24k
            item_rows.reverse();
152
6.24k
        
}259
153
6.50k
        out.extend(item_rows);
154
155
6.50k
        added
156
6.50k
    }
157
158
6.50k
    fn split_sub_lines<'b>(&self, item_text: &'b str) -> Vec<&'b str> {
159
6.50k
        if let Some(
sep667
) = self.multiline {
  Branch (159:16): [True: 665, False: 5.72k]
+
  Branch (159:16): [True: 2, False: 108]
+
160
667
            item_text.split(sep).collect()
161
        } else {
162
5.83k
            vec![item_text]
163
        }
164
6.50k
    }
165
166
6.46k
    fn matched_range(item_text: &str, matched_range: Option<&MatchRange>) -> (usize, usize) {
167
6.35k
        match matched_range {
168
1.69k
            Some(MatchRange::Chars(indices)) => {
169
1.69k
                if indices.is_empty() {
  Branch (169:20): [True: 6, False: 1.68k]
 
  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
4.63k
            Some(MatchRange::ByteRange(start, end)) => {
176
4.63k
                let start_char = item_text[..*start].chars().count();
177
4.63k
                let len = item_text[*start..*end].chars().count();
178
4.63k
                (start_char, start_char + len)
179
            }
180
1
            Some(MatchRange::CharRange(start, end)) => (*start, *end),
181
108
            None => (0, 0),
182
        }
183
6.40k
    }
184
185
6.97k
    fn render_sub_line(
186
6.97k
        &self,
187
6.97k
        item: &MatchedItem,
188
6.97k
        sub_text: &str,
189
6.97k
        state: &SubLineState,
190
6.97k
        match_start_char: usize,
191
6.97k
        match_end_char: usize,
192
6.97k
    ) -> ListItem<'static> {
193
6.97k
        let mut all_spans = self.prefix_spans(item, state);
194
6.97k
        let content_line = self.content_line(item, sub_text, state, match_start_char, match_end_char);
195
196
6.97k
        if state.needs_ellipsis {
  Branch (196:12): [True: 0, False: 6.86k]
-
  Branch (196:12): [True: 1, False: 107]
-
197
1
            all_spans.extend(self.trim_with_ellipsis(content_line, state.is_current));
198
6.96k
        } else {
199
6.96k
            all_spans.extend(content_line.spans);
200
6.96k
        }
201
202
6.97k
        if item.item.disabled() {
  Branch (202:12): [True: 0, False: 6.86k]
-
  Branch (202:12): [True: 0, False: 108]
-
203
0
            for span in &mut all_spans {
204
0
                span.style = span.style.dim();
205
0
            }
206
6.97k
        }
207
208
6.97k
        self.list_item_from_spans(all_spans, state.is_current)
209
6.97k
    }
210
211
6.97k
    fn prefix_spans(&self, item: &MatchedItem, state: &SubLineState) -> Vec<Span<'static>> {
212
6.97k
        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.97k
        let prefix_cursor_style = if self.highlight_line && 
state.is_current208
{
  Branch (216:38): [True: 207, False: 6.65k]
+
170
7
                    (0, 0)
171
                } else {
172
1.68k
                    (indices[0], indices[indices.len() - 1] + 1)
173
                }
174
            }
175
4.65k
            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.46k
    }
184
185
7.02k
    fn render_sub_line(
186
7.02k
        &self,
187
7.02k
        item: &MatchedItem,
188
7.02k
        sub_text: &str,
189
7.02k
        state: &SubLineState,
190
7.02k
        match_start_char: usize,
191
7.02k
        match_end_char: usize,
192
7.02k
    ) -> ListItem<'static> {
193
7.02k
        let mut all_spans = self.prefix_spans(item, state);
194
7.02k
        let content_line = self.content_line(item, sub_text, state, match_start_char, match_end_char);
195
196
7.02k
        if state.needs_ellipsis {
  Branch (196:12): [True: 0, False: 6.91k]
+
  Branch (196:12): [True: 1, False: 108]
+
197
1
            all_spans.extend(self.trim_with_ellipsis(content_line, state.is_current));
198
7.02k
        } else {
199
7.02k
            all_spans.extend(content_line.spans);
200
7.02k
        }
201
202
7.02k
        if item.item.disabled() {
  Branch (202:12): [True: 0, False: 6.91k]
+
  Branch (202:12): [True: 0, False: 109]
+
203
0
            for span in &mut all_spans {
204
0
                span.style = span.style.dim();
205
0
            }
206
7.02k
        }
207
208
7.02k
        self.list_item_from_spans(all_spans, state.is_current)
209
7.02k
    }
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: 110]
+
  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.96k
            self.theme.cursor
220
        };
221
6.97k
        let prefix_selected_style = if self.highlight_line && 
state.is_current208
{
  Branch (221:40): [True: 207, False: 6.65k]
+
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: 110]
+
  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.96k
            self.theme.selected
225
        };
226
227
6.97k
        prefix.push(Span::styled(
228
6.97k
            if state.is_first && 
state.is_current6.44k
{
  Branch (228:16): [True: 6.33k, False: 524]
-  Branch (228:34): [True: 2.05k, False: 4.28k]
-
  Branch (228:16): [True: 109, False: 2]
-  Branch (228:34): [True: 35, False: 74]
-
229
2.09k
                self.selector_icon.to_owned()
230
            } else {
231
4.88k
                str::repeat(" ", self.selector_icon.chars().count())
232
            },
233
6.97k
            prefix_cursor_style,
234
        ));
235
6.97k
        prefix.push(Span::styled(
236
6.97k
            if state.is_first && 
self.multi_select6.44k
&&
state.is_selected394
{
  Branch (236:16): [True: 6.33k, False: 524]
-  Branch (236:34): [True: 392, False: 5.94k]
-  Branch (236:55): [True: 150, False: 242]
-
  Branch (236:16): [True: 109, False: 2]
-  Branch (236:34): [True: 2, False: 107]
+
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.50k
{
  Branch (228:16): [True: 6.39k, False: 524]
+  Branch (228:34): [True: 2.07k, False: 4.31k]
+
  Branch (228:16): [True: 110, False: 2]
+  Branch (228:34): [True: 35, False: 75]
+
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.50k
&&
state.is_selected406
{
  Branch (236:16): [True: 6.39k, False: 524]
+  Branch (236:34): [True: 404, False: 5.98k]
+  Branch (236:55): [True: 153, False: 251]
+
  Branch (236:16): [True: 110, False: 2]
+  Branch (236:34): [True: 2, False: 108]
   Branch (236:55): [True: 2, False: 0]
-
237
152
                self.multi_select_icon.to_owned()
238
            } else {
239
6.82k
                str::repeat(" ", self.multi_select_icon.chars().count())
240
            },
241
6.97k
            prefix_selected_style,
242
        ));
243
6.97k
        if self.show_score {
  Branch (243:12): [True: 0, False: 6.86k]
-
  Branch (243:12): [True: 2, False: 109]
-
244
2
            self.push_first_line_field(&mut prefix, state, item.rank.score);
245
6.97k
        }
246
6.97k
        if self.show_index {
  Branch (246:12): [True: 0, False: 6.86k]
-
  Branch (246:12): [True: 2, False: 109]
-
247
2
            self.push_first_line_field(&mut prefix, state, item.rank.index);
248
6.97k
        }
249
250
6.97k
        prefix
251
6.97k
    }
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]
+
237
155
                self.multi_select_icon.to_owned()
238
            } else {
239
6.87k
                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.91k]
+
  Branch (243:12): [True: 2, False: 110]
+
244
2
            self.push_first_line_field(&mut prefix, state, item.rank.score);
245
7.02k
        }
246
7.02k
        if self.show_index {
  Branch (246:12): [True: 0, False: 6.91k]
+
  Branch (246:12): [True: 2, False: 110]
+
247
2
            self.push_first_line_field(&mut prefix, state, item.rank.index);
248
7.02k
        }
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.97k
    fn content_line(
271
6.97k
        &self,
272
6.97k
        item: &MatchedItem,
273
6.97k
        sub_text: &str,
274
6.97k
        state: &SubLineState,
275
6.97k
        match_start_char: usize,
276
6.97k
        match_end_char: usize,
277
6.97k
    ) -> Line<'static> {
278
6.97k
        if state.is_first && 
state.is_first_sub_line6.44k
{
  Branch (278:12): [True: 6.33k, False: 524]
-  Branch (278:30): [True: 6.33k, False: 0]
-
  Branch (278:12): [True: 107, False: 1]
-  Branch (278:30): [True: 106, False: 1]
-
279
6.44k
            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.97k
    }
284
285
6.44k
    fn first_sub_line_content(
286
6.44k
        &self,
287
6.44k
        item: &MatchedItem,
288
6.44k
        first_sub_line: &str,
289
6.44k
        is_current: bool,
290
6.44k
        match_start_char: usize,
291
6.44k
        match_end_char: usize,
292
6.44k
    ) -> Line<'static> {
293
6.44k
        let first_sub_char_len = first_sub_line.chars().count();
294
6.44k
        let dl = item.item.display(DisplayContext {
295
6.44k
            score: item.rank.score,
296
6.44k
            matches: Self::display_matches(item.matched_range.as_ref()),
297
6.44k
            container_width: self.container_width,
298
6.44k
            base_style: self.base_style(is_current),
299
6.44k
            matched_style: if is_current {
  Branch (299:31): [True: 2.05k, False: 4.28k]
-
  Branch (299:31): [True: 33, False: 74]
-
300
2.09k
                self.theme.current_match
301
            } else {
302
4.35k
                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.44k
        let mut line = if self.multiline.is_some() {
  Branch (308:27): [True: 665, False: 5.67k]
-
  Branch (308:27): [True: 0, False: 107]
-
309
665
            clip_line_to_chars(dl, first_sub_char_len)
310
        } else {
311
5.78k
            dl
312
        };
313
6.44k
        if !self.wrap {
  Branch (313:12): [True: 6.32k, False: 9]
-
  Branch (313:12): [True: 107, False: 0]
-
314
6.43k
            let (shift, full_width, _, _) = if self.multiline.is_some() {
  Branch (314:48): [True: 659, False: 5.67k]
-
  Branch (314:48): [True: 0, False: 107]
-
315
659
                self.calc_hscroll(first_sub_line, match_start_char, match_end_char)
316
            } else {
317
5.77k
                self.calc_line_hscroll(&line, first_sub_line, match_start_char, match_end_char)
318
            };
319
6.43k
            line = self.apply_hscroll(line, shift, full_width);
320
9
        }
321
6.44k
        Self::into_static_line(line)
322
6.44k
    }
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]
+
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.02k
    fn content_line(
271
7.02k
        &self,
272
7.02k
        item: &MatchedItem,
273
7.02k
        sub_text: &str,
274
7.02k
        state: &SubLineState,
275
7.02k
        match_start_char: usize,
276
7.02k
        match_end_char: usize,
277
7.02k
    ) -> Line<'static> {
278
7.02k
        if state.is_first && 
state.is_first_sub_line6.50k
{
  Branch (278:12): [True: 6.39k, False: 524]
+  Branch (278:30): [True: 6.39k, False: 0]
+
  Branch (278:12): [True: 108, False: 1]
+  Branch (278:30): [True: 107, False: 1]
+
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.02k
    }
284
285
6.50k
    fn first_sub_line_content(
286
6.50k
        &self,
287
6.50k
        item: &MatchedItem,
288
6.50k
        first_sub_line: &str,
289
6.50k
        is_current: bool,
290
6.50k
        match_start_char: usize,
291
6.50k
        match_end_char: usize,
292
6.50k
    ) -> Line<'static> {
293
6.50k
        let first_sub_char_len = first_sub_line.chars().count();
294
6.50k
        let dl = item.item.display(DisplayContext {
295
6.50k
            score: item.rank.score,
296
6.50k
            matches: Self::display_matches(item.matched_range.as_ref()),
297
6.50k
            container_width: self.container_width,
298
6.50k
            base_style: self.base_style(is_current),
299
6.50k
            matched_style: if is_current {
  Branch (299:31): [True: 2.07k, False: 4.31k]
+
  Branch (299:31): [True: 33, False: 75]
+
300
2.10k
                self.theme.current_match
301
            } else {
302
4.39k
                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.50k
        let mut line = if self.multiline.is_some() {
  Branch (308:27): [True: 665, False: 5.72k]
+
  Branch (308:27): [True: 0, False: 108]
+
309
665
            clip_line_to_chars(dl, first_sub_char_len)
310
        } else {
311
5.83k
            dl
312
        };
313
6.50k
        if !self.wrap {
  Branch (313:12): [True: 6.38k, False: 9]
+
  Branch (313:12): [True: 108, False: 0]
+
314
6.49k
            let (shift, full_width, _, _) = if self.multiline.is_some() {
  Branch (314:48): [True: 659, False: 5.72k]
+
  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.83k
                self.calc_line_hscroll(&line, first_sub_line, match_start_char, match_end_char)
318
            };
319
6.49k
            line = self.apply_hscroll(line, shift, full_width);
320
9
        }
321
6.50k
        Self::into_static_line(line)
322
6.50k
    }
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.49k
    fn display_matches(matched_range: Option<&MatchRange>) -> crate::Matches {
337
6.38k
        match matched_range {
338
4.69k
            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
108
            None => crate::Matches::None,
342
        }
343
6.49k
    }
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
3
        'trim: for span in 
content_line.spans2
{
352
3
            let span_chars: Vec<char> = span.content.chars().collect();
353
11
            for (i, ch) in 
span_chars.iter()3
.
enumerate3
() {
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.43k
        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.72k
            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
3
        'trim: for span in 
content_line.spans2
{
352
3
            let span_chars: Vec<char> = span.content.chars().collect();
353
11
            for (i, ch) in 
span_chars.iter()3
.
enumerate3
() {
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
1
                    
}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.97k
    fn list_item_from_spans(&self, spans: Vec<Span<'static>>, is_current: bool) -> ListItem<'static> {
371
6.97k
        if self.wrap {
  Branch (371:12): [True: 15, False: 6.84k]
-
  Branch (371:12): [True: 0, False: 108]
-
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
            )
376
15
            .into()
377
        } else {
378
6.95k
            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.95k
            if self.highlight_line && 
is_current207
{
  Branch (381:16): [True: 207, False: 6.64k]
+
358
1
                        trimmed.push(Span::styled(partial, span.style));
359
1
                    
}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.02k
    fn list_item_from_spans(&self, spans: Vec<Span<'static>>, is_current: bool) -> ListItem<'static> {
371
7.02k
        if self.wrap {
  Branch (371:12): [True: 15, False: 6.90k]
+
  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
            )
376
15
            .into()
377
        } else {
378
7.01k
            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.01k
            if self.highlight_line && 
is_current207
{
  Branch (381:16): [True: 207, False: 6.69k]
   Branch (381:39): [True: 9, False: 198]
-
  Branch (381:16): [True: 0, False: 108]
+
  Branch (381:16): [True: 0, False: 109]
   Branch (381:39): [True: 0, False: 0]
-
382
9
                line = line.style(self.theme.current);
383
6.94k
            }
384
6.95k
            line.into()
385
        }
386
6.97k
    }
387
388
6.97k
    fn base_style(&self, is_current: bool) -> ratatui::style::Style {
389
6.97k
        if is_current {
  Branch (389:12): [True: 2.17k, False: 4.68k]
-
  Branch (389:12): [True: 37, False: 79]
-
390
2.21k
            self.theme.current
391
        } else {
392
4.76k
            self.theme.normal
393
        }
394
6.97k
    }
395
396
6.96k
    fn into_static_line(line: Line<'_>) -> Line<'static> {
397
6.96k
        line.spans
398
6.96k
            .into_iter()
399
11.0k
            .
map6.96k
(|span| Span::styled(span.content.into_owned(), span.style))
400
6.96k
            .collect()
401
6.96k
    }
402
403
    // ── hscroll helpers ──────────────────────────────────────────────────────
404
405
5.23k
    fn calc_skip_width(&self, text: &str) -> usize {
406
5.23k
        if let Some(
regex7
) = self.skip_to_pattern
  Branch (406:16): [True: 7, False: 5.11k]
-
  Branch (406:16): [True: 0, False: 111]
+
382
9
                line = line.style(self.theme.current);
383
7.00k
            }
384
7.01k
            line.into()
385
        }
386
7.02k
    }
387
388
7.03k
    fn base_style(&self, is_current: bool) -> ratatui::style::Style {
389
7.03k
        if is_current {
  Branch (389:12): [True: 2.19k, False: 4.72k]
+
  Branch (389:12): [True: 37, False: 80]
+
390
2.23k
            self.theme.current
391
        } else {
392
4.80k
            self.theme.normal
393
        }
394
7.03k
    }
395
396
7.02k
    fn into_static_line(line: Line<'_>) -> Line<'static> {
397
7.02k
        line.spans
398
7.02k
            .into_iter()
399
11.2k
            .
map7.02k
(|span| Span::styled(span.content.into_owned(), span.style))
400
7.02k
            .collect()
401
7.02k
    }
402
403
    // ── hscroll helpers ──────────────────────────────────────────────────────
404
405
5.25k
    fn calc_skip_width(&self, text: &str) -> usize {
406
5.25k
        if let Some(
regex7
) = self.skip_to_pattern
  Branch (406:16): [True: 7, False: 5.13k]
+
  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.22k
        }
411
5.22k
        0
412
5.23k
    }
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.77k
    fn calc_line_hscroll(
421
5.77k
        &self,
422
5.77k
        line: &Line<'_>,
423
5.77k
        text: &str,
424
5.77k
        match_start_char: usize,
425
5.77k
        match_end_char: usize,
426
5.77k
    ) -> (usize, usize, bool, bool) {
427
5.77k
        self.calc_hscroll_for_width(text, match_start_char, match_end_char, self.line_display_width(line))
428
5.77k
    }
429
430
6.96k
    fn calc_hscroll_for_width(
431
6.96k
        &self,
432
6.96k
        text: &str,
433
6.96k
        match_start_char: usize,
434
6.96k
        match_end_char: usize,
435
6.96k
        full_width: usize,
436
6.96k
    ) -> (usize, usize, bool, bool) {
437
6.96k
        let ell_w = usize::try_from(display_width(self.ellipsis)).unwrap();
438
6.96k
        let 
available_width6.95k
= if self.container_width >= ell_w {
  Branch (438:34): [True: 6.84k, False: 0]
-
  Branch (438:34): [True: 112, False: 1]
-
439
6.95k
            self.container_width
440
        } else {
441
1
            return (0, full_width, false, false);
442
        };
443
444
6.95k
        let base_shift = if self.no_hscroll {
  Branch (444:29): [True: 3, False: 6.84k]
-
  Branch (444:29): [True: 0, False: 112]
-
445
3
            0
446
6.95k
        } else if match_start_char == 0 && 
match_end_char == 06.61k
{
  Branch (446:19): [True: 6.50k, False: 341]
-  Branch (446:44): [True: 5.12k, False: 1.38k]
-
  Branch (446:19): [True: 111, False: 1]
-  Branch (446:44): [True: 111, False: 0]
-
447
5.23k
            let skip_width = self.calc_skip_width(text);
448
5.23k
            if skip_width > 0 {
  Branch (448:16): [True: 7, False: 5.11k]
-
  Branch (448:16): [True: 0, False: 111]
-
449
7
                skip_width
450
5.22k
            } else if self.keep_right {
  Branch (450:23): [True: 0, False: 5.11k]
-
  Branch (450:23): [True: 1, False: 110]
-
451
1
                full_width.saturating_sub(available_width)
452
            } else {
453
5.22k
                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
13.6k
            for (idx, ch) in 
text1.72k
.
chars1.72k
().
enumerate1.72k
() {
463
13.6k
                if idx == match_start_char {
  Branch (463:20): [True: 1.72k, False: 11.8k]
+
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.83k
    fn calc_line_hscroll(
421
5.83k
        &self,
422
5.83k
        line: &Line<'_>,
423
5.83k
        text: &str,
424
5.83k
        match_start_char: usize,
425
5.83k
        match_end_char: usize,
426
5.83k
    ) -> (usize, usize, bool, bool) {
427
5.83k
        self.calc_hscroll_for_width(text, match_start_char, match_end_char, self.line_display_width(line))
428
5.83k
    }
429
430
7.01k
    fn calc_hscroll_for_width(
431
7.01k
        &self,
432
7.01k
        text: &str,
433
7.01k
        match_start_char: usize,
434
7.01k
        match_end_char: usize,
435
7.01k
        full_width: usize,
436
7.01k
    ) -> (usize, usize, bool, bool) {
437
7.01k
        let ell_w = usize::try_from(display_width(self.ellipsis)).unwrap();
438
7.01k
        let 
available_width7.01k
= if self.container_width >= ell_w {
  Branch (438:34): [True: 6.90k, False: 0]
+
  Branch (438:34): [True: 113, False: 1]
+
439
7.01k
            self.container_width
440
        } else {
441
1
            return (0, full_width, false, false);
442
        };
443
444
7.01k
        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
7.01k
        } else if match_start_char == 0 && 
match_end_char == 06.63k
{
  Branch (446:19): [True: 6.52k, False: 377]
+  Branch (446:44): [True: 5.14k, False: 1.37k]
+
  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]
+
  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]
+
  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.75k
            let mut match_start_width = 0;
457
1.75k
            let mut match_end_width = 0;
458
1.75k
            let mut current_width = 0;
459
1.75k
            let mut found_start = false;
460
1.75k
            let mut found_end = false;
461
462
13.9k
            for (idx, ch) in 
text1.75k
.
chars1.75k
().
enumerate1.75k
() {
463
13.9k
                if idx == match_start_char {
  Branch (463:20): [True: 1.75k, False: 12.1k]
 
  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.75k
                    match_start_width = current_width;
465
1.75k
                    found_start = true;
466
12.1k
                }
467
13.9k
                if idx == match_end_char {
  Branch (467:20): [True: 1.31k, False: 12.6k]
 
  Branch (467:20): [True: 1, False: 10]
-
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.72k
            if found_start && 
!found_end1.72k
{
  Branch (474:16): [True: 1.72k, False: 3]
-  Branch (474:31): [True: 431, False: 1.28k]
+
468
1.31k
                    match_end_width = current_width;
469
1.31k
                    found_end = true;
470
1.31k
                    break;
471
12.6k
                }
472
12.6k
                current_width = self.add_char_width(current_width, ch);
473
            }
474
1.75k
            if found_start && 
!found_end1.75k
{
  Branch (474:16): [True: 1.75k, False: 3]
+  Branch (474:31): [True: 440, False: 1.31k]
 
  Branch (474:16): [True: 1, False: 0]
   Branch (474:31): [True: 0, False: 1]
-
475
431
                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
440
                match_end_width = current_width;
476
1.31k
            }
477
478
1.75k
            let match_width = match_end_width.saturating_sub(match_start_width);
479
1.75k
            if match_width >= available_width {
  Branch (479:16): [True: 0, False: 1.75k]
 
  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.95k
        let proposed = (i32::try_from(base_shift).unwrap_or(i32::MAX) + self.manual_hscroll)
488
6.95k
            .max(0)
489
6.95k
            .unsigned_abs() as usize;
490
6.95k
        let shift = if full_width > available_width {
  Branch (490:24): [True: 16, False: 6.83k]
-
  Branch (490:24): [True: 4, False: 108]
-
491
20
            proposed.min(full_width.saturating_sub(available_width))
492
        } else {
493
6.93k
            proposed
494
        };
495
496
6.95k
        (shift, full_width, shift > 0, shift + available_width < full_width)
497
6.96k
    }
498
499
1.18k
    fn text_display_width(&self, text: &str) -> usize {
500
2.78k
        
text1.18k
.
chars1.18k
().
fold1.18k
(0usize, |width, ch| self.add_char_width(width, ch))
501
1.18k
    }
502
503
5.77k
    fn line_display_width(&self, line: &Line<'_>) -> usize {
504
20.9k
        
line.spans.iter()5.77k
.
fold5.77k
(0usize, |width, span| {
505
20.9k
            span.content
506
20.9k
                .chars()
507
41.4k
                .
fold20.9k
(
width20.9k
, |span_width, ch| self.add_char_width(span_width, ch))
508
20.9k
        })
509
5.77k
    }
510
511
98.1k
    fn add_char_width(&self, width: usize, ch: char) -> usize {
512
98.1k
        if ch == '\t' {
  Branch (512:12): [True: 135, False: 97.3k]
-
  Branch (512:12): [True: 0, False: 645]
-
513
135
            width + self.tabstop - (width % self.tabstop)
514
        } else {
515
98.0k
            width + char_display_width(ch)
516
        }
517
98.1k
    }
518
519
6.95k
    fn apply_hscroll<'b>(&'b self, line: Line<'b>, shift: usize, full_width: usize) -> Line<'b> {
520
6.95k
        let container_width = self.container_width;
521
6.95k
        let has_left = shift > 0;
522
6.95k
        let has_right = shift + container_width < full_width;
523
524
6.95k
        let ell_w = usize::try_from(display_width(self.ellipsis)).unwrap();
525
6.95k
        let left_w = if has_left { 
ell_w18
} else {
06.93k
};
  Branch (525:25): [True: 17, False: 6.83k]
-
  Branch (525:25): [True: 1, False: 109]
-
526
6.95k
        let right_w = if has_right { 
ell_w14
} else {
06.94k
};
  Branch (526:26): [True: 13, False: 6.83k]
-
  Branch (526:26): [True: 1, False: 109]
-
527
6.95k
        let content_width = container_width.saturating_sub(left_w + right_w);
528
529
6.95k
        let mut result = Line::default();
530
6.95k
        if has_left {
  Branch (530:12): [True: 17, False: 6.83k]
-
  Branch (530:12): [True: 1, False: 109]
-
531
18
            result.push_span(Span::raw(self.ellipsis));
532
6.93k
        }
533
534
6.95k
        let mut current_char_index = 0;
535
6.95k
        let mut current_width = 0;
536
6.95k
        let shift_char_start = self.char_index_at_width(&line, shift);
537
6.95k
        let shift_char_end = self.char_index_at_width(&line, shift + content_width);
538
539
22.1k
        for span in 
line.spans6.95k
{
540
22.1k
            let span_text = span.content.as_ref();
541
22.1k
            let span_chars: Vec<char> = span_text.chars().collect();
542
22.1k
            let span_start = current_char_index;
543
22.1k
            let span_end = current_char_index + span_chars.len();
544
545
22.1k
            if span_end > shift_char_start && 
span_start < shift_char_end12.9k
{
  Branch (545:16): [True: 12.8k, False: 9.18k]
-  Branch (545:47): [True: 12.4k, False: 416]
-
  Branch (545:16): [True: 110, False: 2]
-  Branch (545:47): [True: 110, 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]
-
  Branch (547:34): [True: 2, False: 108]
-
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_start11.0k
< span_chars.len() {
  Branch (552:20): [True: 10.9k, False: 1.51k]
-  Branch (552:43): [True: 10.9k, False: 0]
-
  Branch (552:20): [True: 110, False: 0]
-  Branch (552:43): [True: 110, 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]
-
  Branch (554:40): [True: 0, False: 110]
-
555
45
                        self.expand_tabs(&visible, current_width)
556
                    } else {
557
10.9k
                        visible
558
                    };
559
11.0k
                    if !processed.is_empty() {
  Branch (559:24): [True: 10.9k, False: 0]
-
  Branch (559:24): [True: 110, False: 0]
-
560
11.0k
                        result.push_span(Span::styled(processed, span.style));
561
11.0k
                    
}0
562
1.51k
                }
563
9.60k
            }
564
22.1k
            current_char_index += span_chars.len();
565
22.1k
            current_width += usize::try_from(display_width(span_text)).unwrap();
566
        }
567
568
6.95k
        if has_right {
  Branch (568:12): [True: 13, False: 6.83k]
-
  Branch (568:12): [True: 1, False: 109]
-
569
14
            result.push_span(Span::raw(self.ellipsis));
570
6.94k
        }
571
6.95k
        result
572
6.95k
    }
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
38.2k
        for span in 
&line.spans13.9k
{
578
48.3k
            for ch in 
span.content.chars()38.2k
{
579
48.3k
                if current_width >= target_width {
  Branch (579:20): [True: 6.82k, False: 41.0k]
-
  Branch (579:20): [True: 112, False: 315]
-
580
6.93k
                    return char_index;
581
41.3k
                }
582
41.3k
                current_width = self.add_char_width(current_width, ch);
583
41.3k
                char_index += 1;
584
            }
585
        }
586
6.98k
        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
180
        for ch in 
text45
.
chars45
() {
593
180
            if ch == '\t' {
  Branch (593:16): [True: 45, False: 135]
+
480
1
                match_start_width
481
            } else {
482
1.75k
                let desired = match_start_width.saturating_sub((available_width - match_width) / 2);
483
1.75k
                desired.min(full_width.saturating_sub(available_width))
484
            }
485
        };
486
487
7.01k
        let proposed = (i32::try_from(base_shift).unwrap_or(i32::MAX) + self.manual_hscroll)
488
7.01k
            .max(0)
489
7.01k
            .unsigned_abs() as usize;
490
7.01k
        let shift = if full_width > available_width {
  Branch (490:24): [True: 16, False: 6.88k]
+
  Branch (490:24): [True: 4, False: 109]
+
491
20
            proposed.min(full_width.saturating_sub(available_width))
492
        } else {
493
6.99k
            proposed
494
        };
495
496
7.01k
        (shift, full_width, shift > 0, shift + available_width < full_width)
497
7.01k
    }
498
499
1.18k
    fn text_display_width(&self, text: &str) -> usize {
500
2.78k
        
text1.18k
.
chars1.18k
().
fold1.18k
(0usize, |width, ch| self.add_char_width(width, ch))
501
1.18k
    }
502
503
5.83k
    fn line_display_width(&self, line: &Line<'_>) -> usize {
504
21.1k
        
line.spans.iter()5.83k
.
fold5.83k
(0usize, |width, span| {
505
21.1k
            span.content
506
21.1k
                .chars()
507
41.8k
                .
fold21.1k
(
width21.1k
, |span_width, ch| self.add_char_width(span_width, ch))
508
21.1k
        })
509
5.83k
    }
510
511
99.1k
    fn add_char_width(&self, width: usize, ch: char) -> usize {
512
99.1k
        if ch == '\t' {
  Branch (512:12): [True: 135, False: 98.3k]
+
  Branch (512:12): [True: 0, False: 663]
+
513
135
            width + self.tabstop - (width % self.tabstop)
514
        } else {
515
99.0k
            width + char_display_width(ch)
516
        }
517
99.1k
    }
518
519
7.01k
    fn apply_hscroll<'b>(&'b self, line: Line<'b>, shift: usize, full_width: usize) -> Line<'b> {
520
7.01k
        let container_width = self.container_width;
521
7.01k
        let has_left = shift > 0;
522
7.01k
        let has_right = shift + container_width < full_width;
523
524
7.01k
        let ell_w = usize::try_from(display_width(self.ellipsis)).unwrap();
525
7.01k
        let left_w = if has_left { 
ell_w18
} else {
06.99k
};
  Branch (525:25): [True: 17, False: 6.88k]
+
  Branch (525:25): [True: 1, False: 110]
+
526
7.01k
        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
7.01k
        let content_width = container_width.saturating_sub(left_w + right_w);
528
529
7.01k
        let mut result = Line::default();
530
7.01k
        if has_left {
  Branch (530:12): [True: 17, False: 6.88k]
+
  Branch (530:12): [True: 1, False: 110]
+
531
18
            result.push_span(Span::raw(self.ellipsis));
532
6.99k
        }
533
534
7.01k
        let mut current_char_index = 0;
535
7.01k
        let mut current_width = 0;
536
7.01k
        let shift_char_start = self.char_index_at_width(&line, shift);
537
7.01k
        let shift_char_end = self.char_index_at_width(&line, shift + content_width);
538
539
22.3k
        for span in 
line.spans7.01k
{
540
22.3k
            let span_text = span.content.as_ref();
541
22.3k
            let span_chars: Vec<char> = span_text.chars().collect();
542
22.3k
            let span_start = current_char_index;
543
22.3k
            let span_end = current_char_index + span_chars.len();
544
545
22.3k
            if span_end > shift_char_start && 
span_start < shift_char_end13.1k
{
  Branch (545:16): [True: 13.0k, False: 9.22k]
+  Branch (545:47): [True: 12.5k, False: 419]
+
  Branch (545:16): [True: 111, False: 0]
+  Branch (545:47): [True: 111, False: 0]
+
546
12.7k
                let vis_start = shift_char_start.saturating_sub(span_start);
547
12.7k
                let vis_end = if span_end > shift_char_end {
  Branch (547:34): [True: 12, False: 12.5k]
+
  Branch (547:34): [True: 2, False: 109]
+
548
14
                    shift_char_end - span_start
549
                } else {
550
12.6k
                    span_chars.len()
551
                };
552
12.7k
                if vis_start < vis_end && 
vis_start11.1k
< span_chars.len() {
  Branch (552:20): [True: 11.0k, False: 1.52k]
+  Branch (552:43): [True: 11.0k, False: 0]
+
  Branch (552:20): [True: 111, False: 0]
+  Branch (552:43): [True: 111, False: 0]
+
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: 11.0k]
+
  Branch (554:40): [True: 0, False: 111]
+
555
45
                        self.expand_tabs(&visible, current_width)
556
                    } else {
557
11.1k
                        visible
558
                    };
559
11.1k
                    if !processed.is_empty() {
  Branch (559:24): [True: 11.0k, False: 0]
+
  Branch (559:24): [True: 111, False: 0]
+
560
11.1k
                        result.push_span(Span::styled(processed, span.style));
561
11.1k
                    
}0
562
1.52k
                }
563
9.64k
            }
564
22.3k
            current_char_index += span_chars.len();
565
22.3k
            current_width += usize::try_from(display_width(span_text)).unwrap();
566
        }
567
568
7.01k
        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.99k
        }
571
7.01k
        result
572
7.01k
    }
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
38.5k
        for span in 
&line.spans14.0k
{
578
48.7k
            for ch in 
span.content.chars()38.5k
{
579
48.7k
                if current_width >= target_width {
  Branch (579:20): [True: 6.87k, False: 41.4k]
+
  Branch (579:20): [True: 113, False: 324]
+
580
6.98k
                    return char_index;
581
41.7k
                }
582
41.7k
                current_width = self.add_char_width(current_width, ch);
583
41.7k
                char_index += 1;
584
            }
585
        }
586
7.03k
        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
180
        for ch in 
text45
.
chars45
() {
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
135
            } else {
599
135
                result.push(ch);
600
135
                current_width = self.add_char_width(current_width, ch);
601
135
            }
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 39a76640..080fce58 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-04 10: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
586
    pub fn from_options(options: &SkimOptions, header_height: u16) -> Self {
79
586
        let has_border = options.border.is_some();
80
586
        let collapse_borders = has_border && 
!options.border_no_collapse33
;
  Branch (80:32): [True: 22, False: 366]
+

Coverage Report

Created: 2026-09-05 18:50

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
601
    pub fn from_options(options: &SkimOptions, header_height: u16) -> Self {
79
601
        let has_border = options.border.is_some();
80
601
        let collapse_borders = has_border && 
!options.border_no_collapse33
;
  Branch (80:32): [True: 22, False: 381]
 
  Branch (80:32): [True: 11, False: 187]
-
81
586
        let overlap = u16::from(collapse_borders);
82
83
        // Rows consumed by the input widget.
84
586
        let input_rows: u16 = if has_border {
  Branch (84:34): [True: 22, False: 366]
+
81
601
        let overlap = u16::from(collapse_borders);
82
83
        // Rows consumed by the input widget.
84
601
        let input_rows: u16 = if has_border {
  Branch (84:34): [True: 22, False: 381]
 
  Branch (84:34): [True: 11, False: 187]
-
85
33
            3 // 1 content + 2 border rows
86
        } else {
87
553
            1 + u16::from(
matches!24
(
88
553
                options.info.display,
89
                InfoDisplay::Default | InfoDisplay::Left | InfoDisplay::Right
90
            ))
91
        };
92
93
        // Rows consumed by the header widget.
94
586
        let show_header = options.header.is_some() || 
options.header_lines > 0545
;
  Branch (94:27): [True: 29, False: 359]
+
85
33
            3 // 1 content + 2 border rows
86
        } else {
87
568
            1 + u16::from(
matches!26
(
88
568
                options.info.display,
89
                InfoDisplay::Default | InfoDisplay::Left | InfoDisplay::Right
90
            ))
91
        };
92
93
        // Rows consumed by the header widget.
94
601
        let show_header = options.header.is_some() || 
options.header_lines > 0561
;
  Branch (94:27): [True: 28, False: 375]
 
  Branch (94:27): [True: 12, False: 186]
-
95
586
        let header_rows: u16 = if show_header {
  Branch (95:35): [True: 39, False: 349]
+
95
601
        let header_rows: u16 = if show_header {
  Branch (95:35): [True: 39, False: 364]
 
  Branch (95:35): [True: 13, False: 185]
-
96
52
            if has_border { 
header_height + 210
} else {
header_height42
}
  Branch (96:16): [True: 7, 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
534
            0
99
        };
100
101
        // Preview placement and layout.
102
586
        let preview_visible = (options.preview.is_some() || 
options.preview_fn532
.
is_some532
())
  Branch (102:32): [True: 39, False: 349]
-  Branch (102:61): [True: 0, False: 349]
+
97
        } else {
98
549
            0
99
        };
100
101
        // Preview placement and layout.
102
601
        let preview_visible = (options.preview.is_some() || 
options.preview_fn545
.
is_some545
())
  Branch (102:32): [True: 41, False: 362]
+  Branch (102:61): [True: 0, False: 362]
 
  Branch (102:32): [True: 15, False: 183]
   Branch (102:61): [True: 0, False: 183]
-
103
54
            && !options.preview_window.hidden
  Branch (103:16): [True: 39, False: 0]
+
103
56
            && !options.preview_window.hidden
  Branch (103:16): [True: 41, False: 0]
 
  Branch (103:16): [True: 14, False: 1]
-
104
53
            && !matches!(options.preview_window.size, Size::Fixed(0));
105
106
586
        let (preview_placement, preview_layout) = if preview_visible {
  Branch (106:54): [True: 39, False: 349]
+
104
55
            && !matches!(options.preview_window.size, Size::Fixed(0));
105
106
601
        let (preview_placement, preview_layout) = if preview_visible {
  Branch (106:54): [True: 41, False: 362]
 
  Branch (106:54): [True: 14, False: 184]
-
107
53
            let (preview_c, rest_c) = size_to_constraint(options.preview_window.size);
108
53
            let placement = match options.preview_window.direction {
109
14
                Direction::Left => PreviewPlacement::Left,
110
26
                Direction::Right => PreviewPlacement::Right,
111
7
                Direction::Up => PreviewPlacement::Up,
112
6
                Direction::Down => PreviewPlacement::Down,
113
            };
114
53
            let layout = match placement {
115
14
                PreviewPlacement::Left => Layout::new(RatatuiDirection::Horizontal, [preview_c, rest_c]),
116
26
                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
53
            (placement, Some(layout))
122
        } else {
123
533
            (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
586
        let work_layout_reversed = options.layout == TuiLayout::Reverse;
131
586
        let work_layout = if show_header {
  Branch (131:30): [True: 39, False: 349]
+
107
55
            let (preview_c, rest_c) = size_to_constraint(options.preview_window.size);
108
55
            let placement = match options.preview_window.direction {
109
15
                Direction::Left => PreviewPlacement::Left,
110
27
                Direction::Right => PreviewPlacement::Right,
111
7
                Direction::Up => PreviewPlacement::Up,
112
6
                Direction::Down => PreviewPlacement::Down,
113
            };
114
55
            let layout = match placement {
115
15
                PreviewPlacement::Left => Layout::new(RatatuiDirection::Horizontal, [preview_c, rest_c]),
116
27
                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
546
            (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
601
        let work_layout_reversed = options.layout == TuiLayout::Reverse;
131
601
        let work_layout = if show_header {
  Branch (131:30): [True: 39, False: 364]
 
  Branch (131:30): [True: 13, False: 185]
-
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
534
            match options.layout {
146
524
                TuiLayout::Default | TuiLayout::ReverseList => Layout::vertical([
147
524
                    Constraint::Fill(1),
148
524
                    Constraint::Length(0),
149
524
                    Constraint::Length(input_rows.saturating_sub(overlap)),
150
524
                ]),
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
586
        Self {
160
586
            show_header,
161
586
            preview_placement,
162
586
            work_layout_reversed,
163
586
            preview_layout,
164
586
            collapse_borders,
165
586
            work_layout,
166
586
        }
167
586
    }
168
169
    /// Apply this template to a concrete terminal `area`, producing the
170
    /// absolute [`AppLayout`] for one render frame.
171
    #[must_use]
172
3.23k
    pub fn apply(&self, area: Rect) -> AppLayout {
173
        // ── Step 1: carve out the preview from the full area ─────────────────
174
3.23k
        let (work_area, preview_area): (Rect, Option<Rect>) = match &self.preview_layout {
175
274
            Some(layout) => {
176
274
                let [a, mut b]: [Rect; 2] = layout.areas(area);
177
274
                if self.collapse_borders {
  Branch (177:20): [True: 0, False: 260]
+
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
549
            match options.layout {
146
539
                TuiLayout::Default | TuiLayout::ReverseList => Layout::vertical([
147
539
                    Constraint::Fill(1),
148
539
                    Constraint::Length(0),
149
539
                    Constraint::Length(input_rows.saturating_sub(overlap)),
150
539
                ]),
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
601
        Self {
160
601
            show_header,
161
601
            preview_placement,
162
601
            work_layout_reversed,
163
601
            preview_layout,
164
601
            collapse_borders,
165
601
            work_layout,
166
601
        }
167
601
    }
168
169
    /// Apply this template to a concrete terminal `area`, producing the
170
    /// absolute [`AppLayout`] for one render frame.
171
    #[must_use]
172
3.32k
    pub fn apply(&self, area: Rect) -> AppLayout {
173
        // ── Step 1: carve out the preview from the full area ─────────────────
174
3.32k
        let (work_area, preview_area): (Rect, Option<Rect>) = match &self.preview_layout {
175
289
            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
272
                }
184
274
                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
189
                    _ => (a, Some(b)),
189
                }
190
            }
191
2.96k
            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.23k
        let [slot0, slot1, slot2]: [Rect; 3] = self.work_layout.areas(work_area);
199
200
3.23k
        let (mut list_area, mut header_slot, mut input_area) = if self.work_layout_reversed {
  Branch (200:67): [True: 61, False: 2.95k]
-
  Branch (200:67): [True: 5, False: 213]
-
201
66
            (slot2, slot1, slot0)
202
        } else {
203
3.17k
            (slot0, slot1, slot2)
204
        };
205
206
3.23k
        if self.collapse_borders {
  Branch (206:12): [True: 72, False: 2.94k]
-
  Branch (206:12): [True: 10, False: 208]
-
207
82
            if self.work_layout_reversed {
  Branch (207:16): [True: 8, False: 64]
+
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
89
                    PreviewPlacement::Left | PreviewPlacement::Up => (b, Some(a)),
187
                    // preview is the second segment for Right / Down
188
200
                    _ => (a, Some(b)),
189
                }
190
            }
191
3.03k
            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.32k
        let [slot0, slot1, slot2]: [Rect; 3] = self.work_layout.areas(work_area);
199
200
3.32k
        let (mut list_area, mut header_slot, mut input_area) = if self.work_layout_reversed {
  Branch (200:67): [True: 62, False: 3.04k]
+
  Branch (200:67): [True: 5, False: 212]
+
201
67
            (slot2, slot1, slot0)
202
        } else {
203
3.25k
            (slot0, slot1, slot2)
204
        };
205
206
3.32k
        if self.collapse_borders {
  Branch (206:12): [True: 76, False: 3.03k]
+
  Branch (206:12): [True: 10, False: 207]
+
207
86
            if self.work_layout_reversed {
  Branch (207:16): [True: 8, False: 68]
 
  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]
+
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
76
                if self.show_header {
  Branch (213:20): [True: 8, False: 60]
 
  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.15k
        }
219
220
3.23k
        let header_area = if self.show_header { 
Some(header_slot)138
} else {
None3.10k
};
  Branch (220:30): [True: 126, False: 2.89k]
-
  Branch (220:30): [True: 12, False: 206]
-
221
222
3.23k
        AppLayout {
223
3.23k
            list_area,
224
3.23k
            input_area,
225
3.23k
            header_area,
226
3.23k
            preview_area,
227
3.23k
        }
228
3.23k
    }
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]
+
214
9
                    header_slot = extend_up(header_slot, work_area.y);
215
67
                }
216
76
                input_area = extend_up(input_area, work_area.y);
217
            }
218
3.23k
        }
219
220
3.32k
        let header_area = if self.show_header { 
Some(header_slot)138
} else {
None3.18k
};
  Branch (220:30): [True: 126, False: 2.98k]
+
  Branch (220:30): [True: 12, False: 205]
+
221
222
3.32k
        AppLayout {
223
3.32k
            list_area,
224
3.32k
            input_area,
225
3.32k
            header_area,
226
3.32k
            preview_area,
227
3.32k
        }
228
3.32k
    }
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
100
fn extend_up(mut rect: Rect, top: u16) -> Rect {
270
100
    if rect.y > top {
  Branch (270:8): [True: 66, False: 22]
 
  Branch (270:8): [True: 12, False: 0]
-
271
75
        rect.y -= 1;
272
75
        rect.height = rect.height.saturating_add(1);
273
75
    
}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]
+
271
78
        rect.y -= 1;
272
78
        rect.height = rect.height.saturating_add(1);
273
78
    
}22
274
100
    rect
275
100
}
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
2
    
}0
282
2
    rect
283
2
}
284
285
53
fn size_to_constraint(size: Size) -> (Constraint, Constraint) {
286
53
    match size {
287
6
        Size::Fixed(n) => (Constraint::Length(n), Constraint::Fill(1)),
288
43
        Size::Percent(p) => (Constraint::Percentage(p), Constraint::Fill(1)),
289
4
        Size::Neg(n) => (Constraint::Fill(1), Constraint::Length(n)),
290
    }
291
53
}
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
2
    
}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 6bc865c5..34e31090 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-04 10: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
961
    fn try_from(value: &str) -> Result<Self, Self::Error> {
71
961
        match value.to_lowercase().as_str() {
72
961
            "up" => 
Ok(Self::Up)11
,
73
950
            "down" => 
Ok(Self::Down)9
,
74
941
            "left" => 
Ok(Self::Left)26
,
75
915
            "right" => 
Ok(Self::Right)449
,
76
466
            _ => Err("Unknown direction {value}"),
77
        }
78
961
    }
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.36k
    fn try_from(value: &str) -> Result<Self, Self::Error> {
96
1.36k
        if value.ends_with('%') {
  Branch (96:12): [True: 801, False: 455]
+

Coverage Report

Created: 2026-09-05 18:50

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
990
    fn try_from(value: &str) -> Result<Self, Self::Error> {
71
990
        match value.to_lowercase().as_str() {
72
990
            "up" => 
Ok(Self::Up)11
,
73
979
            "down" => 
Ok(Self::Down)9
,
74
970
            "left" => 
Ok(Self::Left)27
,
75
943
            "right" => 
Ok(Self::Right)463
,
76
480
            _ => Err("Unknown direction {value}"),
77
        }
78
990
    }
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: 830, False: 470]
 
  Branch (96:12): [True: 58, False: 51]
-
97
859
            let 
percent856
= value
98
859
                .strip_suffix("%")
99
859
                .unwrap_or_default()
100
859
                .parse::<u16>()
101
859
                .map_err(|e| SizeParseError::ParseError(
value3
.
to_string3
(),
e3
))
?3
;
102
856
            if percent > 100 {
  Branch (102:16): [True: 0, False: 801]
+
97
888
            let 
percent885
= value
98
888
                .strip_suffix("%")
99
888
                .unwrap_or_default()
100
888
                .parse::<u16>()
101
888
                .map_err(|e| SizeParseError::ParseError(
value3
.
to_string3
(),
e3
))
?3
;
102
885
            if percent > 100 {
  Branch (102:16): [True: 0, False: 830]
 
  Branch (102:16): [True: 1, False: 54]
-
103
1
                return Err(SizeParseError::InvalidPercent(percent));
104
855
            }
105
855
            Ok(Self::Percent(percent))
106
506
        } else if let Some(
neg5
) = value.strip_prefix('-') {
  Branch (106:23): [True: 4, False: 451]
+
103
1
                return Err(SizeParseError::InvalidPercent(percent));
104
884
            }
105
884
            Ok(Self::Percent(percent))
106
521
        } else if let Some(
neg5
) = value.strip_prefix('-') {
  Branch (106:23): [True: 4, False: 466]
 
  Branch (106:23): [True: 1, False: 50]
-
107
            Ok(Self::Neg(
108
5
                neg.parse::<u16>()
109
5
                    .map_err(|e| SizeParseError::ParseError(
value0
.
to_string0
(),
e0
))
?0
,
110
            ))
111
        } else {
112
            Ok(Self::Fixed(
113
501
                value
114
501
                    .parse::<u16>()
115
501
                    .map_err(|e| SizeParseError::ParseError(
value492
.
to_string492
(),
e492
))
?492
,
116
            ))
117
        }
118
1.36k
    }
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
11.8k
    fn is_none(self) -> bool {
169
11.8k
        
matches!369
(self, BorderType::None | BorderType::ForceOff)
170
11.8k
    }
171
6.07k
    fn is_some(self) -> bool {
172
6.07k
        !self.is_none()
173
6.07k
    }
174
5.68k
    fn into_ratatui(self) -> Option<ratatui::widgets::BorderType> {
175
5.68k
        if self.is_none() {
  Branch (175:12): [True: 5.45k, False: 153]
-
  Branch (175:12): [True: 69, False: 1]
-
176
5.52k
            return None;
177
154
        }
178
179
154
        Some(match self {
180
88
            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.68k
    }
195
}
196
197
#[cfg(feature = "cli")]
198
impl std::str::FromStr for BorderType {
199
    type Err = String;
200
201
465
    fn from_str(s: &str) -> Result<Self, Self::Err> {
202
        use clap::ValueEnum as _;
203
1.01k
        for variant in 
Self::value_variants465
() {
204
1.01k
            if variant.to_possible_value().unwrap().matches(s, false) {
  Branch (204:16): [True: 454, False: 542]
+
107
            Ok(Self::Neg(
108
5
                neg.parse::<u16>()
109
5
                    .map_err(|e| SizeParseError::ParseError(
value0
.
to_string0
(),
e0
))
?0
,
110
            ))
111
        } else {
112
            Ok(Self::Fixed(
113
516
                value
114
516
                    .parse::<u16>()
115
516
                    .map_err(|e| SizeParseError::ParseError(
value507
.
to_string507
(),
e507
))
?507
,
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.1k
    fn is_none(self) -> bool {
169
12.1k
        
matches!363
(self, BorderType::None | BorderType::ForceOff)
170
12.1k
    }
171
6.23k
    fn is_some(self) -> bool {
172
6.23k
        !self.is_none()
173
6.23k
    }
174
5.84k
    fn into_ratatui(self) -> Option<ratatui::widgets::BorderType> {
175
5.84k
        if self.is_none() {
  Branch (175:12): [True: 5.62k, False: 150]
+
  Branch (175:12): [True: 68, False: 1]
+
176
5.68k
            return None;
177
151
        }
178
179
151
        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
12
            BorderType::QuadrantOutside => ratatui::widgets::BorderType::QuadrantOutside,
192
0
            BorderType::None | BorderType::ForceOff => unreachable!(),
193
        })
194
5.84k
    }
195
}
196
197
#[cfg(feature = "cli")]
198
impl std::str::FromStr for BorderType {
199
    type Err = String;
200
201
480
    fn from_str(s: &str) -> Result<Self, Self::Err> {
202
        use clap::ValueEnum as _;
203
1.05k
        for variant in 
Self::value_variants480
() {
204
1.05k
            if variant.to_possible_value().unwrap().matches(s, false) {
  Branch (204:16): [True: 469, False: 568]
 
  Branch (204:16): [True: 11, False: 11]
-
205
465
                return Ok(*variant);
206
553
            }
207
        }
208
0
        Ok(Self::Plain)
209
465
    }
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
480
                return Ok(*variant);
206
579
            }
207
        }
208
0
        Ok(Self::Plain)
209
480
    }
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 7417e465..1055e1ff 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-04 10: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
892
    fn default() -> Self {
35
892
        Self {
36
892
            direction: Direction::Right,
37
892
            size: Size::Percent(50),
38
892
            hidden: false,
39
892
            offset: None,
40
892
            wrap: false,
41
892
            pty: false,
42
892
        }
43
892
    }
44
}
45
46
impl From<&str> for PreviewLayout {
47
497
    fn from(value: &str) -> Self {
48
497
        let mut res: Self = PreviewLayout::default();
49
        // Parse the remainder which can be: size:offset:hidden, offset:hidden, size:hidden, etc.
50
497
        let parts: Vec<&str> = value.split(':').collect();
51
52
999
        for part in 
parts497
{
53
999
            if part.is_empty() {
  Branch (53:16): [True: 5, False: 904]
+

Coverage Report

Created: 2026-09-05 18:50

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
912
    fn default() -> Self {
35
912
        Self {
36
912
            direction: Direction::Right,
37
912
            size: Size::Percent(50),
38
912
            hidden: false,
39
912
            offset: None,
40
912
            wrap: false,
41
912
            pty: false,
42
912
        }
43
912
    }
44
}
45
46
impl From<&str> for PreviewLayout {
47
512
    fn from(value: &str) -> Self {
48
512
        let mut res: Self = PreviewLayout::default();
49
        // Parse the remainder which can be: size:offset:hidden, offset:hidden, size:hidden, etc.
50
512
        let parts: Vec<&str> = value.split(':').collect();
51
52
1.02k
        for part in 
parts512
{
53
1.02k
            if part.is_empty() {
  Branch (53:16): [True: 5, False: 934]
 
  Branch (53:16): [True: 1, False: 89]
-
54
6
                continue;
55
993
            }
56
57
993
            if part.starts_with('+') {
  Branch (57:16): [True: 6, False: 898]
+
54
6
                continue;
55
1.02k
            }
56
57
1.02k
            if part.starts_with('+') {
  Branch (57:16): [True: 7, False: 927]
 
  Branch (57:16): [True: 6, False: 83]
-
58
12
                // This is an offset expression
59
12
                res.offset = Some(part.to_string());
60
981
            } else if part == "hidden" {
  Branch (60:23): [True: 0, False: 898]
+
58
13
                // This is an offset expression
59
13
                res.offset = Some(part.to_string());
60
1.01k
            } else if part == "hidden" {
  Branch (60:23): [True: 0, False: 927]
 
  Branch (60:23): [True: 4, False: 79]
-
61
4
                res.hidden = true;
62
977
            } else if part == "nohidden" {
  Branch (62:23): [True: 0, False: 898]
+
61
4
                res.hidden = true;
62
1.00k
            } else if part == "nohidden" {
  Branch (62:23): [True: 0, False: 927]
 
  Branch (62:23): [True: 1, False: 78]
-
63
1
                res.hidden = false;
64
976
            } else if part == "wrap" {
  Branch (64:23): [True: 3, False: 895]
+
63
1
                res.hidden = false;
64
1.00k
            } else if part == "wrap" {
  Branch (64:23): [True: 3, False: 924]
 
  Branch (64:23): [True: 1, False: 77]
-
65
4
                res.wrap = true;
66
972
            } else if part == "nowrap" {
  Branch (66:23): [True: 0, False: 895]
+
65
4
                res.wrap = true;
66
1.00k
            } else if part == "nowrap" {
  Branch (66:23): [True: 0, False: 924]
 
  Branch (66:23): [True: 1, False: 76]
-
67
1
                res.wrap = false;
68
971
            } else if part == "pty" {
  Branch (68:23): [True: 13, False: 882]
+
67
1
                res.wrap = false;
68
1.00k
            } else if part == "pty" {
  Branch (68:23): [True: 13, False: 911]
 
  Branch (68:23): [True: 1, False: 75]
-
69
14
                res.pty = true;
70
957
            } else if part == "nopty" {
  Branch (70:23): [True: 0, False: 882]
+
69
14
                res.pty = true;
70
986
            } else if part == "nopty" {
  Branch (70:23): [True: 0, False: 911]
 
  Branch (70:23): [True: 1, False: 74]
-
71
1
                res.pty = false;
72
1
            } else {
73
                // Try to parse as size
74
956
                if let Ok(
size465
) = part.try_into() {
  Branch (74:24): [True: 433, False: 449]
+
71
1
                res.pty = false;
72
1
            } else {
73
                // Try to parse as size
74
985
                if let Ok(
size479
) = part.try_into() {
  Branch (74:24): [True: 447, False: 464]
 
  Branch (74:24): [True: 32, False: 42]
-
75
465
                    res.size = size;
76
491
                }
77
956
                if let Ok(
dir491
) = part.try_into() {
  Branch (77:24): [True: 449, False: 433]
+
75
479
                    res.size = size;
76
506
                }
77
985
                if let Ok(
dir506
) = part.try_into() {
  Branch (77:24): [True: 464, False: 447]
 
  Branch (77:24): [True: 42, False: 32]
-
78
491
                    res.direction = dir;
79
491
                
}465
80
            }
81
        }
82
497
        res
83
497
    }
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
506
                    res.direction = dir;
79
506
                
}479
80
            }
81
        }
82
512
        res
83
512
    }
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 046e521a..b33e51f1 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,461 +1,486 @@ -

Coverage Report

Created: 2026-09-04 10: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::sync::{Arc, RwLock, mpsc};
19
use std::thread::JoinHandle;
20
use std::time::Instant;
21
22
use super::statusline::spinner_char;
23
use super::util::{find_csi_end, find_osc_end, handle_csi_query, handle_osc_query};
24
use super::widget::{SkimRender, SkimWidget};
25
use super::{BorderType, Direction, Event, Tui};
26
27
use crate::theme::ColorTheme;
28
use crate::{SkimItem, SkimOptions};
29
30
// PreviewCallback for ratatui - returns Vec<String> instead of AnsiString
31
pub type PreviewCallbackFn = dyn Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static;
32
const PREVIEW_MAX_BYTES: usize = 1024 * 1024;
33
const VT_SCROLLBACK: usize = 100_000;
34
35
/// Preview content options
36
pub(crate) enum PreviewContent {
37
    /// Simple text content (for non-PTY previews and callbacks)
38
    Text(Text<'static>),
39
    /// Terminal screen (for PTY previews with cursor positioning)
40
    Terminal(Arc<RwLock<vt100::Parser>>),
41
    /// Image
42
    #[cfg(feature = "image")]
43
    Image {
44
        source: image::DynamicImage,
45
        protocol: Option<ImageProtocol>,
46
        size: ratatui::layout::Size,
47
    },
48
}
49
50
impl Default for PreviewContent {
51
538
    fn default() -> Self {
52
538
        PreviewContent::Text(Text::default())
53
538
    }
54
}
55
56
/// Callback function for generating preview content
57
#[derive(Clone)]
58
pub struct PreviewCallback {
59
    inner: Arc<PreviewCallbackFn>,
60
}
61
62
impl<F> From<F> for PreviewCallback
63
where
64
    F: Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static,
65
{
66
3
    fn from(func: F) -> Self {
67
3
        Self { inner: Arc::new(func) }
68
3
    }
69
}
70
71
impl std::ops::Deref for PreviewCallback {
72
    type Target = dyn Fn(Vec<Arc<dyn SkimItem>>) -> Vec<String> + Send + Sync + 'static;
73
74
3
    fn deref(&self) -> &Self::Target {
75
3
        &*self.inner
76
3
    }
77
}
78
79
pub struct Preview {
80
    pub(crate) content: Arc<RwLock<PreviewContent>>,
81
    pub cmd: String,
82
    pub rows: u16,
83
    pub cols: u16,
84
    pub scroll_y: u16,
85
    pub scroll_x: u16,
86
    pub thread_handle: Option<JoinHandle<()>>,
87
    /// Channel to signal thread interruption
88
    interrupt_tx: Option<mpsc::Sender<()>>,
89
    pub theme: Arc<ColorTheme>,
90
    /// Border type
91
    pub border: BorderType,
92
    pub direction: Direction,
93
    pub wrap: bool,
94
    pty: Option<PtyPair>,
95
    pty_child: Option<Box<dyn portable_pty::Child + Send + Sync>>,
96
    #[cfg(feature = "image")]
97
    image: bool,
98
    #[cfg(feature = "image")]
99
    image_picker: Option<Picker>,
100
    pub total_lines: u16,
101
    loading: bool,
102
    spinner_start: Instant,
103
}
104
105
impl Default for Preview {
106
10
    fn default() -> Self {
107
10
        Self::_default()
108
10
    }
109
}
110
111
impl Preview {
112
    #[cfg(feature = "image")]
113
8
    fn image_protocol(
114
8
        picker: Option<&Picker>,
115
8
        source: image::DynamicImage,
116
8
        size: ratatui::layout::Size,
117
8
    ) -> std::result::Result<ImageProtocol, ratatui_image::errors::Errors> {
118
        let fallback;
119
8
        let picker = if let Some(
picker3
) = picker {
  Branch (119:29): [True: 0, False: 4]
-
  Branch (119:29): [True: 3, False: 1]
-
120
3
            picker
121
        } else {
122
5
            fallback = Picker::halfblocks();
123
5
            &fallback
124
        };
125
126
8
        let font_size = picker.font_size();
127
8
        let max_pixel_width = u128::from(size.width) * u128::from(font_size.width);
128
8
        let max_pixel_height = u128::from(size.height) * u128::from(font_size.height);
129
8
        let image_width = u128::from(source.width());
130
8
        let image_height = u128::from(source.height());
131
132
8
        let size = if image_height * max_pixel_width <= max_pixel_height * image_width {
  Branch (132:23): [True: 3, False: 1]
-
  Branch (132:23): [True: 3, False: 1]
-
133
6
            let pixel_height = image_height * max_pixel_width / image_width;
134
6
            let height = pixel_height.div_ceil(u128::from(font_size.height));
135
136
6
            ratatui::layout::Size::new(size.width, u16::try_from(height).unwrap_or(u16::MAX).max(1))
137
        } else {
138
2
            let pixel_width = image_width * max_pixel_height / image_height;
139
2
            let width = pixel_width.div_ceil(u128::from(font_size.width));
140
141
2
            ratatui::layout::Size::new(u16::try_from(width).unwrap_or(u16::MAX).max(1), size.height)
142
        };
143
144
8
        picker.new_protocol(source, size, ratatui_image::Resize::Scale(None))
145
8
    }
146
147
    #[cfg(feature = "image")]
148
2
    pub(crate) fn set_image_picker(&mut self, picker: Option<Picker>) {
149
2
        self.image_picker = picker;
150
2
    }
151
152
    /// Convert a Size value to an actual offset based on preview dimensions
153
17
    fn size_to_offset(&self, size: super::Size, is_vertical: bool) -> u16 {
154
17
        match size {
155
9
            super::Size::Fixed(n) => n,
156
4
            super::Size::Percent(p) => {
157
4
                let dimension = if is_vertical { 
self.rows2
} else {
self.cols2
};
  Branch (157:36): [True: 0, False: 0]
-
  Branch (157:36): [True: 2, False: 2]
-
158
                // Result is at most dimension (a u16), so truncation cannot occur.
159
4
                u16::try_from(u32::from(dimension) * u32::from(p) / 100).unwrap_or(u16::MAX)
160
            }
161
4
            super::Size::Neg(n) => {
162
4
                let dimension = if is_vertical { 
self.rows2
} else {
self.cols2
};
  Branch (162:36): [True: 0, False: 0]
-
  Branch (162:36): [True: 2, False: 2]
-
163
4
                dimension.saturating_sub(n)
164
            }
165
        }
166
17
    }
167
168
36
    fn init_pty(&mut self) {
169
36
        let pty_system = native_pty_system();
170
36
        let cols = if self.wrap { 
self.cols2
} else {
102434
};
  Branch (170:23): [True: 2, False: 34]
-
  Branch (170:23): [True: 0, False: 0]
-
171
36
        let pair = pty_system.openpty(PtySize {
172
36
            rows: self.rows,
173
36
            cols,
174
36
            pixel_width: 0,
175
36
            pixel_height: 0,
176
36
        });
177
36
        match pair {
178
36
            Ok(p) => self.pty = Some(p),
179
0
            Err(e) => warn!("failed to init preview pty: {e:?}"),
180
        }
181
36
    }
182
183
    /// Filter out terminal query sequences from the output and respond to them.
184
    /// This prevents programs like delta from waiting for responses and timing out.
185
    /// Returns the filtered output with query sequences removed.
186
36
    fn filter_and_respond_to_queries(data: &[u8], writer: &mut Box<dyn std::io::Write + Send>) -> Vec<u8> {
187
36
        let mut result = Vec::new();
188
36
        let mut i = 0;
189
190
18.2k
        while i < data.len() {
  Branch (190:15): [True: 18.2k, False: 35]
-
  Branch (190:15): [True: 7, False: 1]
-
191
18.2k
            if data[i] == b'\x1b' && 
i + 11
< data.len() {
  Branch (191:16): [True: 0, False: 18.2k]
-  Branch (191:38): [True: 0, False: 0]
-
  Branch (191:16): [True: 1, False: 6]
-  Branch (191:38): [True: 1, False: 0]
-
192
1
                match data[i + 1] {
193
                    b']' => {
194
                        // OSC sequences: ESC ] ... ST (where ST is ESC \ or BEL)
195
0
                        if let Some(end) = find_osc_end(&data[i..]) {
  Branch (195:32): [True: 0, False: 0]
-
  Branch (195:32): [True: 0, False: 0]
-
196
0
                            let seq = &data[i..i + end];
197
0
                            handle_osc_query(seq, writer);
198
0
                            i += end;
199
0
                            continue;
200
0
                        }
201
                    }
202
                    b'[' => {
203
                        // CSI sequences: ESC [ ... final_byte
204
1
                        if let Some(end) = find_csi_end(&data[i..]) {
  Branch (204:32): [True: 0, False: 0]
-
  Branch (204:32): [True: 1, False: 0]
-
205
1
                            let seq = &data[i..i + end];
206
1
                            if handle_csi_query(seq, writer) {
  Branch (206:32): [True: 0, False: 0]
-
  Branch (206:32): [True: 1, False: 0]
-
207
                                // It was a query, filter it out
208
1
                                i += end;
209
1
                                continue;
210
0
                            }
211
                            // Not a query, keep it in output
212
0
                        }
213
                    }
214
0
                    _ => {}
215
                }
216
18.2k
            }
217
18.2k
            result.push(data[i]);
218
18.2k
            i += 1;
219
        }
220
221
36
        result
222
36
    }
223
224
11
    pub fn content(&mut self, content: &[u8]) -> Result<()> {
225
11
        let text = content.to_owned().into_text()
?0
;
226
11
        let Ok(mut content) = self.content.write() else {
  Branch (226:13): [True: 0, False: 0]
-
  Branch (226:13): [True: 11, False: 0]
-
227
0
            return Err(eyre::eyre!("Failed to acquire content for writing"));
228
        };
229
11
        self.total_lines = text.lines.len().try_into().unwrap();
230
11
        *content = PreviewContent::Text(text);
231
11
        self.scroll_y = 0;
232
11
        self.scroll_x = 0;
233
11
        self.loading = false;
234
11
        Ok(())
235
11
    }
236
237
1.74k
    pub(crate) fn is_loading(&self) -> bool {
238
1.74k
        self.loading
239
1.74k
    }
240
241
71
    pub(crate) fn mark_ready(&mut self) {
242
71
        self.loading = false;
243
71
    }
244
245
3
    pub fn content_with_position(&mut self, content: &[u8], position: crate::PreviewPosition) -> Result<()> {
246
3
        self.content(content).map(|()| {
247
            // Apply position offsets
248
3
            let v_scroll = self.size_to_offset(position.v_scroll, true);
249
3
            let v_offset = self.size_to_offset(position.v_offset, true);
250
3
            self.scroll_y = v_scroll.saturating_add(v_offset);
251
252
3
            let h_scroll = self.size_to_offset(position.h_scroll, false);
253
3
            let h_offset = self.size_to_offset(position.h_offset, false);
254
3
            self.scroll_x = h_scroll.saturating_add(h_offset);
255
3
        })
256
3
    }
257
258
5
    pub fn scroll_up(&mut self, lines: u16) {
259
5
        self.scroll_y = self.scroll_y.saturating_sub(lines);
260
5
    }
261
262
8
    pub fn scroll_down(&mut self, lines: u16) {
263
8
        trace!(
264
            "scrolling down by {lines} lines, ({} total, {} rows)",
265
            self.total_lines, self.rows
266
        );
267
8
        if self.total_lines > 0 {
  Branch (267:12): [True: 0, False: 0]
-
  Branch (267:12): [True: 2, False: 6]
-
268
2
            self.scroll_y = self
269
2
                .scroll_y
270
2
                .saturating_add(lines)
271
2
                .min(self.total_lines.saturating_sub(self.rows.saturating_sub(1)));
272
6
        } else {
273
6
            // We might not have the actual total_lines value
274
6
            self.scroll_y = self.scroll_y.saturating_add(lines);
275
6
        }
276
8
    }
277
278
3
    pub fn scroll_left(&mut self, cols: u16) {
279
3
        self.scroll_x = self.scroll_x.saturating_sub(cols);
280
3
    }
281
282
2
    pub fn scroll_right(&mut self, cols: u16) {
283
2
        self.scroll_x = self.scroll_x.saturating_add(cols);
284
2
    }
285
286
8
    pub fn set_offset(&mut self, offset: u16) {
287
8
        self.scroll_y = offset.saturating_sub(1); // -1 because line numbers are 1-indexed
288
8
    }
289
290
2
    pub fn page_up(&mut self) {
291
2
        let page_size = self.rows.saturating_sub(2); // Account for borders
292
2
        self.scroll_up(page_size);
293
2
    }
294
295
2
    pub fn page_down(&mut self) {
296
2
        let page_size = self.rows.saturating_sub(2); // Account for borders
297
2
        self.scroll_down(page_size);
298
2
    }
299
    /// Kill the preview child process and interrupt the reader thread.
300
613
    pub fn kill(&mut self) {
301
613
        if let Some(
tx71
) = self.interrupt_tx.take() {
  Branch (301:16): [True: 68, False: 374]
-
  Branch (301:16): [True: 3, False: 168]
-
302
71
            let _ = tx.send(());
303
542
        }
304
305
613
        if let Some(
mut child23
) = self.pty_child.take() {
  Branch (305:16): [True: 23, False: 419]
-
  Branch (305:16): [True: 0, False: 171]
-
306
23
            trace!("killing pty child process");
307
23
            match child.try_wait() {
308
23
                Ok(Some(status)) => {
309
23
                    trace!("child already exited with status: {status:?}");
310
                }
311
                Ok(None) => {
312
0
                    trace!("child still running, sending kill signal");
313
0
                    if let Err(e) = child.kill() {
  Branch (313:28): [True: 0, False: 0]
-
  Branch (313:28): [True: 0, False: 0]
-
314
0
                        trace!("failed to kill pty child: {e:?}");
315
0
                    }
316
                }
317
0
                Err(e) => {
318
0
                    debug!("error checking child status: {e:?}");
319
0
                    let _ = child.kill();
320
                }
321
            }
322
590
        }
323
613
    }
324
325
    #[allow(clippy::too_many_lines)]
326
71
    pub fn spawn<B: Backend>(&mut self, tui: &mut Tui<B>, cmd: &str) -> Result<()>
327
71
    where
328
71
        B::Error: Send + Sync + 'static,
329
    {
330
71
        self.kill();
331
71
        self.cmd = cmd.to_string();
332
71
        self.loading = true;
333
334
        // Reset scroll position and manual_scroll flag for new preview
335
71
        self.scroll_y = 0;
336
71
        self.scroll_x = 0;
337
338
71
        let event_tx_clone = tui.event_tx.clone();
339
71
        let content = self.content.clone();
340
341
        #[cfg(feature = "image")]
342
71
        if self.image {
  Branch (342:12): [True: 0, False: 0]
-
  Branch (342:12): [True: 0, False: 0]
-
  Branch (342:12): [True: 0, False: 5]
-
  Branch (342:12): [True: 0, False: 0]
-
  Branch (342:12): [True: 0, False: 0]
-
  Branch (342:12): [True: 0, False: 0]
-
  Branch (342:12): [True: 0, False: 0]
-
  Branch (342:12): [True: 6, False: 50]
-
  Branch (342:12): [True: 0, False: 2]
-
  Branch (342:12): [True: 0, False: 0]
-
  Branch (342:12): [True: 0, False: 0]
-
  Branch (342:12): [True: 0, False: 0]
-
  Branch (342:12): [True: 0, False: 3]
-
  Branch (342:12): [True: 0, False: 0]
-
  Branch (342:12): [True: 0, False: 0]
-
  Branch (342:12): [True: 0, False: 5]
-
  Branch (342:12): [True: 0, False: 0]
-
  Branch (342:12): [True: 0, False: 0]
-
  Branch (342:12): [True: 0, False: 0]
-
343
6
            let cmd = self.cmd.clone();
344
345
6
            let (interrupt_tx, interrupt_rx) = mpsc::channel();
346
6
            self.interrupt_tx = Some(interrupt_tx);
347
348
6
            self.thread_handle = Some(std::thread::spawn(move || {
349
                let res: PreviewContent;
350
6
                if let Ok(Ok(
decoded4
)) = image::ImageReader::open(&cmd).map(image::ImageReader::decode) {
  Branch (350:24): [True: 0, False: 0]
-
  Branch (350:24): [True: 0, False: 0]
-
  Branch (350:24): [True: 0, False: 0]
-
  Branch (350:24): [True: 0, False: 0]
-
  Branch (350:24): [True: 0, False: 0]
-
  Branch (350:24): [True: 0, False: 0]
-
  Branch (350:24): [True: 0, False: 0]
-
  Branch (350:24): [True: 4, False: 2]
-
  Branch (350:24): [True: 0, False: 0]
-
  Branch (350:24): [True: 0, False: 0]
-
  Branch (350:24): [True: 0, False: 0]
-
  Branch (350:24): [True: 0, False: 0]
-
  Branch (350:24): [True: 0, False: 0]
-
  Branch (350:24): [True: 0, False: 0]
-
  Branch (350:24): [True: 0, False: 0]
-
  Branch (350:24): [True: 0, False: 0]
-
  Branch (350:24): [True: 0, False: 0]
-
  Branch (350:24): [True: 0, False: 0]
-
  Branch (350:24): [True: 0, False: 0]
-
351
4
                    res = PreviewContent::Image {
352
4
                        source: decoded,
353
4
                        protocol: None,
354
4
                        size: ratatui::layout::Size::default(),
355
4
                    };
356
4
                } else {
357
2
                    res = PreviewContent::Text(Text::raw(format!("Failed to open {cmd} as image")));
358
2
                }
359
360
6
                if interrupt_rx.try_recv().is_ok() {
  Branch (360:20): [True: 0, False: 0]
-
  Branch (360:20): [True: 0, False: 0]
-
  Branch (360:20): [True: 0, False: 0]
-
  Branch (360:20): [True: 0, False: 0]
-
  Branch (360:20): [True: 0, False: 0]
-
  Branch (360:20): [True: 0, False: 0]
-
  Branch (360:20): [True: 0, False: 0]
-
  Branch (360:20): [True: 0, False: 6]
-
  Branch (360:20): [True: 0, False: 0]
-
  Branch (360:20): [True: 0, False: 0]
-
  Branch (360:20): [True: 0, False: 0]
-
  Branch (360:20): [True: 0, False: 0]
-
  Branch (360:20): [True: 0, False: 0]
-
  Branch (360:20): [True: 0, False: 0]
-
  Branch (360:20): [True: 0, False: 0]
-
  Branch (360:20): [True: 0, False: 0]
-
  Branch (360:20): [True: 0, False: 0]
-
  Branch (360:20): [True: 0, False: 0]
-
  Branch (360:20): [True: 0, False: 0]
-
361
0
                    trace!("interrupt signal received, exiting");
362
0
                    return;
363
6
                }
364
365
6
                if let Ok(mut c) = content.write() {
  Branch (365:24): [True: 0, False: 0]
-
  Branch (365:24): [True: 0, False: 0]
-
  Branch (365:24): [True: 0, False: 0]
-
  Branch (365:24): [True: 0, False: 0]
-
  Branch (365:24): [True: 0, False: 0]
-
  Branch (365:24): [True: 0, False: 0]
-
  Branch (365:24): [True: 0, False: 0]
-
  Branch (365:24): [True: 6, False: 0]
-
  Branch (365:24): [True: 0, False: 0]
-
  Branch (365:24): [True: 0, False: 0]
-
  Branch (365:24): [True: 0, False: 0]
-
  Branch (365:24): [True: 0, False: 0]
-
  Branch (365:24): [True: 0, False: 0]
-
  Branch (365:24): [True: 0, False: 0]
-
  Branch (365:24): [True: 0, False: 0]
-
  Branch (365:24): [True: 0, False: 0]
-
  Branch (365:24): [True: 0, False: 0]
-
  Branch (365:24): [True: 0, False: 0]
-
  Branch (365:24): [True: 0, False: 0]
-
366
6
                    *c = res;
367
6
                    let _ = event_tx_clone.blocking_send(Event::PreviewReady);
368
6
                
}0
369
6
            }));
370
371
6
            return Ok(());
372
65
        }
373
374
65
        if let Some(
pty23
) = self.pty.take() {
  Branch (374:16): [True: 0, False: 0]
-
  Branch (374:16): [True: 0, False: 0]
-
  Branch (374:16): [True: 0, False: 5]
-
  Branch (374:16): [True: 0, False: 0]
-
  Branch (374:16): [True: 0, False: 0]
-
  Branch (374:16): [True: 0, False: 0]
-
  Branch (374:16): [True: 0, False: 0]
-
  Branch (374:16): [True: 23, False: 27]
-
  Branch (374:16): [True: 0, False: 2]
-
  Branch (374:16): [True: 0, False: 0]
-
  Branch (374:16): [True: 0, False: 0]
-
  Branch (374:16): [True: 0, False: 0]
-
  Branch (374:16): [True: 0, False: 3]
-
  Branch (374:16): [True: 0, False: 0]
-
  Branch (374:16): [True: 0, False: 0]
-
  Branch (374:16): [True: 0, False: 5]
-
  Branch (374:16): [True: 0, False: 0]
-
  Branch (374:16): [True: 0, False: 0]
-
  Branch (374:16): [True: 0, False: 0]
-
375
            // Ensure the PTY has the correct display dimensions before spawning.
376
            // init_pty() creates PTYs with 1024 cols for non-wrap mode (for the vt100 parser's
377
            // horizontal scrolling), but the child process needs to see the actual display size.
378
            // render() only resizes when the area changes, so if spawn() is called twice at the
379
            // same area size, the second PTY would still have 1024 cols.
380
23
            if self.rows > 0 && self.cols > 0 {
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
-
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
-
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
-
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
-
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
-
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
-
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
-
  Branch (380:16): [True: 23, False: 0]
-  Branch (380:33): [True: 23, False: 0]
-
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
-
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
-
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
-
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
-
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
-
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
-
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
-
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
-
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
-
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
-
  Branch (380:16): [True: 0, False: 0]
-  Branch (380:33): [True: 0, False: 0]
-
381
23
                let _ = pty.master.resize(PtySize {
382
23
                    rows: self.rows,
383
23
                    cols: self.cols,
384
23
                    pixel_width: 0,
385
23
                    pixel_height: 0,
386
23
                });
387
23
            
}0
388
23
            trace!(
389
                "spawning preview cmd {cmd} in pty (rows={}, cols={})",
390
                self.rows, self.cols
391
            );
392
23
            self.init_pty();
393
23
            trace!("initialized pty");
394
            // no PTY on windows, we can keep sh
395
23
            let mut shell_cmd = portable_pty::CommandBuilder::new("/bin/sh");
396
23
            shell_cmd.env("ROWS", self.rows.to_string());
397
23
            shell_cmd.env("COLUMNS", self.cols.to_string());
398
23
            shell_cmd.env("PAGER", "");
399
23
            shell_cmd.arg("-c");
400
23
            if let Ok(cwd) = env::current_dir() {
  Branch (400:20): [True: 0, False: 0]
-
  Branch (400:20): [True: 0, False: 0]
-
  Branch (400:20): [True: 0, False: 0]
-
  Branch (400:20): [True: 0, False: 0]
-
  Branch (400:20): [True: 0, False: 0]
-
  Branch (400:20): [True: 0, False: 0]
-
  Branch (400:20): [True: 0, False: 0]
-
  Branch (400:20): [True: 23, False: 0]
-
  Branch (400:20): [True: 0, False: 0]
-
  Branch (400:20): [True: 0, False: 0]
-
  Branch (400:20): [True: 0, False: 0]
-
  Branch (400:20): [True: 0, False: 0]
-
  Branch (400:20): [True: 0, False: 0]
-
  Branch (400:20): [True: 0, False: 0]
-
  Branch (400:20): [True: 0, False: 0]
-
  Branch (400:20): [True: 0, False: 0]
-
  Branch (400:20): [True: 0, False: 0]
-
  Branch (400:20): [True: 0, False: 0]
-
  Branch (400:20): [True: 0, False: 0]
-
401
23
                shell_cmd.cwd(cwd);
402
23
            
}0
403
23
            shell_cmd.arg(cmd);
404
23
            self.pty_child = Some(pty.slave.spawn_command(shell_cmd).map_err(|e| 
{0
405
0
                warn!("{:#?}", e.backtrace());
406
0
                eyre!(Box::<dyn std::error::Error + Send + Sync + 'static>::from(e))
407
0
            })?);
408
409
23
            let mut reader = pty
410
23
                .master
411
23
                .try_clone_reader()
412
23
                .map_err(|e| 
eyre!0
(
Box::<dyn std::error::Error + Send + Sync + 'static>::from(e)0
))
?0
;
413
414
            // Get a writer to respond to terminal queries
415
23
            let mut esc_writer = pty
416
23
                .master
417
23
                .take_writer()
418
23
                .map_err(|e| 
eyre!0
(
Box::<dyn std::error::Error + Send + Sync + 'static>::from(e)0
))
?0
;
419
420
23
            let (interrupt_tx, interrupt_rx) = mpsc::channel();
421
23
            self.interrupt_tx = Some(interrupt_tx);
422
423
            // Create vt100 parser for PTY output with large scrollback buffer
424
23
            let cols = if self.wrap { 
self.cols1
} else {
102422
};
  Branch (424:27): [True: 0, False: 0]
-
  Branch (424:27): [True: 0, False: 0]
-
  Branch (424:27): [True: 0, False: 0]
-
  Branch (424:27): [True: 0, False: 0]
-
  Branch (424:27): [True: 0, False: 0]
-
  Branch (424:27): [True: 0, False: 0]
-
  Branch (424:27): [True: 0, False: 0]
-
  Branch (424:27): [True: 1, False: 22]
-
  Branch (424:27): [True: 0, False: 0]
-
  Branch (424:27): [True: 0, False: 0]
-
  Branch (424:27): [True: 0, False: 0]
-
  Branch (424:27): [True: 0, False: 0]
-
  Branch (424:27): [True: 0, False: 0]
-
  Branch (424:27): [True: 0, False: 0]
-
  Branch (424:27): [True: 0, False: 0]
-
  Branch (424:27): [True: 0, False: 0]
-
  Branch (424:27): [True: 0, False: 0]
-
  Branch (424:27): [True: 0, False: 0]
-
  Branch (424:27): [True: 0, False: 0]
-
425
23
            let parser = Arc::new(RwLock::new(vt100::Parser::new(self.rows, cols, VT_SCROLLBACK)));
426
427
            // Update content to use the parser
428
23
            if let Ok(mut c) = content.write() {
  Branch (428:20): [True: 0, False: 0]
-
  Branch (428:20): [True: 0, False: 0]
-
  Branch (428:20): [True: 0, False: 0]
-
  Branch (428:20): [True: 0, False: 0]
-
  Branch (428:20): [True: 0, False: 0]
-
  Branch (428:20): [True: 0, False: 0]
-
  Branch (428:20): [True: 0, False: 0]
-
  Branch (428:20): [True: 23, False: 0]
-
  Branch (428:20): [True: 0, False: 0]
-
  Branch (428:20): [True: 0, False: 0]
-
  Branch (428:20): [True: 0, False: 0]
-
  Branch (428:20): [True: 0, False: 0]
-
  Branch (428:20): [True: 0, False: 0]
-
  Branch (428:20): [True: 0, False: 0]
-
  Branch (428:20): [True: 0, False: 0]
-
  Branch (428:20): [True: 0, False: 0]
-
  Branch (428:20): [True: 0, False: 0]
-
  Branch (428:20): [True: 0, False: 0]
-
  Branch (428:20): [True: 0, False: 0]
-
429
23
                *c = PreviewContent::Terminal(parser.clone());
430
23
            
}0
431
432
23
            self.thread_handle = Some(std::thread::spawn(move || {
433
23
                let mut buf = [0u8; 8192];
434
23
                let mut unprocessed_buf = Vec::new();
435
436
23
                trace!("preview reader thread started");
437
438
                loop {
439
58
                    if interrupt_rx.try_recv().is_ok() {
  Branch (439:24): [True: 0, False: 0]
-
  Branch (439:24): [True: 0, False: 0]
-
  Branch (439:24): [True: 0, False: 0]
-
  Branch (439:24): [True: 0, False: 0]
-
  Branch (439:24): [True: 0, False: 0]
-
  Branch (439:24): [True: 0, False: 0]
-
  Branch (439:24): [True: 0, False: 0]
-
  Branch (439:24): [True: 0, False: 58]
-
  Branch (439:24): [True: 0, False: 0]
-
  Branch (439:24): [True: 0, False: 0]
-
  Branch (439:24): [True: 0, False: 0]
-
  Branch (439:24): [True: 0, False: 0]
-
  Branch (439:24): [True: 0, False: 0]
-
  Branch (439:24): [True: 0, False: 0]
-
  Branch (439:24): [True: 0, False: 0]
-
  Branch (439:24): [True: 0, False: 0]
-
  Branch (439:24): [True: 0, False: 0]
-
  Branch (439:24): [True: 0, False: 0]
-
  Branch (439:24): [True: 0, False: 0]
-
440
0
                        trace!("interrupt signal received, exiting");
441
0
                        return;
442
58
                    }
443
444
58
                    match reader.read(&mut buf) {
445
                        Ok(0) => {
446
23
                            trace!("reached EOF");
447
23
                            break;
448
                        }
449
35
                        Ok(size) => {
450
35
                            trace!("read {size} bytes");
451
35
                            unprocessed_buf.extend_from_slice(&buf[..size]);
452
453
                            // Filter out terminal query sequences and respond to them
454
                            // This prevents programs like delta from waiting for responses
455
35
                            let filtered = Self::filter_and_respond_to_queries(&unprocessed_buf, &mut esc_writer);
456
457
35
                            if let Ok(mut parser_guard) = parser.write() {
  Branch (457:36): [True: 0, False: 0]
-
  Branch (457:36): [True: 0, False: 0]
-
  Branch (457:36): [True: 0, False: 0]
-
  Branch (457:36): [True: 0, False: 0]
-
  Branch (457:36): [True: 0, False: 0]
-
  Branch (457:36): [True: 0, False: 0]
-
  Branch (457:36): [True: 0, False: 0]
-
  Branch (457:36): [True: 35, False: 0]
-
  Branch (457:36): [True: 0, False: 0]
-
  Branch (457:36): [True: 0, False: 0]
-
  Branch (457:36): [True: 0, False: 0]
-
  Branch (457:36): [True: 0, False: 0]
-
  Branch (457:36): [True: 0, False: 0]
-
  Branch (457:36): [True: 0, False: 0]
-
  Branch (457:36): [True: 0, False: 0]
-
  Branch (457:36): [True: 0, False: 0]
-
  Branch (457:36): [True: 0, False: 0]
-
  Branch (457:36): [True: 0, False: 0]
-
  Branch (457:36): [True: 0, False: 0]
-
458
35
                                parser_guard.process(&filtered);
459
35
                                parser_guard.screen_mut().set_scrollback(VT_SCROLLBACK);
460
35
                            
}0
461
462
                            // Clear the processed portion of the buffer
463
35
                            unprocessed_buf.clear();
464
465
                            // Check interrupt after processing
466
35
                            if interrupt_rx.try_recv().is_ok() {
  Branch (466:32): [True: 0, False: 0]
-
  Branch (466:32): [True: 0, False: 0]
-
  Branch (466:32): [True: 0, False: 0]
-
  Branch (466:32): [True: 0, False: 0]
-
  Branch (466:32): [True: 0, False: 0]
-
  Branch (466:32): [True: 0, False: 0]
-
  Branch (466:32): [True: 0, False: 0]
-
  Branch (466:32): [True: 0, False: 35]
-
  Branch (466:32): [True: 0, False: 0]
-
  Branch (466:32): [True: 0, False: 0]
-
  Branch (466:32): [True: 0, False: 0]
-
  Branch (466:32): [True: 0, False: 0]
-
  Branch (466:32): [True: 0, False: 0]
-
  Branch (466:32): [True: 0, False: 0]
-
  Branch (466:32): [True: 0, False: 0]
-
  Branch (466:32): [True: 0, False: 0]
-
  Branch (466:32): [True: 0, False: 0]
-
  Branch (466:32): [True: 0, False: 0]
-
  Branch (466:32): [True: 0, False: 0]
-
467
0
                                trace!("interrupt signal received during read block, exiting");
468
0
                                return;
469
35
                            }
470
                        }
471
0
                        Err(e) => {
472
0
                            trace!("read error {e:?}");
473
0
                            break;
474
                        }
475
                    }
476
                }
477
478
                // Last check, I promise
479
23
                if interrupt_rx.try_recv().is_ok() {
  Branch (479:20): [True: 0, False: 0]
-
  Branch (479:20): [True: 0, False: 0]
-
  Branch (479:20): [True: 0, False: 0]
-
  Branch (479:20): [True: 0, False: 0]
-
  Branch (479:20): [True: 0, False: 0]
-
  Branch (479:20): [True: 0, False: 0]
-
  Branch (479:20): [True: 0, False: 0]
-
  Branch (479:20): [True: 0, False: 23]
-
  Branch (479:20): [True: 0, False: 0]
-
  Branch (479:20): [True: 0, False: 0]
-
  Branch (479:20): [True: 0, False: 0]
-
  Branch (479:20): [True: 0, False: 0]
-
  Branch (479:20): [True: 0, False: 0]
-
  Branch (479:20): [True: 0, False: 0]
-
  Branch (479:20): [True: 0, False: 0]
-
  Branch (479:20): [True: 0, False: 0]
-
  Branch (479:20): [True: 0, False: 0]
-
  Branch (479:20): [True: 0, False: 0]
-
  Branch (479:20): [True: 0, False: 0]
-
480
0
                    trace!("interrupt signal received, exiting");
481
0
                    return;
482
23
                }
483
484
23
                trace!("read complete");
485
23
                let _ = event_tx_clone.blocking_send(Event::PreviewReady);
486
23
            }));
487
        } else {
488
42
            trace!("spawning preview cmd {cmd}");
489
42
            let mut shell_cmd = crate::shell_cmd(cmd);
490
42
            shell_cmd
491
42
                .env("ROWS", self.rows.to_string())
492
42
                .env("COLUMNS", self.cols.to_string())
493
42
                .env("PAGER", "");
494
42
            if let Ok(cwd) = env::current_dir() {
  Branch (494:20): [True: 0, False: 0]
-
  Branch (494:20): [True: 0, False: 0]
-
  Branch (494:20): [True: 5, False: 0]
-
  Branch (494:20): [True: 0, False: 0]
-
  Branch (494:20): [True: 0, False: 0]
-
  Branch (494:20): [True: 0, False: 0]
-
  Branch (494:20): [True: 0, False: 0]
-
  Branch (494:20): [True: 27, False: 0]
-
  Branch (494:20): [True: 2, False: 0]
-
  Branch (494:20): [True: 0, False: 0]
-
  Branch (494:20): [True: 0, False: 0]
-
  Branch (494:20): [True: 0, False: 0]
-
  Branch (494:20): [True: 3, False: 0]
-
  Branch (494:20): [True: 0, False: 0]
-
  Branch (494:20): [True: 0, False: 0]
-
  Branch (494:20): [True: 5, False: 0]
-
  Branch (494:20): [True: 0, False: 0]
-
  Branch (494:20): [True: 0, False: 0]
-
  Branch (494:20): [True: 0, False: 0]
-
495
42
                shell_cmd.current_dir(cwd);
496
42
            
}0
497
498
42
            let (interrupt_tx, interrupt_rx) = mpsc::channel();
499
42
            self.interrupt_tx = Some(interrupt_tx);
500
501
42
            self.thread_handle = Some(std::thread::spawn(move || {
502
42
                if interrupt_rx.try_recv().is_ok() {
  Branch (502:20): [True: 0, False: 0]
-
  Branch (502:20): [True: 0, False: 0]
-
  Branch (502:20): [True: 0, False: 5]
-
  Branch (502:20): [True: 0, False: 0]
-
  Branch (502:20): [True: 0, False: 0]
-
  Branch (502:20): [True: 0, False: 0]
-
  Branch (502:20): [True: 0, False: 0]
-
  Branch (502:20): [True: 0, False: 27]
-
  Branch (502:20): [True: 0, False: 2]
-
  Branch (502:20): [True: 0, False: 0]
-
  Branch (502:20): [True: 0, False: 0]
-
  Branch (502:20): [True: 0, False: 0]
-
  Branch (502:20): [True: 3, False: 0]
-
  Branch (502:20): [True: 0, False: 0]
-
  Branch (502:20): [True: 0, False: 0]
-
  Branch (502:20): [True: 0, False: 5]
-
  Branch (502:20): [True: 0, False: 0]
-
  Branch (502:20): [True: 0, False: 0]
-
  Branch (502:20): [True: 0, False: 0]
-
503
3
                    return;
504
39
                }
505
506
39
                let try_out = shell_cmd.output();
507
39
                if try_out.is_err() {
  Branch (507:20): [True: 0, False: 0]
-
  Branch (507:20): [True: 0, False: 0]
-
  Branch (507:20): [True: 0, False: 5]
-
  Branch (507:20): [True: 0, False: 0]
-
  Branch (507:20): [True: 0, False: 0]
-
  Branch (507:20): [True: 0, False: 0]
-
  Branch (507:20): [True: 0, False: 0]
-
  Branch (507:20): [True: 0, False: 27]
-
  Branch (507:20): [True: 0, False: 2]
-
  Branch (507:20): [True: 0, False: 0]
-
  Branch (507:20): [True: 0, False: 0]
-
  Branch (507:20): [True: 0, False: 0]
-
  Branch (507:20): [True: 0, False: 0]
-
  Branch (507:20): [True: 0, False: 0]
-
  Branch (507:20): [True: 0, False: 0]
-
  Branch (507:20): [True: 0, False: 5]
-
  Branch (507:20): [True: 0, False: 0]
-
  Branch (507:20): [True: 0, False: 0]
-
  Branch (507:20): [True: 0, False: 0]
-
508
0
                    log::info!("Shell cmd in error: {try_out:?}");
509
0
                    let _ = event_tx_clone.blocking_send(Event::PreviewReady);
510
0
                    return;
511
39
                }
512
513
39
                let mut out = try_out.unwrap();
514
515
39
                if interrupt_rx.try_recv().is_ok() {
  Branch (515:20): [True: 0, False: 0]
-
  Branch (515:20): [True: 0, False: 0]
-
  Branch (515:20): [True: 0, False: 5]
-
  Branch (515:20): [True: 0, False: 0]
-
  Branch (515:20): [True: 0, False: 0]
-
  Branch (515:20): [True: 0, False: 0]
-
  Branch (515:20): [True: 0, False: 0]
-
  Branch (515:20): [True: 0, False: 27]
-
  Branch (515:20): [True: 0, False: 2]
-
  Branch (515:20): [True: 0, False: 0]
-
  Branch (515:20): [True: 0, False: 0]
-
  Branch (515:20): [True: 0, False: 0]
-
  Branch (515:20): [True: 0, False: 0]
-
  Branch (515:20): [True: 0, False: 0]
-
  Branch (515:20): [True: 0, False: 0]
-
  Branch (515:20): [True: 0, False: 5]
-
  Branch (515:20): [True: 0, False: 0]
-
  Branch (515:20): [True: 0, False: 0]
-
  Branch (515:20): [True: 0, False: 0]
-
516
0
                    return;
517
39
                }
518
519
39
                if let Ok(mut c) = content.write() {
  Branch (519:24): [True: 0, False: 0]
-
  Branch (519:24): [True: 0, False: 0]
-
  Branch (519:24): [True: 5, 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: 27, False: 0]
-
  Branch (519:24): [True: 2, 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: 5, False: 0]
-
  Branch (519:24): [True: 0, False: 0]
-
  Branch (519:24): [True: 0, False: 0]
-
  Branch (519:24): [True: 0, False: 0]
-
520
39
                    if out.status.success() {
  Branch (520:24): [True: 0, False: 0]
-
  Branch (520:24): [True: 0, False: 0]
-
  Branch (520:24): [True: 5, False: 0]
-
  Branch (520:24): [True: 0, False: 0]
-
  Branch (520:24): [True: 0, False: 0]
-
  Branch (520:24): [True: 0, False: 0]
-
  Branch (520:24): [True: 0, False: 0]
-
  Branch (520:24): [True: 27, False: 0]
-
  Branch (520:24): [True: 2, False: 0]
-
  Branch (520:24): [True: 0, False: 0]
-
  Branch (520:24): [True: 0, False: 0]
-
  Branch (520:24): [True: 0, False: 0]
-
  Branch (520:24): [True: 0, False: 0]
-
  Branch (520:24): [True: 0, False: 0]
-
  Branch (520:24): [True: 0, False: 0]
-
  Branch (520:24): [True: 5, False: 0]
-
  Branch (520:24): [True: 0, False: 0]
-
  Branch (520:24): [True: 0, False: 0]
-
  Branch (520:24): [True: 0, False: 0]
-
521
39
                        out.stdout.resize(PREVIEW_MAX_BYTES.min(out.stdout.len()), 0);
522
39
                        *c = PreviewContent::Text(out.stdout.into_text().unwrap_or_default());
523
39
                    } else {
524
0
                        *c = PreviewContent::Text(out.stderr.clone().into_text().unwrap_or_default());
525
0
                    }
526
0
                }
527
528
39
                trace!("sending ready ping");
529
39
                let _ = event_tx_clone.blocking_send(Event::PreviewReady);
530
42
            }));
531
        }
532
65
        Ok(())
533
71
    }
534
535
168
    fn render_text(
536
168
        &self,
537
168
        mut outer: Block,
538
168
        area: ratatui::layout::Rect,
539
168
        buf: &mut ratatui::prelude::Buffer,
540
168
        text: &Text,
541
168
    ) -> u16 {
542
        // Calculate total lines in content
543
168
        let total_lines: u16 = text.lines.len().try_into().unwrap();
544
545
        // Create paragraph with optional block
546
168
        let mut paragraph = Paragraph::new(text.clone()).scroll((self.scroll_y, self.scroll_x));
547
548
        // Enable wrapping if wrap is true
549
168
        if self.wrap {
  Branch (549:12): [True: 10, False: 156]
-
  Branch (549:12): [True: 0, False: 2]
-
550
10
            paragraph = paragraph.wrap(ratatui::widgets::Wrap { trim: false });
551
158
        }
552
553
        // Add scroll position indicator at top-right if scrolled
554
168
        if self.scroll_y > 0 && 
total_lines > 06
{
  Branch (554:12): [True: 6, False: 160]
-  Branch (554:33): [True: 6, False: 0]
-
  Branch (554:12): [True: 0, False: 2]
-  Branch (554:33): [True: 0, False: 0]
-
555
6
            let current_line = (self.scroll_y + 1) as usize; // +1 because scroll_y is 0-indexed but we want 1-indexed display
556
6
            let title = format!("{current_line}/{total_lines}");
557
6
558
6
            outer = outer.title_top(Line::from(title).alignment(Alignment::Right).reversed());
559
162
        }
560
561
168
        paragraph = paragraph.block(outer);
562
168
        paragraph.render(area, buf);
563
168
        total_lines
564
168
    }
565
566
48
    fn render_pty(
567
48
        &self,
568
48
        mut outer: Block,
569
48
        area: ratatui::layout::Rect,
570
48
        buf: &mut ratatui::prelude::Buffer,
571
48
        parser: &std::sync::RwLock<tui_term::vt100::Parser>,
572
48
    ) -> u16 {
573
48
        let mut total_lines = 0u16;
574
        // For terminal content, manipulate scrollback to implement scrolling
575
48
        if let Ok(mut parser_guard) = parser.try_write() {
  Branch (575:16): [True: 48, False: 0]
-
  Branch (575:16): [True: 0, False: 0]
-
576
48
            let scrollback_len = parser_guard.screen().scrollback();
577
            // Reset scrollback to its full size first
578
48
            parser_guard.screen_mut().set_scrollback(VT_SCROLLBACK);
579
            // If the scrollback is not empty, we seem to be off by one
580
48
            total_lines = (scrollback_len.saturating_sub(1) + parser_guard.screen().contents().lines().count())
581
48
                .try_into()
582
48
                .unwrap();
583
48
            if self.scroll_y > 0 {
  Branch (583:16): [True: 6, False: 42]
-
  Branch (583:16): [True: 0, False: 0]
-
584
6
                trace!("scrolling in vt buffer: {}/{}", self.scroll_y, total_lines);
585
                // Reduce scrollback by scroll_y to show earlier content
586
6
                parser_guard
587
6
                    .screen_mut()
588
6
                    .set_scrollback(scrollback_len.saturating_sub(self.scroll_y.into()));
589
42
            }
590
0
        }
591
592
        // Render using PseudoTerminal widget for proper terminal emulation
593
48
        if let Ok(parser_guard) = parser.try_read() {
  Branch (593:16): [True: 48, False: 0]
-
  Branch (593:16): [True: 0, False: 0]
-
594
48
            let screen = parser_guard.screen();
595
596
            // Add scroll position indicator if scrolled
597
48
            if self.scroll_y > 0 && 
total_lines > 06
{
  Branch (597:16): [True: 6, False: 42]
-  Branch (597:37): [True: 6, False: 0]
-
  Branch (597:16): [True: 0, False: 0]
-  Branch (597:37): [True: 0, False: 0]
-
598
6
                let title = format!("{}/{}", self.scroll_y + 1, total_lines);
599
6
                outer = outer.title_top(Line::from(title).alignment(Alignment::Right).reversed());
600
42
            }
601
602
            // Use PseudoTerminal widget to render the vt100 screen
603
48
            let pseudo_term = PseudoTerminal::new(screen)
604
48
                .cursor(tui_term::widget::Cursor::default().visibility(false))
605
48
                .block(outer);
606
48
            pseudo_term.render(area, buf);
607
0
        }
608
609
        // Reset scrollback after rendering
610
48
        if self.scroll_y > 0
  Branch (610:12): [True: 6, False: 42]
-
  Branch (610:12): [True: 0, False: 0]
-
611
6
            && let Ok(mut parser_guard) = parser.try_write()
  Branch (611:20): [True: 6, False: 0]
-
  Branch (611:20): [True: 0, False: 0]
-
612
6
        {
613
6
            parser_guard.screen_mut().set_scrollback(VT_SCROLLBACK);
614
42
        }
615
48
        total_lines
616
48
    }
617
    #[cfg(feature = "image")]
618
7
    fn render_image(
619
7
        &self,
620
7
        mut outer: Block,
621
7
        area: ratatui::layout::Rect,
622
7
        buf: &mut ratatui::prelude::Buffer,
623
7
        source: &image::DynamicImage,
624
7
        protocol: &mut Option<ImageProtocol>,
625
7
        size: &mut ratatui::layout::Size,
626
7
    ) {
627
7
        let title = format!("{}x{}", source.width(), source.height());
628
7
        outer = outer.title_top(Line::from(title).alignment(Alignment::Right).reversed());
629
630
7
        let inner = outer.inner(area);
631
7
        outer.render(area, buf);
632
7
        let image_size = ratatui::layout::Size::new(inner.width, inner.height);
633
7
        if image_size.width > 0 && image_size.height > 0 && (protocol.is_none() || 
*size != image_size3
) {
  Branch (633:12): [True: 7, False: 0]
-  Branch (633:36): [True: 7, False: 0]
-  Branch (633:62): [True: 4, False: 3]
-  Branch (633:84): [True: 0, False: 3]
-
  Branch (633:12): [True: 0, False: 0]
-  Branch (633:36): [True: 0, False: 0]
-  Branch (633:62): [True: 0, False: 0]
-  Branch (633:84): [True: 0, False: 0]
-
634
4
            match Self::image_protocol(self.image_picker.as_ref(), source.clone(), image_size) {
635
4
                Ok(new_protocol) => {
636
4
                    *protocol = Some(new_protocol);
637
4
                    *size = image_size;
638
4
                }
639
0
                Err(err) => {
640
0
                    warn!("failed to render image preview: {err:?}");
641
                }
642
            }
643
3
        }
644
7
        if let Some(protocol) = protocol {
  Branch (644:16): [True: 7, False: 0]
-
  Branch (644:16): [True: 0, False: 0]
-
645
7
            let image = ratatui_image::Image::new(protocol).allow_clipping(true);
646
7
            image.render(inner, buf);
647
7
        
}0
648
7
    }
649
}
650
651
impl Drop for Preview {
652
538
    fn drop(&mut self) {
653
538
        if let Some(
pty13
) = self.pty.take() {
  Branch (653:16): [True: 13, False: 361]
-
  Branch (653:16): [True: 0, False: 164]
-
654
13
            let w = pty.master.take_writer();
655
13
            drop(w);
656
525
        }
657
538
        self.kill();
658
538
    }
659
}
660
661
impl SkimWidget for Preview {
662
538
    fn from_options(options: &SkimOptions, theme: Arc<ColorTheme>) -> Self {
663
        #[cfg_attr(not(target_os = "linux"), allow(unused_mut))]
664
538
        let mut res = Self {
665
538
            theme,
666
538
            border: options.border,
667
538
            direction: options.preview_window.direction,
668
538
            wrap: options.preview_window.wrap,
669
538
            content: Arc::new(RwLock::new(PreviewContent::default())),
670
538
            cmd: Default::default(),
671
538
            rows: 0,
672
538
            cols: 0,
673
538
            scroll_y: 0,
674
538
            scroll_x: 0,
675
538
            thread_handle: None,
676
538
            interrupt_tx: None,
677
538
            pty: None,
678
538
            pty_child: None,
679
538
            #[cfg(feature = "image")]
680
538
            image: options.image.is_some(),
681
538
            #[cfg(feature = "image")]
682
538
            image_picker: options.image_picker.clone(),
683
538
            total_lines: 0,
684
538
            loading: false,
685
538
            spinner_start: Instant::now(),
686
538
        };
687
        #[cfg(target_os = "linux")]
688
538
        if options.preview_window.pty {
  Branch (688:12): [True: 13, False: 361]
-
  Branch (688:12): [True: 0, False: 164]
-
689
13
            res.init_pty();
690
525
        }
691
538
        res
692
538
    }
693
694
223
    fn render(&mut self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer) -> SkimRender {
695
223
        if self.rows != area.height || 
self.cols != area.width182
{
  Branch (695:12): [True: 39, False: 182]
-  Branch (695:40): [True: 0, False: 182]
-
  Branch (695:12): [True: 2, False: 0]
-  Branch (695:40): [True: 0, False: 0]
-
696
41
            self.rows = area.height;
697
41
            self.cols = area.width;
698
41
            self.pty.as_ref().map(|p| 
{13
699
13
                p.master.resize(PtySize {
700
13
                    rows: self.rows,
701
13
                    cols: self.cols,
702
13
                    ..Default::default()
703
13
                })
704
13
            });
705
182
        }
706
223
        let Ok(mut content) = self.content.try_write() else {
  Branch (706:13): [True: 221, False: 0]
-
  Branch (706:13): [True: 2, False: 0]
-
707
0
            return SkimRender::default();
708
        };
709
710
223
        let mut block = Block::new().style(self.theme.normal).border_style(self.theme.border);
711
712
        // Add borders based on direction and border setting
713
223
        if let Some(
border_type0
) = self.border.into_ratatui() {
  Branch (713:16): [True: 0, False: 221]
-
  Branch (713:16): [True: 0, False: 2]
-
714
0
            block = block.borders(Borders::ALL).border_type(border_type);
715
0
        } else {
716
            // No border on preview itself - separator will be drawn between areas
717
223
            match self.direction {
718
20
                Direction::Up => block = block.borders(Borders::BOTTOM),
719
16
                Direction::Down => block = block.borders(Borders::TOP),
720
44
                Direction::Left => block = block.borders(Borders::RIGHT),
721
143
                Direction::Right => block = block.borders(Borders::LEFT),
722
            }
723
        }
724
725
223
        Clear.render(area, buf);
726
223
        let spinner_area = block.inner(area);
727
728
223
        match &mut *content {
729
168
            PreviewContent::Text(text) => self.total_lines = self.render_text(block, area, buf, text),
730
48
            PreviewContent::Terminal(parser) => self.total_lines = self.render_pty(block, area, buf, parser.as_ref()),
731
            #[cfg(feature = "image")]
732
7
            PreviewContent::Image { source, protocol, size } => {
733
7
                self.render_image(block, area, buf, source, protocol, size);
734
7
            }
735
        }
736
737
223
        if self.loading && 
spinner_area.width > 02
&&
spinner_area.height > 02
{
  Branch (737:12): [True: 2, False: 219]
-  Branch (737:28): [True: 2, False: 0]
-  Branch (737:54): [True: 2, False: 0]
-
  Branch (737:12): [True: 0, False: 2]
-  Branch (737:28): [True: 0, False: 0]
-  Branch (737:54): [True: 0, False: 0]
-
738
2
            let x = spinner_area.x + spinner_area.width.saturating_sub(1);
739
2
            let y = spinner_area.y + spinner_area.height.saturating_sub(1);
740
2
            buf.set_string(x, y, spinner_char(self.spinner_start).to_string(), self.theme.spinner);
741
221
        }
742
743
223
        SkimRender::default()
744
223
    }
745
}
746
747
#[cfg(test)]
748
#[path = "preview_tests.rs"]
749
mod tests;
\ No newline at end of file +

Coverage Report

Created: 2026-09-05 18:50

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

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
81
pub(crate) fn spinner_char(start: Instant) -> char {
9
81
    let spinner_elapsed_ms = start.elapsed().as_millis();
10
81
    let index = ((spinner_elapsed_ms / u128::from(SPINNER_DURATION)) % (SPINNERS_UNICODE.len() as u128)) as usize;
11
81
    SPINNERS_UNICODE[index]
12
81
}
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
501
    pub(crate) fn is_inline(&self) -> bool {
33
501
        
matches!488
(self, InfoDisplay::Inline | InfoDisplay::InlineRight)
34
501
    }
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.69k
    pub(crate) fn separator(&self) -> Option<&str> {
48
2.69k
        self.separator.as_deref()
49
2.69k
    }
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
475
    fn from(s: &str) -> Self {
68
        use InfoDisplay::{Default, Hidden, Inline, InlineRight, Left, Right};
69
475
        let mut parts = s.split(':');
70
71
475
        let 
display474
= match parts.next() {
72
475
            None | Some("default") => 
Default449
,
73
26
            Some("left") => 
Left2
,
74
24
            Some("right") => 
Right2
,
75
22
            Some("inline") => 
Inline5
,
76
17
            Some("inline-right") => 
InlineRight3
,
77
14
            Some("hidden") => 
Hidden13
,
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
474
        let separator = if display.is_inline() {
  Branch (82:28): [True: 4, False: 450]
+
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
490
    fn from(s: &str) -> Self {
68
        use InfoDisplay::{Default, Hidden, Inline, InlineRight, Left, Right};
69
490
        let mut parts = s.split(':');
70
71
490
        let 
display489
= match parts.next() {
72
490
            None | Some("default") => 
Default463
,
73
27
            Some("left") => 
Left2
,
74
25
            Some("right") => 
Right2
,
75
23
            Some("inline") => 
Inline6
,
76
17
            Some("inline-right") => 
InlineRight3
,
77
14
            Some("hidden") => 
Hidden13
,
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
489
        let separator = if display.is_inline() {
  Branch (82:28): [True: 5, False: 464]
 
  Branch (82:28): [True: 4, False: 16]
-
83
8
            parts.next().or(Some(DEFAULT_SEPARATOR)).map(String::from)
84
        } else {
85
466
            None
86
        };
87
474
        Self { display, separator }
88
474
    }
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
9
            parts.next().or(Some(DEFAULT_SEPARATOR)).map(String::from)
84
        } else {
85
480
            None
86
        };
87
489
        Self { display, separator }
88
489
    }
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 790e3f8c..6f6b0983 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,12 +1,12 @@ -

Coverage Report

Created: 2026-09-04 10: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
2.02k
    for span in 
line.spans676
{
30
2.02k
        if chars_seen >= max_chars {
  Branch (30:12): [True: 13, False: 2.00k]
+

Coverage Report

Created: 2026-09-05 18:50

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
2.02k
    for span in 
line.spans676
{
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_width101k
(
c101k
) {
  Branch (47:8): [True: 0, False: 100k]
-  Branch (47:27): [True: 18, False: 100k]
-
  Branch (47:8): [True: 1, False: 854]
-  Branch (47:27): [True: 11, False: 843]
-
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]
+
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
102k
pub fn char_display_width(c: char) -> usize {
47
102k
    if c == '\u{FE0F}' || 
is_double_width102k
(
c102k
) {
  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
102k
    }
50
102k
    1
51
102k
}
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]
 
  Branch (54:8): [True: 4, False: 7]
 
55
7
        return input;
56
19
    }
57
58
19
    let mut output = Text::default();
59
60
20
    for input_line in 
input19
.
iter19
() {
61
20
        let mut current_line = Line::default();
62
20
        let mut w = 0;
63
53
        for span in 
&input_line.spans20
{
64
53
            let mut curr = Span::default().style(span.style);
65
53
            let mut curr_content = String::new();
66
1.52k
            for c in 
span.content.chars()53
{
67
1.52k
                if w + char_display_width(c) > width {
  Branch (67:20): [True: 15, False: 1.43k]
 
  Branch (67:20): [True: 14, False: 62]
@@ -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
47
            
}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
20
        
}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.21k
pub(crate) fn merge_styles(left: Style, right: Style) -> Style {
102
    use ratatui::style::Color::Reset;
103
3.21k
    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.21k
    set_field!(res, left, right, fg);
117
3.21k
    set_field!(res, left, right, bg);
118
3.21k
    set_field!(res, left, right, underline_color);
119
3.21k
    res.add_modifier = left.add_modifier | right.add_modifier;
120
121
3.21k
    res
122
3.21k
}
123
124
2.94k
pub(crate) fn style_span(span: &mut Span, style: Style) {
125
2.94k
    span.style = merge_styles(style, span.style);
126
2.94k
}
127
2.93k
pub(crate) fn style_line(line: &mut Line, style: Style) {
128
2.94k
    
line2.93k
.
iter_mut2.93k
().
for_each2.93k
(|span| style_span(span, style));
129
2.93k
}
130
145
pub(crate) fn style_text(text: &mut Text, style: Style) {
131
157
    
text145
.
iter_mut145
().
for_each145
(|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
23
    for i in 
2..data.len()3
{
137
23
        if data[i] == b'\x07' {
  Branch (137:12): [True: 0, False: 0]
+
91
20
            output.push_line(current_line);
92
20
        
}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.36k
pub(crate) fn merge_styles(left: Style, right: Style) -> Style {
102
    use ratatui::style::Color::Reset;
103
3.36k
    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.36k
    set_field!(res, left, right, fg);
117
3.36k
    set_field!(res, left, right, bg);
118
3.36k
    set_field!(res, left, right, underline_color);
119
3.36k
    res.add_modifier = left.add_modifier | right.add_modifier;
120
121
3.36k
    res
122
3.36k
}
123
124
3.00k
pub(crate) fn style_span(span: &mut Span, style: Style) {
125
3.00k
    span.style = merge_styles(style, span.style);
126
3.00k
}
127
2.99k
pub(crate) fn style_line(line: &mut Line, style: Style) {
128
3.00k
    
line2.99k
.
iter_mut2.99k
().
for_each2.99k
(|span| style_span(span, style));
129
2.99k
}
130
139
pub(crate) fn style_text(text: &mut Text, style: Style) {
131
151
    
text139
.
iter_mut139
().
for_each139
(|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
23
    for i in 
2..data.len()3
{
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
&&
data[i + 1] == b'\\'1
{
  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 dfba97f3..4c13cda3 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-04 10: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.67k
    fn bitor_assign(&mut self, rhs: Self) {
20
5.67k
        self.items_updated |= rhs.items_updated;
21
5.67k
        self.run_preview |= rhs.run_preview;
22
5.67k
    }
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
140
    fn _default() -> Self {
38
140
        Self::from_options(&SkimOptions::default(), Arc::new(ColorTheme::default()))
39
140
    }
40
}
\ No newline at end of file +

Coverage Report

Created: 2026-09-05 18:50

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.82k
    fn bitor_assign(&mut self, rhs: Self) {
20
5.82k
        self.items_updated |= rhs.items_updated;
21
5.82k
        self.run_preview |= rhs.run_preview;
22
5.82k
    }
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
144
    fn _default() -> Self {
38
144
        Self::from_options(&SkimOptions::default(), Arc::new(ColorTheme::default()))
39
144
    }
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 b61fbfa9..8ed14957 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,39 +1,35 @@ -

Coverage Report

Created: 2026-09-04 10: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
479
pub fn unescape_delimiter(s: &str) -> String {
31
479
    let mut result = String::new();
32
479
    let mut chars = s.chars();
33
34
3.09k
    while let Some(
c2.61k
) = chars.next() {
  Branch (34:15): [True: 2.52k, False: 456]
+

Coverage Report

Created: 2026-09-05 18:50

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
494
pub fn unescape_delimiter(s: &str) -> String {
31
494
    let mut result = String::new();
32
494
    let mut chars = s.chars();
33
34
3.19k
    while let Some(
c2.70k
) = chars.next() {
  Branch (34:15): [True: 2.61k, False: 471]
 
  Branch (34:15): [True: 89, False: 23]
-
35
2.61k
        if c == '\\' {
  Branch (35:12): [True: 831, False: 1.69k]
+
35
2.70k
        if c == '\\' {
  Branch (35:12): [True: 861, False: 1.75k]
 
  Branch (35:12): [True: 35, False: 54]
-
36
866
            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
896
            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(
byte7
) = u8::from_str_radix(&hex, 16) {
  Branch (41:32): [True: 2, False: 0]
 
  Branch (41:32): [True: 5, False: 1]
-
42
7
                            // For null byte and other non-UTF8 safe bytes, we need to handle carefully
43
7
                            // Regex works with strings, so we push the byte as a char
44
7
                            result.push(byte as char);
45
7
                        } else {
46
1
                            // Invalid hex, keep as literal
47
1
                            result.push('\\');
48
1
                            result.push('x');
49
1
                            result.push_str(&hex);
50
1
                        }
51
1
                    } else {
52
1
                        // Not enough hex digits
53
1
                        result.push('\\');
54
1
                        result.push('x');
55
1
                        result.push_str(&hex);
56
1
                    }
57
                }
58
427
                Some('t') => result.push('\t'),
59
427
                Some('n') => result.push('\n'),
60
1
                Some('r') => result.push('\r'),
61
1
                Some('\\') | None => result.push('\\'),
62
1
                Some(other) => {
63
1
                    // Unknown escape, keep both backslash and character
64
1
                    result.push('\\');
65
1
                    result.push(other);
66
1
                }
67
            }
68
1.74k
        } else {
69
1.74k
            result.push(c);
70
1.74k
        }
71
    }
72
73
479
    result
74
479
}
75
76
5
pub fn read_file_lines(filename: &str) -> std::result::Result<Vec<String>, std::io::Error> {
77
5
    let 
file4
= 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
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_arg184
= |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]
-  Branch (108:21): [True: 0, False: 20]
+
42
7
                            // For null byte and other non-UTF8 safe bytes, we need to handle carefully
43
7
                            // Regex works with strings, so we push the byte as a char
44
7
                            result.push(byte as char);
45
7
                        } else {
46
1
                            // Invalid hex, keep as literal
47
1
                            result.push('\\');
48
1
                            result.push('x');
49
1
                            result.push_str(&hex);
50
1
                        }
51
1
                    } else {
52
1
                        // Not enough hex digits
53
1
                        result.push('\\');
54
1
                        result.push('x');
55
1
                        result.push_str(&hex);
56
1
                    }
57
                }
58
442
                Some('t') => result.push('\t'),
59
442
                Some('n') => result.push('\n'),
60
1
                Some('r') => result.push('\r'),
61
1
                Some('\\') | None => result.push('\\'),
62
1
                Some(other) => {
63
1
                    // Unknown escape, keep both backslash and character
64
1
                    result.push('\\');
65
1
                    result.push(other);
66
1
                }
67
            }
68
1.80k
        } else {
69
1.80k
            result.push(c);
70
1.80k
        }
71
    }
72
73
494
    result
74
494
}
75
76
5
pub fn read_file_lines(filename: &str) -> std::result::Result<Vec<String>, std::io::Error> {
77
5
    let 
file4
= 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_arg185
= |s: &str, quote: bool| {
107
609
        let mut res = s.replace('\0', "\\0").clone();
108
609
        if quote && 
quote_args594
{
  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]
   Branch (108:12): [True: 51, False: 0]
   Branch (108:21): [True: 45, False: 6]
-
109
504
            res = format!("'{}'", res.replace('\'', "'\\''"));
110
504
        
}102
111
606
        res
112
606
    };
113
114
184
    let item_text = current.as_ref().map(|s| 
strip_ansi108
(
&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
218
    for part in 
replstr_parts184
{
126
218
        let mut sub = part.split('{');
127
218
        let mut replaced = sub.next().unwrap_or_default().to_string();
128
218
        for 
s61
in sub {
129
61
            let mut inside = true;
130
61
            let mut content = String::new();
131
206
            for c in 
s61
.
chars61
() {
132
206
                if inside {
  Branch (132:20): [True: 73, False: 15]
-
  Branch (132:20): [True: 25, False: 0]
+
  Branch (108:12): [True: 20, False: 2]
+  Branch (108:21): [True: 0, False: 20]
+
109
507
            res = format!("'{}'", res.replace('\'', "'\\''"));
110
507
        
}102
111
609
        res
112
609
    };
113
114
185
    let item_text = current.as_ref().map(|s| 
strip_ansi108
(
&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
219
    for part in 
replstr_parts185
{
126
219
        let mut sub = part.split('{');
127
219
        let mut replaced = sub.next().unwrap_or_default().to_string();
128
219
        for 
s61
in sub {
129
61
            let mut inside = true;
130
61
            let mut content = String::new();
131
206
            for c in 
s61
.
chars61
() {
132
206
                if inside {
  Branch (132:20): [True: 73, False: 15]
 
  Branch (132:20): [True: 56, False: 31]
 
  Branch (132:20): [True: 6, False: 0]
+
  Branch (132:20): [True: 25, False: 0]
 
133
160
                    if c == '}' {
  Branch (133:24): [True: 38, False: 35]
-
  Branch (133:24): [True: 3, False: 22]
 
  Branch (133:24): [True: 17, False: 39]
 
  Branch (133:24): [True: 3, False: 3]
+
  Branch (133:24): [True: 3, False: 22]
 
134
61
                        match content.as_str() {
135
61
                            "" => 
replaced4
.
push_str4
(
"{}"4
),
136
57
                            "q" => 
replaced15
.
push_str15
(
&escaped_query15
),
137
42
                            "cq" => 
replaced1
.
push_str1
(
&escaped_cmd_query1
),
138
41
                            "n" if 
current.as_ref()2
.
is_some2
(
)1
=> {
  Branch (138:36): [True: 0, False: 0]
-
  Branch (138:36): [True: 0, False: 0]
 
  Branch (138:36): [True: 1, False: 1]
 
  Branch (138:36): [True: 0, False: 0]
+
  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
1
                                        .unwrap_or_default()
144
1
                                        .to_string()
145
1
                                        .as_str(),
146
                                );
147
                            }
148
40
                            
s26
if s == "+n" ||
s39
.
starts_with39
("+n:") ||
s == "+"37
||
s15
.
starts_with15
("+:"
)26
=> {
  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 (148:47): [True: 0, False: 3]
-  Branch (148:71): [True: 1, False: 2]
-  Branch (148:83): [True: 0, False: 2]
 
  Branch (148:34): [True: 1, False: 12]
   Branch (148:47): [True: 2, False: 10]
   Branch (148:71): [True: 3, False: 7]
@@ -42,44 +38,48 @@
   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: 3]
+  Branch (148:47): [True: 0, False: 3]
+  Branch (148:71): [True: 1, False: 2]
+  Branch (148:83): [True: 0, False: 2]
 
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: 3, False: 4]
 
  Branch (150:51): [True: 0, False: 0]
+
  Branch (150:51): [True: 0, False: 1]
 
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
                                    |x| x.1,
163
                                );
164
165
26
                                let mut expanded = selected
166
26
                                    .clone()
167
30
                                    .
map26
(|i| escape_arg(&accessor(i), quote_individually))
168
26
                                    .reduce(|a: String, b| 
a16
.clone() + delim +
b.as_str()16
)
169
26
                                    .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: 2, False: 5]
 
  Branch (170:36): [True: 0, False: 0]
+
  Branch (170:36): [True: 0, False: 1]
 
171
12
                                    expanded = current
172
12
                                        .as_ref()
173
12
                                        .map(|i| escape_arg(&accessor(i), quote_args))
174
12
                                        .unwrap_or_default();
175
14
                                }
176
177
26
                                if quote_args && 
!quote_individually23
{
  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: 5, False: 2]
   Branch (177:50): [True: 2, False: 3]
 
  Branch (177:36): [True: 0, False: 0]
   Branch (177:50): [True: 0, False: 0]
+
  Branch (177:36): [True: 0, False: 1]
+  Branch (177:50): [True: 0, False: 0]
 
178
2
                                    let _ = write!(replaced, "'{expanded}'");
179
24
                                } else {
180
24
                                    replaced.push_str(&expanded);
181
24
                                }
182
                            }
183
14
                            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: 2, False: 4]
 
  Branch (188:36): [True: 0, False: 0]
+
  Branch (188:36): [True: 0, False: 2]
 
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(
range1
) = FieldRange::from_str(stripped) {
  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]
+
  Branch (195:44): [True: 0, False: 0]
 
196
1
                                        let expanded = selected
197
1
                                            .clone()
198
2
                                            .
map1
(|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
1
                                            .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: 1]
   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]
 
209
0
                                            let _ = write!(replaced, "'{expanded}'");
210
1
                                        } else {
211
1
                                            replaced.push_str(&expanded);
212
1
                                        }
213
                                    } else {
214
1
                                        log::warn!("Failed to build multi-item field range from {content}");
215
1
                                        let _ = write!(replaced, "{{{s}}}");
216
                                    }
217
12
                                } else if let Some(
range10
) = FieldRange::from_str(stripped) {
  Branch (217:47): [True: 6, False: 0]
-
  Branch (217:47): [True: 2, False: 0]
 
  Branch (217:47): [True: 2, False: 2]
 
  Branch (217:47): [True: 0, False: 0]
+
  Branch (217:47): [True: 2, False: 0]
 
218
10
                                    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
10
                                } else {
222
2
                                    log::warn!("Failed to build field range from {content}");
223
2
                                    let _ = write!(replaced, "{{{s}}}");
224
                                }
225
                            }
226
                        }
227
228
61
                        content.clear();
229
61
                        inside = false;
230
99
                    } else {
231
99
                        content.push(c);
232
99
                    }
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: 31]
 
  Branch (233:27): [True: 0, False: 0]
-
234
0
                    inside = true;
235
46
                } else {
236
46
                    replaced.push(c);
237
46
                }
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
184
        .reduce(|a: String, b| 
a + &escaped_item34
+
&b34
)
248
184
        .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
46
                } else {
236
46
                    replaced.push(c);
237
46
                }
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
185
        .reduce(|a: String, b| 
a + &escaped_item34
+
&b34
)
248
185
        .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 1ae9f769..fc1ee7f7 100644 --- a/coverage/index.html +++ b/coverage/index.html @@ -1 +1 @@ -

Coverage Report

Created: 2026-09-04 10:09

Click here for information about interpreting this report.

FilenameFunction CoverageLine CoverageRegion CoverageBranch Coverage
bin/main.rs
 100.00% (8/8)
  90.71% (127/140)
  86.33% (240/278)
  77.78% (28/36)
binds.rs
 100.00% (21/21)
  98.77% (240/243)
  98.84% (597/604)
  97.50% (39/40)
engine/all.rs
 100.00% (5/5)
 100.00% (22/22)
 100.00% (22/22)
- (0/0)
engine/andor.rs
 100.00% (15/15)
 100.00% (91/91)
 100.00% (115/115)
 100.00% (4/4)
engine/exact.rs
 100.00% (6/6)
 100.00% (68/68)
 100.00% (113/113)
 100.00% (18/18)
engine/factory.rs
 100.00% (21/21)
 100.00% (156/156)
 100.00% (186/186)
 100.00% (20/20)
engine/fuzzy.rs
 100.00% (15/15)
 100.00% (114/114)
 100.00% (193/193)
 100.00% (10/10)
engine/normalized.rs
 100.00% (7/7)
 100.00% (37/37)
 100.00% (75/75)
 100.00% (2/2)
engine/regexp.rs
 100.00% (7/7)
 100.00% (49/49)
 100.00% (87/87)
 100.00% (4/4)
engine/split.rs
 100.00% (12/12)
 100.00% (75/75)
  98.57% (138/140)
 100.00% (6/6)
engine/util.rs
 100.00% (8/8)
 100.00% (66/66)
 100.00% (111/111)
 100.00% (16/16)
field.rs
 100.00% (14/14)
  99.02% (101/102)
  99.56% (226/227)
 100.00% (32/32)
fuzzy_matcher/arinae/algo.rs
 100.00% (6/6)
  88.43% (298/337)
  86.96% (567/652)
  72.07% (80/111)
fuzzy_matcher/arinae/atom.rs
  92.31% (12/13)
  93.33% (56/60)
  92.62% (113/122)
 100.00% (10/10)
fuzzy_matcher/arinae/banding.rs
 100.00% (4/4)
  97.56% (40/41)
  88.24% (75/85)
  62.50% (5/8)
fuzzy_matcher/arinae/helpers.rs
 100.00% (4/4)
 100.00% (47/47)
  98.67% (74/75)
 100.00% (4/4)
fuzzy_matcher/arinae/matrix.rs
 100.00% (7/7)
 100.00% (33/33)
 100.00% (49/49)
 100.00% (2/2)
fuzzy_matcher/arinae/mod.rs
 100.00% (20/20)
 100.00% (180/180)
  98.69% (378/383)
 100.00% (54/54)
fuzzy_matcher/arinae/prefilter.rs
 100.00% (5/5)
 100.00% (54/54)
 100.00% (88/88)
  94.44% (17/18)
fuzzy_matcher/clangd.rs
  96.15% (25/26)
  92.96% (264/284)
  91.24% (406/445)
  98.33% (59/60)
fuzzy_matcher/frizbee.rs
 100.00% (15/15)
 100.00% (51/51)
 100.00% (76/76)
- (0/0)
fuzzy_matcher/fzy.rs
 100.00% (37/37)
  99.30% (567/571)
  98.36% (959/975)
  93.16% (177/190)
fuzzy_matcher/mod.rs
 100.00% (3/3)
 100.00% (11/11)
 100.00% (26/26)
- (0/0)
fuzzy_matcher/skim.rs
 100.00% (38/38)
  99.06% (422/426)
  99.08% (644/650)
  98.44% (63/64)
fuzzy_matcher/util.rs
 100.00% (11/11)
 100.00% (89/89)
 100.00% (159/159)
 100.00% (26/26)
helper/item.rs
 100.00% (31/31)
  97.79% (398/407)
  96.81% (668/690)
  88.81% (119/134)
helper/item_reader.rs
  92.68% (38/41)
  90.76% (275/303)
  89.85% (407/453)
  70.83% (34/48)
helper/selector.rs
 100.00% (6/6)
 100.00% (42/42)
  96.43% (54/56)
  88.89% (16/18)
item.rs
  97.22% (35/36)
  97.17% (309/318)
  97.28% (465/478)
  92.50% (37/40)
lib.rs
 100.00% (8/8)
 100.00% (80/80)
 100.00% (176/176)
- (0/0)
manpage.rs
 100.00% (10/10)
  97.46% (192/197)
  94.60% (263/278)
 100.00% (8/8)
matcher.rs
 100.00% (23/23)
  98.71% (230/233)
  97.66% (334/342)
  91.67% (22/24)
options.rs
  86.96% (20/23)
  98.26% (339/345)
  97.70% (383/392)
  92.11% (35/38)
output.rs
 100.00% (2/2)
  96.43% (54/56)
  88.37% (76/86)
 100.00% (22/22)
popup/mod.rs
  63.64% (7/11)
  76.19% (144/189)
  79.34% (242/305)
  70.00% (42/60)
popup/tmux.rs
  85.71% (6/7)
  96.55% (56/58)
  96.81% (91/94)
  75.00% (3/4)
popup/zellij.rs
  81.82% (9/11)
  84.78% (78/92)
  90.00% (144/160)
  83.33% (5/6)
reader.rs
  95.00% (19/20)
  99.14% (115/116)
  97.22% (175/180)
 100.00% (8/8)
shell.rs
 100.00% (2/2)
  96.15% (25/26)
  95.00% (38/40)
 100.00% (4/4)
skim.rs
  82.50% (33/40)
  83.73% (350/418)
  79.03% (505/639)
  71.82% (79/110)
skim_item.rs
 100.00% (9/9)
 100.00% (27/27)
 100.00% (37/37)
- (0/0)
spinlock.rs
 100.00% (6/6)
 100.00% (28/28)
 100.00% (27/27)
 100.00% (2/2)
theme.rs
 100.00% (16/16)
  99.35% (304/306)
  99.55% (663/666)
  97.22% (35/36)
thread_pool.rs
 100.00% (18/18)
  97.98% (194/198)
  98.28% (285/290)
  88.46% (23/26)
tui/actions.rs
  75.00% (24/32)
  91.11% (82/90)
  88.65% (125/141)
 100.00% (2/2)
tui/app.rs
  97.96% (48/49)
  95.84% (1013/1057)
  93.11% (1568/1684)
  85.65% (197/230)
tui/backend.rs
  81.25% (26/32)
  74.02% (188/254)
  71.00% (284/400)
  56.82% (25/44)
tui/header.rs
 100.00% (11/11)
  98.46% (128/130)
  99.57% (232/233)
 100.00% (28/28)
tui/input.rs
  96.88% (31/32)
  96.58% (339/351)
  96.92% (630/650)
  80.36% (90/112)
tui/item_list.rs
  97.22% (35/36)
  95.23% (399/419)
  94.70% (625/660)
  87.30% (110/126)
tui/item_renderer.rs
 100.00% (29/29)
  98.30% (405/412)
  98.59% (767/778)
  89.68% (113/126)
tui/layout.rs
 100.00% (6/6)
  97.56% (120/123)
  96.77% (180/186)
  94.74% (36/38)
tui/mod.rs
  95.65% (22/23)
  93.94% (124/132)
  94.76% (199/210)
  75.00% (15/20)
tui/options.rs
 100.00% (2/2)
 100.00% (41/41)
 100.00% (45/45)
 100.00% (20/20)
tui/preview.rs
  91.67% (33/36)
  89.42% (431/482)
  90.15% (732/812)
  68.33% (82/120)
tui/statusline.rs
 100.00% (5/5)
 100.00% (33/33)
 100.00% (57/57)
 100.00% (4/4)
tui/util.rs
  78.26% (18/23)
  68.89% (186/270)
  65.19% (324/497)
  57.14% (48/84)
tui/widget.rs
 100.00% (2/2)
 100.00% (7/7)
 100.00% (10/10)
- (0/0)
util.rs
 100.00% (15/15)
  95.48% (169/177)
  96.75% (268/277)
  91.30% (42/46)
Totals
  94.90% (931/981)
  94.42% (10163/10764)
  93.49% (16796/17965)
  85.32% (1982/2323)
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-05 18:50

Click here for information about interpreting this report.

FilenameFunction CoverageLine CoverageRegion CoverageBranch Coverage
bin/main.rs
 100.00% (8/8)
  90.71% (127/140)
  86.33% (240/278)
  77.78% (28/36)
binds.rs
 100.00% (21/21)
  98.77% (240/243)
  98.84% (597/604)
  97.50% (39/40)
engine/all.rs
 100.00% (5/5)
 100.00% (22/22)
 100.00% (22/22)
- (0/0)
engine/andor.rs
 100.00% (15/15)
 100.00% (91/91)
 100.00% (115/115)
 100.00% (4/4)
engine/exact.rs
 100.00% (6/6)
 100.00% (68/68)
 100.00% (113/113)
 100.00% (18/18)
engine/factory.rs
 100.00% (21/21)
 100.00% (156/156)
 100.00% (186/186)
 100.00% (20/20)
engine/fuzzy.rs
 100.00% (15/15)
 100.00% (114/114)
 100.00% (193/193)
 100.00% (10/10)
engine/normalized.rs
 100.00% (7/7)
 100.00% (37/37)
 100.00% (75/75)
 100.00% (2/2)
engine/regexp.rs
 100.00% (7/7)
 100.00% (49/49)
 100.00% (87/87)
 100.00% (4/4)
engine/split.rs
 100.00% (12/12)
 100.00% (75/75)
  98.57% (138/140)
 100.00% (6/6)
engine/util.rs
 100.00% (8/8)
 100.00% (66/66)
 100.00% (111/111)
 100.00% (16/16)
field.rs
 100.00% (14/14)
  99.02% (101/102)
  99.56% (226/227)
 100.00% (32/32)
fuzzy_matcher/arinae/algo.rs
 100.00% (6/6)
  88.43% (298/337)
  86.96% (567/652)
  72.07% (80/111)
fuzzy_matcher/arinae/atom.rs
  92.31% (12/13)
  93.33% (56/60)
  92.62% (113/122)
 100.00% (10/10)
fuzzy_matcher/arinae/banding.rs
 100.00% (4/4)
  97.56% (40/41)
  88.24% (75/85)
  62.50% (5/8)
fuzzy_matcher/arinae/helpers.rs
 100.00% (4/4)
 100.00% (47/47)
  98.67% (74/75)
 100.00% (4/4)
fuzzy_matcher/arinae/matrix.rs
 100.00% (7/7)
 100.00% (33/33)
 100.00% (49/49)
 100.00% (2/2)
fuzzy_matcher/arinae/mod.rs
 100.00% (20/20)
 100.00% (180/180)
  98.69% (378/383)
 100.00% (54/54)
fuzzy_matcher/arinae/prefilter.rs
 100.00% (5/5)
 100.00% (54/54)
 100.00% (88/88)
  94.44% (17/18)
fuzzy_matcher/clangd.rs
  96.15% (25/26)
  92.96% (264/284)
  91.24% (406/445)
  98.33% (59/60)
fuzzy_matcher/frizbee.rs
 100.00% (15/15)
 100.00% (51/51)
 100.00% (76/76)
- (0/0)
fuzzy_matcher/fzy.rs
 100.00% (37/37)
  99.30% (567/571)
  98.36% (959/975)
  93.16% (177/190)
fuzzy_matcher/mod.rs
 100.00% (3/3)
 100.00% (11/11)
 100.00% (26/26)
- (0/0)
fuzzy_matcher/skim.rs
 100.00% (38/38)
  99.06% (422/426)
  99.08% (644/650)
  98.44% (63/64)
fuzzy_matcher/util.rs
 100.00% (11/11)
 100.00% (89/89)
 100.00% (159/159)
 100.00% (26/26)
helper/item.rs
 100.00% (31/31)
  97.79% (398/407)
  96.81% (668/690)
  88.81% (119/134)
helper/item_reader.rs
  92.68% (38/41)
  91.05% (285/313)
  90.25% (426/472)
  72.00% (36/50)
helper/selector.rs
 100.00% (6/6)
 100.00% (42/42)
  96.43% (54/56)
  88.89% (16/18)
item.rs
  97.22% (35/36)
  97.80% (311/318)
  98.12% (469/478)
  95.00% (38/40)
lib.rs
 100.00% (8/8)
 100.00% (80/80)
 100.00% (176/176)
- (0/0)
manpage.rs
 100.00% (10/10)
  97.46% (192/197)
  94.60% (263/278)
 100.00% (8/8)
matcher.rs
 100.00% (23/23)
  99.24% (260/262)
  98.11% (363/370)
  90.00% (27/30)
options.rs
  86.96% (20/23)
  98.26% (339/345)
  97.70% (383/392)
  92.11% (35/38)
output.rs
 100.00% (2/2)
  96.43% (54/56)
  88.37% (76/86)
 100.00% (22/22)
popup/mod.rs
  63.64% (7/11)
  76.19% (144/189)
  79.34% (242/305)
  70.00% (42/60)
popup/tmux.rs
  85.71% (6/7)
  96.55% (56/58)
  96.81% (91/94)
  75.00% (3/4)
popup/zellij.rs
  81.82% (9/11)
  84.78% (78/92)
  90.00% (144/160)
  83.33% (5/6)
reader.rs
  95.00% (19/20)
  97.54% (119/122)
  97.19% (173/178)
  87.50% (7/8)
shell.rs
 100.00% (2/2)
  96.15% (25/26)
  95.00% (38/40)
 100.00% (4/4)
skim.rs
  82.50% (33/40)
  84.27% (359/426)
  79.19% (506/639)
  73.64% (81/110)
skim_item.rs
 100.00% (9/9)
 100.00% (27/27)
 100.00% (37/37)
- (0/0)
spinlock.rs
 100.00% (6/6)
 100.00% (28/28)
 100.00% (27/27)
 100.00% (2/2)
theme.rs
 100.00% (16/16)
  99.35% (304/306)
  99.55% (663/666)
  97.22% (35/36)
thread_pool.rs
 100.00% (18/18)
  97.98% (194/198)
  98.28% (285/290)
  88.46% (23/26)
tui/actions.rs
  75.00% (24/32)
  91.11% (82/90)
  88.65% (125/141)
 100.00% (2/2)
tui/app.rs
  97.96% (48/49)
  95.88% (1024/1068)
  93.23% (1583/1698)
  86.64% (201/232)
tui/backend.rs
  81.25% (26/32)
  74.02% (188/254)
  71.00% (284/400)
  56.82% (25/44)
tui/header.rs
 100.00% (11/11)
  98.46% (128/130)
  99.57% (232/233)
 100.00% (28/28)
tui/input.rs
  96.88% (31/32)
  96.58% (339/351)
  96.92% (630/650)
  81.25% (91/112)
tui/item_list.rs
  97.30% (36/37)
  95.34% (409/429)
  94.94% (638/672)
  88.10% (111/126)
tui/item_renderer.rs
 100.00% (29/29)
  98.30% (405/412)
  98.59% (767/778)
  89.68% (113/126)
tui/layout.rs
 100.00% (6/6)
  97.56% (120/123)
  96.77% (180/186)
  94.74% (36/38)
tui/mod.rs
  95.65% (22/23)
  93.94% (124/132)
  94.76% (199/210)
  75.00% (15/20)
tui/options.rs
 100.00% (2/2)
 100.00% (41/41)
 100.00% (45/45)
 100.00% (20/20)
tui/preview.rs
  92.50% (37/40)
  88.70% (471/531)
  90.26% (825/914)
  67.97% (87/128)
tui/statusline.rs
 100.00% (5/5)
 100.00% (33/33)
 100.00% (57/57)
 100.00% (4/4)
tui/util.rs
  78.26% (18/23)
  68.89% (186/270)
  65.19% (324/497)
  57.14% (48/84)
tui/widget.rs
 100.00% (2/2)
 100.00% (7/7)
 100.00% (10/10)
- (0/0)
util.rs
 100.00% (15/15)
  95.48% (169/177)
  96.75% (268/277)
  91.30% (42/46)
Totals
  94.93% (936/986)
  94.42% (10279/10887)
  93.55% (16968/18138)
  85.52% (2002/2341)
Generated by llvm-cov -- llvm version 23.1.1-rust-1.100.0-nightly
\ No newline at end of file