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