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,568 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../drawable"
4
+ require_relative "../paragraph"
5
+
6
+ module Clogs
7
+ # Shoes' widgets, drawn by hand.
8
+ #
9
+ # libui does have real native buttons and entries, but it can only stack them
10
+ # in boxes and grids -- there is no container that puts a control at an
11
+ # arbitrary (x, y). Shoes' whole layout model needs exactly that, so Clogs
12
+ # paints its widgets into the same area as everything else. The trade-off is
13
+ # noted in docs/libui_shoes_coverage.md: these look like Shoes widgets, not
14
+ # like the host platform's.
15
+ class Control < Drawable
16
+ PADDING_X = 10
17
+ PADDING_Y = 6
18
+
19
+ attr_accessor :hovered, :pressed, :focused
20
+
21
+ def clickable?
22
+ true
23
+ end
24
+
25
+ def text_style(size: nil, color: [0, 0, 0, 255])
26
+ Paragraph::TextStyle.new(
27
+ family: style(:family) || Clogs.default_font_family,
28
+ size: size || (style(:size) || style(:font_size) || Clogs.default_font_size).to_i,
29
+ bold: false, italic: false, underline: false,
30
+ color: color, bg: nil, owner: self
31
+ )
32
+ end
33
+
34
+ def label
35
+ style(:text).to_s
36
+ end
37
+
38
+ def on_mouse_enter
39
+ self.hovered = true
40
+ redraw!
41
+ end
42
+
43
+ def on_mouse_leave
44
+ self.hovered = false
45
+ self.pressed = false
46
+ redraw!
47
+ end
48
+ end
49
+
50
+ class Button < Control
51
+ def measure(available_width)
52
+ st = text_style
53
+ tw, th = Paragraph.measure(label.empty? ? " " : label, st)
54
+ @text_width = tw
55
+ @text_height = th
56
+ @width = requested_width(available_width) || (tw + PADDING_X * 2).ceil
57
+ @height = requested_height(available_width) ||
58
+ (th + PADDING_Y * 2 + style(:padding_top).to_i + style(:padding_bottom).to_i).ceil
59
+ end
60
+
61
+ FACE = [246, 246, 246, 255].freeze
62
+ FACE_HOVER = [252, 252, 252, 255].freeze
63
+ FACE_PRESSED = [225, 225, 225, 255].freeze
64
+ EDGE = [160, 160, 160, 255].freeze
65
+
66
+ def draw(painter, x, y)
67
+ face = Style.color(style(:color), nil) ||
68
+ (pressed ? FACE_PRESSED : (hovered ? FACE_HOVER : FACE))
69
+ painter.draw(fill: face, stroke: EDGE, thickness: 1) do |p|
70
+ Clogs.rounded_rect(p, x + 0.5, y + 0.5, @width - 1, @height - 1, 4)
71
+ end
72
+ st = text_style(color: Style.color(style(:text_color), [0, 0, 0, 255]))
73
+ block = TextBlock.new(
74
+ [Run.new(text: label, size: st.size, family: st.family, color: st.color)],
75
+ -1, default_family: st.family, default_size: st.size
76
+ )
77
+ block.draw(painter, x + (@width - @text_width) / 2.0, y + (@height - @text_height) / 2.0)
78
+ block.free
79
+ end
80
+
81
+ def on_click(_x, _y, _button)
82
+ self.pressed = true
83
+ redraw!
84
+ end
85
+
86
+ def on_release(x, y, _button)
87
+ was_pressed = pressed
88
+ self.pressed = false
89
+ redraw!
90
+ notify("click") if was_pressed && contains?(x, y)
91
+ end
92
+ end
93
+
94
+ class Check < Control
95
+ BOX = 16
96
+
97
+ def measure(_available_width)
98
+ @width = BOX
99
+ @height = BOX
100
+ end
101
+
102
+ def draw(painter, x, y)
103
+ painter.draw(fill: [255, 255, 255, 255], stroke: [120, 120, 120, 255], thickness: 1) do |p|
104
+ Clogs.rounded_rect(p, x + 0.5, y + 0.5, BOX - 1, BOX - 1, 3)
105
+ end
106
+ return unless style(:checked)
107
+
108
+ painter.draw(stroke: [30, 30, 30, 255], thickness: 2, cap: UI::CAP_ROUND) do |p|
109
+ p.move_to(x + 4, y + 8).line_to(x + 7, y + 11).line_to(x + 12, y + 5)
110
+ end
111
+ end
112
+
113
+ def on_release(x, y, _button)
114
+ return unless contains?(x, y)
115
+
116
+ @styles["checked"] = !style(:checked)
117
+ notify("click")
118
+ redraw!
119
+ end
120
+ end
121
+
122
+ class Radio < Control
123
+ BOX = 16
124
+
125
+ def measure(_available_width)
126
+ @width = BOX
127
+ @height = BOX
128
+ end
129
+
130
+ def draw(painter, x, y)
131
+ painter.fill_oval(x, y, BOX, BOX, [255, 255, 255, 255])
132
+ painter.stroke_oval(x + 0.5, y + 0.5, BOX - 1, BOX - 1, [120, 120, 120, 255], thickness: 1)
133
+ return unless style(:checked)
134
+
135
+ painter.fill_oval(x + 4, y + 4, BOX - 8, BOX - 8, [30, 30, 30, 255])
136
+ end
137
+
138
+ def on_release(x, y, _button)
139
+ return unless contains?(x, y)
140
+
141
+ # Selecting one radio in a group clears the others, as Shoes does.
142
+ group = style(:group)
143
+ if group && @parent
144
+ @parent.children.each do |sib|
145
+ sib.styles["checked"] = false if sib.is_a?(Radio) && sib.style(:group) == group
146
+ end
147
+ end
148
+ @styles["checked"] = true
149
+ notify("click")
150
+ redraw!
151
+ end
152
+ end
153
+
154
+ class Progress < Control
155
+ def measure(available_width)
156
+ @width = requested_width(available_width) || [available_width, 200].min
157
+ @height = requested_height(available_width) || 14
158
+ end
159
+
160
+ def draw(painter, x, y)
161
+ painter.draw(fill: [235, 235, 235, 255], stroke: [180, 180, 180, 255], thickness: 1) do |p|
162
+ Clogs.rounded_rect(p, x + 0.5, y + 0.5, @width - 1, @height - 1, @height / 2.0)
163
+ end
164
+ fraction = style(:fraction).to_f.clamp(0.0, 1.0)
165
+ return if fraction.zero?
166
+
167
+ painter.draw(fill: [60, 140, 230, 255]) do |p|
168
+ Clogs.rounded_rect(p, x + 1, y + 1, (@width - 2) * fraction, @height - 2, (@height - 2) / 2.0)
169
+ end
170
+ end
171
+
172
+ def clickable?
173
+ false
174
+ end
175
+ end
176
+
177
+ # Shared single/multi-line text editing.
178
+ #
179
+ # This is a deliberately small editor: caret, selection, the usual navigation
180
+ # keys, and clipboard via {Clogs::Clipboard}. libui offers no way to embed its
181
+ # own entry widget in an area, so there is nothing to delegate to.
182
+ module Editable
183
+ attr_reader :caret, :anchor
184
+
185
+ def editable_init
186
+ @caret = text_value.length
187
+ @anchor = nil
188
+ @scroll_x = 0
189
+ @scroll_y = 0
190
+ end
191
+
192
+ def text_value
193
+ style(:text).to_s
194
+ end
195
+
196
+ def text_value=(new_text)
197
+ @styles["text"] = new_text
198
+ notify("change", new_text)
199
+ end
200
+
201
+ def focusable?
202
+ true
203
+ end
204
+
205
+ def focus_gained
206
+ redraw!
207
+ end
208
+
209
+ def focus_lost
210
+ @anchor = nil
211
+ redraw!
212
+ end
213
+
214
+ def selection_range
215
+ return nil if @anchor.nil? || @anchor == @caret
216
+
217
+ [[@anchor, @caret].min, [@anchor, @caret].max]
218
+ end
219
+
220
+ def delete_selection
221
+ range = selection_range
222
+ return false unless range
223
+
224
+ from, to = range
225
+ self.text_value = text_value.dup.tap { |t| t[from...to] = "" }
226
+ @caret = from
227
+ @anchor = nil
228
+ true
229
+ end
230
+
231
+ def insert(str)
232
+ delete_selection
233
+ t = text_value.dup
234
+ t.insert(@caret, str)
235
+ self.text_value = t
236
+ @caret += str.length
237
+ end
238
+
239
+ def on_key(event)
240
+ return false if event.up
241
+
242
+ if event.ctrl?
243
+ return handle_ctrl(event)
244
+ end
245
+
246
+ case event.ext
247
+ when :left then move_caret(@caret - 1, event.shift?)
248
+ when :right then move_caret(@caret + 1, event.shift?)
249
+ when :home then move_caret(line_start(@caret), event.shift?)
250
+ when :end then move_caret(line_end(@caret), event.shift?)
251
+ when :up then move_caret(caret_line_offset(-1), event.shift?)
252
+ when :down then move_caret(caret_line_offset(1), event.shift?)
253
+ when :delete
254
+ unless delete_selection
255
+ t = text_value.dup
256
+ t[@caret] = "" if @caret < t.length
257
+ self.text_value = t
258
+ end
259
+ else
260
+ return handle_char(event)
261
+ end
262
+ redraw!
263
+ true
264
+ end
265
+
266
+ def handle_char(event)
267
+ char = event.char
268
+ return false unless char
269
+
270
+ case char
271
+ when "\b", "\x7F"
272
+ unless delete_selection
273
+ if @caret.positive?
274
+ t = text_value.dup
275
+ t[@caret - 1] = ""
276
+ self.text_value = t
277
+ @caret -= 1
278
+ end
279
+ end
280
+ when "\r", "\n"
281
+ return false unless multiline?
282
+
283
+ insert("\n")
284
+ when "\t"
285
+ return false
286
+ else
287
+ return false if char.ord < 32
288
+
289
+ insert(char)
290
+ end
291
+ redraw!
292
+ true
293
+ end
294
+
295
+ def handle_ctrl(event)
296
+ case event.char
297
+ when "a", "\x01"
298
+ @anchor = 0
299
+ @caret = text_value.length
300
+ when "c", "\x03"
301
+ range = selection_range
302
+ Clipboard.write(text_value[range[0]...range[1]]) if range
303
+ when "x", "\x18"
304
+ range = selection_range
305
+ if range
306
+ Clipboard.write(text_value[range[0]...range[1]])
307
+ delete_selection
308
+ end
309
+ when "v", "\x16"
310
+ text = Clipboard.read
311
+ insert(text) if text && !text.empty?
312
+ else
313
+ return false
314
+ end
315
+ redraw!
316
+ true
317
+ end
318
+
319
+ def move_caret(pos, extend_selection)
320
+ @anchor = @caret if extend_selection && @anchor.nil?
321
+ @anchor = nil unless extend_selection
322
+ @caret = pos.clamp(0, text_value.length)
323
+ end
324
+
325
+ def line_start(pos)
326
+ (text_value.rindex("\n", [pos - 1, 0].max) || -1) + 1
327
+ end
328
+
329
+ def line_end(pos)
330
+ text_value.index("\n", pos) || text_value.length
331
+ end
332
+
333
+ def caret_line_offset(direction)
334
+ return @caret unless multiline?
335
+
336
+ column = @caret - line_start(@caret)
337
+ if direction.negative?
338
+ prev_end = line_start(@caret) - 1
339
+ return @caret if prev_end.negative?
340
+
341
+ prev_start = line_start(prev_end)
342
+ [prev_start + column, prev_end].min
343
+ else
344
+ next_start = line_end(@caret) + 1
345
+ return @caret if next_start > text_value.length
346
+
347
+ [next_start + column, line_end(next_start)].min
348
+ end
349
+ end
350
+
351
+ def multiline?
352
+ false
353
+ end
354
+ end
355
+
356
+ class EditLine < Control
357
+ include Editable
358
+
359
+ def initialize(properties)
360
+ super
361
+ editable_init
362
+ end
363
+
364
+ def displayed_text
365
+ style(:secret) ? "•" * text_value.length : text_value
366
+ end
367
+
368
+ def measure(available_width)
369
+ @width = requested_width(available_width) || [available_width, 200].min
370
+ st = text_style
371
+ @line_height = Paragraph.line_height(st)
372
+ @height = requested_height(available_width) || (@line_height + PADDING_Y * 2).ceil
373
+ end
374
+
375
+ def draw(painter, x, y)
376
+ painter.draw(fill: [255, 255, 255, 255], stroke: focused ? [60, 140, 230, 255] : [160, 160, 160, 255],
377
+ thickness: focused ? 2 : 1) do |p|
378
+ Clogs.rounded_rect(p, x + 0.5, y + 0.5, @width - 1, @height - 1, 3)
379
+ end
380
+
381
+ st = text_style(color: Style.color(style(:stroke), [0, 0, 0, 255]))
382
+ text = displayed_text
383
+ inner_x = x + 6
384
+ inner_y = y + (@height - @line_height) / 2.0
385
+
386
+ painter.save do |p|
387
+ p.clip_rect(x + 2, y + 2, @width - 4, @height - 4)
388
+ if (range = selection_range)
389
+ sx = Paragraph.measure(text[0...range[0]], st)[0]
390
+ sw = Paragraph.measure(text[range[0]...range[1]], st)[0]
391
+ p.fill_rect(inner_x + sx - @scroll_x, inner_y, sw, @line_height, [180, 210, 255, 255])
392
+ end
393
+ unless text.empty?
394
+ block = TextBlock.new([Run.new(text: text, size: st.size, family: st.family, color: st.color)],
395
+ -1, default_family: st.family, default_size: st.size)
396
+ block.draw(p, inner_x - @scroll_x, inner_y)
397
+ block.free
398
+ end
399
+ if focused
400
+ cx = inner_x + Paragraph.measure(text[0...@caret], st)[0] - @scroll_x
401
+ p.line(cx, inner_y, cx, inner_y + @line_height, [0, 0, 0, 255], thickness: 1)
402
+ end
403
+ end
404
+ end
405
+
406
+ def on_click(x, y, _button)
407
+ st = text_style
408
+ local = x - @abs_x - 6 + @scroll_x
409
+ @caret = index_for_offset(displayed_text, local, st)
410
+ @anchor = nil
411
+ _ = y
412
+ redraw!
413
+ end
414
+
415
+ # Walk the string until the measured prefix passes the click point.
416
+ def index_for_offset(text, offset, st)
417
+ return 0 if offset <= 0
418
+
419
+ (0..text.length).each do |i|
420
+ return [i - 1, 0].max if Paragraph.measure(text[0...i], st)[0] > offset
421
+ end
422
+ text.length
423
+ end
424
+ end
425
+
426
+ class EditBox < Control
427
+ include Editable
428
+
429
+ def initialize(properties)
430
+ super
431
+ editable_init
432
+ end
433
+
434
+ def multiline?
435
+ true
436
+ end
437
+
438
+ def measure(available_width)
439
+ @width = requested_width(available_width) || [available_width, 300].min
440
+ st = text_style
441
+ @line_height = Paragraph.line_height(st)
442
+ @height = requested_height(available_width) || (@line_height * 6 + PADDING_Y * 2).ceil
443
+ @paragraph = Paragraph.new([[text_value.empty? ? " " : text_value, st]], @width - 12)
444
+ end
445
+
446
+ def draw(painter, x, y)
447
+ painter.draw(fill: [255, 255, 255, 255], stroke: focused ? [60, 140, 230, 255] : [160, 160, 160, 255],
448
+ thickness: focused ? 2 : 1) do |p|
449
+ Clogs.rounded_rect(p, x + 0.5, y + 0.5, @width - 1, @height - 1, 3)
450
+ end
451
+ painter.save do |p|
452
+ p.clip_rect(x + 2, y + 2, @width - 4, @height - 4)
453
+ @paragraph.draw(p, x + 6, y + 4 - @scroll_y)
454
+ draw_caret(p, x, y) if focused
455
+ end
456
+ end
457
+
458
+ def draw_caret(painter, x, y)
459
+ item = @paragraph.placed.find { |i| @caret >= i.char_offset && @caret <= i.char_offset + i.text.length }
460
+ return unless item
461
+
462
+ st = item.style
463
+ prefix = item.text[0...(@caret - item.char_offset)]
464
+ cx = x + 6 + item.x + Paragraph.measure(prefix, st)[0]
465
+ cy = y + 4 + item.y - @scroll_y
466
+ painter.line(cx, cy, cx, cy + item.height, [0, 0, 0, 255], thickness: 1)
467
+ end
468
+
469
+ def on_click(x, y, _button)
470
+ @caret = (@paragraph&.index_at(x - @abs_x - 6, y - @abs_y - 4 + @scroll_y) || 0)
471
+ .clamp(0, text_value.length)
472
+ @anchor = nil
473
+ redraw!
474
+ end
475
+ end
476
+
477
+ # A drop-down list. libui's combobox is a real native control, but it cannot
478
+ # live inside an area, so the popup is drawn as an overlay on the same canvas.
479
+ class ListBox < Control
480
+ ROW_HEIGHT = 22
481
+
482
+ def items
483
+ Array(style(:items)).map(&:to_s)
484
+ end
485
+
486
+ def chosen
487
+ style(:chosen) || items.first
488
+ end
489
+
490
+ def measure(available_width)
491
+ @width = requested_width(available_width) || [available_width, 200].min
492
+ @height = requested_height(available_width) || (Clogs.default_font_size + PADDING_Y * 2).ceil
493
+ end
494
+
495
+ def draw(painter, x, y)
496
+ painter.draw(fill: [255, 255, 255, 255], stroke: [160, 160, 160, 255], thickness: 1) do |p|
497
+ Clogs.rounded_rect(p, x + 0.5, y + 0.5, @width - 1, @height - 1, 3)
498
+ end
499
+ st = text_style
500
+ unless chosen.to_s.empty?
501
+ block = TextBlock.new([Run.new(text: chosen.to_s, size: st.size, family: st.family, color: st.color)],
502
+ -1, default_family: st.family, default_size: st.size)
503
+ block.draw(painter, x + 6, y + PADDING_Y / 2.0)
504
+ block.free
505
+ end
506
+ # Arrow
507
+ painter.draw(fill: [80, 80, 80, 255]) do |p|
508
+ ax = x + @width - 16
509
+ ay = y + @height / 2.0 - 2
510
+ p.move_to(ax, ay).line_to(ax + 8, ay).line_to(ax + 4, ay + 5).close
511
+ end
512
+ end
513
+
514
+ # Drawn by the app after everything else so it sits on top.
515
+ def draw_overlay(painter)
516
+ return unless @open
517
+
518
+ x = @abs_x
519
+ y = @abs_y + @height
520
+ h = ROW_HEIGHT * items.size
521
+ painter.draw(fill: [255, 255, 255, 255], stroke: [140, 140, 140, 255], thickness: 1) do |p|
522
+ p.rect(x + 0.5, y + 0.5, @width - 1, h)
523
+ end
524
+ st = text_style
525
+ items.each_with_index do |item, i|
526
+ iy = y + i * ROW_HEIGHT
527
+ painter.fill_rect(x + 1, iy + 1, @width - 2, ROW_HEIGHT - 1, [220, 235, 255, 255]) if item == chosen
528
+ block = TextBlock.new([Run.new(text: item, size: st.size, family: st.family, color: st.color)],
529
+ -1, default_family: st.family, default_size: st.size)
530
+ block.draw(painter, x + 6, iy + 3)
531
+ block.free
532
+ end
533
+ end
534
+
535
+ def open?
536
+ @open
537
+ end
538
+
539
+ def overlay_contains?(px, py)
540
+ return false unless @open
541
+
542
+ py >= @abs_y + @height && py < @abs_y + @height + ROW_HEIGHT * items.size &&
543
+ px >= @abs_x && px < @abs_x + @width
544
+ end
545
+
546
+ def on_click(x, y, _button)
547
+ if @open && overlay_contains?(x, y)
548
+ index = ((y - (@abs_y + @height)) / ROW_HEIGHT).to_i
549
+ item = items[index]
550
+ if item
551
+ @styles["chosen"] = item
552
+ notify("change", item)
553
+ end
554
+ @open = false
555
+ else
556
+ @open = !@open
557
+ end
558
+ redraw!
559
+ end
560
+
561
+ def close
562
+ return unless @open
563
+
564
+ @open = false
565
+ redraw!
566
+ end
567
+ end
568
+ end