natty-ui 0.35.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.
- checksums.yaml +4 -4
- data/README.md +6 -6
- data/examples/24bit-colors.rb +9 -5
- data/examples/3bit-colors.rb +7 -7
- data/examples/8bit-colors.rb +5 -7
- data/examples/attributes.rb +2 -3
- data/examples/elements.rb +9 -6
- data/examples/examples.rb +9 -9
- data/examples/frames.rb +31 -0
- data/examples/hbars.rb +6 -3
- data/examples/info.rb +13 -10
- data/examples/key-codes.rb +8 -9
- data/examples/ls.rb +24 -22
- data/examples/named-colors.rb +4 -3
- data/examples/sections.rb +26 -24
- data/examples/select.rb +28 -0
- data/examples/sh.rb +25 -7
- data/examples/tables.rb +19 -37
- data/examples/tasks.rb +32 -22
- data/examples/vbars.rb +5 -3
- data/lib/natty-ui/dumb_progress.rb +68 -0
- data/lib/natty-ui/element.rb +61 -70
- data/lib/natty-ui/features.rb +771 -949
- data/lib/natty-ui/frame.rb +87 -0
- data/lib/natty-ui/helper/table.rb +1376 -0
- data/lib/natty-ui/margin.rb +83 -0
- data/lib/natty-ui/progress.rb +116 -152
- data/lib/natty-ui/renderer/bars.rb +93 -0
- data/lib/natty-ui/renderer/choice.rb +56 -0
- data/lib/natty-ui/renderer/dumb_choice.rb +34 -0
- data/lib/natty-ui/renderer/dumb_select.rb +60 -0
- data/lib/natty-ui/renderer/dumb_shell_runner.rb +19 -0
- data/lib/natty-ui/renderer/heading.rb +26 -0
- data/lib/natty-ui/renderer/horizontal_rule.rb +32 -0
- data/lib/natty-ui/{ls_renderer.rb → renderer/ls.rb} +15 -27
- data/lib/natty-ui/renderer/mark.rb +13 -0
- data/lib/natty-ui/renderer/quote.rb +13 -0
- data/lib/natty-ui/renderer/select.rb +63 -0
- data/lib/natty-ui/renderer/shell.rb +15 -0
- data/lib/natty-ui/renderer/shell_runner.rb +29 -0
- data/lib/natty-ui/renderer/table_renderer.rb +429 -0
- data/lib/natty-ui/section.rb +144 -32
- data/lib/natty-ui/task.rb +38 -25
- data/lib/natty-ui/temporary.rb +27 -14
- data/lib/natty-ui/utils/border.rb +139 -0
- data/lib/natty-ui/utils/str_const.rb +62 -0
- data/lib/natty-ui/utils/utils.rb +47 -0
- data/lib/natty-ui/version.rb +1 -1
- data/lib/natty-ui.rb +76 -35
- metadata +31 -28
- data/examples/cols.rb +0 -38
- data/examples/illustration.rb +0 -60
- data/examples/options.rb +0 -28
- data/examples/themes.rb +0 -51
- data/lib/natty-ui/attributes.rb +0 -593
- data/lib/natty-ui/choice.rb +0 -67
- data/lib/natty-ui/dumb_choice.rb +0 -47
- data/lib/natty-ui/dumb_options.rb +0 -64
- data/lib/natty-ui/framed.rb +0 -50
- data/lib/natty-ui/hbars_renderer.rb +0 -66
- data/lib/natty-ui/options.rb +0 -78
- data/lib/natty-ui/shell_renderer.rb +0 -91
- data/lib/natty-ui/table.rb +0 -325
- data/lib/natty-ui/table_renderer.rb +0 -165
- data/lib/natty-ui/theme.rb +0 -403
- data/lib/natty-ui/utils.rb +0 -111
- data/lib/natty-ui/vbars_renderer.rb +0 -49
- data/lib/natty-ui/width_finder.rb +0 -137
- 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
|
-
|
|
4
|
+
# @private
|
|
5
|
+
class LS
|
|
6
|
+
# @private
|
|
5
7
|
class << self
|
|
6
|
-
def lines(items, glyph
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
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! {
|
|
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
|
-
|
|
50
|
+
# @private
|
|
51
|
+
class CompactLS < LS
|
|
64
52
|
class << self
|
|
65
|
-
def lines(items, glyph
|
|
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,
|
|
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&.
|
|
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 {
|
|
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 :
|
|
85
|
+
private_constant :LS, :CompactLS
|
|
98
86
|
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
|