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