natty-ui 0.35.0 → 1.0.3

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -6
  3. data/examples/24bit-colors.rb +9 -5
  4. data/examples/3bit-colors.rb +7 -7
  5. data/examples/8bit-colors.rb +5 -7
  6. data/examples/attributes.rb +2 -3
  7. data/examples/elements.rb +9 -6
  8. data/examples/examples.rb +9 -9
  9. data/examples/frames.rb +31 -0
  10. data/examples/hbars.rb +6 -3
  11. data/examples/info.rb +13 -10
  12. data/examples/key-codes.rb +8 -9
  13. data/examples/ls.rb +24 -22
  14. data/examples/named-colors.rb +4 -3
  15. data/examples/sections.rb +26 -24
  16. data/examples/select.rb +28 -0
  17. data/examples/sh.rb +25 -7
  18. data/examples/tables.rb +19 -37
  19. data/examples/tasks.rb +32 -22
  20. data/examples/vbars.rb +5 -3
  21. data/lib/natty-ui/dumb_progress.rb +68 -0
  22. data/lib/natty-ui/element.rb +61 -70
  23. data/lib/natty-ui/features.rb +771 -944
  24. data/lib/natty-ui/frame.rb +87 -0
  25. data/lib/natty-ui/helper/table.rb +1376 -0
  26. data/lib/natty-ui/margin.rb +83 -0
  27. data/lib/natty-ui/progress.rb +116 -152
  28. data/lib/natty-ui/renderer/bars.rb +93 -0
  29. data/lib/natty-ui/renderer/choice.rb +56 -0
  30. data/lib/natty-ui/renderer/dumb_choice.rb +34 -0
  31. data/lib/natty-ui/renderer/dumb_select.rb +60 -0
  32. data/lib/natty-ui/renderer/dumb_shell_runner.rb +19 -0
  33. data/lib/natty-ui/renderer/heading.rb +26 -0
  34. data/lib/natty-ui/renderer/horizontal_rule.rb +32 -0
  35. data/lib/natty-ui/{ls_renderer.rb → renderer/ls.rb} +15 -27
  36. data/lib/natty-ui/renderer/mark.rb +13 -0
  37. data/lib/natty-ui/renderer/quote.rb +13 -0
  38. data/lib/natty-ui/renderer/select.rb +63 -0
  39. data/lib/natty-ui/renderer/shell.rb +15 -0
  40. data/lib/natty-ui/renderer/shell_runner.rb +29 -0
  41. data/lib/natty-ui/renderer/table_renderer.rb +446 -0
  42. data/lib/natty-ui/section.rb +144 -32
  43. data/lib/natty-ui/task.rb +38 -25
  44. data/lib/natty-ui/temporary.rb +27 -14
  45. data/lib/natty-ui/utils/border.rb +139 -0
  46. data/lib/natty-ui/utils/str_const.rb +61 -0
  47. data/lib/natty-ui/utils/utils.rb +47 -0
  48. data/lib/natty-ui/version.rb +1 -1
  49. data/lib/natty-ui.rb +86 -35
  50. metadata +31 -28
  51. data/examples/cols.rb +0 -38
  52. data/examples/illustration.rb +0 -60
  53. data/examples/options.rb +0 -28
  54. data/examples/themes.rb +0 -51
  55. data/lib/natty-ui/attributes.rb +0 -593
  56. data/lib/natty-ui/choice.rb +0 -67
  57. data/lib/natty-ui/dumb_choice.rb +0 -47
  58. data/lib/natty-ui/dumb_options.rb +0 -64
  59. data/lib/natty-ui/framed.rb +0 -50
  60. data/lib/natty-ui/hbars_renderer.rb +0 -66
  61. data/lib/natty-ui/options.rb +0 -78
  62. data/lib/natty-ui/shell_renderer.rb +0 -91
  63. data/lib/natty-ui/table.rb +0 -325
  64. data/lib/natty-ui/table_renderer.rb +0 -165
  65. data/lib/natty-ui/theme.rb +0 -403
  66. data/lib/natty-ui/utils.rb +0 -111
  67. data/lib/natty-ui/vbars_renderer.rb +0 -49
  68. data/lib/natty-ui/width_finder.rb +0 -137
  69. data/natty-ui.gemspec +0 -34
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NattyUI
4
+ # @private
5
+ class HorizontalRule
6
+ def self.render(parent, kind)
7
+ return if (width = parent.columns) < 1
8
+ parent.puts(
9
+ "[bright_blue]#{
10
+ if kind.nil?
11
+ Border[:default].top * width
12
+ elsif kind.is_a?(Symbol)
13
+ Border[kind].top * width
14
+ else
15
+ kind = StrConst[kind]
16
+ if kind.width == 0
17
+ Border[:default].top * width
18
+ elsif kind.width > width
19
+ kind.to_str[0, width]
20
+ elsif kind.width == width
21
+ kind
22
+ else
23
+ kind.to_str * (width / kind.width)
24
+ end
25
+ end
26
+ }"
27
+ )
28
+ end
29
+ end
30
+
31
+ private_constant :HorizontalRule
32
+ end
@@ -1,16 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NattyUI
4
- class LSRenderer
4
+ # @private
5
+ class LS
6
+ # @private
5
7
  class << self
