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,337 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
module Ratatat
|
|
7
|
+
class QueryError < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Base class for all widgets in the UI tree.
|
|
10
|
+
class Widget
|
|
11
|
+
extend T::Sig
|
|
12
|
+
extend Reactive
|
|
13
|
+
|
|
14
|
+
CAN_FOCUS = false
|
|
15
|
+
|
|
16
|
+
sig { returns(T.nilable(String)) }
|
|
17
|
+
attr_reader :id
|
|
18
|
+
|
|
19
|
+
sig { returns(T::Set[String]) }
|
|
20
|
+
attr_reader :classes
|
|
21
|
+
|
|
22
|
+
sig { returns(T.nilable(Widget)) }
|
|
23
|
+
attr_reader :parent
|
|
24
|
+
|
|
25
|
+
sig { returns(T::Array[Widget]) }
|
|
26
|
+
attr_reader :children
|
|
27
|
+
|
|
28
|
+
sig { returns(Styles) }
|
|
29
|
+
attr_reader :styles
|
|
30
|
+
|
|
31
|
+
sig { params(id: T.nilable(String), classes: T::Array[String], styles: T.nilable(T::Hash[Symbol, T.untyped]), kwargs: T.untyped).void }
|
|
32
|
+
def initialize(id: nil, classes: [], styles: nil, **kwargs)
|
|
33
|
+
@id = id
|
|
34
|
+
@classes = T.let(classes.to_set, T::Set[String])
|
|
35
|
+
@parent = T.let(nil, T.nilable(Widget))
|
|
36
|
+
@children = T.let([], T::Array[Widget])
|
|
37
|
+
@has_focus = T.let(false, T::Boolean)
|
|
38
|
+
@styles = T.let(styles ? Styles.new(**styles) : Styles.new, Styles)
|
|
39
|
+
|
|
40
|
+
# Initialize reactive properties from kwargs
|
|
41
|
+
kwargs.each do |key, value|
|
|
42
|
+
setter = :"#{key}="
|
|
43
|
+
send(setter, value) if respond_to?(setter)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Class management
|
|
48
|
+
sig { params(name: String).void }
|
|
49
|
+
def add_class(name)
|
|
50
|
+
@classes.add(name)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
sig { params(name: String).void }
|
|
54
|
+
def remove_class(name)
|
|
55
|
+
@classes.delete(name)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sig { params(name: String).void }
|
|
59
|
+
def toggle_class(name)
|
|
60
|
+
if @classes.include?(name)
|
|
61
|
+
@classes.delete(name)
|
|
62
|
+
else
|
|
63
|
+
@classes.add(name)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
sig { params(condition: T::Boolean, name: String).void }
|
|
68
|
+
def set_class(condition, name)
|
|
69
|
+
if condition
|
|
70
|
+
@classes.add(name)
|
|
71
|
+
else
|
|
72
|
+
@classes.delete(name)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
sig { params(name: String).returns(T::Boolean) }
|
|
77
|
+
def has_class?(name)
|
|
78
|
+
@classes.include?(name)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Reactive state for pseudo-classes
|
|
82
|
+
reactive :disabled, default: false, repaint: true
|
|
83
|
+
reactive :hover, default: false, repaint: true
|
|
84
|
+
|
|
85
|
+
# Trigger repaint (override in subclass or App)
|
|
86
|
+
sig { void }
|
|
87
|
+
def refresh
|
|
88
|
+
# Default: no-op, App overrides to trigger render
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Override to define children declaratively
|
|
92
|
+
sig { returns(T::Array[Widget]) }
|
|
93
|
+
def compose
|
|
94
|
+
[]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Rebuild children from compose
|
|
98
|
+
sig { void }
|
|
99
|
+
def recompose
|
|
100
|
+
# Remove existing children
|
|
101
|
+
@children.each { |c| c.instance_variable_set(:@parent, nil) }
|
|
102
|
+
@children.clear
|
|
103
|
+
|
|
104
|
+
# Reset compose flag and re-compose
|
|
105
|
+
@_composed = nil
|
|
106
|
+
do_compose
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Add children to this widget
|
|
110
|
+
sig { params(widgets: Widget).returns(T.self_type) }
|
|
111
|
+
def mount(*widgets)
|
|
112
|
+
widgets.each do |widget|
|
|
113
|
+
widget.instance_variable_set(:@parent, self)
|
|
114
|
+
@children << widget
|
|
115
|
+
trigger_mount(widget)
|
|
116
|
+
end
|
|
117
|
+
self
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Remove this widget from its parent
|
|
121
|
+
sig { void }
|
|
122
|
+
def remove
|
|
123
|
+
return unless @parent
|
|
124
|
+
|
|
125
|
+
@parent.children.delete(self)
|
|
126
|
+
@parent = nil
|
|
127
|
+
trigger_unmount(self)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Get ancestor chain (parent, grandparent, ...)
|
|
131
|
+
sig { returns(T::Array[Widget]) }
|
|
132
|
+
def ancestors
|
|
133
|
+
result = []
|
|
134
|
+
current = @parent
|
|
135
|
+
while current
|
|
136
|
+
result << current
|
|
137
|
+
current = current.parent
|
|
138
|
+
end
|
|
139
|
+
result
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Focus management
|
|
143
|
+
sig { returns(T::Boolean) }
|
|
144
|
+
def can_focus?
|
|
145
|
+
# Search class hierarchy for CAN_FOCUS
|
|
146
|
+
klass = T.let(self.class, T.nilable(T::Class[T.anything]))
|
|
147
|
+
while klass
|
|
148
|
+
return klass.const_get(:CAN_FOCUS, false) if klass.const_defined?(:CAN_FOCUS, false)
|
|
149
|
+
klass = klass.superclass
|
|
150
|
+
end
|
|
151
|
+
false
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
sig { returns(T::Boolean) }
|
|
155
|
+
def has_focus?
|
|
156
|
+
@has_focus
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Request focus for this widget
|
|
160
|
+
sig { void }
|
|
161
|
+
def focus
|
|
162
|
+
return unless can_focus?
|
|
163
|
+
app&.set_focus(self)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Remove focus from this widget
|
|
167
|
+
sig { void }
|
|
168
|
+
def blur
|
|
169
|
+
return unless @has_focus
|
|
170
|
+
app&.set_focus(nil)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Find the root App
|
|
174
|
+
sig { returns(T.nilable(App)) }
|
|
175
|
+
def app
|
|
176
|
+
current = T.let(self, T.nilable(Widget))
|
|
177
|
+
while current
|
|
178
|
+
return T.cast(current, App) if current.is_a?(App)
|
|
179
|
+
current = current.parent
|
|
180
|
+
end
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Dispatch a message to this widget and bubble up
|
|
185
|
+
sig { params(message: Message).void }
|
|
186
|
+
def dispatch(message)
|
|
187
|
+
# Check key bindings first
|
|
188
|
+
if message.is_a?(Key) && handle_binding(message)
|
|
189
|
+
message.stop
|
|
190
|
+
return
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
handler = handler_for(message)
|
|
194
|
+
send(handler, message) if handler && respond_to?(handler)
|
|
195
|
+
|
|
196
|
+
return if message.stopped? || !message.bubble
|
|
197
|
+
@parent&.dispatch(message)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Query descendants by selector, returns chainable DOMQuery
|
|
201
|
+
sig { params(selector: T.any(String, T::Class[Widget])).returns(DOMQuery) }
|
|
202
|
+
def query(selector)
|
|
203
|
+
results = T.let([], T::Array[Widget])
|
|
204
|
+
walk_descendants { |w| results << w if matches?(w, selector) }
|
|
205
|
+
DOMQuery.new(results)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
sig { params(selector: T.any(String, T::Class[Widget])).returns(T.nilable(Widget)) }
|
|
209
|
+
def query_one(selector)
|
|
210
|
+
query(selector).first
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
sig { params(selector: T.any(String, T::Class[Widget])).returns(Widget) }
|
|
214
|
+
def query_one!(selector)
|
|
215
|
+
query_one(selector) || raise(QueryError, "No widget matching: #{selector}")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private
|
|
219
|
+
|
|
220
|
+
sig { params(message: Message).returns(T.nilable(Symbol)) }
|
|
221
|
+
def handler_for(message)
|
|
222
|
+
# Key -> :on_key, Button::Pressed -> :on_button_pressed
|
|
223
|
+
parts = message.class.name&.split("::")
|
|
224
|
+
return nil unless parts
|
|
225
|
+
|
|
226
|
+
# Remove "Ratatat" prefix if present
|
|
227
|
+
parts.shift if parts.first == "Ratatat"
|
|
228
|
+
|
|
229
|
+
# If nested (e.g., Button::Pressed), use widget_message format
|
|
230
|
+
name = if parts.length >= 2
|
|
231
|
+
"#{parts[-2]}_#{parts[-1]}".downcase
|
|
232
|
+
else
|
|
233
|
+
parts.last&.downcase
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
return nil unless name
|
|
237
|
+
:"on_#{name}"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
sig { params(block: T.proc.params(widget: Widget).void).void }
|
|
241
|
+
def walk_descendants(&block)
|
|
242
|
+
@children.each do |child|
|
|
243
|
+
block.call(child)
|
|
244
|
+
child.send(:walk_descendants, &block)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
sig { params(widget: Widget, selector: T.any(String, T::Class[Widget])).returns(T::Boolean) }
|
|
249
|
+
def matches?(widget, selector)
|
|
250
|
+
case selector
|
|
251
|
+
when String
|
|
252
|
+
if selector.start_with?("#")
|
|
253
|
+
widget.id == selector[1..]
|
|
254
|
+
elsif selector.start_with?(".")
|
|
255
|
+
widget.classes.include?(selector[1..])
|
|
256
|
+
else
|
|
257
|
+
# Type selector - match class name
|
|
258
|
+
widget.class.name&.split("::")&.last == selector
|
|
259
|
+
end
|
|
260
|
+
when Class
|
|
261
|
+
widget.is_a?(selector)
|
|
262
|
+
else
|
|
263
|
+
false
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Check if this widget has BINDINGS and handle the key
|
|
268
|
+
sig { params(message: Key).returns(T::Boolean) }
|
|
269
|
+
def handle_binding(message)
|
|
270
|
+
bindings.each do |binding|
|
|
271
|
+
if binding.matches?(message.key, message.modifiers)
|
|
272
|
+
action_method = :"action_#{binding.action}"
|
|
273
|
+
if respond_to?(action_method)
|
|
274
|
+
send(action_method)
|
|
275
|
+
return true
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
false
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Get bindings for this widget class
|
|
283
|
+
sig { returns(T::Array[Binding]) }
|
|
284
|
+
def bindings
|
|
285
|
+
return [] unless self.class.const_defined?(:BINDINGS, false)
|
|
286
|
+
|
|
287
|
+
raw = self.class.const_get(:BINDINGS, false)
|
|
288
|
+
raw.map do |b|
|
|
289
|
+
case b
|
|
290
|
+
when Binding then b
|
|
291
|
+
when Array then Binding.new(b[0], b[1], b[2] || "")
|
|
292
|
+
else b
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
sig { params(widget: Widget).void }
|
|
298
|
+
def trigger_mount(widget)
|
|
299
|
+
# Only trigger if we're connected to an App
|
|
300
|
+
return unless widget.app
|
|
301
|
+
# Skip if already mounted
|
|
302
|
+
return if widget.instance_variable_get(:@_mounted)
|
|
303
|
+
|
|
304
|
+
widget.instance_variable_set(:@_mounted, true)
|
|
305
|
+
|
|
306
|
+
# Compose children first (only if not already composed)
|
|
307
|
+
widget.send(:do_compose) unless widget.instance_variable_get(:@_composed)
|
|
308
|
+
|
|
309
|
+
widget.on_mount if widget.respond_to?(:on_mount)
|
|
310
|
+
|
|
311
|
+
# Also trigger mount for any pre-existing children (mounted before joining app)
|
|
312
|
+
widget.children.each { |child| trigger_mount(child) }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Internal: run compose and mount results
|
|
316
|
+
sig { void }
|
|
317
|
+
def do_compose
|
|
318
|
+
return if @_composed
|
|
319
|
+
|
|
320
|
+
@_composed = T.let(true, T.nilable(T::Boolean))
|
|
321
|
+
children = compose
|
|
322
|
+
return if children.empty?
|
|
323
|
+
|
|
324
|
+
children.each do |child|
|
|
325
|
+
child.instance_variable_set(:@parent, self)
|
|
326
|
+
@children << child
|
|
327
|
+
trigger_mount(child) if app
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
sig { params(widget: Widget).void }
|
|
332
|
+
def trigger_unmount(widget)
|
|
333
|
+
widget.children.each { |child| trigger_unmount(child) }
|
|
334
|
+
widget.on_unmount if widget.respond_to?(:on_unmount)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Ratatat
|
|
5
|
+
# Clickable button widget
|
|
6
|
+
class Button < Widget
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
CAN_FOCUS = true
|
|
10
|
+
|
|
11
|
+
# Message emitted when button is activated
|
|
12
|
+
class Pressed < Message; end
|
|
13
|
+
|
|
14
|
+
reactive :label, default: "", repaint: true
|
|
15
|
+
|
|
16
|
+
sig { params(label: String, id: T.nilable(String), classes: T::Array[String]).void }
|
|
17
|
+
def initialize(label = "", id: nil, classes: [])
|
|
18
|
+
super(id: id, classes: classes)
|
|
19
|
+
@label = label
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
sig { params(message: Key).void }
|
|
23
|
+
def on_key(message)
|
|
24
|
+
if message.key == "enter" || message.key == " "
|
|
25
|
+
press
|
|
26
|
+
message.stop
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
sig { void }
|
|
31
|
+
def press
|
|
32
|
+
parent&.dispatch(Pressed.new(sender: self))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
36
|
+
def render(buffer, x:, y:, width:, height:)
|
|
37
|
+
display = "< #{label} >"
|
|
38
|
+
# Center the button text
|
|
39
|
+
padding = [(width - display.length) / 2, 0].max
|
|
40
|
+
buffer.put_string(x + padding, y, display[0, width])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Ratatat
|
|
5
|
+
# Checkbox toggle widget
|
|
6
|
+
class Checkbox < Widget
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
CAN_FOCUS = true
|
|
10
|
+
|
|
11
|
+
# Emitted when checked state changes
|
|
12
|
+
class Changed < Message
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
sig { returns(T::Boolean) }
|
|
16
|
+
attr_reader :checked
|
|
17
|
+
|
|
18
|
+
sig { params(sender: Widget, checked: T::Boolean).void }
|
|
19
|
+
def initialize(sender:, checked:)
|
|
20
|
+
super(sender: sender)
|
|
21
|
+
@checked = checked
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
reactive :checked, default: false, repaint: true
|
|
26
|
+
reactive :label, default: "", repaint: true
|
|
27
|
+
|
|
28
|
+
sig { params(label: String, checked: T::Boolean, id: T.nilable(String), classes: T::Array[String]).void }
|
|
29
|
+
def initialize(label = "", checked: false, id: nil, classes: [])
|
|
30
|
+
super(id: id, classes: classes)
|
|
31
|
+
@label = label
|
|
32
|
+
@checked = checked
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
sig { params(message: Key).void }
|
|
36
|
+
def on_key(message)
|
|
37
|
+
if message.key == " " || message.key == "enter"
|
|
38
|
+
toggle
|
|
39
|
+
message.stop
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
sig { void }
|
|
44
|
+
def toggle
|
|
45
|
+
self.checked = !checked
|
|
46
|
+
parent&.dispatch(Changed.new(sender: self, checked: checked))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
50
|
+
def render(buffer, x:, y:, width:, height:)
|
|
51
|
+
indicator = checked ? "[X]" : "[ ]"
|
|
52
|
+
text = "#{indicator} #{label}"
|
|
53
|
+
buffer.put_string(x, y, text[0, width])
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Switch toggle widget (alternative visual style)
|
|
58
|
+
class Switch < Checkbox
|
|
59
|
+
extend T::Sig
|
|
60
|
+
|
|
61
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
62
|
+
def render(buffer, x:, y:, width:, height:)
|
|
63
|
+
indicator = checked ? "[ON ]" : "[OFF]"
|
|
64
|
+
text = "#{indicator} #{label}"
|
|
65
|
+
buffer.put_string(x, y, text[0, width])
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Ratatat
|
|
5
|
+
# Generic container widget
|
|
6
|
+
class Container < Widget
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
10
|
+
def render(buffer, x:, y:, width:, height:)
|
|
11
|
+
# Default: render first child with full size
|
|
12
|
+
return if children.empty?
|
|
13
|
+
|
|
14
|
+
child = children.first
|
|
15
|
+
child.render(buffer, x: x, y: y, width: width, height: height) if child.respond_to?(:render)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Horizontal layout - children side by side
|
|
20
|
+
class Horizontal < Widget
|
|
21
|
+
extend T::Sig
|
|
22
|
+
|
|
23
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
24
|
+
def render(buffer, x:, y:, width:, height:)
|
|
25
|
+
return if children.empty?
|
|
26
|
+
|
|
27
|
+
child_width = width / children.length
|
|
28
|
+
children.each_with_index do |child, i|
|
|
29
|
+
child_x = x + (i * child_width)
|
|
30
|
+
child.render(buffer, x: child_x, y: y, width: child_width, height: height) if child.respond_to?(:render)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Vertical layout - children stacked
|
|
36
|
+
class Vertical < Widget
|
|
37
|
+
extend T::Sig
|
|
38
|
+
|
|
39
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
40
|
+
def render(buffer, x:, y:, width:, height:)
|
|
41
|
+
return if children.empty?
|
|
42
|
+
|
|
43
|
+
child_height = height / children.length
|
|
44
|
+
children.each_with_index do |child, i|
|
|
45
|
+
child_y = y + (i * child_height)
|
|
46
|
+
child.render(buffer, x: x, y: child_y, width: width, height: child_height) if child.respond_to?(:render)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Ratatat
|
|
5
|
+
# Tabular data display with navigation
|
|
6
|
+
class DataTable < Widget
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
CAN_FOCUS = true
|
|
10
|
+
|
|
11
|
+
# Emitted when a row is selected (Enter pressed)
|
|
12
|
+
class RowSelected < Message
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
sig { returns(Integer) }
|
|
16
|
+
attr_reader :index
|
|
17
|
+
|
|
18
|
+
sig { returns(T::Array[String]) }
|
|
19
|
+
attr_reader :row
|
|
20
|
+
|
|
21
|
+
sig { params(sender: Widget, index: Integer, row: T::Array[String]).void }
|
|
22
|
+
def initialize(sender:, index:, row:)
|
|
23
|
+
super(sender: sender)
|
|
24
|
+
@index = index
|
|
25
|
+
@row = row
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { returns(T::Array[String]) }
|
|
30
|
+
attr_reader :columns
|
|
31
|
+
|
|
32
|
+
sig { returns(T::Array[T::Array[String]]) }
|
|
33
|
+
attr_reader :rows
|
|
34
|
+
|
|
35
|
+
reactive :cursor_row, default: 0, repaint: true
|
|
36
|
+
|
|
37
|
+
sig do
|
|
38
|
+
params(
|
|
39
|
+
columns: T::Array[String],
|
|
40
|
+
rows: T::Array[T::Array[String]],
|
|
41
|
+
id: T.nilable(String),
|
|
42
|
+
classes: T::Array[String]
|
|
43
|
+
).void
|
|
44
|
+
end
|
|
45
|
+
def initialize(columns: [], rows: [], id: nil, classes: [])
|
|
46
|
+
super(id: id, classes: classes)
|
|
47
|
+
@columns = columns
|
|
48
|
+
@rows = T.let(rows.dup, T::Array[T::Array[String]])
|
|
49
|
+
@cursor_row = 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
sig { params(row: T::Array[String]).void }
|
|
53
|
+
def add_row(row)
|
|
54
|
+
@rows << row
|
|
55
|
+
refresh
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sig { void }
|
|
59
|
+
def clear_rows
|
|
60
|
+
@rows.clear
|
|
61
|
+
@cursor_row = 0
|
|
62
|
+
refresh
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
sig { params(message: Key).void }
|
|
66
|
+
def on_key(message)
|
|
67
|
+
case message.key
|
|
68
|
+
when "down", "j"
|
|
69
|
+
move_cursor(1)
|
|
70
|
+
message.stop
|
|
71
|
+
when "up", "k"
|
|
72
|
+
move_cursor(-1)
|
|
73
|
+
message.stop
|
|
74
|
+
when "enter"
|
|
75
|
+
select_row
|
|
76
|
+
message.stop
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
81
|
+
def render(buffer, x:, y:, width:, height:)
|
|
82
|
+
return if @columns.empty?
|
|
83
|
+
|
|
84
|
+
col_width = width / @columns.length
|
|
85
|
+
|
|
86
|
+
# Render header
|
|
87
|
+
@columns.each_with_index do |col, i|
|
|
88
|
+
buffer.put_string(x + (i * col_width), y, col[0, col_width])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Render rows
|
|
92
|
+
@rows.each_with_index do |row, row_idx|
|
|
93
|
+
row_y = y + 1 + row_idx
|
|
94
|
+
break if row_y >= y + height
|
|
95
|
+
|
|
96
|
+
row.each_with_index do |cell, col_idx|
|
|
97
|
+
break if col_idx >= @columns.length
|
|
98
|
+
|
|
99
|
+
prefix = row_idx == @cursor_row ? "> " : " "
|
|
100
|
+
text = col_idx == 0 ? "#{prefix}#{cell}" : cell
|
|
101
|
+
buffer.put_string(x + (col_idx * col_width), row_y, text[0, col_width])
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
sig { params(delta: Integer).void }
|
|
109
|
+
def move_cursor(delta)
|
|
110
|
+
return if @rows.empty?
|
|
111
|
+
|
|
112
|
+
@cursor_row = (@cursor_row + delta).clamp(0, @rows.length - 1)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
sig { void }
|
|
116
|
+
def select_row
|
|
117
|
+
return if @rows.empty? || @cursor_row >= @rows.length
|
|
118
|
+
|
|
119
|
+
row = @rows[@cursor_row]
|
|
120
|
+
parent&.dispatch(RowSelected.new(sender: self, index: @cursor_row, row: row || []))
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Ratatat
|
|
5
|
+
# Grid layout widget
|
|
6
|
+
class Grid < Widget
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
sig { returns(Integer) }
|
|
10
|
+
attr_reader :columns, :gap
|
|
11
|
+
|
|
12
|
+
sig { params(columns: Integer, gap: Integer, id: T.nilable(String), classes: T::Array[String]).void }
|
|
13
|
+
def initialize(columns: 2, gap: 0, id: nil, classes: [])
|
|
14
|
+
super(id: id, classes: classes)
|
|
15
|
+
@columns = columns
|
|
16
|
+
@gap = gap
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
20
|
+
def render(buffer, x:, y:, width:, height:)
|
|
21
|
+
return if children.empty?
|
|
22
|
+
|
|
23
|
+
col_width = (width - (@gap * (@columns - 1))) / @columns
|
|
24
|
+
rows = (children.length.to_f / @columns).ceil
|
|
25
|
+
row_height = rows > 0 ? (height - (@gap * (rows - 1))) / rows : 0
|
|
26
|
+
|
|
27
|
+
children.each_with_index do |child, i|
|
|
28
|
+
col = i % @columns
|
|
29
|
+
row = i / @columns
|
|
30
|
+
|
|
31
|
+
child_x = x + (col * (col_width + @gap))
|
|
32
|
+
child_y = y + (row * (row_height + @gap))
|
|
33
|
+
|
|
34
|
+
break if child_y >= y + height
|
|
35
|
+
|
|
36
|
+
child.render(buffer, x: child_x, y: child_y, width: col_width, height: row_height) if child.respond_to?(:render)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Ratatat
|
|
5
|
+
# Horizontal flexbox-like layout widget
|
|
6
|
+
class Horizontal < Widget
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
sig { returns(Integer) }
|
|
10
|
+
attr_reader :gap
|
|
11
|
+
|
|
12
|
+
sig { returns(T.nilable(T::Array[Float])) }
|
|
13
|
+
attr_reader :ratios
|
|
14
|
+
|
|
15
|
+
sig { params(gap: Integer, ratios: T.nilable(T::Array[Float]), id: T.nilable(String), classes: T::Array[String]).void }
|
|
16
|
+
def initialize(gap: 0, ratios: nil, id: nil, classes: [])
|
|
17
|
+
super(id: id, classes: classes)
|
|
18
|
+
@gap = gap
|
|
19
|
+
@ratios = ratios
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
23
|
+
def render(buffer, x:, y:, width:, height:)
|
|
24
|
+
return if children.empty?
|
|
25
|
+
|
|
26
|
+
total_gap = @gap * (children.length - 1)
|
|
27
|
+
available_width = width - total_gap
|
|
28
|
+
|
|
29
|
+
widths = calculate_widths(available_width)
|
|
30
|
+
current_x = x
|
|
31
|
+
|
|
32
|
+
children.each_with_index do |child, i|
|
|
33
|
+
child_width = widths[i] || 0
|
|
34
|
+
child.render(buffer, x: current_x, y: y, width: child_width, height: height) if child.respond_to?(:render)
|
|
35
|
+
current_x += child_width + @gap
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
sig { params(available_width: Integer).returns(T::Array[Integer]) }
|
|
42
|
+
def calculate_widths(available_width)
|
|
43
|
+
if @ratios && @ratios.length == children.length
|
|
44
|
+
@ratios.map { |r| (available_width * r).to_i }
|
|
45
|
+
else
|
|
46
|
+
# Equal distribution
|
|
47
|
+
child_width = available_width / children.length
|
|
48
|
+
Array.new(children.length, child_width)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|