rich-ruby 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.
data/lib/rich/panel.rb ADDED
@@ -0,0 +1,311 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "box"
4
+ require_relative "style"
5
+ require_relative "segment"
6
+ require_relative "cells"
7
+ require_relative "text"
8
+
9
+ module Rich
10
+ # A bordered panel container for content
11
+ class Panel
12
+ # @return [Object] Content to display
13
+ attr_reader :content
14
+
15
+ # @return [String, nil] Panel title
16
+ attr_reader :title
17
+
18
+ # @return [String, nil] Panel subtitle
19
+ attr_reader :subtitle
20
+
21
+ # @return [Box] Box style
22
+ attr_reader :box
23
+
24
+ # @return [Style, nil] Border style
25
+ attr_reader :border_style
26
+
27
+ # @return [Style, nil] Title style
28
+ attr_reader :title_style
29
+
30
+ # @return [Style, nil] Subtitle style
31
+ attr_reader :subtitle_style
32
+
33
+ # @return [Boolean] Expand to fill width
34
+ attr_reader :expand
35
+
36
+ # @return [Integer] Padding inside panel
37
+ attr_reader :padding
38
+
39
+ # @return [Integer, nil] Fixed width
40
+ attr_reader :width
41
+
42
+ # @return [Symbol] Title alignment
43
+ attr_reader :title_align
44
+
45
+ def initialize(
46
+ content,
47
+ title: nil,
48
+ subtitle: nil,
49
+ box: Box::ROUNDED,
50
+ border_style: nil,
51
+ title_style: nil,
52
+ subtitle_style: nil,
53
+ expand: true,
54
+ padding: 1,
55
+ width: nil,
56
+ title_align: :center
57
+ )
58
+ @content = content
59
+ @title = title
60
+ @subtitle = subtitle
61
+ @box = box
62
+ @border_style = border_style.is_a?(String) ? Style.parse(border_style) : border_style
63
+ @title_style = title_style.is_a?(String) ? Style.parse(title_style) : title_style
64
+ @subtitle_style = subtitle_style.is_a?(String) ? Style.parse(subtitle_style) : subtitle_style
65
+ @expand = expand
66
+ @padding = padding
67
+ @width = width
68
+ @title_align = title_align
69
+ end
70
+
71
+ # Render panel to segments
72
+ # @param max_width [Integer] Maximum width
73
+ # @return [Array<Segment>]
74
+ def to_segments(max_width: 80)
75
+ segments = []
76
+ inner_width = calculate_inner_width(max_width)
77
+ content_width = inner_width - @padding * 2
78
+
79
+ # Render content to lines
80
+ content_lines = render_content(content_width)
81
+
82
+ # Top border with title
83
+ segments.concat(render_top_border(inner_width))
84
+ segments << Segment.new("\n")
85
+
86
+ # Padding top
87
+ @padding.times do
88
+ segments.concat(render_empty_row(inner_width))
89
+ segments << Segment.new("\n")
90
+ end
91
+
92
+ # Content rows
93
+ content_lines.each do |line|
94
+ segments.concat(render_content_row(line, inner_width))
95
+ segments << Segment.new("\n")
96
+ end
97
+
98
+ # Padding bottom
99
+ @padding.times do
100
+ segments.concat(render_empty_row(inner_width))
101
+ segments << Segment.new("\n")
102
+ end
103
+
104
+ # Bottom border with subtitle
105
+ segments.concat(render_bottom_border(inner_width))
106
+
107
+ segments
108
+ end
109
+
110
+ # Render panel to string with ANSI codes
111
+ # @param max_width [Integer] Maximum width
112
+ # @param color_system [Symbol] Color system
113
+ # @return [String]
114
+ def render(max_width: 80, color_system: ColorSystem::TRUECOLOR)
115
+ Segment.render(to_segments(max_width: max_width), color_system: color_system)
116
+ end
117
+
118
+ # Print panel to console
119
+ # @param console [Console] Console to print to
120
+ def print_to(console)
121
+ rendered = render(max_width: console.width, color_system: console.color_system)
122
+ console.write(rendered)
123
+ console.write("\n")
124
+ end
125
+
126
+ class << self
127
+ # Create a simple panel
128
+ # @param content [String] Content
129
+ # @param title [String, nil] Title
130
+ # @return [Panel]
131
+ def fit(content, title: nil)
132
+ new(content, title: title, expand: false)
133
+ end
134
+ end
135
+
136
+ private
137
+
138
+ def calculate_inner_width(max_width)
139
+ if @width
140
+ @width - 2 # Subtract borders
141
+ elsif @expand
142
+ max_width - 2
143
+ else
144
+ content_width = measure_content_width + @padding * 2
145
+ [content_width, max_width - 2].min
146
+ end
147
+ end
148
+
149
+ def measure_content_width
150
+ case @content
151
+ when String
152
+ Cells.cell_len(@content)
153
+ when Text
154
+ @content.cell_length
155
+ else
156
+ Cells.cell_len(@content.to_s)
157
+ end
158
+ end
159
+
160
+ def render_content(width)
161
+ case @content
162
+ when String
163
+ wrap_text(@content, width)
164
+ when Text
165
+ @content.wrap(width).map { |t| t.to_segments }
166
+ else
167
+ wrap_text(@content.to_s, width)
168
+ end
169
+ end
170
+
171
+ def wrap_text(text, width)
172
+ lines = []
173
+ text.split("\n").each do |line|
174
+ if Cells.cell_len(line) <= width
175
+ lines << [Segment.new(line)]
176
+ else
177
+ # Simple wrapping
178
+ current_line = +""
179
+ current_width = 0
180
+
181
+ line.each_char do |char|
182
+ char_width = Cells.char_width(char)
183
+ if current_width + char_width > width
184
+ lines << [Segment.new(current_line)]
185
+ current_line = +char
186
+ current_width = char_width
187
+ else
188
+ current_line << char
189
+ current_width += char_width
190
+ end
191
+ end
192
+
193
+ lines << [Segment.new(current_line)] unless current_line.empty?
194
+ end
195
+ end
196
+ lines
197
+ end
198
+
199
+ def render_top_border(inner_width)
200
+ segments = []
201
+
202
+ if @title
203
+ title_text = " #{@title} "
204
+ title_width = Cells.cell_len(title_text)
205
+
206
+ if title_width < inner_width
207
+ remaining = inner_width - title_width
208
+ case @title_align
209
+ when :left
210
+ left = 2
211
+ right = remaining - 2
212
+ when :right
213
+ left = remaining - 2
214
+ right = 2
215
+ else # :center
216
+ left = remaining / 2
217
+ right = remaining - left
218
+ end
219
+
220
+ segments << Segment.new(@box.top_left, style: @border_style)
221
+ segments << Segment.new(@box.horizontal * left, style: @border_style)
222
+ segments << Segment.new(title_text, style: @title_style || @border_style)
223
+ segments << Segment.new(@box.horizontal * right, style: @border_style)
224
+ segments << Segment.new(@box.top_right, style: @border_style)
225
+ else
226
+ segments << Segment.new(@box.top_edge(inner_width), style: @border_style)
227
+ end
228
+ else
229
+ segments << Segment.new(@box.top_left, style: @border_style)
230
+ segments << Segment.new(@box.horizontal * inner_width, style: @border_style)
231
+ segments << Segment.new(@box.top_right, style: @border_style)
232
+ end
233
+
234
+ segments
235
+ end
236
+
237
+ def render_bottom_border(inner_width)
238
+ segments = []
239
+
240
+ if @subtitle
241
+ sub_text = " #{@subtitle} "
242
+ sub_width = Cells.cell_len(sub_text)
243
+
244
+ if sub_width < inner_width
245
+ remaining = inner_width - sub_width
246
+ case @title_align
247
+ when :left
248
+ left = 2
249
+ right = remaining - 2
250
+ when :right
251
+ left = remaining - 2
252
+ right = 2
253
+ else # :center
254
+ left = remaining / 2
255
+ right = remaining - left
256
+ end
257
+
258
+ segments << Segment.new(@box.bottom_left, style: @border_style)
259
+ segments << Segment.new(@box.horizontal * left, style: @border_style)
260
+ segments << Segment.new(sub_text, style: @subtitle_style || @border_style)
261
+ segments << Segment.new(@box.horizontal * right, style: @border_style)
262
+ segments << Segment.new(@box.bottom_right, style: @border_style)
263
+ else
264
+ segments << Segment.new(@box.bottom_edge(inner_width), style: @border_style)
265
+ end
266
+ else
267
+ segments << Segment.new(@box.bottom_left, style: @border_style)
268
+ segments << Segment.new(@box.horizontal * inner_width, style: @border_style)
269
+ segments << Segment.new(@box.bottom_right, style: @border_style)
270
+ end
271
+
272
+ segments
273
+ end
274
+
275
+ def render_content_row(content_segments, inner_width)
276
+ segments = []
277
+
278
+ # Left border
279
+ segments << Segment.new(@box.vertical, style: @border_style)
280
+
281
+ # Left padding
282
+ segments << Segment.new(" " * @padding)
283
+
284
+ # Content
285
+ content_width = content_segments.sum(&:cell_length)
286
+ segments.concat(content_segments)
287
+
288
+ # Right padding (fill to width)
289
+ remaining = inner_width - @padding * 2 - content_width
290
+ segments << Segment.new(" " * [remaining, 0].max)
291
+
292
+ # Right padding
293
+ segments << Segment.new(" " * @padding)
294
+
295
+ # Right border
296
+ segments << Segment.new(@box.vertical, style: @border_style)
297
+
298
+ segments
299
+ end
300
+
301
+ def render_empty_row(inner_width)
302
+ segments = []
303
+
304
+ segments << Segment.new(@box.vertical, style: @border_style)
305
+ segments << Segment.new(" " * inner_width)
306
+ segments << Segment.new(@box.vertical, style: @border_style)
307
+
308
+ segments
309
+ end
310
+ end
311
+ end