require 'curses' module Dispel class Keyboard MAX_CHAR = 255 ENTER = 13 ESCAPE = 27 IS_18 = RUBY_VERSION =~ /^1\.8/ SEQUENCE_TIMEOUT = 0.005 NOTHING = (2**32 - 1) # getch returns this as 'nothing' on 1.8 but nil on 1.9.2 A_TO_Z = ('a'..'z').to_a DEFAULT_INPUT = lambda { Curses.getch } def self.input(&block) @input = block end def self.output(options={}, &block) @sequence = [] @started = Time.now.to_f timeout = options[:timeout] if timeout && SEQUENCE_TIMEOUT > timeout raise "Timeout must be higher then SEQUENCE_TIMEOUT (#{SEQUENCE_TIMEOUT})" end loop do @now = Time.now.to_f @elapsed = @now - @started key = fetch_user_input # finish previous sequence if @sequence.any? and @elapsed > SEQUENCE_TIMEOUT sequence_to_keys(@sequence).each(&block) @sequence = [] end if key # start new sequence @started = @now @sequence << key elsif timeout and @elapsed > timeout @started = @now yield :timeout else sleep SEQUENCE_TIMEOUT # nothing happening -> sleep a bit to save cpu end end end private def self.translate_key_to_code(key) case key # move when Curses::Key::UP then :up when Curses::Key::DOWN then :down when Curses::Key::RIGHT then :right when Curses::Key::LEFT then :left # code, unix, iTerm when 337, '^[1;2A', "^[A" then :"Shift+up" when 336, '^[1;2B', "^[B" then :"Shift+down" when 402, '^[1;2C' then :"Shift+right" when 393, '^[1;2D' then :"Shift+left" when 558, '^[1;3A' then :"Alt+up" when 517, '^[1;3B' then :"Alt+down" when 552, '^[1;3C' then :"Alt+right" when 537, '^[1;3D' then :"Alt+left" when 560, '^[1;5A' then :"Ctrl+up" when 519, '^[1;5B' then :"Ctrl+down" when 554, '^[1;5C' then :"Ctrl+right" when 539, '^[1;5D' then :"Ctrl+left" when 561, '^[1;6A' then :"Ctrl+Shift+up" when 520, '^[1;6B' then :"Ctrl+Shift+down" when 555, '^[1;6C', "^[C" then :"Ctrl+Shift+right" when 540, '^[1;6D', "^[D" then :"Ctrl+Shift+left" when 562, '^[1;7A' then :"Alt+Ctrl+up" when 521, '^[1;7B' then :"Alt+Ctrl+down" when 556, '^[1;7C' then :"Alt+Ctrl+right" when 541, '^[1;7D' then :"Alt+Ctrl+left" when '^[1;8A' then :"Alt+Ctrl+Shift+up" when '^[1;8B' then :"Alt+Ctrl+Shift+down" when '^[1;8C' then :"Alt+Ctrl+Shift+right" when '^[1;8D' then :"Alt+Ctrl+Shift+left" when '^[1;10A' then :"Alt+Shift+up" when '^[1;10B' then :"Alt+Shift+down" when '^[1;10C' then :"Alt+Shift+right" when '^[1;10D' then :"Alt+Shift+left" when '^[F' then :"Shift+end" when '^[H' then :"Shift+home" when '^[1;9F' then :"Alt+end" when '^[1;9H' then :"Alt+home" when '^[1;10F' then :"Alt+Shift+end" when '^[1;10H' then :"Alt+Shift+home" when '^[1;13F' then :"Alt+Ctrl+end" when '^[1;13H' then :"Alt+Ctrl+home" when '^[1;14F' then :"Alt+Ctrl+Shift+end" when '^[1;14H' then :"Alt+Ctrl+Shift+home" when 527 then :"Ctrl+Shift+end" when 532 then :"Ctrl+Shift+home" when Curses::KEY_END then :end when Curses::KEY_HOME then :home when Curses::KEY_NPAGE then :page_down when Curses::KEY_PPAGE then :page_up when Curses::KEY_IC then :insert when Curses::KEY_F0..Curses::KEY_F63 then :"F#{key - Curses::KEY_F0}" # modify when 9 then :tab when 353 then :"Shift+tab" when ENTER then :enter # shadows Ctrl+m when 263, 127 then :backspace when '^[3~', Curses::KEY_DC then :delete # misc when 0 then :"Ctrl+space" when 1..26 then :"Ctrl+#{A_TO_Z[key-1]}" when ESCAPE then :escape when Curses::KEY_RESIZE then :resize else if key.is_a? Integer key > MAX_CHAR ? key : key.chr elsif is_alt_key_code?(key) :"Alt+#{key.slice(1,1)}" else key end end end def self.fetch_user_input key = (@input || DEFAULT_INPUT).call || NOTHING key = key.ord unless IS_18 key if key < NOTHING end def self.bytes_to_string(bytes) bytes.pack('c*').gsub("\r","\n").force_encoding('utf-8') end # split a text so fast-typers do not get bugs like ^B^C in output def self.bytes_to_key_codes(bytes) result = [] multi_byte = [] append_multibyte = lambda{ unless multi_byte.empty? result << bytes_to_string(multi_byte) multi_byte = [] end } bytes.each do |byte| if multi_byte_part?(byte) multi_byte << byte else append_multibyte.call result << translate_key_to_code(byte) end end append_multibyte.call result end # not ascii and not control-char def self.multi_byte_part?(byte) 127 < byte and byte < 256 end # paste of multiple \n or \n in text would cause weird indentation def self.needs_paste_fix?(sequence) sequence.size > 1 and sequence.include?(ENTER) end def self.sequence_to_keys(sequence) if needs_paste_fix?(sequence) [bytes_to_string(sequence)] else # when connected via ssh escape sequences are used if escape_sequence?(sequence) stringified = bytes_to_string(sequence).sub(/\e+/,'^').sub('[[','[') [translate_key_to_code(stringified)] else bytes_to_key_codes(sequence) end end end def self.escape_sequence?(sequence) sequence[0] == ESCAPE and sequence.size.between?(2,7) end def self.is_alt_key_code?(sequence) sequence.slice(0,1) == "^" and sequence.size == 2 end end end