Skip to content

Commit 90bf080

Browse files
haoruileeidawnlightzbx1425lhecker
authored
Add filename autocompletion to Open/Save dialog (#185)
Closes #28 Co-authored-by: idawnlight <idawn@live.com> Co-authored-by: zbx1425 <support@zbx1425.cn> Co-authored-by: Leonard Hecker <leonard@hecker.io>
1 parent 287d17a commit 90bf080

File tree

3 files changed

+166
-43
lines changed

3 files changed

+166
-43
lines changed

src/bin/edit/draw_filepicker.rs

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,72 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) {
5959
ctx.inherit_focus();
6060

6161
ctx.label("name-label", loc(LocId::SaveAsDialogNameLabel));
62-
ctx.editline("name", &mut state.file_picker_pending_name);
62+
63+
let name_changed = ctx.editline("name", &mut state.file_picker_pending_name);
6364
ctx.inherit_focus();
65+
66+
if ctx.contains_focus() {
67+
if name_changed && ctx.is_focused() {
68+
update_autocomplete_suggestions(state);
69+
}
70+
} else if !state.file_picker_autocomplete.is_empty() {
71+
state.file_picker_autocomplete.clear();
72+
}
73+
74+
if !state.file_picker_autocomplete.is_empty() {
75+
let bg = ctx.indexed_alpha(IndexedColor::Background, 3, 4);
76+
let fg = ctx.contrasted(bg);
77+
let focus_list_beg = ctx.is_focused() && ctx.consume_shortcut(vk::DOWN);
78+
let focus_list_end = ctx.is_focused() && ctx.consume_shortcut(vk::UP);
79+
let mut autocomplete_done = ctx.consume_shortcut(vk::ESCAPE);
80+
81+
ctx.list_begin("suggestions");
82+
ctx.attr_float(FloatSpec {
83+
anchor: Anchor::Last,
84+
gravity_x: 0.0,
85+
gravity_y: 0.0,
86+
offset_x: 0.0,
87+
offset_y: 1.0,
88+
});
89+
ctx.attr_border();
90+
ctx.attr_background_rgba(bg);
91+
ctx.attr_foreground_rgba(fg);
92+
{
93+
for (idx, suggestion) in state.file_picker_autocomplete.iter().enumerate() {
94+
let sel = ctx.list_item(false, suggestion.as_str());
95+
if sel != ListSelection::Unchanged {
96+
state.file_picker_pending_name = suggestion.as_path().into();
97+
}
98+
if sel == ListSelection::Activated {
99+
autocomplete_done = true;
100+
}
101+
102+
let is_first = idx == 0;
103+
let is_last = idx == state.file_picker_autocomplete.len() - 1;
104+
if (is_first && focus_list_beg) || (is_last && focus_list_end) {
105+
ctx.list_item_steal_focus();
106+
} else if ctx.is_focused()
107+
&& ((is_first && ctx.consume_shortcut(vk::UP))
108+
|| (is_last && ctx.consume_shortcut(vk::DOWN)))
109+
{
110+
ctx.toss_focus_up();
111+
}
112+
}
113+
}
114+
ctx.list_end();
115+
116+
// If the user typed something, we want to put focus back into the editline.
117+
// TODO: The input should be processed by the editline and not simply get swallowed.
118+
if ctx.keyboard_input().is_some() {
119+
ctx.set_input_consumed();
120+
autocomplete_done = true;
121+
}
122+
123+
if autocomplete_done {
124+
state.file_picker_autocomplete.clear();
125+
}
126+
}
127+
64128
if ctx.is_focused() && ctx.consume_shortcut(vk::RETURN) {
65129
activated = true;
66130
}
@@ -194,6 +258,7 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) {
194258
state.file_picker_pending_name = Default::default();
195259
state.file_picker_entries = Default::default();
196260
state.file_picker_overwrite_warning = Default::default();
261+
state.file_picker_autocomplete = Default::default();
197262
}
198263
}
199264

@@ -286,3 +351,34 @@ fn draw_dialog_saveas_refresh_files(state: &mut State) {
286351

287352
state.file_picker_entries = Some(files);
288353
}
354+
355+
fn update_autocomplete_suggestions(state: &mut State) {
356+
state.file_picker_autocomplete.clear();
357+
358+
if state.file_picker_pending_name.as_os_str().is_empty() {
359+
return;
360+
}
361+
362+
let needle = state.file_picker_pending_name.as_os_str().as_encoded_bytes();
363+
let mut matches = Vec::new();
364+
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
378+
}
379+
}
380+
}
381+
}
382+
383+
state.file_picker_autocomplete = matches;
384+
}

