From e0bf73a56acdec4da5a38a51aa553f519340c99b Mon Sep 17 00:00:00 2001 From: Leonard Hecker Date: Wed, 11 Jun 2025 18:08:11 +0200 Subject: [PATCH 1/2] Make file picker autocompletion case-insensitive --- src/bin/edit/draw_filepicker.rs | 114 ++++++++++++++++++++------------ src/bin/edit/state.rs | 4 +- src/icu.rs | 21 ++++-- 3 files changed, 87 insertions(+), 52 deletions(-) diff --git a/src/bin/edit/draw_filepicker.rs b/src/bin/edit/draw_filepicker.rs index 57d23d74f8f9..78c4c39d2530 100644 --- a/src/bin/edit/draw_filepicker.rs +++ b/src/bin/edit/draw_filepicker.rs @@ -5,6 +5,7 @@ use std::cmp::Ordering; use std::fs; use std::path::{Path, PathBuf}; +use edit::arena::scratch_arena; use edit::framebuffer::IndexedColor; use edit::helpers::*; use edit::input::{kbmod, vk}; @@ -135,8 +136,6 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { draw_dialog_saveas_refresh_files(state); } - let files = state.file_picker_entries.as_ref().unwrap(); - ctx.scrollarea_begin( "directory", Size { @@ -152,16 +151,20 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) { ctx.next_block_id_mixin(state.file_picker_pending_dir_revision); ctx.list_begin("files"); ctx.inherit_focus(); - for entry in files { - match ctx.list_item(false, entry.as_str()) { - ListSelection::Unchanged => {} - ListSelection::Selected => { - state.file_picker_pending_name = entry.as_path().into() + + for entries in state.file_picker_entries.as_ref().unwrap() { + for entry in entries { + match ctx.list_item(false, entry.as_str()) { + ListSelection::Unchanged => {} + ListSelection::Selected => { + state.file_picker_pending_name = entry.as_path().into() + } + ListSelection::Activated => activated = true, } - ListSelection::Activated => activated = true, + ctx.attr_overflow(Overflow::TruncateMiddle); } - ctx.attr_overflow(Overflow::TruncateMiddle); } + ctx.list_end(); } ctx.scrollarea_end(); @@ -300,58 +303,64 @@ fn draw_file_picker_update_path(state: &mut State) -> Option { fn draw_dialog_saveas_refresh_files(state: &mut State) { let dir = state.file_picker_pending_dir.as_path(); - let mut files = Vec::new(); - let mut off = 0; + let mut dirs_files = [Vec::new(), Vec::new()]; + + // This variable ensures that the first entry in dirs_files[0] (directories) + // ".." is always sorted to the top, no matter the other filenames. + let mut dirs_sort_off = 0; #[cfg(windows)] if dir.as_os_str().is_empty() { // If the path is empty, we are at the drive picker. // Add all drives as entries. for drive in edit::sys::drives() { - files.push(DisplayablePathBuf::from_string(format!("{drive}:\\"))); + dirs_files[0].push(DisplayablePathBuf::from_string(format!("{drive}:\\"))); } - state.file_picker_entries = Some(files); + state.file_picker_entries = Some(dirs_files); return; } if cfg!(windows) || dir.parent().is_some() { - files.push(DisplayablePathBuf::from("..")); - off = 1; + dirs_files[0].push(DisplayablePathBuf::from("..")); + dirs_sort_off = 1; } if let Ok(iter) = fs::read_dir(dir) { for entry in iter.flatten() { if let Ok(metadata) = entry.metadata() { let mut name = entry.file_name(); - if metadata.is_dir() + let dir = metadata.is_dir() || (metadata.is_symlink() - && fs::metadata(entry.path()).is_ok_and(|m| m.is_dir())) - { + && fs::metadata(entry.path()).is_ok_and(|m| m.is_dir())); + if dir { name.push("/"); } - files.push(DisplayablePathBuf::from(name)); + dirs_files[!dir as usize].push(DisplayablePathBuf::from(name)); } } } - // Sort directories first, then by name, case-insensitive. - files[off..].sort_by(|a, b| { - let a = a.as_bytes(); - let b = b.as_bytes(); + for entries in &mut dirs_files { + entries[dirs_sort_off..].sort_by(|a, b| { + let a = a.as_bytes(); + let b = b.as_bytes(); - let a_is_dir = a.last() == Some(&b'/'); - let b_is_dir = b.last() == Some(&b'/'); + let a_is_dir = a.last() == Some(&b'/'); + let b_is_dir = b.last() == Some(&b'/'); - match b_is_dir.cmp(&a_is_dir) { - Ordering::Equal => icu::compare_strings(a, b), - other => other, - } - }); + match b_is_dir.cmp(&a_is_dir) { + Ordering::Equal => icu::compare_strings(a, b), + other => other, + } + }); + dirs_sort_off = 0; + } - state.file_picker_entries = Some(files); + state.file_picker_entries = Some(dirs_files); } +#[inline(never)] fn update_autocomplete_suggestions(state: &mut State) { state.file_picker_autocomplete.clear(); @@ -359,22 +368,39 @@ fn update_autocomplete_suggestions(state: &mut State) { return; } + let scratch = scratch_arena(None); let needle = state.file_picker_pending_name.as_os_str().as_encoded_bytes(); let mut matches = Vec::new(); - if let Some(entries) = &state.file_picker_entries { - // Remove the first entry, which is always "..". - for entry in &entries[1.min(entries.len())..] { - let haystack = entry.as_bytes(); - // We only want items that are longer than the needle, - // because we're looking for suggestions, not for matches. - if haystack.len() > needle.len() - && let haystack = &haystack[..needle.len()] - && icu::compare_strings(haystack, needle) == Ordering::Equal - { - matches.push(entry.clone()); - if matches.len() >= 5 { - break; // Limit to 5 suggestions + // Using binary search below we'll quickly find the lower bound + // of items that match the needle (= share a common prefix). + // + // The problem is finding the upper bound. Here I'm using a trick: + // By appending U+10FFFF (the highest possible Unicode code point) + // we create a needle that naturally yields an upper bound. + let mut needle_upper_bound = Vec::with_capacity_in(needle.len() + 4, &*scratch); + needle_upper_bound.extend_from_slice(needle); + needle_upper_bound.extend_from_slice(b"\xf4\x8f\xbf\xbf"); + + if let Some(dirs_files) = &state.file_picker_entries { + 'outer: for entries in dirs_files { + let lower = entries + .binary_search_by(|entry| icu::compare_strings(entry.as_bytes(), needle)) + .unwrap_or_else(|i| i); + + for entry in &entries[lower..] { + let haystack = entry.as_bytes(); + match icu::compare_strings(haystack, &needle_upper_bound) { + Ordering::Less => { + matches.push(entry.clone()); + if matches.len() >= 5 { + break 'outer; // Limit to 5 suggestions + } + } + // We're looking for suggestions, not for matches. + Ordering::Equal => {} + // No more matches possible. + Ordering::Greater => break, } } } diff --git a/src/bin/edit/state.rs b/src/bin/edit/state.rs index ede1bdd79aec..b45caa41d245 100644 --- a/src/bin/edit/state.rs +++ b/src/bin/edit/state.rs @@ -135,8 +135,8 @@ pub struct State { pub file_picker_pending_dir: DisplayablePathBuf, pub file_picker_pending_dir_revision: u64, // Bumped every time `file_picker_pending_dir` changes. pub file_picker_pending_name: PathBuf, - pub file_picker_entries: Option>, - pub file_picker_overwrite_warning: Option, // The path the warning is about. + pub file_picker_entries: Option<[Vec; 2]>, // [0] = directories, [1] = files + pub file_picker_overwrite_warning: Option, // The path the warning is about. pub file_picker_autocomplete: Vec, pub wants_search: StateSearch, diff --git a/src/icu.rs b/src/icu.rs index ac4d7dc3bbb8..72c3040d6700 100644 --- a/src/icu.rs +++ b/src/icu.rs @@ -707,16 +707,25 @@ static mut ROOT_COLLATOR: Option<*mut icu_ffi::UCollator> = None; /// Compares two UTF-8 strings for sorting using ICU's collation algorithm. pub fn compare_strings(a: &[u8], b: &[u8]) -> Ordering { + #[cold] + fn init() { + unsafe { + let mut coll = null_mut(); + + if let Ok(f) = init_if_needed() { + let mut status = icu_ffi::U_ZERO_ERROR; + coll = (f.ucol_open)(c"".as_ptr(), &mut status); + } + + ROOT_COLLATOR = Some(coll); + } + } + // OnceCell for people that want to put it into a static. #[allow(static_mut_refs)] let coll = unsafe { if ROOT_COLLATOR.is_none() { - ROOT_COLLATOR = Some(if let Ok(f) = init_if_needed() { - let mut status = icu_ffi::U_ZERO_ERROR; - (f.ucol_open)(c"".as_ptr(), &mut status) - } else { - null_mut() - }); + init(); } ROOT_COLLATOR.unwrap_unchecked() }; From 6e992d39daea9b31541876f3616e380bc19819d0 Mon Sep 17 00:00:00 2001 From: Leonard Hecker Date: Wed, 11 Jun 2025 18:30:14 +0200 Subject: [PATCH 2/2] Further simplifications --- src/bin/edit/draw_filepicker.rs | 22 ++++++++++------------ src/bin/edit/state.rs | 2 +- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/src/bin/edit/draw_filepicker.rs b/src/bin/edit/draw_filepicker.rs index 78c4c39d2530..3fae635104ce 100644 --- a/src/bin/edit/draw_filepicker.rs +++ b/src/bin/edit/draw_filepicker.rs @@ -303,18 +303,15 @@ fn draw_file_picker_update_path(state: &mut State) -> Option { fn draw_dialog_saveas_refresh_files(state: &mut State) { let dir = state.file_picker_pending_dir.as_path(); - let mut dirs_files = [Vec::new(), Vec::new()]; - - // This variable ensures that the first entry in dirs_files[0] (directories) - // ".." is always sorted to the top, no matter the other filenames. - let mut dirs_sort_off = 0; + // ["..", directories, files] + let mut dirs_files = [Vec::new(), Vec::new(), Vec::new()]; #[cfg(windows)] if dir.as_os_str().is_empty() { // If the path is empty, we are at the drive picker. // Add all drives as entries. for drive in edit::sys::drives() { - dirs_files[0].push(DisplayablePathBuf::from_string(format!("{drive}:\\"))); + dirs_files[1].push(DisplayablePathBuf::from_string(format!("{drive}:\\"))); } state.file_picker_entries = Some(dirs_files); @@ -323,7 +320,6 @@ fn draw_dialog_saveas_refresh_files(state: &mut State) { if cfg!(windows) || dir.parent().is_some() { dirs_files[0].push(DisplayablePathBuf::from("..")); - dirs_sort_off = 1; } if let Ok(iter) = fs::read_dir(dir) { @@ -333,16 +329,19 @@ fn draw_dialog_saveas_refresh_files(state: &mut State) { let dir = metadata.is_dir() || (metadata.is_symlink() && fs::metadata(entry.path()).is_ok_and(|m| m.is_dir())); + let idx = if dir { 1 } else { 2 }; + if dir { name.push("/"); } - dirs_files[!dir as usize].push(DisplayablePathBuf::from(name)); + + dirs_files[idx].push(DisplayablePathBuf::from(name)); } } } - for entries in &mut dirs_files { - entries[dirs_sort_off..].sort_by(|a, b| { + for entries in &mut dirs_files[1..] { + entries.sort_by(|a, b| { let a = a.as_bytes(); let b = b.as_bytes(); @@ -354,7 +353,6 @@ fn draw_dialog_saveas_refresh_files(state: &mut State) { other => other, } }); - dirs_sort_off = 0; } state.file_picker_entries = Some(dirs_files); @@ -383,7 +381,7 @@ fn update_autocomplete_suggestions(state: &mut State) { needle_upper_bound.extend_from_slice(b"\xf4\x8f\xbf\xbf"); if let Some(dirs_files) = &state.file_picker_entries { - 'outer: for entries in dirs_files { + 'outer: for entries in &dirs_files[1..] { let lower = entries .binary_search_by(|entry| icu::compare_strings(entry.as_bytes(), needle)) .unwrap_or_else(|i| i); diff --git a/src/bin/edit/state.rs b/src/bin/edit/state.rs index b45caa41d245..c5229ed6059e 100644 --- a/src/bin/edit/state.rs +++ b/src/bin/edit/state.rs @@ -135,7 +135,7 @@ pub struct State { pub file_picker_pending_dir: DisplayablePathBuf, pub file_picker_pending_dir_revision: u64, // Bumped every time `file_picker_pending_dir` changes. pub file_picker_pending_name: PathBuf, - pub file_picker_entries: Option<[Vec; 2]>, // [0] = directories, [1] = files + pub file_picker_entries: Option<[Vec; 3]>, // ["..", directories, files] pub file_picker_overwrite_warning: Option, // The path the warning is about. pub file_picker_autocomplete: Vec,