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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c5a5068ca08f8566250559ac8272e88a638437c920ede77a3dd7e737db665ef1
|
|
4
|
+
data.tar.gz: a05eb6a0560bc9d6cb44d0edbd39e19ff4d788bd601ad7698fed1658329710f3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: '0383ca3af192cd8644378292bd4171d16ee9499dbd6370963b281559699f633b0428ee667bc1fb77f9e6449591526d921721cd4e89f172251b6948a39b67749f'
|
|
7
|
+
data.tar.gz: ce3f150fe9f1250c5e51a196a9105bec2d7d1b173ec86289169b6172225bfe5fef1a61047b56c81becd0bfbb94d624b2802043d853c2a0d9ff1cba03d9c4820c
|
data/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Clogs
|
|
2
|
+
|
|
3
|
+
**Shoes, worn over libui.**
|
|
4
|
+
|
|
5
|
+
Clogs runs [Shoes](https://github.com/shoes/shoes-deprecated) programs on plain
|
|
6
|
+
CRuby. No browser engine, no JVM — just [libui](https://github.com/libui-ng/libui-ng),
|
|
7
|
+
a small native widget library that ships as a prebuilt shared object for Linux,
|
|
8
|
+
macOS and Windows.
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
require "clogs"
|
|
12
|
+
|
|
13
|
+
Shoes.app(title: "Hello", width: 400, height: 200) do
|
|
14
|
+
para "Hello, ", strong("Shoes"), "!", size: :title
|
|
15
|
+
button("Push me") { @note.replace "Aha! Clicked." }
|
|
16
|
+
@note = para "Nothing pushed so far"
|
|
17
|
+
end
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
gem install clogs
|
|
22
|
+
ruby hello.rb
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## How it fits together
|
|
26
|
+
|
|
27
|
+
The Shoes API itself comes from [Lacci](https://github.com/scarpe-team/scarpe),
|
|
28
|
+
the display-independent half of Scarpe. Lacci owns the DSL and the drawable
|
|
29
|
+
tree; Clogs is a Lacci *display service* and owns the pixels.
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
your Shoes program
|
|
33
|
+
|
|
|
34
|
+
Lacci the Shoes DSL, drawable tree, events
|
|
35
|
+
|
|
|
36
|
+
Clogs layout, painting, widgets, input
|
|
37
|
+
|
|
|
38
|
+
libui one native window, one canvas
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
You can also select it explicitly, which is useful when the same program should
|
|
42
|
+
run under Scarpe's webview backend too:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
SCARPE_DISPLAY_SERVICE=clogs ruby my_app.rb
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## What it looks like
|
|
49
|
+
|
|
50
|
+
Clogs paints the entire Shoes document into a single libui canvas, including the
|
|
51
|
+
widgets. That is not a shortcut: libui has no way to place a native control at an
|
|
52
|
+
arbitrary position, and Shoes needs exactly that. The trade-offs — and a tested
|
|
53
|
+
feature-by-feature matrix — are in
|
|
54
|
+
[docs/libui_shoes_coverage.md](docs/libui_shoes_coverage.md).
|
|
55
|
+
|
|
56
|
+
The short version: layout, text, links, art drawables, images, widgets, events,
|
|
57
|
+
animation and dialogs all work. Video, sound, blur and multiple windows do not.
|
|
58
|
+
|
|
59
|
+
## Development
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
cd clogs
|
|
63
|
+
bundle install
|
|
64
|
+
rake test # unit tests plus real windowed runs
|
|
65
|
+
ruby -Ilib examples/kitchen_sink.rb # see it
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The tests open real windows, so on a headless machine run them under Xvfb:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
xvfb-run -a rake test
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Two environment variables make Shoes apps scriptable, which is how Clogs tests
|
|
75
|
+
itself and how you can screenshot an app from CI:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
CLOGS_EXIT_AFTER_MS=800 CLOGS_SCREENSHOT=shot.png ruby -Ilib examples/kitchen_sink.rb
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Licence
|
|
82
|
+
|
|
83
|
+
MIT.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A tour of what Clogs can draw. Run it with:
|
|
4
|
+
#
|
|
5
|
+
# ruby -Iclogs/lib clogs/examples/kitchen_sink.rb
|
|
6
|
+
|
|
7
|
+
require "clogs"
|
|
8
|
+
|
|
9
|
+
Shoes.app(title: "Clogs kitchen sink", width: 640, height: 620) do
|
|
10
|
+
stack do
|
|
11
|
+
background rgb(250, 250, 252)
|
|
12
|
+
|
|
13
|
+
title "Clogs", stroke: rgb(40, 40, 90)
|
|
14
|
+
para "Shoes drawables rendered by ", strong("libui"), ", with ", em("emphasis"),
|
|
15
|
+
", ", code("code"), " and a ", link("link") { @status.replace "You clicked the link!" }, "."
|
|
16
|
+
|
|
17
|
+
flow do
|
|
18
|
+
button("A button") { @status.replace "Button pushed." }
|
|
19
|
+
check
|
|
20
|
+
radio "group"
|
|
21
|
+
radio "group"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@status = para "Nothing has happened yet.", stroke: rgb(120, 0, 0)
|
|
25
|
+
|
|
26
|
+
flow do
|
|
27
|
+
edit_line text: "edit me"
|
|
28
|
+
list_box items: ["one", "two", "three"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
edit_box text: "A multi-line edit box.\nType into me.", width: 400, height: 90
|
|
32
|
+
|
|
33
|
+
progress fraction: 0.35
|
|
34
|
+
|
|
35
|
+
stack(width: 620, height: 170) do
|
|
36
|
+
background rgb(255, 255, 255)
|
|
37
|
+
border rgb(200, 200, 210), strokewidth: 2
|
|
38
|
+
|
|
39
|
+
fill rgb(220, 60, 60)
|
|
40
|
+
stroke rgb(60, 0, 0)
|
|
41
|
+
rect 20, 20, 120, 60
|
|
42
|
+
|
|
43
|
+
fill rgb(60, 120, 220)
|
|
44
|
+
oval 170, 20, 100, 60
|
|
45
|
+
|
|
46
|
+
nostroke
|
|
47
|
+
fill rgb(250, 200, 40)
|
|
48
|
+
star 320, 20, 6, 34, 16
|
|
49
|
+
|
|
50
|
+
stroke rgb(0, 140, 0)
|
|
51
|
+
strokewidth 4
|
|
52
|
+
line 20, 110, 300, 130
|
|
53
|
+
|
|
54
|
+
nofill
|
|
55
|
+
stroke rgb(120, 60, 200)
|
|
56
|
+
strokewidth 3
|
|
57
|
+
shape do
|
|
58
|
+
move_to 340, 100
|
|
59
|
+
line_to 380, 60
|
|
60
|
+
line_to 420, 140
|
|
61
|
+
line_to 460, 90
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/clogs/app.rb
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "drawable"
|
|
4
|
+
require_relative "canvas"
|
|
5
|
+
require_relative "dialogs"
|
|
6
|
+
require_relative "clipboard"
|
|
7
|
+
|
|
8
|
+
module Clogs
|
|
9
|
+
# The display side of Shoes::App: one libui window, one canvas, and the
|
|
10
|
+
# plumbing that turns libui's callbacks into Shoes events.
|
|
11
|
+
class App < Drawable
|
|
12
|
+
class << self
|
|
13
|
+
attr_accessor :instance
|
|
14
|
+
|
|
15
|
+
# Set synchronously while a `builtin` event is being handled, so that
|
|
16
|
+
# `ask`/`confirm` can return a value even on Lacci versions that have no
|
|
17
|
+
# response channel of their own.
|
|
18
|
+
attr_accessor :builtin_response
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr_reader :window, :canvas, :document_root, :mouse_state
|
|
22
|
+
|
|
23
|
+
def initialize(properties)
|
|
24
|
+
super
|
|
25
|
+
App.instance = self
|
|
26
|
+
@timers = []
|
|
27
|
+
@needs_layout = true
|
|
28
|
+
@focused = nil
|
|
29
|
+
@hovered = nil
|
|
30
|
+
@mouse_state = [0, 0, 0]
|
|
31
|
+
|
|
32
|
+
bind_shoes_event(event_name: "init") { init }
|
|
33
|
+
bind_shoes_event(event_name: "run") { run }
|
|
34
|
+
bind_shoes_event(event_name: "destroy") { destroy }
|
|
35
|
+
Shoes::DisplayService.subscribe_to_event("builtin", nil) do |cmd, args|
|
|
36
|
+
App.builtin_response = builtin(cmd, args)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def app
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def title
|
|
45
|
+
style(:title) || "Shoes"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def app_width
|
|
49
|
+
(style(:width) || 480).to_i
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def app_height
|
|
53
|
+
(style(:height) || 420).to_i
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def init
|
|
57
|
+
UI::L.init unless @initialized
|
|
58
|
+
@initialized = true
|
|
59
|
+
|
|
60
|
+
@window = UI::L.new_window(title, app_width, app_height, 0)
|
|
61
|
+
UI::L.window_set_margined(@window, 0)
|
|
62
|
+
UI::L.window_on_closing(@window) do
|
|
63
|
+
quit
|
|
64
|
+
0
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
@canvas = Canvas.new
|
|
68
|
+
@canvas.on_draw = method(:on_draw)
|
|
69
|
+
@canvas.on_mouse = method(:on_mouse)
|
|
70
|
+
@canvas.on_key = method(:on_key)
|
|
71
|
+
@canvas.on_crossed = method(:on_crossed)
|
|
72
|
+
|
|
73
|
+
# An area needs a stretchy box around it or libui gives it no size.
|
|
74
|
+
box = UI::L.new_vertical_box
|
|
75
|
+
UI::L.box_append(box, @canvas.area, 1)
|
|
76
|
+
UI::L.window_set_child(@window, box)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def run
|
|
80
|
+
UI::L.control_show(@window)
|
|
81
|
+
@running = true
|
|
82
|
+
arm_timers
|
|
83
|
+
install_test_hooks
|
|
84
|
+
UI::L.main
|
|
85
|
+
# libui aborts on exit if a control is still alive, so tear the window
|
|
86
|
+
# down explicitly. Destroying the window destroys its children.
|
|
87
|
+
UI::L.control_destroy(@window) if @window
|
|
88
|
+
@window = nil
|
|
89
|
+
# Anything that runs after the window is gone -- at_exit handlers, an
|
|
90
|
+
# app's own shutdown code -- must not touch the area again. Queueing a
|
|
91
|
+
# redraw on a destroyed area segfaults.
|
|
92
|
+
@canvas = nil
|
|
93
|
+
UI::L.uninit if @initialized
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Hooks for automated testing: run the app for a fixed time, optionally
|
|
97
|
+
# capture the screen, then quit. Used by Clogs' own visual tests and handy
|
|
98
|
+
# for any Shoes app under CI.
|
|
99
|
+
#
|
|
100
|
+
# CLOGS_EXIT_AFTER_MS=800 CLOGS_SCREENSHOT=out.png ruby app.rb
|
|
101
|
+
def install_test_hooks
|
|
102
|
+
ms = ENV["CLOGS_EXIT_AFTER_MS"]&.to_i
|
|
103
|
+
return if ms.nil? || ms <= 0
|
|
104
|
+
|
|
105
|
+
@exit_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + ms / 1000.0
|
|
106
|
+
add_timer(ms, repeat: false) { finish_test_run }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A busy app -- an animation that cannot keep up on a slow machine, say --
|
|
110
|
+
# can starve a one-shot timer indefinitely. The deadline is therefore also
|
|
111
|
+
# checked from the paint callback, so the run ends as long as anything is
|
|
112
|
+
# still happening at all.
|
|
113
|
+
def check_test_deadline
|
|
114
|
+
return unless @exit_deadline
|
|
115
|
+
return if Process.clock_gettime(Process::CLOCK_MONOTONIC) < @exit_deadline
|
|
116
|
+
|
|
117
|
+
finish_test_run
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def finish_test_run
|
|
121
|
+
return if @exit_deadline.nil?
|
|
122
|
+
|
|
123
|
+
@exit_deadline = nil
|
|
124
|
+
capture_screenshot(ENV["CLOGS_SCREENSHOT"]) if ENV["CLOGS_SCREENSHOT"]
|
|
125
|
+
quit
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def capture_screenshot(path)
|
|
129
|
+
# ImageMagick's `import` is the only dependency-free way to do this on
|
|
130
|
+
# X11; elsewhere the screenshot is simply skipped.
|
|
131
|
+
system("import", "-window", "root", path, err: File::NULL)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def destroy
|
|
135
|
+
return if @destroyed
|
|
136
|
+
|
|
137
|
+
@destroyed = true
|
|
138
|
+
UI::L.quit
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def quit
|
|
142
|
+
notify("destroy")
|
|
143
|
+
destroy
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def document_root=(root)
|
|
147
|
+
@document_root = root
|
|
148
|
+
root.app = self
|
|
149
|
+
needs_layout!
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def needs_layout!
|
|
153
|
+
@needs_layout = true
|
|
154
|
+
redraw!
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def redraw!
|
|
158
|
+
return if @destroyed
|
|
159
|
+
|
|
160
|
+
@canvas&.redraw
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# ---- painting -----------------------------------------------------
|
|
164
|
+
|
|
165
|
+
BACKGROUND = [255, 255, 255, 255].freeze
|
|
166
|
+
|
|
167
|
+
def on_draw(painter, _params)
|
|
168
|
+
check_test_deadline
|
|
169
|
+
painter.fill_rect(0, 0, painter.width, painter.height, BACKGROUND)
|
|
170
|
+
return unless @document_root
|
|
171
|
+
|
|
172
|
+
if @needs_layout || @last_width != painter.width
|
|
173
|
+
@document_root.measure(painter.width)
|
|
174
|
+
@last_width = painter.width
|
|
175
|
+
@needs_layout = false
|
|
176
|
+
end
|
|
177
|
+
@document_root.paint(painter, 0, 0)
|
|
178
|
+
open_list_boxes.each { |lb| lb.draw_overlay(painter) }
|
|
179
|
+
rescue StandardError => e
|
|
180
|
+
report_error(e)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def open_list_boxes
|
|
184
|
+
return [] unless @document_root
|
|
185
|
+
|
|
186
|
+
found = []
|
|
187
|
+
@document_root.each_peer { |peer| found << peer if peer.is_a?(ListBox) && peer.open? }
|
|
188
|
+
found
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# ---- input --------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def peers_at(x, y)
|
|
194
|
+
return [] unless @document_root
|
|
195
|
+
|
|
196
|
+
hits = []
|
|
197
|
+
@document_root.each_peer { |peer| hits << peer if peer.contains?(x, y) }
|
|
198
|
+
hits
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def topmost_clickable(x, y)
|
|
202
|
+
peers_at(x, y).reverse.find(&:clickable?)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def on_mouse(event)
|
|
206
|
+
@mouse_state = [event.button_down? ? 1 : 0, event.x.round, event.y.round]
|
|
207
|
+
Shoes::DisplayService.mouse_state = @mouse_state if Shoes::DisplayService.respond_to?(:mouse_state=)
|
|
208
|
+
|
|
209
|
+
update_hover(event)
|
|
210
|
+
notify_subscribers("motion", event.x.round, event.y.round,
|
|
211
|
+
event.modifiers.anybits?(UI::MOD_CTRL), event.modifiers.anybits?(UI::MOD_SHIFT))
|
|
212
|
+
|
|
213
|
+
if event.down.positive?
|
|
214
|
+
handle_press(event)
|
|
215
|
+
elsif event.up.positive?
|
|
216
|
+
handle_release(event)
|
|
217
|
+
end
|
|
218
|
+
rescue StandardError => e
|
|
219
|
+
report_error(e)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def handle_press(event)
|
|
223
|
+
# A click outside an open drop-down closes it.
|
|
224
|
+
open_list_boxes.each { |lb| lb.close unless lb.contains?(event.x, event.y) || lb.overlay_contains?(event.x, event.y) }
|
|
225
|
+
|
|
226
|
+
target = open_list_boxes.find { |lb| lb.overlay_contains?(event.x, event.y) } ||
|
|
227
|
+
topmost_clickable(event.x, event.y)
|
|
228
|
+
set_focus(target&.focusable? ? target : nil)
|
|
229
|
+
target&.on_click(event.x, event.y, event.down)
|
|
230
|
+
@pressed = target
|
|
231
|
+
notify_subscribers("click", event.down, event.x.round, event.y.round)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def handle_release(event)
|
|
235
|
+
@pressed&.on_release(event.x, event.y, event.up)
|
|
236
|
+
@pressed = nil
|
|
237
|
+
notify_subscribers("release", event.up, event.x.round, event.y.round)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def update_hover(event)
|
|
241
|
+
target = peers_at(event.x, event.y).reverse.find { |p| p.clickable? || p.is_a?(Control) }
|
|
242
|
+
return if target == @hovered
|
|
243
|
+
|
|
244
|
+
if @hovered
|
|
245
|
+
@hovered.on_mouse_leave if @hovered.respond_to?(:on_mouse_leave)
|
|
246
|
+
@hovered.notify("leave")
|
|
247
|
+
end
|
|
248
|
+
@hovered = target
|
|
249
|
+
if target
|
|
250
|
+
target.on_mouse_enter if target.respond_to?(:on_mouse_enter)
|
|
251
|
+
target.notify("hover")
|
|
252
|
+
end
|
|
253
|
+
notify_subscribers("hover") if target
|
|
254
|
+
notify_subscribers("leave") unless target
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def set_focus(peer)
|
|
258
|
+
return if peer == @focused
|
|
259
|
+
|
|
260
|
+
@focused&.focused = false
|
|
261
|
+
@focused&.focus_lost
|
|
262
|
+
@focused = peer
|
|
263
|
+
return unless peer
|
|
264
|
+
|
|
265
|
+
peer.focused = true
|
|
266
|
+
peer.focus_gained
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def on_key(event)
|
|
270
|
+
return false if @destroyed
|
|
271
|
+
|
|
272
|
+
if @focused&.on_key(event)
|
|
273
|
+
redraw!
|
|
274
|
+
return true
|
|
275
|
+
end
|
|
276
|
+
name = key_name(event)
|
|
277
|
+
return false unless name && !event.up
|
|
278
|
+
|
|
279
|
+
notify_subscribers("keypress", name)
|
|
280
|
+
true
|
|
281
|
+
rescue StandardError => e
|
|
282
|
+
report_error(e)
|
|
283
|
+
false
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Shoes reports keys as single characters, names like "up", or
|
|
287
|
+
# "control_x" style combinations.
|
|
288
|
+
def key_name(event)
|
|
289
|
+
base = if event.ext
|
|
290
|
+
event.ext.to_s
|
|
291
|
+
elsif event.char
|
|
292
|
+
case event.char
|
|
293
|
+
when "\b", "\x7F" then "backspace"
|
|
294
|
+
when "\r", "\n" then "\n"
|
|
295
|
+
when "\t" then "tab"
|
|
296
|
+
when " " then " "
|
|
297
|
+
else event.char
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
return nil unless base
|
|
301
|
+
|
|
302
|
+
mods = []
|
|
303
|
+
mods << "control" if event.ctrl?
|
|
304
|
+
mods << "alt" if event.alt?
|
|
305
|
+
mods << "shift" if event.shift? && base.length > 1
|
|
306
|
+
mods.empty? ? base : (mods + [base]).join("_")
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def on_crossed(left)
|
|
310
|
+
return unless left
|
|
311
|
+
|
|
312
|
+
@hovered&.on_mouse_leave if @hovered.respond_to?(:on_mouse_leave)
|
|
313
|
+
@hovered = nil
|
|
314
|
+
redraw!
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# ---- subscriptions and timers -------------------------------------
|
|
318
|
+
|
|
319
|
+
def subscriptions
|
|
320
|
+
found = []
|
|
321
|
+
@document_root&.each_peer { |peer| found << peer if peer.is_a?(SubscriptionItem) }
|
|
322
|
+
found
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def notify_subscribers(api_name, *args)
|
|
326
|
+
subscriptions.each do |sub|
|
|
327
|
+
sub.notify(api_name, *args) if sub.api_name == api_name
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# libui's timer callback returns nonzero to keep firing.
|
|
332
|
+
#
|
|
333
|
+
# Timers may only be armed once the main loop is running: Shoes programs
|
|
334
|
+
# routinely call `animate` or `every` while the app is still being built,
|
|
335
|
+
# and handing libui a timer before then crashes it. Queue those and arm
|
|
336
|
+
# them when the loop starts.
|
|
337
|
+
def add_timer(interval_ms, repeat: true, &block)
|
|
338
|
+
interval_ms = 1 if interval_ms.to_i < 1
|
|
339
|
+
unless @running
|
|
340
|
+
(@pending_timers ||= []) << [interval_ms, repeat, block]
|
|
341
|
+
return
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Arming a timer from inside a libui callback re-enters the loop's own
|
|
345
|
+
# bookkeeping and crashes; queue_main defers it to a safe point. Shoes
|
|
346
|
+
# programs create timers from event handlers all the time.
|
|
347
|
+
UI::L.queue_main { arm_timer(interval_ms, repeat, block) }
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def arm_timers
|
|
351
|
+
pending = @pending_timers || []
|
|
352
|
+
@pending_timers = nil
|
|
353
|
+
pending.each { |interval, repeat, block| arm_timer(interval, repeat, block) }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# The libui binding builds the callback itself when given a block, which
|
|
357
|
+
# is the only way to get the `int (*)(void *)` signature right; a
|
|
358
|
+
# hand-rolled closure with the wrong arity corrupts the stack.
|
|
359
|
+
# Returning nonzero keeps the timer running.
|
|
360
|
+
def arm_timer(interval_ms, repeat, block)
|
|
361
|
+
UI::L.timer(interval_ms) do
|
|
362
|
+
if @destroyed
|
|
363
|
+
0
|
|
364
|
+
else
|
|
365
|
+
begin
|
|
366
|
+
block.call
|
|
367
|
+
rescue StandardError => e
|
|
368
|
+
report_error(e)
|
|
369
|
+
end
|
|
370
|
+
repeat ? 1 : 0
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# ---- builtins -----------------------------------------------------
|
|
376
|
+
|
|
377
|
+
def builtin(cmd, args)
|
|
378
|
+
case cmd.to_s
|
|
379
|
+
when "alert" then Dialogs.alert(@window, args[0])
|
|
380
|
+
when "confirm" then Dialogs.confirm(@window, args[0])
|
|
381
|
+
when "ask" then Dialogs.ask(@window, args[0])
|
|
382
|
+
when "ask_open_file" then Dialogs.open_file(@window)
|
|
383
|
+
when "ask_save_file" then Dialogs.save_file(@window)
|
|
384
|
+
when "ask_open_folder" then Dialogs.open_folder(@window)
|
|
385
|
+
when "ask_color" then nil
|
|
386
|
+
when "font" then nil
|
|
387
|
+
end
|
|
388
|
+
rescue StandardError => e
|
|
389
|
+
report_error(e)
|
|
390
|
+
nil
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def report_error(error)
|
|
394
|
+
warn "Clogs error: #{error.class}: #{error.message}"
|
|
395
|
+
warn error.backtrace.first(12).join("\n") if error.backtrace
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
data/lib/clogs/canvas.rb
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ui"
|
|
4
|
+
require_relative "painter"
|
|
5
|
+
|
|
6
|
+
module Clogs
|
|
7
|
+
# Owns a uiArea and turns libui's callbacks into ordinary Ruby ones.
|
|
8
|
+
#
|
|
9
|
+
# Everything Clogs draws lives in a single area per window. That is not a
|
|
10
|
+
# stylistic choice: libui has no container that positions native controls at
|
|
11
|
+
# arbitrary coordinates, and Shoes' layout model requires exactly that. So
|
|
12
|
+
# Clogs paints the whole Shoes document itself and synthesises the widgets
|
|
13
|
+
# Shoes needs. See docs/libui_shoes_coverage.md.
|
|
14
|
+
class Canvas
|
|
15
|
+
attr_reader :area
|
|
16
|
+
attr_accessor :on_draw, :on_mouse, :on_key, :on_crossed
|
|
17
|
+
|
|
18
|
+
def initialize(scrolling: false, scroll_width: 0, scroll_height: 0)
|
|
19
|
+
@handler = UI.malloc(UI::L::FFI::AreaHandler)
|
|
20
|
+
|
|
21
|
+
@handler.Draw = UI.callback(0, [1, 1, 1]) do |_h, _area, params|
|
|
22
|
+
p = UI::L::FFI::AreaDrawParams.new(params)
|
|
23
|
+
painter = Painter.new(p.Context, p.AreaWidth, p.AreaHeight)
|
|
24
|
+
@on_draw&.call(painter, p)
|
|
25
|
+
0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@handler.MouseEvent = UI.callback(0, [1, 1, 1]) do |_h, _area, evt|
|
|
29
|
+
e = UI::L::FFI::AreaMouseEvent.new(evt)
|
|
30
|
+
@on_mouse&.call(mouse_event(e))
|
|
31
|
+
0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@handler.MouseCrossed = UI.callback(0, [1, 1, 0]) do |_h, _area, left|
|
|
35
|
+
@on_crossed&.call(left != 0)
|
|
36
|
+
0
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@handler.DragBroken = UI.callback(0, [1, 1]) { 0 }
|
|
40
|
+
|
|
41
|
+
@handler.KeyEvent = UI.callback(1, [1, 1, 1]) do |_h, _area, evt|
|
|
42
|
+
e = UI::L::FFI::AreaKeyEvent.new(evt)
|
|
43
|
+
handled = @on_key&.call(key_event(e))
|
|
44
|
+
handled ? 1 : 0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@area = if scrolling
|
|
48
|
+
UI::L.new_scrolling_area(@handler, scroll_width, scroll_height)
|
|
49
|
+
else
|
|
50
|
+
UI::L.new_area(@handler)
|
|
51
|
+
end
|
|
52
|
+
@scrolling = scrolling
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def redraw
|
|
56
|
+
UI::L.area_queue_redraw_all(@area)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def set_scroll_size(w, h)
|
|
60
|
+
return unless @scrolling
|
|
61
|
+
|
|
62
|
+
UI::L.area_set_size(@area, w.to_i, h.to_i)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def scroll_to(x, y, w, h)
|
|
66
|
+
return unless @scrolling
|
|
67
|
+
|
|
68
|
+
UI::L.area_scroll_to(@area, x, y, w, h)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
MouseEvent = Struct.new(:x, :y, :area_width, :area_height, :down, :up, :count, :modifiers, :held, keyword_init: true) do
|
|
74
|
+
def button_down?
|
|
75
|
+
held.anybits?(1)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def mouse_event(e)
|
|
80
|
+
MouseEvent.new(
|
|
81
|
+
x: e.X, y: e.Y, area_width: e.AreaWidth, area_height: e.AreaHeight,
|
|
82
|
+
down: e.Down, up: e.Up, count: e.Count, modifiers: e.Modifiers, held: e.Held1To64
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
KeyEvent = Struct.new(:char, :ext, :modifier, :modifiers, :up, keyword_init: true) do
|
|
87
|
+
def ctrl?
|
|
88
|
+
modifiers.anybits?(UI::MOD_CTRL)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def shift?
|
|
92
|
+
modifiers.anybits?(UI::MOD_SHIFT)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def alt?
|
|
96
|
+
modifiers.anybits?(UI::MOD_ALT)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# libui reports the *unshifted* key for an area, so "shift" plus "1" is
|
|
101
|
+
# reported as "1" rather than "!". Applying shift here keeps the rest of
|
|
102
|
+
# Clogs working in characters. The symbol half of the table assumes a US
|
|
103
|
+
# layout; letters are correct everywhere.
|
|
104
|
+
SHIFTED = {
|
|
105
|
+
"1" => "!", "2" => "@", "3" => "#", "4" => "$", "5" => "%", "6" => "^",
|
|
106
|
+
"7" => "&", "8" => "*", "9" => "(", "0" => ")", "-" => "_", "=" => "+",
|
|
107
|
+
"[" => "{", "]" => "}", "\\" => "|", ";" => ":", "'" => '"', "," => "<",
|
|
108
|
+
"." => ">", "/" => "?", "`" => "~"
|
|
109
|
+
}.freeze
|
|
110
|
+
|
|
111
|
+
def key_event(e)
|
|
112
|
+
raw = e.Key
|
|
113
|
+
char = case raw
|
|
114
|
+
when nil, 0 then nil
|
|
115
|
+
when Integer then raw.chr
|
|
116
|
+
else raw.to_s.empty? ? nil : raw.to_s
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if char && e.Modifiers.anybits?(UI::MOD_SHIFT)
|
|
120
|
+
char = SHIFTED[char] || char.upcase
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
KeyEvent.new(
|
|
124
|
+
char: char, ext: UI::EXT_KEYS[e.ExtKey], modifier: e.Modifier,
|
|
125
|
+
modifiers: e.Modifiers, up: e.Up != 0
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|