6
- def lines(items, glyph, max_width)
8
+ def lines(columns, items, glyph)
7
9
  items = as_items(items, glyph)
8
- lines = []
9
10
  width = items.max_by(&:width).width + 3
10
- return lines if (sl_size = max_width / width).zero?
11
- items.each_slice(sl_size) do |slice|
12
- lines << slice.map { _1.to_s(width) }.join
13
- end
11
+ return if (sl_size = columns / width).zero?
12
+ lines = []
13
+ items.each_slice(sl_size) { lines << it.map { it.ljust(width) }.join }
14
14
  lines
15
15
  end
16
16
 
@@ -19,7 +19,7 @@ module NattyUI
19
19
  def as_items(items, glyph)
20
20
  items.flatten!
21
21
  glyph = as_glyph(glyph, items.size)
22
- items.map! { Item.new(glyph[_1]) }
22
+ items.map! { StrConst[glyph[it]] }
23
23
  end
24
24
 
25
25
  def as_glyph(glyph, size)
@@ -44,31 +44,19 @@ module NattyUI
44
44
  ->(s) { "#{glyph} #{s}" }
45
45
  end
46
46
  end
47
-
48
- class Item
49
- attr_reader :width
50
-
51
- def to_s(in_width) = "#{@str}#{' ' * (in_width - @width)}"
52
-
53
- def initialize(str)
54
- @str = str
55
- @width = Text.width(str)
56
- end
57
- end
58
-
59
- private_constant :Item
60
47
  end
61
48
  end
62
49
 
63
- class CompactLSRenderer < LSRenderer
50
+ # @private
51
+ class CompactLS < LS
64
52
  class << self
65
- def lines(items, glyph, max_width)
53
+ def lines(columns, items, glyph)
66
54
  items = as_items(items, glyph)
67
55
  return [] if items.empty?
68
- found, widths = find_columns(items, max_width)
56
+ found, widths = find_columns(items, columns)
69
57
  fill(found[-1], found[0].size)
70
58
  found.transpose.map! do |row|
71
- row.each_with_index.map { |item, col| item&.to_s(widths[col]) }.join
59
+ row.each_with_index.map { |item, col| item&.ljust(widths[col]) }.join
72
60
  end
73
61
  end
74
62
 
@@ -79,7 +67,7 @@ module NattyUI
79
67
  widths = [items.max_by(&:width).width]
80
68
  1.upto(items.size - 1) do |slice_size|
81
69
  candidate = items.each_slice(items.size / slice_size).to_a
82
- cwidths = candidate.map { _1.max_by(&:width).width + 3 }
70
+ cwidths = candidate.map { it.max_by(&:width).width + 3 }
83
71
  cwidths[-1] -= 3
84
72
  break if cwidths.sum > max_width
