feat!: internally compute indexes at match time (removes get/set_index) (#1001)

* chore: remove skim::Item run_items wrapper

* fix: properly trigger re-render on custom previews

* feat: add AppendItems event

* feat!: internally compute indexes at match time (removes get/set_index)

* chore: generate completions & manpage

* chore: better benchmarks

---------

Co-authored-by: Skim bot <skim-bot@skim-rs.github.io>
This commit is contained in:
LoricAndre 2026-03-10 14:12:10 +01:00 committed by GitHub
parent 0ac1ac878d
commit ab514a54c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 512 additions and 403 deletions

View file

@ -1,206 +1,283 @@
use std::fs;
use criterion::{Criterion, criterion_group, criterion_main};
use skim::Typos;
use skim::helper::item::DefaultSkimItem;
use skim::prelude::*;
const CHUNK_SIZE: usize = 1024;
fn load_lines(file: &str) -> Vec<String> {
let data = fs::read_to_string(format!("benches/fixtures/{file}")).expect("{file} missing");
data.lines().map(|l| l.to_string()).collect()
}
fn prepare(file: &str, opt_builder: &mut SkimOptionsBuilder) -> (SkimOptions, SkimItemReceiver) {
let lines = load_lines(file);
let opts = opt_builder.build().unwrap();
let (tx, rx) = unbounded();
let mut chunk_size = 0;
let mut chunk = Vec::new();
for line in lines {
if chunk_size >= CHUNK_SIZE {
tx.send(chunk).unwrap();
chunk_size = 0;
chunk = Vec::new();
}
chunk.push(Arc::new(DefaultSkimItem::from(line)) as Arc<dyn SkimItem>);
}
tx.send(chunk).unwrap();
(opts, rx)
}
fn criterion_benchmark_10m(c: &mut Criterion) {
c.bench_function("filter_10M_default", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/10M.txt")
.filter("test")
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| prepare("10M.txt", SkimOptionsBuilder::default().filter("test")),
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
c.bench_function("filter_10M_regex", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/10M.txt")
.filter("test")
.regex(true)
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| prepare("10M.txt", SkimOptionsBuilder::default().filter("test").regex(true)),
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
c.bench_function("filter_10M_frizbee", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/10M.txt")
.filter("test")
.algorithm(FuzzyAlgorithm::Frizbee)
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| {
prepare(
"10M.txt",
SkimOptionsBuilder::default()
.filter("test")
.algorithm(FuzzyAlgorithm::Frizbee)
.typos(Typos::Disabled),
)
},
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
c.bench_function("filter_10M_frizbee_typos", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/10M.txt")
.filter("test")
.typos(Typos::Smart)
.algorithm(FuzzyAlgorithm::Frizbee)
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| {
prepare(
"10M.txt",
SkimOptionsBuilder::default()
.filter("test")
.algorithm(FuzzyAlgorithm::Frizbee)
.typos(Typos::Smart),
)
},
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
c.bench_function("filter_10M_clangd", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/10M.txt")
.filter("test")
.algorithm(FuzzyAlgorithm::Clangd)
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| {
prepare(
"10M.txt",
SkimOptionsBuilder::default()
.filter("test")
.algorithm(FuzzyAlgorithm::Clangd),
)
},
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
c.bench_function("filter_10M_fzy", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/10M.txt")
.filter("test")
.algorithm(FuzzyAlgorithm::Fzy)
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| {
prepare(
"10M.txt",
SkimOptionsBuilder::default()
.filter("test")
.algorithm(FuzzyAlgorithm::Fzy)
.typos(Typos::Disabled),
)
},
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
c.bench_function("filter_10M_fzy_typos", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/10M.txt")
.filter("test")
.typos(Typos::Smart)
.algorithm(FuzzyAlgorithm::Fzy)
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| {
prepare(
"10M.txt",
SkimOptionsBuilder::default()
.filter("test")
.algorithm(FuzzyAlgorithm::Fzy)
.typos(Typos::Smart),
)
},
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
c.bench_function("filter_10M_arinae", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/10M.txt")
.filter("test")
.algorithm(FuzzyAlgorithm::Arinae)
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| {
prepare(
"10M.txt",
SkimOptionsBuilder::default()
.filter("test")
.algorithm(FuzzyAlgorithm::Arinae)
.typos(Typos::Disabled),
)
},
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
c.bench_function("filter_10M_arinae_typos", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/10M.txt")
.filter("test")
.typos(Typos::Smart)
.algorithm(FuzzyAlgorithm::Arinae)
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| {
prepare(
"10M.txt",
SkimOptionsBuilder::default()
.filter("test")
.algorithm(FuzzyAlgorithm::Arinae)
.typos(Typos::Smart),
)
},
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
}
fn criterion_benchmark_1m(c: &mut Criterion) {
c.bench_function("filter_1M_default", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/1M.txt")
.filter("test")
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| prepare("1M.txt", SkimOptionsBuilder::default().filter("test")),
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
c.bench_function("filter_1M_regex", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/1M.txt")
.filter("test")
.regex(true)
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| prepare("1M.txt", SkimOptionsBuilder::default().filter("test").regex(true)),
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
c.bench_function("filter_1M_frizbee", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/1M.txt")
.filter("test")
.algorithm(FuzzyAlgorithm::Frizbee)
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| {
prepare(
"1M.txt",
SkimOptionsBuilder::default()
.filter("test")
.algorithm(FuzzyAlgorithm::Frizbee)
.typos(Typos::Disabled),
)
},
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
c.bench_function("filter_1M_frizbee_typos", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/1M.txt")
.filter("test")
.typos(Typos::Smart)
.algorithm(FuzzyAlgorithm::Frizbee)
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| {
prepare(
"1M.txt",
SkimOptionsBuilder::default()
.filter("test")
.algorithm(FuzzyAlgorithm::Frizbee)
.typos(Typos::Smart),
)
},
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
c.bench_function("filter_1M_clangd", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/1M.txt")
.filter("test")
.algorithm(FuzzyAlgorithm::Clangd)
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| {
prepare(
"1M.txt",
SkimOptionsBuilder::default()
.filter("test")
.algorithm(FuzzyAlgorithm::Clangd),
)
},
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
c.bench_function("filter_1M_fzy", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/1M.txt")
.filter("test")
.algorithm(FuzzyAlgorithm::Fzy)
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| {
prepare(
"1M.txt",
SkimOptionsBuilder::default()
.filter("test")
.algorithm(FuzzyAlgorithm::Fzy)
.typos(Typos::Disabled),
)
},
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
c.bench_function("filter_1M_fzy_typos", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/1M.txt")
.filter("test")
.typos(Typos::Smart)
.algorithm(FuzzyAlgorithm::Fzy)
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| {
prepare(
"1M.txt",
SkimOptionsBuilder::default()
.filter("test")
.algorithm(FuzzyAlgorithm::Fzy)
.typos(Typos::Smart),
)
},
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
c.bench_function("filter_1M_arinae", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/1M.txt")
.filter("test")
.algorithm(FuzzyAlgorithm::Arinae)
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| {
prepare(
"1M.txt",
SkimOptionsBuilder::default()
.filter("test")
.algorithm(FuzzyAlgorithm::Arinae)
.typos(Typos::Disabled),
)
},
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
c.bench_function("filter_1M_arinae_typos", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/1M.txt")
.filter("test")
.typos(Typos::Smart)
.algorithm(FuzzyAlgorithm::Arinae)
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| {
prepare(
"1M.txt",
SkimOptionsBuilder::default()
.filter("test")
.algorithm(FuzzyAlgorithm::Arinae)
.typos(Typos::Smart),
)
},
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
c.bench_function("filter_1M_andor", |b| {
b.iter(|| {
let opts = SkimOptionsBuilder::default()
.cmd("cat benches/fixtures/1M.txt")
.filter("boot foo | mnt foo")
.build()?;
Skim::run_with(opts, None)
});
b.iter_batched(
|| prepare("1M.txt", SkimOptionsBuilder::default().filter("boot foo | mnt foo")),
|(opts, rx)| Skim::run_with(opts, Some(rx)),
criterion::BatchSize::SmallInput,
);
});
}

View file

@ -12,7 +12,7 @@ use skim::fuzzy_matcher::frizbee::FrizbeeMatcher;
use skim::prelude::SkimMatcherV2;
fn load_lines() -> Vec<String> {
let data = fs::read_to_string("benches/fixtures/1M.txt").expect("1M.txt missing");
let data = fs::read_to_string("benches/fixtures/100K.txt").expect("100K.txt missing");
data.lines().map(|l| l.to_string()).collect()
}

View file

@ -5,7 +5,7 @@ fn main() -> color_eyre::Result<()> {
let res = Skim::run_items(opts, ["hello", "world"])?;
for item in res.selected_items {
println!("Selected {} (id {})", item.output(), item.get_index());
println!("Selected {} (id {})", item.output(), item.rank.index);
}
Ok(())

View file

@ -7,12 +7,13 @@ use std::io::Cursor;
/// This example demonstrates how to bind custom action callbacks to keyboard shortcuts.
///
/// It shows how to:
/// 1. Create custom action callbacks
/// 1. Create custom action callbacks (both sync and async)
/// 2. Bind them to specific key combinations
/// 3. Use them interactively in skim
fn main() {
// Create a custom callback that adds a prefix to the query
let add_prefix_callback = ActionCallback::new(|app: &mut skim::tui::App| {
// Create a synchronous callback that adds a prefix to the query.
// Use `new_sync` for plain closures that do not need to await anything.
let add_prefix_callback = ActionCallback::new_sync(|app: &mut skim::tui::App| {
// Get current query and add prefix
let current_query = app.input.value.clone();
@ -32,14 +33,17 @@ fn main() {
Ok(events)
});
// Create a callback that selects all and exits
// Create an async callback that selects all and exits.
// Use `new` for async closures or blocks that may await futures.
let select_all_callback = ActionCallback::new(|app: &mut skim::tui::App| {
let count = app.item_pool.len();
Ok(vec![
Event::Action(Action::SelectAll),
Event::Action(Action::Accept(Some(format!("Selected {count} items")))),
])
async move {
// Async work could go here (e.g. HTTP requests, file I/O, …).
Ok(vec![
Event::Action(Action::SelectAll),
Event::Action(Action::Accept(Some(format!("Selected {count} items")))),
])
}
});
// Build basic options

View file

@ -23,10 +23,12 @@ fn main() {
let options = SkimOptionsBuilder::default()
.height("50%")
.multi(true)
.preview(String::new()) // preview should be specified to enable preview window
.preview("") // preview should be specified to enable preview window
.build()
.unwrap();
env_logger::init();
let (tx_item, rx_item): (SkimItemSender, SkimItemReceiver) = unbounded();
let _ = tx_item.send(vec![
Arc::new(MyItem {

View file

@ -7,7 +7,6 @@ use skim::prelude::*;
#[derive(Debug, Clone)]
struct Item {
text: String,
index: usize,
}
impl SkimItem for Item {
@ -18,14 +17,6 @@ impl SkimItem for Item {
fn preview(&self, _context: PreviewContext) -> ItemPreview {
ItemPreview::Text(self.text.to_owned())
}
fn get_index(&self) -> usize {
self.index
}
fn set_index(&mut self, index: usize) {
self.index = index
}
}
pub fn main() {
@ -39,18 +30,9 @@ pub fn main() {
let (tx, rx): (SkimItemSender, SkimItemReceiver) = unbounded();
tx.send(vec![
Arc::new(Item {
text: "a".into(),
index: 0,
}) as Arc<dyn SkimItem>,
Arc::new(Item {
text: "b".into(),
index: 1,
}) as Arc<dyn SkimItem>,
Arc::new(Item {
text: "c".into(),
index: 2,
}) as Arc<dyn SkimItem>,
Arc::new(Item { text: "a".into() }) as Arc<dyn SkimItem>,
Arc::new(Item { text: "b".into() }) as Arc<dyn SkimItem>,
Arc::new(Item { text: "c".into() }) as Arc<dyn SkimItem>,
])
.unwrap();
@ -60,7 +42,7 @@ pub fn main() {
.map(|out| out.selected_items)
.unwrap_or_default()
.iter()
.map(|selected_item| (**selected_item).as_any().downcast_ref::<Item>().unwrap().to_owned())
.map(|selected_item| selected_item.downcast_item::<Item>().unwrap().to_owned())
.collect::<Vec<Item>>();
for item in selected_items {

View file

@ -258,7 +258,7 @@ _sk() {
return 0
;;
--flags)
COMPREPLY=($(compgen -W "no-preview-pty show-score" -- "${cur}"))
COMPREPLY=($(compgen -W "no-preview-pty show-score show-index" -- "${cur}"))
return 0
;;
--hscroll-off)

View file

@ -88,7 +88,8 @@ complete -c sk -l tmux -d 'Run in a tmux popup' -r
complete -c sk -l log-level -d 'Set the log level' -r
complete -c sk -l log-file -d 'Pipe log output to a file' -r
complete -c sk -l flags -d 'Feature flags' -r -f -a "no-preview-pty\t'Disable preview PTY on linux'
show-score\t'Display the item\'s match score before its value in the item list (for matcher debugging)'"
show-score\t'Display the item\'s match score before its value in the item list (for matcher debugging)'
show-index\t'Display the item\'s index before its value in the item list'"
complete -c sk -l hscroll-off -r
complete -c sk -l jump-labels -r
complete -c sk -l tail -r

View file

@ -33,7 +33,7 @@ module completions {
}
def "nu-complete sk flags" [] {
[ "no-preview-pty" "show-score" ]
[ "no-preview-pty" "show-score" "show-index" ]
}
# Fuzzy Finder in rust!

View file

@ -89,7 +89,8 @@ zsh\:"Zsh"))' \
'--log-level=[Set the log level]:LOG_LEVEL:_default' \
'--log-file=[Pipe log output to a file]:LOG_FILE:_default' \
'*--flags=[Feature flags]:FLAGS:((no-preview-pty\:"Disable preview PTY on linux"
show-score\:"Display the item'\''s match score before its value in the item list (for matcher debugging)"))' \
show-score\:"Display the item'\''s match score before its value in the item list (for matcher debugging)"
show-index\:"Display the item'\''s index before its value in the item list"))' \
'--hscroll-off=[]:HSCROLL_OFF:_default' \
'--jump-labels=[]:JUMP_LABELS:_default' \
'--tail=[]:TAIL:_default' \

View file

@ -182,7 +182,7 @@ fn sk_main(mut opts: SkimOptions) -> Result<i32> {
output_format,
&bin_options.delimiter,
&bin_options.replstr,
result.selected_items.iter().map(|x| x.item.clone()),
result.selected_items.iter(),
result.current,
&result.query,
&result.cmd,

View file

@ -31,7 +31,7 @@ impl MatchEngine for MatchAllEngine {
fn match_item(&self, item: &dyn SkimItem) -> Option<MatchResult> {
let item_text = item.text();
Some(MatchResult {
rank: self.rank_builder.build_rank(0, 0, 0, &item_text, item.get_index()),
rank: self.rank_builder.build_rank(0, 0, 0, &item_text),
matched_range: MatchRange::ByteRange(0, 0),
})
}

View file

@ -101,9 +101,7 @@ impl MatchEngine for ExactEngine {
let (begin, end) = matched_result?;
let score = (end - begin) as i32;
Some(MatchResult {
rank: self
.rank_builder
.build_rank(score, begin, end, &item_text, item.get_index()),
rank: self.rank_builder.build_rank(score, begin, end, &item_text),
matched_range: MatchRange::ByteRange(begin, end),
})
}

View file

@ -215,9 +215,7 @@ impl MatchEngine for FuzzyEngine {
let (score, begin, end) = best?;
Some(MatchResult {
rank: self
.rank_builder
.build_rank(score as i32, begin, end, &item_text, item.get_index()),
rank: self.rank_builder.build_rank(score as i32, begin, end, &item_text),
matched_range: MatchRange::ByteRange(begin, end),
})
} else {
@ -254,9 +252,7 @@ impl MatchEngine for FuzzyEngine {
let matched_range = MatchRange::Chars(matched_indices);
Some(MatchResult {
rank: self
.rank_builder
.build_rank(score as i32, begin, end, &item_text, item.get_index()),
rank: self.rank_builder.build_rank(score as i32, begin, end, &item_text),
matched_range,
})
}

View file

@ -70,9 +70,7 @@ impl MatchEngine for RegexEngine {
let score = (end - begin) as i32;
Some(MatchResult {
rank: self
.rank_builder
.build_rank(score, begin, end, &item_text, item.get_index()),
rank: self.rank_builder.build_rank(score, begin, end, &item_text),
matched_range: MatchRange::ByteRange(begin, end),
})
}

View file

@ -45,7 +45,7 @@ pub(super) const TYPO_BAND_SLACK: usize = 4;
/// (standard bonus). Entries that are `0` are not considered separators.
pub(super) const SEPARATOR_TABLE: [Score; 128] = {
let mut t = [0 as Score; 128];
t[b' ' as usize] = 12; // space
t[b' ' as usize] = 16; // space
t[b'-' as usize] = 10; // hyphen / kebab-case
t[b'.' as usize] = 12; // dot (file extensions, domain names)
t[b'/' as usize] = 16; // forward slash (path separator — higher bonus)

View file

@ -24,9 +24,6 @@ pub struct DefaultSkimItem {
/// The text that will be shown on screen.
text: Box<str>,
/// The index, for use in matching
index: usize,
/// Metadata containing miscellaneous fields when special options are used
metadata: Option<Box<DefaultSkimItemMetadata>>,
}
@ -60,7 +57,6 @@ impl DefaultSkimItem {
trans_fields: &[FieldRange],
matching_fields: &[FieldRange],
delimiter: &Regex,
index: usize,
) -> Self {
let using_transform_fields = !trans_fields.is_empty();
let contains_ansi = Self::contains_ansi_escape(orig_text);
@ -169,7 +165,6 @@ impl DefaultSkimItem {
DefaultSkimItem {
text: temp_text,
index,
metadata,
}
}
@ -230,6 +225,15 @@ impl DefaultSkimItem {
}
}
impl From<String> for DefaultSkimItem {
fn from(value: String) -> Self {
Self {
text: Box::from(value),
metadata: None,
}
}
}
impl SkimItem for DefaultSkimItem {
#[inline]
fn text(&self) -> Cow<'_, str> {
@ -433,14 +437,6 @@ impl SkimItem for DefaultSkimItem {
context.to_line(Cow::Borrowed(&self.text))
}
}
fn get_index(&self) -> usize {
self.index
}
fn set_index(&mut self, index: usize) {
self.index = index;
}
}
/// Strip ANSI escape sequences from a string
@ -651,7 +647,6 @@ mod test {
&[],
&[],
&delimiter,
0,
);
// text() should return stripped text for matching
@ -693,7 +688,6 @@ mod test {
&[],
&[],
&delimiter,
0,
);
// text() should return "😀text"
@ -727,7 +721,6 @@ mod test {
&[],
&[],
&delimiter,
0,
);
assert_eq!(
item_ansi.text(),
@ -742,7 +735,6 @@ mod test {
&[],
&[],
&delimiter,
0,
);
assert_eq!(
item_no_ansi.text(),
@ -766,7 +758,6 @@ mod test {
&[],
&[],
&delimiter,
0,
);
// Create display context with yellow background highlight for character 0 (the 'g')
@ -805,7 +796,6 @@ mod test {
&[],
&[],
&delimiter,
0,
);
// Create display context with yellow background highlight for characters 1-3 ('re')
@ -846,7 +836,6 @@ mod test {
&[],
&[],
&delimiter,
0,
);
// Create display context with yellow background highlight for bytes 1-3 ('re' in stripped text)
@ -886,7 +875,6 @@ mod test {
&[],
&[], // no matching fields restriction
&delimiter,
0,
);
// text() should return stripped text "green_text"
@ -918,7 +906,6 @@ mod test {
&[], // no transform fields
&[FieldRange::Single(2)], // match field 2
&delimiter,
0,
);
// text() should return text with null bytes stripped for display

View file

@ -203,7 +203,6 @@ impl SkimItemReader {
matching_fields: Vec<FieldRange>,
) {
let mut buffer = Vec::with_capacity(option.buf_size);
let mut line_idx = 0;
let mut items_to_send = Vec::with_capacity(ITEMS_BUFFER_SIZE);
let mut last_send_time = Instant::now();
let send_timeout = Duration::from_millis(SEND_TIMEOUT_MS);
@ -227,7 +226,7 @@ impl SkimItemReader {
continue;
};
trace!("got item {} with index {}", line, line_idx);
trace!("got item {}", line);
let raw_item = DefaultSkimItem::new(
line,
@ -235,11 +234,8 @@ impl SkimItemReader {
&transform_fields,
&matching_fields,
&option.delimiter,
line_idx,
);
items_to_send.push(Arc::new(raw_item) as Arc<dyn SkimItem>);
line_idx += 1;
}
Err(err) => {
trace!("Got {err:?} when reading, skipping");
@ -332,7 +328,6 @@ impl SkimItemReader {
&[],
&[],
&Regex::new(DELIMITER_STR).unwrap(),
0,
)) as Arc<dyn SkimItem>
})
.collect();

View file

@ -60,13 +60,14 @@ impl RankBuilder {
///
/// The values are stored as-is; the tiebreak ordering and sign-flipping are
/// applied lazily by [`Rank::sort_key`] at comparison time.
pub fn build_rank(&self, score: i32, begin: usize, end: usize, item_text: &str, index: usize) -> Rank {
/// The `index` will be overriden later
pub fn build_rank(&self, score: i32, begin: usize, end: usize, item_text: &str) -> Rank {
Rank {
score,
begin: begin as i32,
end: end as i32,
length: item_text.len() as i32,
index: index as i32,
index: Default::default(),
path_name_offset: Self::path_name_offset(item_text),
}
}
@ -131,7 +132,7 @@ impl std::fmt::Debug for MatchedItem {
impl Hash for MatchedItem {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
state.write_usize(self.get_index());
state.write_i32(self.rank.index);
self.text().hash(state);
}
}
@ -244,11 +245,18 @@ impl MatchedItem {
}
}
impl MatchedItem {
/// Downcast the MatchedItem to the corresponding SkimItem struct
pub fn downcast_item<T: SkimItem>(&self) -> Option<&T> {
(*self.item).as_any().downcast_ref::<T>()
}
}
use std::cmp::Ordering as CmpOrd;
impl PartialEq for MatchedItem {
fn eq(&self, other: &Self) -> bool {
self.text().eq(&other.text()) && self.get_index().eq(&other.get_index())
self.text().eq(&other.text()) && self.rank.index.eq(&other.rank.index)
}
}

View file

@ -193,6 +193,7 @@ impl Matcher {
// if we took items inside the spawned closure, a subsequent restart_matcher()
// could call kill() + reset() before the old closure runs, causing the old
// closure to re-take items that should belong to the new matcher.
let start = item_pool.num_taken();
let items = item_pool.take();
let total = items.len();
trace!("matcher start, total: {}", total);
@ -212,9 +213,10 @@ impl Matcher {
let matched_items: Vec<MatchedItem> = items
.into_par_iter()
.with_min_len(CHUNK_SIZE)
.enumerate()
.fold(
|| (Vec::new(), 0usize, 0usize), // (local_matches, local_processed, local_matched)
|(mut local_matches, mut local_processed, mut local_matched), item| {
|(mut local_matches, mut local_processed, mut local_matched), (index, item)| {
// Check interrupt once at the start of each chunk boundary.
// The fold processes items sequentially within each rayon work unit,
// so checking every CHUNK_SIZE items amortizes the atomic load.
@ -226,9 +228,11 @@ impl Matcher {
if let Some(match_result) = matcher_engine.match_item(item.as_ref()) {
local_matched += 1;
let mut rank = match_result.rank;
rank.index = (index + start) as i32;
local_matches.push(MatchedItem {
item,
rank: match_result.rank,
rank,
rank_builder: rank_builder.clone(),
matched_range: Some(match_result.matched_range),
});

View file

@ -1275,6 +1275,8 @@ pub enum FeatureFlag {
NoPreviewPty,
/// Display the item's match score before its value in the item list (for matcher debugging)
ShowScore,
/// Display the item's index before its value in the item list
ShowIndex,
}
#[allow(unused_macros)]

View file

@ -1,7 +1,5 @@
use crate::SkimItem;
use crate::item::MatchedItem;
use crate::tui::Event;
use std::sync::Arc;
/// Output from running skim, containing the final selection and state
#[derive(Debug)]
@ -25,10 +23,10 @@ pub struct SkimOutput {
pub cmd: String,
/// The selected items.
pub selected_items: Vec<Arc<MatchedItem>>,
pub selected_items: Vec<MatchedItem>,
/// The current item
pub current: Option<Arc<dyn SkimItem>>,
pub current: Option<MatchedItem>,
/// The header
pub header: String,

View file

@ -13,7 +13,6 @@ pub use crate::helper::selector::DefaultSkimSelector;
pub use crate::options::{SkimOptions, SkimOptionsBuilder};
pub use crate::output::SkimOutput;
pub use crate::reader::CommandCollector;
pub use crate::skim_item::Item;
pub use crate::tui::{Event, PreviewCallback, event::Action};
pub use crate::*;
pub use kanal::{Receiver, Sender, bounded, unbounded};

View file

@ -95,13 +95,11 @@ impl Skim {
const BATCH_SIZE: usize = 1024;
let (tx, rx) = crate::prelude::unbounded();
let mut batch: Vec<Arc<dyn SkimItem>> = Vec::with_capacity(BATCH_SIZE);
for (idx, raw_item) in items.into_iter().enumerate() {
for item in items {
if batch.len() == 1024 {
tx.send(batch)?;
batch = Vec::with_capacity(BATCH_SIZE);
}
let mut item = crate::prelude::Item::from(raw_item);
item.set_index(idx);
batch.push(Arc::new(item) as Arc<dyn SkimItem>);
}
tx.send(batch)?;

View file

@ -75,17 +75,6 @@ pub trait SkimItem: AsAny + Send + Sync + 'static {
fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> {
None
}
/// Get index, for matching purposes
///
/// Implemented as no-op for retro-compatibility purposes
fn get_index(&self) -> usize {
0
}
/// Set index, for matching purposes
///
/// Implemented as no-op for retro-compatibility purposes
fn set_index(&mut self, _index: usize) {}
}
//------------------------------------------------------------------------------
@ -97,47 +86,6 @@ impl<T: AsRef<str> + Send + Sync + 'static> SkimItem for T {
}
}
/// A basic SkimItem implementation for basic types
pub struct Item<T: SkimItem> {
inner: T,
index: usize,
}
impl<T: SkimItem> SkimItem for Item<T> {
fn text(&self) -> Cow<'_, str> {
self.inner.text()
}
fn display<'a>(&'a self, context: DisplayContext) -> Line<'a> {
self.inner.display(context)
}
fn preview(&self, context: PreviewContext) -> ItemPreview {
self.inner.preview(context)
}
fn output(&self) -> Cow<'_, str> {
self.inner.output()
}
fn get_matching_ranges(&self) -> Option<&[(usize, usize)]> {
self.inner.get_matching_ranges()
}
fn get_index(&self) -> usize {
self.index
}
fn set_index(&mut self, index: usize) {
self.index = index;
}
}
impl<T: SkimItem> From<T> for Item<T> {
fn from(value: T) -> Self {
Self { inner: value, index: 0 }
}
}
impl Display for dyn SkimItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.text())
@ -145,10 +93,6 @@ impl Display for dyn SkimItem {
}
impl Debug for dyn SkimItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_fmt(format_args!(
"SkimItem {{ text: {}, index: {} }}",
self.text(),
self.get_index()
))
f.write_fmt(format_args!("SkimItem {{ text: {} }}", self.text(),))
}
}

View file

@ -284,18 +284,23 @@ pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
}
.to_string();
let current: Option<Arc<dyn SkimItem>> = if status.success() {
let current: Option<MatchedItem> = if status.success() {
let line = stdout.next().unwrap_or_default();
if line.is_empty() {
None
} else {
Some(Arc::new(SkimTmuxOutput { line: line.to_string() }))
Some(MatchedItem {
item: Arc::new(SkimTmuxOutput { line: line.to_string() }),
rank: Rank::default(),
rank_builder: Arc::new(RankBuilder::default()),
matched_range: None,
})
}
} else {
None
};
let mut output_lines: Vec<Arc<MatchedItem>> = vec![];
let mut output_lines: Vec<MatchedItem> = vec![];
while let Some(line) = stdout.next() {
debug!("Adding output line: {line}");
// --print-score is always enabled in the child, so every item is followed by its score.
@ -309,7 +314,7 @@ pub fn run_with(opts: &SkimOptions) -> Option<SkimOutput> {
rank_builder: Arc::new(RankBuilder::default()),
matched_range: None,
};
output_lines.push(Arc::new(item));
output_lines.push(item);
}
let is_abort = !status.success();

View file

@ -12,8 +12,8 @@ use crate::tui::layout::{AppLayout, LayoutTemplate};
use crate::tui::options::TuiLayout;
use crate::tui::statusline::InfoDisplay;
use crate::tui::widget::SkimWidget;
use crate::util;
use crate::{ItemPreview, PreviewContext, SkimItem, SkimOptions};
use crate::{Rank, util};
use super::Event;
use super::Tui;
@ -404,6 +404,7 @@ impl App {
{
let selection: Vec<_> = self.item_list.selection.iter().map(|i| i.text().into_owned()).collect();
let selection_str: Vec<_> = selection.iter().map(|s| s.as_str()).collect();
let selected = self.item_list.selected();
let ctx = PreviewContext {
query: &self.input.value,
cmd_query: if self.options.interactive {
@ -413,21 +414,21 @@ impl App {
},
width: self.preview.cols as usize,
height: self.preview.rows as usize,
current_index: self.item_list.selected().map(|i| i.get_index()).unwrap_or_default(),
current_selection: &self
.item_list
.selected()
.map(|i| i.text().into_owned())
.unwrap_or_default(),
current_index: selected.as_ref().map(|i| i.rank.index as usize).unwrap_or_default(),
current_selection: &selected.map(|i| i.text().into_owned()).unwrap_or_default(),
selected_indices: &self
.item_list
.selection
.iter()
.map(|v| v.get_index())
.map(|v| v.rank.index as usize)
.collect::<Vec<_>>(),
selections: &selection_str,
};
let preview = item.preview(ctx);
let preview_ready = !matches!(
preview,
ItemPreview::Global | ItemPreview::Command(_) | ItemPreview::CommandWithPos(_, _)
);
match preview {
ItemPreview::Command(cmd) => self.preview.spawn(tui, &self.expand_cmd(&cmd, true))?,
ItemPreview::Text(t) | ItemPreview::AnsiText(t) => self.preview.content(t.bytes().collect())?,
@ -464,12 +465,15 @@ impl App {
.content_with_position(t.bytes().collect(), preview_position)?,
ItemPreview::Global => self.preview.spawn(tui, &self.expand_cmd(preview_opt, true))?,
}
if preview_ready {
let _ = tui.event_tx.try_send(Event::PreviewReady);
}
} else if let Some(cb) = &self.options.preview_fn {
let selection: Vec<Arc<dyn SkimItem>>;
if self.options.multi {
selection = self.item_list.selection.iter().map(|i| i.item.clone()).collect();
} else if let Some(sel) = self.item_list.selected() {
selection = vec![sel];
selection = vec![sel.item];
} else {
selection = Vec::new();
}
@ -537,7 +541,7 @@ impl App {
let offset = self.calculate_preview_offset(offset_expr);
self.preview.set_offset(offset);
}
tui.event_tx.try_send(Event::Render)?;
self.needs_render();
}
Event::Error(msg) => {
tui.exit()?;
@ -587,6 +591,10 @@ impl App {
self.item_pool.clear();
self.restart_matcher(true);
}
Event::AppendItems(items) => {
self.item_pool.append(items.to_owned());
self.restart_matcher(false);
}
Event::Reload(_) => {
unreachable!("Reload is handled by the TUI event loop in lib.rs")
}
@ -644,10 +652,14 @@ impl App {
AppendAndSelect => {
let value = self.input.value.clone();
let item: Arc<dyn SkimItem> = Arc::new(value);
let rank = Rank {
index: self.item_pool.len() as i32,
..Default::default()
};
self.item_pool.append(vec![item.clone()]);
self.item_list.append(&mut vec![MatchedItem {
item,
rank: Default::default(),
rank,
rank_builder: self.matcher.rank_builder.clone(),
matched_range: None,
}]);
@ -1101,18 +1113,14 @@ impl App {
}
/// Returns the selected items as results
pub fn results(&mut self) -> Vec<Arc<MatchedItem>> {
pub fn results(&mut self) -> Vec<MatchedItem> {
if self.options.filter.is_some() {
// In filter mode, drain items to avoid cloning
self.item_list.items.drain(..).map(Arc::new).collect()
self.item_list.items.drain(..).collect()
} else if self.options.multi && !self.item_list.selection.is_empty() {
self.item_list
.selection
.iter()
.map(|item| Arc::new(item.clone()))
.collect()
} else if let Some(sel) = self.item_list.items.get(self.item_list.current) {
vec![Arc::new(sel.clone())]
self.item_list.selection.clone().into_iter().collect()
} else if let Some(sel) = self.item_list.selected() {
vec![sel]
} else {
vec![]
}
@ -1224,7 +1232,7 @@ impl App {
cmd,
&self.options.delimiter,
&self.options.replstr,
self.item_list.selection.iter().map(|x| x.item.clone()),
self.item_list.selection.iter(),
self.item_list.selected(),
&self.input.value,
&self.input.value,

View file

@ -1,18 +1,57 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use crate::exhaustive_match;
use crossterm::event::{KeyEvent, MouseEvent};
use derive_more::{Debug, Eq, PartialEq};
type ActionCallbackFn =
dyn Fn(&mut crate::tui::App) -> Result<Vec<Event>, Box<dyn std::error::Error + Sync + Send>> + Send;
type BoxError = Box<dyn std::error::Error + Sync + Send>;
type BoxFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<Event>, BoxError>> + Send + 'a>>;
/// Trait object stored inside [`ActionCallback`].
///
/// Having an explicit trait (rather than a bare `dyn Fn` type alias) allows
/// Rust to correctly resolve the higher-ranked lifetime in the return type.
trait AsyncCallbackFn: Send {
fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a>;
}
/// Adapter that stores a concrete async closure and implements [`AsyncCallbackFn`].
struct AsyncFnWrapper<F>(F);
impl<F, Fut> AsyncCallbackFn for AsyncFnWrapper<F>
where
F: for<'a> Fn(&'a mut crate::tui::App) -> Fut + Send,
Fut: Future<Output = Result<Vec<Event>, BoxError>> + Send + 'static,
{
fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a> {
Box::pin((self.0)(app))
}
}
/// Adapter that stores a plain synchronous closure and implements [`AsyncCallbackFn`].
struct SyncFnWrapper<F>(F);
impl<F> AsyncCallbackFn for SyncFnWrapper<F>
where
F: Fn(&mut crate::tui::App) -> Result<Vec<Event>, BoxError> + Send,
{
fn call<'a>(&'a self, app: &'a mut crate::tui::App) -> BoxFuture<'a> {
Box::pin(std::future::ready((self.0)(app)))
}
}
/// A custom action callback that receives a mutable reference to the App.
///
/// The closure will be called with a mutable reference to App and should return
/// a vec of events that will be processed after the callback completes.
///
/// Both sync and async closures are supported:
/// - Use [`ActionCallback::new`] to wrap an **async** closure or block.
/// - Use [`ActionCallback::new_sync`] to wrap a plain synchronous closure.
#[derive(Clone)]
pub struct ActionCallback(Arc<Mutex<ActionCallbackFn>>);
pub struct ActionCallback(Arc<Mutex<dyn AsyncCallbackFn>>);
impl std::fmt::Debug for ActionCallback {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@ -21,24 +60,50 @@ impl std::fmt::Debug for ActionCallback {
}
impl ActionCallback {
/// Create a new action callback from a closure.
/// Create a new action callback from an **async** closure or block.
///
/// The closure will be called with a mutable reference to App and should return a vec of
/// events that will be run after the callback is done.
pub fn new<F>(f: F) -> Self
/// ```rust,ignore
/// ActionCallback::new(|app| async move {
/// // async work here …
/// Ok(vec![])
/// });
/// ```
pub fn new<F, Fut>(f: F) -> Self
where
F: Fn(&mut crate::tui::App) -> Result<Vec<Event>, Box<dyn std::error::Error + Sync + Send>> + Send + 'static,
F: for<'a> Fn(&'a mut crate::tui::App) -> Fut + Send + 'static,
Fut: Future<Output = Result<Vec<Event>, BoxError>> + Send + 'static,
{
Self(Arc::new(Mutex::new(f)))
Self(Arc::new(Mutex::new(AsyncFnWrapper(f))))
}
/// Call the callback with an App reference.
pub(crate) fn call(
&self,
app: &mut crate::tui::App,
) -> Result<Vec<Event>, Box<dyn std::error::Error + Sync + Send>> {
/// Create a new action callback from a plain **synchronous** closure.
///
/// This is a convenience wrapper; the closure is lifted into an immediately-
/// resolving future so it integrates with the same async call site.
///
/// ```rust,ignore
/// ActionCallback::new_sync(|app| {
/// Ok(vec![Event::Action(Action::SelectAll)])
/// });
/// ```
pub fn new_sync<F>(f: F) -> Self
where
F: Fn(&mut crate::tui::App) -> Result<Vec<Event>, BoxError> + Send + 'static,
{
Self(Arc::new(Mutex::new(SyncFnWrapper(f))))
}
/// Call the callback with an App reference, driving the returned future to completion.
///
/// Must be called from within a Tokio multi-thread runtime context.
pub(crate) fn call(&self, app: &mut crate::tui::App) -> Result<Vec<Event>, BoxError> {
let callback = self.0.lock().unwrap();
callback(app)
let fut = callback.call(app);
// We are inside a synchronous call stack that originates from an async
// tokio context. `block_in_place` moves the current thread out of the
// async worker pool temporarily so we can block on the future without
// starving the runtime.
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
}
}
@ -67,6 +132,8 @@ pub enum Event {
InvalidInput,
/// An action was triggered
Action(Action),
/// Append items to the pool
AppendItems(Vec<Arc<dyn crate::SkimItem>>),
/// Clear all items
ClearItems,
/// Clear the screen
@ -349,7 +416,7 @@ pub fn parse_action(raw_action: &str) -> Option<Action> {
"unreachable-if-non-matched" => Some(IfNonMatched(Default::default(), None)),
"unreachable-if-query-empty" => Some(IfQueryEmpty(Default::default(), None)),
"unreachable-if-query-not-empty" => Some(IfQueryNotEmpty(Default::default(), None)),
"custom-do-not-use-from-cli" => Some(Custom(ActionCallback::new(|_: &mut crate::tui::App| { Ok(Vec::new()) }))),
"custom-do-not-use-from-cli" => Some(Custom(ActionCallback::new_sync(|_: &mut crate::tui::App| { Ok(Vec::new()) }))),
}
default _ => None
}

View file

@ -6,9 +6,10 @@ use ratatui::widgets::{Block, Borders, Clear, List, ListDirection, ListItem, Lis
use regex::Regex;
use unicode_display_width::width as display_width;
use crate::options::feature_flag;
use crate::tui::util::char_display_width;
use crate::{
DisplayContext, MatchRange, Selector, SkimItem, SkimOptions,
DisplayContext, MatchRange, Selector, SkimOptions,
item::MatchedItem,
spinlock::SpinLock,
theme::ColorTheme,
@ -75,7 +76,8 @@ pub struct ItemList {
/// Border type, if borders are enabled
pub border: Option<BorderType>,
/// When true, prepend each item's match score to its display text
print_score: bool,
show_score: bool,
show_index: bool,
}
impl Default for ItemList {
@ -109,7 +111,8 @@ impl Default for ItemList {
cycle: false,
wrap: false,
border: None,
print_score: false,
show_score: false,
show_index: false,
}
}
}
@ -127,8 +130,8 @@ impl ItemList {
}
/// Returns the currently selected item, if any
pub fn selected(&self) -> Option<Arc<dyn SkimItem>> {
self.items.get(self.cursor()).map(|x| x.item.clone())
pub fn selected(&self) -> Option<MatchedItem> {
self.items.get(self.cursor()).cloned()
}
/// Appends new matched items to the list
@ -571,7 +574,8 @@ impl SkimWidget for ItemList {
cycle: options.cycle,
wrap: options.wrap_items,
border: options.border,
print_score: options.flags.contains(&crate::options::FeatureFlag::ShowScore),
show_score: feature_flag!(options, ShowScore),
show_index: feature_flag!(options, ShowIndex),
}
}
@ -741,14 +745,21 @@ impl SkimWidget for ItemList {
},
theme.selected,
));
// Optionally prepend the match score for debugging
if this.print_score {
// Optionally prepend debug fields
if this.show_score {
let score = item.rank.score;
spans.push(Span::styled(
format!("[{score}] "),
if is_current { theme.current } else { theme.normal },
));
}
if this.show_index {
let index = item.rank.index;
spans.push(Span::styled(
format!("[{index}] "),
if is_current { theme.current } else { theme.normal },
));
}
spans.extend(display_line.spans);
if *wrap {

View file

@ -1,12 +1,11 @@
use crate::SkimItem;
use crate::field::FieldRange;
use crate::field::get_string_by_field;
use crate::helper::item::strip_ansi;
use crate::item::MatchedItem;
use regex::Regex;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::prelude::v1::*;
use std::sync::Arc;
#[cfg(feature = "cli")]
/// Unescape a delimiter string to handle escape sequences like \x00, \t, \n, etc.
@ -90,12 +89,12 @@ pub fn read_file_lines(filename: &str) -> std::result::Result<Vec<String>, std::
/// - `{cq}` -> current command query
///
#[allow(clippy::too_many_arguments)]
pub fn printf(
pub fn printf<'a>(
pattern: &str,
delimiter: &Regex,
replstr: &str,
selected: impl Iterator<Item = Arc<dyn SkimItem>> + std::clone::Clone,
current: Option<Arc<dyn SkimItem>>,
selected: impl Iterator<Item = &'a MatchedItem> + std::clone::Clone,
current: Option<MatchedItem>,
query: &str,
command_query: &str,
quote_args: bool,
@ -133,14 +132,14 @@ pub fn printf(
"q" => replaced.push_str(&escaped_query),
"cq" => replaced.push_str(&escaped_cmd_query),
"n" if current.as_ref().is_some() => {
replaced.push_str(current.as_ref().unwrap().get_index().to_string().as_str());
replaced.push_str(current.as_ref().unwrap().rank.index.to_string().as_str());
}
s if s == "+n" || s.starts_with("+n:") || s == "+" || s.starts_with("+:") => {
let is_n = s.starts_with("+n");
let accessor = if is_n {
|i: &Arc<dyn SkimItem>| i.get_index().to_string()
|i: &MatchedItem| i.rank.index.to_string()
} else {
|i: &Arc<dyn SkimItem>| strip_ansi(&i.output()).0
|i: &MatchedItem| strip_ansi(&i.output()).0
};
let mut quote_individually = false;
@ -151,7 +150,7 @@ pub fn printf(
let mut expanded = selected
.clone()
.map(|i| escape_arg(&accessor(&i), quote_individually))
.map(|i| escape_arg(&accessor(i), quote_individually))
.reduce(|a: String, b| a.to_owned() + delim + b.as_str())
.unwrap_or_default();
if expanded.is_empty() {
@ -238,8 +237,19 @@ pub fn printf(
#[cfg(test)]
mod test {
use super::*;
use crate::SkimItem;
use crate::Rank;
use crate::item::{MatchedItem, RankBuilder};
use regex::Regex;
use std::sync::Arc;
fn make_item(s: &'static str) -> MatchedItem {
MatchedItem {
item: Arc::new(s),
rank: Rank::default(),
rank_builder: Arc::new(RankBuilder::default()),
matched_range: None,
}
}
#[test]
fn test_unescape_delimiter() {
@ -277,11 +287,11 @@ mod test {
#[test]
fn test_printf() {
let pattern = "[1] {} [2] {..2} [3] {2..} [4] {+} [5] {q} [6] {cq} [7] {+:, } [8] {+n:','}";
let items: Vec<Arc<dyn SkimItem>> = vec![
Arc::new("item 1"),
Arc::new("item 2"),
Arc::new("item 3"),
Arc::new("item 4"),
let items = [
make_item("item 1"),
make_item("item 2"),
make_item("item 3"),
make_item("item 4"),
];
let delimiter = Regex::new(" ").unwrap();
assert_eq!(
@ -289,8 +299,8 @@ mod test {
pattern,
&delimiter,
"{}",
items.iter().cloned(),
Some(Arc::new("item 2")),
items.iter(),
Some(make_item("item 2")),
"query",
"cmd query",
true
@ -305,10 +315,8 @@ mod test {
"{+}",
&Regex::new(" ").unwrap(),
"{}",
[Arc::new("1"), Arc::new("2")]
.iter()
.map(|x| x.clone() as Arc<dyn SkimItem>),
Some(Arc::new("1")),
[make_item("1"), make_item("2")].iter(),
Some(make_item("1")),
"q",
"cq",
true
@ -320,8 +328,8 @@ mod test {
"{+}",
&Regex::new(" ").unwrap(),
"{}",
vec![].into_iter(),
Some(Arc::new("1")),
[].iter(),
Some(make_item("1")),
"q",
"cq",
true
@ -336,8 +344,8 @@ mod test {
"{}",
&Regex::new(" ").unwrap(),
"{}",
vec![].into_iter(),
Some(Arc::new("{..2}")),
[].iter(),
Some(make_item("{..2}")),
"q",
"cq",
true
@ -352,10 +360,8 @@ mod test {
"{} ##",
&Regex::new(" ").unwrap(),
"##",
[Arc::new("1"), Arc::new("2")]
.iter()
.map(|x| x.clone() as Arc<dyn SkimItem>),
Some(Arc::new("1")),
[make_item("1"), make_item("2")].iter(),
Some(make_item("1")),
"q",
"cq",
true

View file

@ -176,6 +176,13 @@ impl TestHarness {
self.tick()?;
self.handle_remaining_events()?;
// Force a final render so that any state changes (e.g. PreviewReady) that
// were processed inside handle_remaining_events are reflected in the buffer
// before we take the snapshot. We bypass the frame-rate throttle by sending
// Render directly instead of relying on the Heartbeat path.
self.send(Event::Render)?;
self.tick()?;
Ok(())
}
@ -261,32 +268,43 @@ impl TestHarness {
// Process any queued events first (including RunPreview)
self.tick()?;
// Now check if there's a pending preview task
// If not, there's nothing to wait for
if let Some(ref handle) = self.skim.app().preview.thread_handle {
if handle.is_finished() {
return Ok(());
}
} else {
// If there's no preview task running, nothing to wait for
let has_pending = match self.skim.app().preview.thread_handle {
Some(ref handle) => !handle.is_finished(),
None => false,
};
if !has_pending {
// Thread is already done (or was never started). Drain any events it may
// have sent (e.g. PreviewReady) that arrived after our initial tick().
self.tick()?;
return Ok(());
}
// Wait for preview to execute
// With multi-threaded runtime, spawned tasks run on background threads
// Wait for the preview thread to finish, then drain its events.
let timeout = std::time::Duration::from_secs(2);
let start = std::time::Instant::now();
loop {
// Sleep to give background tasks time to execute
std::thread::sleep(std::time::Duration::from_millis(50));
// Sleep to give the background thread time to make progress
std::thread::sleep(std::time::Duration::from_millis(10));
// Drain events then process, so we can check for PreviewReady
let mut events = Vec::new();
while let Ok(event) = self.skim.tui_mut().event_rx.try_recv() {
events.push(event);
}
for event in events {
self.process_event(event)?;
// Drain and process any events (including PreviewReady)
self.tick()?;
// Exit as soon as the thread is done and we have processed its events
let finished = self
.skim
.app()
.preview
.thread_handle
.as_ref()
.map(|h| h.is_finished())
.unwrap_or(true);
if finished {
// One final drain to catch any events emitted right at thread exit
self.tick()?;
return Ok(());
}
if start.elapsed() > timeout {