glance-widgets-sdk 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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +17 -0
  3. data/README.md +47 -0
  4. data/lib/glance_widgets/box.rb +117 -0
  5. data/lib/glance_widgets/color.rb +48 -0
  6. data/lib/glance_widgets/components/bar_chart.rb +65 -0
  7. data/lib/glance_widgets/components/battery.rb +77 -0
  8. data/lib/glance_widgets/components/charts.rb +5 -0
  9. data/lib/glance_widgets/components/heatmap.rb +184 -0
  10. data/lib/glance_widgets/components/line_chart.rb +213 -0
  11. data/lib/glance_widgets/components/modern.rb +59 -0
  12. data/lib/glance_widgets/components/pixel_bar_chart.rb +106 -0
  13. data/lib/glance_widgets/components/pixel_progress.rb +151 -0
  14. data/lib/glance_widgets/components/pixel_text.rb +66 -0
  15. data/lib/glance_widgets/components/progress.rb +62 -0
  16. data/lib/glance_widgets/components/ring_progress.rb +89 -0
  17. data/lib/glance_widgets/components/shared.rb +131 -0
  18. data/lib/glance_widgets/components/signal.rb +41 -0
  19. data/lib/glance_widgets/components/stat.rb +171 -0
  20. data/lib/glance_widgets/components/status.rb +102 -0
  21. data/lib/glance_widgets/data/fonts/font-3x5.json +120 -0
  22. data/lib/glance_widgets/data/fonts/font-5x7.json +118 -0
  23. data/lib/glance_widgets/data/icons.json +5293 -0
  24. data/lib/glance_widgets/data/schemas.json +418 -0
  25. data/lib/glance_widgets/data/text-advances.json +120 -0
  26. data/lib/glance_widgets/font.rb +165 -0
  27. data/lib/glance_widgets/geometry.rb +108 -0
  28. data/lib/glance_widgets/layers.rb +152 -0
  29. data/lib/glance_widgets/matrix.rb +277 -0
  30. data/lib/glance_widgets/options.rb +31 -0
  31. data/lib/glance_widgets/output.rb +91 -0
  32. data/lib/glance_widgets/pixelate.rb +46 -0
  33. data/lib/glance_widgets/protocol.rb +217 -0
  34. data/lib/glance_widgets/raster.rb +130 -0
  35. data/lib/glance_widgets/signature.rb +30 -0
  36. data/lib/glance_widgets/support.rb +134 -0
  37. data/lib/glance_widgets/themes.rb +34 -0
  38. data/lib/glance_widgets.rb +38 -0
  39. metadata +79 -0
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GlanceWidgets
4
+ DEFAULT_ALPHA_THRESHOLD = 128
5
+
6
+ def pixelate(image, options)
7
+ width, height, data = image.fetch('width'), image.fetch('height'), image.fetch('data')
8
+ Raster.integers('Image size', [width, height])
9
+ width, height = width.to_i, height.to_i
10
+ unless width >= 1 && height >= 1 && data.length == width * height * 4
11
+ raise ArgumentError, 'Invalid RGBA dimensions/data length'
12
+ end
13
+ raise ArgumentError, 'Unsupported dither' unless DataValues.fetch_or(options, 'dither', 'none') == 'none'
14
+ palette = DataValues.fetch_or(options, 'palette', []).map { |color| [normalize_color(color), parse_color(color)] }
15
+ raise ArgumentError, 'Palette must not be empty' if DataValues.set?(options, 'palette') && palette.empty?
16
+ matrix = Matrix.new(options.fetch('columns'), options.fetch('rows'), DataValues.fetch_or(options, 'matrix', {}))
17
+ divide_round = ->(n, d) { (2 * n + d).div(2 * d) }
18
+ matrix.rows.times do |row|
19
+ top = row * height / matrix.rows
20
+ bottom = [top + 1, (row + 1) * height / matrix.rows].max
21
+ matrix.columns.times do |col|
22
+ left = col * width / matrix.columns
23
+ right = [left + 1, (col + 1) * width / matrix.columns].max
24
+ sums, alpha = [0, 0, 0], 0
25
+ (top...bottom).each do |y|
26
+ (left...right).each do |x|
27
+ index = (y * width + x) * 4
28
+ a = data[index + 3]
29
+ alpha += a
30
+ 3.times { |k| sums[k] += data[index + k] * a }
31
+ end
32
+ end
33
+ next if divide_round.call(alpha, (bottom - top) * (right - left)) < DataValues.fetch_or(options, 'alphaThreshold', 128)
34
+ channels = alpha.zero? ? [0, 0, 0] : sums.map { |n| divide_round.call(n, alpha) }
35
+ average = %w[r g b].zip(channels).to_h.merge('a' => 255)
36
+ color = if palette.empty?
37
+ format_color(average)
38
+ else
39
+ palette.min_by { |_, rgb| %w[r g b].sum { |key| (rgb[key] - average[key])**2 } }[0]
40
+ end
41
+ matrix.set(col, row, color)
42
+ end
43
+ end
44
+ matrix
45
+ end
46
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GlanceWidgets
4
+ PROTOCOL_VERSION = 1
5
+ MAX_RESPONSE_BYTES = 524_288
6
+ MAX_MATRIX_CELLS = 4096
7
+ LAYER_ID_PREFIX = 'remote:'
8
+ MAX_SAFE_NATIVE_INTEGER = 9.2e18
9
+ CELL_ASPECT_RATIO_MIN = 0.1
10
+ CELL_ASPECT_RATIO_MAX = 10
11
+ EDGE_INSET_RATIO_LIMIT = 0.45
12
+ GAP_RATIO_MAX = 10
13
+ FRAME_SIZE_MIN = 0.02
14
+ FRAME_SIZE_MAX = 1.5
15
+ SHAPE_MIN_SIZE_PT = 24
16
+ DATA_PATH_PATTERN = /\Adata(?:\.[A-Za-z_][A-Za-z0-9_]*|\[[0-9]+\])*\z/
17
+
18
+ class GlanceValidationError < ArgumentError
19
+ attr_reader :path
20
+
21
+ def initialize(path, message)
22
+ @path = path
23
+ super("#{path}: #{message}")
24
+ end
25
+ end
26
+
27
+ # Schema tables are protocol data. Validation itself uses Ruby's JSON types.
28
+ module Protocol
29
+ SCHEMAS = DataValues.load('schemas.json')
30
+ module_function
31
+
32
+ def fail_at(path, message)
33
+ raise GlanceValidationError.new(path, message)
34
+ end
35
+
36
+ def json(value, path)
37
+ case value
38
+ when NilClass, String, TrueClass, FalseClass then nil
39
+ when Numeric
40
+ unless Numbers.finite?(value) && value.abs < MAX_SAFE_NATIVE_INTEGER
41
+ fail_at(path, 'number is unsafe for the native renderer')
42
+ end
43
+ when Array
44
+ value.each_with_index { |item, index| json(item, "#{path}[#{index}]") }
45
+ when Hash
46
+ value.each do |key, item|
47
+ fail_at(path, 'JSON object keys must be strings') unless key.is_a?(String)
48
+ json(item, "#{path}.#{key}")
49
+ end
50
+ else fail_at(path, 'value is not JSON')
51
+ end
52
+ end
53
+
54
+ def rule(value, schema, path)
55
+ if schema.is_a?(String)
56
+ valid = case schema
57
+ when 'number' then Numbers.finite?(value)
58
+ when 'textValue' then value.is_a?(String) || Numbers.finite?(value)
59
+ when 'integer' then Numbers.integer?(value)
60
+ when 'boolean' then value == true || value == false
61
+ when 'string' then value.is_a?(String)
62
+ when 'color' then GlanceWidgets.is_app_color(value)
63
+ end
64
+ fail_at(path, "expected #{schema}") unless valid
65
+ elsif schema.key?('enum')
66
+ fail_at(path, 'invalid enum value') unless schema['enum'].include?(value)
67
+ elsif schema.key?('arrayOf')
68
+ unless value.is_a?(Array) && (!schema.key?('length') || value.length == schema['length'])
69
+ fail_at(path, 'invalid array')
70
+ end
71
+ value.each_with_index { |item, index| rule(item, schema['arrayOf'], "#{path}[#{index}]") }
72
+ elsif schema.key?('object')
73
+ object(value, schema['object'], path)
74
+ else
75
+ fail_at(path, 'expected object') unless value.is_a?(Hash)
76
+ value.each { |key, item| rule(item, schema['record'], "#{path}.#{key}") }
77
+ end
78
+ end
79
+
80
+ def object(value, schema, path, strict = false)
81
+ fail_at(path, 'expected object') unless value.is_a?(Hash)
82
+ value.each_key { |key| fail_at("#{path}.#{key}", 'unsupported field') unless schema.key?(key) } if strict
83
+ schema.each { |key, item| rule(value[key], item, "#{path}.#{key}") if value.key?(key) && !value[key].nil? }
84
+ end
85
+
86
+ def matrix(value, path)
87
+ pixels = value['pixels']
88
+ unless pixels.is_a?(Array) && !pixels.empty? && pixels[0].is_a?(Array) && !pixels[0].empty?
89
+ fail_at(path + '.pixels', 'must be a non-empty 2D array')
90
+ end
91
+ rows, columns = pixels.length, pixels[0].length
92
+ fail_at(path + '.pixels', 'matrix exceeds 4096 cells') if rows * columns > MAX_MATRIX_CELLS
93
+ pixels.each_with_index do |row, y|
94
+ row_path = "#{path}.pixels[#{y}]"
95
+ fail_at(row_path, 'inconsistent row width') unless row.is_a?(Array) && row.length == columns
96
+ row.each_with_index { |pixel, x| object(pixel, SCHEMAS['pixel'], "#{row_path}[#{x}]") unless pixel.nil? }
97
+ end
98
+ object(value['default'], SCHEMAS['pixel'], path + '.default') if value.key?('default')
99
+ object(value['layout'], SCHEMAS['layout'], path + '.layout') if value.key?('layout')
100
+ return unless value.key?('batch')
101
+ fail_at(path + '.batch', 'expected array') unless value['batch'].is_a?(Array)
102
+ value['batch'].each_with_index do |entry, index|
103
+ batch_path = "#{path}.batch[#{index}]"
104
+ object(entry, { 'pixel' => { 'object' => SCHEMAS['pixel'] } }, batch_path)
105
+ coordinates = entry['coordinates']
106
+ fail_at(batch_path + '.coordinates', 'expected array') unless coordinates.is_a?(Array)
107
+ coordinates.each_with_index do |pair, position|
108
+ pair_path = "#{batch_path}.coordinates[#{position}]"
109
+ rule(pair, { 'arrayOf' => 'integer', 'length' => 2 }, pair_path)
110
+ unless pair[0] >= 0 && pair[0] < rows && pair[1] >= 0 && pair[1] < columns
111
+ fail_at(pair_path, 'coordinates outside matrix')
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ def layer(layer, data, path)
118
+ object(layer, { 'id' => 'string', 'type' => 'string', 'dataPath' => 'string' }, path, true)
119
+ unless layer['id'].is_a?(String) && layer['id'].start_with?(LAYER_ID_PREFIX) && layer['id'].length > 7
120
+ fail_at(path + '.id', 'must start with remote:')
121
+ end
122
+ schema = SCHEMAS['layers'][layer['type']]
123
+ fail_at(path + '.type', 'unknown layer type') unless schema
124
+ binding = layer['dataPath']
125
+ unless binding.is_a?(String) && DATA_PATH_PATTERN.match(binding)
126
+ fail_at(path + '.dataPath', 'invalid dataPath')
127
+ end
128
+ node(GlanceWidgets.resolve_data_path(data, binding), schema, layer['type'], binding, true)
129
+ end
130
+
131
+ def node(node, schema, kind, path, requires_frame)
132
+ object(node, {}, path)
133
+ node.each_key do |key|
134
+ fail_at(path + '.' + key, 'unsupported field') unless %w[frame zIndex rotation transform content style].include?(key)
135
+ end
136
+ frame = node['frame']
137
+ if (frame || requires_frame) && !(frame.is_a?(Hash) && Numbers.finite?(frame['width']) && Numbers.finite?(frame['height']))
138
+ fail_at(path + '.frame', 'remote frame needs width and height')
139
+ end
140
+ object(frame, SCHEMAS['frame'], path + '.frame', true) unless frame.nil?
141
+ %w[zIndex rotation].each { |key| rule(node[key], key == 'zIndex' ? 'integer' : 'number', path + '.' + key) unless node[key].nil? }
142
+ object(node['transform'], SCHEMAS['transform'], path + '.transform', true) unless node['transform'].nil?
143
+ content = node['content'].nil? ? {} : node['content']
144
+ if kind == 'matrix'
145
+ object(content, {}, path + '.content')
146
+ content.each_key { |key| fail_at(path + '.content.' + key, 'unsupported field') unless %w[pixels default batch layout].include?(key) }
147
+ content = content.reject { |_, value| value.nil? }
148
+ if !content['pixels'].nil? || requires_frame
149
+ matrix(content, path + '.content')
150
+ elsif !content['batch'].nil?
151
+ fail_at(path + '.content.batch', 'batch requires pixels')
152
+ elsif !content['default'].nil?
153
+ matrix({ 'pixels' => [[nil]], 'default' => content['default'] }, path + '.content')
154
+ end
155
+ object(content['layout'], SCHEMAS['layout'], path + '.content.layout', true) unless content['layout'].nil?
156
+ else
157
+ object(content, schema['content'], path + '.content', true)
158
+ end
159
+ object(node['style'], schema['style'], path + '.style', true) unless node['style'].nil?
160
+ fail_at(path + '.style.gradientColors', 'must contain at least one color') if node['style'].is_a?(Hash) && node['style']['gradientColors'] == []
161
+ if %w[image icon].include?(kind)
162
+ glyph = !content['icon'].nil?
163
+ if (glyph && !content['url'].nil?) || (!glyph && %w[iconCodePoint iconFont].any? { |key| !content[key].nil? })
164
+ fail_at(path + '.content.icon', 'choose one artwork source')
165
+ end
166
+ end
167
+ end
168
+
169
+ end
170
+
171
+ def validate_layer_node(node, type, path = '$', requires_frame = false)
172
+ Protocol.json(node, path)
173
+ schema = Protocol::SCHEMAS['layers'][type]
174
+ Protocol.fail_at(path, 'unknown layer type') unless schema
175
+ Protocol.node(node, schema, type, path, requires_frame)
176
+ nil
177
+ end
178
+
179
+ def resolve_data_path(data, path)
180
+ return nil unless path.is_a?(String) && DATA_PATH_PATTERN.match(path)
181
+ path[4..-1].scan(/\.([A-Za-z_][A-Za-z0-9_]*)|\[([0-9]+)\]/).each do |key, index|
182
+ data = if key
183
+ data.is_a?(Hash) ? data[key] : nil
184
+ else
185
+ data.is_a?(Array) ? data[index.to_i] : nil
186
+ end
187
+ return nil if data.nil?
188
+ end
189
+ data
190
+ end
191
+
192
+ def validate(response)
193
+ Protocol.fail_at('$', 'response must be a JSON object') unless response.is_a?(Hash)
194
+ Protocol.json(response, '$')
195
+ if JSON.generate(response).bytesize > MAX_RESPONSE_BYTES
196
+ Protocol.fail_at('$', 'response exceeds 512 KB')
197
+ end
198
+ if response.key?('protocol') && (!Numbers.finite?(response['protocol']) || response['protocol'] != 1)
199
+ Protocol.fail_at('$.protocol', 'must be the number 1')
200
+ end
201
+ Protocol.matrix(response, '$')
202
+ data = response['data'].nil? ? {} : response['data']
203
+ Protocol.fail_at('$.data', 'expected object') unless data.is_a?(Hash)
204
+ return nil unless response.key?('layers')
205
+ layers = response['layers']
206
+ unless layers.is_a?(Array)
207
+ Protocol.fail_at('$.layers', 'expected array')
208
+ end
209
+ ids = {}
210
+ layers.each_with_index do |item, index|
211
+ Protocol.layer(item, data, "$.layers[#{index}]")
212
+ Protocol.fail_at("$.layers[#{index}].id", 'duplicate layer id') if ids[item['id']]
213
+ ids[item['id']] = true
214
+ end
215
+ nil
216
+ end
217
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GlanceWidgets
4
+ RASTER_MAX_EXTENT = 4096
5
+ RASTER_MAX_COORDINATE = 1 << 24
6
+ ARC_DIRECTION_SCALE = 1 << 20
7
+
8
+ module Raster
9
+ module_function
10
+
11
+ def integers(label, values)
12
+ raise ArgumentError, "#{label} must be integers" unless values.all? { |v| Numbers.integer?(v) }
13
+ end
14
+
15
+ def range(label, values, low, high)
16
+ integers(label, values)
17
+ raise ArgumentError, "#{label} outside permitted range" unless values.all? { |v| v.between?(low, high) }
18
+ end
19
+
20
+ def line(x0, y0, x1, y1)
21
+ dx, dy = (x1 - x0).abs, -(y1 - y0).abs
22
+ sx, sy = x0 < x1 ? 1 : -1, y0 < y1 ? 1 : -1
23
+ error, x, y = dx + dy, x0, y0
24
+ loop do
25
+ yield x, y
26
+ break if x == x1 && y == y1
27
+ twice = 2 * error
28
+ if twice >= dy
29
+ error += dy
30
+ x += sx
31
+ end
32
+ if twice <= dx
33
+ error += dx
34
+ y += sy
35
+ end
36
+ end
37
+ end
38
+
39
+ def mask(x, y, width, height, contains, fill, bounds)
40
+ inside = ->(i, j) { i >= 0 && j >= 0 && i < width && j < height && contains.call(i, j) }
41
+ ([0, -y].max.to_i...[height, bounds.rows - y].min.to_i).each do |j|
42
+ ([0, -x].max.to_i...[width, bounds.columns - x].min.to_i).each do |i|
43
+ next unless inside.call(i, j)
44
+ edge = [[1, 0], [-1, 0], [0, 1], [0, -1]].any? { |dx, dy| !inside.call(i + dx, j + dy) }
45
+ yield x + i, y + j if fill || edge
46
+ end
47
+ end
48
+ end
49
+
50
+ def ellipse(x, y, width, height, fill, bounds, &emit)
51
+ integers('ellipse position', [x, y])
52
+ range('ellipse size', [width, height], 1, RASTER_MAX_EXTENT)
53
+ contains = lambda do |i, j|
54
+ (2 * i + 1 - width)**2 * height**2 + (2 * j + 1 - height)**2 * width**2 <= width**2 * height**2
55
+ end
56
+ mask(x, y, width, height, contains, fill, bounds, &emit)
57
+ end
58
+
59
+ def round_rect(x, y, width, height, radius, fill, bounds, &emit)
60
+ integers('round_rect position/radius', [x, y, radius])
61
+ range('round_rect size', [width, height], 1, RASTER_MAX_EXTENT)
62
+ raise ArgumentError, 'Radius must be non-negative' if radius < 0
63
+ radius = [radius, [width, height].min.div(2)].min
64
+ contains = lambda do |i, j|
65
+ qi, qj = [i, width - 1 - i].min, [j, height - 1 - j].min
66
+ qi >= radius || qj >= radius || (2 * qi + 1 - 2 * radius)**2 +
67
+ (2 * qj + 1 - 2 * radius)**2 <= 4 * radius**2
68
+ end
69
+ mask(x, y, width, height, contains, fill, bounds, &emit)
70
+ end
71
+
72
+ def arc(cx, cy, radius, start_angle, end_angle, thickness, bounds)
73
+ integers('arc center', [cx, cy])
74
+ range('arc radius', [radius], 0, RASTER_MAX_EXTENT)
75
+ range('arc thickness', [thickness], 1, RASTER_MAX_EXTENT)
76
+ [start_angle, end_angle].each { |v| Numbers.require_finite(v, 'Arc angle') }
77
+ sweep = end_angle - start_angle
78
+ return if sweep <= 0
79
+ direction = lambda do |angle|
80
+ radians = angle * Math::PI / 180
81
+ x = Numbers.round(Math.sin(radians) * ARC_DIRECTION_SCALE)
82
+ [x, -Numbers.round(Math.cos(radians) * ARC_DIRECTION_SCALE)]
83
+ end
84
+ sx, sy = direction.call(start_angle)
85
+ ex, ey = direction.call(end_angle)
86
+ hole = radius - thickness
87
+ inner = hole >= 0 ? hole**2 + hole : -1
88
+ ([0, cy - radius].max.to_i..[bounds.rows - 1, cy + radius].min.to_i).each do |y|
89
+ ([0, cx - radius].max.to_i..[bounds.columns - 1, cx + radius].min.to_i).each do |x|
90
+ dx, dy = x - cx, y - cy
91
+ after, before = sx * dy - sy * dx >= 0, dx * ey - dy * ex >= 0
92
+ segment = sweep >= 360 || (sweep < 180 ? after && before : after || before)
93
+ distance = dx**2 + dy**2
94
+ yield x, y if distance > inner && distance <= radius**2 + radius && segment
95
+ end
96
+ end
97
+ end
98
+
99
+ def polygon(points, fill, bounds)
100
+ raise ArgumentError, 'Polygon needs at least 3 points' unless points.is_a?(Array) && points.length >= 3
101
+ points.each do |point|
102
+ raise ArgumentError, 'Polygon points must be pairs' unless point.is_a?(Array) && point.length == 2
103
+ range('polygon coordinate', point, -RASTER_MAX_COORDINATE, RASTER_MAX_COORDINATE)
104
+ end
105
+ edges = points.each_index.map { |i| [points[i], points[(i + 1) % points.length]] }
106
+ edges.each do |a, b|
107
+ line(*a, *b) { |x, y| yield x, y if x >= 0 && y >= 0 && x < bounds.columns && y < bounds.rows }
108
+ end
109
+ return unless fill
110
+ xs, ys = points.map(&:first), points.map(&:last)
111
+ ([0, ys.min].max.to_i..[bounds.rows - 1, ys.max].min.to_i).each do |y|
112
+ cy = 2 * y + 1
113
+ spanning = edges.select { |a, b| (2 * a[1] <= cy) != (2 * b[1] <= cy) }
114
+ ([0, xs.min].max.to_i..[bounds.columns - 1, xs.max].min.to_i).each do |x|
115
+ right = through = 0
116
+ spanning.each do |a, b|
117
+ dy = b[1] - a[1]
118
+ cross = (2 * a[0] - 2 * x - 1) * dy + (cy - 2 * a[1]) * (b[0] - a[0])
119
+ if cross.zero?
120
+ through += 1
121
+ elsif cross.positive? == dy.positive?
122
+ right += 1
123
+ end
124
+ end
125
+ yield x, y if right.odd? || (right + through).odd?
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GlanceWidgets
4
+ TIMESTAMP_HEADER = 'X-GlanceWidgets-Timestamp'
5
+ SIGNATURE_HEADER = 'X-GlanceWidgets-Signature'
6
+ DEFAULT_SIGNATURE_TOLERANCE_MS = 300_000
7
+
8
+ def sign(timestamp, secret)
9
+ timestamp = Numbers.text(timestamp) if timestamp.is_a?(Numeric)
10
+ Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, "#{timestamp}\n#{secret}"))
11
+ end
12
+
13
+ def verify_signature(request, secret, options = {})
14
+ timestamp, signature = request['timestamp'], request['signature']
15
+ return false unless timestamp.is_a?(String) && signature.is_a?(String) && /\A[0-9]+\z/.match(timestamp)
16
+ now = DataValues.fetch_or(options, 'now', Time.now.to_f * 1000)
17
+ return false if (now - timestamp.to_f).abs > DataValues.fetch_or(options, 'toleranceMs', DEFAULT_SIGNATURE_TOLERANCE_MS)
18
+ expected = sign(timestamp, secret)
19
+ return false unless signature.bytesize == expected.bytesize
20
+ if OpenSSL.respond_to?(:fixed_length_secure_compare)
21
+ OpenSSL.fixed_length_secure_compare(signature, expected)
22
+ else
23
+ # Ruby 2.6's bundled OpenSSL lacks secure_compare. Inspect every byte;
24
+ # the only early exit above depends on the public signature length.
25
+ difference = 0
26
+ expected.bytes.each_with_index { |byte, index| difference |= byte ^ signature.getbyte(index) }
27
+ difference.zero?
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GlanceWidgets
4
+ # Numeric formatting follows ECMAScript because numbers also appear in labels
5
+ # and signed messages, where an equivalent JSON number is insufficient.
6
+ module Numbers
7
+ module_function
8
+
9
+ def finite?(value)
10
+ value.is_a?(Numeric) && value.real? && value.finite?
11
+ end
12
+
13
+ def integer?(value)
14
+ finite?(value) && value == value.to_i
15
+ end
16
+
17
+ def require_finite(value, label)
18
+ raise ArgumentError, "#{label} must be finite" unless finite?(value)
19
+ value
20
+ end
21
+
22
+ # JS Math.round/floor/ceil keep non-finite values; Ruby would raise FloatDomainError.
23
+ def round(value)
24
+ value.is_a?(Float) && !value.finite? ? value : (value + 0.5).floor
25
+ end
26
+
27
+ def floor(value)
28
+ value.is_a?(Float) && !value.finite? ? value : value.floor
29
+ end
30
+
31
+ def ceil(value)
32
+ value.is_a?(Float) && !value.finite? ? value : value.ceil
33
+ end
34
+
35
+ def clamp(value, low, high)
36
+ [[value, low].max, high].min
37
+ end
38
+
39
+ def text(number)
40
+ value = number.to_f
41
+ return '0' if value.zero?
42
+ return 'NaN' if value.nan?
43
+ return value.negative? ? '-Infinity' : 'Infinity' unless value.finite?
44
+ mantissa, exponent = value.to_s.downcase.split('e')
45
+ mantissa = mantissa.sub(/\.0\z/, '')
46
+ return mantissa unless exponent
47
+ exponent = exponent.to_i
48
+ if value.abs >= 1e21 || value.abs < 1e-6
49
+ return "#{mantissa}e#{exponent >= 0 ? '+' : '-'}#{exponent.abs}"
50
+ end
51
+ sign = mantissa.start_with?('-') ? '-' : ''
52
+ digits = mantissa.delete('-')
53
+ point = (digits.index('.') || digits.length) + exponent
54
+ digits = digits.delete('.')
55
+ expanded = if point <= 0
56
+ '0.' + '0' * -point + digits
57
+ elsif point >= digits.length
58
+ digits + '0' * (point - digits.length)
59
+ else
60
+ digits[0...point] + '.' + digits[point..-1]
61
+ end
62
+ sign + expanded
63
+ end
64
+
65
+ def fixed(number, decimals)
66
+ # JS toFixed: ToIntegerOrInfinity (NaN -> 0, truncation), then a 0..100 range check.
67
+ raise ArgumentError, 'decimals must be a number from 0 to 100' unless decimals.is_a?(Numeric) && decimals.real?
68
+ decimals = decimals.to_f
69
+ decimals = 0.0 if decimals.nan?
70
+ raise ArgumentError, 'decimals must be a number from 0 to 100' unless decimals.finite? && decimals.truncate.between?(0, 100)
71
+ decimals = decimals.truncate
72
+ value = number.to_f
73
+ return text(value) unless value.finite? && value.abs < 1e21
74
+ ratio = value.abs.to_r
75
+ quotient, remainder = (ratio.numerator * 10**decimals).divmod(ratio.denominator)
76
+ quotient += 1 if remainder * 2 >= ratio.denominator
77
+ digits = quotient.to_s.rjust(decimals + 1, '0')
78
+ result = decimals.zero? ? digits : digits[0...-decimals] + '.' + digits[-decimals..-1]
79
+ (value.negative? ? '-' : '') + result
80
+ end
81
+
82
+ def format_number(value, decimals)
83
+ result = fixed(value, decimals)
84
+ result.sub(/\A-0(?=\z|\.0*\z)/, '0')
85
+ end
86
+ end
87
+
88
+ module DataValues
89
+ module_function
90
+
91
+ def copy(value)
92
+ case value
93
+ when Hash then value.each_with_object({}) { |(key, item), result| result[key] = copy(item) }
94
+ when Array then value.map { |item| copy(item) }
95
+ when String then value.dup
96
+ else value
97
+ end
98
+ end
99
+
100
+ # Hash#fetch that treats nil like a missing key (JS `options.key ?? default`).
101
+ def fetch_or(hash, key, default)
102
+ value = hash[key]
103
+ value.nil? ? default : value
104
+ end
105
+
106
+ # True when the option is present and not nil (JS `options.key !== undefined` with null as unset).
107
+ def set?(hash, key)
108
+ !hash[key].nil?
109
+ end
110
+
111
+ # Deep copy with symbol keys turned into strings, so symbol-keyed options behave like JSON ones.
112
+ def stringify_keys(value)
113
+ case value
114
+ when Hash then value.each_with_object({}) { |(key, item), result| result[key.is_a?(Symbol) ? key.to_s : key] = stringify_keys(item) }
115
+ when Array then value.map { |item| stringify_keys(item) }
116
+ else value
117
+ end
118
+ end
119
+
120
+ # Numbers in canonical form (1.0 -> 1, -0.0 -> 0) so numerically equal values group together.
121
+ def canonical_numbers(value)
122
+ case value
123
+ when Hash then value.each_with_object({}) { |(key, item), result| result[key] = canonical_numbers(item) }
124
+ when Array then value.map { |item| canonical_numbers(item) }
125
+ when Float then value.finite? && value == value.floor ? value.to_i : value
126
+ else value
127
+ end
128
+ end
129
+
130
+ def load(name)
131
+ JSON.parse(File.read(File.join(__dir__, 'data', name)))
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GlanceWidgets
4
+ OFF_PIXEL_OPACITY = 0.3
5
+ ROOT_RING_OPACITY_THRESHOLD = 0.4
6
+
7
+ def build_theme(name, colors, corner, gap, rotation = 0, halo = 0)
8
+ palette = %w[on off dim accent].zip(colors).to_h
9
+ shape = { 'cornerRadius' => corner, 'rotation' => rotation }
10
+ { 'name' => name, 'palette' => palette,
11
+ 'default' => shape.merge('color' => palette['off'], 'opacity' => 0.3, 'halo' => 0),
12
+ 'base' => shape.merge('color' => palette['on'], 'opacity' => 1, 'halo' => halo),
13
+ 'layout' => { 'gapMode' => gap, 'edgeInsetRatio' => 0, 'cellAspectRatio' => 1 } }
14
+ end
15
+ private :build_theme
16
+
17
+ THEMES = {
18
+ 'led' => build_theme('led', %w[#FF4D2E #3A1F1B #A33A26 #FFD23F], 0.5, 'far', 0, 0.6),
19
+ 'lcd' => build_theme('lcd', %w[#9BFF6B #27402B #5E9B4A #E6FF8A], 0, 'near'),
20
+ 'dot' => build_theme('dot', %w[#F2F2F7 #48484A #8E8E93 #0A84FF], 0.5, 'extraFar'),
21
+ 'diamond' => build_theme('diamond', %w[#5AC8FA #1C3A4A #3D7C99 #FF9F0A], 0.1, 'extraFar', 45),
22
+ 'soft' => build_theme('soft', %w[#FFE2B8 #4A3F38 #B59A82 #FF7A59], 0.3, 'default', 0, 0.25)
23
+ }.freeze
24
+
25
+ def get_theme(name)
26
+ DataValues.copy(THEMES.fetch(name) { raise ArgumentError, "Unknown theme: #{name}" })
27
+ end
28
+
29
+ def theme_options(theme, overrides = {})
30
+ DataValues.copy('default' => overrides.fetch('default', theme['default']),
31
+ 'base' => theme['base'].merge(DataValues.fetch_or(overrides, 'base', {})),
32
+ 'layout' => theme['layout'].merge(DataValues.fetch_or(overrides, 'layout', {})))
33
+ end
34
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'openssl'
5
+ require 'base64'
6
+ require 'date'
7
+
8
+ # Builders return ordinary string-keyed Hashes suitable for JSON.generate.
9
+ module GlanceWidgets
10
+ extend self
11
+ end
12
+
13
+ require_relative 'glance_widgets/support'
14
+ require_relative 'glance_widgets/color'
15
+ require_relative 'glance_widgets/protocol'
16
+ require_relative 'glance_widgets/geometry'
17
+ require_relative 'glance_widgets/box'
18
+ require_relative 'glance_widgets/output'
19
+ require_relative 'glance_widgets/raster'
20
+ require_relative 'glance_widgets/matrix'
21
+ require_relative 'glance_widgets/font'
22
+ require_relative 'glance_widgets/themes'
23
+ require_relative 'glance_widgets/pixelate'
24
+ require_relative 'glance_widgets/layers'
25
+ require_relative 'glance_widgets/signature'
26
+ require_relative 'glance_widgets/components/shared'
27
+ require_relative 'glance_widgets/components/pixel_progress'
28
+ require_relative 'glance_widgets/components/modern'
29
+ require_relative 'glance_widgets/components/progress'
30
+ require_relative 'glance_widgets/components/ring_progress'
31
+ require_relative 'glance_widgets/components/battery'
32
+ require_relative 'glance_widgets/components/signal'
33
+ require_relative 'glance_widgets/components/status'
34
+ require_relative 'glance_widgets/components/heatmap'
35
+ require_relative 'glance_widgets/components/charts'
36
+ require_relative 'glance_widgets/components/stat'
37
+ require_relative 'glance_widgets/components/pixel_text'
38
+ require_relative 'glance_widgets/options'