85
73
  found = candidate
@@ -94,5 +82,5 @@ module NattyUI
94
82
  end
95
83
  end
96
84
 
97
- private_constant :LSRenderer, :CompactLSRenderer
85
+ private_constant :LS, :CompactLS
98
86
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NattyUI
4
+ # @private
5
+ module Mark
6
+ def self.render(parent, mark, *, **options)
7
+ options[:cprefix] = Utils.as_mark(mark)
8
+ parent.puts(*, **options)
9
+ end
10
+ end
11
+
12
+ private_constant :Mark
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NattyUI
4
+ # @private
5
+ Quote = StrConst["#{Ansi[:bright_blue]}▍#{Ansi::RESET}"]
6
+ def Quote.render(parent, *)
7
+ space = parent.columns
8
+ width = space * 0.75
9
+ parent.puts(*, prefix: self, max_width: width < 20 ? space : width.round)
10
+ end
11
+
12
+ private_constant :Quote
13
+ end
@@ -0,0 +1,63 @@
1
+ module NattyUI
2
+ # @private
3
+ class Select
4
+ def select(abortable)
5
+ start_line = NattyUI.lines_written
6
+ draw(last = current = 0)
7
+ Terminal.on_key_event do |event|
8
+ case event.name
9
+ when 'Esc', 'Ctrl+c'
10
+ break [] if abortable
11
+ when 'Space'
12
+ *last, selected = @items[current]
13
+ @items[current] = last << !selected
14
+ when 'Enter'
15
+ break @items.filter_map { |ret, _str, selected| ret if selected }
16
+ when 'Home'
17
+ current = 0
18
+ when 'End'
19
+ current = @items.size - 1
20
+ when 'Up', 'Back', 'Shift+Tab', 'i', 'w'
21
+ current = @items.size - 1 if (current -= 1) < 0
22
+ when 'Down', 'Tab', 'k', 's'
23
+ current = 0 if (current += 1) == @items.size
24
+ end
25
+ next if last == current
26
+ start_line = NattyUI.back_to_line(start_line, erase: false)
27
+ draw(last = current)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def draw(current)
34
+ @items.each_with_index do |(_ret, str, selected), idx|
35
+ @parent.puts(
36
+ str,
37
+ cprefix: MARKS.dig(idx == current, selected),
38
+ prefix: MARK_NONE
39
+ )
40
+ end
41
+ end
42
+
43
+ def initialize(parent, items)
44
+ @parent = parent
45
+ @items = items
46
+ end
47
+
48
+ MARKS = {
49
+ true => {
50
+ true => StrConst['[b bright_green]→[/fg] [\X][/b] '],
51
+ false => StrConst['[b bright_green]→[/fg] [\ ][/b] ']
52
+ }.compare_by_identity,
53
+ false => {
54
+ true => StrConst[' [\X] '],
55
+ false => StrConst[' [\ ] ']
56
+ }.compare_by_identity
57
+ }.compare_by_identity.freeze
58
+ MARK_NONE = StrConst.spacer(MARKS.dig(true, true).width)
59
+ private_constant :MARKS, :MARK_NONE
60
+ end
61
+
62
+ private_constant :Select
63
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NattyUI
4
+ # @private
5
+ module Shell
6
+ def self.render(parent, *, **)
7
+ Terminal.sh(*, **) do |line, kind|
8
+ parent.puts(line, bbcode: false, prefix: Utils.mark[kind])
9
+ end
10
+ parent
11
+ end
12
+ end
13
+
14
+ private_constant :Shell
15
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NattyUI
4
+ # @private
5
+ module ShellRunner
6
+ def self.render(parent, tail, *, **)
7
+ tail = tail.clamp(1, Terminal.rows)
8
+ out, err, show = [], [], []
9
+ start = NattyUI.lines_written
10
+ width = parent.columns - Utils.mark.default.width
11
+ ret =
12
+ Terminal.sh(*, **) do |line, kind|
13
+ (kind == :error ? err : out) << line
14
+ first, *lines = Text::Formatter.format(line, width:, bbcode: false)
15
+ next unless first
16
+ start = NattyUI.back_to_line(start)
17
+ show << "#{Utils.mark[kind]}#{first}"
18
+ show.concat(lines.map! { "#{MARK_NONE}#{it}" }) unless lines.empty?
19
+ show = show.last(tail) if show.size > tail
20
+ parent.puts(*show, bbcode: false)
21
+ end
22
+ [ret, out, err] if ret
23
+ end
24
+
25
+ MARK_NONE = Utils.mark[:none]
26
+ end
27
+
28
+ private_constant :ShellRunner
29
+ end
@@ -0,0 +1,446 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../utils/border'
4
+
5
+ module NattyUI
6
+ # @private
7
+ class TableRenderer
8
+ def self.lines(columns, table, **) = new(columns, table, **).lines
9
+
10
+ def lines
11
+ return [] if @rows.empty?
12
+ fit_widths
13
+ compose(compute_heights)
14
+ end
15
+
16
+ private
17
+
18
+ def initialize(max_width, table, **options)
19
+ @border = TableBorder.new(**options)
20
+ max_width = find_width(max_width, options[:width])
21
+ @rows, @col_count = build_rows(table, max_width)
22
+ return if @rows.empty?
23
+ seps = @border.col_sep && @col_count > 1 ? @col_count - 1 : 0
24
+ @content_width = max_width - @border.frame_size - seps
25
+ end
26
+
27
+ def build_rows(table, max_width)
28
+ rows = table.rows.each_filled.to_a
29
+ return [], 0 if rows.empty?
30
+
31
+ col_atts =
32
+ Array.new(table.columns.max) do |idx|
33
+ col = table.columns.at(idx)
34
+ col ? col.attributes.to_hash : {}
35
+ end
36
+
37
+ items =
38
+ rows.map do |row|
39
+ row_att = row.attributes.to_hash
40
+ col_atts.each_with_index.map do |col_att, idx|
41
+ Item.new(row.at(idx), row_att, col_att, max_width)
42
+ end
43
+ end
44
+
45
+ cols = items.transpose.keep_if { it.any?(&:any?) }
46
+ return [], 0 if cols.empty?
47
+ return items, col_atts.size if col_atts.size == cols.size
48
+ [cols.transpose, cols.size]
49
+ end
50
+
51
+ def fit_widths
52
+ cols =
53
+ Array.new(@col_count) do |c|
54
+ cells = @rows.map { it[c] }
55
+ pad = cells.map(&:h_pad).max
56
+
57
+ has_min = cells.any?(&:has_min)
58
+ cmin = has_min ? cells.map(&:content_min).max : 1
59
+
60
+ value = [cells.map(&:natural_width).max, cmin].max
61
+
62
+ cmax = cells.filter_map(&:content_max).min
63
+ if cmax
64
+ value = [value, cmax].min
65
+ max = cmax + pad
66
+ end
67
+
68
+ ColWidth.new(value, pad, has_min, cmin, max)
69
+ end
70
+ adjust(cols, @content_width)
71
+ @rows.each do |row|
72
+ row.each_with_index do |cell, c|
73
+ cell.resolve(cols[c].value, cols[c].pad)
74
+ end
75
+ end
76
+ @column_widths = cols.map(&:value).freeze # TODO: remove?
77
+ end
78
+
79
+ def adjust(cols, target)
80
+ delta = (cols.sum(&:value) - target).nonzero? or return
81
+ # 1) wrap content by shrinking columns down to their content minimum.
82
+ # Columns without a min_width are shrunk first; a column with a min_width
83
+ # is only reduced once no unconstrained column can give up more room.
84
+ while delta > 0
85
+ cand = []
86
+ free = []
87
+ cols.each do |c|
88
+ next unless c.shrinkable?
89
+ cand << c
90
+ free << c unless c.has_min
91
+ end
92
+ break if cand.empty?
93
+ (free.empty? ? cand : free).max_by(&:value).shrink!
94
+ delta -= 1
95
+ end
96
+ # 2) still too wide: trim padding, widest-padded column first
97
+ while delta > 0
98
+ break if (cand = cols.select(&:pad_reducible?)).empty?
99
+ cand.max_by(&:pad).reduce_pad!
100
+ delta -= 1
101
+ end
102
+ # 3) room to spare: grow columns to fill the width, but only on request
103
+ # and never past a column's configured max_width
104
+ return unless @expand
105
+ while delta < 0
106
+ break if (cand = cols.select(&:expandable?)).empty?
107
+ cand.min_by(&:value).expand!
108
+ delta += 1
109
+ end
110
+ end
111
+
112
+ def compute_heights
113
+ @rows.map do |row|
114
+ row.each(&:reflow!)
115
+ cells = row.map(&:height)
116
+ h = [cells.map(&:value).max, cells.map(&:min).max].max
117
+ mx = cells.filter_map(&:max).min
118
+ mx ? [h, mx].min : h
119
+ end
120
+ end
121
+
122
+ def compose(row_heights)
123
+ blanks = @column_widths.map { ' ' * it }
124
+ top, mid, bottom, left, right, sep = @border.parts(@column_widths)
125
+ ret = top ? [top] : []
126
+ @rows.each_with_index do |row, ri|
127
+ ret << mid if mid && ri > 0
128
+ h = row_heights[ri]
129
+ cell_lines = row.map { it.lines(h) }
130
+ h.times do |idx|
131
+ parts =
132
+ cell_lines.each_with_index.map do |cl, ci|
133
+ ctx = cl[idx]
134
+ ctx.nil? || ctx.empty? ? blanks[ci] : ctx
135
+ end
136
+ ret << "#{left}#{parts.join(sep)}#{right}"
137
+ end
138
+ end
139
+ bottom ? ret << bottom : ret
140
+ end
141
+
142
+ def find_width(max_width, value)
143
+ case value
144
+ when :max
145
+ @expand = true
146
+ max_width
147
+ when 0
148
+ [3, max_width].max
149
+ when Integer
150
+ @expand = true
151
+ if value < 0
152
+ [3, max_width + value].max
153
+ else
154
+ [value, max_width].min
155
+ end
156
+ when (0...1)
157
+ @expand = true
158
+ max_width * value
159
+ else
160
+ max_width
161
+ end
162
+ end
163
+
164
+ # @private
165
+ class TableBorder
166
+ attr_reader :frame_size, :col_sep
167
+
168
+ def parts(column_widths)
169
+ if @frame
170
+ top = top_row(column_widths) if @pos.top
171
+ right = "#{@style}#{@frame.right}#{@elyts}" if @pos.right
172
+ bottom = bottom_row(column_widths) if @pos.bottom
173
+ left = "#{@style}#{@frame.left}#{@elyts}" if @pos.left
174
+ end
175
+ mid = mid_row(column_widths) if @horizontal
176
+ sep = "#{@style}#{@col_sep}#{@elyts}" if @col_sep
177
+ [top, mid, bottom, left, right, sep]
178
+ end
179
+
180
+ private
181
+
182
+ def initialize(**options)
183
+ @style, @elyts = Utils.affix(options[:border_style])
184
+
185
+ @default =
186
+ if options.key?(:border)
187
+ case (value = options[:border])
188
+ when nil, false, :none
189
+ nil
190
+ when true
191
+ Border[:default]
192
+ when Symbol
193
+ Border[value]
194
+ end
195
+ else
196
+ Border[:default]
197
+ end
198
+
199
+ @frame = as_border(options[:border_frame])
200
+ @frame_size = 0
201
+
202
+ @vertical = as_border(options[:border_vertical])
203
+ @col_sep = @vertical&.left
204
+
205
+ @horizontal = as_border(options[:border_horizontal])
206
+
207
+ return @pos = FramePos::NONE unless @frame
208
+
209
+ @pos = FramePos[options.fetch(:frame, :all)]
210
+ return @frame = nil unless @pos.any?
211
+
212
+ @default = @frame
213
+ @frame_size += 1 if @pos.right
214
+ @frame_size += 1 if @pos.left
215
+ end
216
+
217
+ def as_border(value)
218
+ case value
219
+ when nil, true
220
+ @default
221
+ when :none
222
+ nil
223
+ when Symbol
224
+ Border[value]
225
+ end
226
+ end
227
+
228
+ def top_row(column_widths)
229
+ bar = @frame.top
230
+ cross = @vertical.vert_for(@default)[1] if @vertical
231
+ "#{@style}#{@frame.top_left if @pos.left}#{
232
+ column_widths.map { bar * it }.join(cross)
233
+ }#{@frame.top_right if @pos.right}#{@elyts}"
234
+ end
235
+
236
+ def bottom_row(column_widths)
237
+ bar = @frame.bottom
238
+ cross = @vertical.vert_for(@default)[2] if @vertical
239
+ "#{@style}#{@frame.bottom_left if @pos.left}#{
240
+ column_widths.map { bar * it }.join(cross)
241
+ }#{@frame.bottom_right if @pos.right}#{@elyts}"
242
+ end
243
+
244
+ def mid_row(column_widths)
245
+ if @frame
246
+ bar, left, right = @frame.hor_for(@horizontal).chars
247
+ else
248
+ bar = @horizontal.top
249
+ end
250
+ cross = @vertical&.inter_for(@horizontal)
251
+ "#{@style}#{left if @pos.left}#{
252
+ column_widths.map { bar * it }.join(cross)
253
+ }#{right if @pos.right}#{@elyts}"
254
+ end
255
+
256
+ class FramePos
257
+ # @private
258
+ def self.[](value)
259
+ case value
260
+ when Symbol
261
+ @defined[value]
262
+ when Enumerable
263
+ new(
264
+ value.include?(:top),
265
+ value.include?(:right),
266
+ value.include?(:bottom),
267
+ value.include?(:left)
268
+ )
269
+ else
270
+ NONE
271
+ end
272
+ end
273
+
274
+ attr_reader :top, :right, :bottom, :left
275
+
276
+ def any? = @top || @right || @bottom || @left
277
+
278
+ private
279
+
280
+ def initialize(top, right, bottom, left)
281
+ @top = top
282
+ @right = right
283
+ @bottom = bottom
284
+ @left = left
285
+ end
286
+
287
+ @defined = {
288
+ all: new(true, true, true, true),
289
+ top: new(true, false, false, false),
290
+ right: new(false, true, false, false),
291
+ bottom: new(false, false, true, false),
292
+ left: new(false, false, false, true)
293
+ }.compare_by_identity
294
+
295
+ @defined.default = NONE = new(false, false, false, false)
296
+ end
297
+ end
298
+
299
+ # @private
300
+ class ColWidth
301
+ attr_reader :value, :pad, :has_min
302
+
303
+ def shrinkable? = @value > @min
304
+ def expandable? = @max.nil? || @value < @max
305
+ def pad_reducible? = @pad > 0
306
+ def shrink! = (@value -= 1)
307
+ def expand! = (@value += 1)
308
+
309
+ def reduce_pad!
310
+ @value -= 1
311
+ @pad -= 1
312
+ end
313
+
314
+ def initialize(value, pad, has_min, min, max)
315
+ @value = value
316
+ @pad = pad
317
+ @min = min + pad
318
+ @has_min = has_min
319
+ @max = max
320
+ end
321
+ end
322
+
323
+ # @private
324
+ class Size
325
+ attr_reader :min, :max
326
+ attr_accessor :value
327
+
328
+ def initialize(min, max, value)
329
+ @min = min
330
+ @max = max
331
+ @value = [value, min].max
332
+ @value = max if max && @value > max
333
+ end
334
+ end
335
+
336
+ # @private
337
+ class Item
338
+ attr_reader :content_min, :content_max, :h_pad, :height, :has_min, :width
339
+
340
+ def any? = @any
341
+ def natural_width = @content_natural + @h_pad
342
+
343
+ def resolve(width, pad_budget)
344
+ @width = width
345
+ right, left = reduce_padding(pad_budget)
346
+ @hpad = [0, right, 0, left]
347
+ @rpad_h = right + left
348
+ end
349
+
350
+ def reflow!
351
+ @formatted =
352
+ if @width - @rpad_h < 1
353
+ []
354
+ else
355
+ @fmt.format(align: @align, padding: @hpad, width: @width)
356
+ end
357
+ h = [@formatted.size + @v_pad, @height.min].max
358
+ @height.value = @height.max ? [h, @height.max].min : h
359
+ end
360
+
361
+ def lines(box_height) = fit_height(@formatted, box_height, @vertical)
362
+
363
+ private
364
+
365
+ # Horizontal padding [right, left] capped at `budget` columns: the cell's
366
+ # own padding when it fits, otherwise `budget` split as evenly as possible
367
+ # with an odd column going to the left.
368
+ def reduce_padding(budget)
369
+ return @pad_right, @pad_left if @h_pad <= budget
370
+ right = (budget / 2.0).round
371
+ [right, budget - right]
372
+ end
373
+
374
+ # Compose the cell's content lines into a `box_height`-tall block: keep
375
+ # the vertical padding (@pad_top/@pad_bottom) as fixed top/bottom margins
376
+ # and distribute the remaining slack per vertical alignment; clip the
377
+ # content when it plus its padding is taller than the row.
378
+ def fit_height(lines, box_height, valign)
379
+ blank = ' ' * @width
380
+ slack = box_height - lines.size - @v_pad
381
+ if slack < 0
382
+ room = box_height - @v_pad
383
+ return Array.new(box_height, blank) if room <= 0
384
+ lines = valign == :bottom ? lines.last(room) : lines.first(room)
385
+ slack = 0
386
+ end
387
+ top, bottom =
388
+ case valign
389
+ when :bottom
390
+ [slack, 0]
391
+ when :middle
392
+ b = (slack / 2.0).round
393
+ [slack - b, b]
394
+ else
395
+ [0, slack]
396
+ end
397
+ Array.new(@pad_top + top, blank).concat(
398
+ lines,
399
+ Array.new(@pad_bottom + bottom, blank)
400
+ )
401
+ end
402
+
403
+ def initialize(cell, row_att, col_att, max_width)
404
+ att = row_att.merge(col_att)
405
+ if cell
406
+ @any = !cell.empty?
407
+ txt = cell.text
408
+ att.merge!(cell.attributes.to_hash)
409
+ else
410
+ @any = false
411
+ end
412
+ att = Table::Cell::Attributes.new(**att)
413
+ @fmt =
414
+ Text::Formatter.new(
415
+ *txt,
416
+ ansi: Terminal.ansi?,
417
+ spaces: att.spaces,
418
+ eol: att.eol
419
+ )
420
+ @content_natural = @any ? @fmt.max_line_width : 0
421
+ @align = att.align || :left
422
+ @vertical = att.vertical || :top
423
+ @pad_top, @pad_right, @pad_bottom, @pad_left = att.padding
424
+ @h_pad = @pad_right + @pad_left
425
+ @v_pad = @pad_top + @pad_bottom
426
+ @has_min = !att.min_width.nil?
427
+ @content_min = dim(att.min_width, 1, max_width)
428
+ @content_max = dim(att.max_width, nil, max_width)
429
+ @height =
430
+ Size.new(
431
+ (att.min_height || 1) + @v_pad,
432
+ att.max_height ? att.max_height + @v_pad : nil,
433
+ 0 # placeholder: the real height is computed in reflow! once the width is known
434
+ )
435
+ resolve(natural_width, @h_pad)
436
+ end
437
+
438
+ def dim(value, default, max)
439
+ return default unless value
440
+ value.is_a?(Float) ? (value * max).round : value
441
+ end
442
+ end
443
+ end
444
+
445
+ private_constant :TableRenderer
446
+ end