ratatat 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +201 -0
  3. data/examples/log_tailer.rb +460 -0
  4. data/lib/ratatat/ansi_backend.rb +175 -0
  5. data/lib/ratatat/app.rb +342 -0
  6. data/lib/ratatat/binding.rb +74 -0
  7. data/lib/ratatat/buffer.rb +238 -0
  8. data/lib/ratatat/cell.rb +166 -0
  9. data/lib/ratatat/color.rb +191 -0
  10. data/lib/ratatat/css_parser.rb +192 -0
  11. data/lib/ratatat/dom_query.rb +124 -0
  12. data/lib/ratatat/driver.rb +200 -0
  13. data/lib/ratatat/input.rb +208 -0
  14. data/lib/ratatat/message.rb +147 -0
  15. data/lib/ratatat/reactive.rb +79 -0
  16. data/lib/ratatat/styles.rb +293 -0
  17. data/lib/ratatat/terminal.rb +168 -0
  18. data/lib/ratatat/version.rb +3 -0
  19. data/lib/ratatat/widget.rb +337 -0
  20. data/lib/ratatat/widgets/button.rb +43 -0
  21. data/lib/ratatat/widgets/checkbox.rb +68 -0
  22. data/lib/ratatat/widgets/container.rb +50 -0
  23. data/lib/ratatat/widgets/data_table.rb +123 -0
  24. data/lib/ratatat/widgets/grid.rb +40 -0
  25. data/lib/ratatat/widgets/horizontal.rb +52 -0
  26. data/lib/ratatat/widgets/log.rb +97 -0
  27. data/lib/ratatat/widgets/modal.rb +161 -0
  28. data/lib/ratatat/widgets/progress_bar.rb +80 -0
  29. data/lib/ratatat/widgets/radio_set.rb +91 -0
  30. data/lib/ratatat/widgets/scrollable_container.rb +88 -0
  31. data/lib/ratatat/widgets/select.rb +100 -0
  32. data/lib/ratatat/widgets/sparkline.rb +61 -0
  33. data/lib/ratatat/widgets/spinner.rb +93 -0
  34. data/lib/ratatat/widgets/static.rb +23 -0
  35. data/lib/ratatat/widgets/tabbed_content.rb +114 -0
  36. data/lib/ratatat/widgets/text_area.rb +183 -0
  37. data/lib/ratatat/widgets/text_input.rb +143 -0
  38. data/lib/ratatat/widgets/toast.rb +55 -0
  39. data/lib/ratatat/widgets/tooltip.rb +66 -0
  40. data/lib/ratatat/widgets/tree.rb +172 -0
  41. data/lib/ratatat/widgets/vertical.rb +52 -0
  42. data/lib/ratatat.rb +51 -0
  43. metadata +142 -0
