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,97 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Ratatat
|
|
5
|
+
# Scrolling log viewer widget
|
|
6
|
+
class Log < Widget
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
CAN_FOCUS = true
|
|
10
|
+
|
|
11
|
+
sig { returns(T::Array[String]) }
|
|
12
|
+
attr_reader :lines
|
|
13
|
+
|
|
14
|
+
sig { returns(T::Boolean) }
|
|
15
|
+
attr_accessor :auto_scroll
|
|
16
|
+
|
|
17
|
+
sig { returns(T.nilable(Integer)) }
|
|
18
|
+
attr_reader :max_lines
|
|
19
|
+
|
|
20
|
+
reactive :scroll_offset, default: 0, repaint: true
|
|
21
|
+
|
|
22
|
+
sig { params(max_lines: T.nilable(Integer), auto_scroll: T::Boolean, id: T.nilable(String), classes: T::Array[String]).void }
|
|
23
|
+
def initialize(max_lines: nil, auto_scroll: true, id: nil, classes: [])
|
|
24
|
+
super(id: id, classes: classes)
|
|
25
|
+
@lines = T.let([], T::Array[String])
|
|
26
|
+
@max_lines = max_lines
|
|
27
|
+
@auto_scroll = auto_scroll
|
|
28
|
+
@scroll_offset = 0
|
|
29
|
+
@view_height = T.let(5, Integer)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
sig { params(text: String).void }
|
|
33
|
+
def write(text)
|
|
34
|
+
@lines << text
|
|
35
|
+
@lines.shift if @max_lines && @lines.length > @max_lines
|
|
36
|
+
@scroll_offset = [0, @lines.length - @view_height].max if @auto_scroll
|
|
37
|
+
refresh
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
alias write_line write
|
|
41
|
+
|
|
42
|
+
sig { void }
|
|
43
|
+
def clear
|
|
44
|
+
@lines.clear
|
|
45
|
+
@scroll_offset = 0
|
|
46
|
+
refresh
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
sig { params(message: Key).void }
|
|
50
|
+
def on_key(message)
|
|
51
|
+
case message.key
|
|
52
|
+
when "up", "k"
|
|
53
|
+
scroll(-1)
|
|
54
|
+
message.stop
|
|
55
|
+
when "down", "j"
|
|
56
|
+
scroll(1)
|
|
57
|
+
message.stop
|
|
58
|
+
when "page_up"
|
|
59
|
+
scroll(-@view_height)
|
|
60
|
+
message.stop
|
|
61
|
+
when "page_down"
|
|
62
|
+
scroll(@view_height)
|
|
63
|
+
message.stop
|
|
64
|
+
when "home"
|
|
65
|
+
@scroll_offset = 0
|
|
66
|
+
@auto_scroll = false
|
|
67
|
+
message.stop
|
|
68
|
+
when "end"
|
|
69
|
+
@scroll_offset = [0, @lines.length - @view_height].max
|
|
70
|
+
@auto_scroll = true
|
|
71
|
+
message.stop
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
76
|
+
def render(buffer, x:, y:, width:, height:)
|
|
77
|
+
@view_height = height
|
|
78
|
+
|
|
79
|
+
# Recalculate scroll position for auto-scroll based on actual view height
|
|
80
|
+
@scroll_offset = [0, @lines.length - @view_height].max if @auto_scroll
|
|
81
|
+
|
|
82
|
+
visible_lines = @lines[@scroll_offset, height] || []
|
|
83
|
+
visible_lines.each_with_index do |line, i|
|
|
84
|
+
buffer.put_string(x, y + i, line[0, width] || "")
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
sig { params(delta: Integer).void }
|
|
91
|
+
def scroll(delta)
|
|
92
|
+
max_offset = [0, @lines.length - @view_height].max
|
|
93
|
+
@scroll_offset = (@scroll_offset + delta).clamp(0, max_offset)
|
|
94
|
+
@auto_scroll = false if delta < 0
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Ratatat
|
|
5
|
+
# Modal dialog widget
|
|
6
|
+
class Modal < Widget
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
CAN_FOCUS = true
|
|
10
|
+
|
|
11
|
+
# Emitted when modal is closed
|
|
12
|
+
class Closed < Message; end
|
|
13
|
+
|
|
14
|
+
# Emitted when a button is pressed
|
|
15
|
+
class ButtonPressed < Message
|
|
16
|
+
extend T::Sig
|
|
17
|
+
|
|
18
|
+
sig { returns(String) }
|
|
19
|
+
attr_reader :button
|
|
20
|
+
|
|
21
|
+
sig { returns(Integer) }
|
|
22
|
+
attr_reader :index
|
|
23
|
+
|
|
24
|
+
sig { params(sender: Widget, button: String, index: Integer).void }
|
|
25
|
+
def initialize(sender:, button:, index:)
|
|
26
|
+
super(sender: sender)
|
|
27
|
+
@button = button
|
|
28
|
+
@index = index
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
reactive :title, default: "", repaint: true
|
|
33
|
+
reactive :body, default: "", repaint: true
|
|
34
|
+
reactive :selected_button, default: 0, repaint: true
|
|
35
|
+
|
|
36
|
+
sig { returns(T::Array[String]) }
|
|
37
|
+
attr_reader :buttons
|
|
38
|
+
|
|
39
|
+
sig do
|
|
40
|
+
params(
|
|
41
|
+
title: String,
|
|
42
|
+
body: String,
|
|
43
|
+
buttons: T::Array[String],
|
|
44
|
+
id: T.nilable(String),
|
|
45
|
+
classes: T::Array[String]
|
|
46
|
+
).void
|
|
47
|
+
end
|
|
48
|
+
def initialize(title: "", body: "", buttons: [], id: nil, classes: [])
|
|
49
|
+
super(id: id, classes: classes)
|
|
50
|
+
@title = title
|
|
51
|
+
@body = body
|
|
52
|
+
@buttons = buttons
|
|
53
|
+
@selected_button = 0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
sig { params(message: Key).void }
|
|
57
|
+
def on_key(message)
|
|
58
|
+
case message.key
|
|
59
|
+
when "escape"
|
|
60
|
+
close
|
|
61
|
+
message.stop
|
|
62
|
+
when "enter"
|
|
63
|
+
press_button
|
|
64
|
+
message.stop
|
|
65
|
+
when "tab"
|
|
66
|
+
next_button
|
|
67
|
+
message.stop
|
|
68
|
+
when "shift_tab"
|
|
69
|
+
prev_button
|
|
70
|
+
message.stop
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
sig { void }
|
|
75
|
+
def close
|
|
76
|
+
parent&.dispatch(Closed.new(sender: self))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
80
|
+
def render(buffer, x:, y:, width:, height:)
|
|
81
|
+
# Draw border
|
|
82
|
+
draw_border(buffer, x, y, width, height)
|
|
83
|
+
|
|
84
|
+
# Draw title
|
|
85
|
+
title_x = x + (width - @title.length) / 2
|
|
86
|
+
buffer.put_string(title_x, y, @title) if title_x >= x
|
|
87
|
+
|
|
88
|
+
# Draw body
|
|
89
|
+
if height > 3
|
|
90
|
+
body_lines = @body.split("\n")
|
|
91
|
+
body_lines.each_with_index do |line, i|
|
|
92
|
+
break if i + 2 >= height - 1
|
|
93
|
+
|
|
94
|
+
buffer.put_string(x + 2, y + 2 + i, line[0, width - 4])
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Draw buttons at bottom
|
|
99
|
+
render_buttons(buffer, x, y + height - 2, width) if height > 2 && !@buttons.empty?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
105
|
+
def draw_border(buffer, x, y, width, height)
|
|
106
|
+
# Top
|
|
107
|
+
buffer.put_string(x, y, "┌" + "─" * (width - 2) + "┐")
|
|
108
|
+
|
|
109
|
+
# Sides
|
|
110
|
+
(1...height - 1).each do |i|
|
|
111
|
+
buffer.put_string(x, y + i, "│")
|
|
112
|
+
buffer.put_string(x + width - 1, y + i, "│")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Bottom
|
|
116
|
+
buffer.put_string(x, y + height - 1, "└" + "─" * (width - 2) + "┘")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer).void }
|
|
120
|
+
def render_buttons(buffer, x, y, width)
|
|
121
|
+
btn_strs = @buttons.each_with_index.map do |btn, i|
|
|
122
|
+
if i == @selected_button
|
|
123
|
+
"[ #{btn} ]"
|
|
124
|
+
else
|
|
125
|
+
" #{btn} "
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
total = btn_strs.join(" ").length
|
|
130
|
+
start_x = x + (width - total) / 2
|
|
131
|
+
|
|
132
|
+
current_x = start_x
|
|
133
|
+
btn_strs.each do |btn_str|
|
|
134
|
+
buffer.put_string(current_x, y, btn_str) if current_x >= x
|
|
135
|
+
current_x += btn_str.length + 1
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
sig { void }
|
|
140
|
+
def next_button
|
|
141
|
+
return if @buttons.empty?
|
|
142
|
+
|
|
143
|
+
@selected_button = (@selected_button + 1) % @buttons.length
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
sig { void }
|
|
147
|
+
def prev_button
|
|
148
|
+
return if @buttons.empty?
|
|
149
|
+
|
|
150
|
+
@selected_button = (@selected_button - 1) % @buttons.length
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
sig { void }
|
|
154
|
+
def press_button
|
|
155
|
+
return if @buttons.empty?
|
|
156
|
+
|
|
157
|
+
button = @buttons[@selected_button]
|
|
158
|
+
parent&.dispatch(ButtonPressed.new(sender: self, button: button || "", index: @selected_button)) if button
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Ratatat
|
|
5
|
+
# Progress bar widget
|
|
6
|
+
class ProgressBar < Widget
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
reactive :total, default: 100.0, repaint: true
|
|
10
|
+
reactive :completed, default: 0.0, repaint: true
|
|
11
|
+
|
|
12
|
+
sig { params(progress: T.nilable(Float), total: Float, completed: Float, id: T.nilable(String), classes: T::Array[String]).void }
|
|
13
|
+
def initialize(progress: nil, total: 100.0, completed: 0.0, id: nil, classes: [])
|
|
14
|
+
super(id: id, classes: classes)
|
|
15
|
+
@total = total
|
|
16
|
+
@completed = completed
|
|
17
|
+
self.progress = progress if progress
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
sig { returns(Float) }
|
|
21
|
+
def progress
|
|
22
|
+
return 0.0 if total <= 0
|
|
23
|
+
|
|
24
|
+
(completed / total).clamp(0.0, 1.0)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
sig { params(value: Float).void }
|
|
28
|
+
def progress=(value)
|
|
29
|
+
self.completed = (value.clamp(0.0, 1.0) * total)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
sig { params(amount: Float).void }
|
|
33
|
+
def advance(amount = 1.0)
|
|
34
|
+
self.completed = completed + amount
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def validate_completed(value)
|
|
38
|
+
value.clamp(0.0, total)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
42
|
+
def render(buffer, x:, y:, width:, height:)
|
|
43
|
+
filled = (progress * width).round
|
|
44
|
+
empty = width - filled
|
|
45
|
+
|
|
46
|
+
bar = "█" * filled + "░" * empty
|
|
47
|
+
buffer.put_string(x, y, bar)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Spinning indicator widget
|
|
52
|
+
class Spinner < Widget
|
|
53
|
+
extend T::Sig
|
|
54
|
+
|
|
55
|
+
FRAMES = T.let(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"].freeze, T::Array[String])
|
|
56
|
+
|
|
57
|
+
reactive :frame, default: 0, repaint: true
|
|
58
|
+
|
|
59
|
+
sig { params(id: T.nilable(String), classes: T::Array[String]).void }
|
|
60
|
+
def initialize(id: nil, classes: [])
|
|
61
|
+
super(id: id, classes: classes)
|
|
62
|
+
@frame = 0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
sig { returns(String) }
|
|
66
|
+
def current_frame
|
|
67
|
+
FRAMES[@frame % FRAMES.length] || FRAMES.first || ""
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
sig { void }
|
|
71
|
+
def advance
|
|
72
|
+
self.frame = (@frame + 1) % FRAMES.length
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
76
|
+
def render(buffer, x:, y:, width:, height:)
|
|
77
|
+
buffer.put_string(x, y, current_frame)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Ratatat
|
|
5
|
+
# Exclusive selection radio button group
|
|
6
|
+
class RadioSet < Widget
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
CAN_FOCUS = true
|
|
10
|
+
|
|
11
|
+
class Changed < Message
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
14
|
+
sig { returns(Integer) }
|
|
15
|
+
attr_reader :selected
|
|
16
|
+
|
|
17
|
+
sig { returns(String) }
|
|
18
|
+
attr_reader :value
|
|
19
|
+
|
|
20
|
+
sig { params(sender: Widget, selected: Integer, value: String).void }
|
|
21
|
+
def initialize(sender:, selected:, value:)
|
|
22
|
+
super(sender: sender)
|
|
23
|
+
@selected = selected
|
|
24
|
+
@value = value
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sig { returns(T::Array[String]) }
|
|
29
|
+
attr_reader :options
|
|
30
|
+
|
|
31
|
+
reactive :selected, default: 0, repaint: true
|
|
32
|
+
reactive :highlight, default: 0, repaint: true
|
|
33
|
+
|
|
34
|
+
sig { params(options: T::Array[String], selected: Integer, id: T.nilable(String), classes: T::Array[String]).void }
|
|
35
|
+
def initialize(options: [], selected: 0, id: nil, classes: [])
|
|
36
|
+
super(id: id, classes: classes)
|
|
37
|
+
@options = options
|
|
38
|
+
@selected = selected
|
|
39
|
+
@highlight = selected
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sig { returns(T.nilable(String)) }
|
|
43
|
+
def selected_option
|
|
44
|
+
@options[@selected]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
sig { params(message: Key).void }
|
|
48
|
+
def on_key(message)
|
|
49
|
+
case message.key
|
|
50
|
+
when "up", "k"
|
|
51
|
+
move_selection(-1)
|
|
52
|
+
message.stop
|
|
53
|
+
when "down", "j"
|
|
54
|
+
move_selection(1)
|
|
55
|
+
message.stop
|
|
56
|
+
when " ", "enter"
|
|
57
|
+
emit_changed
|
|
58
|
+
message.stop
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
63
|
+
def render(buffer, x:, y:, width:, height:)
|
|
64
|
+
@options.each_with_index do |option, i|
|
|
65
|
+
break if i >= height
|
|
66
|
+
|
|
67
|
+
indicator = i == @selected ? "(*)" : "( )"
|
|
68
|
+
prefix = i == @highlight ? "> " : " "
|
|
69
|
+
text = "#{prefix}#{indicator} #{option}"
|
|
70
|
+
buffer.put_string(x, y + i, text[0, width])
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
sig { params(delta: Integer).void }
|
|
77
|
+
def move_selection(delta)
|
|
78
|
+
return if @options.empty?
|
|
79
|
+
|
|
80
|
+
@selected = (@selected + delta) % @options.length
|
|
81
|
+
@highlight = @selected
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
sig { void }
|
|
85
|
+
def emit_changed
|
|
86
|
+
return if @options.empty?
|
|
87
|
+
|
|
88
|
+
parent&.dispatch(Changed.new(sender: self, selected: @selected, value: selected_option || ""))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Ratatat
|
|
5
|
+
# Scrollable viewport widget
|
|
6
|
+
class ScrollableContainer < Widget
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
CAN_FOCUS = true
|
|
10
|
+
|
|
11
|
+
sig { returns(Integer) }
|
|
12
|
+
attr_reader :virtual_width, :virtual_height
|
|
13
|
+
|
|
14
|
+
reactive :scroll_x, default: 0, repaint: true
|
|
15
|
+
reactive :scroll_y, default: 0, repaint: true
|
|
16
|
+
|
|
17
|
+
sig { params(virtual_width: Integer, virtual_height: Integer, id: T.nilable(String), classes: T::Array[String]).void }
|
|
18
|
+
def initialize(virtual_width: 80, virtual_height: 24, id: nil, classes: [])
|
|
19
|
+
super(id: id, classes: classes)
|
|
20
|
+
@virtual_width = virtual_width
|
|
21
|
+
@virtual_height = virtual_height
|
|
22
|
+
@scroll_x = 0
|
|
23
|
+
@scroll_y = 0
|
|
24
|
+
@viewport_width = T.let(80, Integer)
|
|
25
|
+
@viewport_height = T.let(24, Integer)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
sig { params(message: Key).void }
|
|
29
|
+
def on_key(message)
|
|
30
|
+
case message.key
|
|
31
|
+
when "up", "k"
|
|
32
|
+
scroll_vertical(-1)
|
|
33
|
+
message.stop
|
|
34
|
+
when "down", "j"
|
|
35
|
+
scroll_vertical(1)
|
|
36
|
+
message.stop
|
|
37
|
+
when "left", "h"
|
|
38
|
+
scroll_horizontal(-1)
|
|
39
|
+
message.stop
|
|
40
|
+
when "right", "l"
|
|
41
|
+
scroll_horizontal(1)
|
|
42
|
+
message.stop
|
|
43
|
+
when "page_up"
|
|
44
|
+
scroll_vertical(-@viewport_height)
|
|
45
|
+
message.stop
|
|
46
|
+
when "page_down"
|
|
47
|
+
scroll_vertical(@viewport_height)
|
|
48
|
+
message.stop
|
|
49
|
+
when "home"
|
|
50
|
+
@scroll_y = 0
|
|
51
|
+
message.stop
|
|
52
|
+
when "end"
|
|
53
|
+
@scroll_y = [@virtual_height - @viewport_height, 0].max
|
|
54
|
+
message.stop
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
59
|
+
def render(buffer, x:, y:, width:, height:)
|
|
60
|
+
@viewport_width = width
|
|
61
|
+
@viewport_height = height
|
|
62
|
+
|
|
63
|
+
# Render children with scroll offset
|
|
64
|
+
# Children should render to positions that account for scroll
|
|
65
|
+
children.each do |child|
|
|
66
|
+
next unless child.respond_to?(:render)
|
|
67
|
+
|
|
68
|
+
# For now, simple implementation: child renders at (x - scroll_x, y - scroll_y)
|
|
69
|
+
# A more sophisticated implementation would use a virtual buffer
|
|
70
|
+
child.render(buffer, x: x, y: y, width: width, height: height)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
sig { params(delta: Integer).void }
|
|
77
|
+
def scroll_vertical(delta)
|
|
78
|
+
max_scroll = [@virtual_height - @viewport_height, 0].max
|
|
79
|
+
@scroll_y = (@scroll_y + delta).clamp(0, max_scroll)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
sig { params(delta: Integer).void }
|
|
83
|
+
def scroll_horizontal(delta)
|
|
84
|
+
max_scroll = [@virtual_width - @viewport_width, 0].max
|
|
85
|
+
@scroll_x = (@scroll_x + delta).clamp(0, max_scroll)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Ratatat
|
|
5
|
+
# Dropdown-style select widget
|
|
6
|
+
class Select < Widget
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
CAN_FOCUS = true
|
|
10
|
+
|
|
11
|
+
# Emitted when selection changes
|
|
12
|
+
class Changed < Message
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
sig { returns(Integer) }
|
|
16
|
+
attr_reader :selected
|
|
17
|
+
|
|
18
|
+
sig { returns(String) }
|
|
19
|
+
attr_reader :value
|
|
20
|
+
|
|
21
|
+
sig { params(sender: Widget, selected: Integer, value: String).void }
|
|
22
|
+
def initialize(sender:, selected:, value:)
|
|
23
|
+
super(sender: sender)
|
|
24
|
+
@selected = selected
|
|
25
|
+
@value = value
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
reactive :selected, default: 0, repaint: true
|
|
30
|
+
|
|
31
|
+
sig { returns(T::Array[String]) }
|
|
32
|
+
attr_reader :options
|
|
33
|
+
|
|
34
|
+
sig do
|
|
35
|
+
params(
|
|
36
|
+
options: T::Array[String],
|
|
37
|
+
selected: Integer,
|
|
38
|
+
id: T.nilable(String),
|
|
39
|
+
classes: T::Array[String]
|
|
40
|
+
).void
|
|
41
|
+
end
|
|
42
|
+
def initialize(options: [], selected: 0, id: nil, classes: [])
|
|
43
|
+
super(id: id, classes: classes)
|
|
44
|
+
@options = options
|
|
45
|
+
@selected = selected
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
sig { returns(T.nilable(String)) }
|
|
49
|
+
def selected_option
|
|
50
|
+
@options[@selected]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
sig { params(message: Key).void }
|
|
54
|
+
def on_key(message)
|
|
55
|
+
case message.key
|
|
56
|
+
when "down", "j"
|
|
57
|
+
move_selection(1)
|
|
58
|
+
message.stop
|
|
59
|
+
when "up", "k"
|
|
60
|
+
move_selection(-1)
|
|
61
|
+
message.stop
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
66
|
+
def render(buffer, x:, y:, width:, height:)
|
|
67
|
+
text = "[#{selected_option || ""}]"
|
|
68
|
+
buffer.put_string(x, y, text[0, width])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
sig { params(delta: Integer).void }
|
|
74
|
+
def move_selection(delta)
|
|
75
|
+
return if @options.empty?
|
|
76
|
+
|
|
77
|
+
old = @selected
|
|
78
|
+
@selected = (@selected + delta) % @options.length
|
|
79
|
+
return if old == @selected
|
|
80
|
+
|
|
81
|
+
parent&.dispatch(Changed.new(sender: self, selected: @selected, value: selected_option || ""))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# List showing all options with selection marker
|
|
86
|
+
class SelectionList < Select
|
|
87
|
+
extend T::Sig
|
|
88
|
+
|
|
89
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
90
|
+
def render(buffer, x:, y:, width:, height:)
|
|
91
|
+
@options.each_with_index do |option, i|
|
|
92
|
+
break if i >= height
|
|
93
|
+
|
|
94
|
+
marker = i == @selected ? "> " : " "
|
|
95
|
+
text = "#{marker}#{option}"
|
|
96
|
+
buffer.put_string(x, y + i, text[0, width])
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Ratatat
|
|
5
|
+
# Inline sparkline chart widget
|
|
6
|
+
class Sparkline < Widget
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
# Block characters for different heights (8 levels)
|
|
10
|
+
BLOCKS = T.let([" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"].freeze, T::Array[String])
|
|
11
|
+
|
|
12
|
+
sig { returns(T::Array[Numeric]) }
|
|
13
|
+
attr_reader :data
|
|
14
|
+
|
|
15
|
+
sig { returns(T.nilable(Integer)) }
|
|
16
|
+
attr_reader :max_data_points
|
|
17
|
+
|
|
18
|
+
sig { params(data: T::Array[Numeric], max_data_points: T.nilable(Integer), id: T.nilable(String), classes: T::Array[String]).void }
|
|
19
|
+
def initialize(data: [], max_data_points: nil, id: nil, classes: [])
|
|
20
|
+
super(id: id, classes: classes)
|
|
21
|
+
@data = T.let(data.dup, T::Array[Numeric])
|
|
22
|
+
@max_data_points = max_data_points
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
sig { params(value: Numeric).void }
|
|
26
|
+
def push(value)
|
|
27
|
+
@data << value
|
|
28
|
+
@data.shift if @max_data_points && @data.length > @max_data_points
|
|
29
|
+
refresh
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
sig { void }
|
|
33
|
+
def clear
|
|
34
|
+
@data.clear
|
|
35
|
+
refresh
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
|
|
39
|
+
def render(buffer, x:, y:, width:, height:)
|
|
40
|
+
return if @data.empty?
|
|
41
|
+
|
|
42
|
+
min_val = @data.min || 0
|
|
43
|
+
max_val = @data.max || 0
|
|
44
|
+
range = max_val - min_val
|
|
45
|
+
|
|
46
|
+
@data.each_with_index do |value, i|
|
|
47
|
+
break if i >= width
|
|
48
|
+
|
|
49
|
+
# Normalize to 0-8 range
|
|
50
|
+
normalized = if range == 0
|
|
51
|
+
4 # Middle if all values same
|
|
52
|
+
else
|
|
53
|
+
((value - min_val) / range.to_f * 8).round
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
block = BLOCKS[normalized.clamp(0, 8)] || BLOCKS[0]
|
|
57
|
+
buffer.put_string(x + i, y, block || " ")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|