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.
- checksums.yaml +7 -0
- data/README.md +201 -0
- data/examples/log_tailer.rb +460 -0
- data/lib/ratatat/ansi_backend.rb +175 -0
- data/lib/ratatat/app.rb +342 -0
- data/lib/ratatat/binding.rb +74 -0
- data/lib/ratatat/buffer.rb +238 -0
- data/lib/ratatat/cell.rb +166 -0
- data/lib/ratatat/color.rb +191 -0
- data/lib/ratatat/css_parser.rb +192 -0
- data/lib/ratatat/dom_query.rb +124 -0
- data/lib/ratatat/driver.rb +200 -0
- data/lib/ratatat/input.rb +208 -0
- data/lib/ratatat/message.rb +147 -0
- data/lib/ratatat/reactive.rb +79 -0
- data/lib/ratatat/styles.rb +293 -0
- data/lib/ratatat/terminal.rb +168 -0
- data/lib/ratatat/version.rb +3 -0
- data/lib/ratatat/widget.rb +337 -0
- data/lib/ratatat/widgets/button.rb +43 -0
- data/lib/ratatat/widgets/checkbox.rb +68 -0
- data/lib/ratatat/widgets/container.rb +50 -0
- data/lib/ratatat/widgets/data_table.rb +123 -0
- data/lib/ratatat/widgets/grid.rb +40 -0
- data/lib/ratatat/widgets/horizontal.rb +52 -0
- data/lib/ratatat/widgets/log.rb +97 -0
- data/lib/ratatat/widgets/modal.rb +161 -0
- data/lib/ratatat/widgets/progress_bar.rb +80 -0
- data/lib/ratatat/widgets/radio_set.rb +91 -0
- data/lib/ratatat/widgets/scrollable_container.rb +88 -0
- data/lib/ratatat/widgets/select.rb +100 -0
- data/lib/ratatat/widgets/sparkline.rb +61 -0
- data/lib/ratatat/widgets/spinner.rb +93 -0
- data/lib/ratatat/widgets/static.rb +23 -0
- data/lib/ratatat/widgets/tabbed_content.rb +114 -0
- data/lib/ratatat/widgets/text_area.rb +183 -0
- data/lib/ratatat/widgets/text_input.rb +143 -0
- data/lib/ratatat/widgets/toast.rb +55 -0
- data/lib/ratatat/widgets/tooltip.rb +66 -0
- data/lib/ratatat/widgets/tree.rb +172 -0
- data/lib/ratatat/widgets/vertical.rb +52 -0
- data/lib/ratatat.rb +51 -0
- 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
|