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,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