table_tennis 0.0.5 → 0.0.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '009a0c9bbb140d308d55441602f96a534e51d989259c70b2e3cc2661213252cb'
4
- data.tar.gz: 6422661f90e1e6e15ddb67c0fff92d6d1c1f3d3454e95a57ae79652d83b981ca
3
+ metadata.gz: 85ab2cbb42c928e7231529feaa15416f752b3efa3ca13d0ba54331b79cb41cc0
4
+ data.tar.gz: bbd1d0477437ab63c0302a8796ca058eb94a5084595a190032695188680214ef
5
5
  SHA512:
6
- metadata.gz: af82ae82579222df273e5244c2dde9137547cce527b4c5e4b900feeb7129beb7d9b5d9ea80ae849f62ac79befd14c38be0f8e8c311a651f8dc63660b1953190a
7
- data.tar.gz: 7eae197b20fe45f0b65f9987097e38233dac3ea113fcabbfd27011340a18910f9c8d701a3352cb482819f266391e98a0daaeec0857e966982e6d6ffcf2aa2121
6
+ metadata.gz: 7729436cacaa34103047f9d039e1a81626ad8b9f86b33062e3e8ab58067aa69bc56242d19e7861ec44284c6e277b3daf32966129c0d41da7b389dce591371ee0
7
+ data.tar.gz: 97165044e352b52af3b31870b1999624a4a3b3f6dd8624cd69a9935d594461a1842568973fb8a101438d4a26b6ddc2df754ff6b88fcc5f61a982894fb33c329c
@@ -2,9 +2,8 @@ name: test
2
2
 
3
3
  on:
4
4
  push:
5
- paths-ignore:
6
- - README.md
7
- - 'screenshots/**'
5
+ branches: [main]
6
+ paths-ignore: [README.md, "screenshots/**"]
8
7
  pull_request:
9
8
  workflow_dispatch:
10
9
 
@@ -17,7 +16,7 @@ jobs:
17
16
  ruby-version: [3.0, 3.4]
18
17
  runs-on: ${{ matrix.os }}-latest
19
18
  steps:
20
- - uses: actions/checkout@v3
19
+ - uses: actions/checkout@v4
21
20
  - uses: taiki-e/install-action@just
22
21
  - uses: ruby/setup-ruby@v1
