funicular 0.0.1 → 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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +58 -20
  4. data/Rakefile +74 -2
  5. data/demo/keymap_editor.html +582 -0
  6. data/demo/test_cable.html +179 -0
  7. data/demo/test_chartjs.html +235 -0
  8. data/demo/test_component.html +201 -0
  9. data/demo/test_diff_patch.html +146 -0
  10. data/demo/test_error_boundary.html +284 -0
  11. data/demo/test_router.html +257 -0
  12. data/demo/test_vdom.html +100 -0
  13. data/demo/tic-tac-toe.html +201 -0
  14. data/docs/README.md +419 -0
  15. data/docs/advanced-features.md +632 -0
  16. data/docs/architecture.md +409 -0
  17. data/docs/components-and-state.md +539 -0
  18. data/docs/data-fetching.md +528 -0
  19. data/docs/forms.md +446 -0
  20. data/docs/rails-integration.md +426 -0
  21. data/docs/realtime.md +543 -0
  22. data/docs/routing-and-navigation.md +427 -0
  23. data/docs/styling.md +285 -0
  24. data/exe/funicular +32 -0
  25. data/lib/funicular/assets/funicular.rb +21 -0
  26. data/lib/funicular/assets/funicular_debug.css +73 -0
  27. data/lib/funicular/assets/funicular_debug.js +183 -0
  28. data/lib/funicular/commands/routes.rb +69 -0
  29. data/lib/funicular/compiler.rb +135 -0
  30. data/lib/funicular/configuration.rb +76 -0
  31. data/lib/funicular/helpers/picoruby_helper.rb +50 -0
  32. data/lib/funicular/middleware.rb +98 -0
  33. data/lib/funicular/railtie.rb +26 -0
  34. data/lib/funicular/route_parser.rb +137 -0
  35. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  37. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  38. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  39. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6404 -0
  41. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  42. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  44. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  45. data/lib/funicular/version.rb +1 -1
  46. data/lib/funicular.rb +29 -1
  47. data/lib/tasks/funicular.rake +135 -0
  48. data/minitest/funicular_test.rb +13 -0
  49. data/minitest/test_helper.rb +7 -0
  50. data/mrbgem.rake +15 -0
  51. data/mrblib/cable.rb +417 -0
  52. data/mrblib/component.rb +911 -0
  53. data/mrblib/debug.rb +205 -0
  54. data/mrblib/differ.rb +244 -0
  55. data/mrblib/environment_inquirer.rb +34 -0
  56. data/mrblib/error_boundary.rb +125 -0
  57. data/mrblib/file_upload.rb +184 -0
  58. data/mrblib/form_builder.rb +284 -0
  59. data/mrblib/funicular.rb +156 -0
  60. data/mrblib/http.rb +89 -0
  61. data/mrblib/model.rb +146 -0
  62. data/mrblib/patcher.rb +203 -0
  63. data/mrblib/router.rb +229 -0
  64. data/mrblib/styles.rb +83 -0
  65. data/mrblib/vdom.rb +273 -0
  66. data/sig/cable.rbs +65 -0
  67. data/sig/component.rbs +141 -0
  68. data/sig/debug.rbs +28 -0
  69. data/sig/differ.rbs +18 -0
  70. data/sig/environment_iquirer.rbs +10 -0
  71. data/sig/error_boundary.rbs +14 -0
  72. data/sig/file_upload.rbs +18 -0
  73. data/sig/form_builder.rbs +29 -0
  74. data/sig/funicular.rbs +11 -1
  75. data/sig/http.rbs +22 -0
  76. data/sig/model.rbs +23 -0
  77. data/sig/patcher.rbs +15 -0
  78. data/sig/router.rbs +43 -0
  79. data/sig/styles.rbs +25 -0
  80. data/sig/vdom.rbs +59 -0
  81. metadata +119 -8
