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.
@@ -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,9 @@
1
+ module TableTennis
2
+ # A single table row (a hash). Doesn't have much behavior.
3
+ class Row < Hash
4
+ def initialize(column_names, fat_row)
5
+ super()
6
+ column_names.each { self[_1] = fat_row[_1] }
7
+ end
8
+ end
9
+ 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