table_tennis 0.0.1
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 +7 -0
- data/.github/workflows/test.yml +26 -0
- data/.gitignore +5 -0
- data/.rubocop.yml +58 -0
- data/Gemfile +14 -0
- data/Gemfile.lock +122 -0
- data/LICENSE +21 -0
- data/README.md +154 -0
- data/Rakefile +12 -0
- data/justfile +75 -0
- data/lib/table_tennis/column.rb +41 -0
- data/lib/table_tennis/config.rb +221 -0
- data/lib/table_tennis/row.rb +9 -0
- data/lib/table_tennis/stage/base.rb +19 -0
- data/lib/table_tennis/stage/format.rb +68 -0
- data/lib/table_tennis/stage/layout.rb +76 -0
- data/lib/table_tennis/stage/painter.rb +84 -0
- data/lib/table_tennis/stage/render.rb +146 -0
- data/lib/table_tennis/table.rb +79 -0
- data/lib/table_tennis/table_data.rb +161 -0
- data/lib/table_tennis/theme.rb +92 -0
- data/lib/table_tennis/util/colors.rb +524 -0
- data/lib/table_tennis/util/inspectable.rb +25 -0
- data/lib/table_tennis/util/scale.rb +55 -0
- data/lib/table_tennis/util/strings.rb +62 -0
- data/lib/table_tennis/util/termbg.rb +275 -0
- data/lib/table_tennis/version.rb +3 -0
- data/lib/table_tennis.rb +29 -0
- data/screenshots/dark.png +0 -0
- data/screenshots/droids.png +0 -0
- data/screenshots/hope.png +0 -0
- data/screenshots/light.png +0 -0
- data/screenshots/row_numbers.png +0 -0
- data/screenshots/scales.png +0 -0
- data/screenshots/themes.png +0 -0
- data/table_tennis.gemspec +28 -0
- metadata +145 -0
@@ -0,0 +1,221 @@
|
|
1
|
+
module TableTennis
|
2
|
+
class << self
|
3
|
+
attr_accessor :defaults
|
4
|
+
end
|
5
|
+
|
6
|
+
# Store the table configuration options, with lots of validation.
|
7
|
+
class Config
|
8
|
+
OPTIONS = {
|
9
|
+
color_scales: nil, # columns => color scale
|
10
|
+
color: nil, # true/false/nil (detect)
|
11
|
+
columns: nil, # array of symbols, or inferred from rows
|
12
|
+
debug: false, # true for debug output
|
13
|
+
digits: 3, # format floats
|
14
|
+
layout: true, # true/false/int or hash of columns -> width. true to infer
|
15
|
+
mark: nil, # lambda returning boolean or symbol to mark rows in output
|
16
|
+
placeholder: "—", # placeholder for empty cells. default is emdash
|
17
|
+
row_numbers: false, # show line numbers?
|
18
|
+
save: nil, # csv file path to save the table when created
|
19
|
+
search: nil, # string/regex to highlight in output
|
20
|
+
strftime: nil, # string for formatting dates
|
21
|
+
theme: nil, # :dark, :light or :ansi. :dark is the default
|
22
|
+
title: nil, # string for table title, if any
|
23
|
+
zebra: false, # turn on zebra stripes
|
24
|
+
}.freeze
|
25
|
+
|
26
|
+
def initialize(options = {}, &block)
|
27
|
+
options = [OPTIONS, TableTennis.defaults, options].reduce { _1.merge(_2 || {}) }
|
28
|
+
options[:color] = Config.detect_color? if options[:color].nil?
|
29
|
+
options[:theme] = Config.detect_theme if options[:theme].nil?
|
30
|
+
options[:debug] = true if ENV["TM_DEBUG"]
|
31
|
+
options.each { self[_1] = _2 }
|
32
|
+
|
33
|
+
yield self if block_given?
|
34
|
+
end
|
35
|
+
|
36
|
+
# readers
|
37
|
+
attr_reader(*OPTIONS.keys)
|
38
|
+
|
39
|
+
#
|
40
|
+
# simple writers
|
41
|
+
#
|
42
|
+
|
43
|
+
{
|
44
|
+
color: :bool,
|
45
|
+
debug: :bool,
|
46
|
+
digits: :int,
|
47
|
+
mark: :proc,
|
48
|
+
placeholder: :str,
|
49
|
+
row_numbers: :bool,
|
50
|
+
save: :str,
|
51
|
+
strftime: :str,
|
52
|
+
title: :str,
|
53
|
+
zebra: :bool,
|
54
|
+
}.each do |option, type|
|
55
|
+
define_method(:"#{option}=") do |value|
|
56
|
+
instance_variable_set(:"@#{option}", send(:"_#{type}", option, value))
|
57
|
+
end
|
58
|
+
alias_method(:"#{option}?", option) if type == :bool
|
59
|
+
end
|
60
|
+
|
61
|
+
#
|
62
|
+
# helpers
|
63
|
+
#
|
64
|
+
|
65
|
+
# is this a dark terminal?
|
66
|
+
def self.terminal_dark?
|
67
|
+
if (bg = Util::Termbg.bg)
|
68
|
+
Util::Colors.dark?(bg)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def self.detect_color?
|
73
|
+
return false if ENV["NO_COLOR"] || ENV["CI"]
|
74
|
+
return true if ENV["FORCE_COLOR"] == "1"
|
75
|
+
return false if !($stdout.tty? && $stderr.tty?)
|
76
|
+
Paint.detect_mode > 0
|
77
|
+
end
|
78
|
+
|
79
|
+
def self.detect_theme
|
80
|
+
case terminal_dark?
|
81
|
+
when true, nil then :dark
|
82
|
+
when false then :light
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
#
|
87
|
+
# complex writers
|
88
|
+
#
|
89
|
+
|
90
|
+
def color_scales=(value)
|
91
|
+
case value
|
92
|
+
when Array then value = value.to_h { [_1, :g] }
|
93
|
+
when Symbol then value = {value => :g}
|
94
|
+
end
|
95
|
+
value.to_h { [_1, :g] } if value.is_a?(Array)
|
96
|
+
@color_scales = validate(:color_scales, value) do
|
97
|
+
if !value.is_a?(Hash)
|
98
|
+
"expected hash"
|
99
|
+
elsif value.keys.any? { !_1.is_a?(Symbol) }
|
100
|
+
"keys must be symbols"
|
101
|
+
elsif value.values.any? { !_1.is_a?(Symbol) }
|
102
|
+
"values must be symbols"
|
103
|
+
elsif value.values.any? { !Util::Scale::SCALES.include?(_1) }
|
104
|
+
"values must be the name of a color scale"
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
alias_method(:"color_scale=", :"color_scales=")
|
109
|
+
|
110
|
+
def columns=(value)
|
111
|
+
@columns = validate(:columns, value) do
|
112
|
+
if !(value.is_a?(Array) && !value.empty? && value.all? { _1.is_a?(Symbol) })
|
113
|
+
"expected array of symbols"
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def theme=(value)
|
119
|
+
@theme = validate(:theme, value) do
|
120
|
+
if !value.is_a?(Symbol)
|
121
|
+
"expected symbol"
|
122
|
+
elsif !Theme::THEMES.key?(value)
|
123
|
+
"expected one of #{Theme::THEMES.keys.inspect}"
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def search=(value)
|
129
|
+
@search = validate(:search, value) do
|
130
|
+
if !(value.is_a?(String) || value.is_a?(Regexp))
|
131
|
+
"expected string/regex"
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def layout=(value)
|
137
|
+
@layout = validate(:layout, value) do
|
138
|
+
next if [true, false].include?(value) || value.is_a?(Integer)
|
139
|
+
if !value.is_a?(Hash)
|
140
|
+
"expected boolean, int or hash"
|
141
|
+
elsif value.keys.any? { !_1.is_a?(Symbol) }
|
142
|
+
"keys must be symbols"
|
143
|
+
elsif value.values.any? { !_1.is_a?(Integer) }
|
144
|
+
"values must be ints"
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
def [](key)
|
150
|
+
raise ArgumentError, "unknown TableTennis.#{key}" if !respond_to?(key)
|
151
|
+
send(key)
|
152
|
+
end
|
153
|
+
|
154
|
+
def []=(key, value)
|
155
|
+
raise ArgumentError, "unknown TableTennis.#{key}=" if !respond_to?(:"#{key}=")
|
156
|
+
send(:"#{key}=", value)
|
157
|
+
end
|
158
|
+
|
159
|
+
def inspect
|
160
|
+
options = to_h.map { "@#{_1}=#{_2.inspect}" }.join(", ")
|
161
|
+
"#<Config #{options}>"
|
162
|
+
end
|
163
|
+
|
164
|
+
def to_h
|
165
|
+
OPTIONS.keys.to_h { [_1, self[_1]] }.compact
|
166
|
+
end
|
167
|
+
|
168
|
+
protected
|
169
|
+
|
170
|
+
#
|
171
|
+
# validations
|
172
|
+
#
|
173
|
+
|
174
|
+
def validate(option, value, &block)
|
175
|
+
if value != nil && (error = yield)
|
176
|
+
raise ArgumentError, "TableTennis.#{option} #{error}, got #{value.inspect}"
|
177
|
+
end
|
178
|
+
value
|
179
|
+
end
|
180
|
+
|
181
|
+
def _bool(option, value)
|
182
|
+
value = case value
|
183
|
+
when true, 1, "1", "true" then true
|
184
|
+
when false, 0, "", "0", "false" then false
|
185
|
+
else; value # this will turn into an error down below
|
186
|
+
end
|
187
|
+
validate(option, value) do
|
188
|
+
"expected boolean" if ![true, false].include?(value)
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
def _int(option, value)
|
193
|
+
validate(option, value) do
|
194
|
+
if !value.is_a?(Integer)
|
195
|
+
"expected int"
|
196
|
+
elsif value < 0
|
197
|
+
"expected positive int"
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
def _proc(option, value)
|
203
|
+
validate(option, value) do
|
204
|
+
"expected proc" if !value.is_a?(Proc)
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
def _str(option, value)
|
209
|
+
value = value.to_s if option == :title && value.is_a?(Symbol)
|
210
|
+
validate(option, value) do
|
211
|
+
"expected string" if !value.is_a?(String)
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
def _sym(option, value)
|
216
|
+
validate(option, value) do
|
217
|
+
"expected symbol" if !value.is_a?(Symbol)
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module TableTennis
|
2
|
+
module Stage
|
3
|
+
# Base class for each stage of the rendering pipeline, mostly here just to
|
4
|
+
# delegate to TableData.
|
5
|
+
class Base
|
6
|
+
prepend MemoWise
|
7
|
+
extend Forwardable
|
8
|
+
include Util::Inspectable
|
9
|
+
|
10
|
+
attr_reader :data
|
11
|
+
def_delegators :data, *%i[column_names columns config input_rows rows theme]
|
12
|
+
def_delegators :data, *%i[debug debug_if_slow]
|
13
|
+
|
14
|
+
def initialize(data)
|
15
|
+
@data = data
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
module TableTennis
|
2
|
+
module Stage
|
3
|
+
# This stage "formats" the table by injesting cells that contain various
|
4
|
+
# ruby objects and formatting each one as a string. `rows` are formatted in
|
5
|
+
# place.
|
6
|
+
#
|
7
|
+
# For performance reasons, formatting is implemented as a series of lambdas.
|
8
|
+
# Each fn must return a string. Minimize the number of times we examine each
|
9
|
+
# cell. Float detection/printing can be slow. Favor .match? over =~.
|
10
|
+
class Format < Base
|
11
|
+
attr_reader :fns
|
12
|
+
|
13
|
+
def run
|
14
|
+
setup_fns
|
15
|
+
rows.each do |row|
|
16
|
+
row.transform_values! do
|
17
|
+
fn = fns[fn_for(_1)] || fns[:other]
|
18
|
+
fn.call(_1)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def fn_for(value)
|
24
|
+
case value
|
25
|
+
when String then float?(value) ? :floatstr : :str
|
26
|
+
when Float then :float
|
27
|
+
when Date, Time, DateTime then :time
|
28
|
+
else
|
29
|
+
if value.respond_to?(:acts_like_time)
|
30
|
+
# Rails TimeWithZone
|
31
|
+
return :time
|
32
|
+
end
|
33
|
+
:other
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def float?(str) = str.match?(/^-?\d+[.]\d+$/)
|
38
|
+
|
39
|
+
def setup_fns
|
40
|
+
placeholder = config.placeholder || ""
|
41
|
+
str = ->(s) do
|
42
|
+
# normalize whitespace
|
43
|
+
if s.match?(/\s/)
|
44
|
+
s = s.strip.gsub("\n", "\\n").gsub("\r", "\\r")
|
45
|
+
end
|
46
|
+
# replace empty values with placeholder
|
47
|
+
s = placeholder if s.empty?
|
48
|
+
s
|
49
|
+
end
|
50
|
+
other = ->(x) { str.call(x.to_s) }
|
51
|
+
|
52
|
+
# default behavior
|
53
|
+
@fns = {other:, str:}
|
54
|
+
|
55
|
+
# now mix in optional float/time formatters
|
56
|
+
if config.digits
|
57
|
+
fmt = "%.#{config.digits}f"
|
58
|
+
@to_f_cache = Hash.new { _1[_2] = fmt % _2.to_f }
|
59
|
+
fns[:float] = -> { fmt % _1 }
|
60
|
+
fns[:floatstr] = -> { @to_f_cache[_1] }
|
61
|
+
end
|
62
|
+
if config.strftime
|
63
|
+
fns[:time] = -> { _1.strftime(config.strftime) }
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
module TableTennis
|
2
|
+
module Stage
|
3
|
+
# This stage figures out how wide each column should be. Most of the fun is
|
4
|
+
# in the autolayout behavior, which tries to fill the terminal without
|
5
|
+
# overflowing. Once we know the column sizes, it will go ahead and truncate
|
6
|
+
# the cells if necessary.
|
7
|
+
class Layout < Base
|
8
|
+
def_delegators :data, :chrome_width, :data_width
|
9
|
+
|
10
|
+
def run
|
11
|
+
# did the user specify a layout strategy?
|
12
|
+
if config.layout
|
13
|
+
case config.layout
|
14
|
+
when true then autolayout
|
15
|
+
when Hash then columns.each { _1.width = config.layout[_1.name] }
|
16
|
+
when Integer then columns.each { _1.width = config.layout }
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# fill in missing widths, and truncate if necessary
|
21
|
+
columns.each do
|
22
|
+
_1.width ||= _1.measure
|
23
|
+
_1.truncate(_1.width) if config.layout
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
#
|
28
|
+
# some math
|
29
|
+
#
|
30
|
+
|
31
|
+
AUTOLAYOUT_MIN_COLUMN_WIDTH = 2
|
32
|
+
AUTOLAYOUT_FUDGE = 2
|
33
|
+
|
34
|
+
# Fit columns into terminal width. This is copied from the very simple HTML
|
35
|
+
# table column algorithm. Returns a hash of column name to width.
|
36
|
+
def autolayout
|
37
|
+
# set provisional widths
|
38
|
+
columns.each { _1.width = _1.measure }
|
39
|
+
|
40
|
+
# how much space is available, and do we already fit?
|
41
|
+
screen_width = IO.console.winsize[1]
|
42
|
+
available = screen_width - chrome_width - AUTOLAYOUT_FUDGE
|
43
|
+
return if available >= data_width
|
44
|
+
|
45
|
+
# min/max column widths, which we use below
|
46
|
+
min = columns.map { [_1.width, AUTOLAYOUT_MIN_COLUMN_WIDTH].min }
|
47
|
+
max = columns.map(&:width)
|
48
|
+
|
49
|
+
# W = difference between the available space and the minimum table width
|
50
|
+
# D = difference between maximum and minimum width of the table
|
51
|
+
w = available - min.sum
|
52
|
+
d = max.sum - min.sum
|
53
|
+
|
54
|
+
# edge case if we don't even have enough room for min
|
55
|
+
if w <= 0
|
56
|
+
columns.each.with_index { _1.width = min[_2] }
|
57
|
+
return
|
58
|
+
end
|
59
|
+
|
60
|
+
# expand min to fit available space
|
61
|
+
columns.each.with_index do
|
62
|
+
# width = min + (delta * W / D)
|
63
|
+
_1.width = min[_2] + ((max[_2] - min[_2]) * w / d.to_f).to_i
|
64
|
+
end
|
65
|
+
|
66
|
+
# because we always round down, there might be some extra space to distribute
|
67
|
+
if (extra_space = available - data_width) > 0
|
68
|
+
distribute = columns.sort_by.with_index do |_, c|
|
69
|
+
[-(max[c] - min[c]), c]
|
70
|
+
end
|
71
|
+
distribute[0, extra_space].each { _1.width += 1 }
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
module TableTennis
|
2
|
+
module Stage
|
3
|
+
# This stage "paints" the table by calculating the style for cells, rows and
|
4
|
+
# columns. The styles are stuffed into `data.set_style`. Later, the
|
5
|
+
# rendering stage can apply them if color is enabled.
|
6
|
+
#
|
7
|
+
# A "style" is either:
|
8
|
+
#
|
9
|
+
# - a theme symbol (like :title or :chrome)
|
10
|
+
# - hex colors, for color scaling
|
11
|
+
#
|
12
|
+
# Other kinds of styles are theoretically possible but not tested.
|
13
|
+
class Painter < Base
|
14
|
+
def_delegators :data, :set_style
|
15
|
+
|
16
|
+
def run
|
17
|
+
return if !config.color
|
18
|
+
paint_title if config.title
|
19
|
+
paint_row_numbers if config.row_numbers
|
20
|
+
paint_rows if config.mark || config.zebra
|
21
|
+
paint_columns if config.color_scales
|
22
|
+
paint_placeholders if config.placeholder
|
23
|
+
end
|
24
|
+
|
25
|
+
protected
|
26
|
+
|
27
|
+
#
|
28
|
+
# helpers
|
29
|
+
#
|
30
|
+
|
31
|
+
def paint_title
|
32
|
+
set_style(r: :title, style: :title)
|
33
|
+
end
|
34
|
+
|
35
|
+
def paint_row_numbers
|
36
|
+
set_style(c: 0, style: :chrome)
|
37
|
+
end
|
38
|
+
|
39
|
+
def paint_rows
|
40
|
+
rows.each_index do |r|
|
41
|
+
style = nil
|
42
|
+
if config.zebra? && r.even?
|
43
|
+
style = :zebra
|
44
|
+
end
|
45
|
+
if (mark_style = config.mark&.call(input_rows[r]))
|
46
|
+
style = mark_style.is_a?(Symbol) ? mark_style : :mark
|
47
|
+
end
|
48
|
+
set_style(r:, style:) if style
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def paint_columns
|
53
|
+
columns.each.with_index do |column, c|
|
54
|
+
scale = config.color_scales[column.name]
|
55
|
+
next if !scale
|
56
|
+
|
57
|
+
floats = column.map { _1.to_f if _1 =~ /^-?\d+(\.\d+)?$/ }
|
58
|
+
min, max = floats.compact.minmax
|
59
|
+
next if min == max # edge case
|
60
|
+
|
61
|
+
# color
|
62
|
+
column.each_index.zip(floats).each do |r, f|
|
63
|
+
next if !f
|
64
|
+
t = (f - min) / (max - min)
|
65
|
+
bg = Util::Scale.interpolate(scale, t)
|
66
|
+
fg = Util::Colors.contrast(bg)
|
67
|
+
set_style(r:, c:, style: [fg, bg])
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def paint_placeholders
|
73
|
+
placeholder = config.placeholder
|
74
|
+
rows.each.with_index do |row, r|
|
75
|
+
row.each_value.with_index do |value, c|
|
76
|
+
if value == placeholder
|
77
|
+
set_style(r:, c:, style: :chrome)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,146 @@
|
|
1
|
+
module TableTennis
|
2
|
+
module Stage
|
3
|
+
# This is the final stage of the rending pipeline - take our layout
|
4
|
+
# information, the table cells, and the painted styles to render the table
|
5
|
+
# as a string. This is the slowest part of rendering since there is a lot of
|
6
|
+
# ansi/string manipulation.
|
7
|
+
#
|
8
|
+
# This is also where config.search is applied.
|
9
|
+
class Render < Base
|
10
|
+
BOX = [
|
11
|
+
"╭─┬─╮", # 0
|
12
|
+
"│ │ │", # 1
|
13
|
+
"├─┼─┤", # 2
|
14
|
+
"╰─┴─╯", # 3
|
15
|
+
]
|
16
|
+
|
17
|
+
# take these from BOX
|
18
|
+
NW, N, NE = BOX[0].chars.values_at(0, 2, 4)
|
19
|
+
W, C, E = BOX[2].chars.values_at(0, 2, 4)
|
20
|
+
SW, S, SE = BOX[3].chars.values_at(0, 2, 4)
|
21
|
+
|
22
|
+
# horizontal and vertical lines
|
23
|
+
BAR, PIPE = BOX[0][1], BOX[1][0]
|
24
|
+
|
25
|
+
def run(io)
|
26
|
+
# edge case - empty
|
27
|
+
if rows.empty? || columns.empty?
|
28
|
+
io.puts render_empty
|
29
|
+
return
|
30
|
+
end
|
31
|
+
|
32
|
+
if config.title
|
33
|
+
io.puts render_separator(NW, BAR, NE)
|
34
|
+
io.puts render_title
|
35
|
+
io.puts render_separator(W, N, E)
|
36
|
+
else
|
37
|
+
io.puts render_separator(NW, N, NE)
|
38
|
+
end
|
39
|
+
io.puts render_row(:header)
|
40
|
+
io.puts render_separator(W, C, E)
|
41
|
+
rows.each_index { io.puts render_row(_1) }
|
42
|
+
io.puts render_separator(SW, S, SE)
|
43
|
+
end
|
44
|
+
|
45
|
+
#
|
46
|
+
# render different parts of the table
|
47
|
+
#
|
48
|
+
|
49
|
+
def render_title
|
50
|
+
title_width = data.table_width - 4
|
51
|
+
title = Util::Strings.truncate(config.title, title_width)
|
52
|
+
title_style = data.get_style(r: :title) || :cell
|
53
|
+
line = paint(title.center(title_width), title_style || :cell)
|
54
|
+
paint("#{pipe} #{line} #{pipe}", Theme::BG)
|
55
|
+
end
|
56
|
+
|
57
|
+
def render_row(r)
|
58
|
+
row_style = data.get_style(r:)
|
59
|
+
|
60
|
+
# assemble line by rendering cells
|
61
|
+
enum = (r != :header) ? rows[r].each_value : columns.map(&:header)
|
62
|
+
line = enum.map.with_index do |value, c|
|
63
|
+
render_cell(value, r, c, row_style)
|
64
|
+
end.join(" #{pipe} ")
|
65
|
+
line = "#{pipe} #{line} #{pipe}"
|
66
|
+
|
67
|
+
# afterward, apply row color
|
68
|
+
paint(line, row_style || Theme::BG)
|
69
|
+
end
|
70
|
+
|
71
|
+
def render_cell(value, r, c, default_cell_style)
|
72
|
+
# calculate whitespace based on plaintext
|
73
|
+
whitespace = columns[c].width - Util::Strings.width(value)
|
74
|
+
|
75
|
+
# cell > column > default > cell (row styles are applied elsewhere)
|
76
|
+
style = nil
|
77
|
+
style ||= data.get_style(r:, c:)
|
78
|
+
style ||= data.get_style(c:)
|
79
|
+
style ||= default_cell_style
|
80
|
+
style ||= :cell
|
81
|
+
|
82
|
+
# add ansi codes for search
|
83
|
+
value = value.gsub(search) { paint(_1, :search) } if search
|
84
|
+
|
85
|
+
# pad and paint
|
86
|
+
value = "#{value}#{" " * whitespace}" if whitespace > 0
|
87
|
+
paint(value, style)
|
88
|
+
end
|
89
|
+
|
90
|
+
def render_separator(l, m, r)
|
91
|
+
line = [].tap do |buf|
|
92
|
+
columns.each.with_index do |column, c|
|
93
|
+
buf << ((c == 0) ? l : m)
|
94
|
+
buf << (BAR * (column.width + 2))
|
95
|
+
end
|
96
|
+
buf << r
|
97
|
+
end.join
|
98
|
+
paint(paint(line, :chrome), Theme::BG)
|
99
|
+
end
|
100
|
+
|
101
|
+
def render_empty
|
102
|
+
title, body = config.title || "empty table", "no data"
|
103
|
+
width = [title, body].map(&:length).max
|
104
|
+
|
105
|
+
# helpers
|
106
|
+
sep_row = ->(l, c, r) do
|
107
|
+
paint("#{l}#{c * (width + 2)}#{r}", :chrome)
|
108
|
+
end
|
109
|
+
text_row = ->(str, style) do
|
110
|
+
inner = paint(str.center(width), style)
|
111
|
+
paint("#{pipe} #{inner} #{pipe}", Theme::BG)
|
112
|
+
end
|
113
|
+
|
114
|
+
# go
|
115
|
+
[].tap do
|
116
|
+
_1 << sep_row.call(NW, BAR, NE)
|
117
|
+
_1 << text_row.call(title, data.get_style(r: :title) || :cell)
|
118
|
+
_1 << sep_row.call(W, BAR, E)
|
119
|
+
_1 << text_row.call(body, :chrome)
|
120
|
+
_1 << sep_row.call(SW, BAR, SE)
|
121
|
+
end.join("\n")
|
122
|
+
end
|
123
|
+
|
124
|
+
#
|
125
|
+
# helpers
|
126
|
+
#
|
127
|
+
|
128
|
+
def paint(str, style)
|
129
|
+
# delegate painting to the theme, if color is enabled
|
130
|
+
str = theme.paint(str, style) if config.color
|
131
|
+
str
|
132
|
+
end
|
133
|
+
|
134
|
+
def pipe = paint(PIPE, :chrome)
|
135
|
+
memo_wise :pipe
|
136
|
+
|
137
|
+
def search
|
138
|
+
case config.search
|
139
|
+
when String then /#{Regexp.escape(config.search)}/i
|
140
|
+
when Regexp then config.search
|
141
|
+
end
|
142
|
+
end
|
143
|
+
memo_wise :search
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
#
|
2
|
+
# Welcome to TableTennis! Use as follows:
|
3
|
+
#
|
4
|
+
# puts TableTennis.new(array_of_hashes, options = {})
|
5
|
+
#
|
6
|
+
# See the README for details on options - _color_scales_, _color_, _columns_,
|
7
|
+
# _debug_, _digits_, _layout_, _mark_, _placeholder_, _row_numbers_, _save_,
|
8
|
+
# _search_, _strftime_, _theme_, _title_, _zebra_
|
9
|
+
#
|
10
|
+
|
11
|
+
module TableTennis
|
12
|
+
# Public API for TableTennis.
|
13
|
+
class Table
|
14
|
+
extend Forwardable
|
15
|
+
include Util::Inspectable
|
16
|
+
|
17
|
+
attr_reader :data
|
18
|
+
def_delegators :data, *%i[column_names columns config input_rows rows]
|
19
|
+
def_delegators :data, *%i[debug debug_if_slow]
|
20
|
+
|
21
|
+
# Create a new table with options (see Config or README). This is typically
|
22
|
+
# called using TableTennis.new.
|
23
|
+
def initialize(input_rows, options = {}, &block)
|
24
|
+
config = Config.new(options, &block)
|
25
|
+
@data = TableData.new(config:, input_rows:)
|
26
|
+
sanity!
|
27
|
+
save(config.save) if config.save
|
28
|
+
end
|
29
|
+
|
30
|
+
# Render the table to $stdout or another IO object.
|
31
|
+
def render(io = $stdout)
|
32
|
+
%w[format layout painter render].each do |stage|
|
33
|
+
args = [].tap do
|
34
|
+
_1 << io if stage == "render"
|
35
|
+
end
|
36
|
+
Stage.const_get(stage.capitalize).new(data).run(*args)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Save the table as a CSV file. Users can also do this manually.
|
41
|
+
def save(path)
|
42
|
+
headers = column_names
|
43
|
+
CSV.open(path, "wb", headers:, write_headers: true) do |csv|
|
44
|
+
rows.each { csv << _1.values }
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Calls render to convert the table to a string.
|
49
|
+
def to_s
|
50
|
+
StringIO.new.tap { render(_1) }.string
|
51
|
+
end
|
52
|
+
|
53
|
+
protected
|
54
|
+
|
55
|
+
# we cnan do a bit more config checking now
|
56
|
+
def sanity!
|
57
|
+
%i[color_scales layout].each do |key|
|
58
|
+
next if !config[key].is_a?(Hash)
|
59
|
+
next if rows.empty? # ignore on empty data
|
60
|
+
invalid = config[key].keys - data.column_names
|
61
|
+
if !invalid.empty?
|
62
|
+
raise ArgumentError, "#{key} columns `#{invalid.join(", ")}` not found in input data"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
class << self
|
69
|
+
#
|
70
|
+
# Welcome to TableTennis! Use as follows:
|
71
|
+
#
|
72
|
+
# puts TableTennis.new(array_of_hashes_or_records, options = {})
|
73
|
+
#
|
74
|
+
# See the README for details on options - _color_scales_, _color_,
|
75
|
+
# _columns_, _debug_, _digits_, _layout_, _mark_, _placeholder_,
|
76
|
+
# _row_numbers_, _save_, _search_, _strftime_, _theme_, _title_, _zebra_
|
77
|
+
def new(*args, &block) = Table.new(*args, &block)
|
78
|
+
end
|
79
|
+
end
|