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,175 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "stringio"
|
|
5
|
+
require "sorbet-runtime"
|
|
6
|
+
|
|
7
|
+
module Ratatat
|
|
8
|
+
# ANSI terminal backend for rendering buffer diffs.
|
|
9
|
+
# Optimizes output by batching cursor moves and style changes.
|
|
10
|
+
class AnsiBackend
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
# ANSI escape sequences
|
|
14
|
+
ESC = "\e["
|
|
15
|
+
RESET = "#{ESC}0m"
|
|
16
|
+
HIDE_CURSOR = "#{ESC}?25l"
|
|
17
|
+
SHOW_CURSOR = "#{ESC}?25h"
|
|
18
|
+
CLEAR_SCREEN = "#{ESC}2J"
|
|
19
|
+
MOVE_HOME = "#{ESC}H"
|
|
20
|
+
ALTERNATE_SCREEN = "#{ESC}?1049h"
|
|
21
|
+
MAIN_SCREEN = "#{ESC}?1049l"
|
|
22
|
+
|
|
23
|
+
sig { returns(IO) }
|
|
24
|
+
attr_reader :io
|
|
25
|
+
|
|
26
|
+
sig { params(io: IO).void }
|
|
27
|
+
def initialize(io: $stdout)
|
|
28
|
+
@io = io
|
|
29
|
+
@current_fg = T.let(nil, T.nilable(Color::AnyColor))
|
|
30
|
+
@current_bg = T.let(nil, T.nilable(Color::AnyColor))
|
|
31
|
+
@current_modifiers = T.let(Set.new, T::Set[Modifier])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Draw a list of updates from Buffer#diff
|
|
35
|
+
# updates: Array of [x, y, Cell]
|
|
36
|
+
sig { params(updates: T::Array[[Integer, Integer, Cell]]).void }
|
|
37
|
+
def draw(updates)
|
|
38
|
+
return if updates.empty?
|
|
39
|
+
|
|
40
|
+
output = StringIO.new
|
|
41
|
+
last_x = T.let(nil, T.nilable(Integer))
|
|
42
|
+
last_y = T.let(nil, T.nilable(Integer))
|
|
43
|
+
|
|
44
|
+
updates.each do |x, y, cell|
|
|
45
|
+
# OPTIMIZATION 1: Skip cursor move if adjacent to previous position
|
|
46
|
+
unless last_x && last_y && y == last_y && x == last_x + 1
|
|
47
|
+
output << move_to(x, y)
|
|
48
|
+
end
|
|
49
|
+
last_x = x
|
|
50
|
+
last_y = y
|
|
51
|
+
|
|
52
|
+
# OPTIMIZATION 2: Only emit style changes when needed
|
|
53
|
+
output << style_codes(cell)
|
|
54
|
+
|
|
55
|
+
# Write the character
|
|
56
|
+
output << cell.normalized_symbol
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Reset styles at end
|
|
60
|
+
output << RESET
|
|
61
|
+
@current_fg = nil
|
|
62
|
+
@current_bg = nil
|
|
63
|
+
@current_modifiers = Set.new
|
|
64
|
+
|
|
65
|
+
# Single write to terminal
|
|
66
|
+
@io.write(output.string)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Move cursor to position (ANSI is 1-indexed)
|
|
70
|
+
sig { params(x: Integer, y: Integer).returns(String) }
|
|
71
|
+
def move_to(x, y)
|
|
72
|
+
"#{ESC}#{y + 1};#{x + 1}H"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Enter alternate screen buffer
|
|
76
|
+
sig { void }
|
|
77
|
+
def enter_alternate_screen
|
|
78
|
+
@io.write(ALTERNATE_SCREEN)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Leave alternate screen buffer
|
|
82
|
+
sig { void }
|
|
83
|
+
def leave_alternate_screen
|
|
84
|
+
@io.write(MAIN_SCREEN)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Hide cursor
|
|
88
|
+
sig { void }
|
|
89
|
+
def hide_cursor
|
|
90
|
+
@io.write(HIDE_CURSOR)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Show cursor
|
|
94
|
+
sig { void }
|
|
95
|
+
def show_cursor
|
|
96
|
+
@io.write(SHOW_CURSOR)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Clear entire screen
|
|
100
|
+
sig { void }
|
|
101
|
+
def clear
|
|
102
|
+
@io.write("#{CLEAR_SCREEN}#{MOVE_HOME}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Flush output buffer
|
|
106
|
+
sig { void }
|
|
107
|
+
def flush
|
|
108
|
+
@io.flush
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Reset all styles
|
|
112
|
+
sig { void }
|
|
113
|
+
def reset_style
|
|
114
|
+
@io.write(RESET)
|
|
115
|
+
@current_fg = nil
|
|
116
|
+
@current_bg = nil
|
|
117
|
+
@current_modifiers = Set.new
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
# Generate style escape codes for a cell, optimized to only emit changes
|
|
123
|
+
sig { params(cell: Cell).returns(String) }
|
|
124
|
+
def style_codes(cell)
|
|
125
|
+
codes = T.let([], T::Array[T.any(String, Integer)])
|
|
126
|
+
|
|
127
|
+
# Handle modifiers
|
|
128
|
+
modifier_codes = modifier_diff(cell.modifiers)
|
|
129
|
+
codes.concat(modifier_codes) unless modifier_codes.empty?
|
|
130
|
+
|
|
131
|
+
# Handle foreground color
|
|
132
|
+
if cell.fg != @current_fg
|
|
133
|
+
codes << Color.fg_ansi(cell.fg)
|
|
134
|
+
@current_fg = cell.fg
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Handle background color
|
|
138
|
+
if cell.bg != @current_bg
|
|
139
|
+
codes << Color.bg_ansi(cell.bg)
|
|
140
|
+
@current_bg = cell.bg
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
return "" if codes.empty?
|
|
144
|
+
|
|
145
|
+
"#{ESC}#{codes.join(";")}m"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Calculate modifier changes and return ANSI codes
|
|
149
|
+
sig { params(new_modifiers: T::Set[Modifier]).returns(T::Array[Integer]) }
|
|
150
|
+
def modifier_diff(new_modifiers)
|
|
151
|
+
return [] if new_modifiers == @current_modifiers
|
|
152
|
+
|
|
153
|
+
codes = T.let([], T::Array[Integer])
|
|
154
|
+
|
|
155
|
+
# Find modifiers that were turned off
|
|
156
|
+
@current_modifiers.each do |mod|
|
|
157
|
+
unless new_modifiers.include?(mod)
|
|
158
|
+
code = mod.disable_code
|
|
159
|
+
codes << code if code
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Find modifiers that were turned on
|
|
164
|
+
new_modifiers.each do |mod|
|
|
165
|
+
unless @current_modifiers.include?(mod)
|
|
166
|
+
code = mod.enable_code
|
|
167
|
+
codes << code if code
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
@current_modifiers = new_modifiers
|
|
172
|
+
codes
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
data/lib/ratatat/app.rb
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module Ratatat
|
|
7
|
+
# Application base class. Subclass and override compose/handlers.
|
|
8
|
+
class App < Widget
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
BINDINGS = T.let([
|
|
12
|
+
Binding.new("tab", "focus_next", "Focus next", show: false),
|
|
13
|
+
Binding.new("shift_tab", "focus_previous", "Focus previous", show: false),
|
|
14
|
+
], T::Array[Binding])
|
|
15
|
+
|
|
16
|
+
sig { returns(T.nilable(Terminal)) }
|
|
17
|
+
attr_reader :terminal
|
|
18
|
+
|
|
19
|
+
sig { returns(T.nilable(Widget)) }
|
|
20
|
+
attr_reader :focused
|
|
21
|
+
|
|
22
|
+
sig { returns(StyleSheet) }
|
|
23
|
+
attr_reader :stylesheet
|
|
24
|
+
|
|
25
|
+
sig { params(id: T.nilable(String), classes: T::Array[String]).void }
|
|
26
|
+
def initialize(id: nil, classes: [])
|
|
27
|
+
super
|
|
28
|
+
@running = T.let(false, T::Boolean)
|
|
29
|
+
@message_queue = T.let([], T::Array[Message])
|
|
30
|
+
@terminal = T.let(nil, T.nilable(Terminal))
|
|
31
|
+
@input = T.let(nil, T.nilable(Input))
|
|
32
|
+
@focused = T.let(nil, T.nilable(Widget))
|
|
33
|
+
@timers = T.let({}, T::Hash[Integer, T::Hash[Symbol, T.untyped]])
|
|
34
|
+
@timer_id = T.let(0, Integer)
|
|
35
|
+
@deferred = T.let([], T::Array[T.proc.void])
|
|
36
|
+
@stylesheet = T.let(load_stylesheet, StyleSheet)
|
|
37
|
+
@workers = T.let({}, T::Hash[Symbol, Thread])
|
|
38
|
+
@worker_results = T.let([], T::Array[Worker::Done])
|
|
39
|
+
@worker_mutex = T.let(Mutex.new, Mutex)
|
|
40
|
+
@post_refresh_callbacks = T.let([], T::Array[T.proc.void])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
sig { returns(StyleSheet) }
|
|
46
|
+
def load_stylesheet
|
|
47
|
+
if self.class.const_defined?(:CSS, false)
|
|
48
|
+
css = self.class.const_get(:CSS, false)
|
|
49
|
+
CSSParser.parse(css)
|
|
50
|
+
elsif self.class.const_defined?(:CSS_PATH, false)
|
|
51
|
+
path = self.class.const_get(:CSS_PATH, false)
|
|
52
|
+
CSSParser.parse_file(path)
|
|
53
|
+
else
|
|
54
|
+
StyleSheet.new
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
public
|
|
59
|
+
|
|
60
|
+
sig { returns(T::Boolean) }
|
|
61
|
+
def running?
|
|
62
|
+
@running
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Post a message to the queue for later processing
|
|
66
|
+
sig { params(message: Message).void }
|
|
67
|
+
def post(message)
|
|
68
|
+
@message_queue << message
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Process all pending messages
|
|
72
|
+
sig { void }
|
|
73
|
+
def process_messages
|
|
74
|
+
while (msg = @message_queue.shift)
|
|
75
|
+
handle_message(msg)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Request application exit
|
|
80
|
+
sig { void }
|
|
81
|
+
def exit
|
|
82
|
+
post(Quit.new(sender: self))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Set focus to a widget (or nil to clear)
|
|
86
|
+
sig { params(widget: T.nilable(Widget)).void }
|
|
87
|
+
def set_focus(widget)
|
|
88
|
+
return if @focused == widget
|
|
89
|
+
|
|
90
|
+
old_focused = @focused
|
|
91
|
+
old_focused&.instance_variable_set(:@has_focus, false)
|
|
92
|
+
old_focused&.dispatch(Blur.new(sender: self))
|
|
93
|
+
|
|
94
|
+
@focused = widget
|
|
95
|
+
@focused&.instance_variable_set(:@has_focus, true)
|
|
96
|
+
@focused&.dispatch(Focus.new(sender: self))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Move focus to next focusable widget
|
|
100
|
+
sig { void }
|
|
101
|
+
def focus_next
|
|
102
|
+
widgets = focusable_widgets
|
|
103
|
+
return if widgets.empty?
|
|
104
|
+
|
|
105
|
+
if @focused.nil?
|
|
106
|
+
set_focus(widgets.first)
|
|
107
|
+
else
|
|
108
|
+
idx = widgets.index(@focused) || -1
|
|
109
|
+
next_idx = (idx + 1) % widgets.length
|
|
110
|
+
set_focus(widgets[next_idx])
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
alias action_focus_next focus_next
|
|
114
|
+
|
|
115
|
+
# Move focus to previous focusable widget
|
|
116
|
+
sig { void }
|
|
117
|
+
def focus_previous
|
|
118
|
+
widgets = focusable_widgets
|
|
119
|
+
return if widgets.empty?
|
|
120
|
+
|
|
121
|
+
if @focused.nil?
|
|
122
|
+
set_focus(widgets.last)
|
|
123
|
+
else
|
|
124
|
+
idx = widgets.index(@focused) || 0
|
|
125
|
+
prev_idx = (idx - 1) % widgets.length
|
|
126
|
+
set_focus(widgets[prev_idx])
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
alias action_focus_previous focus_previous
|
|
130
|
+
|
|
131
|
+
# Schedule a one-shot timer
|
|
132
|
+
sig { params(delay: Numeric, block: T.proc.void).returns(Integer) }
|
|
133
|
+
def set_timer(delay, &block)
|
|
134
|
+
id = next_timer_id
|
|
135
|
+
@timers[id] = { at: Time.now + delay, block: block, repeat: false }
|
|
136
|
+
id
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Schedule a repeating timer
|
|
140
|
+
sig { params(period: Numeric, block: T.proc.void).returns(Integer) }
|
|
141
|
+
def set_interval(period, &block)
|
|
142
|
+
id = next_timer_id
|
|
143
|
+
@timers[id] = { at: Time.now + period, block: block, repeat: true, period: period }
|
|
144
|
+
id
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Cancel a timer
|
|
148
|
+
sig { params(id: Integer).void }
|
|
149
|
+
def cancel_timer(id)
|
|
150
|
+
@timers.delete(id)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Schedule callback for next tick
|
|
154
|
+
sig { params(block: T.proc.void).void }
|
|
155
|
+
def call_later(&block)
|
|
156
|
+
@deferred << block
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Schedule callback for after next render
|
|
160
|
+
sig { params(block: T.proc.void).void }
|
|
161
|
+
def call_after_refresh(&block)
|
|
162
|
+
@post_refresh_callbacks << block
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Process timers and deferred callbacks
|
|
166
|
+
sig { void }
|
|
167
|
+
def process_timers
|
|
168
|
+
now = Time.now
|
|
169
|
+
|
|
170
|
+
# Process deferred first
|
|
171
|
+
pending = @deferred.dup
|
|
172
|
+
@deferred.clear
|
|
173
|
+
pending.each(&:call)
|
|
174
|
+
|
|
175
|
+
# Process timers
|
|
176
|
+
@timers.each do |id, timer|
|
|
177
|
+
next if timer[:at] > now
|
|
178
|
+
|
|
179
|
+
timer[:block].call
|
|
180
|
+
|
|
181
|
+
if timer[:repeat]
|
|
182
|
+
timer[:at] = now + timer[:period]
|
|
183
|
+
else
|
|
184
|
+
@timers.delete(id)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Run a block in a background thread
|
|
190
|
+
sig { params(name: Symbol, block: T.proc.returns(T.untyped)).void }
|
|
191
|
+
def run_worker(name, &block)
|
|
192
|
+
@workers[name] = Thread.new do
|
|
193
|
+
result = nil
|
|
194
|
+
error = nil
|
|
195
|
+
begin
|
|
196
|
+
result = block.call
|
|
197
|
+
rescue StandardError => e
|
|
198
|
+
error = e
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Only post result if not cancelled
|
|
202
|
+
@worker_mutex.synchronize do
|
|
203
|
+
if @workers.key?(name)
|
|
204
|
+
@worker_results << Worker::Done.new(sender: self, name: name, result: result, error: error)
|
|
205
|
+
@workers.delete(name)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Cancel a running worker
|
|
212
|
+
sig { params(name: Symbol).void }
|
|
213
|
+
def cancel_worker(name)
|
|
214
|
+
@worker_mutex.synchronize do
|
|
215
|
+
thread = @workers.delete(name)
|
|
216
|
+
thread&.kill
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Process completed workers and post their messages
|
|
221
|
+
sig { void }
|
|
222
|
+
def process_workers
|
|
223
|
+
results = @worker_mutex.synchronize do
|
|
224
|
+
pending = @worker_results.dup
|
|
225
|
+
@worker_results.clear
|
|
226
|
+
pending
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
results.each { |msg| post(msg) }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Main event loop
|
|
233
|
+
sig { params(poll_interval: Float).void }
|
|
234
|
+
def run(poll_interval: 0.05)
|
|
235
|
+
@terminal = Terminal.new
|
|
236
|
+
@input = Input.new
|
|
237
|
+
@running = true
|
|
238
|
+
|
|
239
|
+
# Handle Ctrl+C gracefully
|
|
240
|
+
old_sigint = trap("INT") { @running = false }
|
|
241
|
+
|
|
242
|
+
@terminal.enter
|
|
243
|
+
begin
|
|
244
|
+
# Initialize widget tree
|
|
245
|
+
send(:do_compose)
|
|
246
|
+
@children.each { |child| send(:trigger_mount, child) }
|
|
247
|
+
|
|
248
|
+
# Call on_mount for the app itself
|
|
249
|
+
on_mount if respond_to?(:on_mount)
|
|
250
|
+
|
|
251
|
+
while @running
|
|
252
|
+
poll_input
|
|
253
|
+
process_messages
|
|
254
|
+
process_timers
|
|
255
|
+
process_workers
|
|
256
|
+
render_frame
|
|
257
|
+
process_post_refresh
|
|
258
|
+
sleep(poll_interval)
|
|
259
|
+
end
|
|
260
|
+
ensure
|
|
261
|
+
@terminal.exit
|
|
262
|
+
trap("INT", old_sigint || "DEFAULT")
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
private
|
|
267
|
+
|
|
268
|
+
sig { returns(Integer) }
|
|
269
|
+
def next_timer_id
|
|
270
|
+
@timer_id += 1
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
sig { void }
|
|
274
|
+
def poll_input
|
|
275
|
+
return unless @input
|
|
276
|
+
|
|
277
|
+
event = @input.poll(0.01)
|
|
278
|
+
return unless event
|
|
279
|
+
|
|
280
|
+
post(Key.new(sender: self, key: event.key, modifiers: event.modifiers))
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
sig { void }
|
|
284
|
+
def render_frame
|
|
285
|
+
return unless @terminal
|
|
286
|
+
|
|
287
|
+
@terminal.draw do |buffer|
|
|
288
|
+
render(buffer)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
sig { void }
|
|
293
|
+
def process_post_refresh
|
|
294
|
+
pending = @post_refresh_callbacks.dup
|
|
295
|
+
@post_refresh_callbacks.clear
|
|
296
|
+
pending.each(&:call)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Override in subclass to render content
|
|
300
|
+
sig { params(buffer: Buffer).void }
|
|
301
|
+
def render(buffer)
|
|
302
|
+
width = @terminal&.width || 80
|
|
303
|
+
height = @terminal&.height || 24
|
|
304
|
+
@children.each do |child|
|
|
305
|
+
child.render(buffer, x: 0, y: 0, width: width, height: height) if child.respond_to?(:render)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
sig { params(message: Message).void }
|
|
310
|
+
def handle_message(message)
|
|
311
|
+
case message
|
|
312
|
+
when Quit
|
|
313
|
+
@running = false
|
|
314
|
+
else
|
|
315
|
+
# Dispatch to children, then self
|
|
316
|
+
dispatch_to_focused(message)
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
sig { params(message: Message).void }
|
|
321
|
+
def dispatch_to_focused(message)
|
|
322
|
+
@focused&.dispatch(message)
|
|
323
|
+
dispatch(message) unless message.stopped?
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Get all focusable widgets in tree order
|
|
327
|
+
sig { returns(T::Array[Widget]) }
|
|
328
|
+
def focusable_widgets
|
|
329
|
+
result = T.let([], T::Array[Widget])
|
|
330
|
+
collect_focusable(@children, result)
|
|
331
|
+
result
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
sig { params(widgets: T::Array[Widget], result: T::Array[Widget]).void }
|
|
335
|
+
def collect_focusable(widgets, result)
|
|
336
|
+
widgets.each do |widget|
|
|
337
|
+
result << widget if widget.can_focus?
|
|
338
|
+
collect_focusable(widget.children, result)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module Ratatat
|
|
7
|
+
# Represents a key binding that maps keys to actions.
|
|
8
|
+
class Binding
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { returns(String) }
|
|
12
|
+
attr_reader :key, :action, :description
|
|
13
|
+
|
|
14
|
+
sig { returns(T::Boolean) }
|
|
15
|
+
attr_reader :show, :priority
|
|
16
|
+
|
|
17
|
+
sig { params(key: String, action: String, description: String, show: T::Boolean, priority: T::Boolean).void }
|
|
18
|
+
def initialize(key, action, description, show: true, priority: false)
|
|
19
|
+
@key = key
|
|
20
|
+
@action = action
|
|
21
|
+
@description = description
|
|
22
|
+
@show = show
|
|
23
|
+
@priority = priority
|
|
24
|
+
@parsed_keys = T.let(parse_keys(key), T::Array[ParsedKey])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Check if this binding matches the given key and modifiers
|
|
28
|
+
sig { params(key: T.any(Symbol, String), modifiers: T::Set[Symbol]).returns(T::Boolean) }
|
|
29
|
+
def matches?(key, modifiers)
|
|
30
|
+
key_str = key.to_s
|
|
31
|
+
@parsed_keys.any? { |pk| pk.matches?(key_str, modifiers) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# A parsed key with optional modifiers
|
|
37
|
+
class ParsedKey
|
|
38
|
+
extend T::Sig
|
|
39
|
+
|
|
40
|
+
sig { returns(String) }
|
|
41
|
+
attr_reader :key
|
|
42
|
+
|
|
43
|
+
sig { returns(T::Set[Symbol]) }
|
|
44
|
+
attr_reader :modifiers
|
|
45
|
+
|
|
46
|
+
sig { params(key: String, modifiers: T::Set[Symbol]).void }
|
|
47
|
+
def initialize(key, modifiers)
|
|
48
|
+
@key = key
|
|
49
|
+
@modifiers = modifiers
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
sig { params(key: String, modifiers: T::Set[Symbol]).returns(T::Boolean) }
|
|
53
|
+
def matches?(key, modifiers)
|
|
54
|
+
@key == key && @modifiers == modifiers
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sig { params(key_spec: String).returns(T::Array[ParsedKey]) }
|
|
59
|
+
def parse_keys(key_spec)
|
|
60
|
+
key_spec.split(",").map { |k| parse_single_key(k.strip) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
sig { params(key_str: String).returns(ParsedKey) }
|
|
64
|
+
def parse_single_key(key_str)
|
|
65
|
+
parts = key_str.split("+")
|
|
66
|
+
if parts.length == 1
|
|
67
|
+
ParsedKey.new(parts[0], Set.new)
|
|
68
|
+
else
|
|
69
|
+
modifiers = parts[0..-2].map(&:to_sym).to_set
|
|
70
|
+
ParsedKey.new(T.must(parts.last), modifiers)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|