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,93 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Ratatat
5
+ # Animated loading spinner widget
6
+ class Spinner < Widget
7
+ extend T::Sig
8
+
9
+ STYLES = T.let({
10
+ dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
11
+ line: ["-", "\\", "|", "/"],
12
+ blocks: ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"],
13
+ arrows: ["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"],
14
+ bounce: ["⠁", "⠂", "⠄", "⠂"],
15
+ pulse: ["◐", "◓", "◑", "◒"]
16
+ }.freeze, T::Hash[Symbol, T::Array[String]])
17
+
18
+ DEFAULT_FRAMES = T.let(STYLES[:dots] || [], T::Array[String])
19
+
20
+ sig { returns(T::Array[String]) }
21
+ attr_reader :frames
22
+
23
+ sig { returns(Float) }
24
+ attr_reader :speed
25
+
26
+ sig { returns(T.nilable(String)) }
27
+ attr_reader :text
28
+
29
+ reactive :frame_index, default: 0, repaint: true
30
+
31
+ sig { params(frames: T.nilable(T::Array[String]), style: T.nilable(Symbol), speed: Float, text: T.nilable(String), id: T.nilable(String), classes: T::Array[String]).void }
32
+ def initialize(frames: nil, style: nil, speed: 0.1, text: nil, id: nil, classes: [])
33
+ super(id: id, classes: classes)
34
+ @frames = T.let(
35
+ frames || (style ? (STYLES[style] || DEFAULT_FRAMES) : DEFAULT_FRAMES),
36
+ T::Array[String]
37
+ )
38
+ @speed = speed
39
+ @text = text
40
+ @frame_index = 0
41
+ @spinning = T.let(false, T::Boolean)
42
+ @timer_id = T.let(nil, T.nilable(Integer))
43
+ end
44
+
45
+ sig { returns(String) }
46
+ def current_frame
47
+ @frames[@frame_index] || @frames.first || " "
48
+ end
49
+
50
+ sig { void }
51
+ def advance
52
+ @frame_index = (@frame_index + 1) % @frames.length
53
+ end
54
+
55
+ sig { returns(T::Boolean) }
56
+ def spinning?
57
+ @spinning
58
+ end
59
+
60
+ sig { void }
61
+ def start
62
+ return if @spinning
63
+
64
+ @spinning = true
65
+ app_instance = app
66
+ return unless app_instance
67
+
68
+ @timer_id = app_instance.set_interval(@speed) { advance }
69
+ end
70
+
71
+ sig { void }
72
+ def stop
73
+ return unless @spinning
74
+
75
+ @spinning = false
76
+ app_instance = app
77
+ return unless app_instance || @timer_id.nil?
78
+
79
+ app_instance&.cancel_timer(T.must(@timer_id)) if @timer_id
80
+ @timer_id = nil
81
+ end
82
+
83
+ sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
84
+ def render(buffer, x:, y:, width:, height:)
85
+ output = if @text
86
+ "#{current_frame} #{@text}"
87
+ else
88
+ current_frame
89
+ end
90
+ buffer.put_string(x, y, output[0, width] || "")
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,23 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Ratatat
5
+ # Simple text display widget
6
+ class Static < Widget
7
+ extend T::Sig
8
+
9
+ reactive :text, default: "", repaint: true
10
+
11
+ sig { params(text: String, id: T.nilable(String), classes: T::Array[String], styles: T.nilable(T::Hash[Symbol, T.untyped])).void }
12
+ def initialize(text = "", id: nil, classes: [], styles: nil)
13
+ super(id: id, classes: classes, styles: styles)
14
+ @text = text
15
+ end
16
+
17
+ sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
18
+ def render(buffer, x:, y:, width:, height:)
19
+ display_text = text[0, width] || ""
20
+ buffer.put_string(x, y, display_text)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,114 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Ratatat
5
+ # Tabbed content container with switchable panes
6
+ class TabbedContent < Widget
7
+ extend T::Sig
8
+
9
+ CAN_FOCUS = true
10
+
11
+ class TabChanged < Message
12
+ extend T::Sig
13
+
14
+ sig { returns(Integer) }
15
+ attr_reader :index
16
+
17
+ sig { returns(String) }
18
+ attr_reader :label
19
+
20
+ sig { params(sender: Widget, index: Integer, label: String).void }
21
+ def initialize(sender:, index:, label:)
22
+ super(sender: sender)
23
+ @index = index
24
+ @label = label
25
+ end
26
+ end
27
+
28
+ sig { returns(T::Array[String]) }
29
+ attr_reader :labels
30
+
31
+ reactive :active_tab, default: 0, repaint: true
32
+
33
+ sig { params(id: T.nilable(String), classes: T::Array[String]).void }
34
+ def initialize(id: nil, classes: [])
35
+ super(id: id, classes: classes)
36
+ @labels = T.let([], T::Array[String])
37
+ @panes = T.let([], T::Array[Widget])
38
+ @active_tab = 0
39
+ end
40
+
41
+ sig { params(label: String, content: Widget).void }
42
+ def add_tab(label, content)
43
+ @labels << label
44
+ @panes << content
45
+ mount(content)
46
+ end
47
+
48
+ sig { returns(Integer) }
49
+ def tab_count
50
+ @labels.length
51
+ end
52
+
53
+ sig { returns(T.nilable(String)) }
54
+ def active_label
55
+ @labels[@active_tab]
56
+ end
57
+
58
+ sig { returns(T.nilable(Widget)) }
59
+ def active_pane
60
+ @panes[@active_tab]
61
+ end
62
+
63
+ sig { params(message: Key).void }
64
+ def on_key(message)
65
+ case message.key
66
+ when "left", "shift+tab"
67
+ switch_tab(-1)
68
+ message.stop
69
+ when "right", "tab"
70
+ switch_tab(1)
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
+ return if height < 2
78
+
79
+ # Render tab bar
80
+ render_tab_bar(buffer, x, y, width)
81
+
82
+ # Render active pane content
83
+ pane = active_pane
84
+ pane&.render(buffer, x: x, y: y + 1, width: width, height: height - 1) if pane.respond_to?(:render)
85
+ end
86
+
87
+ private
88
+
89
+ sig { params(delta: Integer).void }
90
+ def switch_tab(delta)
91
+ return if @labels.empty?
92
+
93
+ old_tab = @active_tab
94
+ @active_tab = (@active_tab + delta) % @labels.length
95
+ return if old_tab == @active_tab
96
+
97
+ parent&.dispatch(TabChanged.new(sender: self, index: @active_tab, label: active_label || ""))
98
+ end
99
+
100
+ sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer).void }
101
+ def render_tab_bar(buffer, x, y, width)
102
+ pos = x
103
+ @labels.each_with_index do |label, i|
104
+ break if pos >= x + width
105
+
106
+ prefix = i == @active_tab ? "[" : " "
107
+ suffix = i == @active_tab ? "]" : " "
108
+ text = "#{prefix}#{label}#{suffix}"
109
+ buffer.put_string(pos, y, text[0, width - (pos - x)])
110
+ pos += text.length + 1
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,183 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Ratatat
5
+ # Multi-line text input widget
6
+ class TextArea < Widget
7
+ extend T::Sig
8
+
9
+ CAN_FOCUS = true
10
+
11
+ class Changed < Message
12
+ extend T::Sig
13
+
14
+ sig { returns(String) }
15
+ attr_reader :value
16
+
17
+ sig { params(sender: Widget, value: String).void }
18
+ def initialize(sender:, value:)
19
+ super(sender: sender)
20
+ @value = value
21
+ end
22
+ end
23
+
24
+ reactive :value, default: "", repaint: true
25
+
26
+ sig { returns(Integer) }
27
+ attr_reader :cursor_row, :cursor_col
28
+
29
+ sig { params(value: String, id: T.nilable(String), classes: T::Array[String]).void }
30
+ def initialize(value: "", id: nil, classes: [])
31
+ super(id: id, classes: classes)
32
+ @value = value
33
+ @cursor_row = T.let(0, Integer)
34
+ @cursor_col = T.let(value.split("\n").first&.length || 0, Integer)
35
+ end
36
+
37
+ sig { returns(T::Array[String]) }
38
+ def lines
39
+ @value.split("\n", -1)
40
+ end
41
+
42
+ sig { params(message: Key).void }
43
+ def on_key(message)
44
+ case message.key
45
+ when "up"
46
+ move_cursor_vertical(-1)
47
+ message.stop
48
+ when "down"
49
+ move_cursor_vertical(1)
50
+ message.stop
51
+ when "left"
52
+ move_cursor_horizontal(-1)
53
+ message.stop
54
+ when "right"
55
+ move_cursor_horizontal(1)
56
+ message.stop
57
+ when "home"
58
+ @cursor_col = 0
59
+ message.stop
60
+ when "end"
61
+ @cursor_col = current_line.length
62
+ message.stop
63
+ when "enter"
64
+ insert_newline
65
+ message.stop
66
+ when "backspace"
67
+ delete_backward
68
+ message.stop
69
+ when "delete"
70
+ delete_forward
71
+ message.stop
72
+ else
73
+ insert_char(message.key) if message.key.length == 1
74
+ message.stop
75
+ end
76
+ end
77
+
78
+ sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
79
+ def render(buffer, x:, y:, width:, height:)
80
+ lines.each_with_index do |line, i|
81
+ break if i >= height
82
+
83
+ buffer.put_string(x, y + i, line[0, width] || "")
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ sig { returns(String) }
90
+ def current_line
91
+ lines[@cursor_row] || ""
92
+ end
93
+
94
+ sig { params(delta: Integer).void }
95
+ def move_cursor_vertical(delta)
96
+ new_row = (@cursor_row + delta).clamp(0, [lines.length - 1, 0].max)
97
+ @cursor_row = new_row
98
+ @cursor_col = [@cursor_col, current_line.length].min
99
+ end
100
+
101
+ sig { params(delta: Integer).void }
102
+ def move_cursor_horizontal(delta)
103
+ @cursor_col = (@cursor_col + delta).clamp(0, current_line.length)
104
+ end
105
+
106
+ sig { params(char: String).void }
107
+ def insert_char(char)
108
+ current_lines = lines
109
+ line = current_lines[@cursor_row] || ""
110
+ new_line = line.dup
111
+ new_line.insert(@cursor_col, char)
112
+ current_lines[@cursor_row] = new_line
113
+ @cursor_col += 1
114
+ self.value = current_lines.join("\n")
115
+ emit_changed
116
+ end
117
+
118
+ sig { void }
119
+ def insert_newline
120
+ current_lines = lines
121
+ line = current_lines[@cursor_row] || ""
122
+ before = line[0, @cursor_col] || ""
123
+ after = line[@cursor_col..] || ""
124
+ current_lines[@cursor_row] = before
125
+ current_lines.insert(@cursor_row + 1, after)
126
+ @cursor_row += 1
127
+ @cursor_col = 0
128
+ self.value = current_lines.join("\n")
129
+ emit_changed
130
+ end
131
+
132
+ sig { void }
133
+ def delete_backward
134
+ if @cursor_col > 0
135
+ current_lines = lines
136
+ line = current_lines[@cursor_row] || ""
137
+ new_line = line.dup
138
+ new_line.slice!(@cursor_col - 1)
139
+ current_lines[@cursor_row] = new_line
140
+ @cursor_col -= 1
141
+ self.value = current_lines.join("\n")
142
+ emit_changed
143
+ elsif @cursor_row > 0
144
+ # Merge with previous line
145
+ current_lines = lines
146
+ prev_line = current_lines[@cursor_row - 1] || ""
147
+ curr_line = current_lines[@cursor_row] || ""
148
+ @cursor_col = prev_line.length
149
+ current_lines[@cursor_row - 1] = prev_line + curr_line
150
+ current_lines.delete_at(@cursor_row)
151
+ @cursor_row -= 1
152
+ self.value = current_lines.join("\n")
153
+ emit_changed
154
+ end
155
+ end
156
+
157
+ sig { void }
158
+ def delete_forward
159
+ current_lines = lines
160
+ line = current_lines[@cursor_row] || ""
161
+
162
+ if @cursor_col < line.length
163
+ new_line = line.dup
164
+ new_line.slice!(@cursor_col)
165
+ current_lines[@cursor_row] = new_line
166
+ self.value = current_lines.join("\n")
167
+ emit_changed
168
+ elsif @cursor_row < current_lines.length - 1
169
+ # Merge with next line
170
+ next_line = current_lines[@cursor_row + 1] || ""
171
+ current_lines[@cursor_row] = line + next_line
172
+ current_lines.delete_at(@cursor_row + 1)
173
+ self.value = current_lines.join("\n")
174
+ emit_changed
175
+ end
176
+ end
177
+
178
+ sig { void }
179
+ def emit_changed
180
+ parent&.dispatch(Changed.new(sender: self, value: @value))
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,143 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Ratatat
5
+ # Single-line text input widget
6
+ class TextInput < Widget
7
+ extend T::Sig
8
+
9
+ CAN_FOCUS = true
10
+
11
+ # Emitted when value changes
12
+ class Changed < Message
13
+ extend T::Sig
14
+
15
+ sig { returns(String) }
16
+ attr_reader :value
17
+
18
+ sig { params(sender: Widget, value: String).void }
19
+ def initialize(sender:, value:)
20
+ super(sender: sender)
21
+ @value = value
22
+ end
23
+ end
24
+
25
+ # Emitted when Enter is pressed
26
+ class Submitted < Message
27
+ extend T::Sig
28
+
29
+ sig { returns(String) }
30
+ attr_reader :value
31
+
32
+ sig { params(sender: Widget, value: String).void }
33
+ def initialize(sender:, value:)
34
+ super(sender: sender)
35
+ @value = value
36
+ end
37
+ end
38
+
39
+ reactive :value, default: "", repaint: true
40
+ reactive :placeholder, default: "", repaint: true
41
+
42
+ sig { returns(Integer) }
43
+ attr_reader :cursor
44
+
45
+ sig do
46
+ params(
47
+ value: String,
48
+ placeholder: String,
49
+ id: T.nilable(String),
50
+ classes: T::Array[String]
51
+ ).void
52
+ end
53
+ def initialize(value: "", placeholder: "", id: nil, classes: [])
54
+ super(id: id, classes: classes)
55
+ @value = value
56
+ @placeholder = placeholder
57
+ @cursor = T.let(value.length, Integer)
58
+ end
59
+
60
+ sig { params(message: Key).void }
61
+ def on_key(message)
62
+ case message.key
63
+ when "backspace"
64
+ delete_backward
65
+ message.stop
66
+ when "delete"
67
+ delete_forward
68
+ message.stop
69
+ when "left"
70
+ move_cursor(-1)
71
+ message.stop
72
+ when "right"
73
+ move_cursor(1)
74
+ message.stop
75
+ when "home"
76
+ @cursor = 0
77
+ message.stop
78
+ when "end"
79
+ @cursor = value.length
80
+ message.stop
81
+ when "enter"
82
+ submit
83
+ message.stop
84
+ else
85
+ insert_char(message.key) if message.key.length == 1
86
+ message.stop
87
+ end
88
+ end
89
+
90
+ sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
91
+ def render(buffer, x:, y:, width:, height:)
92
+ display = value.empty? ? placeholder : value
93
+ buffer.put_string(x, y, display[0, width] || "")
94
+ end
95
+
96
+ private
97
+
98
+ sig { params(char: String).void }
99
+ def insert_char(char)
100
+ new_value = value.dup
101
+ new_value.insert(@cursor, char)
102
+ @cursor += 1
103
+ self.value = new_value
104
+ emit_changed
105
+ end
106
+
107
+ sig { void }
108
+ def delete_backward
109
+ return if @cursor == 0
110
+
111
+ new_value = value.dup
112
+ new_value.slice!(@cursor - 1)
113
+ @cursor -= 1
114
+ self.value = new_value
115
+ emit_changed
116
+ end
117
+
118
+ sig { void }
119
+ def delete_forward
120
+ return if @cursor >= value.length
121
+
122
+ new_value = value.dup
123
+ new_value.slice!(@cursor)
124
+ self.value = new_value
125
+ emit_changed
126
+ end
127
+
128
+ sig { params(delta: Integer).void }
129
+ def move_cursor(delta)
130
+ @cursor = (@cursor + delta).clamp(0, value.length)
131
+ end
132
+
133
+ sig { void }
134
+ def emit_changed
135
+ parent&.dispatch(Changed.new(sender: self, value: value))
136
+ end
137
+
138
+ sig { void }
139
+ def submit
140
+ parent&.dispatch(Submitted.new(sender: self, value: value))
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,55 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Ratatat
5
+ # Toast notification widget
6
+ class Toast < Widget
7
+ extend T::Sig
8
+
9
+ ICONS = T.let({
10
+ info: "ℹ",
11
+ success: "✓",
12
+ warning: "⚠",
13
+ error: "✗"
14
+ }.freeze, T::Hash[Symbol, String])
15
+
16
+ sig { returns(String) }
17
+ attr_reader :message
18
+
19
+ sig { returns(Symbol) }
20
+ attr_reader :severity
21
+
22
+ sig { returns(Float) }
23
+ attr_reader :duration
24
+
25
+ reactive :visible, default: true, repaint: true
26
+
27
+ sig { params(message: String, severity: Symbol, duration: Float, id: T.nilable(String), classes: T::Array[String]).void }
28
+ def initialize(message:, severity: :info, duration: 5.0, id: nil, classes: [])
29
+ super(id: id, classes: classes)
30
+ @message = message
31
+ @severity = severity
32
+ @duration = duration
33
+ @visible = true
34
+ end
35
+
36
+ sig { void }
37
+ def show
38
+ @visible = true
39
+ end
40
+
41
+ sig { void }
42
+ def hide
43
+ @visible = false
44
+ end
45
+
46
+ sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
47
+ def render(buffer, x:, y:, width:, height:)
48
+ return unless @visible
49
+
50
+ icon = ICONS[@severity] || ICONS[:info]
51
+ text = "#{icon} #{@message}"
52
+ buffer.put_string(x, y, text[0, width] || "")
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,66 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Ratatat
5
+ # Floating tooltip widget
6
+ class Tooltip < Widget
7
+ extend T::Sig
8
+
9
+ sig { returns(String) }
10
+ attr_reader :text
11
+
12
+ sig { returns(Integer) }
13
+ attr_reader :anchor_x, :anchor_y
14
+
15
+ reactive :visible, default: false, repaint: true
16
+
17
+ sig { params(text: String, anchor_x: Integer, anchor_y: Integer, id: T.nilable(String), classes: T::Array[String]).void }
18
+ def initialize(text:, anchor_x: 0, anchor_y: 0, id: nil, classes: [])
19
+ super(id: id, classes: classes)
20
+ @text = text
21
+ @anchor_x = anchor_x
22
+ @anchor_y = anchor_y
23
+ @visible = false
24
+ end
25
+
26
+ sig { void }
27
+ def show
28
+ @visible = true
29
+ end
30
+
31
+ sig { void }
32
+ def hide
33
+ @visible = false
34
+ end
35
+
36
+ sig { params(new_x: Integer, new_y: Integer).void }
37
+ def move_to(new_x, new_y)
38
+ @anchor_x = new_x
39
+ @anchor_y = new_y
40
+ refresh
41
+ end
42
+
43
+ sig { params(buffer: Buffer, x: Integer, y: Integer, width: Integer, height: Integer).void }
44
+ def render(buffer, x:, y:, width:, height:)
45
+ return unless @visible
46
+ return if height < 3
47
+
48
+ text_width = @text.length
49
+ box_width = [text_width + 4, width].min
50
+ box_height = 3
51
+
52
+ # Draw top border
53
+ top = "┌#{"─" * (box_width - 2)}┐"
54
+ buffer.put_string(x, y, top[0, width])
55
+
56
+ # Draw middle with text
57
+ padding = (box_width - 2 - text_width) / 2
58
+ middle = "│#{" " * padding}#{@text}#{" " * (box_width - 3 - padding - text_width)}│"
59
+ buffer.put_string(x, y + 1, middle[0, width])
60
+
61
+ # Draw bottom border
62
+ bottom = "└#{"─" * (box_width - 2)}┘"
63
+ buffer.put_string(x, y + 2, bottom[0, width])
64
+ end
65
+ end
66
+ end