23
22
  with:
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
- | option | default | details |
71
- | ------ | ------- | ------- |
72
- | `color_scales` | | Color code a column of floats, similar to the "conditional formatting" feature in Google Sheets. See [docs below](#color-scales). |
73
- | `color` | `nil` | 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"]`. |
74
- | `columns` | `nil` | Manually set which columns to include. Leave unset to show all columns.
75
- | `delims` | true | Format ints & floats with comma delimiter, like 123,456. |
76
- | `digits` | `3` | Format floats to this number of digits. TableTennis will look for either `Float` cells or string floats. |
77
- | `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
- | `placeholder` | `"—"` | Put this into empty cells. |
80
- | `row_numbers` | `false` | Show row numbers in the table. |
81
- | `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
- | `strftime` | see → | strftime string for formatting Date/Time objects. The default is `"%Y-%m-%d"`, which looks like `2025-04-21` |
85
- | `theme` | nil | 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.|
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. |
70
+ #### Popular Options
71
+
72
+ | option | default | details |
73
+ | -------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
74
+ | `color_scales` | | Color code a column of floats, similar to the "conditional formatting" feature in Google Sheets. See [docs below](#color-scales). |
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. |
92
+ | `digits` | `3` | Format floats to this number of digits. TableTennis will look for either `Float` cells or string floats. |
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. |
94
+ | `placeholder` | `"—"` | Put this into empty cells. |
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. |
96
+ | `strftime` | see → | strftime string for formatting Date/Time objects. The default is `"%Y-%m-%d"`, which looks like `2025-04-21` |
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
 
@@ -111,8 +120,8 @@ puts TableTennis.new(rows, search: /hope.*empire/i })
111
120
  puts TableTennis.new(rows, row_numbers: true, zebra: true)
112
121
  ```
113
122
 
114
- | `:mark` | `:search` | `:row_numbers` and `:zebra` |
115
- | - | - | - |
123
+ | `:mark` | `:search` | `:row_numbers` and `:zebra` |
124
+ | ----------------------------------- | ------------------------------- | --------------------------------------------- |
116
125
  | ![droids](./screenshots/droids.png) | ![hope](./screenshots/hope.png) | ![row numbers](./screenshots/row_numbers.png) |
117
126
 
118
127
  ### Links
@@ -163,6 +172,18 @@ 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.7 (Aug '25)
176
+
177
+ - handle data that already contains ANSI colors (thx @ronaldtse, #12)
178
+ - don't crash if IO.console is nil (thx @ronaldtse, #14)
179
+
180
+ #### 0.0.6 (May '25)
181
+
182
+ - added `coerce:` option to disable string => numeric coercion
183
+ - added `headers:` option to manually set column headers
184
+ - fixed some issues related to links, including `save:`
185
+ - added MagicOptions
186
+
166
187
  #### 0.0.5 (April '25)
167
188
 
168
189
  - Support for markdown style links in the cells
data/justfile CHANGED
@@ -22,8 +22,9 @@ gem-local:
22
22
 
23
23
  # this will tag, build and push to rubygems
24
24
  gem-push: check
25
+ @if rg -g '!justfile' "\bREMIND\b" ; then just _fatal "REMIND found, bailing" ; fi
25
26
  @just _banner rake release...
26
- rake release
27
+ bundle exec rake release
27
28
 
28
29
  # optimize images
29
30
  image_optim:
@@ -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 data&.config&.titleize?
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
@@ -3,15 +3,17 @@ module TableTennis
3
3
  attr_accessor :defaults
4
4
  end
5
5
 
6
- # Store the table configuration options, with lots of validation.
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
- def initialize(options = {}, &block)
30
- options = [OPTIONS, TableTennis.defaults, options].reduce { _1.merge(_2 || {}) }
31
- options[:color] = Config.detect_color? if options[:color].nil?
32
- options[:theme] = Config.detect_theme if options[:theme].nil?
33
- options[:debug] = true if ENV["TT_DEBUG"]
34
- options.each { self[_1] = _2 }
35
-
36
- yield self if block_given?
37
- end
38
-
39
- # readers
40
- attr_reader(*OPTIONS.keys)
41
-
42
- #
43
- # simple writers
44
- #
45
-
46
- {
31
+ SCHEMA = {
32
+ coerce: :bool,
33
+ color_scales: -> do
34
+ Config.magic_validate!(:color_scales, _1, {Symbol => Symbol})
35
+ if !(invalid = _1.values - Util::Scale::SCALES.keys).empty?
36
+ raise ArgumentError, "invalid color scale(s): #{invalid.inspect}"
37
+ end
38
+ end,
47
39
  color: :bool,
40
+ columns: :symbols,
48
41
  debug: :bool,
49
42
  delims: :bool,
50
- digits: :int,
43
+ digits: (0..10),
44
+ headers: {sym: :str},
45
+ layout: -> do
46
+ return if _1 == true || _1 == false || _1.is_a?(Integer)
47
+ Config.magic_validate!(:layout, _1, {Symbol => Integer})
48
+ end,
51
49
  mark: :proc,
52
50
  placeholder: :str,
53
51
  row_numbers: :bool,
54
52
  save: :str,
53
+ search: -> do
54
+ if !(_1.is_a?(String) || _1.is_a?(Regexp))
55
+ raise ArgumentError, "expected string/regex"
56
+ end
57
+ end,
55
58
  separators: :bool,
56
59
  strftime: :str,
60
+ theme: %i[dark light ansi],
57
61
  title: :str,
58
62
  titleize: :bool,
59
63
  zebra: :bool,
60
- }.each do |option, type|
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
- #
64
+ }
70
65
 
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
77
-
78
- def self.detect_color?
79
- return false if ENV["NO_COLOR"] || ENV["CI"]
80
- return true if ENV["FORCE_COLOR"] == "1"
81
- return false if !($stdout.tty? && $stderr.tty?)
82
- Paint.detect_mode > 0
83
- end
84
-
85
- def self.detect_theme
86
- case terminal_dark?
87
- when true, nil then :dark
88
- when false then :light
89
- end
66
+ def initialize(options = {}, &block)
67
+ # assemble from OPTIONS, defaults and options
68
+ options = [OPTIONS, TableTennis.defaults, options].reduce { _1.merge(_2 || {}) }
69
+ options[:color] = Config.detect_color? if options[:color].nil?
70
+ options[:debug] = true if ENV["TT_DEBUG"]
71
+ options[:theme] = Config.detect_theme if options[:theme].nil?
72
+ super(SCHEMA, options, &block)
90
73
  end
91
74
 
92
75
  #
93
- # complex writers
76
+ # override a few setters to coerce values
94
77
  #
95
78
 
96
79
  def color_scales=(value)
97
- case value
98
- when Array then value = value.to_h { [_1, :g] }
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
112
- end
113
- end
114
- alias_method(:"color_scale=", :"color_scales=")
115
-
116
- def columns=(value)
117
- @columns = validate(:columns, value) do
118
- if !(value.is_a?(Array) && !value.empty? && value.all? { _1.is_a?(Symbol) })
119
- "expected array of symbols"
120
- end
121
- end
122
- end
123
-
124
- def theme=(value)
125
- @theme = validate(:theme, value) do
126
- if !value.is_a?(Symbol)
127
- "expected symbol"
128
- elsif !Theme::THEMES.key?(value)
129
- "expected one of #{Theme::THEMES.keys.inspect}"
130
- end
131
- end
132
- end
133
-
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
80
+ if value.is_a?(Array) || value.is_a?(Symbol)
81
+ value = Array(value).to_h { [_1, :g] }
152
82
  end
83
+ self[:color_scales] = value
153
84
  end
85
+ alias_method :color_scale=, :color_scales=
154
86
 
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}>"
87
+ def placeholder=(value)
88
+ value = "" if value.nil?
89
+ self[:placeholder] = value
168
90
  end
169
91
 
170
- def to_h
171
- OPTIONS.keys.to_h { [_1, self[_1]] }.compact
92
+ def title=(value)
93
+ value = value.to_s if value.is_a?(Symbol)
94
+ self[:title] = value
172
95
  end
173
96
 
174
- protected
175
-
176
97
  #
177
- # validations
98
+ # helpers
178
99
  #
179
100
 
180
- def validate(option, value, &block)
181
- if value != nil && (error = yield)
182
- raise ArgumentError, "TableTennis.#{option} #{error}, got #{value.inspect}"
183
- end
184
- value
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 _str(option, value)
215
- case option
216
- when :placeholder
217
- value = "" if value.nil?
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
- def _sym(option, value)
227
- validate(option, value) do
228
- "expected symbol" if !value.is_a?(Symbol)
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 = detect_link(str))
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 then fmt_number(to_f(value), digits: config.digits) if Util::Identify.number?(value)
50
- when Numeric then fmt_number(value, digits: config.digits)
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 then fmt_number(to_i(value)) if Util::Identify.int?(value)
58
- when Integer then fmt_number(value)
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
@@ -37,7 +37,7 @@ module TableTennis
37
37
  columns.each { _1.width = _1.measure }
38
38
 
39
39
  # How much space is available, and do we already fit?
40
- screen_width = IO.console.winsize[1]
40
+ screen_width = Util::Console.winsize[1]
41
41
  available = screen_width - chrome_width - FUDGE
42
42
  return if available >= data_width
43
43
 
@@ -50,7 +50,7 @@ module TableTennis
50
50
  title_width = data.table_width - 4
51
51
  title = Util::Strings.truncate(config.title, title_width)
52
52
  title_style = data.get_style(r: :title) || :cell
53
- line = paint(title.center(title_width), title_style || :cell)
53
+ line = paint(Util::Strings.center(title, title_width), title_style || :cell)
54
54
  paint("#{pipe} #{line} #{pipe}", Theme::BG)
55
55
  end
56
56
 
@@ -81,7 +81,7 @@ module TableTennis
81
81
  style ||= :cell
82
82
 
83
83
  # add ansi codes for search
84
- value = value.gsub(search) { paint(_1, :search) } if search
84
+ value = search_cell(value) if search
85
85
 
86
86
  # add ansi codes for links
87
87
  if config.color && (link = data.links[[r, c]])
@@ -155,6 +155,14 @@ module TableTennis
155
155
  end
156
156
  end
157
157
  memo_wise :search
158
+
159
+ # add ansi codes for search
160
+ def search_cell(value)
161
+ return value if !value.match?(search)
162
+ # edge case - we can't gsub a painted cell, it can mess up the escaping
163
+ value = Util::Strings.unpaint(value)
164
+ value.gsub(search) { paint(_1, :search) }
165
+ end
158
166
  end
159
167
  end
160
168
  end
@@ -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 { csv << _1 }
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
@@ -114,7 +114,7 @@ module TableTennis
114
114
  def debug(str)
115
115
  return if !config&.debug
116
116
  str = "[#{Time.now.strftime("%H:%M:%S")}] #{str}"
117
- str = str.ljust(@debug_width ||= IO.console.winsize[1])
117
+ str = str.ljust(@debug_width ||= Util::Console.winsize[1])
118
118
  puts Paint[str, :white, :green]
119
119
  end
120
120
 
@@ -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
- Paint[linked, :underline]
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,23 @@
1
+ module TableTennis
2
+ module Util
3
+ # static wrapper around IO.console to handle the case where IO.console is nil
4
+ module Console
5
+ module_function
6
+
7
+ # supported when IO.console is nil
8
+ def winsize(...)
9
+ IO.console&.winsize(...) || [48, 80]
10
+ end
11
+
12
+ # not supported, don't call these
13
+ %i[fileno getbyte raw syswrite].each do |name|
14
+ define_method(name) do |*args, **kwargs, &block|
15
+ if !IO.console
16
+ raise "IO.console.#{name} not supported when IO.console is nil"
17
+ end
18
+ IO.console.send(name, *args, **kwargs, &block)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,285 @@
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 custom validation lambda. The lambda should raise an ArgumentError if
36
+ # the value is invalid.
37
+ # (5) An array of possible values (typically numbers, strings, or symbols). The
38
+ # value must be one of those possibilities.
39
+ # (6) A hash with one element { class => class }. This specifies the hash
40
+ # signature, and the value must be a hash where the keys and values are
41
+ # those classes.
42
+ #
43
+ # There is a bit of type coercion, but not much. For example, the string "true" or "1" will be
44
+ # coerced to true for boolean options. Integers can be used when the schema calls for floats.
45
+ #
46
+
47
+ module TableTennis
48
+ module Util
49
+ class MagicOptions
50
+ #
51
+ # public api (also see [] and []=)
52
+ #
53
+
54
+ attr_accessor :magic_attributes, :magic_values
55
+
56
+ def initialize(schema, options = {}, &block)
57
+ @magic_attributes, @magic_values = {}, {}
58
+
59
+ if self.class == MagicOptions # rubocop:disable Style/ClassEqualityComparison
60
+ raise ArgumentError, "MagicOptions is an abstract class"
61
+ end
62
+
63
+ schema.each { magic_add_attribute(_1, _2) }
64
+ update!(options) if options
65
+ yield self if block_given?
66
+ end
67
+
68
+ def update!(hash) = hash.each { send("#{_1}=", _2) }
69
+ def to_h = magic_values.dup
70
+
71
+ def inspect
72
+ values = magic_values.compact.map { "#{_1}=#{_2.inspect}" }.join(", ")
73
+ "#<#{self.class} #{values}>"
74
+ end
75
+
76
+ #
77
+ # magic_add_attribute
78
+ #
79
+
80
+ def magic_add_attribute(name, type)
81
+ # resolve :boolean to :bool, :int => Integer class, etc.
82
+ type = if type.is_a?(Hash)
83
+ type.to_h { [self.class.magic_resolve(_1), self.class.magic_resolve(_2)] }
84
+ else
85
+ self.class.magic_resolve(type)
86
+ end
87
+
88
+ # sanity check for schema errors
89
+ if (error = magic_sanity(name, type))
90
+ raise ArgumentError, "MagicOptions schema #{name.inspect} #{error}"
91
+ end
92
+
93
+ # all is well
94
+ magic_attributes[name] = type
95
+ if !respond_to?(name)
96
+ define_singleton_method(name) { self[name] }
97
+ end
98
+ if type == :bool && !respond_to?("#{name}?")
99
+ define_singleton_method("#{name}?") { !!self[name] }
100
+ end
101
+ if !respond_to?("#{name}=")
102
+ define_singleton_method("#{name}=") { |value| self[name] = value }
103
+ end
104
+ end
105
+
106
+ # sanity check a name/type from the schema
107
+ def magic_sanity(name, type)
108
+ if !name.is_a?(Symbol)
109
+ return "attribute names must be symbols"
110
+ end
111
+ if !name.to_s.match?(/\A[a-z_][0-9a-z_]*\z/i)
112
+ return "attribute names must be valid method names"
113
+ end
114
+
115
+ case type
116
+ when :bool, :bools, :floats, :ints, :nums, :strs, :syms, Class, Proc, Range, Regexp
117
+ return
118
+ when Array
119
+ "must be an array of possible values" if type.empty?
120
+ when Hash
121
+ valid = type.length == 1 && type.first.all? { _1 == :bool || _1.is_a?(Class) }
122
+ "must be { class => class }" if !valid
123
+ else
124
+ "unknown schema type #{type.inspect}"
125
+ end
126
+ end
127
+
128
+ #
129
+ # magic_get/set
130
+ #
131
+
132
+ def magic_get(name)
133
+ raise ArgumentError, "unknown #{self.class}.#{name}" if !magic_attributes.key?(name)
134
+ magic_values[name]
135
+ end
136
+
137
+ def magic_set(name, value)
138
+ raise ArgumentError, "unknown #{self.class}.#{name}=" if !magic_attributes.key?(name)
139
+ magic_values[name] = self.class.magic_validate!(name, value, magic_attributes[name])
140
+ end
141
+
142
+ # these are part of the public api
143
+ alias_method :[], :magic_get
144
+ alias_method :[]=, :magic_set
145
+
146
+ #
147
+ # magic_validate! and static helpers
148
+ #
149
+
150
+ # validate name=value against type, raise on failure
151
+ def self.magic_validate!(name, value, type)
152
+ # we validate against coerced values, but squirrel away the original
153
+ # uncoerced in case we need to use it inside an error message
154
+ original, value = value, magic_coerce(value, type)
155
+ return if value.nil?
156
+
157
+ case type
158
+ when Array then validate_any_of(value, type)
159
+ when Class, :bool then validate_class(value, type)
160
+ when Hash then validate_hash(value, type)
161
+ when Proc then type.call(value)
162
+ when Range then validate_range(value, type)
163
+ when Regexp then validate_regexp(value, type)
164
+ when :bools, :floats, :ints, :nums, :strs, :syms then validate_array(value, type)
165
+ else
166
+ raise "impossible"
167
+ end
168
+ value
169
+ rescue ArgumentError => ex
170
+ # add context to msg if necessary
171
+ msg = ex.message
172
+ if !msg.include?("#{name} = #{original.inspect}")
173
+ msg = "#{self}.#{name} = #{original.inspect} failed, #{msg}"
174
+ end
175
+ raise ArgumentError, msg
176
+ end
177
+
178
+ #
179
+ # validators
180
+ #
181
+
182
+ def self.validate_any_of(value, possibilities)
183
+ if !possibilities.include?(value)
184
+ raise ArgumentError, "expected one of #{possibilities.inspect}"
185
+ end
186
+ end
187
+
188
+ def self.validate_class(value, klass)
189
+ if !magic_is_a?(value, klass)
190
+ raise ArgumentError, "expected #{magic_pretty(klass)}"
191
+ end
192
+ end
193
+
194
+ def self.validate_hash(value, hash_type)
195
+ kk, vk = hash_type.first
196
+ if !(value.is_a?(Hash) && value.all? { magic_is_a?(_1, kk) && magic_is_a?(_2, vk) })
197
+ raise ArgumentError, "expected hash of #{magic_pretty(kk)} => #{magic_pretty(vk)}"
198
+ end
199
+ end
200
+
201
+ def self.validate_range(value, range)
202
+ if !value.is_a?(Numeric) || !range.include?(value)
203
+ raise ArgumentError, "expected to be in range #{range.inspect}"
204
+ end
205
+ end
206
+
207
+ def self.validate_regexp(value, regexp)
208
+ if !value.is_a?(String) || !value.match?(regexp)
209
+ raise ArgumentError, "expected to be a string matching #{regexp}"
210
+ end
211
+ end
212
+
213
+ def self.validate_array(value, array_type)
214
+ klass = magic_resolve(array_type.to_s[..-2].to_sym)
215
+ if !(value.is_a?(Array) && value.all? { magic_is_a?(_1, klass) })
216
+ raise ArgumentError, "expected array of #{array_type}"
217
+ end
218
+ end
219
+
220
+ # coerce value into type. pretty conservative at the moment
221
+ def self.magic_coerce(value, type)
222
+ if type == :bool
223
+ case value
224
+ when true, 1, "1", "true" then value = true
225
+ when false, 0, "", "0", "false" then value = false
226
+ end
227
+ end
228
+ value
229
+ end
230
+
231
+ # like is_a?, but supports :bool and allows ints to be floats
232
+ def self.magic_is_a?(value, klass)
233
+ if klass == :bool
234
+ value == true || value == false
235
+ elsif klass == Float
236
+ value.is_a?(klass) || value.is_a?(Integer)
237
+ else
238
+ value.is_a?(klass)
239
+ end
240
+ end
241
+
242
+ MAGIC_ALIASES = {
243
+ boolean: :bool,
244
+ booleans: :bools,
245
+ bool: :bool,
246
+ bools: :bools,
247
+ float: Float,
248
+ floats: :floats,
249
+ int: Integer,
250
+ integer: Integer,
251
+ integers: :ints,
252
+ ints: :ints,
253
+ lambda: Proc,
254
+ num: Numeric,
255
+ number: Numeric,
256
+ numbers: :nums,
257
+ nums: :nums,
258
+ proc: Proc,
259
+ str: String,
260
+ string: String,
261
+ strings: :strs,
262
+ strs: :strs,
263
+ sym: Symbol,
264
+ symbol: Symbol,
265
+ symbols: :syms,
266
+ syms: :syms,
267
+ }
268
+
269
+ MAGIC_PRETTY = {
270
+ :bool => "boolean",
271
+ Float => "float",
272
+ Integer => "integer",
273
+ Numeric => "number",
274
+ String => "string",
275
+ Symbol => "symbol",
276
+ }
277
+
278
+ # pretty print a class (or :bool)
279
+ def self.magic_pretty(klass) = MAGIC_PRETTY[klass] || klass.to_s
280
+
281
+ # resolve :boolean to :bool, :int => Integer class, etc.
282
+ def self.magic_resolve(type) = MAGIC_ALIASES[type] || type
283
+ end
284
+ end
285
+ end
@@ -6,6 +6,9 @@ module TableTennis
6
6
 
7
7
  module_function
8
8
 
9
+ # does this string contain ansi codes?
10
+ def painted?(str) = str.match?(/\e/)
11
+
9
12
  # strip ansi codes
10
13
  def unpaint(str) = str.gsub(/\e\[[0-9;]*m/, "")
11
14
 
@@ -18,23 +21,55 @@ module TableTennis
18
21
  str
19
22
  end
20
23
 
21
- def width(text)
22
- simple?(text) ? text.length : Unicode::DisplayWidth.of(text)
24
+ # measure width of text, with support for emojis, painted/ansi strings, etc
25
+ def width(str)
26
+ if simple?(str)
27
+ str.length
28
+ elsif painted?(str)
29
+ unpaint(str).length
30
+ else
31
+ Unicode::DisplayWidth.of(str)
32
+ end
23
33
  end
24
34
 
25
- def hyperlink(value)
26
- if value =~ /^\[([^\]]*)\]\(([^\)]*)\)$/
35
+ # center text, like String#center but works with painted strings
36
+ def center(str, width)
37
+ # artificially inflate width to include escape codes
38
+ if painted?(str)
39
+ width += str.length - unpaint(str).length
40
+ end
41
+ str.center(width)
42
+ end
43
+
44
+ def hyperlink(str)
45
+ # fail fast, for speed
46
+ return unless str.length >= 6 && str[0] == "["
47
+ if str =~ /^\[(.*)\]\((.*)\)$/
27
48
  [$1, $2]
28
49
  end
29
50
  end
30
51
 
31
52
  # truncate a string based on the display width of the grapheme clusters.
32
- # Should handle emojis and international characters
33
- def truncate(text, stop)
34
- if simple?(text)
35
- return (text.length > stop) ? "#{text[0, stop - 1]}…" : text
53
+ # Should handle emojis and international characters. Painted strings too.
54
+ def truncate(str, stop)
55
+ if simple?(str)
56
+ (str.length > stop) ? "#{str[0, stop - 1]}…" : str
57
+ elsif painted?(str)
58
+ # generate truncated plain version
59
+ plain = truncate0(unpaint(str), stop)
60
+ # make a best effort to apply the colors
61
+ if (opening_codes = str[/\e\[(?:[0-9];?)+m/])
62
+ "#{opening_codes}#{plain}#{Paint::NOTHING}"
63
+ else
64
+ plain
65
+ end
66
+ else
67
+ truncate0(str, stop)
36
68
  end
69
+ end
37
70
 
71
+ # slow, but handles graphemes
72
+ def truncate0(text, stop)
38
73
  # get grapheme clusters, and attach zero width graphemes to the previous grapheme
39
74
  list = [].tap do |accum|
40
75
  text.grapheme_clusters.each do
@@ -59,8 +94,10 @@ module TableTennis
59
94
 
60
95
  text
61
96
  end
97
+ private_class_method :truncate0
62
98
 
63
- SIMPLE = /\A[\x00-\x7F–—…·‘’“”•áéíñóúÓ]*\Z/
99
+ # note that escape \e (0x1b) is excluded
100
+ SIMPLE = /\A[\x00-\x1a\x1c-\x7F–—…·‘’“”•áéíñóúÓ]*\Z/
64
101
 
65
102
  # Is this a "simple" string? (no emojis, etc). Caches results for small
66
103
  # strings for performance reasons.
@@ -54,6 +54,8 @@ module TableTennis
54
54
  "bad TERM"
55
55
  elsif ENV["ZELLIJ"]
56
56
  "zellij"
57
+ elsif !IO.console
58
+ "no console"
57
59
  end
58
60
  if error
59
61
  debug("osc_supported? #{{host:, platform:, term:}} => #{error}")
@@ -75,7 +77,7 @@ module TableTennis
75
77
 
76
78
  debug("osc_query(#{attr})")
77
79
  begin
78
- IO.console.raw do
80
+ Util::Console.raw do
79
81
  logs << " IO.console.raw"
80
82
 
81
83
  # we send two messages - the cursor query is widely supported, so we
@@ -89,7 +91,7 @@ module TableTennis
89
91
  end.join
90
92
 
91
93
  logs << " syswrite #{msg.inspect}"
92
- IO.console.syswrite(msg)
94
+ Util::Console.syswrite(msg)
93
95
 
94
96
  # there should always be at least one response. If this is a response to
95
97
  # the cursor message, the first message didn't work
@@ -116,18 +118,18 @@ module TableTennis
116
118
  def read_term_response
117
119
  # fast forward to ESC
118
120
  loop do
119
- return if !(ch = IO.console.getbyte&.chr)
121
+ return if !(ch = Util::Console.getbyte&.chr)
120
122
  break ch if ch == ESC
121
123
  end
122
124
  # next char should be either [ or ]
123
- return if !(type = IO.console.getbyte&.chr)
125
+ return if !(type = Util::Console.getbyte&.chr)
124
126
  return if !(type == "[" || type == "]")
125
127
 
126
128
  # now read the response. note that the response can end in different ways
127
129
  # and we have to check for all of them
128
130
  buf = "#{ESC}#{type}"
129
131
  loop do
130
- return if !(ch = IO.console.getbyte&.chr)
132
+ return if !(ch = Util::Console.getbyte&.chr)
131
133
  buf << ch
132
134
  break if type == "[" && buf.end_with?("R")
133
135
  break if type == "]" && buf.end_with?(BEL, ST)
@@ -162,7 +164,7 @@ module TableTennis
162
164
  return if !respond_to?(:tcgetpgrp)
163
165
  end
164
166
 
165
- io = IO.console
167
+ io = Util::Console
166
168
  if (ttypgrp = tcgetpgrp(io.fileno)) <= 0
167
169
  debug("tcpgrp(#{io.fileno}) => #{ttypgrp}, errno=#{FFI.errno}")
168
170
  return
@@ -1,3 +1,3 @@
1
1
  module TableTennis
2
- VERSION = "0.0.5"
2
+ VERSION = "0.0.7"
3
3
  end
data/lib/table_tennis.rb CHANGED
@@ -9,6 +9,7 @@ require "unicode/display_width"
9
9
 
10
10
  # mixins must be at top
11
11
  require "table_tennis/util/inspectable"
12
+ require "table_tennis/util/magic_options"
12
13
 
13
14
  require "table_tennis/column"
14
15
  require "table_tennis/config"
@@ -24,6 +25,7 @@ require "table_tennis/stage/painter"
24
25
  require "table_tennis/stage/render"
25
26
 
26
27
  require "table_tennis/util/colors"
28
+ require "table_tennis/util/console"
27
29
  require "table_tennis/util/identify"
28
30
  require "table_tennis/util/scale"
29
31
  require "table_tennis/util/strings"
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.5
4
+ version: 0.0.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adam Doppelt
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-04-28 00:00:00.000000000 Z
10
+ date: 2025-08-06 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: csv
@@ -106,8 +106,10 @@ files:
106
106
  - lib/table_tennis/table_data.rb
107
107
  - lib/table_tennis/theme.rb
108
108
  - lib/table_tennis/util/colors.rb
109
+ - lib/table_tennis/util/console.rb
109
110
  - lib/table_tennis/util/identify.rb
110
111
  - lib/table_tennis/util/inspectable.rb
112
+ - lib/table_tennis/util/magic_options.rb
111
113
  - lib/table_tennis/util/scale.rb
112
114
  - lib/table_tennis/util/strings.rb
113
115
  - lib/table_tennis/util/termbg.rb