Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 68 additions & 44 deletions src/bin/edit/draw_filepicker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use std::cmp::Ordering;
use std::fs;
use std::path::{Path, PathBuf};

use edit::arena::scratch_arena;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use edit::framebuffer::IndexedColor;
use edit::helpers::*;
use edit::input::{kbmod, vk};
Expand Down Expand Up @@ -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 {
Expand All @@ -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();
Expand Down Expand Up @@ -300,81 +303,102 @@ fn draw_file_picker_update_path(state: &mut State) -> Option<PathBuf> {

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;
// ["..", directories, files]
let mut dirs_files = [Vec::new(), Vec::new(), Vec::new()];
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This split allows us to use binary search to quickly find matching items during autocompletion. Previously it was O(n). We can't use a single list with a single binary search, because the list is sorted by directories first and then files.


#[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[1].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(".."));
}

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()));
let idx = if dir { 1 } else { 2 };

if dir {
name.push("/");
}
files.push(DisplayablePathBuf::from(name));

dirs_files[idx].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[1..] {
entries.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,
}
});
}

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();

if state.file_picker_pending_name.as_os_str().is_empty() {
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[1..] {
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,
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/bin/edit/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<DisplayablePathBuf>>,
pub file_picker_overwrite_warning: Option<PathBuf>, // The path the warning is about.
pub file_picker_entries: Option<[Vec<DisplayablePathBuf>; 3]>, // ["..", directories, files]
pub file_picker_overwrite_warning: Option<PathBuf>, // The path the warning is about.
pub file_picker_autocomplete: Vec<DisplayablePathBuf>,

pub wants_search: StateSearch,
Expand Down
21 changes: 15 additions & 6 deletions src/icu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
};
Expand Down