table_tennis 0.0.6 → 1.0.0

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: b141b15004b90e5acaf6f4ed92d7f71a49fbb8376875fd50cf5ed1d8e555c365
4
- data.tar.gz: f9dd1e36fa97767addcce2abbfca0eb721137d6c87f0556f0ed79ca8677fa15c
3
+ metadata.gz: 2dea31d2827226b7c2a9a92414ef69a99eddecafd6dc8bb9865c975fa48a9f2a
4
+ data.tar.gz: 2fe778598f290d8ede809971ab3ddf9bc12c16fa913542dac795708145230759
5
5
  SHA512:
6
- metadata.gz: eba4146e9b2953bef53d958b7d3bab8d65f25be9c0011309cc511f3ecc254385dc3193f9f84c12c6d768010a9d7b4ae35d49530c6a2756906eb50aeb756d72db
7
- data.tar.gz: f7d7262f3afb54a563a73d5e7279052e6dd2f4fb39a4e916994ee35f966b1f36ff7a45e22eb18619db43fe6cc516f0f34a714bb17614fcd03d1562a20fcd0ede
6
+ metadata.gz: 964ac069a29278563120f105ae399b42cb086aa332ba2e47e2816142b5fab6af80acd91a03e7b69b746ca59c85541d64e71c56a3409304e009b33b0ff7887820
7
+ data.tar.gz: 06ab17c03ed9b367b3a668275d3ac5030f3c865222d851c5545ee757dbe525a67cae60f045554775589191168016f3aa4587862b79f28fae2bda0f2f4b7959e5
@@ -13,10 +13,10 @@ jobs:
13
13
  max-parallel: 3
14
14
  matrix:
15
15
  os: [macos, ubuntu, windows]
16
- ruby-version: [3.0, 3.4]
16
+ ruby-version: [3.0, 3.4, 4.0]
17
17
  runs-on: ${{ matrix.os }}-latest
18
18
  steps:
19
- - uses: actions/checkout@v3
19
+ - uses: actions/checkout@v7
20
20
  - uses: taiki-e/install-action@just
21
21
  - uses: ruby/setup-ruby@v1
22
22
  with:
