table_tennis 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,275 @@
1
+ module TableTennis
2
+ module Util
3
+ # Very complicated module for determining the terminal background color,
4
+ # used to select the default color theme.
5
+ module Termbg
6
+ prepend MemoWise
7
+
8
+ module_function
9
+
10
+ # get fg color as "#RRGGBB", or nil if we can't tel
11
+ def fg = osc_query(10) || env_colorfgbg&.fetch(0)
12
+ memo_wise self: :fg
13
+
14
+ # get bg color as "#RRGGBB", or nil if we can't tell
15
+ def bg = osc_query(11) || env_colorfgbg&.fetch(1)
16
+ memo_wise self: :bg
17
+
18
+ # mostly for debugging
19
+ def info
20
+ {
21
+ fg:,
22
+ bg:,
23
+ bg_luma: bg ? Colors.luma(bg) : nil,
24
+ tty?: "#{$stdin.tty?}/#{$stdout.tty?}/#{$stderr.tty?}",
25
+ in_foreground?: in_foreground?,
26
+ osc_supported?: osc_supported?,
27
+ "$COLORFGBG": ENV["COLORFGBG"],
28
+ "$TERM": ENV["TERM"],
29
+ colorfgbg: env_colorfgbg,
30
+ }
31
+ end
32
+
33
+ #
34
+ # osc_query
35
+ #
36
+
37
+ # escape chars
38
+ ESC, BEL, ST, = "\e", "\a", "\e\\"
39
+
40
+ # Operating System Control queries
41
+ OSC_FG, OSC_BG = 10, 11
42
+
43
+ def osc_supported?
44
+ host, platform, term = [
45
+ RbConfig::CONFIG["host_os"],
46
+ RbConfig::CONFIG["platform"],
47
+ ENV["TERM"],
48
+ ]
49
+ error = if host !~ /darwin|freebsd|linux|netbsd|openbsd/
50
+ "bad host"
51
+ elsif platform !~ /^(arm64|x86_64)/
52
+ "bad platform"
53
+ elsif term =~ /^(screen|tmux|dumb)/i
54
+ "bad TERM"
55
+ elsif ENV["ZELLIJ"]
56
+ "zellij"
57
+ end
58
+ if error
59
+ debug("osc_supported? #{{host:, platform:, term:}} => #{error}")
60
+ return false
61
+ end
62
+ debug("osc_supported? #{{host:, platform:, term:}} => success")
63
+ true
64
+ end
65
+
66
+ def osc_query(attr)
67
+ # let's be conservative
68
+ return if !osc_supported?
69
+
70
+ # mucking with the tty will hang if we are not in the foreground
71
+ return if !in_foreground?
72
+
73
+ # we can't touch stdout inside IO.console.raw, so save these for later
74
+ logs = []
75
+
76
+ debug("osc_query(#{attr})")
77
+ begin
78
+ IO.console.raw do
79
+ logs << " IO.console.raw"
80
+
81
+ # we send two messages - the cursor query is widely supported, so we
82
+ # always end with that. if the first message is ignored we will still
83
+ # get an answer to the second so we know when to stop reading from stdin
84
+ msg = [].tap do
85
+ # operating system control with Ps=attr
86
+ _1 << "\e]#{attr};?\a"
87
+ # device status report with Ps = 6 (cursor position)
88
+ _1 << "\e[6n"
89
+ end.join
90
+
91
+ logs << " syswrite #{msg.inspect}"
92
+ IO.console.syswrite(msg)
93
+
94
+ # there should always be at least one response. If this is a response to
95
+ # the cursor message, the first message didn't work
96
+ response1 = read_term_response.tap do
97
+ logs << " got #{_1.inspect}"
98
+ if !(_1 && _1[1] == "]")
99
+ logs << " not OSC, bailing"
100
+ return
101
+ end
102
+ response2 = read_term_response # skip cursor response
103
+ logs << " got #{response2.inspect}"
104
+ end
105
+ decoded = decode_osc_response(response1)
106
+ logs << "=> #{decoded}"
107
+ decoded
108
+ end
109
+ ensure
110
+ logs.each { debug(_1) }
111
+ end
112
+ end
113
+ private_class_method :osc_query
114
+
115
+ # read a response, which could be either an OSC or cursor response
116
+ def read_term_response
117
+ # fast forward to ESC
118
+ loop do
119
+ return if !(ch = IO.console.getbyte&.chr)
120
+ break ch if ch == ESC
121
+ end
122
+ # next char should be either [ or ]
123
+ return if !(type = IO.console.getbyte&.chr)
124
+ return if !(type == "[" || type == "]")
125
+
126
+ # now read the response. note that the response can end in different ways
127
+ # and we have to check for all of them
128
+ buf = "#{ESC}#{type}"
129
+ loop do
130
+ return if !(ch = IO.console.getbyte&.chr)
131
+ buf << ch
132
+ break if type == "[" && buf.end_with?("R")
133
+ break if type == "]" && buf.end_with?(BEL, ST)
134
+ end
135
+ buf
136
+ end
137
+ private_class_method :read_term_response
138
+
139
+ #
140
+ # color math
141
+ #
142
+
143
+ def decode_osc_response(response)
144
+ if response =~ %r{;rgb:([0-9a-f/]+)}i
145
+ rgb = $1.split("/")
146
+ return if rgb.length != 3
147
+ hex = rgb.join
148
+ return if hex.length % 3 != 0
149
+ Colors.to_hex(Colors.to_rgb(hex))
150
+ end
151
+ end
152
+ private_class_method :decode_osc_response
153
+
154
+ #
155
+ # in_foreground?
156
+ #
157
+
158
+ # returns true/false or nil (if unknown)
159
+ def in_foreground?
160
+ if !respond_to?(:tcgetpgrp)
161
+ load_ffi!
162
+ return if !respond_to?(:tcgetpgrp)
163
+ end
164
+
165
+ io = IO.console
166
+ if (ttypgrp = tcgetpgrp(io.fileno)) <= 0
167
+ debug("tcpgrp(#{io.fileno}) => #{ttypgrp}, errno=#{FFI.errno}")
168
+ return
169
+ end
170
+ debug("tcpgrp(#{io.fileno}) => #{ttypgrp}")
171
+
172
+ # now compare against our process group
173
+ infg = Process.getpgrp == ttypgrp
174
+ debug("Process.getpgrp => #{Process.getpgrp}, in_foreground? #{infg}")
175
+ infg
176
+ end
177
+ private_class_method :in_foreground?
178
+ memo_wise self: :in_foreground?
179
+
180
+ def load_ffi!
181
+ module_eval do
182
+ extend FFI::Library
183
+ ffi_lib "c"
184
+ attach_function :tcgetpgrp, %i[int], :int32
185
+ debug("ffi attach libc.tcgetpgrp => success")
186
+ end
187
+ rescue LoadError => ex
188
+ debug("ffi attach libc.tcgetpgrp => failed #{ex.message}")
189
+ end
190
+ private_class_method :load_ffi!
191
+ memo_wise self: :load_ffi!
192
+
193
+ def env_colorfgbg(env = ENV["COLORFGBG"])
194
+ if env !~ /^\d+;\d+$/
195
+ debug("env_colorfgbg: COLORFGBG '#{env.inspect}'") if env
196
+ return
197
+ end
198
+ colors = env.split(";").map { Colors.ansi_color_to_hex(_1.to_i) }
199
+ debug("env_colorfgbg: #{env.inspect}' => #{colors.inspect}")
200
+ colors
201
+ end
202
+ private_class_method :env_colorfgbg
203
+
204
+ def debug(s)
205
+ puts "termbg: #{s}" if ENV["TM_DEBUG"]
206
+ end
207
+ private_class_method :debug
208
+ end
209
+ end
210
+ end
211
+
212
+ #
213
+ # This comment is down here to avoid polluting ruby-lsp hover.
214
+ #
215
+ # Is the terminal dark or light? To answer this simple question, we need to
216
+ # query the terminal to get the current background color.
217
+ #
218
+ # https://github.com/dalance/termbg
219
+ # https://github.com/muesli/termenv
220
+ # https://github.com/rocky/shell-term-background
221
+ #
222
+ # This is absurdly difficult, so here is our approach:
223
+ #
224
+ # 1. Use OSC 11 to query the bgcolor using a magical escape sequence. We write
225
+ # the escape sequence to stdout and read the response from stdin. Not all
226
+ # terminals support this. Also, the terminal must be in "raw" mode for this
227
+ # to work. Raw mode means disable echo and disable line buffering.
228
+ #
229
+ # https://en.wikipedia.org/wiki/ANSI_escape_code
230
+ # https://github.com/ruby/io-console/blob/master/lib/ffi/io/console/common.rb
231
+ # https://www.xfree86.org/4.8.0/ctlseqs.html
232
+ # https://stackoverflow.com/questions/2507337/
233
+ #
234
+ # 2. Because not all terminals support OSC 11, we actually send two magic escape
235
+ # sequences - OSC 11 and a "where is the cursor" message. Because the second
236
+ # query is universally supported we always get a response. That's how we
237
+ # avoid breaking stdin by over/under reading.
238
+ #
239
+ # 3. Mucking with the tty can hang (!) under some circumstances, which is a poor
240
+ # outcome for a fun ruby library like this. Only try this if we are "in the
241
+ # foreground". You can easily try this with the following command:
242
+ #
243
+ # $ watchexec stty sane # this hangs
244
+ # $ watchexec --wrap-process=none stty sane # this works fine
245
+ #
246
+ # https://github.com/watchexec/watchexec/issues/874
247
+ # https://github.com/ruby/io-console
248
+ #
249
+ # 4. To detect if we are in the foreground, compare the process group against
250
+ # the group the process group that owns stdin. If they match, we are good to
251
+ # go.
252
+ #
253
+ # https://unix.stackexchange.com/questions/736821/
254
+ #
255
+ # 5. Sadly, ruby does not have an easy way to get the process group of stdin.
256
+ # Instead, we have to use ruby-termios, ffi or $stdin.ioctl using a magic
257
+ # ioctl number (this magic number differs across platforms). I went with
258
+ # ffi.
259
+ #
260
+ # https://github.com/arika/ruby-termios
261
+ # https://github.com/torvalds/linux/blob/master/include/uapi/asm-generic/ioctls.h
262
+ # https://github.com/swiftlang/swift/blob/main/stdlib/public/Platform/TiocConstants.swift
263
+ #
264
+ # 6. If the foreground is lighter than the background, the background is dark.
265
+ #
266
+ # 7. As a fallback to OSC 11, we also support $COLORFGBG. Terminals can set this
267
+ # environment variable to communicate colors to apps. Support is spotty,
268
+ # unfortunately. Even when COLORFGBG is set, it is not updated after the
269
+ # terminal is started. So it can be out of date if the user mucks with
270
+ # colors.
271
+ #
272
+ # https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
273
+ # https://unix.stackexchange.com/questions/245378/
274
+ # https://www.xfree86.org/4.8.0/XLookupColor.3.html#toc4
275
+ #
@@ -0,0 +1,3 @@
1
+ module TableTennis
2
+ VERSION = "0.0.1".freeze
3
+ end
@@ -0,0 +1,29 @@
1
+ # gems
2
+ require "csv"
3
+ require "ffi"
4
+ require "forwardable"
5
+ require "io/console"
6
+ require "memo_wise"
7
+ require "paint"
8
+ require "unicode/display_width"
9
+
10
+ # mixins must be at top
11
+ require "table_tennis/util/inspectable"
12
+
13
+ require "table_tennis/column"
14
+ require "table_tennis/config"
15
+ require "table_tennis/row"
16
+ require "table_tennis/table_data"
17
+ require "table_tennis/table"
18
+ require "table_tennis/theme"
19
+
20
+ require "table_tennis/stage/base"
21
+ require "table_tennis/stage/format"
22
+ require "table_tennis/stage/layout"
23
+ require "table_tennis/stage/painter"
24
+ require "table_tennis/stage/render"
25
+
26
+ require "table_tennis/util/colors"
27
+ require "table_tennis/util/scale"
28
+ require "table_tennis/util/strings"
29
+ require "table_tennis/util/termbg"
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,28 @@
1
+ require_relative "lib/table_tennis/version"
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = "table_tennis"
5
+ s.version = TableTennis::VERSION
6
+ s.authors = ["Adam Doppelt"]
7
+ s.email = "amd@gurge.com"
8
+
9
+ s.summary = "Stylish tables in your terminal."
10
+ s.homepage = "http://github.com/gurgeous/table_tennis"
11
+ s.license = "MIT"
12
+ s.required_ruby_version = ">= 3.0.0"
13
+ s.metadata = {
14
+ "rubygems_mfa_required" => "true",
15
+ "source_code_uri" => s.homepage,
16
+ }
17
+
18
+ # what's in the gem?
19
+ s.files = `git ls-files`.split("\n").grep_v(%r{^(bin|samples|test)/})
20
+ s.require_paths = ["lib"]
21
+
22
+ # gem dependencies
23
+ s.add_dependency "csv", "~> 3.3" # required for Ruby 3.4+
24
+ s.add_dependency "ffi", "~> 1.17" # required for Ruby 3.2+
25
+ s.add_dependency "memo_wise", "~> 1.11"
26
+ s.add_dependency "paint", "~> 2.3"
27
+ s.add_dependency "unicode-display_width", "~> 3.1"
28
+ end
metadata ADDED
@@ -0,0 +1,145 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: table_tennis
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Adam Doppelt
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-04-11 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: csv
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.3'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ffi
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.17'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.17'
40
+ - !ruby/object:Gem::Dependency
41
+ name: memo_wise
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.11'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.11'
54
+ - !ruby/object:Gem::Dependency
55
+ name: paint
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.3'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.3'
68
+ - !ruby/object:Gem::Dependency
69
+ name: unicode-display_width
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.1'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.1'
82
+ email: amd@gurge.com
83
+ executables: []
84
+ extensions: []
85
+ extra_rdoc_files: []
86
+ files:
87
+ - ".github/workflows/test.yml"
88
+ - ".gitignore"
89
+ - ".rubocop.yml"
90
+ - Gemfile
91
+ - Gemfile.lock
92
+ - LICENSE
93
+ - README.md
94
+ - Rakefile
95
+ - justfile
96
+ - lib/table_tennis.rb
97
+ - lib/table_tennis/column.rb
98
+ - lib/table_tennis/config.rb
99
+ - lib/table_tennis/row.rb
100
+ - lib/table_tennis/stage/base.rb
101
+ - lib/table_tennis/stage/format.rb
102
+ - lib/table_tennis/stage/layout.rb
103
+ - lib/table_tennis/stage/painter.rb
104
+ - lib/table_tennis/stage/render.rb
105
+ - lib/table_tennis/table.rb
106
+ - lib/table_tennis/table_data.rb
107
+ - lib/table_tennis/theme.rb
108
+ - lib/table_tennis/util/colors.rb
109
+ - lib/table_tennis/util/inspectable.rb
110
+ - lib/table_tennis/util/scale.rb
111
+ - lib/table_tennis/util/strings.rb
112
+ - lib/table_tennis/util/termbg.rb
113
+ - lib/table_tennis/version.rb
114
+ - screenshots/dark.png
115
+ - screenshots/droids.png
116
+ - screenshots/hope.png
117
+ - screenshots/light.png
118
+ - screenshots/row_numbers.png
119
+ - screenshots/scales.png
120
+ - screenshots/themes.png
121
+ - table_tennis.gemspec
122
+ homepage: http://github.com/gurgeous/table_tennis
123
+ licenses:
124
+ - MIT
125
+ metadata:
126
+ rubygems_mfa_required: 'true'
127
+ source_code_uri: http://github.com/gurgeous/table_tennis
128
+ rdoc_options: []
129
+ require_paths:
130
+ - lib
131
+ required_ruby_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: 3.0.0
136
+ required_rubygems_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: '0'
141
+ requirements: []
142
+ rubygems_version: 3.6.2
143
+ specification_version: 4
144
+ summary: Stylish tables in your terminal.
145
+ test_files: []