@@ -0,0 +1,582 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Keymap Editor - DRb over WebSocket</title>
7
+ <style>
8
+ body {
9
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
10
+ max-width: 1000px;
11
+ margin: 30px auto;
12
+ padding: 20px;
13
+ background: #f0f0f0;
14
+ }
15
+ h1 { color: #333; border-bottom: 3px solid #2196F3; padding-bottom: 8px; }
16
+ h2 { color: #444; margin: 16px 0 8px; }
17
+ .container { background: white; padding: 24px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
18
+ .panel { margin: 16px 0; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
19
+ label { font-weight: bold; color: #555; }
20
+ input[type="text"] {
21
+ padding: 8px 12px; border: 2px solid #ddd; border-radius: 4px;
22
+ font-size: 14px; width: 280px;
23
+ }
24
+ button {
25
+ background: #2196F3; color: white; padding: 8px 16px;
26
+ border: none; border-radius: 4px; cursor: pointer; font-size: 14px;
27
+ }
28
+ button:hover { background: #1976D2; }
29
+ button:disabled { background: #bbb; cursor: not-allowed; }
30
+ button.danger { background: #f44336; }
31
+ button.danger:hover { background: #d32f2f; }
32
+ button.success { background: #4CAF50; }
33
+ button.success:hover { background: #388E3C; }
34
+ .uri-prefix, .uri-suffix { font-family: monospace; font-size: 14px; color: #555; }
35
+ .status {
36
+ display: inline-block; padding: 3px 10px; border-radius: 4px;
37
+ font-size: 12px; font-weight: bold; margin-left: 8px;
38
+ }
39
+ .status.connected { background: #4CAF50; color: white; }
40
+ .status.disconnected { background: #f44336; color: white; }
41
+ /* Layer tabs */
42
+ .tab-btn {
43
+ background: #eee; color: #333; margin: 2px;
44
+ border: 2px solid #ddd;
45
+ }
46
+ .tab-btn:hover { background: #ddd; }
47
+ .tab-btn.active { background: #2196F3; color: white; border-color: #2196F3; }
48
+ /* Key grid: columns set dynamically via JS */
49
+ .grid {
50
+ display: grid;
51
+ gap: 4px;
52
+ margin: 12px 0;
53
+ }
54
+ .key-cell {
55
+ background: #e8e8e8;
56
+ border: 2px solid #ccc;
57
+ border-radius: 4px;
58
+ padding: 6px 2px;
59
+ text-align: center;
60
+ font-size: 11px;
61
+ font-weight: bold;
62
+ cursor: pointer;
63
+ min-height: 44px;
64
+ display: flex;
65
+ align-items: center;
66
+ justify-content: center;
67
+ word-break: break-all;
68
+ color: #333;
69
+ }
70
+ .key-cell:hover { background: #c8e6fa; border-color: #2196F3; }
71
+ .key-cell.selected { background: #2196F3; color: white; border-color: #1565C0; }
72
+ .key-cell.transparent { color: #aaa; }
73
+ /* VOID: position has no key switch */
74
+ .key-cell.void {
75
+ background: transparent;
76
+ border-color: transparent;
77
+ cursor: default;
78
+ pointer-events: none;
79
+ }
80
+ /* Modal */
81
+ .modal {
82
+ display: none;
83
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
84
+ background: rgba(0,0,0,0.5);
85
+ justify-content: center;
86
+ align-items: flex-start;
87
+ padding-top: 60px;
88
+ z-index: 100;
89
+ }
90
+ .modal-content {
91
+ background: white;
92
+ padding: 24px;
93
+ border-radius: 8px;
94
+ max-width: 640px;
95
+ max-height: 70vh;
96
+ overflow-y: auto;
97
+ width: 90%;
98
+ }
99
+ .modal-content h3 { margin-top: 0; }
100
+ .pick-section { margin: 12px 0; }
101
+ .pick-section h4 { margin: 4px 0 6px; color: #555; font-size: 13px; }
102
+ .pick-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; }
103
+ .pick-btn {
104
+ background: #f5f5f5; color: #333; border: 1px solid #ccc;
105
+ padding: 5px 8px; font-size: 12px; font-weight: bold;
106
+ border-radius: 3px; min-width: 40px;
107
+ }
108
+ .pick-btn:hover { background: #2196F3; color: white; border-color: #2196F3; }
109
+ /* Output log */
110
+ #output {
111
+ background: #1e1e1e; color: #d4d4d4;
112
+ padding: 12px; border-radius: 4px;
113
+ font-family: 'Courier New', monospace; font-size: 12px;
114
+ max-height: 200px; overflow-y: auto;
115
+ white-space: pre-wrap; margin-top: 16px;
116
+ }
117
+ #output .success { color: #4CAF50; }
118
+ #output .error { color: #f44336; }
119
+ #output .info { color: #64B5F6; }
120
+ .hidden { display: none; }
121
+ .apply-row { margin: 12px 0; display: flex; gap: 8px; align-items: center; }
122
+ </style>
123
+ </head>
124
+ <body>
125
+ <div class="container">
126
+ <div><a href="index.html">Back</a></div>
127
+ <h1>
128
+ Keymap Editor
129
+ <span id="status" class="status disconnected">Disconnected</span>
130
+ </h1>
131
+
132
+ <!-- Connection panel -->
133
+ <div class="panel">
134
+ <label>IP Address:</label>
135
+ <span class="uri-prefix">ws://</span>
136
+ <input type="text" id="server-ip" value="192.168.x.x" style="width:140px">
137
+ <span class="uri-suffix">:9090</span>
138
+ <button id="connect-btn">Connect</button>
139
+ <button id="disconnect-btn" disabled class="danger">Disconnect</button>
140
+ </div>
141
+
142
+ <!-- Layer selector -->
143
+ <div id="layer-panel" class="hidden">
144
+ <h2>Layers</h2>
145
+ <div class="panel" id="layer-tabs"></div>
146
+ </div>
147
+
148
+ <!-- Key grid (layout determined dynamically from device) -->
149
+ <div id="grid-panel" class="hidden">
150
+ <h2>Layer: <span id="current-layer-name"></span></h2>
151
+ <div id="key-grid" class="grid"></div>
152
+ <div class="apply-row">
153
+ <button id="apply-btn" class="success">Apply to Device</button>
154
+ <button id="reload-btn">Reload from Device</button>
155
+ <span id="apply-status"></span>
156
+ </div>
157
+ </div>
158
+
159
+ <div id="output"></div>
160
+ </div>
161
+
162
+ <!-- Key picker modal -->
163
+ <div id="picker-modal" class="modal">
164
+ <div class="modal-content">
165
+ <h3>Select Key (<span id="picker-pos"></span>)</h3>
166
+ <div id="picker-body"></div>
167
+ <button id="picker-cancel" class="danger" style="margin-top:12px">Cancel</button>
168
+ </div>
169
+ </div>
170
+
171
+ <script type="text/ruby">
172
+ require 'js'
173
+ require 'drb'
174
+
175
+ # USB HID keycode names (from keycode.txt)
176
+ KEYCODE_NAMES = {
177
+ 0x04 => "A", 0x05 => "B", 0x06 => "C", 0x07 => "D",
178
+ 0x08 => "E", 0x09 => "F", 0x0A => "G", 0x0B => "H",
179
+ 0x0C => "I", 0x0D => "J", 0x0E => "K", 0x0F => "L",
180
+ 0x10 => "M", 0x11 => "N", 0x12 => "O", 0x13 => "P",
181
+ 0x14 => "Q", 0x15 => "R", 0x16 => "S", 0x17 => "T",
182
+ 0x18 => "U", 0x19 => "V", 0x1A => "W", 0x1B => "X",
183
+ 0x1C => "Y", 0x1D => "Z",
184
+ 0x1E => "1", 0x1F => "2", 0x20 => "3", 0x21 => "4",
185
+ 0x22 => "5", 0x23 => "6", 0x24 => "7", 0x25 => "8",
186
+ 0x26 => "9", 0x27 => "0",
187
+ 0x28 => "ENTER", 0x29 => "ESC", 0x2A => "BSPC", 0x2B => "TAB",
188
+ 0x2C => "SPACE",
189
+ 0x2D => "-", 0x2E => "=", 0x2F => "[", 0x30 => "]",
190
+ 0x31 => "\\", 0x33 => ";", 0x34 => "'", 0x35 => "`",
191
+ 0x36 => ",", 0x37 => ".", 0x38 => "/", 0x39 => "CAPS",
192
+ 0x3A => "F1", 0x3B => "F2", 0x3C => "F3", 0x3D => "F4",
193
+ 0x3E => "F5", 0x3F => "F6", 0x40 => "F7", 0x41 => "F8",
194
+ 0x42 => "F9", 0x43 => "F10", 0x44 => "F11", 0x45 => "F12",
195
+ 0x49 => "INS", 0x4A => "HOME", 0x4B => "PGUP",
196
+ 0x4C => "DEL", 0x4D => "END", 0x4E => "PGDN",
197
+ 0x4F => "RIGHT", 0x50 => "LEFT", 0x51 => "DOWN", 0x52 => "UP"
198
+ }
199
+
200
+ MOD_NAMES = {
201
+ 0xE0 => "LCTL", 0xE1 => "LSFT", 0xE2 => "LALT", 0xE3 => "LGUI",
202
+ 0xE4 => "RCTL", 0xE5 => "RSFT", 0xE6 => "RALT", 0xE7 => "RGUI"
203
+ }
204
+
205
+ MO_BASE = 0xE000
206
+ TG_BASE = 0xE100
207
+ LT_BASE = 0xE200
208
+ MT_BASE = 0xF200
209
+ SM_BASE = 0xFA00
210
+ X_OFFSET = 1000 # separates V range (-999...-1) from X range (...-1000)
211
+
212
+ def kc_name(kc)
213
+ return "---" if kc == 0 || kc.nil?
214
+ if MO_BASE <= kc && kc < MO_BASE + 256
215
+ "MO(#{kc - MO_BASE})"
216
+ elsif TG_BASE <= kc && kc < TG_BASE + 256
217
+ "TG(#{kc - TG_BASE})"
218
+ elsif LT_BASE <= kc && kc < LT_BASE + 4096
219
+ layer = (kc - LT_BASE) >> 8
220
+ tap = (kc - LT_BASE) & 0xFF
221
+ "LT(#{layer},#{KEYCODE_NAMES[tap] || hex(tap)})"
222
+ elsif MT_BASE <= kc && kc < MT_BASE + 2048
223
+ mod_idx = (kc - MT_BASE) >> 8
224
+ tap = (kc - MT_BASE) & 0xFF
225
+ mod_name = MOD_NAMES[0xE0 + mod_idx] || "MOD#{mod_idx}"
226
+ "MT(#{mod_name},#{KEYCODE_NAMES[tap] || hex(tap)})"
227
+ elsif SM_BASE <= kc && kc < SM_BASE + 256
228
+ base = kc - SM_BASE
229
+ "S(#{KEYCODE_NAMES[base] || hex(base)})"
230
+ elsif MOD_NAMES.key?(kc)
231
+ MOD_NAMES[kc]
232
+ elsif KEYCODE_NAMES.key?(kc)
233
+ KEYCODE_NAMES[kc]
234
+ else
235
+ hex(kc)
236
+ end
237
+ end
238
+
239
+ def hex(n)
240
+ "0x" + n.to_s(16).upcase
241
+ end
242
+
243
+ def doc
244
+ @doc ||= JS.document
245
+ end
246
+
247
+ def log_message(msg, type = 'info')
248
+ output = doc.getElementById('output')
249
+ line = doc.createElement('div')
250
+ line.className = type
251
+ line.textContent = msg
252
+ output.appendChild(line)
253
+ output.scrollTop = output.scrollHeight
254
+ end
255
+
256
+ def set_status(connected)
257
+ el = doc.getElementById('status')
258
+ if connected
259
+ el.textContent = 'Connected'
260
+ el.className = 'status connected'
261
+ else
262
+ el.textContent = 'Disconnected'
263
+ el.className = 'status disconnected'
264
+ end
265
+ doc.getElementById('connect-btn').disabled = connected
266
+ doc.getElementById('disconnect-btn').disabled = !connected
267
+ end
268
+
269
+ def show_el(id)
270
+ doc.getElementById(id).classList.remove('hidden')
271
+ end
272
+
273
+ def hide_el(id)
274
+ doc.getElementById(id).classList.add('hidden')
275
+ end
276
+
277
+ def render_layer_tabs
278
+ tabs = doc.getElementById('layer-tabs')
279
+ tabs.innerHTML = ''
280
+ @layers.each_key do |name|
281
+ btn = doc.createElement('button')
282
+ btn.textContent = name.to_s
283
+ btn.className = (name == @current_layer) ? 'tab-btn active' : 'tab-btn'
284
+ btn.addEventListener('click') do
285
+ select_layer(name)
286
+ end
287
+ tabs.appendChild(btn)
288
+ end
289
+ show_el('layer-panel')
290
+ end
291
+
292
+ def select_layer(name)
293
+ @current_layer = name
294
+ @editing_keymap = @layers[name].dup
295
+ doc.getElementById('current-layer-name').textContent = name.to_s
296
+ render_layer_tabs
297
+ render_grid
298
+ end
299
+
300
+ # Build a mapping from [row_i][col_i] -> keymap_index.
301
+ # Vacancy positions (kc < 0) map to nil.
302
+ def build_layout_map(layout)
303
+ layout_map = []
304
+ keymap_index = 0
305
+ layout.each do |layout_row|
306
+ row_map = []
307
+ layout_row.each do |kc|
308
+ if kc < 0
309
+ row_map << nil
310
+ else
311
+ row_map << keymap_index
312
+ keymap_index += 1
313
+ end
314
+ end
315
+ layout_map << row_map
316
+ end
317
+ layout_map
318
+ end
319
+
320
+ # Returns the span in quarters-of-U for a layout cell.
321
+ # Normal keys: 1U = 4 quarters.
322
+ # V (vacancy): encoded as -kc quarters (range (-X_OFFSET+1)...-1).
323
+ # X (extension): encoded as (-kc - X_OFFSET) quarters (range ...-X_OFFSET).
324
+ def cell_span(kc)
325
+ if kc <= -X_OFFSET
326
+ -kc - X_OFFSET
327
+ elsif kc < 0
328
+ -kc
329
+ else
330
+ 4
331
+ end
332
+ end
333
+
334
+ def render_grid
335
+ grid = doc.getElementById('key-grid')
336
+ grid.innerHTML = ''
337
+
338
+ if @layout.nil? || @layout.empty?
339
+ show_el('grid-panel')
340
+ return
341
+ end
342
+
343
+ # Grid unit = 0.25U. Compute max total quarters per row.
344
+ max_quarters = 0
345
+ @layout.each do |layout_row|
346
+ quarters = 0
347
+ layout_row.each { |kc| quarters += cell_span(kc) }
348
+ max_quarters = quarters if max_quarters < quarters
349
+ end
350
+ grid.style.gridTemplateColumns = "repeat(#{max_quarters}, 1fr)"
351
+
352
+ layout_map = build_layout_map(@layout)
353
+
354
+ @layout.each_with_index do |layout_row, row_i|
355
+ row_quarters = 0
356
+ last_cell = nil
357
+ last_cell_span = 0
358
+ layout_row.each_with_index do |layout_kc, col_i|
359
+ span = cell_span(layout_kc)
360
+ if layout_kc <= -X_OFFSET
361
+ # X: extend previous key's visual width (no new cell)
362
+ if last_cell
363
+ last_cell_span += span
364
+ last_cell.style.gridColumn = "span #{last_cell_span}"
365
+ end
366
+ row_quarters += span
367
+ elsif layout_kc < 0
368
+ # V: void spacer cell
369
+ cell = doc.createElement('div')
370
+ cell.style.gridColumn = "span #{span}"
371
+ cell.className = 'key-cell void'
372
+ grid.appendChild(cell)
373
+ row_quarters += span
374
+ last_cell = nil
375
+ last_cell_span = 0
376
+ else
377
+ ki = layout_map[row_i][col_i]
378
+ kc = @editing_keymap[ki]
379
+ cell = doc.createElement('div')
380
+ cell.style.gridColumn = "span #{span}"
381
+ cell.textContent = kc_name(kc)
382
+ cell.className = (kc == 0 || kc.nil?) ? 'key-cell transparent' : 'key-cell'
383
+ cell.addEventListener('click') do
384
+ open_picker(ki)
385
+ end
386
+ grid.appendChild(cell)
387
+ row_quarters += span
388
+ last_cell = cell
389
+ last_cell_span = span
390
+ end
391
+ end
392
+ # Pad short rows to max_quarters with a void spacer
393
+ if row_quarters < max_quarters
394
+ spacer = doc.createElement('div')
395
+ spacer.style.gridColumn = "span #{max_quarters - row_quarters}"
396
+ spacer.className = 'key-cell void'
397
+ grid.appendChild(spacer)
398
+ end
399
+ end
400
+
401
+ show_el('grid-panel')
402
+ end
403
+
404
+ def open_picker(cell_index)
405
+ @editing_cell = cell_index
406
+ doc.getElementById('picker-pos').textContent =
407
+ "index #{cell_index} (#{kc_name(@editing_keymap[cell_index])})"
408
+
409
+ body = doc.getElementById('picker-body')
410
+ body.innerHTML = ''
411
+
412
+ # Section: transparent / no key
413
+ add_pick_section(body, "Transparent / No Key", { 0x00 => "--- (transparent)" })
414
+
415
+ # Section: letters
416
+ letters = {}
417
+ (0x04..0x1D).each { |c| letters[c] = KEYCODE_NAMES[c] }
418
+ add_pick_section(body, "Letters", letters)
419
+
420
+ # Section: numbers
421
+ nums = {}
422
+ (0x1E..0x27).each { |c| nums[c] = KEYCODE_NAMES[c] }
423
+ add_pick_section(body, "Numbers", nums)
424
+
425
+ # Section: common keys
426
+ common = {
427
+ 0x28 => "ENTER", 0x29 => "ESC", 0x2A => "BSPC", 0x2B => "TAB",
428
+ 0x2C => "SPACE", 0x4C => "DEL", 0x4A => "HOME", 0x4D => "END",
429
+ 0x4B => "PGUP", 0x4E => "PGDN"
430
+ }
431
+ add_pick_section(body, "Common Keys", common)
432
+
433
+ # Section: punctuation
434
+ punct = {
435
+ 0x2D => "-", 0x2E => "=", 0x2F => "[", 0x30 => "]",
436
+ 0x31 => "\\", 0x33 => ";", 0x34 => "'", 0x35 => "`",
437
+ 0x36 => ",", 0x37 => ".", 0x38 => "/"
438
+ }
439
+ add_pick_section(body, "Punctuation", punct)
440
+
441
+ # Section: arrows
442
+ arrows = {
443
+ 0x4F => "RIGHT", 0x50 => "LEFT", 0x51 => "DOWN", 0x52 => "UP"
444
+ }
445
+ add_pick_section(body, "Arrows", arrows)
446
+
447
+ # Section: function keys
448
+ fkeys = {}
449
+ (0x3A..0x45).each { |c| fkeys[c] = KEYCODE_NAMES[c] }
450
+ add_pick_section(body, "Function Keys", fkeys)
451
+
452
+ # Section: layer-tap (LT 0-4 with SPACE as tap code)
453
+ lt_keys = {}
454
+ (0..4).each { |n| lt_keys[LT_BASE + (n << 8) + 0x2C] = "LT(#{n},SPACE)" }
455
+ add_pick_section(body, "Layer-Tap LT(n,SPACE) - hold activates layer", lt_keys)
456
+
457
+ # Section: momentary layer (MO)
458
+ mo_keys = {}
459
+ (0..4).each { |n| mo_keys[MO_BASE + n] = "MO(#{n})" }
460
+ add_pick_section(body, "Momentary Layer MO(n)", mo_keys)
461
+
462
+ # Section: toggle layer (TG)
463
+ tg_keys = {}
464
+ (0..4).each { |n| tg_keys[TG_BASE + n] = "TG(#{n})" }
465
+ add_pick_section(body, "Toggle Layer TG(n)", tg_keys)
466
+
467
+ doc.getElementById('picker-modal').style.display = 'flex'
468
+ end
469
+
470
+ def add_pick_section(container, title, keycode_map)
471
+ section = doc.createElement('div')
472
+ section.className = 'pick-section'
473
+ h4 = doc.createElement('h4')
474
+ h4.textContent = title
475
+ section.appendChild(h4)
476
+ row = doc.createElement('div')
477
+ row.className = 'pick-row'
478
+ keycode_map.each do |kc, name|
479
+ btn = doc.createElement('button')
480
+ btn.textContent = name
481
+ btn.className = 'pick-btn'
482
+ btn.addEventListener('click') do
483
+ pick_keycode(kc)
484
+ end
485
+ row.appendChild(btn)
486
+ end
487
+ section.appendChild(row)
488
+ container.appendChild(section)
489
+ end
490
+
491
+ def pick_keycode(kc)
492
+ if @editing_cell
493
+ @editing_keymap[@editing_cell] = kc
494
+ render_grid
495
+ @editing_cell = nil
496
+ end
497
+ doc.getElementById('picker-modal').style.display = 'none'
498
+ end
499
+
500
+ def load_layers(select_name = nil)
501
+ names = @service.layer_names
502
+ log_message("Found #{names.size} layer(s): #{names.map { |k| k.to_s }.join(', ')}", 'success')
503
+ @layers = {}
504
+ names.each do |name|
505
+ log_message("Loading layer :#{name}...")
506
+ @layers[name] = @service.get_layer(name)
507
+ end
508
+ log_message('All layers loaded', 'success')
509
+ select_layer(select_name || names.first)
510
+ end
511
+
512
+ # --- Event handlers ---
513
+
514
+ doc.getElementById('connect-btn').addEventListener('click') do
515
+ ip = doc.getElementById('server-ip').value
516
+ uri = "ws://#{ip}:9090"
517
+ log_message("Connecting to #{uri}...")
518
+ begin
519
+ @service = DRb::DRbObject.new_with_uri(uri)
520
+ set_status(true)
521
+ log_message("Connected to #{uri}", 'success')
522
+ log_message('Fetching layout from device...')
523
+ @layout = @service.layout
524
+ max_cols = 0
525
+ @layout.each { |r| max_cols = r.size if max_cols < r.size }
526
+ log_message("Layout: #{@layout.size} rows x #{max_cols} cols max", 'success')
527
+ load_layers
528
+ rescue => e
529
+ log_message("Connection failed: #{e.message}", 'error')
530
+ set_status(false)
531
+ @service = nil
532
+ end
533
+ end
534
+
535
+ doc.getElementById('disconnect-btn').addEventListener('click') do
536
+ @service = nil
537
+ @layout = nil
538
+ @layers = nil
539
+ @current_layer = nil
540
+ @editing_keymap = nil
541
+ set_status(false)
542
+ hide_el('layer-panel')
543
+ hide_el('grid-panel')
544
+ log_message('Disconnected')
545
+ end
546
+
547
+ doc.getElementById('apply-btn').addEventListener('click') do
548
+ begin
549
+ doc.getElementById('apply-status').textContent = 'Applying...'
550
+ @service.send(:add_layer, @current_layer, @editing_keymap)
551
+ @layers[@current_layer] = @editing_keymap.dup
552
+ doc.getElementById('apply-status').textContent = 'Applied!'
553
+ log_message("Layer :#{@current_layer} updated on device", 'success')
554
+ rescue => e
555
+ doc.getElementById('apply-status').textContent = 'Error'
556
+ log_message("Apply error: #{e.message}", 'error')
557
+ end
558
+ end
559
+
560
+ doc.getElementById('reload-btn').addEventListener('click') do
561
+ begin
562
+ log_message('Reloading layers from device...')
563
+ load_layers(@current_layer)
564
+ rescue => e
565
+ log_message("Reload error: #{e.message}", 'error')
566
+ end
567
+ end
568
+
569
+ doc.getElementById('picker-cancel').addEventListener('click') do
570
+ @editing_cell = nil
571
+ doc.getElementById('picker-modal').style.display = 'none'
572
+ end
573
+
574
+ log_message('Keymap Editor - DRb over WebSocket', 'info')
575
+ log_message('1. Start keyboard server: picoruby gherkin_drb.rb', 'info')
576
+ log_message('2. Type the device IP address and click Connect', 'info')
577
+ log_message('3. Click any key cell to reassign it', 'info')
578
+ log_message('4. Click "Apply to Device" to send changes', 'info')
579
+ </script>
580
+ <script src="../init.iife.js"></script>
581
+ </body>
582
+ </html>