data/.justfile ADDED
@@ -0,0 +1,85 @@
1
+ set quiet := true
2
+
3
+ default: test
4
+
5
+ # check repo - lint & test
6
+ check: lint test
7
+
8
+ # for ci. don't bother linting on windows
9
+ ci:
10
+ if [[ "{{os()}}" != "windows" ]]; then just lint ; fi
11
+ just test
12
+
13
+ # check test coverage
14
+ coverage:
15
+ COVERAGE=1 just test
16
+ open /tmp/coverage/index.html
17
+
18
+ # format with rubocop
19
+ fmt: (lint "-a")
20
+
21
+ gem-local:
22
+ just banner rake install:local...
23
+ bundle exec rake install:local
24
+
25
+ # this will tag, build and push to rubygems
26
+ gem-push: check
27
+ if rg -g '!.justfile' "\bREMIND\b" ; then just _fatal "REMIND found, bailing" ; fi
28
+ just banner rake release...
29
+ bundle exec rake release
30
+
31
+ # optimize images
32
+ image_optim:
33
+ # advpng/pngout are slow. consider --verbose as well
34
+ bundle exec image_optim --allow-lossy --svgo-precision=1 --no-advpng --no-pngout -r .
35
+
36
+ # check for outdated deps
37
+ outdated:
38
+ just banner Here are the easy ones:
39
+ bundle outdated --filter-minor || true
40
+ just banner The full list:
41
+ bundle outdated || true
42
+
43
+ # lint with rubocop
44
+ lint *ARGS:
45
+ just banner lint...
46
+ bundle exec rubocop {{ARGS}}
47
+
48
+ # start pry with the lib loaded
49
+ pry:
50
+ bundle exec pry -I lib -r table_tennis.rb
51
+
52
+ # run tennis repeatedly
53
+ tennis-watch *ARGS:
54
+ watchexec --stop-timeout=0 --clear clear tennis {{ARGS}}
55
+
56
+ # run tests
57
+ test *ARGS:
58
+ just banner rake test {{ARGS}}
59
+ bundle exec rake test {{ARGS}}
60
+
61
+ # run tests repeatedly
62
+ test-watch *ARGS:
63
+ watchexec --stop-timeout=0 --clear clear just test "{{ARGS}}"
64
+
65
+ # create sceenshot using vhs
66
+ vhs:
67
+ just banner "running vhs..."
68
+ vhs demo.tape
69
+ magick /tmp/dark.png -crop 1448x1004+18+16 screenshots/dark.png
70
+
71
+ #
72
+ # util
73
+ #
74
+
75
+ TRUWHITE := '\e[38;5;231m'
76
+ GREEN := '\e[48;2;064;160;043m'
77
+ ORANGE := '\e[48;2;251;100;011m'
78
+ RED := '\e[48;2;210;015;057m'
79
+
80
+ banner +ARGS: (_banner GREEN ARGS)
81
+ warning +ARGS: (_banner ORANGE ARGS)
82
+ fatal +ARGS: (_banner RED ARGS)
83
+ exit 1
84
+ _banner BG +ARGS:
85
+ printf '{{BOLD+TRUWHITE+BG}}[%s] %-72s {{NORMAL}}\n' "$(date +%H:%M:%S)" "{{ARGS}}"
data/.mise.toml ADDED
@@ -0,0 +1,9 @@
1
+ [env]
2
+ _.path = ['{{config_root}}/bin']
3
+
4
+ [hooks]
5
+ postinstall = ['bundle']
6
+
7
+ [tools]
8
+ just = "latest"
9
+ ruby = "3.4"
data/AGENTS.md ADDED
@@ -0,0 +1,23 @@
1
+ # Purpose
2
+
3
+ TableTennis is a popular ruby gem for rendering ANSI-colored tables from csv-like rows (hash-ish objects). Beauty > performance, but keep small-table latency snappy.
4
+
5
+ # Key flow
6
+
7
+ - Public API: `TableTennis.new` -> `TableTennis::Table` (`lib/table_tennis/table.rb`).
8
+ - Pipeline: `Format` -> `Layout` -> `Painter` -> `Render` (`lib/table_tennis/stage/*.rb`).
9
+ - Data model: `TableData`, `Column`, `Row` (`lib/table_tennis/table_data.rb`, `lib/table_tennis/column.rb`, `lib/table_tennis/row.rb`).
10
+ - Config/theming: `Config`, `Theme`, `Util::Colors/Console/Strings/Termbg` (`lib/table_tennis/config.rb`, `lib/table_tennis/theme.rb`, `lib/table_tennis/util/*`).
11
+
12
+ # dev (prefer `just`)
13
+
14
+ - `just test`
15
+ - `just lint` / `just format`
16
+ - `just check`
17
+
18
+ # Style/conventions
19
+
20
+ - Defaults and validation live in `Config` (`MagicOptions`).
21
+ - Rendering cost is mostly in `Stage::Render` (ANSI/string work); memoization via `MemoWise` is used in hot paths.
22
+ - Prefer short methods, short variable names, and comment lightly when code is complex
23
+ - Ruby one-liners are find and even preferred for trivial methods
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", "~> 1.7"
6
- gem "image_optim", "~> 0.31"
7
- gem "image_optim_pack", "~> 0.12"
8
- gem "minitest", "~> 5.25"
5
+ gem "amazing_print", "~> 2.0"
6
+ gem "image_optim", "~> 0.32"
7
+ gem "image_optim_pack", "~> 0.13"
8
+ gem "minitest", "~> 5.27"
9
9
  gem "minitest-hooks", "~> 1.5"
10
- gem "mocha", "~> 2.7"
10
+ gem "mocha", "~> 3.1"
11
11
  gem "ostruct", "~> 0.6" # required for Ruby 3.5+
12
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
13
+ gem "rake", "~> 13.4"
14
+ gem "simplecov", "~> 1.0", require: false
15
+ gem "standard", "~> 1.56", require: false, platform: :mri
16
16
  end
data/README.md CHANGED
@@ -69,32 +69,32 @@ options = {
69
69
 
70
70
  #### Popular Options
71
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. |
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
84
 
85
85
  #### More Advanced Options
86
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.|
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. |
98
98
 
99
99
  ### Color Scales
100
100
 
@@ -120,8 +120,8 @@ puts TableTennis.new(rows, search: /hope.*empire/i })
120
120
  puts TableTennis.new(rows, row_numbers: true, zebra: true)
