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.
- checksums.yaml +7 -0
- data/README.md +83 -0
- data/examples/kitchen_sink.rb +65 -0
- data/lib/clogs/app.rb +398 -0
- data/lib/clogs/canvas.rb +129 -0
- data/lib/clogs/clipboard.rb +56 -0
- data/lib/clogs/dialogs.rb +136 -0
- data/lib/clogs/display_service.rb +109 -0
- data/lib/clogs/drawable.rb +182 -0
- data/lib/clogs/drawables/controls.rb +568 -0
- data/lib/clogs/drawables/image.rb +198 -0
- data/lib/clogs/drawables/misc.rb +125 -0
- data/lib/clogs/drawables/shapes.rb +302 -0
- data/lib/clogs/drawables/slot.rb +128 -0
- data/lib/clogs/drawables/text.rb +217 -0
- data/lib/clogs/lacci_compat.rb +48 -0
- data/lib/clogs/log.rb +54 -0
- data/lib/clogs/painter.rb +192 -0
- data/lib/clogs/paragraph.rb +218 -0
- data/lib/clogs/style.rb +124 -0
- data/lib/clogs/text.rb +132 -0
- data/lib/clogs/ui.rb +157 -0
- data/lib/clogs/version.rb +5 -0
- data/lib/clogs.rb +92 -0
- metadata +109 -0
|
@@ -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
|