Skip to content

Add filename autocompletion to Open/Save dialog #185

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Jun 10, 2025
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
98 changes: 97 additions & 1 deletion src/bin/edit/draw_filepicker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,72 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) {
ctx.inherit_focus();

ctx.label("name-label", loc(LocId::SaveAsDialogNameLabel));
ctx.editline("name", &mut state.file_picker_pending_name);

let name_changed = ctx.editline("name", &mut state.file_picker_pending_name);
ctx.inherit_focus();

if ctx.contains_focus() {
if name_changed && ctx.is_focused() {
update_autocomplete_suggestions(state);
}
} else if !state.file_picker_autocomplete.is_empty() {
state.file_picker_autocomplete.clear();
}

if !state.file_picker_autocomplete.is_empty() {
let bg = ctx.indexed_alpha(IndexedColor::Background, 3, 4);
let fg = ctx.contrasted(bg);
let focus_list_beg = ctx.is_focused() && ctx.consume_shortcut(vk::DOWN);
let focus_list_end = ctx.is_focused() && ctx.consume_shortcut(vk::UP);
let mut autocomplete_done = ctx.consume_shortcut(vk::ESCAPE);

ctx.list_begin("suggestions");
ctx.attr_float(FloatSpec {
anchor: Anchor::Last,
gravity_x: 0.0,
gravity_y: 0.0,
offset_x: 0.0,
offset_y: 1.0,
});
ctx.attr_border();
ctx.attr_background_rgba(bg);
ctx.attr_foreground_rgba(fg);
{
for (idx, suggestion) in state.file_picker_autocomplete.iter().enumerate() {
let sel = ctx.list_item(false, suggestion.as_str());
if sel != ListSelection::Unchanged {
state.file_picker_pending_name = suggestion.as_path().into();
}
if sel == ListSelection::Activated {
autocomplete_done = true;
}

let is_first = idx == 0;
let is_last = idx == state.file_picker_autocomplete.len() - 1;
if (is_first && focus_list_beg) || (is_last && focus_list_end) {
ctx.list_item_steal_focus();
} else if ctx.is_focused()
&& ((is_first && ctx.consume_shortcut(vk::UP))
|| (is_last && ctx.consume_shortcut(vk::DOWN)))
{
ctx.toss_focus_up();
}
}
}
ctx.list_end();

// If the user typed something, we want to put focus back into the editline.
// TODO: The input should be processed by the editline and not simply get swallowed.
if ctx.keyboard_input().is_some() {
ctx.set_input_consumed();
autocomplete_done = true;
}

if autocomplete_done {
state.file_picker_autocomplete.clear();
}
}

if ctx.is_focused() && ctx.consume_shortcut(vk::RETURN) {
activated = true;
}
Expand Down Expand Up @@ -191,6 +255,7 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) {
state.file_picker_pending_name = Default::default();
state.file_picker_entries = Default::default();
state.file_picker_overwrite_warning = Default::default();
state.file_picker_autocomplete = Default::default();
}
}

Expand Down Expand Up @@ -281,3 +346,34 @@ fn draw_dialog_saveas_refresh_files(state: &mut State) {

state.file_picker_entries = Some(files);
}

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 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
}
}
}
}

