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,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
@@ -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