From 525083d6fc26bdfa91b3d79411185d3cd1529ea2 Mon Sep 17 00:00:00 2001 From: I3oris Date: Tue, 6 Aug 2024 21:23:48 +0200 Subject: [PATCH] Implement reverse-i-search on `ctrl+r`, and add a `disable_search?` hook. --- README.md | 2 +- src/char_reader.cr | 3 +++ src/expression_editor.cr | 33 ++++++++++++++++++++++-- src/history.cr | 34 +++++++++++++++++++++++-- src/reader.cr | 55 +++++++++++++++++++++++++++++++++++++++- src/search.cr | 39 ++++++++++++++++++++++++++++ 6 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 src/search.cr diff --git a/README.md b/README.md index ae33523..7e2b472 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,10 @@ It includes the following features: * Hook for Auto formatting * Hook for Auto indentation * Hook for Auto completion (Experimental) +* History Reverse i-search * Work on Windows 10 It doesn't support yet: -* History reverse i-search * Customizable hotkeys * Unicode characters diff --git a/src/char_reader.cr b/src/char_reader.cr index c4ab01c..94c739b 100644 --- a/src/char_reader.cr +++ b/src/char_reader.cr @@ -19,6 +19,7 @@ module Reply CTRL_K CTRL_N CTRL_P + CTRL_R CTRL_U CTRL_X CTRL_UP @@ -141,6 +142,8 @@ module Reply Sequence::CTRL_N when ctrl('p') Sequence::CTRL_P + when ctrl('r') + Sequence::CTRL_R when ctrl('u') Sequence::CTRL_U when ctrl('x') diff --git a/src/expression_editor.cr b/src/expression_editor.cr index 9d084ed..77f40c7 100644 --- a/src/expression_editor.cr +++ b/src/expression_editor.cr @@ -83,8 +83,10 @@ module Reply @scroll_offset = 0 @header_height = 0 + @footer_height = 0 @header : IO, Int32 -> Int32 = ->(io : IO, previous_height : Int32) { 0 } + @footer : IO, Int32 -> Int32 = ->(io : IO, previous_height : Int32) { 0 } @highlight = ->(code : String) { code } # The list of characters delimiting words. @@ -101,11 +103,19 @@ module Reply # Sets a `Proc` allowing to display a header above the prompt. (used by auto-completion) # # *io*: The IO in which the header should be displayed. - # *previous_hight*: Previous header height, useful to keep a header size constant. + # *previous_height*: Previous header height, useful to keep a header size constant. # Should returns the exact *height* printed in the io. def set_header(&@header : IO, Int32 -> Int32) end + # Sets a `Proc` allowing to display a footer under the prompt. (used by search) + # + # *io*: The IO in which the footer should be displayed. + # *previous_height*: Previous footer height. + # Should returns the exact *height* printed in the io. + def set_footer(&@footer : IO, Int32 -> Int32) + end + # Sets the `Proc` to highlight the expression. def set_highlight(&@highlight : String -> String) end @@ -383,7 +393,7 @@ module Reply # # The expression scrolls if it's higher than epression_max_height. private def epression_max_height - self.height - @header_height + self.height - @header_height - @footer_height end def move_cursor_left(allow_scrolling = true) @@ -724,6 +734,13 @@ module Reply end end + # Calls the footer proc and saves the *footer_height* + private def update_footer : String + String.build do |io| + @footer_height = @footer.call(io, @footer_height) + end + end + def replace(lines : Array(String)) update { @lines = lines } end @@ -873,6 +890,7 @@ module Reply private def print_expression_and_header(height_to_clear, force_full_view = false) height_to_clear += @header_height header = update_header() + footer = update_footer() if force_full_view start, end_ = 0, Int32::MAX @@ -945,6 +963,17 @@ module Reply @output.print header @output.print display + if @footer_height != 0 + # Display footer, then rewind cursor at the top left of the footer + @output.puts + @output.print footer + @output.print Term::Cursor.column(1) + move_real_cursor(x: @prompt_size, y: 1 - @footer_height) + + cursor_move_y += 1 + cursor_move_x = 0 + end + # Retrieve the real cursor at its corresponding cursor position (`@x`, `@y`) x_save, y_save = @x, @y @y = cursor_move_y diff --git a/src/history.cr b/src/history.cr index 3f12f0f..8431751 100644 --- a/src/history.cr +++ b/src/history.cr @@ -2,12 +2,16 @@ module Reply class History getter history = Deque(Array(String)).new getter max_size = 10_000 - @index = 0 + getter index = 0 # Hold the history lines being edited, always contains one element more than @history # because it can also contain the current line (not yet in history) @edited_history = [nil] of Array(String)? + def size + @history.size + end + def <<(lines) lines = lines.dup # make history elements independent @@ -45,8 +49,34 @@ module Reply @edited_history[@index] = current_edited_lines @index += 1 - (@edited_history[@index]? || @history[@index]).dup + (@edited_history[@index]? || @history[@index]? || [""]).dup + end + end + + def go_to(index) + if 0 <= index < @history.size + @index = index + + @history[@index].dup + end + end + + def search_up(query, from_index = @index - 1) + return nil, 0, 0 if query.empty? + return nil, 0, 0 unless 0 <= from_index < @history.size + + # Search the history starting by `from_index` until first entry, + # then cycle the search by searching from last entry to `from_index` + from_index.downto(0).chain( + (@history.size - 1).downto(from_index + 1) + ).each do |i| + @history[i].each_with_index do |line, y| + x = line.index query + return i, x, y if x + end end + + return nil, 0, 0 end def max_size=(max_size) diff --git a/src/reader.cr b/src/reader.cr index 01228cf..02dfe4b 100644 --- a/src/reader.cr +++ b/src/reader.cr @@ -2,6 +2,7 @@ require "./history" require "./expression_editor" require "./char_reader" require "./auto_completion" +require "./search" module Reply # Reader for your REPL. @@ -45,12 +46,14 @@ module Reply # ^ ^ # | | # History AutoCompletion + # +Search # ``` getter history = History.new getter editor : ExpressionEditor @auto_completion : AutoCompletion @char_reader = CharReader.new + @search = Search.new getter line_number = 1 delegate :color?, :color=, :lines, :output, :output=, to: @editor @@ -72,6 +75,10 @@ module Reply @auto_completion.display_entries(io, color?, max_height: {10, Term::Size.height - 1}.min, min_height: previous_height) end + @editor.set_footer do |io, _previous_height| + @search.footer(io, color?) + end + @editor.set_highlight(&->highlight(String)) if file = self.history_file @@ -118,7 +125,7 @@ module Reply 0 end - # Override to select with expression is saved in history. + # Override to select which expression is saved in history. # # default: `!expression.blank?` def save_in_history?(expression : String) @@ -132,6 +139,13 @@ module Reply nil end + # Override with `true` to disable the reverse i-search (ctrl-r). + # + # default: `true` (disabled) if `history_file` not set. + def disable_search? + history_file.nil? + end + # Override to integrate auto-completion. # # *current_word* is picked following `word_delimiters`. @@ -232,6 +246,7 @@ module Reply in .ctrl_delete? then @editor.update { delete_word } in .alt_d? then @editor.update { delete_word } in .ctrl_c? then on_ctrl_c + in .ctrl_r? then on_ctrl_r in .ctrl_d? if @editor.empty? output.puts @@ -244,6 +259,12 @@ module Reply return nil end + if (read.is_a?(CharReader::Sequence) && (read.ctrl_r? || read.backspace?)) || read.is_a?(Char) || read.is_a?(String) + else + @search.close + @editor.update + end + if read.is_a?(CharReader::Sequence) && (read.tab? || read.enter? || read.alt_enter? || read.shift_tab? || read.escape? || read.backspace? || read.ctrl_c?) else if @auto_completion.open? @@ -278,6 +299,8 @@ module Reply end private def on_char(char) + return search_and_replace(@search.query + char) if @search.open? + @editor.update do @editor << char line = @editor.current_line.rstrip(' ') @@ -294,6 +317,8 @@ module Reply end private def on_string(string) + return search_and_replace(@search.query + string) if @search.open? + @editor.update do @editor << string end @@ -301,6 +326,12 @@ module Reply private def on_enter(alt_enter = false, ctrl_enter = false, &) @auto_completion.close + if @search.open? + @search.close + @editor.update + return + end + if alt_enter || ctrl_enter || (@editor.cursor_on_last_line? && continue?(@editor.expression)) @editor.update do insert_new_line(indent: self.indentation_level(@editor.expression_before_cursor)) @@ -338,6 +369,8 @@ module Reply end private def on_back + return search_and_replace(@search.query.rchop) if @search.open? + auto_complete_remove_char if @auto_completion.open? @editor.update { back } end @@ -365,12 +398,21 @@ module Reply private def on_ctrl_c @auto_completion.close + @search.close @editor.end_editing output.puts "^C" @history.set_to_last @editor.prompt_next end + private def on_ctrl_r + return if disable_search? + + @auto_completion.close + @search.open + search_and_replace(reuse_index?: true) + end + private def on_tab(shift_tab = false) if @auto_completion.open? if shift_tab @@ -390,6 +432,7 @@ module Reply private def on_escape @auto_completion.close + @search.close @editor.update end @@ -446,6 +489,16 @@ module Reply end end + private def search_and_replace(query = nil, reuse_index? = false) + @search.query = query if query + + from_index = reuse_index? ? @history.index - 1 : @history.size - 1 + result, x, y = @search.search(@history, from_index) + @editor.replace(result || [""]) + + @editor.move_cursor_to(x + @search.query.size, y) if result + end + private def submit_expr(*, history = true) formated = format(@editor.expression).try &.split('\n') @editor.end_editing(replacement: formated) diff --git a/src/search.cr b/src/search.cr new file mode 100644 index 0000000..e1f571b --- /dev/null +++ b/src/search.cr @@ -0,0 +1,39 @@ +module Reply + class Search + getter? open = false + property query = "" + getter? failed = false + + def footer(io : IO, color? : Bool) + if open? + io << "search: #{@query.colorize.toggle(failed? && color?).bold.red}_" + 1 + else + 0 + end + end + + def open + @open = true + @failed = false + end + + def close + @open = false + @query = "" + end + + def search(history, from_index = history.index) + index, x, y = history.search_up(@query, from_index: from_index) + if index + @failed = false + result = history.go_to index + return result, x, y + end + + @failed = true + history.set_to_last + return nil, 0, 0 + end + end +end