clogs 0.1.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.
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../drawable"
4
+
5
+ module Clogs
6
+ # Bitmaps.
7
+ #
8
+ # This is the one place where libui really lets Shoes down. libui-ng has
9
+ # `uiImage`, but the only thing that can display one is a table cell -- the
10
+ # released library exports no `uiDrawImage`, so there is no way to blit a
11
+ # bitmap into an area. (Clogs checks for it at runtime and uses it if a
12
+ # future build provides one.)
13
+ #
14
+ # The fallback is the same trick glimmer-dsl-libui uses: turn the image into
15
+ # filled rectangles. Clogs run-length encodes each row first, so flat art --
16
+ # icons, logos, screenshots of text -- costs a handful of rectangles per row
17
+ # instead of one per pixel. Photographs are genuinely slow and are best
18
+ # avoided until libui grows a draw-image call.
19
+ class Image < Drawable
20
+ class << self
21
+ def cache
22
+ @cache ||= {}
23
+ end
24
+
25
+ # Load an image and reduce it to per-row colour runs at the requested
26
+ # size. Cached by [path, width, height].
27
+ def runs_for(url, width, height)
28
+ key = [url, width, height]
29
+ cache[key] ||= build_runs(url, width, height)
30
+ end
31
+
32
+ # Every rectangle is a separate libui fill, so a photograph can cost tens
33
+ # of thousands of draw calls per frame and starve the event loop. Flat
34
+ # art stays well under this; anything that does not gets sampled at a
35
+ # coarser resolution until it fits, trading sharpness for a UI that still
36
+ # responds. None of this is needed once libui exports uiDrawImage.
37
+ RUN_BUDGET = 40_000
38
+
39
+ def build_runs(url, width, height)
40
+ pixels, src_w, src_h = load_pixels(url)
41
+ return nil unless pixels
42
+
43
+ width = src_w if width.nil? || width <= 0
44
+ height = src_h if height.nil? || height <= 0
45
+
46
+ runs = encode(pixels, src_w, src_h, width, height, 1)
47
+ step = 1
48
+ while runs.size > RUN_BUDGET && step < 8
49
+ step *= 2
50
+ runs = encode(pixels, src_w, src_h, width, height, step)
51
+ end
52
+ [runs, width, height]
53
+ end
54
+
55
+ # Walk the target rectangle at `step` pixels at a time, emitting one
56
+ # rectangle per run of equal colour.
57
+ def encode(pixels, src_w, src_h, width, height, step)
58
+ runs = []
59
+ 0.step(height - 1, step) do |y|
60
+ sy = src_h == height ? y : (y * src_h / height)
61
+ row_start = sy * src_w
62
+ run_color = nil
63
+ run_x = 0
64
+ 0.step(width - 1, step) do |x|
65
+ sx = src_w == width ? x : (x * src_w / width)
66
+ color = pixels[row_start + sx]
67
+ next if color == run_color
68
+
69
+ runs << [run_x, y, x - run_x, run_color] if run_color && run_color[3].positive?
70
+ run_color = color
71
+ run_x = x
72
+ end
73
+ runs << [run_x, y, width - run_x, run_color] if run_color && run_color[3].positive?
74
+ end
75
+ compact(runs, step)
76
+ end
77
+
78
+ # Rows that repeat identically become one taller rectangle. Flat art
79
+ # collapses dramatically here.
80
+ def compact(runs, step = 1)
81
+ by_key = {}
82
+ result = []
83
+ runs.each do |x, y, w, color|
84
+ key = [x, w, color]
85
+ prev = by_key[key]
86
+ if prev && prev[1] + prev[3] == y
87
+ prev[3] += step
88
+ else
89
+ rect = [x, y, w, step, color]
90
+ result << rect
91
+ by_key[key] = rect
92
+ end
93
+ end
94
+ result
95
+ end
96
+
97
+ def load_pixels(url)
98
+ path = local_path(url)
99
+ return nil unless path && File.exist?(path)
100
+
101
+ png = png_canvas(path)
102
+ return nil unless png
103
+
104
+ pixels = Array.new(png.width * png.height)
105
+ png.height.times do |y|
106
+ png.width.times do |x|
107
+ v = png[x, y]
108
+ pixels[y * png.width + x] = [(v >> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff]
109
+ end
110
+ end
111
+ [pixels, png.width, png.height]
112
+ rescue StandardError => e
113
+ warn "Clogs: could not load image #{url.inspect}: #{e.message}"
114
+ nil
115
+ end
116
+
117
+ def png_canvas(path)
118
+ require "chunky_png"
119
+ return ChunkyPNG::Canvas.from_file(path) if File.extname(path).downcase == ".png"
120
+
121
+ converted = convert_to_png(path)
122
+ converted ? ChunkyPNG::Canvas.from_file(converted) : nil
123
+ end
124
+
125
+ # chunky_png only reads PNG. Anything else goes through ImageMagick if it
126
+ # happens to be installed.
127
+ def convert_to_png(path)
128
+ require "tmpdir"
129
+ out = File.join(Dir.tmpdir, "clogs-#{File.basename(path)}.png")
130
+ return out if File.exist?(out)
131
+ return nil unless system("which convert > /dev/null 2>&1")
132
+
133
+ system("convert", path, out) ? out : nil
134
+ end
135
+
136
+ def local_path(url)
137
+ return nil if url.nil?
138
+
139
+ url = url.to_s
140
+ return url unless url.start_with?("http://", "https://")
141
+
142
+ download(url)
143
+ end
144
+
145
+ def download(url)
146
+ require "tmpdir"
147
+ require "digest"
148
+ require "open-uri"
149
+ dest = File.join(Dir.tmpdir, "clogs-img-#{Digest::SHA256.hexdigest(url)[0, 16]}")
150
+ return dest if File.exist?(dest)
151
+
152
+ URI.parse(url).open { |io| File.binwrite(dest, io.read) }
153
+ dest
154
+ rescue StandardError => e
155
+ warn "Clogs: could not fetch #{url}: #{e.message}"
156
+ nil
157
+ end
158
+ end
159
+
160
+ def positioned?
161
+ !style(:left).nil? && !style(:top).nil?
162
+ end
163
+
164
+ def measure(available_width)
165
+ req_w = requested_width(available_width)
166
+ req_h = requested_height(available_width)
167
+ loaded = self.class.runs_for(style(:url), req_w, req_h)
168
+ if loaded
169
+ @runs, @width, @height = loaded
170
+ else
171
+ @runs = nil
172
+ @width = req_w || 0
173
+ @height = req_h || 0
174
+ end
175
+ end
176
+
177
+ def draw(painter, x, y)
178
+ unless @runs
179
+ # Nothing loaded: a light placeholder box beats an invisible hole.
180
+ painter.stroke_rect(x + 0.5, y + 0.5, [@width - 1, 1].max, [@height - 1, 1].max,
181
+ [200, 200, 200, 255], thickness: 1)
182
+ return
183
+ end
184
+
185
+ @runs.each do |rx, ry, rw, rh, color|
186
+ painter.fill_rect(x + rx, y + ry, rw, rh, color)
187
+ end
188
+ end
189
+
190
+ def clickable?
191
+ !style(:click).nil?
192
+ end
193
+
194
+ def on_release(x, y, _button)
195
+ notify("click") if contains?(x, y) && style(:click)
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../drawable"
4
+ require_relative "slot"
5
+
6
+ module Clogs
7
+ # `animate`, `every`, `timer`, `motion`, `click`, `hover`, `keypress`: Shoes
8
+ # calls that subscribe to something rather than drawing anything.
9
+ class SubscriptionItem < Drawable
10
+ def initialize(properties)
11
+ super
12
+ @frame = 0
13
+ start_timer
14
+ end
15
+
16
+ def api_name
17
+ style(:shoes_api_name).to_s
18
+ end
19
+
20
+ def args
21
+ Array(style(:args))
22
+ end
23
+
24
+ def measure(_available_width)
25
+ @width = 0
26
+ @height = 0
27
+ end
28
+
29
+ def clickable?
30
+ false
31
+ end
32
+
33
+ def start_timer
34
+ case api_name
35
+ when "animate"
36
+ fps = (args[0] || 10).to_i
37
+ fps = 10 if fps <= 0
38
+ schedule(1000 / fps) { notify("animate", @frame += 1) }
39
+ when "every"
40
+ seconds = (args[0] || 1).to_f
41
+ schedule((seconds * 1000).round) { notify("every", @frame += 1) }
42
+ when "timer"
43
+ seconds = (args[0] || 1).to_f
44
+ schedule((seconds * 1000).round, repeat: false) { notify("timer") }
45
+ end
46
+ end
47
+
48
+ # The app may not exist yet when the subscription is created, so defer.
49
+ def schedule(interval, repeat: true, &block)
50
+ @pending_timer = [interval, repeat, block]
51
+ install_timer
52
+ end
53
+
54
+ def install_timer
55
+ return unless @pending_timer
56
+ return unless app
57
+
58
+ interval, repeat, block = @pending_timer
59
+ @pending_timer = nil
60
+ @stopped = false
61
+ app.add_timer(interval, repeat: repeat) do
62
+ block.call unless @stopped
63
+ end
64
+ end
65
+
66
+ def set_parent(new_parent)
67
+ super
68
+ @app = nil
69
+ install_timer
70
+ end
71
+
72
+ def destroy_self
73
+ @stopped = true
74
+ super
75
+ end
76
+ end
77
+
78
+ # A user-defined Shoes::Widget subclass. It behaves as a slot.
79
+ class Widget < Slot; end
80
+
81
+ # Shoes' `mask` composites its contents as an alpha mask over the slot
82
+ # beneath it. libui has no group/alpha-compositing support, so the contents
83
+ # are drawn normally; see the coverage matrix.
84
+ class Mask < Slot; end
85
+
86
+ class Video < Drawable
87
+ def measure(available_width)
88
+ @width = requested_width(available_width) || 320
89
+ @height = requested_height(available_width) || 240
90
+ end
91
+
92
+ # libui has no media support at all, so this is an honest placeholder
93
+ # rather than a silent no-op.
94
+ def draw(painter, x, y)
95
+ painter.fill_rect(x, y, @width, @height, [20, 20, 20, 255])
96
+ painter.draw(fill: [200, 200, 200, 255]) do |p|
97
+ cx = x + @width / 2.0
98
+ cy = y + @height / 2.0
99
+ p.move_to(cx - 12, cy - 16).line_to(cx + 16, cy).line_to(cx - 12, cy + 16).close
100
+ end
101
+ end
102
+ end
103
+
104
+ class Arrow < ArtDrawable
105
+ def measure(available_width)
106
+ @width = Style.dimension(style(:width), available_width).to_i
107
+ @height = (@width / 2.0).round
108
+ end
109
+
110
+ def draw(painter, x, y)
111
+ w = @width
112
+ h = @height
113
+ painter.draw(fill: fill_paint, stroke: stroke_paint, thickness: strokewidth) do |p|
114
+ p.move_to(x, y + h * 0.35)
115
+ .line_to(x + w * 0.6, y + h * 0.35)
116
+ .line_to(x + w * 0.6, y)
117
+ .line_to(x + w, y + h * 0.5)
118
+ .line_to(x + w * 0.6, y + h)
119
+ .line_to(x + w * 0.6, y + h * 0.65)
120
+ .line_to(x, y + h * 0.65)
121
+ .close
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../drawable"
4
+
5
+ module Clogs
6
+ # A rounded rectangle, built from four quarter-circle arcs.
7
+ def self.rounded_rect(path, x, y, w, h, r)
8
+ r = [r, w / 2.0, h / 2.0].min
9
+ hp = Math::PI / 2
10
+ path.arc_figure(x + r, y + r, r, Math::PI, hp)
11
+ path.arc_to(x + w - r, y + r, r, -hp, hp)
12
+ path.arc_to(x + w - r, y + h - r, r, 0, hp)
13
+ path.arc_to(x + r, y + h - r, r, hp, hp)
14
+ path.close
15
+ end
16
+
17
+ # Shoes' art drawables. In Shoes these always carry their own left/top and so
18
+ # are positioned within the current slot rather than flowed.
19
+ class ArtDrawable < Drawable
20
+ def positioned?
21
+ true
22
+ end
23
+
24
+ def left
25
+ Style.dimension(style(:left), parent_content_width).to_i
26
+ end
27
+
28
+ def top
29
+ Style.dimension(style(:top), parent_content_width).to_i
30
+ end
31
+
32
+ def parent_content_width
33
+ @parent.respond_to?(:instance_variable_get) ? (@parent&.instance_variable_get(:@content_width) || 0) : 0
34
+ end
35
+
36
+ # Shoes' `fill`, `stroke`, `strokewidth` and `rotate` are *draw context*
37
+ # styles: set once on a slot, inherited by every art drawable made inside
38
+ # it. Lacci copies them onto drawables that declare them as their own
39
+ # styles, but drawables like Line and Shape do not, so the draw context has
40
+ # to be consulted directly.
41
+ def draw_context
42
+ @styles["draw_context"] || {}
43
+ end
44
+
45
+ def context_style(name)
46
+ value = style(name)
47
+ value.nil? ? draw_context[name.to_s] : value
48
+ end
49
+
50
+ # `nofill` and `nostroke` arrive as fully transparent colours rather than
51
+ # as nil, so treat zero alpha as "do not paint this".
52
+ def fill_paint
53
+ opaque(Style.color(context_style(:fill), nil))
54
+ end
55
+
56
+ def stroke_paint
57
+ opaque(Style.color(context_style(:stroke), nil))
58
+ end
59
+
60
+ def opaque(color)
61
+ color && color[3].to_i.positive? ? color : nil
62
+ end
63
+
64
+ def strokewidth
65
+ (context_style(:strokewidth) || 1).to_i
66
+ end
67
+
68
+ def rotation
69
+ context_style(:rotate).to_f
70
+ end
71
+
72
+ # Art drawables paint relative to the slot, honouring any rotation from the
73
+ # draw context.
74
+ def paint(painter, ox, oy)
75
+ @abs_x = ox + @x
76
+ @abs_y = oy + @y
77
+ if rotation.zero?
78
+ draw(painter, @abs_x, @abs_y)
79
+ else
80
+ painter.save do |p|
81
+ p.rotate(rotation, @abs_x + @width / 2.0, @abs_y + @height / 2.0)
82
+ draw(p, @abs_x, @abs_y)
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ class Rect < ArtDrawable
89
+ def measure(available_width)
90
+ @width = Style.dimension(style(:width), available_width).to_i
91
+ @height = Style.dimension(style(:height), available_width).to_i
92
+ end
93
+
94
+ def draw(painter, x, y)
95
+ curve = style(:curve).to_i
96
+ if curve.positive?
97
+ painter.draw(fill: fill_paint, stroke: stroke_paint, thickness: strokewidth) do |p|
98
+ Clogs.rounded_rect(p, x, y, @width, @height, curve)
99
+ end
100
+ else
101
+ painter.fill_rect(x, y, @width, @height, fill_paint) if fill_paint
102
+ painter.stroke_rect(x, y, @width, @height, stroke_paint, thickness: strokewidth) if stroke_paint
103
+ end
104
+ end
105
+
106
+ end
107
+
108
+ class Oval < ArtDrawable
109
+ def measure(available_width)
110
+ radius = style(:radius)
111
+ if radius && !style(:width)
112
+ @width = @height = radius.to_i * 2
113
+ else
114
+ @width = Style.dimension(style(:width), available_width).to_i
115
+ @height = Style.dimension(style(:height), available_width).to_i
116
+ end
117
+ end
118
+
119
+ def draw(painter, x, y)
120
+ # Shoes' `center: true` means left/top name the centre, not the corner.
121
+ if style(:center)
122
+ x -= @width / 2.0
123
+ y -= @height / 2.0
124
+ end
125
+ painter.fill_oval(x, y, @width, @height, fill_paint) if fill_paint
126
+ painter.stroke_oval(x, y, @width, @height, stroke_paint, thickness: strokewidth) if stroke_paint
127
+ end
128
+ end
129
+
130
+ class Line < ArtDrawable
131
+ def measure(available_width)
132
+ @x2 = Style.dimension(style(:x2), available_width).to_i
133
+ @y2 = Style.dimension(style(:y2), available_width).to_i
134
+ @width = (@x2 - left).abs
135
+ @height = (@y2 - top).abs
136
+ end
137
+
138
+ # A line's endpoints are both slot coordinates, so it cannot use the usual
139
+ # "draw at my top-left" convention.
140
+ def paint(painter, ox, oy)
141
+ @abs_x = ox + [left, @x2].min
142
+ @abs_y = oy + [top, @y2].min
143
+ painter.line(ox + left, oy + top, ox + @x2, oy + @y2,
144
+ stroke_paint || [0, 0, 0, 255], thickness: strokewidth)
145
+ end
146
+ end
147
+
148
+ class Star < ArtDrawable
149
+ def points
150
+ (style(:points) || 10).to_i
151
+ end
152
+
153
+ def outer
154
+ (style(:outer) || 100).to_f
155
+ end
156
+
157
+ def inner
158
+ (style(:inner) || 50).to_f
159
+ end
160
+
161
+ def measure(_available_width)
162
+ @width = @height = (outer * 2).round
163
+ end
164
+
165
+ def draw(painter, x, y)
166
+ cx = x + outer
167
+ cy = y + outer
168
+ painter.draw(fill: fill_paint, stroke: stroke_paint, thickness: strokewidth) do |p|
169
+ (0...(points * 2)).each do |i|
170
+ r = i.even? ? outer : inner
171
+ angle = -Math::PI / 2 + i * Math::PI / points
172
+ px = cx + r * Math.cos(angle)
173
+ py = cy + r * Math.sin(angle)
174
+ i.zero? ? p.move_to(px, py) : p.line_to(px, py)
175
+ end
176
+ p.close
177
+ end
178
+ end
179
+ end
180
+
181
+ class Arc < ArtDrawable
182
+ def measure(available_width)
183
+ @width = Style.dimension(style(:width), available_width).to_i
184
+ @height = Style.dimension(style(:height), available_width).to_i
185
+ end
186
+
187
+ def draw(painter, x, y)
188
+ a1 = style(:angle1).to_f
189
+ a2 = style(:angle2).to_f
190
+ sweep = a2 - a1
191
+ radius = [@width, @height].min / 2.0
192
+ cx = x + @width / 2.0
193
+ cy = y + @height / 2.0
194
+ painter.draw(fill: fill_paint, stroke: stroke_paint, thickness: strokewidth) do |p|
195
+ p.move_to(cx, cy) if style(:wedge)
196
+ p.arc_figure(cx, cy, radius, a1, sweep) unless style(:wedge)
197
+ p.arc_to(cx, cy, radius, a1, sweep) if style(:wedge)
198
+ p.close if style(:wedge)
199
+ end
200
+ end
201
+ end
202
+
203
+ # `shape { move_to ...; line_to ... }` -- Shoes records the commands and the
204
+ # display service replays them.
205
+ class Shape < ArtDrawable
206
+ def measure(_available_width)
207
+ xs = []
208
+ ys = []
209
+ Array(style(:shape_commands)).each do |cmd|
210
+ _name, *args = cmd
211
+ args.each_slice(2) { |px, py| xs << px.to_f; ys << py.to_f if py }
212
+ end
213
+ @width = xs.empty? ? 0 : xs.max.ceil
214
+ @height = ys.empty? ? 0 : ys.max.ceil
215
+ end
216
+
217
+ def draw(painter, x, y)
218
+ painter.draw(fill: fill_paint, stroke: stroke_paint, thickness: strokewidth) do |p|
219
+ started = false
220
+ Array(style(:shape_commands)).each do |cmd|
221
+ name, *args = cmd
222
+ case name.to_s
223
+ when "move_to"
224
+ p.move_to(x + args[0], y + args[1])
225
+ started = true
226
+ when "line_to"
227
+ p.move_to(x + args[0], y + args[1]) unless started
228
+ started = true
229
+ p.line_to(x + args[0], y + args[1])
230
+ when "curve_to"
231
+ p.curve_to(x + args[2], y + args[3], x + args[4], y + args[5], x + args[0], y + args[1])
232
+ when "arc_to"
233
+ p.arc_to(x + args[0], y + args[1], args[2], args[4].to_f, args[5].to_f - args[4].to_f)
234
+ end
235
+ end
236
+ end
237
+ end
238
+ end
239
+
240
+ # Background and Border cover the slot that contains them rather than
241
+ # occupying a place in its layout.
242
+ class SlotDecoration < Drawable
243
+ def measure_for_slot(width, height)
244
+ @width = width
245
+ @height = height
246
+ end
247
+
248
+ def measure(_available_width); end
249
+ end
250
+
251
+ class Background < SlotDecoration
252
+ def paint_for_slot(painter, x, y, w, h)
253
+ @abs_x = x
254
+ @abs_y = y
255
+ @width = w
256
+ @height = h
257
+ paint = background_paint(x, y, w, h)
258
+ return unless paint
259
+
260
+ curve = style(:curve).to_i
261
+ if curve.positive?
262
+ painter.draw(fill: paint) { |p| Clogs.rounded_rect(p, x, y, w, h, curve) }
263
+ else
264
+ painter.fill_rect(x, y, w, h, paint)
265
+ end
266
+ end
267
+
268
+ # Shoes lets a background be a colour, or a gradient written as
269
+ # `background red..blue`.
270
+ def background_paint(x, y, w, h)
271
+ fill = style(:fill) || style(:color)
272
+ if fill.is_a?(Range) || (fill.is_a?(Array) && fill.length == 2 && fill.first.is_a?(Array))
273
+ from, to = fill.is_a?(Range) ? [fill.first, fill.last] : fill
274
+ UI.gradient_brush(UI::BRUSH_LINEAR_GRADIENT, [x, y], [x, y + h],
275
+ [[0.0, Style.color(from)], [1.0, Style.color(to)]])
276
+ else
277
+ _ = w
278
+ Style.color(fill, nil)
279
+ end
280
+ end
281
+ end
282
+
283
+ class Border < SlotDecoration
284
+ def paint_for_slot(painter, x, y, w, h)
285
+ @abs_x = x
286
+ @abs_y = y
287
+ @width = w
288
+ @height = h
289
+ color = Style.color(style(:stroke), [0, 0, 0, 255])
290
+ thickness = (style(:strokewidth) || 1).to_i
291
+ inset = thickness / 2.0
292
+ curve = style(:curve).to_i
293
+ if curve.positive?
294
+ painter.draw(stroke: color, thickness: thickness) do |p|
295
+ Clogs.rounded_rect(p, x + inset, y + inset, w - thickness, h - thickness, curve)
296
+ end
297
+ else
298
+ painter.stroke_rect(x + inset, y + inset, w - thickness, h - thickness, color, thickness: thickness)
299
+ end
300
+ end
301
+ end
302
+ end