src/bin/edit/state.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ pub struct State {
137137
pub file_picker_pending_name: PathBuf,
138138
pub file_picker_entries: Option<Vec<DisplayablePathBuf>>,
139139
pub file_picker_overwrite_warning: Option<PathBuf>, // The path the warning is about.
140+
pub file_picker_autocomplete: Vec<DisplayablePathBuf>,
140141

141142
pub wants_search: StateSearch,
142143
pub search_needle: String,
@@ -185,6 +186,7 @@ impl State {
185186
file_picker_pending_name: Default::default(),
186187
file_picker_entries: None,
187188
file_picker_overwrite_warning: None,
189+
file_picker_autocomplete: Vec::new(),
188190

189191
wants_search: StateSearch { kind: StateSearchKind::Hidden, focus: false },
190192
search_needle: Default::default(),

src/tui.rs

Lines changed: 67 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2526,6 +2526,9 @@ impl<'a> Context<'a, '_> {
25262526
}
25272527
}
25282528
vk::UP => {
2529+
if single_line {
2530+
return false;
2531+
}
25292532
match modifiers {
25302533
kbmod::NONE => {
25312534
let mut x = tc.preferred_column;
@@ -2583,57 +2586,62 @@ impl<'a> Context<'a, '_> {
25832586
tb.cursor_move_delta(granularity, 1);
25842587
}
25852588
}
2586-
vk::DOWN => match modifiers {
2587-
kbmod::NONE => {
2588-
let mut x = tc.preferred_column;
2589-
let mut y = tb.cursor_visual_pos().y + 1;
2590-
2591-
// If there's a selection we put the cursor below it.
2592-
if let Some((_, end)) = tb.selection_range() {
2593-
x = end.visual_pos.x;
2594-
y = end.visual_pos.y + 1;
2595-
tc.preferred_column = x;
2596-
}
2589+
vk::DOWN => {
2590+
if single_line {
2591+
return false;
2592+
}
2593+
match modifiers {
2594+
kbmod::NONE => {
2595+
let mut x = tc.preferred_column;
2596+
let mut y = tb.cursor_visual_pos().y + 1;
25972597

2598-
// If the cursor was already on the last line,
2599-
// move it to the end of the buffer.
2600-
if y >= tb.visual_line_count() {
2601-
x = CoordType::MAX;
2602-
}
2598+
// If there's a selection we put the cursor below it.
2599+
if let Some((_, end)) = tb.selection_range() {
2600+
x = end.visual_pos.x;
2601+
y = end.visual_pos.y + 1;
2602+
tc.preferred_column = x;
2603+
}
2604+
2605+
// If the cursor was already on the last line,
2606+
// move it to the end of the buffer.
2607+
if y >= tb.visual_line_count() {
2608+
x = CoordType::MAX;
2609+
}
26032610

2604-
tb.cursor_move_to_visual(Point { x, y });
2611+
tb.cursor_move_to_visual(Point { x, y });
26052612

2606-
// If we fell into the `if y >= tb.get_visual_line_count()` above, we wanted to
2607-
// update the `preferred_column` but didn't know yet what it was. Now we know!
2608-
if x == CoordType::MAX {
2609-
tc.preferred_column = tb.cursor_visual_pos().x;
2613+
// If we fell into the `if y >= tb.get_visual_line_count()` above, we wanted to
2614+
// update the `preferred_column` but didn't know yet what it was. Now we know!
2615+
if x == CoordType::MAX {
2616+
tc.preferred_column = tb.cursor_visual_pos().x;
2617+
}
26102618
}
2611-
}
2612-
kbmod::CTRL => {
2613-
tc.scroll_offset.y += 1;
2614-
make_cursor_visible = false;
2615-
}
2616-
kbmod::SHIFT => {
2617-
// If the cursor was already on the last line,
2618-
// move it to the end of the buffer.
2619-
if tb.cursor_visual_pos().y >= tb.visual_line_count() - 1 {
2620-
tc.preferred_column = CoordType::MAX;
2619+
kbmod::CTRL => {
2620+
tc.scroll_offset.y += 1;
2621+
make_cursor_visible = false;
26212622
}
2623+
kbmod::SHIFT => {
2624+
// If the cursor was already on the last line,
2625+
// move it to the end of the buffer.
2626+
if tb.cursor_visual_pos().y >= tb.visual_line_count() - 1 {
2627+
tc.preferred_column = CoordType::MAX;
2628+
}
26222629

2623-
tb.selection_update_visual(Point {
2624-
x: tc.preferred_column,
2625-
y: tb.cursor_visual_pos().y + 1,
2626-
});
2630+
tb.selection_update_visual(Point {
2631+
x: tc.preferred_column,
2632+
y: tb.cursor_visual_pos().y + 1,
2633+
});
26272634

2628-
if tc.preferred_column == CoordType::MAX {
2629-
tc.preferred_column = tb.cursor_visual_pos().x;
2635+
if tc.preferred_column == CoordType::MAX {
2636+
tc.preferred_column = tb.cursor_visual_pos().x;
2637+
}
26302638
}
2639+
kbmod::CTRL_ALT => {
2640+
// TODO: Add cursor above
2641+
}
2642+
_ => return false,
26312643
}
2632-
kbmod::CTRL_ALT => {
2633-
// TODO: Add cursor above
2634-
}
2635-
_ => return false,
2636-
},
2644+
}
26372645
vk::INSERT => match modifiers {
26382646
kbmod::SHIFT => {
26392647
write = &self.tui.clipboard;
@@ -2983,6 +2991,23 @@ impl<'a> Context<'a, '_> {
29832991
}
29842992
}
29852993

2994+
/// [`Context::steal_focus`], but for a list view.
2995+
///
2996+
/// This exists, because didn't want to figure out how to get
2997+
/// [`Context::styled_list_item_end`] to recognize a regular,
2998+
/// programmatic focus steal.
2999+
pub fn list_item_steal_focus(&mut self) {
3000+
self.steal_focus();
3001+
3002+
match &mut self.tree.current_node.borrow_mut().content {
3003+
NodeContent::List(content) => {
3004+
content.selected = self.tree.last_node.borrow().id;
3005+
content.selected_node = Some(self.tree.last_node);
3006+
}
3007+
_ => unreachable!(),
3008+
}
3009+
}
3010+
29863011
/// Ends the current list block.
29873012
pub fn list_end(&mut self) {
29883013
self.block_end();

0 commit comments

Comments
 (0)