Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support key stroke in trap key #332

Merged
merged 10 commits into from
Sep 5, 2021
73 changes: 51 additions & 22 deletions lib/reline.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,23 @@ class ConfigEncodingConversionError < StandardError; end

Key = Struct.new('Key', :char, :combined_char, :with_meta) do
def match?(key)
(key.char.nil? or char.nil? or char == key.char) and
(key.combined_char.nil? or combined_char.nil? or combined_char == key.combined_char) and
(key.with_meta.nil? or with_meta.nil? or with_meta == key.with_meta)
if key.instance_of?(Reline::Key)
(key.char.nil? or char.nil? or char == key.char) and
(key.combined_char.nil? or combined_char.nil? or combined_char == key.combined_char) and
(key.with_meta.nil? or with_meta.nil? or with_meta == key.with_meta)
elsif key.is_a?(Integer) or key.is_a?(Symbol)
if not combined_char.nil? and combined_char == key
true
elsif combined_char.nil? and not char.nil? and char == key
true
else
false
end
else
false
end
end
alias_method :==, :match?
end
CursorPos = Struct.new(:x, :y)
DialogRenderInfo = Struct.new(:pos, :contents, :pointer, :bg_color, :width, :height, :scrollbar, keyword_init: true)
Expand Down Expand Up @@ -368,25 +381,9 @@ def readline(prompt = '', add_hist = false)
break
when :matching
if buffer.size == 1
begin
succ_c = nil
Timeout.timeout(keyseq_timeout / 1000.0) {
succ_c = Reline::IOGate.getc
}
rescue Timeout::Error # cancel matching only when first byte
block.([Reline::Key.new(c, c, false)])
break
else
if key_stroke.match_status(buffer.dup.push(succ_c)) == :unmatched
if c == "\e".ord
block.([Reline::Key.new(succ_c, succ_c | 0b10000000, true)])
else
block.([Reline::Key.new(c, c, false), Reline::Key.new(succ_c, succ_c, false)])
end
break
else
Reline::IOGate.ungetc(succ_c)
end
case read_2nd_character_of_key_sequence(keyseq_timeout, buffer, c, block)
when :break then break
when :next then next
end
end
when :unmatched
Expand All @@ -403,6 +400,38 @@ def readline(prompt = '', add_hist = false)
end
end

private def read_2nd_character_of_key_sequence(keyseq_timeout, buffer, c, block)
begin
succ_c = nil
Timeout.timeout(keyseq_timeout / 1000.0) {
succ_c = Reline::IOGate.getc
}
rescue Timeout::Error # cancel matching only when first byte
block.([Reline::Key.new(c, c, false)])
return :break
else
case key_stroke.match_status(buffer.dup.push(succ_c))
when :unmatched
if c == "\e".ord
block.([Reline::Key.new(succ_c, succ_c | 0b10000000, true)])
else
block.([Reline::Key.new(c, c, false), Reline::Key.new(succ_c, succ_c, false)])
end
return :break
when :matching
Reline::IOGate.ungetc(succ_c)
return :next
when :matched
buffer << succ_c
expanded = key_stroke.expand(buffer).map{ |expanded_c|
Reline::Key.new(expanded_c, expanded_c, false)
}
block.(expanded)
return :break
end
end
end

private def read_escaped_key(keyseq_timeout, c, block)
begin
escaped_c = nil
Expand Down
12 changes: 11 additions & 1 deletion lib/reline/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def initialize
@additional_key_bindings[:emacs] = {}
@additional_key_bindings[:vi_insert] = {}
@additional_key_bindings[:vi_command] = {}
@oneshot_key_bindings = {}
@skip_section = nil
@if_stack = nil
@editing_mode_label = :emacs
Expand All @@ -75,6 +76,7 @@ def reset
@additional_key_bindings.keys.each do |key|
@additional_key_bindings[key].clear
end
@oneshot_key_bindings.clear
reset_default_key_bindings
end

Expand Down Expand Up @@ -149,7 +151,15 @@ def read(file = nil)

def key_bindings
# override @key_actors[@editing_mode_label].default_key_bindings with @additional_key_bindings[@editing_mode_label]
@key_actors[@editing_mode_label].default_key_bindings.merge(@additional_key_bindings[@editing_mode_label])
@key_actors[@editing_mode_label].default_key_bindings.merge(@additional_key_bindings[@editing_mode_label]).merge(@oneshot_key_bindings)
end

def add_oneshot_key_binding(keystroke, target)
@oneshot_key_bindings[keystroke] = target
end

def reset_oneshot_key_bindings
@oneshot_key_bindings.clear
end