state.file_picker_autocomplete = matches;
}
2 changes: 2 additions & 0 deletions src/bin/edit/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ pub struct State {
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_autocomplete: Vec<DisplayablePathBuf>,

pub wants_search: StateSearch,
pub search_needle: String,
Expand Down Expand Up @@ -182,6 +183,7 @@ impl State {
file_picker_pending_name: Default::default(),
file_picker_entries: None,
file_picker_overwrite_warning: None,
file_picker_autocomplete: Vec::new(),

wants_search: StateSearch { kind: StateSearchKind::Hidden, focus: false },
search_needle: Default::default(),
Expand Down
109 changes: 67 additions & 42 deletions src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2511,6 +2511,9 @@ impl<'a> Context<'a, '_> {
}
}
vk::UP => {
if single_line {
return false;
}
match modifiers {
kbmod::NONE => {
let mut x = tc.preferred_column;
Expand Down Expand Up @@ -2568,57 +2571,62 @@ impl<'a> Context<'a, '_> {
tb.cursor_move_delta(granularity, 1);
}
}
vk::DOWN => match modifiers {
kbmod::NONE => {
let mut x = tc.preferred_column;
let mut y = tb.cursor_visual_pos().y + 1;

// If there's a selection we put the cursor below it.
if let Some((_, end)) = tb.selection_range() {
x = end.visual_pos.x;
y = end.visual_pos.y + 1;
tc.preferred_column = x;
}
vk::DOWN => {
if single_line {
return false;
}
match modifiers {
kbmod::NONE => {
let mut x = tc.preferred_column;
let mut y = tb.cursor_visual_pos().y + 1;

// If the cursor was already on the last line,
// move it to the end of the buffer.
if y >= tb.visual_line_count() {
x = CoordType::MAX;
}
// If there's a selection we put the cursor below it.
if let Some((_, end)) = tb.selection_range() {
x = end.visual_pos.x;
y = end.visual_pos.y + 1;
tc.preferred_column = x;
}

// If the cursor was already on the last line,
// move it to the end of the buffer.
if y >= tb.visual_line_count() {
x = CoordType::MAX;
}

tb.cursor_move_to_visual(Point { x, y });
tb.cursor_move_to_visual(Point { x, y });

// If we fell into the `if y >= tb.get_visual_line_count()` above, we wanted to
// update the `preferred_column` but didn't know yet what it was. Now we know!
if x == CoordType::MAX {
tc.preferred_column = tb.cursor_visual_pos().x;
// If we fell into the `if y >= tb.get_visual_line_count()` above, we wanted to
// update the `preferred_column` but didn't know yet what it was. Now we know!
if x == CoordType::MAX {
tc.preferred_column = tb.cursor_visual_pos().x;
}
}
}
kbmod::CTRL => {
tc.scroll_offset.y += 1;
make_cursor_visible = false;
}
kbmod::SHIFT => {
// If the cursor was already on the last line,
// move it to the end of the buffer.
if tb.cursor_visual_pos().y >= tb.visual_line_count() - 1 {
tc.preferred_column = CoordType::MAX;
kbmod::CTRL => {
tc.scroll_offset.y += 1;
make_cursor_visible = false;
}
kbmod::SHIFT => {
// If the cursor was already on the last line,
// move it to the end of the buffer.
if tb.cursor_visual_pos().y >= tb.visual_line_count() - 1 {
tc.preferred_column = CoordType::MAX;
}

tb.selection_update_visual(Point {
x: tc.preferred_column,
y: tb.cursor_visual_pos().y + 1,
});
tb.selection_update_visual(Point {
x: tc.preferred_column,
y: tb.cursor_visual_pos().y + 1,
});

if tc.preferred_column == CoordType::MAX {
tc.preferred_column = tb.cursor_visual_pos().x;
if tc.preferred_column == CoordType::MAX {
tc.preferred_column = tb.cursor_visual_pos().x;
}
}
kbmod::CTRL_ALT => {
// TODO: Add cursor above
}
_ => return false,
}
kbmod::CTRL_ALT => {
// TODO: Add cursor above
}
_ => return false,
},
}
vk::INSERT => match modifiers {
kbmod::SHIFT => {
write = &self.tui.clipboard;
Expand Down Expand Up @@ -2968,6 +2976,23 @@ impl<'a> Context<'a, '_> {
}
}

/// [`Context::steal_focus`], but for a list view.
///
/// This exists, because didn't want to figure out how to get
/// [`Context::styled_list_item_end`] to recognize a regular,
/// programmatic focus steal.
pub fn list_item_steal_focus(&mut self) {
self.steal_focus();

match &mut self.tree.current_node.borrow_mut().content {
NodeContent::List(content) => {
content.selected = self.tree.last_node.borrow().id;
content.selected_node = Some(self.tree.last_node);
}
_ => unreachable!(),
}
}

/// Ends the current list block.
pub fn list_end(&mut self) {
self.block_end();
Expand Down