table_tennis 0.0.5 → 0.0.6
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/.github/workflows/test.yml +2 -3
- data/.rubocop.yml +1 -0
- data/Gemfile +11 -11
- data/README.md +27 -11
- data/justfile +1 -0
- data/lib/table_tennis/column.rb +3 -3
- data/lib/table_tennis/config.rb +58 -168
- data/lib/table_tennis/stage/format.rb +13 -13
- data/lib/table_tennis/table.rb +11 -9
- data/lib/table_tennis/theme.rb +10 -1
- data/lib/table_tennis/util/magic_options.rb +255 -0
- data/lib/table_tennis/util/strings.rb +4 -2
- data/lib/table_tennis/version.rb +1 -1
- data/lib/table_tennis.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b141b15004b90e5acaf6f4ed92d7f71a49fbb8376875fd50cf5ed1d8e555c365
|
4
|
+
data.tar.gz: f9dd1e36fa97767addcce2abbfca0eb721137d6c87f0556f0ed79ca8677fa15c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: eba4146e9b2953bef53d958b7d3bab8d65f25be9c0011309cc511f3ecc254385dc3193f9f84c12c6d768010a9d7b4ae35d49530c6a2756906eb50aeb756d72db
|
7
|
+
data.tar.gz: f7d7262f3afb54a563a73d5e7279052e6dd2f4fb39a4e916994ee35f966b1f36ff7a45e22eb18619db43fe6cc516f0f34a714bb17614fcd03d1562a20fcd0ede
|
data/.github/workflows/test.yml
CHANGED
data/.rubocop.yml
CHANGED
@@ -21,6 +21,7 @@ AllCops:
|
|
21
21
|
|
22
22
|
# we like these, don't remove
|
23
23
|
Bundler/OrderedGems: { Enabled: true } # sort gems in gemfile
|
24
|
+
Bundler/GemVersion: { Enabled: true } # make sure we have versions
|
24
25
|
Layout/EmptyLineBetweenDefs: { AllowAdjacentOneLineDefs: true }
|
25
26
|
Lint/NonLocalExitFromIterator: { Enabled: false }
|
26
27
|
Lint/RedundantDirGlobSort: { Enabled: true } # glob is already sorted
|
data/Gemfile
CHANGED
@@ -2,15 +2,15 @@ source "https://rubygems.org"
|
|
2
2
|
gemspec
|
3
3
|
|
4
4
|
group :development, :test do
|
5
|
-
gem "amazing_print"
|
6
|
-
gem "image_optim"
|
7
|
-
gem "image_optim_pack"
|
8
|
-
gem "minitest"
|
9
|
-
gem "minitest-hooks"
|
10
|
-
gem "mocha"
|
11
|
-
gem "ostruct" # required for Ruby 3.5+
|
12
|
-
gem "pry"
|
13
|
-
gem "rake"
|
14
|
-
gem "simplecov", require: false
|
15
|
-
gem "standard", require: false, platform: :mri
|
5
|
+
gem "amazing_print", "~> 1.7"
|
6
|
+
gem "image_optim", "~> 0.31"
|
7
|
+
gem "image_optim_pack", "~> 0.12"
|
8
|
+
gem "minitest", "~> 5.25"
|
9
|
+
gem "minitest-hooks", "~> 1.5"
|
10
|
+
gem "mocha", "~> 2.7"
|
11
|
+
gem "ostruct", "~> 0.6" # required for Ruby 3.5+
|
12
|
+
gem "pry", "~> 0.15"
|
13
|
+
gem "rake", "~> 13.2"
|
14
|
+
gem "simplecov", "~> 0.22", require: false
|
15
|
+
gem "standard", "~> 1.49", require: false, platform: :mri
|
16
16
|
end
|
data/README.md
CHANGED
@@ -67,25 +67,34 @@ options = {
|
|
67
67
|
}
|
68
68
|
```
|
69
69
|
|
70
|
+
#### Popular Options
|
71
|
+
|
70
72
|
| option | default | details |
|
71
73
|
| ------ | ------- | ------- |
|
72
74
|
| `color_scales` | ─ | Color code a column of floats, similar to the "conditional formatting" feature in Google Sheets. See [docs below](#color-scales). |
|
73
|
-
| `
|
74
|
-
| `
|
75
|
-
| `
|
75
|
+
| `columns` | ─ | Manually set which columns to include. Leave unset to show all columns.
|
76
|
+
| `headers` | ─ | Specify some or all column headers. For example, `{user_id: "Customer"}`. When unset, headers are inferred. |
|
77
|
+
| `mark` | ─ | `mark` is a way to highlight specific columns with a nice color. For example, use `mark: ->(row) { row[:planet] == "tatooine" }` to highlight those rows. Your lambda can also return a specific bg color or Paint color array.
|
78
|
+
| `row_numbers` | `false` | Show row numbers in the table. |
|
79
|
+
| `search` | ─ | String/regex to highlight in output. |
|
80
|
+
| `separators` | `true` | Include column and header separators in output. |
|
81
|
+
| `title` | ─ | Add a title line to the table. |
|
82
|
+
| `titleize` | ─ | Titleize column headers, so `person_id` becomes `Person`. |
|
83
|
+
| `zebra` | `false` | Turn on zebra stripes. |
|
84
|
+
|
85
|
+
#### More Advanced Options
|
86
|
+
|
87
|
+
| option | default | details |
|
88
|
+
| ------ | ------- | ------- |
|
89
|
+
| `coerce` | `true` | if true, try to coerce strings into numbers where possible so we can format with `digits`. You may want to disable this if you already have nicely formatted ints/floats and you don't want TableTennis to mess with them. |
|
90
|
+
| `color` | ─ | Are ANSI colors enabled? Specify `true` or `false`, or leave it as nil to autodetect. Autodetect will turn on color unless redirecting to a file. When using autodetect, you can force it on by setting `ENV["FORCE_COLOR"]`, or off with `ENV["NO_COLOR"]`. |
|
91
|
+
| `delims` | `true` | Format ints & floats with comma delimiter, like 123,456. |
|
76
92
|
| `digits` | `3` | Format floats to this number of digits. TableTennis will look for either `Float` cells or string floats. |
|
77
93
|
| `layout` | `true` | This controls column widths. Leave unset or use `true` for autolayout. Autolayout will shrink the table to fit inside the terminal. `false` turns off layout and columns will be full width. Use an int to fix all columns to a certain width, or a hash to just set a few. |
|
78
|
-
| `mark` | ─ | `mark` is a way to highlight specific columns with a nice color. For example, use `mark: ->(row) { row[:planet] == "tatooine" }` to highlight those rows. Your lambda can also return a specific color if you want.
|
79
94
|
| `placeholder` | `"—"` | Put this into empty cells. |
|
80
|
-
| `row_numbers` | `false` | Show row numbers in the table. |
|
81
95
|
| `save` | ─ | If you set this to a file path, TableTennis will save your table as a CSV file too. Useful if you want to do something else with the data. |
|
82
|
-
| `search` | ─ | string/regex to highlight in output |
|
83
|
-
| `separators` | `true` | Include column and header separators in output. |
|
84
96
|
| `strftime` | see → | strftime string for formatting Date/Time objects. The default is `"%Y-%m-%d"`, which looks like `2025-04-21` |
|
85
|
-
| `theme` |
|
86
|
-
| `title` | ─ | Add a title line to the table. |
|
87
|
-
| `titleize` | ─ | Titleize column names, so `person_id` becomes `Person`. |
|
88
|
-
| `zebra` | `false` | Turn on zebra stripes. |
|
97
|
+
| `theme` | ─ | When unset, will be autodetected based on terminal background color. If autodetect fails the theme defaults to :dark. You can also manually specify `:dark`, `:light` or `:ansi`. If colors are turned off this setting has no effect.|
|
89
98
|
|
90
99
|
### Color Scales
|
91
100
|
|
@@ -163,6 +172,13 @@ We love CSV tools and use them all the time! Here are a few that we rely on:
|
|
163
172
|
|
164
173
|
### Changelog
|
165
174
|
|
175
|
+
#### 0.0.6 (May '25)
|
176
|
+
|
177
|
+
- added `coerce:` option to disable string => numeric coercion
|
178
|
+
- added `headers:` option to manually set column headers
|
179
|
+
- fixed some issues related to links, including `save:`
|
180
|
+
- added MagicOptions
|
181
|
+
|
166
182
|
#### 0.0.5 (April '25)
|
167
183
|
|
168
184
|
- Support for markdown style links in the cells
|
data/justfile
CHANGED
data/lib/table_tennis/column.rb
CHANGED
@@ -10,12 +10,12 @@ module TableTennis
|
|
10
10
|
# c is the column index
|
11
11
|
attr_reader :name, :data, :c
|
12
12
|
attr_accessor :header, :width
|
13
|
-
def_delegators :data, *%i[rows]
|
13
|
+
def_delegators :data, *%i[config rows]
|
14
14
|
|
15
15
|
def initialize(data, name, c)
|
16
16
|
@name, @data, @c = name, data, c
|
17
|
-
@header = name.to_s
|
18
|
-
if
|
17
|
+
@header = config&.headers&.dig(name) || name.to_s
|
18
|
+
if config&.titleize?
|
19
19
|
@header = Util::Strings.titleize(@header)
|
20
20
|
end
|
21
21
|
end
|
data/lib/table_tennis/config.rb
CHANGED
@@ -3,15 +3,17 @@ module TableTennis
|
|
3
3
|
attr_accessor :defaults
|
4
4
|
end
|
5
5
|
|
6
|
-
#
|
7
|
-
class Config
|
6
|
+
# Table configuration options, with schema validation courtesy of MagicOptions.
|
7
|
+
class Config < Util::MagicOptions
|
8
8
|
OPTIONS = {
|
9
|
+
coerce: true, # if true, we try to coerce strings into numbers
|
9
10
|
color_scales: nil, # columns => color scale
|
10
11
|
color: nil, # true/false/nil (detect)
|
11
12
|
columns: nil, # array of symbols, or inferred from rows
|
12
13
|
debug: false, # true for debug output
|
13
14
|
delims: true, # true for numeric delimeters
|
14
15
|
digits: 3, # format floats
|
16
|
+
headers: nil, # columns => header strings, or inferred from columns
|
15
17
|
layout: true, # true/false/int or hash of columns -> width. true to infer
|
16
18
|
mark: nil, # lambda returning boolean or symbol to mark rows in output
|
17
19
|
placeholder: "—", # placeholder for empty cells. default is emdash
|
@@ -26,206 +28,94 @@ module TableTennis
|
|
26
28
|
zebra: false, # turn on zebra stripes
|
27
29
|
}.freeze
|
28
30
|
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
# readers
|
40
|
-
attr_reader(*OPTIONS.keys)
|
41
|
-
|
42
|
-
#
|
43
|
-
# simple writers
|
44
|
-
#
|
45
|
-
|
46
|
-
{
|
31
|
+
SCHEMA = {
|
32
|
+
coerce: :bool,
|
33
|
+
color_scales: ->(value) do
|
34
|
+
if (error = Config.magic_validate(value, {Symbol => Symbol}))
|
35
|
+
error
|
36
|
+
elsif value.values.any? { !Util::Scale::SCALES.include?(_1) }
|
37
|
+
"values must be the name of a color scale"
|
38
|
+
end
|
39
|
+
end,
|
47
40
|
color: :bool,
|
41
|
+
columns: :symbols,
|
48
42
|
debug: :bool,
|
49
43
|
delims: :bool,
|
50
|
-
digits:
|
44
|
+
digits: (0..10),
|
45
|
+
headers: {sym: :str},
|
46
|
+
layout: -> do
|
47
|
+
return if _1 == true || _1 == false || _1.is_a?(Integer)
|
48
|
+
Config.magic_validate(_1, {Symbol => Integer})
|
49
|
+
end,
|
51
50
|
mark: :proc,
|
52
51
|
placeholder: :str,
|
53
52
|
row_numbers: :bool,
|
54
53
|
save: :str,
|
54
|
+
search: -> do
|
55
|
+
if !(_1.is_a?(String) || _1.is_a?(Regexp))
|
56
|
+
"expected string/regex"
|
57
|
+
end
|
58
|
+
end,
|
55
59
|
separators: :bool,
|
56
60
|
strftime: :str,
|
61
|
+
theme: %i[dark light ansi],
|
57
62
|
title: :str,
|
58
63
|
titleize: :bool,
|
59
64
|
zebra: :bool,
|
60
|
-
}
|
61
|
-
define_method(:"#{option}=") do |value|
|
62
|
-
instance_variable_set(:"@#{option}", send(:"_#{type}", option, value))
|
63
|
-
end
|
64
|
-
alias_method(:"#{option}?", option) if type == :bool
|
65
|
-
end
|
66
|
-
|
67
|
-
#
|
68
|
-
# helpers
|
69
|
-
#
|
70
|
-
|
71
|
-
# is this a dark terminal?
|
72
|
-
def self.terminal_dark?
|
73
|
-
if (bg = Util::Termbg.bg)
|
74
|
-
Util::Colors.dark?(bg)
|
75
|
-
end
|
76
|
-
end
|
65
|
+
}
|
77
66
|
|
78
|
-
def
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
def self.detect_theme
|
86
|
-
case terminal_dark?
|
87
|
-
when true, nil then :dark
|
88
|
-
when false then :light
|
89
|
-
end
|
67
|
+
def initialize(options = {}, &block)
|
68
|
+
# assemble from OPTIONS, defaults and options
|
69
|
+
options = [OPTIONS, TableTennis.defaults, options].reduce { _1.merge(_2 || {}) }
|
70
|
+
options[:color] = Config.detect_color? if options[:color].nil?
|
71
|
+
options[:debug] = true if ENV["TT_DEBUG"]
|
72
|
+
options[:theme] = Config.detect_theme if options[:theme].nil?
|
73
|
+
super(SCHEMA, options, &block)
|
90
74
|
end
|
91
75
|
|
92
76
|
#
|
93
|
-
#
|
77
|
+
# override a few setters to coerce values
|
94
78
|
#
|
95
79
|
|
96
80
|
def color_scales=(value)
|
97
|
-
|
98
|
-
|
99
|
-
when Symbol then value = {value => :g}
|
100
|
-
end
|
101
|
-
value.to_h { [_1, :g] } if value.is_a?(Array)
|
102
|
-
@color_scales = validate(:color_scales, value) do
|
103
|
-
if !value.is_a?(Hash)
|
104
|
-
"expected hash"
|
105
|
-
elsif value.keys.any? { !_1.is_a?(Symbol) }
|
106
|
-
"keys must be symbols"
|
107
|
-
elsif value.values.any? { !_1.is_a?(Symbol) }
|
108
|
-
"values must be symbols"
|
109
|
-
elsif value.values.any? { !Util::Scale::SCALES.include?(_1) }
|
110
|
-
"values must be the name of a color scale"
|
111
|
-
end
|
81
|
+
if value.is_a?(Array) || value.is_a?(Symbol)
|
82
|
+
value = Array(value).to_h { [_1, :g] }
|
112
83
|
end
|
84
|
+
self[:color_scales] = value
|
113
85
|
end
|
114
|
-
alias_method(:"color_scale=", :"color_scales=")
|
115
86
|
|
116
|
-
def
|
117
|
-
|
118
|
-
|
119
|
-
"expected array of symbols"
|
120
|
-
end
|
121
|
-
end
|
87
|
+
def placeholder=(value)
|
88
|
+
value = "" if value.nil?
|
89
|
+
self[:placeholder] = value
|
122
90
|
end
|
123
91
|
|
124
|
-
def
|
125
|
-
|
126
|
-
|
127
|
-
"expected symbol"
|
128
|
-
elsif !Theme::THEMES.key?(value)
|
129
|
-
"expected one of #{Theme::THEMES.keys.inspect}"
|
130
|
-
end
|
131
|
-
end
|
92
|
+
def title=(value)
|
93
|
+
value = value.to_s if value.is_a?(Symbol)
|
94
|
+
self[:title] = value
|
132
95
|
end
|
133
96
|
|
134
|
-
def search=(value)
|
135
|
-
@search = validate(:search, value) do
|
136
|
-
if !(value.is_a?(String) || value.is_a?(Regexp))
|
137
|
-
"expected string/regex"
|
138
|
-
end
|
139
|
-
end
|
140
|
-
end
|
141
|
-
|
142
|
-
def layout=(value)
|
143
|
-
@layout = validate(:layout, value) do
|
144
|
-
next if [true, false].include?(value) || value.is_a?(Integer)
|
145
|
-
if !value.is_a?(Hash)
|
146
|
-
"expected boolean, int or hash"
|
147
|
-
elsif value.keys.any? { !_1.is_a?(Symbol) }
|
148
|
-
"keys must be symbols"
|
149
|
-
elsif value.values.any? { !_1.is_a?(Integer) }
|
150
|
-
"values must be ints"
|
151
|
-
end
|
152
|
-
end
|
153
|
-
end
|
154
|
-
|
155
|
-
def [](key)
|
156
|
-
raise ArgumentError, "unknown TableTennis.#{key}" if !respond_to?(key)
|
157
|
-
send(key)
|
158
|
-
end
|
159
|
-
|
160
|
-
def []=(key, value)
|
161
|
-
raise ArgumentError, "unknown TableTennis.#{key}=" if !respond_to?(:"#{key}=")
|
162
|
-
send(:"#{key}=", value)
|
163
|
-
end
|
164
|
-
|
165
|
-
def inspect
|
166
|
-
options = to_h.map { "@#{_1}=#{_2.inspect}" }.join(", ")
|
167
|
-
"#<Config #{options}>"
|
168
|
-
end
|
169
|
-
|
170
|
-
def to_h
|
171
|
-
OPTIONS.keys.to_h { [_1, self[_1]] }.compact
|
172
|
-
end
|
173
|
-
|
174
|
-
protected
|
175
|
-
|
176
97
|
#
|
177
|
-
#
|
98
|
+
# helpers
|
178
99
|
#
|
179
100
|
|
180
|
-
def
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
end
|
186
|
-
|
187
|
-
def _bool(option, value)
|
188
|
-
value = case value
|
189
|
-
when true, 1, "1", "true" then true
|
190
|
-
when false, 0, "", "0", "false" then false
|
191
|
-
else; value # this will turn into an error down below
|
192
|
-
end
|
193
|
-
validate(option, value) do
|
194
|
-
"expected boolean" if ![true, false].include?(value)
|
195
|
-
end
|
196
|
-
end
|
197
|
-
|
198
|
-
def _int(option, value)
|
199
|
-
validate(option, value) do
|
200
|
-
if !value.is_a?(Integer)
|
201
|
-
"expected int"
|
202
|
-
elsif value < 0
|
203
|
-
"expected positive int"
|
204
|
-
end
|
205
|
-
end
|
206
|
-
end
|
207
|
-
|
208
|
-
def _proc(option, value)
|
209
|
-
validate(option, value) do
|
210
|
-
"expected proc" if !value.is_a?(Proc)
|
211
|
-
end
|
101
|
+
def self.detect_color?
|
102
|
+
return false if ENV["NO_COLOR"] || ENV["CI"]
|
103
|
+
return true if ENV["FORCE_COLOR"] == "1"
|
104
|
+
return false if !($stdout.tty? && $stderr.tty?)
|
105
|
+
Paint.detect_mode > 0
|
212
106
|
end
|
213
107
|
|
214
|
-
def
|
215
|
-
case
|
216
|
-
when :
|
217
|
-
|
218
|
-
when :title
|
219
|
-
value = value.to_s if value.is_a?(Symbol)
|
220
|
-
end
|
221
|
-
validate(option, value) do
|
222
|
-
"expected string" if !value.is_a?(String)
|
108
|
+
def self.detect_theme
|
109
|
+
case terminal_dark?
|
110
|
+
when true, nil then :dark
|
111
|
+
when false then :light
|
223
112
|
end
|
224
113
|
end
|
225
114
|
|
226
|
-
|
227
|
-
|
228
|
-
|
115
|
+
# is this a dark terminal?
|
116
|
+
def self.terminal_dark?
|
117
|
+
if (bg = Util::Termbg.bg)
|
118
|
+
Util::Colors.dark?(bg)
|
229
119
|
end
|
230
120
|
end
|
231
121
|
end
|
@@ -27,7 +27,7 @@ module TableTennis
|
|
27
27
|
str ||= fn_default(value) || config.placeholder
|
28
28
|
|
29
29
|
# look for markdown-style links
|
30
|
-
if (link =
|
30
|
+
if (link = Util::Strings.hyperlink(str))
|
31
31
|
str, data.links[[r, c]] = link
|
32
32
|
end
|
33
33
|
|
@@ -46,16 +46,24 @@ module TableTennis
|
|
46
46
|
@_memo_wise[__method__].tap { _1.clear if _1.length > 5000 }
|
47
47
|
|
48
48
|
case value
|
49
|
-
when String
|
50
|
-
|
49
|
+
when String
|
50
|
+
if config.coerce && Util::Identify.number?(value)
|
51
|
+
fmt_number(to_f(value), digits: config.digits)
|
52
|
+
end
|
53
|
+
when Numeric
|
54
|
+
fmt_number(value, digits: config.digits)
|
51
55
|
end
|
52
56
|
end
|
53
57
|
memo_wise :fn_float
|
54
58
|
|
55
59
|
def fn_int(value)
|
56
60
|
case value
|
57
|
-
when String
|
58
|
-
|
61
|
+
when String
|
62
|
+
if config.coerce && Util::Identify.int?(value)
|
63
|
+
fmt_number(to_i(value))
|
64
|
+
end
|
65
|
+
when Integer
|
66
|
+
fmt_number(value)
|
59
67
|
end
|
60
68
|
end
|
61
69
|
|
@@ -100,14 +108,6 @@ module TableTennis
|
|
100
108
|
x
|
101
109
|
end
|
102
110
|
|
103
|
-
def detect_link(str)
|
104
|
-
# fail fast, for speed
|
105
|
-
return unless str.length >= 6 && str[0] == "["
|
106
|
-
if str =~ /^\[([^\]]+)\]\(([^\)]+)\)$/
|
107
|
-
[$1, $2]
|
108
|
-
end
|
109
|
-
end
|
110
|
-
|
111
111
|
# str to_xxx that are resistant to commas
|
112
112
|
def to_f(str) = str.delete(",").to_f
|
113
113
|
def to_i(str) = str.delete(",").to_i
|
data/lib/table_tennis/table.rb
CHANGED
@@ -3,10 +3,6 @@
|
|
3
3
|
#
|
4
4
|
# puts TableTennis.new(array_of_hashes, options = {})
|
5
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
6
|
|
11
7
|
module TableTennis
|
12
8
|
# Public API for TableTennis.
|
@@ -41,7 +37,16 @@ module TableTennis
|
|
41
37
|
def save(path)
|
42
38
|
headers = column_names
|
43
39
|
CSV.open(path, "wb", headers:, write_headers: true) do |csv|
|
44
|
-
rows.each
|
40
|
+
rows.each do |row|
|
41
|
+
# strip hyperlinks
|
42
|
+
row = row.map do |value|
|
43
|
+
if value.is_a?(String) && (h = Util::Strings.hyperlink(value))
|
44
|
+
value = h[1]
|
45
|
+
end
|
46
|
+
value
|
47
|
+
end
|
48
|
+
csv << row
|
49
|
+
end
|
45
50
|
end
|
46
51
|
end
|
47
52
|
|
@@ -54,7 +59,7 @@ module TableTennis
|
|
54
59
|
|
55
60
|
# we cnan do a bit more config checking now
|
56
61
|
def sanity!
|
57
|
-
%i[color_scales layout].each do |key|
|
62
|
+
%i[color_scales headers layout].each do |key|
|
58
63
|
next if !config[key].is_a?(Hash)
|
59
64
|
next if rows.empty? # ignore on empty data
|
60
65
|
invalid = config[key].keys - data.column_names
|
@@ -71,9 +76,6 @@ module TableTennis
|
|
71
76
|
#
|
72
77
|
# puts TableTennis.new(array_of_hashes_or_records, options = {})
|
73
78
|
#
|
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
79
|
def new(*args, &block) = Table.new(*args, &block)
|
78
80
|
end
|
79
81
|
end
|
data/lib/table_tennis/theme.rb
CHANGED
@@ -101,9 +101,18 @@ module TableTennis
|
|
101
101
|
# use osc 8 to create a terminal hyperlink. underline too
|
102
102
|
def link(str, link)
|
103
103
|
linked = "#{OSC_8}#{link}#{ST}#{str}#{OSC_8}#{ST}"
|
104
|
-
|
104
|
+
# underline if aren't on iterm
|
105
|
+
linked = Paint[linked, :underline] if term_program != :iterm
|
106
|
+
linked
|
105
107
|
end
|
106
108
|
|
109
|
+
def term_program
|
110
|
+
if ENV["TERM_PROGRAM"] == "iTerm.app"
|
111
|
+
:iterm
|
112
|
+
end
|
113
|
+
end
|
114
|
+
memo_wise :term_program
|
115
|
+
|
107
116
|
# for debugging, mostly
|
108
117
|
def self.info
|
109
118
|
sample = if !Config.detect_color?
|
@@ -0,0 +1,255 @@
|
|
1
|
+
#
|
2
|
+
# Helper class for validated option processing. This is used by Config but could probably be a
|
3
|
+
# custom gem at some point...
|
4
|
+
#
|
5
|
+
# MagicOptions is created with a `schema` defining a list of `attributes`. Each attribute has a
|
6
|
+
# `name` and a `type`. `options` is a hash of values that will be validated against the schema.
|
7
|
+
# MagicOptions adds getters and setters for each attribute, and also supports [] and []=. The
|
8
|
+
# setters perform validation and raise ArgumentError if something is awry. Because setters always
|
9
|
+
# validate, it is not possible to populate MagicOptions with invalid values.
|
10
|
+
#
|
11
|
+
# To use, subclass MagicOptions and construct with a schema:
|
12
|
+
#
|
13
|
+
# class Config < MagicOptions
|
14
|
+
# def initialize
|
15
|
+
# super(
|
16
|
+
# first_name: :str,
|
17
|
+
# colors: :strings,
|
18
|
+
# customer: :bool,
|
19
|
+
# age: (20..90)
|
20
|
+
# )
|
21
|
+
# end
|
22
|
+
# end
|
23
|
+
#
|
24
|
+
# Then assign values:
|
25
|
+
#
|
26
|
+
# config = Config.new
|
27
|
+
# config.colors = %w[red white blue]
|
28
|
+
# config.update!(first_name: "john", customer: false)
|
29
|
+
#
|
30
|
+
# Here are the supported attribute types:
|
31
|
+
#
|
32
|
+
# (1) A simple type like :bool, :int, :num, :float, :str or :sym.
|
33
|
+
# (2) An array type like :bools, :ints, :nums, :floats, :strs, or :syms.
|
34
|
+
# (3) A range, regexp or Class.
|
35
|
+
# (4) A lambda which should return an error string or nil.
|
36
|
+
# (5) An array of possible values (typically numbers, strings, or symbols). The
|
37
|
+
# value must be one of those possibilities.
|
38
|
+
# (6) A hash with one element { class => class }. This specifies the hash
|
39
|
+
# signature, and the value must be a hash where the keys and values are
|
40
|
+
# those classes.
|
41
|
+
#
|
42
|
+
# There is a bit of type coercion, but not much. For example, the string "true" or "1" will be
|
43
|
+
# coerced to true for boolean options. Integers can be used when the schema calls for floats.
|
44
|
+
#
|
45
|
+
|
46
|
+
module TableTennis
|
47
|
+
module Util
|
48
|
+
class MagicOptions
|
49
|
+
#
|
50
|
+
# public api (also see [] and []=)
|
51
|
+
#
|
52
|
+
|
53
|
+
attr_accessor :magic_attributes, :magic_values
|
54
|
+
|
55
|
+
def initialize(schema, options = {}, &block)
|
56
|
+
@magic_attributes, @magic_values = {}, {}
|
57
|
+
|
58
|
+
if self.class == MagicOptions # rubocop:disable Style/ClassEqualityComparison
|
59
|
+
raise ArgumentError, "MagicOptions is an abstract class"
|
60
|
+
end
|
61
|
+
|
62
|
+
schema.each { magic_add_attribute(_1, _2) }
|
63
|
+
update!(options) if options
|
64
|
+
yield self if block_given?
|
65
|
+
end
|
66
|
+
|
67
|
+
def update!(hash) = hash.each { send("#{_1}=", _2) }
|
68
|
+
def to_h = magic_values.dup
|
69
|
+
|
70
|
+
def inspect
|
71
|
+
values = magic_values.compact.map { "#{_1}=#{_2.inspect}" }.join(", ")
|
72
|
+
"#<#{self.class} #{values}>"
|
73
|
+
end
|
74
|
+
|
75
|
+
#
|
76
|
+
# magic_add_attribute
|
77
|
+
#
|
78
|
+
|
79
|
+
def magic_add_attribute(name, type)
|
80
|
+
# resolve :boolean to :bool, :int => Integer class, etc.
|
81
|
+
type = if type.is_a?(Hash)
|
82
|
+
type.to_h { [self.class.magic_resolve(_1), self.class.magic_resolve(_2)] }
|
83
|
+
else
|
84
|
+
self.class.magic_resolve(type)
|
85
|
+
end
|
86
|
+
|
87
|
+
# sanity check for schema errors
|
88
|
+
if (error = magic_sanity(name, type))
|
89
|
+
raise ArgumentError, "MagicOptions schema #{name.inspect} #{error}"
|
90
|
+
end
|
91
|
+
|
92
|
+
# all is well
|
93
|
+
magic_attributes[name] = type
|
94
|
+
if !respond_to?(name)
|
95
|
+
define_singleton_method(name) { self[name] }
|
96
|
+
end
|
97
|
+
if type == :bool && !respond_to?("#{name}?")
|
98
|
+
define_singleton_method("#{name}?") { !!self[name] }
|
99
|
+
end
|
100
|
+
if !respond_to?("#{name}=")
|
101
|
+
define_singleton_method("#{name}=") { |value| self[name] = value }
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# sanity check a name/type from the schema
|
106
|
+
def magic_sanity(name, type)
|
107
|
+
if !name.is_a?(Symbol)
|
108
|
+
return "attribute names must be symbols"
|
109
|
+
end
|
110
|
+
if !name.to_s.match?(/\A[a-z_][0-9a-z_]*\z/i)
|
111
|
+
return "attribute names must be valid method names"
|
112
|
+
end
|
113
|
+
|
114
|
+
case type
|
115
|
+
when :bool, :bools, :floats, :ints, :nums, :strs, :syms, Class, Proc, Range, Regexp
|
116
|
+
return
|
117
|
+
when Array
|
118
|
+
"must be an array of possible values" if type.empty?
|
119
|
+
when Hash
|
120
|
+
valid = type.length == 1 && type.first.all? { _1 == :bool || _1.is_a?(Class) }
|
121
|
+
"must be { class => class }" if !valid
|
122
|
+
else
|
123
|
+
"unknown schema type #{type.inspect}"
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
#
|
128
|
+
# magic_get/set
|
129
|
+
#
|
130
|
+
|
131
|
+
def magic_get(name)
|
132
|
+
raise ArgumentError, "unknown #{self.class}.#{name}" if !magic_attributes.key?(name)
|
133
|
+
magic_values[name]
|
134
|
+
end
|
135
|
+
|
136
|
+
def magic_set(name, value)
|
137
|
+
raise ArgumentError, "unknown #{self.class}.#{name}=" if !magic_attributes.key?(name)
|
138
|
+
type = magic_attributes[name]
|
139
|
+
value = self.class.magic_coerce(value, type)
|
140
|
+
if !value.nil? && (error = MagicOptions.magic_validate(value, type))
|
141
|
+
if !type.is_a?(Proc)
|
142
|
+
error = "#{error}, got #{value.inspect}"
|
143
|
+
end
|
144
|
+
raise ArgumentError, "#{self.class}.#{name}= #{error}"
|
145
|
+
end
|
146
|
+
magic_values[name] = value
|
147
|
+
end
|
148
|
+
|
149
|
+
# these are part of the public api
|
150
|
+
alias_method :[], :magic_get
|
151
|
+
alias_method :[]=, :magic_set
|
152
|
+
|
153
|
+
#
|
154
|
+
# magic_validate and static helpers
|
155
|
+
#
|
156
|
+
|
157
|
+
def self.magic_validate(value, type)
|
158
|
+
case type
|
159
|
+
when Array
|
160
|
+
"expected one of #{type.inspect}" if !type.include?(value)
|
161
|
+
when Class, :bool
|
162
|
+
"expected #{magic_pretty(type)}" if !magic_is_a?(value, type)
|
163
|
+
when Hash
|
164
|
+
name_klass, value_klass = type.first
|
165
|
+
valid = value.is_a?(Hash) && value.all? { magic_is_a?(_1, name_klass) && magic_is_a?(_2, value_klass) }
|
166
|
+
"expected hash of #{magic_pretty(name_klass)} => #{magic_pretty(value_klass)}" if !valid
|
167
|
+
when Proc
|
168
|
+
ret = type.call(value)
|
169
|
+
case ret
|
170
|
+
when String, false, nil then ret
|
171
|
+
else
|
172
|
+
puts "warning: MagicOptions.proc should ONLY return error string or nil/false, not #{ret.inspect}"
|
173
|
+
ret.to_s
|
174
|
+
end
|
175
|
+
when Range
|
176
|
+
if !value.is_a?(Numeric) || !type.include?(value)
|
177
|
+
"expected to be in range #{type.inspect}"
|
178
|
+
end
|
179
|
+
when Regexp
|
180
|
+
if !value.is_a?(String) || !value.match?(type)
|
181
|
+
"expected to be a string matching #{type.inspect}"
|
182
|
+
end
|
183
|
+
when :bools, :floats, :ints, :nums, :strs, :syms
|
184
|
+
klass = magic_resolve(type.to_s[..-2].to_sym)
|
185
|
+
valid = value.is_a?(Array) && value.all? { magic_is_a?(_1, klass) }
|
186
|
+
"expected array of #{type}" if !valid
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
# coerce value into type. pretty conservative at the moment
|
191
|
+
def self.magic_coerce(value, type)
|
192
|
+
if type == :bool
|
193
|
+
case value
|
194
|
+
when true, 1, "1", "true" then value = true
|
195
|
+
when false, 0, "", "0", "false" then value = false
|
196
|
+
end
|
197
|
+
end
|
198
|
+
value
|
199
|
+
end
|
200
|
+
|
201
|
+
# like is_a?, but supports :bool and allows ints to be floats
|
202
|
+
def self.magic_is_a?(value, klass)
|
203
|
+
if klass == :bool
|
204
|
+
value == true || value == false
|
205
|
+
elsif klass == Float
|
206
|
+
value.is_a?(klass) || value.is_a?(Integer)
|
207
|
+
else
|
208
|
+
value.is_a?(klass)
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
MAGIC_ALIASES = {
|
213
|
+
boolean: :bool,
|
214
|
+
booleans: :bools,
|
215
|
+
bool: :bool,
|
216
|
+
bools: :bools,
|
217
|
+
float: Float,
|
218
|
+
floats: :floats,
|
219
|
+
int: Integer,
|
220
|
+
integer: Integer,
|
221
|
+
integers: :ints,
|
222
|
+
ints: :ints,
|
223
|
+
lambda: Proc,
|
224
|
+
num: Numeric,
|
225
|
+
number: Numeric,
|
226
|
+
numbers: :nums,
|
227
|
+
nums: :nums,
|
228
|
+
proc: Proc,
|
229
|
+
str: String,
|
230
|
+
string: String,
|
231
|
+
strings: :strs,
|
232
|
+
strs: :strs,
|
233
|
+
sym: Symbol,
|
234
|
+
symbol: Symbol,
|
235
|
+
symbols: :syms,
|
236
|
+
syms: :syms,
|
237
|
+
}
|
238
|
+
|
239
|
+
MAGIC_PRETTY = {
|
240
|
+
:bool => "boolean",
|
241
|
+
Float => "float",
|
242
|
+
Integer => "integer",
|
243
|
+
Numeric => "number",
|
244
|
+
String => "string",
|
245
|
+
Symbol => "symbol",
|
246
|
+
}
|
247
|
+
|
248
|
+
# pretty print a class (or :bool)
|
249
|
+
def self.magic_pretty(klass) = MAGIC_PRETTY[klass] || klass.to_s
|
250
|
+
|
251
|
+
# resolve :boolean to :bool, :int => Integer class, etc.
|
252
|
+
def self.magic_resolve(type) = MAGIC_ALIASES[type] || type
|
253
|
+
end
|
254
|
+
end
|
255
|
+
end
|
@@ -22,8 +22,10 @@ module TableTennis
|
|
22
22
|
simple?(text) ? text.length : Unicode::DisplayWidth.of(text)
|
23
23
|
end
|
24
24
|
|
25
|
-
def hyperlink(
|
26
|
-
|
25
|
+
def hyperlink(str)
|
26
|
+
# fail fast, for speed
|
27
|
+
return unless str.length >= 6 && str[0] == "["
|
28
|
+
if str =~ /^\[(.*)\]\((.*)\)$/
|
27
29
|
[$1, $2]
|
28
30
|
end
|
29
31
|
end
|
data/lib/table_tennis/version.rb
CHANGED
data/lib/table_tennis.rb
CHANGED
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: table_tennis
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.6
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Adam Doppelt
|
8
8
|
bindir: bin
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-
|
10
|
+
date: 2025-05-01 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: csv
|
@@ -108,6 +108,7 @@ files:
|
|
108
108
|
- lib/table_tennis/util/colors.rb
|
109
109
|
- lib/table_tennis/util/identify.rb
|
110
110
|
- lib/table_tennis/util/inspectable.rb
|
111
|
+
- lib/table_tennis/util/magic_options.rb
|
111
112
|
- lib/table_tennis/util/scale.rb
|
112
113
|
- lib/table_tennis/util/strings.rb
|
113
114
|
- lib/table_tennis/util/termbg.rb
|