diff --git a/config/default.focus-config b/config/default.focus-config index 2202843ec..792f1d336 100644 --- a/config/default.focus-config +++ b/config/default.focus-config @@ -136,6 +136,10 @@ Ctrl-K delete_to_end_of_line Ctrl-Shift-Delete delete_to_end_of_line Ctrl-Shift-Backspace delete_to_start_of_line +Ctrl-E word_completion_next +Ctrl-Q word_completion_previous +Ctrl-R word_completion_subword + Alt-ArrowUp move_selected_lines_up Alt-ArrowDown move_selected_lines_down diff --git a/src/draw.jai b/src/draw.jai index 9dbde8724..0d41743bb 100644 --- a/src/draw.jai +++ b/src/draw.jai @@ -1204,6 +1204,75 @@ draw_editor :: (editor_id: s64, main_area: Rect, status_bar_height: float, ui_id } } + // Draw word completion + if editor_is_active && word_complete.results.count { + + word_complete.max_visible_item_at_once = word_complete.DEFAULT_MAX_VISIBLE_ITEM; + max_wc_item: s64 = min(word_complete.results.count, word_complete.max_visible_item_at_once); + + padding := floor(10 * dpi_scale); + item_delta_scaler := (line_height + 5) * dpi_scale; + cursor_screen_pos := get_cursor_screen_pos(text_origin, cursor_coords[0].pos); + + max_char := 0; + for word_complete.results { if it.word.count > max_char max_char = it.word.count; } + + rect_w, rect_h, rect_left, rect_bottom: float = 0; + calc_rect :: () #expand { + rect_w = (max_char * char_x_advance) + (padding * 2); + rect_h = max_wc_item * (item_delta_scaler) + padding; + rect_bottom = cursor_screen_pos.y - rect_h; + rect_left = cursor_screen_pos.x + padding*2; + + if rect_bottom < line_number_panel.h { + rect_bottom = line_number_panel.h + line_height; + } + if rect_left + rect_w + padding > main_area.w + main_area.x { + rect_left = (main_area.w + main_area.x) - (rect_w + padding); + } + } + calc_rect(); + + rect_top := rect_bottom + rect_h; + if (rect_top > main_area.h) { + // Reduce the max visible results if there is no more space to expand at the top + usable_h := main_area.h - line_number_panel.h; + max_wc_item = cast(s64)(usable_h / item_delta_scaler / 2); + word_complete.max_visible_item_at_once = max_wc_item; + calc_rect(); + } + + rect := make_rect(rect_left, rect_bottom, rect_w, rect_h); + background_dark := Color.BACKGROUND_1; + draw_rounded_rect(rect, background_dark, radius = rounding_radius_large, set_shader = true); + + i := 0; + for word_complete.scroll_top..max_wc_item + word_complete.scroll_top - 1 { + if it == word_complete.results.count then break; + item_left := rect_left + padding; + item_top := (rect_bottom + rect_h) - (item_delta_scaler * (i + 1)); + item_color := Token_Type.default; + if (it % word_complete.results.count) == word_complete.selected_result_index { + item_color = Token_Type.warning; + } + + Simp.prepare_text(font, word_complete.results[it].word); + Simp.draw_prepared_text(font, cast(s64) item_left, cast(s64) item_top, xx item_color); + + i+=1; + } + + if (word_complete.scroll_top) { + Simp.prepare_text(font_ui_small, BULLET_ICON); + Simp.draw_prepared_text(font_ui_small, xx (rect_left + (rect_w/2)), xx (rect_bottom+rect_h-line_height/2), color = xx Color.UI_DEFAULT); + } + + if (word_complete.results.count > word_complete.scroll_top + max_wc_item) { + Simp.prepare_text(font_ui_small, BULLET_ICON); + Simp.draw_prepared_text(font_ui_small, xx (rect_left + (rect_w/2)), xx rect_bottom, color = xx Color.UI_DEFAULT); + } + } + // Draw search bar if active_global_widget != .editors then search_bar.active = false; diff --git a/src/editors.jai b/src/editors.jai index 68e642d1f..3f130a3b1 100644 --- a/src/editors.jai +++ b/src/editors.jai @@ -157,6 +157,9 @@ active_editor_handle_event :: (editor: *Editor, buffer: *Buffer, event: Input.Ev keep_selection := false; enabled_whole_words := false; + word_complete.should_abort = true; + defer { if word_complete.should_abort && word_complete.active then word_complete_abort(); } + handled := true; if action == { @@ -287,6 +290,10 @@ active_editor_handle_event :: (editor: *Editor, buffer: *Buffer, event: Input.Ev case .delete_to_start_of_line; delete_to_start_of_line (editor, buffer); case .delete_to_end_of_line; delete_to_end_of_line (editor, buffer); + case .word_completion_next; word_completion(); + case .word_completion_previous; word_completion(forwards = false); + case .word_completion_subword; word_completion(subwords = true); + case; return false; } } @@ -3417,8 +3424,209 @@ copy_current_line_info :: (using editor: *Editor, buffer: Buffer) { add_success_message("String '%' copied to clipboard", line_info, dismiss_in_seconds = 5); } +word_completion :: (forwards := true, subwords := false) { + using word_complete; + should_abort = false; + + if !active { + search_for_subwords = subwords; + + context.underscore_is_part_of_word = !subwords; + word_complete_search(); + context.underscore_is_part_of_word = true; + + if results.count == 0 then return; + active = true; + } + + if forwards word_completion_next(); + else word_completion_previous(); +} + +word_completion_next :: () { + using word_complete; + + selected_result_index = (selected_result_index + 1) % results.count; + + start := scroll_top; + end := start + max_visible_item_at_once; + + if selected_result_index+1 > end { + scroll_top += 1; + } else if selected_result_index == 0 { + scroll_top = 0; + } + + word_complete_apply(); +} + +word_completion_previous :: () { + using word_complete; + + if selected_result_index - 1 < 0 { + selected_result_index = results.count-1; + if selected_result_index - (max_visible_item_at_once-1) > -1 { + scroll_top = selected_result_index - (max_visible_item_at_once-1); + } + } else { + selected_result_index -= 1; + if selected_result_index < scroll_top { + scroll_top -= 1; + } + } + + word_complete_apply(); +} + +word_complete_abort :: () { + using word_complete; + active = false; + scroll_top = 0; + if !results.count return; + for results { free(it.word); } + array_reset(*results); + selected_result_index = -1; +} + +word_complete_search :: () { + // hmm... + UNDERSCORE_PENALTY :: 1000000000; + CASE_SENSITIVE_PENALTY :: 100000000; + + editor, buffer := get_active_editor_and_buffer(); + + cursor := *editor.cursors[0]; + right := cursor.pos; + left := scan_through_similar_chars_on_the_left(buffer, cursor.pos, Char_Type.word, skip_one_space = false); + start_subword := get_range_as_string(buffer, Offset_Range.{left, right}); + if start_subword.count == 0 then return; + + buffer_str := cast(string)buffer.bytes; + + add_new_result_or_update_delta :: inline (new_result: string, delta: s64) { + if word_complete.search_for_subwords { + for 0..new_result.count-1 { + if new_result[it] == #char "_" { + delta += UNDERSCORE_PENALTY; + break; + } + } + } + + for * word_complete.results { + if it.word == new_result { + // found a closer one + if delta < it.cursor_delta then it.cursor_delta = xx delta; + return; + } + } + + result := Word_Result.{word = new_result, cursor_delta = xx delta}; + array_add(*word_complete.results, result); + } + + pos := 0; + while pos != buffer_str.count-1 { + pos = find_index_from_left_nocase(buffer_str, start_subword, pos); + if pos == -1 break; + + defer pos += start_subword.count; + if pos == left || (pos > 0 && is_word_char(buffer_str[pos-1])) { + // Exclude results that are part of another word. For instance, if your subword is 'ine' and you have a word like 'wine' elsewhere, it will not include the result 'ine' from 'w[ine]'. + continue; + } + + for i: pos..buffer_str.count-1 { + c := buffer_str[i]; + if !is_word_char(c) || i == buffer_str.count-1 { + is_subword := c == #char "_"; + + s := advance(buffer_str, pos); + s.count = i - pos; + if s != start_subword { + // Now, we're going left to right, so the distance between the word and the cursor will + // always be closer than in right-to-left searching; thus, we need to apply some corrections to it. + cursor_delta := cursor.pos - pos; + if pos >= right then cursor_delta = i - cursor.pos; + if is_upper(start_subword[0]) != is_upper(buffer_str[pos]) then cursor_delta += CASE_SENSITIVE_PENALTY; + add_new_result_or_update_delta(s, cursor_delta); + } + + if !is_subword then break; + } + } + + } + + if !word_complete.results.count then return; + + bubble_sort(word_complete.results, (a, b) => cast(s64) ((a.cursor_delta) - (b.cursor_delta))); + + add_new_result_or_update_delta(start_subword, delta = 0); + for * word_complete.results { it.word = copy_string(it.word); } + + return; +} + +word_complete_apply :: () { + using word_complete; + + if !results || !open_editors[editors.active].cursors.count return; + editor, buffer := get_active_editor_and_buffer(); + + result := results[selected_result_index]; + word := result.word; + + offset_delta: s32 = 0; + buf_len := cast(s32) buffer.bytes.count; + + for *cursor: editor.cursors { + left := scan_through_similar_chars_on_the_left(buffer, cursor.pos, Char_Type.word, skip_one_space = false); + cursor.sel = left; + } + + for *cursor: editor.cursors { + new_len := cast(s32) buffer.bytes.count; + if new_len != buf_len { + offset_delta += new_len - buf_len; + cursor.pos += offset_delta; + cursor.sel += offset_delta; + buf_len = new_len; + } + + range := get_selection(cursor); + replace_range(buffer, range, word); + + end := cast(s32)(range.start+word.count); + add_paste_animation(editor, Offset_Range.{range.start, end}); + + cursor.pos = end; + cursor.sel = end; + } +} + #scope_export +Word_Completion :: struct { + DEFAULT_MAX_VISIBLE_ITEM :: 3; + MAX_TOTAL_RESULT :: 30; + + active := false; + should_abort := false; + selected_result_index := -1; + search_for_subwords := false; + results: [..] Word_Result; + scroll_top := 0; + max_visible_item_at_once := DEFAULT_MAX_VISIBLE_ITEM; +} + +Word_Result :: struct { + word: string; + cursor_delta: s32; +} + +word_complete: Word_Completion; + Editor :: struct { buffer_id: s64; diff --git a/src/keymap.jai b/src/keymap.jai index d7c184752..154689607 100644 --- a/src/keymap.jai +++ b/src/keymap.jai @@ -410,6 +410,10 @@ ACTIONS_EDITORS :: #run arrays_concat(ACTIONS_COMMON, string.[ "toggle_line_wrap", "toggle_line_numbers", "copy_current_line_info", + + "word_completion_next", + "word_completion_previous", + "word_completion_subword", ]); ACTIONS_OPEN_FILE_DIALOG :: #run arrays_concat(ACTIONS_COMMON, string.[ diff --git a/src/utils.jai b/src/utils.jai index 165499151..d2486f430 100644 --- a/src/utils.jai +++ b/src/utils.jai @@ -277,6 +277,23 @@ find_index_from_left_nocase :: (s: string, substring: string, start_index := 0) return -1; } + +find_index_from_right_nocase :: (s: string, substring: string, start_index := 0) -> s64 { + if !substring return -1; + x := s; + if start_index { + if start_index > s.count return -1; // assert? + x.count = start_index; + } + + for < i: x.count-substring.count..0 { + t := slice(x, i, substring.count); + if equal_nocase(t, substring) return i; + } + + return -1; +} + match_whole_word :: (s: string, offset: int, count: int) -> bool { word_ends_on_the_left := offset <= 0 || !is_word_char(s[offset - 1]) || !is_word_char(s[offset]); word_ends_on_the_right := offset + count >= s.count || !is_word_char(s[offset + count]) || !is_word_char(s[offset + count - 1]); @@ -362,6 +379,11 @@ is_all_whitespace :: (s: string) -> bool { return true; } +is_upper :: inline (byte: u8) -> bool { + if byte >= #char "A" && byte <= #char "Z" return true; + return false; +} + count_whitespace :: (bytes: [] u8, start_offset: s64, max_offset: s64, spaces := " \t") -> count: s32 { subarray := array_view(bytes, start_offset, max_offset - start_offset); for subarray {