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.
- checksums.yaml +7 -0
- data/LICENSE +17 -0
- data/README.md +47 -0
- data/lib/glance_widgets/box.rb +117 -0
- data/lib/glance_widgets/color.rb +48 -0
- data/lib/glance_widgets/components/bar_chart.rb +65 -0
- data/lib/glance_widgets/components/battery.rb +77 -0
- data/lib/glance_widgets/components/charts.rb +5 -0
- data/lib/glance_widgets/components/heatmap.rb +184 -0
- data/lib/glance_widgets/components/line_chart.rb +213 -0
- data/lib/glance_widgets/components/modern.rb +59 -0
- data/lib/glance_widgets/components/pixel_bar_chart.rb +106 -0
- data/lib/glance_widgets/components/pixel_progress.rb +151 -0
- data/lib/glance_widgets/components/pixel_text.rb +66 -0
- data/lib/glance_widgets/components/progress.rb +62 -0
- data/lib/glance_widgets/components/ring_progress.rb +89 -0
- data/lib/glance_widgets/components/shared.rb +131 -0
- data/lib/glance_widgets/components/signal.rb +41 -0
- data/lib/glance_widgets/components/stat.rb +171 -0
- data/lib/glance_widgets/components/status.rb +102 -0
- data/lib/glance_widgets/data/fonts/font-3x5.json +120 -0
- data/lib/glance_widgets/data/fonts/font-5x7.json +118 -0
- data/lib/glance_widgets/data/icons.json +5293 -0
- data/lib/glance_widgets/data/schemas.json +418 -0
- data/lib/glance_widgets/data/text-advances.json +120 -0
- data/lib/glance_widgets/font.rb +165 -0
- data/lib/glance_widgets/geometry.rb +108 -0
- data/lib/glance_widgets/layers.rb +152 -0
- data/lib/glance_widgets/matrix.rb +277 -0
- data/lib/glance_widgets/options.rb +31 -0
- data/lib/glance_widgets/output.rb +91 -0
- data/lib/glance_widgets/pixelate.rb +46 -0
- data/lib/glance_widgets/protocol.rb +217 -0
- data/lib/glance_widgets/raster.rb +130 -0
- data/lib/glance_widgets/signature.rb +30 -0
- data/lib/glance_widgets/support.rb +134 -0
- data/lib/glance_widgets/themes.rb +34 -0
- data/lib/glance_widgets.rb +38 -0
- 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'
|