use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use hjkl_buffer::View;
use hjkl_engine::{Input, Key};
use hjkl_form::TextFieldEditor;
use crate::logic::{FilteredEntry, PickerAction, PickerEvent, PickerLogic, RequeryMode};
use hjkl_fuzzy::score;
const REQUERY_DEBOUNCE_MS: u64 = 150;
static PREVIEW_GENERATION: AtomicU64 = AtomicU64::new(0);
fn next_preview_generation() -> u64 {
PREVIEW_GENERATION.fetch_add(1, Ordering::Relaxed) + 1
}
fn lower_with_map(s: &str) -> (String, Vec<usize>) {
let mut lowered = String::new();
let mut map = Vec::new();
for (i, ch) in s.chars().enumerate() {
for lc in ch.to_lowercase() {
lowered.push(lc);
map.push(i);
}
}
(lowered, map)
}
fn flatten_preview(buf: &View) -> Vec<u8> {
let joined = buf.content_joined();
let mut bytes = Vec::with_capacity(joined.len() + 1);
bytes.extend_from_slice(joined.as_bytes());
if !bytes.is_empty() {
bytes.push(b'\n');
}
bytes
}
pub struct Picker {
pub query: TextFieldEditor,
source: Box<dyn PickerLogic>,
filtered: Vec<FilteredEntry>,
pub selected: usize,
last_query: String,
last_seen_count: usize,
cancel: Arc<AtomicBool>,
_scan: Option<JoinHandle<()>>,
requery_at: Option<Instant>,
preview_idx: Option<usize>,
preview_buffer: View,
preview_bytes: Vec<u8>,
preview_generation: u64,
preview_status: String,
preview_label: Option<String>,
preview_path: Option<std::path::PathBuf>,
preview_top_row: usize,
preview_match_row: Option<usize>,
preview_line_offset: usize,
}
impl Picker {
pub fn new(mut source: Box<dyn PickerLogic>) -> Self {
let cancel = Arc::new(AtomicBool::new(false));
let handle = source.enumerate(None, Arc::clone(&cancel));
let mut query = TextFieldEditor::new(true);
query.enter_insert_at_end();
let mut me = Self {
query,
source,
filtered: Vec::new(),
selected: 0,
last_query: String::new(),
last_seen_count: 0,
cancel,
_scan: handle,
requery_at: None,
preview_idx: None,
preview_buffer: View::new(),
preview_bytes: Vec::new(),
preview_generation: next_preview_generation(),
preview_status: String::new(),
preview_label: None,
preview_path: None,
preview_top_row: 0,
preview_match_row: None,
preview_line_offset: 0,
};
me.wait_for_items(Duration::from_millis(30));
me.refresh();
me.refresh_preview();
me
}
pub fn new_with_query(source: Box<dyn PickerLogic>, initial_query: &str) -> Self {
let mut me = Self::new(source);
me.query.set_text(initial_query);
me.refresh();
me.refresh_preview();
me
}
fn wait_for_items(&self, timeout: Duration) {
let deadline = Instant::now() + timeout;
loop {
if self.source.item_count() > 0 {
return;
}
if Instant::now() >= deadline {
return;
}
std::thread::sleep(Duration::from_millis(2));
}
}
pub fn title(&self) -> &str {
self.source.title()
}
pub fn has_preview(&self) -> bool {
self.source.has_preview()
}
pub fn scan_done(&self) -> bool {
self._scan.as_ref().is_none_or(|h| h.is_finished())
}
pub fn total(&self) -> usize {
self.source.item_count()
}
pub fn matched(&self) -> usize {
self.filtered.len()
}
pub fn tick(&mut self, now: Instant) {
if self.source.requery_mode() != RequeryMode::Spawn {
return;
}
let Some(at) = self.requery_at else { return };
if now < at {
return;
}
self.requery_at = None;
self.cancel.store(true, Ordering::Release);
let new_cancel = Arc::new(AtomicBool::new(false));
self.cancel = Arc::clone(&new_cancel);
let q = self.query.text();
let handle = self.source.enumerate(Some(&q), new_cancel);
self._scan = handle;
self.selected = 0;
self.preview_idx = None;
}
pub fn refresh(&mut self) -> bool {
let count = self.source.item_count();
let q = self.query.text();
let q_changed = q != self.last_query;
let count_changed = count != self.last_seen_count;
if !q_changed && !count_changed {
return false;
}
let spawn_mode = self.source.requery_mode() == RequeryMode::Spawn;
if spawn_mode && q_changed {
self.requery_at = Some(Instant::now() + Duration::from_millis(REQUERY_DEBOUNCE_MS));
}
self.last_query.clone_from(&q);
self.last_seen_count = count;
if spawn_mode {
self.filtered = (0..count)
.map(|idx| FilteredEntry {
idx,
matches: Vec::new(),
})
.collect();
if self.selected >= self.filtered.len() {
self.selected = self.filtered.len().saturating_sub(1);
}
return true;
}
if q.is_empty() && self.source.preserve_source_order() {
self.filtered = (0..count)
.map(|idx| FilteredEntry {
idx,
matches: Vec::new(),
})
.collect();
if self.selected >= self.filtered.len() {
self.selected = self.filtered.len().saturating_sub(1);
}
return true;
}
let q_lower = q.to_lowercase();
let mut scored: Vec<(i64, usize, String, Vec<usize>)> = Vec::new();
for i in 0..count {
let m = self.source.match_text(i);
let (m_lower, index_map) = lower_with_map(&m);
let (sc, positions) = if q.is_empty() {
(0i64, Vec::new())
} else {
match score(&m_lower, &q_lower) {
Some((sc, folded_pos)) => {
let mut orig: Vec<usize> = folded_pos
.into_iter()
.filter_map(|p| index_map.get(p).copied())
.collect();
orig.dedup();
(sc, orig)
}
None => continue,
}
};
scored.push((sc, i, m_lower, positions));
}
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.2.cmp(&b.2)));
scored.truncate(500);
self.filtered = scored
.into_iter()
.map(|(_, idx, _, matches)| FilteredEntry { idx, matches })
.collect();
if self.selected >= self.filtered.len() {
self.selected = self.filtered.len().saturating_sub(1);
}
true
}
pub fn refresh_preview(&mut self) {
if !self.source.has_preview() {
return;
}
let target_idx = self.filtered.get(self.selected).map(|e| e.idx);
if target_idx == self.preview_idx {
return;
}
self.preview_idx = target_idx;
self.preview_generation = next_preview_generation();
let Some(idx) = target_idx else {
self.preview_buffer = View::new();
self.preview_bytes.clear();
self.preview_status.clear();
self.preview_label = None;
self.preview_path = None;
self.preview_top_row = 0;
self.preview_match_row = None;
self.preview_line_offset = 0;
return;
};
let label = self.source.label(idx);
let (buf, status) = self.source.preview(idx);
self.preview_buffer = buf;
self.preview_bytes = flatten_preview(&self.preview_buffer);
self.preview_status = status;
self.preview_label = Some(label);
self.preview_path = self.source.preview_path(idx);
self.preview_top_row = self.source.preview_top_row(idx);
self.preview_match_row = self.source.preview_match_row(idx);
self.preview_line_offset = self.source.preview_line_offset(idx);
}
pub fn preview_top_row(&self) -> usize {
self.preview_top_row
}
pub fn preview_match_row(&self) -> Option<usize> {
self.preview_match_row
}
pub fn preview_line_offset(&self) -> usize {
self.preview_line_offset
}
pub fn preview_path(&self) -> Option<&std::path::Path> {
self.preview_path.as_deref()
}
pub fn preview_buffer(&self) -> &View {
&self.preview_buffer
}
pub fn preview_bytes(&self) -> &[u8] {
&self.preview_bytes
}
pub fn preview_generation(&self) -> u64 {
self.preview_generation
}
pub fn preview_status(&self) -> &str {
&self.preview_status
}
pub fn preview_label(&self) -> Option<&str> {
self.preview_label.as_deref()
}
pub fn visible_entries(&self) -> Vec<(String, Vec<usize>)> {
let query = &self.last_query;
self.filtered
.iter()
.map(|e| {
let label = self.source.label(e.idx);
let positions = self
.source
.label_match_positions(e.idx, query, &label)
.unwrap_or_else(|| e.matches.clone());
(label, positions)
})
.collect()
}
pub fn visible_entry_styles(
&self,
) -> Vec<Vec<(std::ops::Range<usize>, hjkl_engine::types::Style)>> {
self.filtered
.iter()
.map(|e| {
let label = self.source.label(e.idx);
self.source.label_styles(e.idx, &label).unwrap_or_default()
})
.collect()
}
fn selected_action(&self) -> Option<PickerAction> {
let idx = self.filtered.get(self.selected)?.idx;
Some(self.source.select(idx))
}
pub fn path_for_visible_row(&self, row_idx: usize) -> Option<std::path::PathBuf> {
let src_idx = self.filtered.get(row_idx)?.idx;
self.source.preview_path(src_idx)
}
pub fn cancel(&mut self) -> PickerEvent {
PickerEvent::Cancel
}
pub fn accept(&mut self) -> PickerEvent {
match self.selected_action() {
Some(a) => PickerEvent::Select(a),
None => PickerEvent::None,
}
}
pub fn select_next(&mut self) {
self.move_selection(1);
}
pub fn select_prev(&mut self) {
self.move_selection(-1);
}
pub fn handle_query_input(&mut self, input: Input) {
if input.key == Key::Enter || input.key == Key::Esc {
return;
}
self.query.handle_input(input);
}
pub fn handle_source_key(&mut self, input: Input) -> Option<PickerAction> {
let idx = self.filtered.get(self.selected).map(|e| e.idx)?;
self.source.handle_key(idx, input)
}
fn move_selection(&mut self, delta: i32) {
if self.filtered.is_empty() {
self.selected = 0;
return;
}
let len = self.filtered.len() as i32;
let next = self.selected as i32 + delta;
let wrapped = next.rem_euclid(len);
self.selected = wrapped as usize;
}
}
impl Drop for Picker {
fn drop(&mut self) {
self.cancel.store(true, Ordering::Release);
}
}
#[cfg(test)]
mod filter_tests {
use super::*;
use crate::logic::{PickerAction, PickerLogic};
struct OneItem(String);
impl PickerLogic for OneItem {
fn title(&self) -> &str {
"x"
}
fn item_count(&self) -> usize {
1
}
fn label(&self, _idx: usize) -> String {
self.0.clone()
}
fn match_text(&self, _idx: usize) -> String {
self.0.clone()
}
fn select(&self, _idx: usize) -> PickerAction {
PickerAction::None
}
fn enumerate(
&mut self,
_query: Option<&str>,
_cancel: Arc<AtomicBool>,
) -> Option<JoinHandle<()>> {
None
}
}
#[test]
fn lower_with_map_tracks_source_indices_across_expansion() {
let (lowered, map) = lower_with_map("İs");
assert_eq!(lowered.chars().count(), 3);
assert_eq!(map, vec![0, 0, 1]);
}
#[test]
fn highlight_positions_map_to_original_after_case_expansion() {
let mut p = Picker::new_with_query(Box::new(OneItem("İstanbul".into())), "stanbul");
p.refresh();
let entries = p.visible_entries();
assert_eq!(entries.len(), 1);
let (label, positions) = &entries[0];
assert_eq!(label, "İstanbul");
let nchars = label.chars().count();
assert!(
positions.iter().all(|&pos| pos < nchars),
"positions {positions:?} out of range for {nchars} chars"
);
assert!(
positions.contains(&1),
"positions {positions:?} miss index 1"
);
}
}
#[cfg(test)]
mod preview_generation_tests {
use super::*;
use crate::logic::{PickerAction, PickerLogic};
struct TwoFiles;
impl PickerLogic for TwoFiles {
fn title(&self) -> &str {
"two"
}
fn item_count(&self) -> usize {
2
}
fn label(&self, idx: usize) -> String {
format!("f{idx}.rs")
}
fn match_text(&self, idx: usize) -> String {
self.label(idx)
}
fn preview(&self, idx: usize) -> (View, String) {
(View::from_str(&format!("fn f{idx}() {{}}")), String::new())
}
fn preview_path(&self, idx: usize) -> Option<std::path::PathBuf> {
Some(std::path::PathBuf::from(self.label(idx)))
}
fn preserve_source_order(&self) -> bool {
true
}
fn select(&self, _idx: usize) -> PickerAction {
PickerAction::None
}
fn enumerate(
&mut self,
_query: Option<&str>,
_cancel: Arc<AtomicBool>,
) -> Option<JoinHandle<()>> {
None
}
}
#[test]
fn preview_bytes_are_flattened_once_and_newline_terminated() {
let p = Picker::new(Box::new(TwoFiles));
assert_eq!(p.preview_bytes(), b"fn f0() {}\n");
}
#[test]
fn generation_is_stable_while_the_preview_is_unchanged() {
let mut p = Picker::new(Box::new(TwoFiles));
let g = p.preview_generation();
p.refresh_preview();
p.refresh_preview();
assert_eq!(p.preview_generation(), g);
assert_eq!(p.preview_bytes(), b"fn f0() {}\n");
}
#[test]
fn generation_and_bytes_change_when_the_preview_is_replaced() {
let mut p = Picker::new(Box::new(TwoFiles));
let g = p.preview_generation();
p.selected = 1;
p.refresh_preview();
assert_ne!(p.preview_generation(), g, "token must move with content");
assert_eq!(p.preview_bytes(), b"fn f1() {}\n");
}
#[test]
fn generations_never_repeat_across_picker_sessions() {
let first = Picker::new(Box::new(TwoFiles)).preview_generation();
let second = Picker::new(Box::new(TwoFiles)).preview_generation();
assert_ne!(
first, second,
"a host caching on the token would serve the closed session's parse"
);
assert_ne!(first, 0, "0 stays reserved as a host sentinel");
}
}
#[cfg(test)]
mod drop_tests {
use super::*;
use crate::logic::{PickerAction, PickerLogic};
struct CancelProbe {
seen: Arc<std::sync::Mutex<Option<Arc<AtomicBool>>>>,
}
impl PickerLogic for CancelProbe {
fn title(&self) -> &str {
"probe"
}
fn item_count(&self) -> usize {
0
}
fn label(&self, _idx: usize) -> String {
String::new()
}
fn match_text(&self, _idx: usize) -> String {
String::new()
}
fn select(&self, _idx: usize) -> PickerAction {
PickerAction::None
}
fn enumerate(
&mut self,
_query: Option<&str>,
cancel: Arc<AtomicBool>,
) -> Option<JoinHandle<()>> {
*self.seen.lock().unwrap() = Some(cancel);
None
}
}
#[test]
fn drop_sets_cancel_flag_for_background_scan() {
let seen = Arc::new(std::sync::Mutex::new(None));
let picker = Picker::new(Box::new(CancelProbe {
seen: Arc::clone(&seen),
}));
let cancel = seen.lock().unwrap().clone().expect("enumerate called");
assert!(!cancel.load(Ordering::Acquire));
drop(picker);
assert!(
cancel.load(Ordering::Acquire),
"dropping the picker must signal the scan to stop"
);
}
}