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