[ruby/reline] Rewrite dialog rendering

(https://github.com/ruby/reline/pull/492)

* Rewrite dialog rendering

* Fix failing test of dialog with small screen

* Add multiple-dialog rendering test

* Add description comments for each part of render_dialog_changes
This commit is contained in:
tomoya ishida 2023-05-01 21:20:09 +09:00 коммит произвёл git
Родитель dd5ba1b725
Коммит 13dfbcf7bf
3 изменённых файлов: 177 добавлений и 209 удалений

Просмотреть файл

@ -94,7 +94,7 @@ class Reline::LineEditor
mode_string
end
private def check_multiline_prompt(buffer)
private def check_multiline_prompt(buffer, force_recalc: false)
if @vi_arg
prompt = "(arg: #{@vi_arg}) "
@rerender_all = true
@ -104,7 +104,7 @@ class Reline::LineEditor
else
prompt = @prompt
end
if simplified_rendering?
if simplified_rendering? && !force_recalc
mode_string = check_mode_string
prompt = mode_string + prompt if mode_string
return [prompt, calculate_width(prompt, true), [prompt] * buffer.size]
@ -220,7 +220,7 @@ class Reline::LineEditor
def set_signal_handlers
@old_trap = Signal.trap('INT') {
clear_dialog
clear_dialog(0)
if @scroll_partial_screen
move_cursor_down(@screen_height - (@line_index - @scroll_partial_screen) - 1)
else
@ -283,6 +283,7 @@ class Reline::LineEditor
@in_pasting = false
@auto_indent_proc = nil
@dialogs = []
@previous_rendered_dialog_y = 0
@last_key = nil
@resized = false
reset_line
@ -429,6 +430,7 @@ class Reline::LineEditor
@menu_info = nil
end
prompt, prompt_width, prompt_list = check_multiline_prompt(whole_lines)
cursor_column = (prompt_width + @cursor) % @screen_size.last
if @cleared
clear_screen_buffer(prompt, prompt_list, prompt_width)
@cleared = false
@ -446,23 +448,23 @@ class Reline::LineEditor
Reline::IOGate.erase_after_cursor
end
@output.flush
clear_dialog
clear_dialog(cursor_column)
return
end
new_highest_in_this = calculate_height_by_width(prompt_width + calculate_width(@line.nil? ? '' : @line))
rendered = false
if @add_newline_to_end_of_buffer
clear_dialog_with_content
clear_dialog_with_trap_key(cursor_column)
rerender_added_newline(prompt, prompt_width, prompt_list)
@add_newline_to_end_of_buffer = false
else
if @just_cursor_moving and not @rerender_all
clear_dialog_with_content
clear_dialog_with_trap_key(cursor_column)
rendered = just_move_cursor
@just_cursor_moving = false
return
elsif @previous_line_index or new_highest_in_this != @highest_in_this
clear_dialog_with_content
clear_dialog_with_trap_key(cursor_column)
rerender_changed_current_line
@previous_line_index = nil
rendered = true
@ -478,7 +480,7 @@ class Reline::LineEditor
# Always rerender on finish because output_modifier_proc may return a different output.
new_lines = whole_lines
line = modify_lines(new_lines)[@line_index]
clear_dialog
clear_dialog(cursor_column)
prompt, prompt_width, prompt_list = check_multiline_prompt(new_lines)
render_partial(prompt, prompt_width, line, @first_line_started_from)
move_cursor_down(@highest_in_all - (@first_line_started_from + @highest_in_this - 1) - 1)
@ -491,7 +493,7 @@ class Reline::LineEditor
prompt, prompt_width, prompt_list = check_multiline_prompt(whole_lines)
render_partial(prompt, prompt_width, line, @first_line_started_from)
end
render_dialog((prompt_width + @cursor) % @screen_size.last)
render_dialog(cursor_column)
end
@buffer_of_lines[@line_index] = @line
@rest_height = 0 if @scroll_partial_screen
@ -575,7 +577,7 @@ class Reline::LineEditor
class Dialog
attr_reader :name, :contents, :width
attr_accessor :scroll_top, :pointer, :column, :vertical_offset, :lines_backup, :trap_key
attr_accessor :scroll_top, :pointer, :column, :vertical_offset, :trap_key
def initialize(name, config, proc_scope)
@name = name
@ -631,9 +633,12 @@ class Reline::LineEditor
DIALOG_DEFAULT_HEIGHT = 20
private def render_dialog(cursor_column)
@dialogs.each do |dialog|
render_each_dialog(dialog, cursor_column)
changes = @dialogs.map do |dialog|
old_dialog = dialog.dup
update_each_dialog(dialog, cursor_column)
[old_dialog, dialog]
end
render_dialog_changes(changes, cursor_column)
end
private def padding_space_with_escape_sequences(str, width)
@ -643,9 +648,106 @@ class Reline::LineEditor
str + (' ' * padding_width)
end
private def render_each_dialog(dialog, cursor_column)
private def range_subtract(base_ranges, subtract_ranges)
indices = base_ranges.flat_map(&:to_a).uniq.sort - subtract_ranges.flat_map(&:to_a)
chunks = indices.chunk_while { |a, b| a + 1 == b }
chunks.map { |a| a.first...a.last + 1 }
end
private def dialog_range(dialog, dialog_y)
x_range = dialog.column...dialog.column + dialog.width
y_range = dialog_y + dialog.vertical_offset...dialog_y + dialog.vertical_offset + dialog.contents.size
[x_range, y_range]
end
private def render_dialog_changes(changes, cursor_column)
# Collect x-coordinate range and content of previous and current dialogs for each line
old_dialog_ranges = {}
new_dialog_ranges = {}
new_dialog_contents = {}
changes.each do |old_dialog, new_dialog|
if old_dialog.contents
x_range, y_range = dialog_range(old_dialog, @previous_rendered_dialog_y)
y_range.each do |y|
(old_dialog_ranges[y] ||= []) << x_range
end
end
if new_dialog.contents
x_range, y_range = dialog_range(new_dialog, @first_line_started_from + @started_from)
y_range.each do |y|
(new_dialog_ranges[y] ||= []) << x_range
(new_dialog_contents[y] ||= []) << [x_range, new_dialog.contents[y - y_range.begin]]
end
end
end
return if old_dialog_ranges.empty? && new_dialog_ranges.empty?
# Calculate x-coordinate ranges to restore text that was hidden behind dialogs for each line
ranges_to_restore = {}
subtract_cache = {}
old_dialog_ranges.each do |y, old_x_ranges|
new_x_ranges = new_dialog_ranges[y] || []
ranges = subtract_cache[[old_x_ranges, new_x_ranges]] ||= range_subtract(old_x_ranges, new_x_ranges)
ranges_to_restore[y] = ranges if ranges.any?
end
# Create visual_lines for restoring text hidden behind dialogs
if ranges_to_restore.any?
lines = whole_lines
prompt, _prompt_width, prompt_list = check_multiline_prompt(lines, force_recalc: true)
modified_lines = modify_lines(lines, force_recalc: true)
visual_lines = []
modified_lines.each_with_index { |l, i|
pr = prompt_list ? prompt_list[i] : prompt
vl, = split_by_width(pr + l, @screen_size.last)
vl.compact!
visual_lines.concat(vl)
}
end
# Clear and rerender all dialogs line by line
Reline::IOGate.hide_cursor
ymin, ymax = (ranges_to_restore.keys + new_dialog_ranges.keys).minmax
dialog_y = @first_line_started_from + @started_from
cursor_y = dialog_y
scroll_down(ymax - cursor_y)
move_cursor_up(ymax - cursor_y)
(ymin..ymax).each do |y|
move_cursor_down(y - cursor_y)
cursor_y = y
new_x_ranges = new_dialog_ranges[y]
restore_ranges = ranges_to_restore[y]
# Restore text that was hidden behind dialogs
if restore_ranges
line = visual_lines[y] || ''
restore_ranges.each do |range|
col = range.begin
width = range.end - range.begin
s = padding_space_with_escape_sequences(Reline::Unicode.take_range(line, col, width), width)
Reline::IOGate.move_cursor_column(col)
@output.write "\e[0m#{s}\e[0m"
end
max_column = [calculate_width(line, true), new_x_ranges&.map(&:end)&.max || 0].max
if max_column < restore_ranges.map(&:end).max
Reline::IOGate.move_cursor_column(max_column)
Reline::IOGate.erase_after_cursor
end
end
# Render dialog contents
new_dialog_contents[y]&.each do |x_range, content|
Reline::IOGate.move_cursor_column(x_range.begin)
@output.write "\e[0m#{content}\e[0m"
end
end
move_cursor_up(cursor_y - dialog_y)
Reline::IOGate.move_cursor_column(cursor_column)
Reline::IOGate.show_cursor
@previous_rendered_dialog_y = dialog_y
end
private def update_each_dialog(dialog, cursor_column)
if @in_pasting
clear_each_dialog(dialog)
dialog.contents = nil
dialog.trap_key = nil
return
@ -653,31 +755,20 @@ class Reline::LineEditor
dialog.set_cursor_pos(cursor_column, @first_line_started_from + @started_from)
dialog_render_info = dialog.call(@last_key)
if dialog_render_info.nil? or dialog_render_info.contents.nil? or dialog_render_info.contents.empty?
lines = whole_lines
dialog.lines_backup = {
unmodified_lines: lines,
lines: modify_lines(lines),
line_index: @line_index,
first_line_started_from: @first_line_started_from,
started_from: @started_from,
byte_pointer: @byte_pointer
}
clear_each_dialog(dialog)
dialog.contents = nil
dialog.trap_key = nil
return
end
old_dialog = dialog.clone
dialog.contents = dialog_render_info.contents
contents = dialog_render_info.contents
pointer = dialog.pointer
if dialog_render_info.width
dialog.width = dialog_render_info.width
else
dialog.width = dialog.contents.map { |l| calculate_width(l, true) }.max
dialog.width = contents.map { |l| calculate_width(l, true) }.max
end
height = dialog_render_info.height || DIALOG_DEFAULT_HEIGHT
height = dialog.contents.size if dialog.contents.size < height
if dialog.contents.size > height
height = contents.size if contents.size < height
if contents.size > height
if dialog.pointer
if dialog.pointer < 0
dialog.scroll_top = 0
@ -690,13 +781,13 @@ class Reline::LineEditor
else
dialog.scroll_top = 0
end
dialog.contents = dialog.contents[dialog.scroll_top, height]
contents = contents[dialog.scroll_top, height]
end
if dialog_render_info.scrollbar and dialog_render_info.contents.size > height
bar_max_height = height * 2
moving_distance = (dialog_render_info.contents.size - height) * 2
position_ratio = dialog.scroll_top.zero? ? 0.0 : ((dialog.scroll_top * 2).to_f / moving_distance)
bar_height = (bar_max_height * ((dialog.contents.size * 2).to_f / (dialog_render_info.contents.size * 2))).floor.to_i
bar_height = (bar_max_height * ((contents.size * 2).to_f / (dialog_render_info.contents.size * 2))).floor.to_i
bar_height = MINIMUM_SCROLLBAR_HEIGHT if bar_height < MINIMUM_SCROLLBAR_HEIGHT
scrollbar_pos = ((bar_max_height - bar_height) * position_ratio).floor.to_i
else
@ -714,21 +805,13 @@ class Reline::LineEditor
elsif upper_space >= height
dialog.vertical_offset = dialog_render_info.pos.y - height
else
if (@rest_height - dialog_render_info.pos.y) < height
scroll_down(height + dialog_render_info.pos.y)
move_cursor_up(height + dialog_render_info.pos.y)
end
dialog.vertical_offset = dialog_render_info.pos.y + 1
end
Reline::IOGate.hide_cursor
if dialog.column < 0
dialog.column = 0
dialog.width = @screen_size.last
end
reset_dialog(dialog, old_dialog)
move_cursor_down(dialog.vertical_offset)
Reline::IOGate.move_cursor_column(dialog.column)
dialog.contents.each_with_index do |item, i|
dialog.contents = contents.map.with_index do |item, i|
if i == pointer
fg_color = dialog_render_info.pointer_fg_color
bg_color = dialog_render_info.pointer_bg_color
@ -738,185 +821,40 @@ class Reline::LineEditor
end
str_width = dialog.width - (scrollbar_pos.nil? ? 0 : @block_elem_width)
str = padding_space_with_escape_sequences(Reline::Unicode.take_range(item, 0, str_width), str_width)
@output.write "\e[#{bg_color}m\e[#{fg_color}m#{str}"
colored_content = "\e[#{bg_color}m\e[#{fg_color}m#{str}"
if scrollbar_pos
@output.write "\e[37m"
color_seq = "\e[37m"
if scrollbar_pos <= (i * 2) and (i * 2 + 1) < (scrollbar_pos + bar_height)
@output.write @full_block
colored_content + color_seq + @full_block
elsif scrollbar_pos <= (i * 2) and (i * 2) < (scrollbar_pos + bar_height)
@output.write @upper_half_block
colored_content + color_seq + @upper_half_block
elsif scrollbar_pos <= (i * 2 + 1) and (i * 2) < (scrollbar_pos + bar_height)
@output.write @lower_half_block
colored_content + color_seq + @lower_half_block
else
@output.write ' ' * @block_elem_width
colored_content + color_seq + ' ' * @block_elem_width
end
else
colored_content
end
@output.write "\e[0m"
Reline::IOGate.move_cursor_column(dialog.column)
move_cursor_down(1) if i < (dialog.contents.size - 1)
end
Reline::IOGate.move_cursor_column(cursor_column)
move_cursor_up(dialog.vertical_offset + dialog.contents.size - 1)
Reline::IOGate.show_cursor
lines = whole_lines
dialog.lines_backup = {
unmodified_lines: lines,
lines: modify_lines(lines),
line_index: @line_index,
first_line_started_from: @first_line_started_from,
started_from: @started_from,
byte_pointer: @byte_pointer
}
end
private def reset_dialog(dialog, old_dialog)
return if dialog.lines_backup.nil? or old_dialog.contents.nil?
prompt, prompt_width, prompt_list = check_multiline_prompt(dialog.lines_backup[:unmodified_lines])
visual_lines = []
visual_start = nil
dialog.lines_backup[:lines].each_with_index { |l, i|
pr = prompt_list ? prompt_list[i] : prompt
vl, _ = split_by_width(pr + l, @screen_size.last)
vl.compact!
if i == dialog.lines_backup[:line_index]
visual_start = visual_lines.size + dialog.lines_backup[:started_from]
end
visual_lines.concat(vl)
}
old_y = dialog.lines_backup[:first_line_started_from] + dialog.lines_backup[:started_from]
y = @first_line_started_from + @started_from
y_diff = y - old_y
if (old_y + old_dialog.vertical_offset) < (y + dialog.vertical_offset)
# rerender top
move_cursor_down(old_dialog.vertical_offset - y_diff)
start = visual_start + old_dialog.vertical_offset
line_num = dialog.vertical_offset - old_dialog.vertical_offset
line_num.times do |i|
Reline::IOGate.move_cursor_column(old_dialog.column)
if visual_lines[start + i].nil?
s = ' ' * old_dialog.width
else
s = Reline::Unicode.take_range(visual_lines[start + i], old_dialog.column, old_dialog.width)
s = padding_space_with_escape_sequences(s, old_dialog.width)
end
@output.write "\e[0m#{s}\e[0m"
move_cursor_down(1) if i < (line_num - 1)
end
move_cursor_up(old_dialog.vertical_offset + line_num - 1 - y_diff)
end
if (old_y + old_dialog.vertical_offset + old_dialog.contents.size) > (y + dialog.vertical_offset + dialog.contents.size)
# rerender bottom
move_cursor_down(dialog.vertical_offset + dialog.contents.size - y_diff)
start = visual_start + dialog.vertical_offset + dialog.contents.size
line_num = (old_dialog.vertical_offset + old_dialog.contents.size) - (dialog.vertical_offset + dialog.contents.size)
line_num.times do |i|
Reline::IOGate.move_cursor_column(old_dialog.column)
if visual_lines[start + i].nil?
s = ' ' * old_dialog.width
else
s = Reline::Unicode.take_range(visual_lines[start + i], old_dialog.column, old_dialog.width)
s = padding_space_with_escape_sequences(s, old_dialog.width)
end
@output.write "\e[0m#{s}\e[0m"
move_cursor_down(1) if i < (line_num - 1)
end
move_cursor_up(dialog.vertical_offset + dialog.contents.size + line_num - 1 - y_diff)
end
if old_dialog.column < dialog.column
# rerender left
move_cursor_down(old_dialog.vertical_offset - y_diff)
width = dialog.column - old_dialog.column
start = visual_start + old_dialog.vertical_offset
line_num = old_dialog.contents.size
line_num.times do |i|
Reline::IOGate.move_cursor_column(old_dialog.column)
if visual_lines[start + i].nil?
s = ' ' * width
else
s = Reline::Unicode.take_range(visual_lines[start + i], old_dialog.column, width)
s = padding_space_with_escape_sequences(s, dialog.width)
end
@output.write "\e[0m#{s}\e[0m"
move_cursor_down(1) if i < (line_num - 1)
end
move_cursor_up(old_dialog.vertical_offset + line_num - 1 - y_diff)
end
if (old_dialog.column + old_dialog.width) > (dialog.column + dialog.width)
# rerender right
move_cursor_down(old_dialog.vertical_offset + y_diff)
width = (old_dialog.column + old_dialog.width) - (dialog.column + dialog.width)
start = visual_start + old_dialog.vertical_offset
line_num = old_dialog.contents.size
line_num.times do |i|
Reline::IOGate.move_cursor_column(old_dialog.column + dialog.width)
if visual_lines[start + i].nil?
s = ' ' * width
else
s = Reline::Unicode.take_range(visual_lines[start + i], old_dialog.column + dialog.width, width)
rerender_width = old_dialog.width - dialog.width
s = padding_space_with_escape_sequences(s, rerender_width)
end
Reline::IOGate.move_cursor_column(dialog.column + dialog.width)
@output.write "\e[0m#{s}\e[0m"
move_cursor_down(1) if i < (line_num - 1)
end
move_cursor_up(old_dialog.vertical_offset + line_num - 1 + y_diff)
end
Reline::IOGate.move_cursor_column((prompt_width + @cursor) % @screen_size.last)
end
private def clear_dialog
@dialogs.each do |dialog|
clear_each_dialog(dialog)
end
end
private def clear_dialog_with_content
@dialogs.each do |dialog|
clear_each_dialog(dialog)
private def clear_dialog(cursor_column)
changes = @dialogs.map do |dialog|
old_dialog = dialog.dup
dialog.contents = nil
[old_dialog, dialog]
end
render_dialog_changes(changes, cursor_column)
end
private def clear_dialog_with_trap_key(cursor_column)
clear_dialog(cursor_column)
@dialogs.each do |dialog|
dialog.trap_key = nil
end
end
private def clear_each_dialog(dialog)
dialog.trap_key = nil
return unless dialog.contents
prompt, prompt_width, prompt_list = check_multiline_prompt(dialog.lines_backup[:unmodified_lines])
visual_lines = []
visual_lines_under_dialog = []
visual_start = nil
dialog.lines_backup[:lines].each_with_index { |l, i|
pr = prompt_list ? prompt_list[i] : prompt
vl, _ = split_by_width(pr + l, @screen_size.last)
vl.compact!
if i == dialog.lines_backup[:line_index]
visual_start = visual_lines.size + dialog.lines_backup[:started_from] + dialog.vertical_offset
end
visual_lines.concat(vl)
}
visual_lines_under_dialog = visual_lines[visual_start, dialog.contents.size]
visual_lines_under_dialog = [] if visual_lines_under_dialog.nil?
Reline::IOGate.hide_cursor
move_cursor_down(dialog.vertical_offset)
dialog_vertical_size = dialog.contents.size
dialog_vertical_size.times do |i|
if i < visual_lines_under_dialog.size
Reline::IOGate.move_cursor_column(dialog.column)
str = Reline::Unicode.take_range(visual_lines_under_dialog[i], dialog.column, dialog.width)
str = padding_space_with_escape_sequences(str, dialog.width)
@output.write "\e[0m#{str}\e[0m"
else
Reline::IOGate.move_cursor_column(dialog.column)
@output.write "\e[0m#{' ' * dialog.width}\e[0m"
end
move_cursor_down(1) if i < (dialog_vertical_size - 1)
end
move_cursor_up(dialog_vertical_size - 1 + dialog.vertical_offset)
Reline::IOGate.move_cursor_column((prompt_width + @cursor) % @screen_size.last)
Reline::IOGate.show_cursor
end
private def calculate_scroll_partial_screen(highest_in_all, cursor_y)
if @screen_height < highest_in_all
old_scroll_partial_screen = @scroll_partial_screen
@ -1224,8 +1162,8 @@ class Reline::LineEditor
height
end
private def modify_lines(before)
return before if before.nil? || before.empty? || simplified_rendering?
private def modify_lines(before, force_recalc: false)
return before if !force_recalc && (before.nil? || before.empty? || simplified_rendering?)
if after = @output_modifier_proc&.call("#{before.join("\n")}\n", complete: finished?)
after.lines("\n").map { |l| l.chomp('') }

Просмотреть файл

@ -0,0 +1,13 @@
require_relative 'helper'
require 'reline/line_editor'
class Reline::LineEditor::Test < Reline::TestCase
def test_range_subtract
dummy_config = nil
editor = Reline::LineEditor.new(dummy_config, 'ascii-8bit')
base_ranges = [3...5, 4...10, 6...8, 12...15, 15...20]
subtract_ranges = [5...7, 8...9, 11...13, 17...18, 18...19]
expected_result = [3...5, 7...8, 9...10, 13...17, 19...20]
assert_equal expected_result, editor.send(:range_subtract, base_ranges, subtract_ranges)
end
end

Просмотреть файл

@ -1138,6 +1138,23 @@ begin
EOC
end
def test_rerender_multiple_dialog
start_terminal(20, 60, %W{ruby -I#{@pwd}/lib #{@pwd}/test/reline/yamatanooroti/multiline_repl --autocomplete --dialog simple}, startup_message: 'Multiline REPL.')
write("if\n abcdef\n 123456\n 456789\nend\C-p\C-p\C-p\C-p Str")
write("\t")
close
assert_screen(<<~'EOC')
Multiline REPL.
prompt> if String
prompt> aStringRuby is...
prompt> 1StructA dynamic, open source programming
prompt> 456789 language with a focus on simplicity
prompt> end and productivity. It has an elegant
syntax that is natural to read and
easy to write.
EOC
end
def test_autocomplete_long_with_scrollbar
start_terminal(20, 30, %W{ruby -I#{@pwd}/lib #{@pwd}/test/reline/yamatanooroti/multiline_repl --autocomplete-long}, startup_message: 'Multiline REPL.')
write('S')
@ -1343,11 +1360,11 @@ begin
prompt>
prompt>
prompt>
prompt> S
prompt> String
prompt> Struct
prompt> enSymbol
ScriptError
prompt> Symbol
prompt> enScriptError
SyntaxError
Signal
EOC
end