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,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GlanceWidgets
|
|
4
|
+
GAP_MODE_RATIOS = { 'zero' => 0, 'near' => 0.08, 'default' => 0.16, 'far' => 0.28, 'extraFar' => 0.42 }.freeze
|
|
5
|
+
FRAME_DECIMALS = 6
|
|
6
|
+
|
|
7
|
+
def require_canvas(canvas, operation = 'operation')
|
|
8
|
+
unless canvas.is_a?(Hash) && canvas['width'].is_a?(Numeric) && canvas['height'].is_a?(Numeric) &&
|
|
9
|
+
canvas['width'] > 0 && canvas['height'] > 0
|
|
10
|
+
raise ArgumentError, "#{operation} needs a positive canvas size in points"
|
|
11
|
+
end
|
|
12
|
+
canvas
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def resolve_frame(frame, canvas)
|
|
16
|
+
require_canvas(canvas, 'resolve_frame')
|
|
17
|
+
axis = lambda do |start, finish, size, extent, fallback|
|
|
18
|
+
a = start && start * extent
|
|
19
|
+
b = finish && finish * extent
|
|
20
|
+
length = if a && b
|
|
21
|
+
[0, extent - a - b].max
|
|
22
|
+
else
|
|
23
|
+
Numbers.clamp(size || fallback, 0.02, 1.5) * extent
|
|
24
|
+
end
|
|
25
|
+
[a || (b ? extent - b - length : 0), length]
|
|
26
|
+
end
|
|
27
|
+
x, width = axis.call(frame['left'], frame['right'], frame['width'], canvas['width'], 0.4)
|
|
28
|
+
y, height = axis.call(frame['top'], frame['bottom'], frame['height'], canvas['height'], 0.16)
|
|
29
|
+
{ 'x' => x, 'y' => y, 'width' => width, 'height' => height }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def fit_matrix(region, grid, layout)
|
|
33
|
+
columns, rows = grid.fetch('columns'), grid.fetch('rows')
|
|
34
|
+
aspect = Numbers.clamp(DataValues.fetch_or(layout, 'cellAspectRatio', 1), 0.1, 10)
|
|
35
|
+
scale = [aspect, 1].min
|
|
36
|
+
gap = GAP_MODE_RATIOS.fetch(DataValues.fetch_or(layout, 'gapMode', 'default'), 0.16)
|
|
37
|
+
gx = Numbers.clamp(DataValues.fetch_or(layout, 'columnGapRatio', gap), 0, 10) * scale
|
|
38
|
+
gy = Numbers.clamp(DataValues.fetch_or(layout, 'rowGapRatio', gap), 0, 10) * scale
|
|
39
|
+
inset = Numbers.clamp(DataValues.fetch_or(layout, 'edgeInsetRatio', 0), -0.45, 0.45) * scale
|
|
40
|
+
cell_height = [region['width'].fdiv(columns * aspect + (columns - 1) * gx + 2 * inset),
|
|
41
|
+
region['height'].fdiv(rows + (rows - 1) * gy + 2 * inset)].min
|
|
42
|
+
cell_width = cell_height * aspect
|
|
43
|
+
gap_x, gap_y = gx * cell_height, gy * cell_height
|
|
44
|
+
{ 'columns' => columns, 'rows' => rows, 'cellWidth' => cell_width, 'cellHeight' => cell_height,
|
|
45
|
+
'gapX' => gap_x, 'gapY' => gap_y,
|
|
46
|
+
'originX' => region['x'] + (region['width'] - columns * cell_width - (columns - 1) * gap_x) / 2.0,
|
|
47
|
+
'originY' => region['y'] + (region['height'] - rows * cell_height - (rows - 1) * gap_y) / 2.0 }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def cell_rect(geometry, x, y)
|
|
51
|
+
{ 'x' => geometry['originX'] + x * (geometry['cellWidth'] + geometry['gapX']),
|
|
52
|
+
'y' => geometry['originY'] + y * (geometry['cellHeight'] + geometry['gapY']),
|
|
53
|
+
'width' => geometry['cellWidth'], 'height' => geometry['cellHeight'] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def column_boundary(geometry, index)
|
|
57
|
+
geometry['originX'] + index * (geometry['cellWidth'] + geometry['gapX']) - geometry['gapX'] / 2.0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def row_boundary(geometry, index)
|
|
61
|
+
geometry['originY'] + index * (geometry['cellHeight'] + geometry['gapY']) - geometry['gapY'] / 2.0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def to_ratio_rect(rect, canvas)
|
|
65
|
+
require_canvas(canvas)
|
|
66
|
+
{ 'x' => rect['x'].fdiv(canvas['width']), 'y' => rect['y'].fdiv(canvas['height']),
|
|
67
|
+
'width' => rect['width'].fdiv(canvas['width']), 'height' => rect['height'].fdiv(canvas['height']) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def solve_grid(options)
|
|
71
|
+
region = options.fetch('region')
|
|
72
|
+
raise ArgumentError, 'solve_grid needs a positive region' unless region['width'] > 0 && region['height'] > 0
|
|
73
|
+
mode = DataValues.fetch_or(options, 'gapMode', 'default')
|
|
74
|
+
unless mode.is_a?(String) && GAP_MODE_RATIOS.key?(mode)
|
|
75
|
+
raise ArgumentError, "solve_grid: unknown gapMode #{mode.inspect}; expected one of #{GAP_MODE_RATIOS.keys.join(', ')}"
|
|
76
|
+
end
|
|
77
|
+
gap = DataValues.fetch_or(options, 'gap', GAP_MODE_RATIOS.fetch(mode))
|
|
78
|
+
inset = DataValues.fetch_or(options, 'edgeInsetRatio', 0)
|
|
79
|
+
unless Numbers.finite?(gap) && gap.between?(0, 10) && Numbers.finite?(inset) && inset.abs <= 0.45
|
|
80
|
+
raise ArgumentError, 'Invalid grid gap/inset'
|
|
81
|
+
end
|
|
82
|
+
count = ->(extent, cell) { [1, Numbers.round((extent.fdiv(cell) + gap - 2 * inset) / (1 + gap))].max }
|
|
83
|
+
square = ->(extent, n) { extent.fdiv(n + (n - 1) * gap + 2 * inset) }
|
|
84
|
+
rows, columns = options['rows'], options['columns']
|
|
85
|
+
if !rows && !columns
|
|
86
|
+
raise ArgumentError, 'Give rows, columns or positive cellSize' unless DataValues.fetch_or(options, 'cellSize', 0) > 0
|
|
87
|
+
rows = count.call(region['height'], options['cellSize'])
|
|
88
|
+
end
|
|
89
|
+
rows ||= count.call(region['height'], square.call(region['width'], columns))
|
|
90
|
+
columns ||= count.call(region['width'], square.call(region['height'], rows))
|
|
91
|
+
unless [rows, columns].all? { |n| Numbers.integer?(n) && n >= 1 } && rows * columns <= MAX_MATRIX_CELLS
|
|
92
|
+
raise ArgumentError, 'Invalid grid dimensions'
|
|
93
|
+
end
|
|
94
|
+
ratio = region['width'].fdiv(region['height'])
|
|
95
|
+
extra_x, extra_y = (columns - 1) * gap + 2 * inset, (rows - 1) * gap + 2 * inset
|
|
96
|
+
aspect = (ratio * (rows + extra_y) - extra_x).fdiv(columns)
|
|
97
|
+
if aspect < 1
|
|
98
|
+
denominator = columns + extra_x - ratio * extra_y
|
|
99
|
+
aspect = denominator > 0 ? ratio * rows / denominator : nil
|
|
100
|
+
aspect = nil if aspect && !(aspect > 0 && aspect <= 1)
|
|
101
|
+
end
|
|
102
|
+
aspect = Numbers.clamp(aspect || 0.1, 0.1, 10) if options['clampAspect'] == true
|
|
103
|
+
raise ArgumentError, 'Grid exceeds cell aspect limits' unless aspect && aspect.between?(0.1, 10)
|
|
104
|
+
layout = { 'gapMode' => mode, 'edgeInsetRatio' => inset, 'cellAspectRatio' => aspect }
|
|
105
|
+
layout.merge!('columnGapRatio' => gap, 'rowGapRatio' => gap) if DataValues.set?(options, 'gap')
|
|
106
|
+
{ 'columns' => columns.to_i, 'rows' => rows.to_i, 'layout' => layout }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GlanceWidgets
|
|
4
|
+
DEFAULT_ICON_NAME = 'ph_activity'
|
|
5
|
+
ICON_CATALOG = DataValues.load('icons.json').fetch('icons')
|
|
6
|
+
TEXT_DEFAULTS = {
|
|
7
|
+
'color' => '#FFFFFF', 'textOpacity' => 1, 'opacity' => 0, 'fontSizeRatio' => 0.12,
|
|
8
|
+
'fontWeight' => '400', 'fontFamily' => 'system', 'letterSpacing' => 0,
|
|
9
|
+
'horizontalAlignment' => 'left', 'verticalAlignment' => 'top', 'autoWrap' => false, 'paddingRatio' => 0
|
|
10
|
+
}.freeze
|
|
11
|
+
LINE_DEFAULTS = {
|
|
12
|
+
'color' => '#FFFFFF', 'opacity' => 1, 'strokeWidth' => 2, 'pattern' => 'solid', 'cap' => 'butt'
|
|
13
|
+
}.freeze
|
|
14
|
+
SHAPE_DEFAULTS = { 'color' => '#2A63D4', 'opacity' => 1, 'cornerRadiusRatio' => 0 }.freeze
|
|
15
|
+
ICON_DEFAULTS = { 'color' => '#FFFFFF', 'textOpacity' => 1 }.freeze
|
|
16
|
+
IMAGE_DEFAULTS = { 'opacity' => 1, 'cornerRadiusRatio' => 0 }.freeze
|
|
17
|
+
CURVE_DEFAULTS = {
|
|
18
|
+
'color' => '#6BA7FF', 'opacity' => 1, 'strokeWidth' => 2, 'lineCap' => 'round',
|
|
19
|
+
'lineJoin' => 'round', 'fillOpacity' => 0, 'plotPadding' => 0
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
def has_icon(name)
|
|
23
|
+
ICON_CATALOG.key?(name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def icon_names
|
|
27
|
+
ICON_CATALOG.keys
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def icon_glyph(name)
|
|
31
|
+
glyph = ICON_CATALOG.fetch(name) { raise ArgumentError, "Unknown icon: #{name}" }
|
|
32
|
+
DataValues.copy(glyph).merge('icon' => name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def normalize_style_colors(style)
|
|
36
|
+
style.each_with_object({}) do |(key, value), result|
|
|
37
|
+
result[key] = if (key == 'color' || key.end_with?('Color')) && value.is_a?(String)
|
|
38
|
+
normalize_color(value)
|
|
39
|
+
elsif key == 'gradientColors' && value.is_a?(Array)
|
|
40
|
+
value.map { |color| normalize_color(color) }
|
|
41
|
+
else
|
|
42
|
+
DataValues.copy(value)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def layer_base(kind, options)
|
|
48
|
+
frame = options.fetch('frame')
|
|
49
|
+
node = { 'frame' => frame.is_a?(Box) ? frame.to_frame : DataValues.copy(frame) }
|
|
50
|
+
%w[zIndex rotation transform].each { |key| node[key] = DataValues.copy(options[key]) if DataValues.set?(options, key) }
|
|
51
|
+
path = options['dataPath'] || layer_path(options['id'])
|
|
52
|
+
{ 'layers' => [{ 'id' => layer_id(options.fetch('id')), 'type' => kind, 'dataPath' => path }], 'data' => nest_at_path(path, node) }
|
|
53
|
+
end
|
|
54
|
+
private :layer_base
|
|
55
|
+
|
|
56
|
+
def finish_layer(base, fields)
|
|
57
|
+
path = base['layers'][0]['dataPath']
|
|
58
|
+
node = resolve_data_path(base['data'], path).merge(fields)
|
|
59
|
+
base['data'] = nest_at_path(path, node)
|
|
60
|
+
base
|
|
61
|
+
end
|
|
62
|
+
private :finish_layer
|
|
63
|
+
|
|
64
|
+
def text(options)
|
|
65
|
+
content = { 'text' => DataValues.fetch_or(options, 'text', '') }
|
|
66
|
+
finish_layer(layer_base('text', options), 'content' => content,
|
|
67
|
+
'style' => normalize_style_colors(TEXT_DEFAULTS.merge(DataValues.fetch_or(options, 'style', {}))))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def line(options)
|
|
71
|
+
finish_layer(layer_base('line', options),
|
|
72
|
+
'content' => { 'direction' => options.fetch('direction') },
|
|
73
|
+
'style' => normalize_style_colors(LINE_DEFAULTS.merge(DataValues.fetch_or(options, 'style', {})))
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def horizontal_line(options)
|
|
78
|
+
axis_line(options, false)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def vertical_line(options)
|
|
82
|
+
axis_line(options, true)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def axis_line(options, vertical)
|
|
86
|
+
length = options.fetch('to') - options.fetch('from')
|
|
87
|
+
raise ArgumentError, 'Lines need at least .02 of the canvas' if length < 0.02
|
|
88
|
+
frame = if vertical
|
|
89
|
+
Box.new(options['at'], options['from'], 0, length)
|
|
90
|
+
else
|
|
91
|
+
Box.new(options['from'], options['at'], length, 0)
|
|
92
|
+
end
|
|
93
|
+
line(options.merge('frame' => frame, 'direction' => vertical ? 'vertical' : 'horizontal'))
|
|
94
|
+
end
|
|
95
|
+
private :axis_line
|
|
96
|
+
|
|
97
|
+
def shape(options)
|
|
98
|
+
frame = options.fetch('frame')
|
|
99
|
+
if frame.is_a?(Box) && frame.instance_variable_get(:@canvas)
|
|
100
|
+
rect = frame.to_pt
|
|
101
|
+
raise ArgumentError, 'Shape smaller than App minimum 24pt' if rect['width'] < 24 || rect['height'] < 24
|
|
102
|
+
end
|
|
103
|
+
style = SHAPE_DEFAULTS.merge(DataValues.fetch_or(options, 'style', {}))
|
|
104
|
+
style['backgroundColor'] = style['color'] unless DataValues.set?(style, 'backgroundColor')
|
|
105
|
+
finish_layer(layer_base('shape', options), 'content' => {}, 'style' => normalize_style_colors(style))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def icon(options)
|
|
109
|
+
unless %w[name glyph url].count { |key| DataValues.set?(options, key) } == 1
|
|
110
|
+
raise ArgumentError, 'Icon needs exactly one source'
|
|
111
|
+
end
|
|
112
|
+
glyph = options['glyph'] || (DataValues.set?(options, 'name') ? icon_glyph(options['name']) : nil)
|
|
113
|
+
content = if glyph
|
|
114
|
+
triple = glyph.select { |key, _| %w[icon iconCodePoint iconFont].include?(key) }
|
|
115
|
+
triple
|
|
116
|
+
elsif DataValues.set?(options, 'url')
|
|
117
|
+
{ 'url' => options['url'] }
|
|
118
|
+
else
|
|
119
|
+
{}
|
|
120
|
+
end
|
|
121
|
+
finish_layer(layer_base('icon', options), 'content' => content,
|
|
122
|
+
'style' => normalize_style_colors(ICON_DEFAULTS.merge(DataValues.fetch_or(options, 'style', {}))))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def image(options)
|
|
126
|
+
raise ArgumentError, 'Image needs exactly one source' unless DataValues.set?(options, 'url')
|
|
127
|
+
content = DataValues.set?(options, 'url') ? { 'url' => options['url'] } : {}
|
|
128
|
+
style = normalize_style_colors(IMAGE_DEFAULTS.merge(DataValues.fetch_or(options, 'style', {})))
|
|
129
|
+
finish_layer(layer_base('image', options), 'content' => content, 'style' => style)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def curve(options)
|
|
133
|
+
data = DataValues.fetch_or(options, 'data', {})
|
|
134
|
+
function = data['mode'] == 'function' || (!DataValues.set?(data, 'mode') && DataValues.set?(data, 'expression'))
|
|
135
|
+
if function && (!DataValues.set?(data, 'domain') || !DataValues.set?(data, 'yDomain'))
|
|
136
|
+
raise ArgumentError, 'Function curve needs domain and yDomain'
|
|
137
|
+
end
|
|
138
|
+
if DataValues.fetch_or(data, 'points', []).length > 1024 || DataValues.fetch_or(data, 'markers', []).length > 32
|
|
139
|
+
raise ArgumentError, 'Curve exceeds point/marker limit'
|
|
140
|
+
end
|
|
141
|
+
DataValues.fetch_or(data, 'markers', []).each do |marker|
|
|
142
|
+
if marker.fetch('at', {}).fetch('offsetPt', [0, 0]).any? { |value| value.abs > 64 }
|
|
143
|
+
raise ArgumentError, 'Marker offset must stay within 64pt'
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
mode = DataValues.fetch_or(data, 'mode', DataValues.set?(data, 'expression') ? 'function' : 'samples')
|
|
147
|
+
content = DataValues.copy(data).merge('mode' => mode, 'interpolation' => DataValues.fetch_or(data, 'interpolation', 'monotone'))
|
|
148
|
+
style = CURVE_DEFAULTS.merge(DataValues.fetch_or(options, 'style', {}))
|
|
149
|
+
style['fillColor'] = style['color'] unless DataValues.set?(style, 'fillColor')
|
|
150
|
+
finish_layer(layer_base('curve', options), 'content' => content, 'style' => normalize_style_colors(style))
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GlanceWidgets
|
|
4
|
+
EMPTY = 'empty'
|
|
5
|
+
DEFAULT_PIXEL_BASE = {
|
|
6
|
+
'color' => '#FFFFFF', 'opacity' => 1, 'cornerRadius' => 0, 'rotation' => 0, 'halo' => 0
|
|
7
|
+
}.freeze
|
|
8
|
+
BATCH_MIN_OCCURRENCES = 2
|
|
9
|
+
|
|
10
|
+
class Matrix
|
|
11
|
+
attr_reader :columns, :rows
|
|
12
|
+
|
|
13
|
+
def initialize(columns, rows, options = {})
|
|
14
|
+
Raster.integers('Matrix size', [columns, rows])
|
|
15
|
+
unless columns >= 1 && rows >= 1 && columns * rows <= MAX_MATRIX_CELLS
|
|
16
|
+
raise ArgumentError, 'Matrix must have positive dimensions and at most 4096 cells'
|
|
17
|
+
end
|
|
18
|
+
@columns, @rows = columns.to_i, rows.to_i
|
|
19
|
+
@layout = { 'gapMode' => 'default', 'edgeInsetRatio' => 0, 'cellAspectRatio' => 1 }
|
|
20
|
+
known_layout = %w[gapMode edgeInsetRatio cellAspectRatio columnGapRatio rowGapRatio]
|
|
21
|
+
@layout.merge!((options['layout'] || {}).select { |key, value| known_layout.include?(key) && !value.nil? })
|
|
22
|
+
raise ArgumentError, 'Unknown gapMode' unless @layout['gapMode'].is_a?(String) && GAP_MODE_RATIOS.key?(@layout['gapMode'])
|
|
23
|
+
@layout.each { |key, value| Numbers.require_finite(value, 'Layout field') unless key == 'gapMode' }
|
|
24
|
+
@default = resolve_pixel(DataValues.fetch_or(options, 'default', EMPTY) || EMPTY, DEFAULT_PIXEL_BASE)
|
|
25
|
+
visible = @default == EMPTY ? DEFAULT_PIXEL_BASE : @default
|
|
26
|
+
@base = resolve_pixel(visible.merge(options['base'] || {}), DEFAULT_PIXEL_BASE)
|
|
27
|
+
@cells = Array.new(@columns * @rows)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def layout
|
|
31
|
+
DataValues.copy(@layout)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def base
|
|
35
|
+
DataValues.copy(@base)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def default_style
|
|
39
|
+
DataValues.copy(@default)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def contains(x, y)
|
|
43
|
+
Numbers.integer?(x) && Numbers.integer?(y) && x >= 0 && y >= 0 && x < columns && y < rows
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def get(x, y)
|
|
47
|
+
cell = @cells[index(x, y)]
|
|
48
|
+
DataValues.copy(cell.nil? ? @default : cell)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def is_set(x, y)
|
|
52
|
+
!@cells[index(x, y)].nil?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def set(x, y, style)
|
|
56
|
+
@cells[index(x, y)] = resolve(style)
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def fill(style)
|
|
61
|
+
cell = resolve(style)
|
|
62
|
+
@cells = Array.new(columns * rows) { DataValues.copy(cell) }
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def clear
|
|
67
|
+
fill(nil)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def blit(other, x, y)
|
|
71
|
+
other.rows.times do |row|
|
|
72
|
+
other.columns.times do |col|
|
|
73
|
+
cell = other.raw_cell(col, row)
|
|
74
|
+
plot(x + col, y + row, DataValues.copy(cell)) unless cell.nil? || cell == EMPTY
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def line(x0, y0, x1, y1, style)
|
|
81
|
+
Raster.integers('line coordinates', [x0, y0, x1, y1])
|
|
82
|
+
cell = resolve(style)
|
|
83
|
+
Raster.line(x0, y0, x1, y1) { |x, y| plot(x, y, cell) }
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def rect(x, y, width, height, style, options = {})
|
|
88
|
+
Raster.integers('rect arguments', [x, y, width, height])
|
|
89
|
+
cell = resolve(style)
|
|
90
|
+
([0, y].max.to_i...[rows, y + height].min.to_i).each do |row|
|
|
91
|
+
([0, x].max.to_i...[columns, x + width].min.to_i).each do |col|
|
|
92
|
+
edge = row == y || row == y + height - 1 || col == x || col == x + width - 1
|
|
93
|
+
plot(col, row, cell) if DataValues.fetch_or(options, 'fill', true) || edge
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def round_rect(x, y, width, height, radius, style, options = {})
|
|
100
|
+
cell = resolve(style)
|
|
101
|
+
Raster.round_rect(x, y, width, height, radius, DataValues.fetch_or(options, 'fill', true), self) do |col, row|
|
|
102
|
+
plot(col, row, cell)
|
|
103
|
+
end
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def ellipse(x, y, width, height, style, options = {})
|
|
108
|
+
cell = resolve(style)
|
|
109
|
+
Raster.ellipse(x, y, width, height, DataValues.fetch_or(options, 'fill', true), self) { |col, row| plot(col, row, cell) }
|
|
110
|
+
self
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def circle(cx, cy, radius, style, options = {})
|
|
114
|
+
Raster.integers('circle arguments', [cx, cy, radius])
|
|
115
|
+
raise ArgumentError, 'Circle radius must be non-negative' if radius < 0
|
|
116
|
+
ellipse(cx - radius, cy - radius, 2 * radius + 1, 2 * radius + 1, style, options)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def arc(cx, cy, radius, start_angle, end_angle, style, options = {})
|
|
120
|
+
cell = resolve(style)
|
|
121
|
+
Raster.arc(cx, cy, radius, start_angle, end_angle, DataValues.fetch_or(options, 'thickness', 1), self) do |x, y|
|
|
122
|
+
plot(x, y, cell)
|
|
123
|
+
end
|
|
124
|
+
self
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def polygon(points, style, options = {})
|
|
128
|
+
cell = resolve(style)
|
|
129
|
+
Raster.polygon(points, DataValues.fetch_or(options, 'fill', true), self) { |x, y| plot(x, y, cell) }
|
|
130
|
+
self
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def flood_fill(x, y, style, options = {})
|
|
134
|
+
Raster.integers('flood_fill seed', [x, y])
|
|
135
|
+
return self unless contains(x, y)
|
|
136
|
+
target, cell = get(x, y), resolve(style)
|
|
137
|
+
offsets = [[1, 0], [-1, 0], [0, 1], [0, -1]]
|
|
138
|
+
offsets += [[1, 1], [1, -1], [-1, 1], [-1, -1]] if options['diagonal'] == true
|
|
139
|
+
pending, visited = [[x, y]], { [x, y] => true }
|
|
140
|
+
until pending.empty?
|
|
141
|
+
col, row = pending.pop
|
|
142
|
+
plot(col, row, cell)
|
|
143
|
+
offsets.each do |dx, dy|
|
|
144
|
+
point = [col + dx, row + dy]
|
|
145
|
+
next unless contains(*point) && !visited[point] && get(*point) == target
|
|
146
|
+
visited[point] = true
|
|
147
|
+
pending << point
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
self
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def text(content, x, y, options = {})
|
|
154
|
+
Raster.integers('text position', [x, y])
|
|
155
|
+
cell = resolve(options.key?('style') ? options['style'] : @base)
|
|
156
|
+
GlanceWidgets.layout_text(content, options)['glyphs'].each do |placed|
|
|
157
|
+
placed['glyph']['rows'].each_with_index do |row, j|
|
|
158
|
+
row.each_char.with_index { |ch, i| plot(x + placed['x'] + i, y + placed['y'] + j, cell) if ch == '#' }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
self
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def clone
|
|
165
|
+
result = empty_like(columns, rows)
|
|
166
|
+
@cells.each_with_index { |cell, i| result.store(i % columns, i / columns, DataValues.copy(cell)) }
|
|
167
|
+
result
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def flip_x
|
|
171
|
+
transformed(columns, rows) { |x, y| [columns - 1 - x, y] }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def flip_y
|
|
175
|
+
transformed(columns, rows) { |x, y| [x, rows - 1 - y] }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def rotate90(turns = 1)
|
|
179
|
+
Raster.integers('rotate90 turns', [turns])
|
|
180
|
+
result = clone
|
|
181
|
+
(turns.to_i % 4).times do
|
|
182
|
+
previous = result
|
|
183
|
+
result = previous.transformed(previous.rows, previous.columns) { |x, y| [previous.rows - 1 - y, x] }
|
|
184
|
+
end
|
|
185
|
+
result
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def crop(x, y, width, height)
|
|
189
|
+
Raster.integers('crop arguments', [x, y, width, height])
|
|
190
|
+
result = empty_like(width, height)
|
|
191
|
+
height.to_i.times do |row|
|
|
192
|
+
width.to_i.times do |col|
|
|
193
|
+
result.store(col, row, DataValues.copy(raw_cell(x + col, y + row))) if contains(x + col, y + row)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
result
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def to_root
|
|
200
|
+
groups = {}
|
|
201
|
+
@cells.each_with_index do |cell, i|
|
|
202
|
+
next if cell.nil? || cell == @default
|
|
203
|
+
pixel = protocol_pixel(cell)
|
|
204
|
+
group = groups[DataValues.canonical_numbers(pixel)] ||= { 'pixel' => pixel, 'coordinates' => [] }
|
|
205
|
+
group['coordinates'] << [i / columns, i % columns]
|
|
206
|
+
end
|
|
207
|
+
pixels, batch = Array.new(rows) { Array.new(columns) }, []
|
|
208
|
+
groups.each_value do |group|
|
|
209
|
+
if group['coordinates'].length >= BATCH_MIN_OCCURRENCES
|
|
210
|
+
batch << group
|
|
211
|
+
else
|
|
212
|
+
group['coordinates'].each { |row, col| pixels[row][col] = group['pixel'] }
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
result = { 'pixels' => pixels, 'default' => protocol_pixel(@default), 'layout' => layout }
|
|
216
|
+
result['batch'] = batch unless batch.empty?
|
|
217
|
+
result
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def to_part
|
|
221
|
+
{ 'content' => to_root }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
protected
|
|
225
|
+
|
|
226
|
+
def raw_cell(x, y)
|
|
227
|
+
@cells[index(x, y)]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def store(x, y, cell)
|
|
231
|
+
@cells[index(x, y)] = cell
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def transformed(width, height)
|
|
235
|
+
result = empty_like(width, height)
|
|
236
|
+
rows.times do |y|
|
|
237
|
+
columns.times { |x| result.store(*yield(x, y), DataValues.copy(raw_cell(x, y))) }
|
|
238
|
+
end
|
|
239
|
+
result
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
private
|
|
243
|
+
|
|
244
|
+
def index(x, y)
|
|
245
|
+
raise ArgumentError, 'Cell outside matrix' unless contains(x, y)
|
|
246
|
+
y.to_i * columns + x.to_i
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def plot(x, y, cell)
|
|
250
|
+
@cells[y.to_i * columns + x.to_i] = cell if contains(x, y)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def resolve(style)
|
|
254
|
+
style.nil? ? nil : resolve_pixel(style, @base)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def resolve_pixel(value, base)
|
|
258
|
+
return EMPTY if value == EMPTY
|
|
259
|
+
value = { 'color' => value } if value.is_a?(String)
|
|
260
|
+
raise ArgumentError, 'Pixel must be a color or style object' unless value.is_a?(Hash)
|
|
261
|
+
merged = base.merge(value)
|
|
262
|
+
%w[opacity cornerRadius rotation halo].each { |key| Numbers.require_finite(merged[key], "Pixel #{key}") }
|
|
263
|
+
{ 'color' => GlanceWidgets.normalize_color(merged['color']),
|
|
264
|
+
'opacity' => Numbers.clamp(merged['opacity'], 0, 1),
|
|
265
|
+
'cornerRadius' => Numbers.clamp(merged['cornerRadius'], 0, 0.5),
|
|
266
|
+
'rotation' => merged['rotation'], 'halo' => Numbers.clamp(merged['halo'], 0, 1) }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def protocol_pixel(cell)
|
|
270
|
+
cell == EMPTY ? { 'empty' => true } : DataValues.copy(cell)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def empty_like(width, height)
|
|
274
|
+
Matrix.new(width, height, 'default' => @default, 'base' => @base, 'layout' => @layout)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GlanceWidgets
|
|
4
|
+
# Public entry points accept symbol-keyed option hashes (`merge(part, onConflict: 'error')`,
|
|
5
|
+
# `Matrix.new(3, 3, layout: {...})`); they are normalized to the string keys the SDK uses internally.
|
|
6
|
+
module OptionKeys
|
|
7
|
+
def self.wrapper(names)
|
|
8
|
+
Module.new do
|
|
9
|
+
names.each do |name|
|
|
10
|
+
define_method(name) do |*args, &block|
|
|
11
|
+
super(*args.map { |arg| DataValues.stringify_keys(arg) }, &block)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
FUNCTIONS_WITH_OPTIONS = %i[
|
|
19
|
+
merge place to_response layout_text measure_text text_matrix define_font text line horizontal_line
|
|
20
|
+
vertical_line shape icon image curve resolve_frame fit_matrix solve_grid to_ratio_rect require_canvas
|
|
21
|
+
pixelate theme_options verify_signature line_chart area_chart sparkline pixel_progress_bar pixel_ring_progress pixel_bar_chart
|
|
22
|
+
labeled_pixel_progress_bar progress_bar ring_progress labeled_pixel_ring_progress battery signal stat heatmap
|
|
23
|
+
calendar_heatmap labeled_calendar_heatmap status_strip labeled_status_strip bar_chart
|
|
24
|
+
pixel_text_panel pixel_text_root
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
prepend OptionKeys.wrapper(FUNCTIONS_WITH_OPTIONS)
|
|
28
|
+
Matrix.prepend OptionKeys.wrapper(%i[initialize set fill text rect round_rect ellipse circle arc polygon flood_fill])
|
|
29
|
+
Box.prepend OptionKeys.wrapper(%i[initialize inset inset_pt rows columns split])
|
|
30
|
+
Box.singleton_class.prepend OptionKeys.wrapper(%i[canvas from_frame from_pt])
|
|
31
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GlanceWidgets
|
|
4
|
+
def layer_id(value)
|
|
5
|
+
raise ArgumentError, 'Layer id must be a non-empty string' unless value.is_a?(String) && !value.empty?
|
|
6
|
+
value.start_with?(LAYER_ID_PREFIX) ? value : LAYER_ID_PREFIX + value
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def merge(*parts)
|
|
10
|
+
options = parts.last.is_a?(Hash) && parts.last.keys.map(&:to_s) == ['onConflict'] ? DataValues.stringify_keys(parts.pop) : {}
|
|
11
|
+
strict = options['onConflict'] == 'error'
|
|
12
|
+
result = { 'protocol' => 1 }
|
|
13
|
+
root_keys = %w[pixels default layout batch]
|
|
14
|
+
parts.each do |part|
|
|
15
|
+
unless part.is_a?(Hash) && !part.key?('content') && !part.key?('style')
|
|
16
|
+
raise ArgumentError, 'merge requires objects; place a MatrixPart before merging'
|
|
17
|
+
end
|
|
18
|
+
if part.key?('protocol') && (!Numbers.finite?(part['protocol']) || part['protocol'] != 1)
|
|
19
|
+
raise ArgumentError, 'Unsupported protocol'
|
|
20
|
+
end
|
|
21
|
+
if root_keys.any? { |key| part.key?(key) }
|
|
22
|
+
raise ArgumentError, 'Root matrix fields need pixels' unless part.key?('pixels')
|
|
23
|
+
raise ArgumentError, 'merge conflict: root matrix' if strict && result.key?('pixels')
|
|
24
|
+
root_keys.each do |key|
|
|
25
|
+
result.delete(key)
|
|
26
|
+
result[key] = DataValues.copy(part[key]) if part.key?(key)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
if part.key?('data')
|
|
30
|
+
raise ArgumentError, 'Data must be an object' unless part['data'].is_a?(Hash)
|
|
31
|
+
merge_data(result['data'] ||= {}, part['data'], 'data', strict)
|
|
32
|
+
end
|
|
33
|
+
next unless part.key?('layers')
|
|
34
|
+
raise ArgumentError, 'Layers must be an array' unless part['layers'].is_a?(Array)
|
|
35
|
+
layers = result['layers'] ||= []
|
|
36
|
+
part['layers'].each do |layer|
|
|
37
|
+
raise ArgumentError, 'Layer must be an object' unless layer.is_a?(Hash)
|
|
38
|
+
index = layers.index { |existing| existing['id'] == layer['id'] }
|
|
39
|
+
raise ArgumentError, 'merge conflict: duplicate layer id' if index && strict
|
|
40
|
+
index ? layers[index] = DataValues.copy(layer) : layers << DataValues.copy(layer)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def merge_data(target, source, path, strict)
|
|
47
|
+
source.each do |key, value|
|
|
48
|
+
child = "#{path}.#{key}"
|
|
49
|
+
if target[key].is_a?(Hash) && value.is_a?(Hash)
|
|
50
|
+
merge_data(target[key], value, child, strict)
|
|
51
|
+
elsif strict && target.key?(key)
|
|
52
|
+
raise ArgumentError, "merge conflict at #{child}"
|
|
53
|
+
else
|
|
54
|
+
target[key] = DataValues.copy(value)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
private :merge_data
|
|
59
|
+
|
|
60
|
+
def nest_at_path(path, value)
|
|
61
|
+
unless path.is_a?(String) && /\Adata(?:\.[A-Za-z_][A-Za-z0-9_]*)+\z/.match(path)
|
|
62
|
+
raise ArgumentError, 'Expected a path of the form data.name(.name)*'
|
|
63
|
+
end
|
|
64
|
+
path.split('.')[1..-1].reverse_each { |key| value = { key => value } }
|
|
65
|
+
value
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def layer_path(id)
|
|
69
|
+
name = layer_id(id)[7..-1]
|
|
70
|
+
'data.layer_' + name.codepoints.map { |c| (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) ? c.chr : "_#{c.to_s(16)}_" }.join
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def place(part, options)
|
|
74
|
+
frame = options.fetch('frame')
|
|
75
|
+
node = { 'frame' => frame.is_a?(Box) ? frame.to_frame : DataValues.copy(frame), 'content' => DataValues.copy(part.fetch('content')), 'style' => {} }
|
|
76
|
+
%w[zIndex rotation transform].each { |key| node[key] = DataValues.copy(options[key]) if DataValues.set?(options, key) }
|
|
77
|
+
{ 'layers' => [{ 'id' => layer_id(options.fetch('id')), 'type' => 'matrix', 'dataPath' => options.fetch('path') }], 'data' => nest_at_path(options.fetch('path'), node) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def empty_root
|
|
81
|
+
{ 'pixels' => [[{ 'empty' => true }]], 'default' => { 'empty' => true },
|
|
82
|
+
'layout' => { 'gapMode' => 'default', 'edgeInsetRatio' => 0, 'cellAspectRatio' => 1 } }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def to_response(part)
|
|
86
|
+
result = merge(part)
|
|
87
|
+
result = merge(empty_root, result) unless result.key?('pixels')
|
|
88
|
+
validate(result)
|
|
89
|
+
result
|
|
90
|
+
end
|
|
91
|
+
end
|