def add_default_key_binding_by_keymap(keymap, keystroke, target)
Expand Down
60 changes: 56 additions & 4 deletions lib/reline/key_stroke.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,59 @@
class Reline::KeyStroke
using Module.new {
refine Integer do
def ==(other)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we achieve this another way? Redefining == on integer (even in a refinement) will clear BOP_EQ for integers, making all comparisons slower.

ie. This optimization in MRI will no longer work https://github.com/ruby/ruby/blob/master/vm_insnhelper.c#L1986-L1987

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Terminal control software often spends more than 99% of its processing time displaying characters to the terminal. Do you have any performance measurement results that show that key processing is the bottleneck and should be improved?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry. The issue isn't in reline's processing, but that this will make Ruby's integer comparisons slower globally. All code will be slower, not just reline.

Here's a hacky demonstration benchmark on latest Ruby trunk:

jhawthorn@zergling:~/src/ruby (int_bop ) [ruby 3.1.0]
$ ruby -r 'benchmark' -e 'p Benchmark.realtime { 10_000_000.times { 1==1;1==1;1==1;1==1;1==1;1==1;1==1;1==1;1==1;1==1 } }'
1.1078672040021047

jhawthorn@zergling:~/src/ruby (int_bop ) [ruby 3.1.0]
$ ruby -r 'benchmark' -rreline -e 'p Benchmark.realtime { 10_000_000.times { 1==1;1==1;1==1;1==1;1==1;1==1;1==1;1==1;1==1;1==1 } }'
4.35402021300979

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will make Ruby's integer comparisons slower globally.

Thanks for the concise and clear explanation. I now understand the problem. It looks like binding.irb is going to cause serious problems in Rails applications.

Please wait a bit while we work on it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#347
I did it. I think this problem was pretty bad. I'm glad I could handle it. Thanks again.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change looks great! Thank you for looking into this.

if other.is_a?(Reline::Key)
if other.combined_char == "\e".ord
false
else
other.combined_char == self
end
else
super
end
end
end

refine Array do
def start_with?(other)
other.size <= size && other == self.take(other.size)
compressed_me = compress_meta_key
compressed_other = other.compress_meta_key
i = 0
loop do
my_c = compressed_me[i]
other_c = compressed_other[i]
other_is_last = (i + 1) == compressed_other.size
me_is_last = (i + 1) == compressed_me.size
if my_c != other_c
if other_c == "\e".ord and other_is_last and my_c.is_a?(Reline::Key) and my_c.with_meta
return true
else
return false
end
elsif other_is_last
return true
elsif me_is_last
return false
end
i += 1
end
end

def ==(other)
compressed_me = compress_meta_key
compressed_other = other.compress_meta_key
compressed_me.size == compressed_other.size and [compressed_me, compressed_other].transpose.all?{ |i| i[0] == i[1] }
end

def compress_meta_key
inject([]) { |result, key|
if result.size > 0 and result.last == "\e".ord
result[result.size - 1] = Reline::Key.new(key, key | 0b10000000, true)
else
result << key
end
result
}
end

def bytes
Expand All @@ -19,8 +70,8 @@ def match_status(input)
key_mapping.keys.select { |lhs|
lhs.start_with? input
}.tap { |it|
return :matched if it.size == 1 && (it.max_by(&:size)&.size&.== input.size)
return :matching if it.size == 1 && (it.max_by(&:size)&.size&.!= input.size)
return :matched if it.size == 1 && (it[0] == input)
return :matching if it.size == 1 && (it[0] != input)
return :matched if it.max_by(&:size)&.size&.< input.size
return :matching if it.size > 1
}
Expand All @@ -32,7 +83,8 @@ def match_status(input)
end

def expand(input)
lhs = key_mapping.keys.select { |item| input.start_with? item }.sort_by(&:size).reverse.first
input = input.compress_meta_key
lhs = key_mapping.keys.select { |item| input.start_with? item }.sort_by(&:size).last
return input unless lhs
rhs = key_mapping[lhs]

Expand Down
23 changes: 18 additions & 5 deletions lib/reline/line_editor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -550,8 +550,9 @@ class Dialog
attr_reader :name, :contents, :width
attr_accessor :scroll_top, :column, :vertical_offset, :lines_backup, :trap_key

def initialize(name, proc_scope)
def initialize(name, config, proc_scope)
@name = name
@config = config
@proc_scope = proc_scope
@width = nil
@scroll_top = 0
Expand All @@ -575,13 +576,25 @@ def contents=(contents)
def call(key)
@proc_scope.set_dialog(self)
@proc_scope.set_key(key)
@proc_scope.call
dialog_render_info = @proc_scope.call
if @trap_key
if @trap_key.any?{ |i| i.is_a?(Array) } # multiple trap
@trap_key.each do |t|
@config.add_oneshot_key_binding(t, @name)
end
elsif @trap_key.is_a?(Array)
@config.add_oneshot_key_binding(@trap_key, @name)
elsif @trap_key.is_a?(Integer) or @trap_key.is_a?(Reline::Key)
@config.add_oneshot_key_binding([@trap_key], @name)
end
end
dialog_render_info
end
end

def add_dialog_proc(name, p, context = nil)
return if @dialogs.any? { |d| d.name == name }
@dialogs << Dialog.new(name, DialogProcScope.new(self, @config, p, context))
@dialogs << Dialog.new(name, @config, DialogProcScope.new(self, @config, p, context))
end

DIALOG_HEIGHT = 20
Expand Down Expand Up @@ -1497,9 +1510,9 @@ def wrap_method_call(method_symbol, method_obj, key, with_operator = false)

def input_key(key)
@last_key = key
@config.reset_oneshot_key_bindings
@dialogs.each do |dialog|
# The dialog will intercept the key if trap_key is set.
if dialog.trap_key and dialog.trap_key.match?(key)
if key.char.instance_of?(Symbol) and key.char == dialog.name
return
end
end
Expand Down