121
121
  ```
122
122
 
123
- | `:mark` | `:search` | `:row_numbers` and `:zebra` |
124
- | - | - | - |
123
+ | `:mark` | `:search` | `:row_numbers` and `:zebra` |
124
+ | ----------------------------------- | ------------------------------- | --------------------------------------------- |
125
125
  | ![droids](./screenshots/droids.png) | ![hope](./screenshots/hope.png) | ![row numbers](./screenshots/row_numbers.png) |
126
126
 
127
127
  ### Links
@@ -172,6 +172,18 @@ We love CSV tools and use them all the time! Here are a few that we rely on:
172
172
 
173
173
  ### Changelog
174
174
 
175
+ #### 1.0.0 (Jul '26)
176
+
177
+ - better truncation with ansi colors, inspired by strings-truncation gem
178
+ - set timeout in io/console getch to make OSC query more resilient
179
+ - truncation no longer supports graphemes (breaking)
180
+ - added AGENTS.md and mise.toml
181
+
182
+ #### 0.0.7 (Aug '25)
183
+
184
+ - handle data that already contains ANSI colors (thx @ronaldtse, #12)
185
+ - don't crash if IO.console is nil (thx @ronaldtse, #14)
186
+
175
187
  #### 0.0.6 (May '25)
176
188
 
177
189
  - added `coerce:` option to disable string => numeric coercion
@@ -30,11 +30,10 @@ module TableTennis
30
30
 
31
31
  SCHEMA = {
32
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"
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}"
38
37
  end
39
38
  end,
40
39
  color: :bool,
@@ -45,7 +44,7 @@ module TableTennis
45
44
  headers: {sym: :str},
46
45
  layout: -> do
47
46
  return if _1 == true || _1 == false || _1.is_a?(Integer)
48
- Config.magic_validate(_1, {Symbol => Integer})
47
+ Config.magic_validate!(:layout, _1, {Symbol => Integer})
49
48
  end,
50
49
  mark: :proc,
51
50
  placeholder: :str,
@@ -53,7 +52,7 @@ module TableTennis
53
52
  save: :str,
54
53
  search: -> do
55
54
  if !(_1.is_a?(String) || _1.is_a?(Regexp))
56
- "expected string/regex"
55
+ raise ArgumentError, "expected string/regex"
57
56
  end
58
57
  end,
59
58
  separators: :bool,
@@ -83,6 +82,7 @@ module TableTennis
83
82
  end
84
83
  self[:color_scales] = value
85
84
  end
85
+ alias_method :color_scale=, :color_scales=
86
86
 
87
87
  def placeholder=(value)
88
88
  value = "" if value.nil?
@@ -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
@@ -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
 
@@ -489,6 +489,7 @@ module TableTennis
489
489
 
490
490
  # print all colors to $stdout, helps with creating themes
491
491
  def spectrum
492
+ # paint goes bg, fg
492
493
  max = NAMED.keys.map(&:length).max
493
494
  fmt = " %-#{max}s %s %0.3f "
494
495
  NAMED.each do |name, color|
@@ -500,6 +501,17 @@ module TableTennis
500
501
  puts "#{str1}#{str2}#{str3}#{str4}"
501
502
  end
502
503
 
504
+ # with 256 - 38/5/fg or 38/5/bg
505
+ fmt = " ansi256 %d"
506
+ (1..256).each do |color|
507
+ str = sprintf(" ansi256 %3d ", color)
508
+ str1 = Paint[str, 38, 5, color, "white"]
509
+ str2 = Paint[str, 38, 5, color, "black"]
510
+ str3 = Paint[" white ", "white", 48, 5, color]
511
+ str4 = Paint[" black ", "black", 48, 5, color]
512
+ puts "#{str1}#{str2}#{str3}#{str4}"
513
+ end
514
+
503
515
  fmt = " ansi %-6s %-8s "
504
516
  colors = Paint::ANSI_COLORS_FOREGROUND.keys
505
517
  puts
@@ -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 getch 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
@@ -32,7 +32,8 @@
32
32
  # (1) A simple type like :bool, :int, :num, :float, :str or :sym.
33
33
  # (2) An array type like :bools, :ints, :nums, :floats, :strs, or :syms.
34
34
  # (3) A range, regexp or Class.
35
- # (4) A lambda which should return an error string or nil.
35
+ # (4) A custom validation lambda. The lambda should raise an ArgumentError if
36
+ # the value is invalid.
36
37
  # (5) An array of possible values (typically numbers, strings, or symbols). The
37
38
  # value must be one of those possibilities.
38
39
  # (6) A hash with one element { class => class }. This specifies the hash
@@ -135,15 +136,7 @@ module TableTennis
135
136
 
136
137
  def magic_set(name, value)
137
138
  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
139
+ magic_values[name] = self.class.magic_validate!(name, value, magic_attributes[name])
147
140
  end
148
141
 
149
142
  # these are part of the public api
@@ -151,39 +144,76 @@ module TableTennis
151
144
  alias_method :[]=, :magic_set
152
145
 
153
146
  #
154
- # magic_validate and static helpers
147
+ # magic_validate! and static helpers
155
148
  #
156
149
 
157
- def self.magic_validate(value, type)
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
+
158
157
  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
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}"
187
217
  end
188
218
  end
189
219
 
@@ -6,8 +6,13 @@ module TableTennis
6
6
 
7
7
  module_function
8
8
 
9
+ ANSI_CODE = /\e\[[0-9;]*m/
10
+
11
+ # does this string contain ansi codes?
12
+ def painted?(str) = str.match?(/\e/)
13
+
9
14
  # strip ansi codes
10
- def unpaint(str) = str.gsub(/\e\[[0-9;]*m/, "")
15
+ def unpaint(str) = str.gsub(ANSI_CODE, "")
11
16
 
12
17
  # similar to rails titleize
13
18
  def titleize(str)
@@ -18,8 +23,24 @@ module TableTennis
18
23
  str
19
24
  end
20
25
 
21
- def width(text)
22
- simple?(text) ? text.length : Unicode::DisplayWidth.of(text)
26
+ # measure width of text, with support for emojis, painted/ansi strings, etc
27
+ def width(str)
28
+ if simple?(str)
29
+ str.length
30
+ elsif painted?(str)
31
+ unpaint(str).length
32
+ else
33
+ Unicode::DisplayWidth.of(str)
34
+ end
35
+ end
36
+
37
+ # center text, like String#center but works with painted strings
38
+ def center(str, width)
39
+ # artificially inflate width to include escape codes
40
+ if painted?(str)
41
+ width += str.length - unpaint(str).length
42
+ end
43
+ str.center(width)
23
44
  end
24
45
 
25
46
  def hyperlink(str)
@@ -30,39 +51,55 @@ module TableTennis
30
51
  end
31
52
  end
32
53
 
33
- # truncate a string based on the display width of the grapheme clusters.
34
- # Should handle emojis and international characters
35
- def truncate(text, stop)
36
- if simple?(text)
37
- return (text.length > stop) ? "#{text[0, stop - 1]}…" : text
54
+ ELLIPSIS = "…"
55
+ INVISIBLE = /\A(\e\[[0-9;]*m|\u200B)*\z/
56
+
57
+ # Truncate a string based on the display width of characters. Does not
58
+ # attempt to handle graphemes. Should handle emojis and international
59
+ # characters. Painted strings too.
60
+ def truncate(str, stop)
61
+ if str.bytesize <= stop
62
+ str
63
+ elsif simple?(str)
64
+ (str.length <= stop) ? str : "#{str[0, stop - 1]}#{ELLIPSIS}"
65
+ else
66
+ truncate0(str, stop)
38
67
  end
68
+ end
39
69
 
40
- # get grapheme clusters, and attach zero width graphemes to the previous grapheme
41
- list = [].tap do |accum|
42
- text.grapheme_clusters.each do
43
- if width(_1) == 0 && !accum.empty?
44
- accum[-1] = "#{accum[-1]}#{_1}"
45
- else
46
- accum << _1
70
+ # This is a slower truncate to handle ansi colors and wide characters like
71
+ # emojis. Inspired by piotrmurach/strings-truncation
72
+ def truncate0(text, stop)
73
+ [].tap do |buf|
74
+ scan, len, painting = StringScanner.new(text), 0, false
75
+ until scan.eos?
76
+ # are we looking at an ansi code?
77
+ if scan.scan(ANSI_CODE)
78
+ buf << scan.matched
79
+ painting = scan.matched != Paint::NOTHING
80
+ next
47
81
  end
48
- end
49
- end
50
82
 
51
- width = 0
52
- list.each_index do
53
- w = Unicode::DisplayWidth.of(list[_1])
54
- next if (width += w) <= stop
83
+ # what's next?
84
+ ch = scan.getch
55
85
 
56
- # we've gone too far. do we need to pop for the ellipsis?
57
- text = list[0, _1]
58
- text.pop if width - w == stop
59
- return "#{text.join}…"
60
- end
86
+ # done? append one final char, possible an ELLIPSIS
87
+ len += Unicode::DisplayWidth.of(ch)
88
+ if len >= stop
89
+ buf << (scan.check(INVISIBLE) ? ch : ELLIPSIS)
90
+ break
91
+ end
61
92
 
62
- text
93
+ # keep going
94
+ buf << ch
95
+ end
96
+ buf << Paint::NOTHING if painting
97
+ end.join
63
98
  end
99
+ private_class_method :truncate0
64
100
 
65
- SIMPLE = /\A[\x00-\x7F–—…·‘’“”•áéíñóúÓ]*\Z/
101
+ # note that escape \e (0x1b) is excluded
102
+ SIMPLE = /\A[\x00-\x1a\x1c-\x7F–—…·‘’“”•áéíñóúÓ]*\Z/
66
103
 
67
104
  # Is this a "simple" string? (no emojis, etc). Caches results for small
68
105
  # 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 = getch)
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 = getch)
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 = getch)
131
133
  buf << ch
132
134
  break if type == "[" && buf.end_with?("R")
133
135
  break if type == "]" && buf.end_with?(BEL, ST)
@@ -136,6 +138,10 @@ module TableTennis
136
138
  end
137
139
  private_class_method :read_term_response
138
140
 
141
+ # get the next character, with a 100ms timeout
142
+ def getch = Util::Console.getch(min: 0, time: 0.1)
143
+ private_class_method :read_term_response
144
+
139
145
  #
140
146
  # color math
141
147
  #
@@ -162,7 +168,7 @@ module TableTennis
162
168
  return if !respond_to?(:tcgetpgrp)
163
169
  end
164
170
 
165
- io = IO.console
171
+ io = Util::Console
166
172
  if (ttypgrp = tcgetpgrp(io.fileno)) <= 0
167
173
  debug("tcpgrp(#{io.fileno}) => #{ttypgrp}, errno=#{FFI.errno}")
168
174
  return
@@ -180,6 +186,7 @@ module TableTennis
180
186
  def load_ffi!
181
187
  module_eval do
182
188
  extend FFI::Library
189
+
183
190
  ffi_lib "c"
184
191
  attach_function :tcgetpgrp, %i[int], :int32
185
192
  debug("ffi attach libc.tcgetpgrp => success")
@@ -202,7 +209,9 @@ module TableTennis
202
209
  private_class_method :env_colorfgbg
203
210
 
204
211
  def debug(s)
205
- puts "termbg: #{s}" if ENV["TT_DEBUG"]
212
+ return if !ENV["TT_DEBUG"]
213
+ $stderr.write "termbg: #{s}\r\n"
214
+ $stderr.flush
206
215
  end
207
216
  private_class_method :debug
208
217
  end
@@ -1,3 +1,3 @@
1
1
  module TableTennis
2
- VERSION = "0.0.6"
2
+ VERSION = "1.0.0"
3
3
  end
data/lib/table_tennis.rb CHANGED
@@ -25,6 +25,7 @@ require "table_tennis/stage/painter"
25
25
  require "table_tennis/stage/render"
26
26
 
27
27
  require "table_tennis/util/colors"
28
+ require "table_tennis/util/console"
28
29
  require "table_tennis/util/identify"
29
30
  require "table_tennis/util/scale"
30
31
  require "table_tennis/util/strings"
data/table_tennis.gemspec CHANGED
@@ -23,7 +23,7 @@ Gem::Specification.new do |s|
23
23
  # gem dependencies
24
24
  s.add_dependency "csv", "~> 3.3" # required for Ruby 3.4+
25
25
  s.add_dependency "ffi", "~> 1.17" # required for Ruby 3.2+
26
- s.add_dependency "memo_wise", "~> 1.11"
26
+ s.add_dependency "memo_wise", "~> 1.13"
27
27
  s.add_dependency "paint", "~> 2.3"
28
- s.add_dependency "unicode-display_width", "~> 3.1"
28
+ s.add_dependency "unicode-display_width", "~> 3.2"
29
29
  end
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.6
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adam Doppelt
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-05-01 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: csv
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '1.11'
46
+ version: '1.13'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '1.11'
53
+ version: '1.13'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: paint
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -71,14 +71,14 @@ dependencies:
71
71
  requirements:
72
72
  - - "~>"
73
73
  - !ruby/object:Gem::Version
74
- version: '3.1'
74
+ version: '3.2'
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
- version: '3.1'
81
+ version: '3.2'
82
82
  email: amd@gurge.com
83
83
  executables: []
84
84
  extensions: []
@@ -86,13 +86,15 @@ extra_rdoc_files: []
86
86
  files:
87
87
  - ".github/workflows/test.yml"
88
88
  - ".gitignore"
89
+ - ".justfile"
90
+ - ".mise.toml"
89
91
  - ".rubocop.yml"
92
+ - AGENTS.md
90
93
  - Gemfile
91
94
  - LICENSE
92
95
  - README.md
93
96
  - Rakefile
94
97
  - demo.tape
95
- - justfile
96
98
  - lib/table_tennis.rb
97
99
  - lib/table_tennis/column.rb
98
100
  - lib/table_tennis/config.rb
@@ -106,6 +108,7 @@ files:
106
108
  - lib/table_tennis/table_data.rb
107
109
  - lib/table_tennis/theme.rb
108
110
  - lib/table_tennis/util/colors.rb
111
+ - lib/table_tennis/util/console.rb
109
112
  - lib/table_tennis/util/identify.rb
110
113
  - lib/table_tennis/util/inspectable.rb
111
114
  - lib/table_tennis/util/magic_options.rb
@@ -143,7 +146,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
143
146
  - !ruby/object:Gem::Version
144
147
  version: '0'
145
148
  requirements: []
146
- rubygems_version: 3.6.2
149
+ rubygems_version: 3.6.9
147
150
  specification_version: 4
148
151
  summary: Stylish tables in your terminal.
149
152
  test_files: []
data/justfile DELETED
@@ -1,72 +0,0 @@
1
- default: test
2
-
3
- # check repo - lint & test
4
- check: lint test
5
-
6
- # for ci. don't bother linting on windows
7
- ci:
8
- @if [[ "{{os()}}" != "windows" ]]; then just lint ; fi
9
- @just test
10
-
11
- # check test coverage
12
- coverage:
13
- COVERAGE=1 just test
14
- open /tmp/coverage/index.html
15
-
16
- # format with rubocop
17
- format: (lint "-a")
18
-
19
- gem-local:
20
- @just _banner rake install:local...
21
- bundle exec rake install:local
22
-
23
- # this will tag, build and push to rubygems
24
- gem-push: check
25
- @if rg -g '!justfile' "\bREMIND\b" ; then just _fatal "REMIND found, bailing" ; fi
26
- @just _banner rake release...
27
- rake release
28
-
29
- # optimize images
30
- image_optim:
31
- @# advpng/pngout are slow. consider --verbose as well
32
- @bundle exec image_optim --allow-lossy --svgo-precision=1 --no-advpng --no-pngout -r .
33
-
34
- # lint with rubocop
35
- lint *ARGS:
36
- @just _banner lint...
37
- bundle exec rubocop {{ARGS}}
38
-
39
- # start pry with the lib loaded
40
- pry:
41
- bundle exec pry -I lib -r table_tennis.rb
42
-
43
- # run tennis repeatedly
44
- tennis-watch *ARGS:
45
- @watchexec --stop-timeout=0 --clear clear tennis {{ARGS}}
46
-
47
- # run tests
48
- test *ARGS:
49
- @just _banner rake test {{ARGS}}
50
- @bundle exec rake test {{ARGS}}
51
-
52
- # run tests repeatedly
53
- test-watch *ARGS:
54
- watchexec --stop-timeout=0 --clear clear just test "{{ARGS}}"
55
-
56
- # create sceenshot using vhs
57
- vhs:
58
- @just _banner "running vhs..."
59
- vhs demo.tape
60
- magick /tmp/dark.png -crop 1448x1004+18+16 screenshots/dark.png
61
-
62
- #
63
- # util
64
- #
65
-
66
- _banner *ARGS: (_message BG_GREEN ARGS)
67
- _warning *ARGS: (_message BG_YELLOW ARGS)
68
- _fatal *ARGS: (_message BG_RED ARGS)
69
- @exit 1
70
- _message color *ARGS:
71
- @msg=$(printf "[%s] %s" $(date +%H:%M:%S) "{{ARGS}}") ; \
72
- printf "{{color+BOLD+WHITE}}%-72s{{ NORMAL }}\n" "$msg"