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,161 @@
1
+ module TableTennis
2
+ # This class stores our data as rows & columns. The initialization is a little
3
+ # tricky due to memoization and some ordering issues, but basically works like
4
+ # this:
5
+ #
6
+ # 1) Start with input_rows.
7
+ # 2) Calculate `fat_rows` which is an array of hashes with all keys. This
8
+ # might be too much data because we haven't taken config.columns into
9
+ # account.
10
+ # 3) Use fat_rows to calculate rows & columns.
11
+ #
12
+ # Rows are hash tables and column names are symbols.
13
+ class TableData
14
+ prepend MemoWise
15
+ include Util::Inspectable
16
+
17
+ attr_accessor :config, :input_rows, :styles
18
+
19
+ def initialize(input_rows:, config: nil)
20
+ @config, @input_rows = config, input_rows
21
+
22
+ if !config && !ENV["MINITEST"]
23
+ raise ArgumentError, "must provide a config"
24
+ end
25
+
26
+ # We leave input_rows untouched so we can pass them back to the user for
27
+ # `mark`. The only strange case is if the user passed in a plain old Hash.
28
+ if @input_rows.is_a?(Hash)
29
+ @input_rows = @input_rows.map { |key, value| {key:, value:} }
30
+ elsif !@input_rows.is_a?(Enumerable)
31
+ raise ArgumentError, "input_rows must be an array of hash-like objects, not #{input_rows.class}"
32
+ end
33
+
34
+ @styles = {}
35
+ end
36
+
37
+ # Lazily calculate the list of columns.
38
+ def columns
39
+ names = config&.columns
40
+ names ||= {}.tap do |memo|
41
+ fat_rows.each { |row| row.each_key { memo[_1] = 1 } }
42
+ end.keys
43
+ names.each do |name|
44
+ if !fat_rows.any? { _1.key?(name) }
45
+ raise ArgumentError, "specified column `#{name}` not found in any row of input data"
46
+ end
47
+ end
48
+ names.map { Column.new(_1, self) }
49
+ end
50
+ memo_wise :columns
51
+
52
+ # Lazily calculate column names (always symbols)
53
+ def column_names = columns.map(&:name)
54
+ memo_wise :column_names
55
+
56
+ # fat_rows is an array of hashes with ALL keys (not just config.columns).
57
+ # rows is an array of Row objects with just the keys we want. We use
58
+ # memoization to cache the result of fat_rows, and then we create final rows
59
+ # with just the columns we want
60
+ def rows
61
+ fat_rows.map { Row.new(column_names, _1) }
62
+ end
63
+ memo_wise :rows
64
+
65
+ # currnet theme
66
+ def theme = Theme.new(config&.theme)
67
+ memo_wise :theme
68
+
69
+ # Set the style for a cell, row or column. The "style" is a
70
+ # theme symbol or hex color.
71
+ def set_style(style:, r: nil, c: nil)
72
+ styles[[r, c]] = style
73
+ end
74
+
75
+ # Get the style for a cell, row or column.
76
+ def get_style(r: nil, c: nil)
77
+ styles[[r, c]]
78
+ end
79
+
80
+ # what is the width of the columns, not including chrome?
81
+ def data_width = columns.sum(&:width)
82
+
83
+ # how wide is the table?
84
+ def table_width = data_width + chrome_width
85
+
86
+ # layout math
87
+ #
88
+ # |•xxxx•|•xxxx•|•xxxx•|•xxxx•||•xxxx•|•xxxx•|•xxxx•|•xxxx•|
89
+ # ↑↑ ↑ ↑
90
+ # 12 3 <- three chrome chars per column │
91
+ # │
92
+ # extra chrome char at the end
93
+ #
94
+ def chrome_width = columns.length * 3 + 1
95
+
96
+ # this is handy for tests
97
+ def first_cell = rows.first.values&.first
98
+
99
+ # for debugging
100
+ def debug(str)
101
+ return if !config&.debug
102
+ str = "[#{Time.now.strftime("%H:%M:%S")}] #{str}"
103
+ str = str.ljust(@debug_width ||= IO.console.winsize[1])
104
+ puts Paint[str, :white, :green]
105
+ end
106
+
107
+ def debug_if_slow(str, &block)
108
+ tm = Time.now
109
+ yield.tap do
110
+ if (elapsed = Time.now - tm) > 0.01
111
+ debug(sprintf("%-40s [+%0.3fs]", str, elapsed))
112
+ end
113
+ end
114
+ end
115
+
116
+ protected
117
+
118
+ # fat_rows is an array of hashes with ALL keys (not just config.columns).
119
+ def fat_rows
120
+ first_row = input_rows.first
121
+ array = if first_row.is_a?(Hash)
122
+ input_rows
123
+ elsif first_row.is_a?(Array)
124
+ indexes = first_row.each_index.to_a
125
+ input_rows.map { indexes.zip(_1).to_h }
126
+ elsif first_row.respond_to?(:to_h)
127
+ input_rows.map(&:to_h)
128
+ elsif first_row.respond_to?(:attributes)
129
+ input_rows.map(&:attributes)
130
+ else
131
+ raise ArgumentError, "unknown row type #{first_row.inspect}"
132
+ end
133
+ return [] if array.empty?
134
+
135
+ # this is faster with a lookup table
136
+ quick = array.first.keys.map { [_1, symbolize(_1)] }
137
+ fat_rows = array.map do |row|
138
+ row.transform_keys.with_index do |name, ii|
139
+ if (q = quick[ii]) && (name == q[0])
140
+ q[1]
141
+ else
142
+ symbolize(name)
143
+ end
144
+ end
145
+ end
146
+
147
+ # add row_numbers
148
+ if config&.row_numbers?
149
+ fat_rows = fat_rows.map.with_index do |row, r|
150
+ row.to_a.unshift([:"#", r + 1]).to_h
151
+ end
152
+ end
153
+
154
+ fat_rows
155
+ end
156
+ memo_wise :fat_rows
157
+
158
+ # helper for turning something into a symbol
159
+ def symbolize(obj) = obj.is_a?(Symbol) ? obj : obj.to_s.to_sym
160
+ end
161
+ end
@@ -0,0 +1,92 @@
1
+ module TableTennis
2
+ # This class contains the current theme, as well as the definitions for all
3
+ # themes.
4
+ class Theme
5
+ prepend MemoWise
6
+
7
+ RESET = Paint::NOTHING
8
+ THEMES = {
9
+ dark: {
10
+ title: "#7f0",
11
+ chrome: "gray-500",
12
+ cell: "gray-200",
13
+ mark: %w[white blue-500],
14
+ search: %w[black yellow-300],
15
+ zebra: %w[white #333],
16
+ },
17
+ light: {
18
+ title: "blue-600",
19
+ chrome: "#bbb",
20
+ cell: "gray-800",
21
+ mark: %w[white blue-500],
22
+ search: %w[black yellow-300],
23
+ zebra: %w[black gray-200],
24
+ },
25
+ ansi: {
26
+ title: :green,
27
+ chrome: %i[faint default],
28
+ cell: :default,
29
+ mark: %i[white blue],
30
+ search: %i[white magenta],
31
+ zebra: nil, # not supported
32
+ },
33
+ }
34
+ THEME_KEYS = THEMES[:dark].keys
35
+ BG = [nil, :default]
36
+
37
+ attr_reader :name
38
+
39
+ def initialize(name)
40
+ @name = name
41
+ raise ArgumentError, "unknown theme #{name}, should be one of #{THEMES.keys}" if !THEMES.key?(name)
42
+ end
43
+
44
+ # Value is one of the following:
45
+ # - theme.symbol (like ":title")
46
+ # - a color that works with Colors.get (#fff, or :bold, or "steelblue")
47
+ # - an array of colors
48
+ def codes(value)
49
+ # theme key?
50
+ if value.is_a?(Symbol) && THEME_KEYS.include?(value)
51
+ value = THEMES[name][value]
52
+ end
53
+ # turn value(s) into colors
54
+ colors = Array(value).map { Util::Colors.get(_1) }
55
+ return if colors == [] || colors == [nil]
56
+
57
+ # turn colors into ansi codes
58
+ Paint["", *colors].gsub(RESET, "")
59
+ end
60
+ memo_wise :codes
61
+
62
+ # Apply colors to a string. Value is one of the following:
63
+ # - theme.symbol (like ":title")
64
+ # - a color that works with Colors.get (#fff, or :bold, or "steelblue")
65
+ # - an array of colors
66
+ def paint(str, value)
67
+ if (codes = codes(value))
68
+ str = str.gsub(RESET, "#{RESET}#{codes}")
69
+ str = "#{codes}#{str}#{RESET}"
70
+ end
71
+ str
72
+ end
73
+
74
+ # for debugging, mostly
75
+ def self.info
76
+ sample = if !Config.detect_color?
77
+ "(color is disabled)"
78
+ elsif Config.detect_theme == :light
79
+ Paint[" light theme ", "#000", "#eee", :bold]
80
+ elsif Config.detect_theme == :dark
81
+ Paint[" dark theme ", "#fff", "#444", :bold]
82
+ end
83
+
84
+ {
85
+ detect_color?: Config.detect_color?,
86
+ detect_theme: Config.detect_theme,
87
+ sample:,
88
+ terminal_dark?: Config.terminal_dark?,
89
+ }.merge(Util::Termbg.info)
90
+ end
91
+ end
92
+ end