require "./term_cursor" require "./term_size" require "colorize" module Reply # Interface of auto-completion. # # It provides following important methods: # # * `complete_on`: Trigger the auto-completion given a *word_on_cursor* and expression before. # Stores the list of entries, and returns the *replacement* string. # # * `name_filter=`: Update the filtering of entries. # # * `display_entries`: Displays on screen the stored entries. # Highlight the one selected. (initially `nil`). # # * `selection_next`/`selection_previous`: Increases/decrease the selected entry. # # * `open`/`close`: Toggle display, clear entries if close. # # * `clear`: Like `close`, but display a empty space instead of nothing. private class AutoCompletion getter? open = false getter? cleared = false @selection_pos : Int32? = nil @title = "" @all_entries = [] of String getter entries = [] of String property name_filter = "" def initialize(&@auto_complete : String, String -> {String, Array(String)}) @display_title = ->default_display_title(IO, String) @display_entry = ->default_display_entry(IO, String, String) @display_selected_entry = ->default_display_selected_entry(IO, String) end def complete_on(current_word : String, expression_before : String) : String? @title, @all_entries = @auto_complete.call(current_word, expression_before) self.name_filter = current_word @entries.empty? ? nil : common_root(@entries) end def name_filter=(@name_filter) @selection_pos = nil @entries = @all_entries.select(&.starts_with?(@name_filter)) end # If open, displays completion entries by columns, minimizing the height. # Highlight the selected entry (initially `nil`). # # If cleared, displays `clear_size` space. # # If closed, do nothing. # # Returns the actual displayed height. def display_entries(io, color = true, width = Term::Size.width, max_height = 10, min_height = 0) : Int32 # ameba:disable Metrics/CyclomaticComplexity if cleared? min_height.times { io.puts } return min_height end return 0 unless open? return 0 if max_height <= 1 height = 0 # Print title: if color @display_title.call(io, @title) else io << @title << ":" end io.puts height += 1 if @entries.empty? (min_height - height).times { io.puts } return {height, min_height}.max end nb_rows = compute_nb_row(@entries, max_nb_row: max_height - height, width: width) columns = @entries.in_groups_of(nb_rows, filled_up_with: "") column_widths = columns.map &.max_of &.size.+(2) nb_cols = nb_colomns_in_width(column_widths, width) col_start = 0 if pos = @selection_pos col_end = pos // nb_rows if col_end >= nb_cols nb_cols = nb_colomns_in_width(column_widths[..col_end].reverse_each, width) col_start = col_end - nb_cols + 1 end end nb_rows.times do |r| nb_cols.times do |c| c += col_start entry = columns[c][r] col_width = column_widths[c] # `...` on the last column and row: if (r == nb_rows - 1) && (c - col_start == nb_cols - 1) && columns[c + 1]? entry += ".." end # Entry to display: entry_str = entry.ljust(col_width) if r + c*nb_rows == @selection_pos # Colorize selection: if color @display_selected_entry.call(io, entry_str) else io << ">" + entry_str[...-1] # if no color, remove last spaces to let place to '*'. end else # Display entry_str, with @name_filter prefix in bright: unless entry.empty? if color io << @display_entry.call(io, @name_filter, entry_str.lchop(@name_filter)) else io << entry_str end end end end io << Term::Cursor.clear_line_after if color io.puts end height += nb_rows (min_height - height).times { io.puts } {height, min_height}.max end # Increases selected entry. def selection_next return nil if @entries.empty? if (pos = @selection_pos).nil? new_pos = 0 else new_pos = (pos + 1) % @entries.size end @selection_pos = new_pos @entries[new_pos] end # Decreases selected entry. def selection_previous return nil if @entries.empty? if (pos = @selection_pos).nil? new_pos = 0 else new_pos = (pos - 1) % @entries.size end @selection_pos = new_pos @entries[new_pos] end def open @open = true @cleared = false end def close @selection_pos = nil @entries.clear @name_filter = "" @all_entries.clear @open = false @cleared = false end def clear close @cleared = true end def set_display_title(&@display_title : IO, String ->) end def set_display_entry(&@display_entry : IO, String, String ->) end def set_display_selected_entry(&@display_selected_entry : IO, String ->) end protected def default_display_title(io, title) io << title.colorize.underline << ":" end protected def default_display_entry(io, entry_matched, entry_remaining) io << entry_matched.colorize.bright << entry_remaining end protected def default_display_selected_entry(io, entry) io << entry.colorize.bright.on_dark_gray end private def nb_colomns_in_width(column_widths, width) nb_cols = 0 w = 0 column_widths.each do |col_width| w += col_width break if w > width nb_cols += 1 end nb_cols end # Computes the min number of rows required to display entries: # * if all entries cannot fit in `max_nb_row` rows, returns `max_nb_row`, # * if there are less than 10 entries, returns `entries.size` because in this case, it's more convenient to display them in one column. private def compute_nb_row(entries, max_nb_row, width) if entries.size > 10 # test possible nb rows: (1 to max_nb_row) 1.to max_nb_row do |r| w = 0 # Sum the width of each given column: entries.each_slice(r, reuse: true) do |col| w += col.max_of &.size + 2 end # If *w* goes past *width*, we found min row required: return r if w < width end end {entries.size, max_nb_row}.min end # Finds the common root text between given entries. private def common_root(entries) return "" if entries.empty? return entries[0] if entries.size == 1 i = 0 entry_iterators = entries.map &.each_char loop do char_on_first_entry = entries[0][i]? same = entry_iterators.all? do |entry| entry.next == char_on_first_entry end i += 1 break if !same end entries[0][...(i - 1)] end end end