@@ -0,0 +1,192 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module Ratatat
7
+ # Parses CSS-like stylesheets into StyleSheet objects
8
+ module CSSParser
9
+ extend T::Sig
10
+
11
+ class ParseError < StandardError; end
12
+
13
+ sig { params(css: String).returns(StyleSheet) }
14
+ def self.parse(css)
15
+ sheet = StyleSheet.new
16
+ tokens = tokenize(css)
17
+ parse_rules(tokens, sheet)
18
+ sheet
19
+ end
20
+
21
+ sig { params(path: String).returns(StyleSheet) }
22
+ def self.parse_file(path)
23
+ parse(File.read(path))
24
+ end
25
+
26
+ class << self
27
+ extend T::Sig
28
+
29
+ private
30
+
31
+ sig { params(css: String).returns(T::Array[String]) }
32
+ def tokenize(css)
33
+ # Remove comments
34
+ css = css.gsub(%r{/\*.*?\*/}m, "")
35
+
36
+ # Split into tokens preserving structure
37
+ # Only treat : as separator inside blocks (for property:value)
38
+ tokens = []
39
+ current = ""
40
+ in_block = false
41
+
42
+ css.each_char do |char|
43
+ case char
44
+ when "{"
45
+ tokens << current.strip unless current.strip.empty?
46
+ tokens << char
47
+ current = ""
48
+ in_block = true
49
+ when "}"
50
+ tokens << current.strip unless current.strip.empty?
51
+ tokens << char
52
+ current = ""
53
+ in_block = false
54
+ when ";"
55
+ tokens << current.strip unless current.strip.empty?
56
+ tokens << char
57
+ current = ""
58
+ when ":"
59
+ if in_block
60
+ # Property:value separator
61
+ tokens << current.strip unless current.strip.empty?
62
+ tokens << char
63
+ current = ""
64
+ else
65
+ # Pseudo-class, keep as part of selector
66
+ current += char
67
+ end
68
+ when "\n", "\r"
69
+ tokens << current.strip unless current.strip.empty?
70
+ current = ""
71
+ else
72
+ current += char
73
+ end
74
+ end
75
+
76
+ tokens << current.strip unless current.strip.empty?
77
+ tokens.reject(&:empty?)
78
+ end
79
+
80
+ sig { params(tokens: T::Array[String], sheet: StyleSheet).void }
81
+ def parse_rules(tokens, sheet)
82
+ i = 0
83
+ while i < tokens.length
84
+ # Find selector
85
+ selector = tokens[i]
86
+ break unless selector
87
+ i += 1
88
+
89
+ # Expect {
90
+ break unless tokens[i] == "{"
91
+ i += 1
92
+
93
+ # Parse properties until }
94
+ props = {}
95
+ while i < tokens.length && tokens[i] != "}"
96
+ prop_name = tokens[i]
97
+ i += 1
98
+
99
+ break unless tokens[i] == ":"
100
+ i += 1
101
+
102
+ # Collect value tokens until ; or }
103
+ value_tokens = []
104
+ while i < tokens.length && tokens[i] != ";" && tokens[i] != "}"
105
+ value_tokens << tokens[i]
106
+ i += 1
107
+ end
108
+
109
+ # Skip ;
110
+ i += 1 if tokens[i] == ";"
111
+
112
+ prop_sym = prop_name.to_sym
113
+ value = parse_value(prop_sym, value_tokens.join(" ").strip)
114
+ props[prop_sym] = value
115
+ end
116
+
117
+ # Skip }
118
+ i += 1 if tokens[i] == "}"
119
+
120
+ sheet.add_rule(selector, **props) unless props.empty?
121
+ end
122
+ end
123
+
124
+ sig { params(prop: Symbol, value_str: String).returns(T.untyped) }
125
+ def parse_value(prop, value_str)
126
+ case prop
127
+ when :foreground, :background
128
+ parse_color(value_str)
129
+ when :width, :height, :min_width, :max_width, :min_height, :max_height
130
+ value_str.to_i
131
+ when :padding, :margin
132
+ parse_spacing(value_str)
133
+ when :bold, :italic, :underline
134
+ value_str == "true"
135
+ when :border, :text_align
136
+ value_str.to_sym
137
+ when :border_title
138
+ value_str
139
+ else
140
+ value_str
141
+ end
142
+ end
143
+
144
+ sig { params(value: String).returns(T.any(Symbol, Color::Rgb)) }
145
+ def parse_color(value)
146
+ if value.start_with?("#")
147
+ parse_hex_color(value)
148
+ elsif value.start_with?("rgb(")
149
+ parse_rgb_color(value)
150
+ else
151
+ value.to_sym
152
+ end
153
+ end
154
+
155
+ sig { params(hex: String).returns(Color::Rgb) }
156
+ def parse_hex_color(hex)
157
+ hex = hex.delete_prefix("#")
158
+ r = hex[0..1].to_i(16)
159
+ g = hex[2..3].to_i(16)
160
+ b = hex[4..5].to_i(16)
161
+ Color::Rgb.new(r: r, g: g, b: b)
162
+ end
163
+
164
+ sig { params(rgb: String).returns(Color::Rgb) }
165
+ def parse_rgb_color(rgb)
166
+ match = rgb.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/)
167
+ raise ParseError, "Invalid rgb color: #{rgb}" unless match
168
+
169
+ Color::Rgb.new(
170
+ r: match[1].to_i,
171
+ g: match[2].to_i,
172
+ b: match[3].to_i
173
+ )
174
+ end
175
+
176
+ sig { params(value: String).returns(T::Array[Integer]) }
177
+ def parse_spacing(value)
178
+ parts = value.split.map(&:to_i)
179
+ case parts.length
180
+ when 1
181
+ [parts[0], parts[0], parts[0], parts[0]]
182
+ when 2
183
+ [parts[0], parts[1], parts[0], parts[1]]
184
+ when 4
185
+ parts
186
+ else
187
+ raise ParseError, "Invalid spacing: #{value}"
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,124 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Ratatat
5
+ # Chainable query object for finding widgets in the tree
6
+ class DOMQuery
7
+ extend T::Sig
8
+ include Enumerable
9
+
10
+ sig { params(widgets: T::Array[Widget]).void }
11
+ def initialize(widgets)
12
+ @widgets = widgets
13
+ end
14
+
15
+ sig { params(selector: String).returns(DOMQuery) }
16
+ def filter(selector)
17
+ DOMQuery.new(@widgets.select { |w| matches?(w, selector) })
18
+ end
19
+
20
+ sig { params(selector: String).returns(DOMQuery) }
21
+ def exclude(selector)
22
+ DOMQuery.new(@widgets.reject { |w| matches?(w, selector) })
23
+ end
24
+
25
+ sig { returns(T.nilable(Widget)) }
26
+ def first
27
+ @widgets.first
28
+ end
29
+
30
+ sig { returns(T.nilable(Widget)) }
31
+ def last
32
+ @widgets.last
33
+ end
34
+
35
+ sig { returns(T::Array[Widget]) }
36
+ def to_a
37
+ @widgets.dup
38
+ end
39
+
40
+ sig { override.params(block: T.proc.params(arg0: Widget).void).returns(T.untyped) }
41
+ def each(&block)
42
+ @widgets.each(&block)
43
+ end
44
+
45
+ sig { returns(Integer) }
46
+ def count
47
+ @widgets.length
48
+ end
49
+
50
+ sig { returns(T::Boolean) }
51
+ def empty?
52
+ @widgets.empty?
53
+ end
54
+
55
+ # Bulk operations
56
+
57
+ # Add a class to all matched widgets
58
+ sig { params(name: String).returns(DOMQuery) }
59
+ def add_class(name)
60
+ @widgets.each { |w| w.add_class(name) }
61
+ self
62
+ end
63
+
64
+ # Remove a class from all matched widgets
65
+ sig { params(name: String).returns(DOMQuery) }
66
+ def remove_class(name)
67
+ @widgets.each { |w| w.remove_class(name) }
68
+ self
69
+ end
70
+
71
+ # Toggle a class on all matched widgets
72
+ sig { params(name: String).returns(DOMQuery) }
73
+ def toggle_class(name)
74
+ @widgets.each { |w| w.toggle_class(name) }
75
+ self
76
+ end
77
+
78
+ # Refresh all matched widgets
79
+ sig { returns(DOMQuery) }
80
+ def refresh
81
+ @widgets.each(&:refresh)
82
+ self
83
+ end
84
+
85
+ # Remove all matched widgets from their parents
86
+ sig { returns(DOMQuery) }
87
+ def remove
88
+ @widgets.each(&:remove)
89
+ self
90
+ end
91
+
92
+ # Focus the first matched widget
93
+ sig { returns(T.nilable(Widget)) }
94
+ def focus
95
+ first&.focus
96
+ first
97
+ end
98
+
99
+ # Set styles on all matched widgets
100
+ sig { params(props: T.untyped).returns(DOMQuery) }
101
+ def set_styles(**props)
102
+ @widgets.each do |w|
103
+ props.each do |key, value|
104
+ setter = :"#{key}="
105
+ w.styles.send(setter, value) if w.styles.respond_to?(setter)
106
+ end
107
+ end
108
+ self
109
+ end
110
+
111
+ private
112
+
113
+ sig { params(widget: Widget, selector: String).returns(T::Boolean) }
114
+ def matches?(widget, selector)
115
+ if selector.start_with?("#")
116
+ widget.id == selector[1..]
117
+ elsif selector.start_with?(".")
118
+ widget.classes.include?(selector[1..])
119
+ else
120
+ widget.class.name&.split("::")&.last == selector
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,200 @@
1
+ module Ratatat
2
+ module Driver
3
+ begin
4
+ require "ffi"
5
+ rescue LoadError
6
+ # FFI gem not available; we'll rely on Native or Null driver.
7
+ end
8
+
9
+ # Pure Ruby driver with cell-based diffing for flicker-free rendering.
10
+ # This is the recommended driver - no FFI dependencies required.
11
+ class Native
12
+ def initialize(io: $stdout, input_io: $stdin)
13
+ @terminal = Terminal.new(io: io)
14
+ @input = Input.new(io: input_io)
15
+ @render_callback = nil
16
+ end
17
+
18
+ def open
19
+ @terminal.enter
20
+ end
21
+
22
+ def close
23
+ @terminal.exit
24
+ end
25
+
26
+ # Render lines to terminal using cell-based diffing
27
+ # Accepts either:
28
+ # - Array of strings (legacy API)
29
+ # - Block that draws to buffer (new API)
30
+ def render(lines = nil, &block)
31
+ if block_given?
32
+ @terminal.draw(&block)
33
+ elsif lines
34
+ # Legacy API: convert string lines to buffer
35
+ @terminal.draw do |buffer|
36
+ lines.each_with_index do |line, y|
37
+ buffer.put_string(0, y, line)
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+ # Poll for keyboard event with timeout (milliseconds)
44
+ # Returns symbol (:quit, :up, :down, etc.) for compatibility
45
+ # or KeyEvent for new API
46
+ def poll_event(timeout_ms = 50)
47
+ event = @input.poll(timeout_ms / 1000.0)
48
+ return nil unless event
49
+
50
+ # Legacy API compatibility: convert to symbols
51
+ case event.key
52
+ when :up then :up
53
+ when :down then :down
54
+ when :left then :left
55
+ when :right then :right
56
+ when :escape, :q then :quit
57
+ when :c
58
+ event.ctrl? ? :quit : nil
59
+ when :f then :toggle_follow
60
+ else
61
+ # Return the KeyEvent for new code
62
+ event
63
+ end
64
+ end
65
+
66
+ # Get terminal size as [rows, cols]
67
+ def size
68
+ width, height = @terminal.size
69
+ [height, width] # Return as [rows, cols] for compatibility
70
+ end
71
+
72
+ # Access to underlying components for advanced usage
73
+ attr_reader :terminal, :input
74
+ end
75
+
76
+ # A fallback driver that dumps frames to STDOUT; used for specs and when the native
77
+ # shim is unavailable.
78
+ class Null
79
+ def initialize(io: $stdout)
80
+ @io = io
81
+ @rows, @cols = detect_size
82
+ end
83
+
84
+ def open; end
85
+ def close; end
86
+
87
+ def render(lines)
88
+ @io.puts("\e[2J") # clear
89
+ lines.each { |line| @io.puts(line) }
90
+ end
91
+
92
+ def poll_event(_timeout_ms = 16)
93
+ nil
94
+ end
95
+
96
+ def size
97
+ [@rows, @cols]
98
+ end
99
+
100
+ private
101
+
102
+ def detect_size
103
+ require "io/console"
104
+ rows, cols = IO.console.winsize
105
+ [rows || 24, cols || 80]
106
+ rescue LoadError, NoMethodError
107
+ [24, 80]
108
+ end
109
+ end
110
+
111
+ # Native driver backed by ratatui via the Rust cdylib.
112
+ if defined?(FFI)
113
+ class Ffi
114
+ extend FFI::Library
115
+
116
+ def self.find_lib
117
+ candidates = []
118
+ candidates << ENV["RATATAT_NATIVE_PATH"] if ENV["RATATAT_NATIVE_PATH"]
119
+ base = File.expand_path("../../native/ratatat-ffi/target/release", __dir__)
120
+ candidates << File.join(base, "libratatat_ffi.dylib")
121
+ candidates << File.join(base, "libratatat_ffi.so")
122
+ candidates << "libratatat_ffi"
123
+ candidates
124
+ end
125
+
126
+ begin
127
+ ffi_lib(find_lib)
128
+
129
+ class Context < FFI::Struct
130
+ layout :ptr, :pointer
131
+ end
132
+
133
+ class RtEvent < FFI::Struct
134
+ layout :kind, :int, :code, :uint, :modifiers, :uint
135
+ end
136
+
137
+ attach_function :rt_init, [], :pointer
138
+ attach_function :rt_shutdown, [:pointer], :void
139
+ attach_function :rt_render_lines, [:pointer, :pointer, :int], :void
140
+ attach_function :rt_poll_event, [:pointer, :uint, RtEvent.by_ref], :bool
141
+ attach_function :rt_size, [:pointer, :pointer, :pointer], :bool
142
+
143
+ def initialize
144
+ @ctx = self.class.rt_init
145
+ end
146
+
147
+ def open; end
148
+
149
+ def close
150
+ self.class.rt_shutdown(@ctx) if @ctx && !@ctx.null?
151
+ @ctx = nil
152
+ end
153
+
154
+ def render(lines)
155
+ joined = lines.join("\n") + "\0"
156
+ ptr = FFI::MemoryPointer.from_string(joined)
157
+ self.class.rt_render_lines(@ctx, ptr, lines.length)
158
+ end
159
+
160
+ def size
161
+ rows_ptr = FFI::MemoryPointer.new(:uint)
162
+ cols_ptr = FFI::MemoryPointer.new(:uint)
163
+ if self.class.rt_size(@ctx, cols_ptr, rows_ptr)
164
+ [rows_ptr.read_uint, cols_ptr.read_uint]
165
+ else
166
+ [nil, nil]
167
+ end
168
+ end
169
+
170
+ MOD_CTRL = 0x1
171
+
172
+ def poll_event(timeout_ms = 50)
173
+ evt = RtEvent.new
174
+ return nil unless self.class.rt_poll_event(@ctx, timeout_ms, evt)
175
+
176
+ if evt[:kind] == 1
177
+ code = evt[:code]
178
+ mods = evt[:modifiers]
179
+ if code == "q".ord || (code == "c".ord && (mods & MOD_CTRL) != 0)
180
+ return :quit
181
+ elsif code == "f".ord
182
+ return :toggle_follow
183
+ elsif code == 1001
184
+ return :up
185
+ elsif code == 1002
186
+ return :down
187
+ end
188
+ end
189
+ nil
190
+ end
191
+ rescue LoadError
192
+ # Native library missing; degrade to null driver.
193
+ def initialize
194
+ raise LoadError, "libratatat_ffi not built; run cargo build -p ratatat-ffi --release"
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,208 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module Ratatat
7
+ # Keyboard event representation
8
+ class KeyEvent < T::Struct
9
+ extend T::Sig
10
+
11
+ const :key, T.any(Symbol, String)
12
+ const :modifiers, T::Set[Symbol], default: Set.new
13
+
14
+ sig { returns(T::Boolean) }
15
+ def ctrl? = modifiers.include?(:ctrl)
16
+
17
+ sig { returns(T::Boolean) }
18
+ def alt? = modifiers.include?(:alt)
19
+
20
+ sig { returns(T::Boolean) }
21
+ def shift? = modifiers.include?(:shift)
22
+
23
+ sig { returns(String) }
24
+ def to_s
25
+ mods = modifiers.to_a.map(&:to_s).join("+")
26
+ mods.empty? ? key.to_s : "#{mods}+#{key}"
27
+ end
28
+ end
29
+
30
+ # Keyboard input handler with escape sequence parsing
31
+ class Input
32
+ extend T::Sig
33
+
34
+ # Escape sequence mappings
35
+ ESCAPE_SEQUENCES = T.let({
36
+ # Arrow keys
37
+ "\e[A" => :up,
38
+ "\e[B" => :down,
39
+ "\e[C" => :right,
40
+ "\e[D" => :left,
41
+
42
+ # Arrow keys (alternate)
43
+ "\eOA" => :up,
44
+ "\eOB" => :down,
45
+ "\eOC" => :right,
46
+ "\eOD" => :left,
47
+
48
+ # Home/End/Insert/Delete/PageUp/PageDown
49
+ "\e[H" => :home,
50
+ "\e[F" => :end,
51
+ "\e[2~" => :insert,
52
+ "\e[3~" => :delete,
53
+ "\e[5~" => :page_up,
54
+ "\e[6~" => :page_down,
55
+
56
+ # Home/End (alternate)
57
+ "\e[1~" => :home,
58
+ "\e[4~" => :end,
59
+ "\eOH" => :home,
60
+ "\eOF" => :end,
61
+
62
+ # Function keys F1-F12
63
+ "\eOP" => :f1,
64
+ "\eOQ" => :f2,
65
+ "\eOR" => :f3,
66
+ "\eOS" => :f4,
67
+ "\e[15~" => :f5,
68
+ "\e[17~" => :f6,
69
+ "\e[18~" => :f7,
70
+ "\e[19~" => :f8,
71
+ "\e[20~" => :f9,
72
+ "\e[21~" => :f10,
73
+ "\e[23~" => :f11,
74
+ "\e[24~" => :f12,
75
+
76
+ # Shift+Tab
77
+ "\e[Z" => :shift_tab
78
+ }.freeze, T::Hash[String, Symbol])
79
+
80
+ # Control character mappings
81
+ CONTROL_CHARS = T.let({
82
+ "\x00" => [:space, [:ctrl]],
83
+ "\x01" => [:a, [:ctrl]],
84
+ "\x02" => [:b, [:ctrl]],
85
+ "\x03" => [:c, [:ctrl]],
86
+ "\x04" => [:d, [:ctrl]],
87
+ "\x05" => [:e, [:ctrl]],
88
+ "\x06" => [:f, [:ctrl]],
89
+ "\x07" => [:g, [:ctrl]],
90
+ "\x08" => :backspace,
91
+ "\x09" => :tab,
92
+ "\x0A" => :enter,
93
+ "\x0B" => [:k, [:ctrl]],
94
+ "\x0C" => [:l, [:ctrl]],
95
+ "\x0D" => :enter,
96
+ "\x0E" => [:n, [:ctrl]],
97
+ "\x0F" => [:o, [:ctrl]],
98
+ "\x10" => [:p, [:ctrl]],
99
+ "\x11" => [:q, [:ctrl]],
100
+ "\x12" => [:r, [:ctrl]],
101
+ "\x13" => [:s, [:ctrl]],
102
+ "\x14" => [:t, [:ctrl]],
103
+ "\x15" => [:u, [:ctrl]],
104
+ "\x16" => [:v, [:ctrl]],
105
+ "\x17" => [:w, [:ctrl]],
106
+ "\x18" => [:x, [:ctrl]],
107
+ "\x19" => [:y, [:ctrl]],
108
+ "\x1A" => [:z, [:ctrl]],
109
+ "\x1B" => :escape,
110
+ "\x7F" => :backspace
111
+ }.freeze, T::Hash[String, T.any(Symbol, [Symbol, T::Array[Symbol]])])
112
+
113
+ sig { returns(IO) }
114
+ attr_reader :io
115
+
116
+ sig { params(io: IO).void }
117
+ def initialize(io: $stdin)
118
+ @io = io
119
+ end
120
+
121
+ # Poll for a key event with timeout (in seconds)
122
+ # Returns KeyEvent or nil if timeout
123
+ sig { params(timeout_sec: Float).returns(T.nilable(KeyEvent)) }
124
+ def poll(timeout_sec)
125
+ ready = IO.select([@io], nil, nil, timeout_sec)
126
+ return nil unless ready
127
+
128
+ read_key
129
+ end
130
+
131
+ # Read a key event (blocking)
132
+ sig { returns(T.nilable(KeyEvent)) }
133
+ def read_blocking
134
+ read_key
135
+ end
136
+
137
+ private
138
+
139
+ sig { returns(T.nilable(KeyEvent)) }
140
+ def read_key
141
+ char = read_char
142
+ return nil unless char
143
+
144
+ # Check for escape sequence
145
+ if char == "\e"
146
+ return parse_escape_sequence
147
+ end
148
+
149
+ # Check for control characters
150
+ if char.ord < 32 || char.ord == 127
151
+ return parse_control_char(char)
152
+ end
153
+
154
+ # Regular character
155
+ KeyEvent.new(key: char)
156
+ end
157
+
158
+ sig { returns(T.nilable(String)) }
159
+ def read_char
160
+ @io.read_nonblock(1)
161
+ rescue IO::WaitReadable, EOFError
162
+ nil
163
+ end
164
+
165
+ sig { params(timeout_sec: Float).returns(T.nilable(String)) }
166
+ def read_char_timeout(timeout_sec = 0.01)
167
+ ready = IO.select([@io], nil, nil, timeout_sec)
168
+ return nil unless ready
169
+
170
+ read_char
171
+ end
172
+
173
+ sig { returns(KeyEvent) }
174
+ def parse_escape_sequence
175
+ seq = +"\e"
176
+
177
+ 5.times do
178
+ char = read_char_timeout(0.005)
179
+ break unless char
180
+
181
+ seq << char
182
+
183
+ if (key = ESCAPE_SEQUENCES[seq])
184
+ return KeyEvent.new(key: key)
185
+ end
186
+
187
+ if seq.length == 2 && seq[1] != "[" && seq[1] != "O"
188
+ return KeyEvent.new(key: T.must(seq[1]), modifiers: Set[:alt])
189
+ end
190
+ end
191
+
192
+ KeyEvent.new(key: :escape)
193
+ end
194
+
195
+ sig { params(char: String).returns(KeyEvent) }
196
+ def parse_control_char(char)
197
+ mapping = CONTROL_CHARS[char]
198
+ return KeyEvent.new(key: :unknown) unless mapping
199
+
200
+ if mapping.is_a?(Array)
201
+ key, mods = mapping
202
+ KeyEvent.new(key: key, modifiers: mods.to_set)
203
+ else
204
+ KeyEvent.new(key: mapping)
205
+ end
206
+ end
207
+ end
208
+ end