Skip to content

Commit e779c16

Browse files
authored
Make file picker autocompletion case-insensitive (#464)
1 parent 0e60e50 commit e779c16

3 files changed

Lines changed: 85 additions & 52 deletions

File tree

src/bin/edit/draw_filepicker.rs

Lines changed: 68 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::cmp::Ordering;
55
use std::fs;
66
use std::path::{Path, PathBuf};
77

8+
use edit::arena::scratch_arena;
89
use edit::framebuffer::IndexedColor;
910
use edit::helpers::*;
1011
use edit::input::{kbmod, vk};
@@ -135,8 +136,6 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) {
135136
draw_dialog_saveas_refresh_files(state);
136137
}
137138

138-
let files = state.file_picker_entries.as_ref().unwrap();
139-
140139
ctx.scrollarea_begin(
141140
"directory",
142141
Size {
@@ -152,16 +151,20 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) {
152151
ctx.next_block_id_mixin(state.file_picker_pending_dir_revision);
153152
ctx.list_begin("files");
154153
ctx.inherit_focus();
155-
for entry in files {
156-
match ctx.list_item(false, entry.as_str()) {
157-
ListSelection::Unchanged => {}
158-
ListSelection::Selected => {
159-
state.file_picker_pending_name = entry.as_path().into()
154+
155+
for entries in state.file_picker_entries.as_ref().unwrap() {
156+
for entry in entries {
157+
match ctx.list_item(false, entry.as_str()) {
158+
ListSelection::Unchanged => {}
159+
ListSelection::Selected => {
160+
state.file_picker_pending_name = entry.as_path().into()
161+
}
162+
ListSelection::Activated => activated = true,
160163
}
161-
ListSelection::Activated => activated = true,
164+
ctx.attr_overflow(Overflow::TruncateMiddle);
162165
}
163-
ctx.attr_overflow(Overflow::TruncateMiddle);
164166
}
167+
165168
ctx.list_end();
166169
}
167170
ctx.scrollarea_end();
@@ -300,81 +303,102 @@ fn draw_file_picker_update_path(state: &mut State) -> Option<PathBuf> {
300303

301304
fn draw_dialog_saveas_refresh_files(state: &mut State) {
302305
let dir = state.file_picker_pending_dir.as_path();
303-
let mut files = Vec::new();
304-
let mut off = 0;
306+
// ["..", directories, files]
307+
let mut dirs_files = [Vec::new(), Vec::new(), Vec::new()];
305308

306309
#[cfg(windows)]
307310
if dir.as_os_str().is_empty() {
308311
// If the path is empty, we are at the drive picker.
309312
// Add all drives as entries.
310313
for drive in edit::sys::drives() {
311-
files.push(DisplayablePathBuf::from_string(format!("{drive}:\\")));
314+
dirs_files[1].push(DisplayablePathBuf::from_string(format!("{drive}:\\")));
312315
}
313316

314-
state.file_picker_entries = Some(files);
317+
state.file_picker_entries = Some(dirs_files);
315318
return;
316319
}
317320

318321
if cfg!(windows) || dir.parent().is_some() {
319-
files.push(DisplayablePathBuf::from(".."));
320-
off = 1;
322+
dirs_files[0].push(DisplayablePathBuf::from(".."));
321323
}
322324

323325
if let Ok(iter) = fs::read_dir(dir) {
324326
for entry in iter.flatten() {
325327
if let Ok(metadata) = entry.metadata() {
326328
let mut name = entry.file_name();
327-
if metadata.is_dir()
329+
let dir = metadata.is_dir()
328330
|| (metadata.is_symlink()
329-
&& fs::metadata(entry.path()).is_ok_and(|m| m.is_dir()))
330-
{
331+
&& fs::metadata(entry.path()).is_ok_and(|m| m.is_dir()));
332+
let idx = if dir { 1 } else { 2 };
333+
334+
if dir {
331335
name.push("/");
332336
}
333-
files.push(DisplayablePathBuf::from(name));
337+
338+
dirs_files[idx].push(DisplayablePathBuf::from(name));
334339
}
335340
}
336341
}
337342

338-
// Sort directories first, then by name, case-insensitive.
339-
files[off..].sort_by(|a, b| {
340-
let a = a.as_bytes();
341-
let b = b.as_bytes();
343+
for entries in &mut dirs_files[1..] {
344+
entries.sort_by(|a, b| {
345+
let a = a.as_bytes();
346+
let b = b.as_bytes();
342347

343-
let a_is_dir = a.last() == Some(&b'/');
344-
let b_is_dir = b.last() == Some(&b'/');
348+
let a_is_dir = a.last() == Some(&b'/');
349+
let b_is_dir = b.last() == Some(&b'/');
345350

346-
match b_is_dir.cmp(&a_is_dir) {
347-
Ordering::Equal => icu::compare_strings(a, b),
348-
other => other,
349-
}
350-
});
351+
match b_is_dir.cmp(&a_is_dir) {
352+
Ordering::Equal => icu::compare_strings(a, b),
353+
other => other,
354+
}
355+
});
356+
}
351357

352-
state.file_picker_entries = Some(files);
358+
state.file_picker_entries = Some(dirs_files);
353359
}
354360

361+
#[inline(never)]
355362
fn update_autocomplete_suggestions(state: &mut State) {
356363
state.file_picker_autocomplete.clear();
357364

358365
if state.file_picker_pending_name.as_os_str().is_empty() {
359366
return;
360367
}
361368

369+
let scratch = scratch_arena(None);
362370
let needle = state.file_picker_pending_name.as_os_str().as_encoded_bytes();
363371
let mut matches = Vec::new();
364372

365-
if let Some(entries) = &state.file_picker_entries {
366-
// Remove the first entry, which is always "..".
367-
for entry in &entries[1.min(entries.len())..] {
368-
let haystack = entry.as_bytes();
369-
// We only want items that are longer than the needle,
370-
// because we're looking for suggestions, not for matches.
371-
if haystack.len() > needle.len()
372-
&& let haystack = &haystack[..needle.len()]
373-
&& icu::compare_strings(haystack, needle) == Ordering::Equal
374-
{
375-
matches.push(entry.clone());
376-
if matches.len() >= 5 {
377-
break; // Limit to 5 suggestions
373+
// Using binary search below we'll quickly find the lower bound
374+
// of items that match the needle (= share a common prefix).
375+
//
376+
// The problem is finding the upper bound. Here I'm using a trick:
377+
// By appending U+10FFFF (the highest possible Unicode code point)
378+
// we create a needle that naturally yields an upper bound.
379+
let mut needle_upper_bound = Vec::with_capacity_in(needle.len() + 4, &*scratch);
380+
needle_upper_bound.extend_from_slice(needle);
381+
needle_upper_bound.extend_from_slice(b"\xf4\x8f\xbf\xbf");
382+
383+
if let Some(dirs_files) = &state.file_picker_entries {
384+
'outer: for entries in &dirs_files[1..] {
385+
let lower = entries
386+
.binary_search_by(|entry| icu::compare_strings(entry.as_bytes(), needle))
387+
.unwrap_or_else(|i| i);
388+
389+
for entry in &entries[lower..] {
390+
let haystack = entry.as_bytes();
391+
match icu::compare_strings(haystack, &needle_upper_bound) {
392+
Ordering::Less => {
393+
matches.push(entry.clone());
394+
if matches.len() >= 5 {
395+
break 'outer; // Limit to 5 suggestions
396+
}
397+
}
398+
// We're looking for suggestions, not for matches.
399+
Ordering::Equal => {}
400+
// No more matches possible.
401+
Ordering::Greater => break,
378402
}
379403
}
380404
}

src/bin/edit/state.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,8 @@ pub struct State {
135135
pub file_picker_pending_dir: DisplayablePathBuf,
136136
pub file_picker_pending_dir_revision: u64, // Bumped every time `file_picker_pending_dir` changes.
137137
pub file_picker_pending_name: PathBuf,
138-
pub file_picker_entries: Option<Vec<DisplayablePathBuf>>,
139-
pub file_picker_overwrite_warning: Option<PathBuf>, // The path the warning is about.
138+
pub file_picker_entries: Option<[Vec<DisplayablePathBuf>; 3]>, // ["..", directories, files]
139+
pub file_picker_overwrite_warning: Option<PathBuf>, // The path the warning is about.
140140
pub file_picker_autocomplete: Vec<DisplayablePathBuf>,
141141

142142
pub wants_search: StateSearch,

src/icu.rs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -707,16 +707,25 @@ static mut ROOT_COLLATOR: Option<*mut icu_ffi::UCollator> = None;
707707

708708
/// Compares two UTF-8 strings for sorting using ICU's collation algorithm.
709709
pub fn compare_strings(a: &[u8], b: &[u8]) -> Ordering {
710+
#[cold]
711+
fn init() {
712+
unsafe {
713+
let mut coll = null_mut();
714+
715+
if let Ok(f) = init_if_needed() {
716+
let mut status = icu_ffi::U_ZERO_ERROR;
717+
coll = (f.ucol_open)(c"".as_ptr(), &mut status);
718+
}
719+
720+
ROOT_COLLATOR = Some(coll);
721+
}
722+
}
723+
710724
// OnceCell for people that want to put it into a static.
711725
#[allow(static_mut_refs)]
712726
let coll = unsafe {
713727
if ROOT_COLLATOR.is_none() {
714-
ROOT_COLLATOR = Some(if let Ok(f) = init_if_needed() {
715-
let mut status = icu_ffi::U_ZERO_ERROR;
716-
(f.ucol_open)(c"".as_ptr(), &mut status)
717-
} else {
718-
null_mut()
719-
});
728+
init();
720729
}
721730
ROOT_COLLATOR.unwrap_unchecked()
722731
};

0 commit comments

Comments
 (0)