rich-ruby 1.0.1 → 1.0.2

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 CHANGED
@@ -1,311 +1,318 @@
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
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
+ # @return [Symbol] Subtitle alignment
46
+ attr_reader :subtitle_align
47
+
48
+ def initialize(
49
+ content,
50
+ title: nil,
51
+ subtitle: nil,
52
+ box: Box::ROUNDED,
53
+ border_style: nil,
54
+ title_style: nil,
55
+ subtitle_style: nil,
56
+ expand: true,
57
+ padding: 1,
58
+ width: nil,
59
+ title_align: :center,
60
+ subtitle_align: :center
61
+ )
62
+ @content = content
63
+ @title = title
64
+ @subtitle = subtitle
65
+ @box = box
66
+ @border_style = border_style.is_a?(String) ? Style.parse(border_style) : border_style
67
+ @title_style = title_style.is_a?(String) ? Style.parse(title_style) : title_style
68
+ @subtitle_style = subtitle_style.is_a?(String) ? Style.parse(subtitle_style) : subtitle_style
69
+ @expand = expand
70
+ @padding = padding
71
+ @width = width
72
+ @title_align = title_align
73
+ @subtitle_align = subtitle_align
74
+ end
75
+
76
+ # Render panel to segments
77
+ # @param max_width [Integer] Maximum width
78
+ # @return [Array<Segment>]
79
+ def to_segments(max_width: 80)
80
+ segments = []
81
+ inner_width = calculate_inner_width(max_width)
82
+ content_width = [inner_width - @padding * 2, 0].max
83
+
84
+ # Render content to lines
85
+ content_lines = render_content(content_width)
86
+
87
+ # Top border with title
88
+ segments.concat(render_top_border(inner_width))
89
+ segments << Segment.new("\n")
90
+
91
+ # Padding top
92
+ @padding.times do
93
+ segments.concat(render_empty_row(inner_width))
94
+ segments << Segment.new("\n")
95
+ end
96
+
97
+ # Content rows
98
+ content_lines.each do |line|
99
+ segments.concat(render_content_row(line, inner_width))
100
+ segments << Segment.new("\n")
101
+ end
102
+
103
+ # Padding bottom
104
+ @padding.times do
105
+ segments.concat(render_empty_row(inner_width))
106
+ segments << Segment.new("\n")
107
+ end
108
+
109
+ # Bottom border with subtitle
110
+ segments.concat(render_bottom_border(inner_width))
111
+
112
+ segments
113
+ end
114
+
115
+ # Render panel to string with ANSI codes
116
+ # @param max_width [Integer] Maximum width
117
+ # @param color_system [Symbol] Color system
118
+ # @return [String]
119
+ def render(max_width: 80, color_system: ColorSystem::TRUECOLOR)
120
+ Segment.render(to_segments(max_width: max_width), color_system: color_system)
121
+ end
122
+
123
+ # Print panel to console
124
+ # @param console [Console] Console to print to
125
+ def print_to(console)
126
+ rendered = render(max_width: console.width, color_system: console.color_system)
127
+ console.write(rendered)
128
+ console.write("\n")
129
+ end
130
+
131
+ class << self
132
+ # Create a simple panel
133
+ # @param content [String] Content
134
+ # @param title [String, nil] Title
135
+ # @return [Panel]
136
+ def fit(content, title: nil)
137
+ new(content, title: title, expand: false)
138
+ end
139
+ end
140
+
141
+ private
142
+
143
+ def calculate_inner_width(max_width)
144
+ raw = if @width
145
+ @width - 2 # Subtract borders
146
+ elsif @expand
147
+ max_width - 2
148
+ else
149
+ content_width = measure_content_width + @padding * 2
150
+ [content_width, max_width - 2].min
151
+ end
152
+ # Never go negative: a width smaller than the two border columns would
153
+ # otherwise produce `String * -1` and crash.
154
+ [raw, 0].max
155
+ end
156
+
157
+ def measure_content_width
158
+ case @content
159
+ when String
160
+ Cells.cell_len(@content)
161
+ when Text
162
+ @content.cell_length
163
+ else
164
+ Cells.cell_len(@content.to_s)
165
+ end
166
+ end
167
+
168
+ def render_content(width)
169
+ case @content
170
+ when String
171
+ wrap_text(@content, width)
172
+ when Text
173
+ @content.wrap(width).map { |t| t.to_segments }
174
+ else
175
+ wrap_text(@content.to_s, width)
176
+ end
177
+ end
178
+
179
+ def wrap_text(text, width)
180
+ lines = []
181
+ text.split("\n").each do |line|
182
+ if Cells.cell_len(line) <= width
183
+ lines << [Segment.new(line)]
184
+ else
185
+ # Simple wrapping
186
+ current_line = +""
187
+ current_width = 0
188
+
189
+ line.each_char do |char|
190
+ char_width = Cells.char_width(char)
191
+ if current_width + char_width > width
192
+ lines << [Segment.new(current_line)]
193
+ current_line = +char
194
+ current_width = char_width
195
+ else
196
+ current_line << char
197
+ current_width += char_width
198
+ end
199
+ end
200
+
201
+ lines << [Segment.new(current_line)] unless current_line.empty?
202
+ end
203
+ end
204
+ lines
205
+ end
206
+
207
+ def render_top_border(inner_width)
208
+ render_label_edge(@title, @title_align, @box.top_left, @box.top_right,
209
+ @title_style, inner_width)
210
+ end
211
+
212
+ def render_bottom_border(inner_width)
213
+ render_label_edge(@subtitle, @subtitle_align, @box.bottom_left, @box.bottom_right,
214
+ @subtitle_style, inner_width)
215
+ end
216
+
217
+ # Build a horizontal edge that optionally embeds a centered/left/right
218
+ # label. The edge always spans exactly inner_width cells between the two
219
+ # corners, and every fill count is clamped non-negative so a label that is
220
+ # as wide as (or wider than) the panel can never crash or misalign.
221
+ def render_label_edge(label, align, left_corner, right_corner, label_style, inner_width)
222
+ segments = []
223
+
224
+ unless label
225
+ segments << Segment.new(left_corner, style: @border_style)
226
+ segments << Segment.new(@box.horizontal * inner_width, style: @border_style)
227
+ segments << Segment.new(right_corner, style: @border_style)
228
+ return segments
229
+ end
230
+
231
+ text = " #{label} "
232
+ if Cells.cell_len(text) > inner_width
233
+ label = truncate_label(label, [inner_width - 2, 0].max)
234
+ text = " #{label} "
235
+ end
236
+ text_width = Cells.cell_len(text)
237
+ remaining = [inner_width - text_width, 0].max
238
+
239
+ case align
240
+ when :left
241
+ left = [2, remaining].min
242
+ right = remaining - left
243
+ when :right
244
+ right = [2, remaining].min
245
+ left = remaining - right
246
+ else # :center
247
+ left = remaining / 2
248
+ right = remaining - left
249
+ end
250
+
251
+ segments << Segment.new(left_corner, style: @border_style)
252
+ segments << Segment.new(@box.horizontal * left, style: @border_style)
253
+ segments << Segment.new(text, style: label_style || @border_style)
254
+ segments << Segment.new(@box.horizontal * right, style: @border_style)
255
+ segments << Segment.new(right_corner, style: @border_style)
256
+ segments
257
+ end
258
+
259
+ # Truncate a label to a maximum cell width (used when a title/subtitle is
260
+ # wider than the panel).
261
+ def truncate_label(text, max_width)
262
+ return "" if max_width <= 0
263
+
264
+ result = +""
265
+ current_width = 0
266
+ text.to_s.each_char do |char|
267
+ cw = Cells.char_width(char)
268
+ break if current_width + cw > max_width
269
+
270
+ result << char
271
+ current_width += cw
272
+ end
273
+ result
274
+ end
275
+
276
+ def render_content_row(content_segments, inner_width)
277
+ segments = []
278
+
279
+ # Left border
280
+ segments << Segment.new(@box.vertical, style: @border_style)
281
+
282
+ # Left padding
283
+ segments << Segment.new(" " * @padding)
284
+
285
+ # Content (cropped so it can never exceed the content area and push the
286
+ # right border out of alignment)
287
+ content_area = [inner_width - @padding * 2, 0].max
288
+ content_width = content_segments.sum(&:cell_length)
289
+ if content_width > content_area
290
+ content_segments = Segment.crop_line(content_segments, content_area)
291
+ content_width = content_segments.sum(&:cell_length)
292
+ end
293
+ segments.concat(content_segments)
294
+
295
+ # Right padding (fill to width)
296
+ remaining = inner_width - @padding * 2 - content_width
297
+ segments << Segment.new(" " * [remaining, 0].max)
298
+
299
+ # Right padding
300
+ segments << Segment.new(" " * @padding)
301
+
302
+ # Right border
303
+ segments << Segment.new(@box.vertical, style: @border_style)
304
+
305
+ segments
306
+ end
307
+
308
+ def render_empty_row(inner_width)
309
+ segments = []
310
+
311
+ segments << Segment.new(@box.vertical, style: @border_style)
312
+ segments << Segment.new(" " * inner_width)
313
+ segments << Segment.new(@box.vertical, style: @border_style)
314
+
315
+ segments
316
+ end
317
+ end
318
+ end