teek 0.1.1 → 0.1.3

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +46 -0
  3. data/Rakefile +162 -5
  4. data/ext/teek/extconf.rb +1 -1
  5. data/ext/teek/tcltkbridge.c +9 -0
  6. data/ext/teek/tcltkbridge.h +3 -0
  7. data/ext/teek/tkeventsource.c +195 -0
  8. data/ext/teek/tkphoto.c +169 -5
  9. data/ext/teek/tkwin.c +84 -0
  10. data/lib/teek/background_ractor4x.rb +32 -4
  11. data/lib/teek/photo.rb +232 -0
  12. data/lib/teek/version.rb +1 -1
  13. data/lib/teek.rb +202 -5
  14. data/sample/gamepad_viewer/assets/controller.png +0 -0
  15. data/sample/gamepad_viewer/gamepad_viewer.rb +554 -0
  16. data/sample/optcarrot/thwaite.nes +0 -0
  17. data/sample/optcarrot/vendor/optcarrot/apu.rb +856 -0
  18. data/sample/optcarrot/vendor/optcarrot/config.rb +257 -0
  19. data/sample/optcarrot/vendor/optcarrot/cpu.rb +1162 -0
  20. data/sample/optcarrot/vendor/optcarrot/driver.rb +144 -0
  21. data/sample/optcarrot/vendor/optcarrot/mapper/cnrom.rb +14 -0
  22. data/sample/optcarrot/vendor/optcarrot/mapper/mmc1.rb +105 -0
  23. data/sample/optcarrot/vendor/optcarrot/mapper/mmc3.rb +153 -0
  24. data/sample/optcarrot/vendor/optcarrot/mapper/uxrom.rb +14 -0
  25. data/sample/optcarrot/vendor/optcarrot/nes.rb +105 -0
  26. data/sample/optcarrot/vendor/optcarrot/opt.rb +168 -0
  27. data/sample/optcarrot/vendor/optcarrot/pad.rb +92 -0
  28. data/sample/optcarrot/vendor/optcarrot/palette.rb +65 -0
  29. data/sample/optcarrot/vendor/optcarrot/ppu.rb +1468 -0
  30. data/sample/optcarrot/vendor/optcarrot/rom.rb +143 -0
  31. data/sample/optcarrot/vendor/optcarrot.rb +14 -0
  32. data/sample/optcarrot.rb +354 -0
  33. data/sample/paint/assets/bucket.png +0 -0
  34. data/sample/paint/assets/cursor.png +0 -0
  35. data/sample/paint/assets/eraser.png +0 -0
  36. data/sample/paint/assets/pencil.png +0 -0
  37. data/sample/paint/assets/spray.png +0 -0
  38. data/sample/paint/layer.rb +255 -0
  39. data/sample/paint/layer_manager.rb +179 -0
  40. data/sample/paint/paint_demo.rb +837 -0
  41. data/sample/paint/sparse_pixel_buffer.rb +202 -0
  42. data/sample/sdl2_demo.rb +318 -0
  43. data/sample/yam/assets/click.wav +0 -0
  44. data/sample/yam/assets/explosion.wav +0 -0
  45. data/sample/yam/assets/flag.wav +0 -0
  46. data/sample/yam/assets/music.mp3 +0 -0
  47. data/sample/yam/assets/sweep.wav +0 -0
  48. data/sample/{minesweeper/minesweeper.rb → yam/yam.rb} +147 -12
  49. metadata +50 -14
  50. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_0.png +0 -0
  51. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_1.png +0 -0
  52. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_2.png +0 -0
  53. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_3.png +0 -0
  54. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_4.png +0 -0
  55. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_5.png +0 -0
  56. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_6.png +0 -0
  57. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_7.png +0 -0
  58. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_8.png +0 -0
  59. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_F.png +0 -0
  60. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_M.png +0 -0
  61. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_X.png +0 -0
@@ -0,0 +1,554 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # Gamepad Viewer — SDL2 gamepad input visualized with pure Tk widgets.
4
+ #
5
+ # Demonstrates:
6
+ # - Teek::SDL2::Gamepad for controller input (polling + events)
7
+ # - Virtual gamepad wired to keyboard for testing without hardware
8
+ # - Combobox for gamepad selection with hot-plug detection
9
+ # - Canvas overlay highlights on a controller image
10
+ # - Tk label indicators for button/axis state
11
+ # - Periodic polling via app.every (50ms game loop)
12
+ #
13
+ # Controller artwork: "Generic Gamepad Template" by Erratic (CC0)
14
+ # https://opengameart.org/content/generic-gamepad-template
15
+
16
+ require_relative '../../lib/teek'
17
+ require_relative '../../teek-sdl2/lib/teek/sdl2'
18
+
19
+ class GamepadViewer
20
+ GP = Teek::SDL2::Gamepad
21
+
22
+ # Approximate pixel coordinates for button highlights on controller.png (479x310)
23
+ # Format: [center_x, center_y, radius]
24
+ BUTTON_POS = {
25
+ a: [352, 140, 18],
26
+ b: [390, 107, 18],
27
+ x: [315, 107, 18],
28
+ y: [353, 71, 18],
29
+ dpad_up: [127, 74, 12],
30
+ dpad_down: [127, 143, 12],
31
+ dpad_left: [ 93, 111, 12],
32
+ dpad_right: [160, 110, 12],
33
+ left_shoulder: [ 97, 18, 20],
34
+ right_shoulder: [383, 21, 20],
35
+ back: [215, 93, 10],
36
+ start: [262, 95, 10],
37
+ left_stick: [165, 200, 16],
38
+ right_stick: [313, 200, 16],
39
+ guide: [242, 135, 12],
40
+ }.freeze
41
+
42
+ # Center of each analog stick for the position dot
43
+ STICK_CENTER = {
44
+ left: [165, 200],
45
+ right: [313, 200],
46
+ }.freeze
47
+
48
+ # Keyboard → virtual gamepad button mapping
49
+ KEY_MAP_BUTTONS = {
50
+ 'space' => :a,
51
+ 'shift_l' => :b,
52
+ 'shift_r' => :b,
53
+ 'z' => :x,
54
+ 'c' => :y,
55
+ 'return' => :start,
56
+ 'tab' => :back,
57
+ 'q' => :left_shoulder,
58
+ 'e' => :right_shoulder,
59
+ 'up' => :dpad_up,
60
+ 'down' => :dpad_down,
61
+ 'left' => :dpad_left,
62
+ 'right' => :dpad_right,
63
+ 'f' => :left_stick,
64
+ 'g' => :right_stick,
65
+ 'escape' => :guide,
66
+ }.freeze
67
+
68
+ # Keyboard → virtual analog stick mapping
69
+ # WASD → left stick, IJKL → right stick
70
+ KEY_MAP_AXES = {
71
+ 'w' => [:left_y, -GP::AXIS_MAX],
72
+ 's' => [:left_y, GP::AXIS_MAX],
73
+ 'a' => [:left_x, GP::AXIS_MIN],
74
+ 'd' => [:left_x, GP::AXIS_MAX],
75
+ 'i' => [:right_y, -GP::AXIS_MAX],
76
+ 'k' => [:right_y, GP::AXIS_MAX],
77
+ 'j' => [:right_x, GP::AXIS_MIN],
78
+ 'l' => [:right_x, GP::AXIS_MAX],
79
+ }.freeze
80
+
81
+ def initialize(calibrate: false)
82
+ @calibrate = calibrate
83
+ @app = Teek::App.new(title: calibrate ? 'Gamepad Viewer — CALIBRATE' : 'Gamepad Viewer')
84
+ @gamepads = [] # [[display_name, :virtual | device_index], ...]
85
+ @current_gp = nil # opened Gamepad instance
86
+ @current_mode = nil # :virtual or device index
87
+ @keys_held = {} # keysym → true (for virtual stick axes)
88
+ @var_counter = 0 # unique Tcl variable names
89
+ @prev_buttons = {} # btn → pressed (skip UI update when unchanged)
90
+ @prev_axes = {} # ax → value
91
+
92
+ GP.init_subsystem
93
+
94
+ build_ui
95
+ unless @calibrate
96
+ refresh_gamepad_list
97
+ start_poll_loop
98
+ end
99
+ @app.show
100
+ end
101
+
102
+ def run
103
+ @app.mainloop
104
+ ensure
105
+ @poll_timer&.cancel
106
+ @current_gp&.close unless @current_gp&.closed?
107
+ GP.detach_virtual
108
+ GP.shutdown_subsystem
109
+ end
110
+
111
+ private
112
+
113
+ # ── UI construction ────────────────────────────────────────────────────────
114
+
115
+ def build_ui
116
+ # Top bar: gamepad selector
117
+ top = @app.create_widget('ttk::frame')
118
+ top.pack(fill: :x, padx: 8, pady: 4)
119
+
120
+ @app.create_widget('ttk::label', parent: top, text: 'Gamepad:')
121
+ .pack(side: :left)
122
+
123
+ # Tcl variable must exist before combobox references it via -textvariable
124
+ @combo_var = next_var
125
+ set_var(@combo_var, '')
126
+ @combo = @app.create_widget('ttk::combobox', parent: top,
127
+ textvariable: @combo_var,
128
+ state: :readonly, width: 35)
129
+ @combo.pack(side: :left, padx: 4)
130
+ @combo.bind('<<ComboboxSelected>>') { on_gamepad_selected }
131
+
132
+ @app.create_widget('ttk::button', parent: top, text: 'Refresh',
133
+ command: proc { refresh_gamepad_list })
134
+ .pack(side: :left, padx: 4)
135
+
136
+ # Main area: canvas (left) + info panel (right)
137
+ main = @app.create_widget('ttk::frame')
138
+ main.pack(fill: :both, expand: true, padx: 8, pady: 4)
139
+
140
+ build_canvas(main)
141
+ build_info_panel(main)
142
+
143
+ # Status bar (left = status text, right = mouse coords on canvas)
144
+ status_frame = @app.create_widget('ttk::frame')
145
+ status_frame.pack(fill: :x, side: :bottom, padx: 8, pady: 4)
146
+
147
+ @status_var = next_var
148
+ set_var(@status_var, 'Select a gamepad to begin')
149
+ @app.create_widget('ttk::label', parent: status_frame,
150
+ textvariable: @status_var, anchor: :w)
151
+ .pack(fill: :x, side: :left, expand: true)
152
+
153
+ @mouse_var = next_var
154
+ set_var(@mouse_var, 'x: — y: —')
155
+ @app.create_widget('ttk::label', parent: status_frame,
156
+ textvariable: @mouse_var, anchor: :e,
157
+ width: 16, font: 'TkFixedFont')
158
+ .pack(side: :right, padx: [4, 0])
159
+
160
+ # Keyboard bindings for virtual mode (skip in calibrate — it uses its own)
161
+ unless @calibrate
162
+ @app.bind('all', 'KeyPress', :keysym) { |k| on_key_press(k) }
163
+ @app.bind('all', 'KeyRelease', :keysym) { |k| on_key_release(k) }
164
+ end
165
+ end
166
+
167
+ def build_canvas(parent)
168
+ @canvas = @app.create_widget('canvas', parent: parent,
169
+ width: 479, height: 310,
170
+ background: '#2b2b2b', highlightthickness: 0)
171
+ @canvas.pack(side: :left, padx: [0, 8])
172
+
173
+ # Load controller image
174
+ img_path = File.join(__dir__, 'assets', 'controller.png')
175
+ @controller_img = "gp_controller"
176
+ @app.command(:image, :create, :photo, @controller_img, file: img_path)
177
+ @canvas.command(:create, :image, 0, 0, anchor: :nw, image: @controller_img,
178
+ tag: :controller)
179
+
180
+ # Mouse coordinate tracking for tweaking button positions
181
+ @canvas.bind('Motion', :x, :y) do |x, y|
182
+ set_var(@mouse_var, "x:#{x} y:#{y}")
183
+ end
184
+
185
+ # Create highlight ovals for each button
186
+ @highlight_items = {}
187
+ @calibrate_pos = {} # live positions for calibration mode
188
+ BUTTON_POS.each do |btn, (cx, cy, r)|
189
+ color = button_highlight_color(btn)
190
+ tag = "hl_#{btn}"
191
+ @canvas.command(:create, :oval, cx - r, cy - r, cx + r, cy + r,
192
+ fill: color, outline: '', tag: tag,
193
+ state: @calibrate ? :normal : :hidden)
194
+ @highlight_items[btn] = tag
195
+ @calibrate_pos[btn] = [cx, cy, r]
196
+
197
+ if @calibrate
198
+ # Label each highlight so you know which is which
199
+ @canvas.command(:create, :text, cx, cy - r - 8,
200
+ text: btn.to_s, fill: '#ffffff',
201
+ font: 'TkSmallCaptionFont', tag: "lbl_#{btn}")
202
+ end
203
+ end
204
+
205
+ if @calibrate
206
+ setup_calibration_drag
207
+ end
208
+
209
+ # Stick position dots
210
+ @stick_dots = {}
211
+ STICK_CENTER.each do |side, (cx, cy)|
212
+ tag = "stick_#{side}"
213
+ r = 6
214
+ @canvas.command(:create, :oval, cx - r, cy - r, cx + r, cy + r,
215
+ fill: '#00ff88', outline: '#00cc66', width: 2, tag: tag)
216
+ @stick_dots[side] = tag
217
+ end
218
+ end
219
+
220
+ def build_info_panel(parent)
221
+ info = @app.create_widget('ttk::labelframe', parent: parent, text: 'State')
222
+ info.pack(side: :right, fill: :both, expand: true)
223
+
224
+ # Button indicators
225
+ btn_frame = @app.create_widget('ttk::labelframe', parent: info, text: 'Buttons')
226
+ btn_frame.pack(fill: :x, padx: 4, pady: 2)
227
+
228
+ @btn_labels = {}
229
+ GP.buttons.each_slice(4) do |row|
230
+ rf = @app.create_widget('ttk::frame', parent: btn_frame)
231
+ rf.pack(fill: :x)
232
+ row.each do |btn|
233
+ var = next_var
234
+ set_var(var, btn.to_s)
235
+ # Use plain label (not ttk) for background color support
236
+ lbl = @app.create_widget('label', parent: rf,
237
+ textvariable: var,
238
+ width: 12, relief: :groove,
239
+ anchor: :center, padx: 2, pady: 1)
240
+ lbl.pack(side: :left, padx: 1, pady: 1)
241
+ # Store default colors so we can restore them when button is released
242
+ default_bg = lbl.command(:cget, '-background')
243
+ default_fg = lbl.command(:cget, '-foreground')
244
+ @btn_labels[btn] = { var: var, label: lbl,
245
+ default_bg: default_bg, default_fg: default_fg }
246
+ end
247
+ end
248
+
249
+ # Axis readouts
250
+ axis_frame = @app.create_widget('ttk::labelframe', parent: info, text: 'Axes')
251
+ axis_frame.pack(fill: :x, padx: 4, pady: 2)
252
+
253
+ @axis_labels = {}
254
+ GP.axes.each do |ax|
255
+ rf = @app.create_widget('ttk::frame', parent: axis_frame)
256
+ rf.pack(fill: :x)
257
+ @app.create_widget('ttk::label', parent: rf, text: "#{ax}:",
258
+ width: 14, anchor: :e)
259
+ .pack(side: :left)
260
+ var = next_var
261
+ set_var(var, '0')
262
+ @app.create_widget('ttk::label', parent: rf, textvariable: var,
263
+ width: 8, anchor: :w, font: 'TkFixedFont')
264
+ .pack(side: :left, padx: 4)
265
+ @axis_labels[ax] = var
266
+ end
267
+
268
+ # Key help for virtual mode
269
+ help_frame = @app.create_widget('ttk::labelframe', parent: info,
270
+ text: 'Virtual Keys')
271
+ help_frame.pack(fill: :x, padx: 4, pady: 2)
272
+
273
+ help_text = "WASD: L-stick IJKL: R-stick\n" \
274
+ "Arrows: D-pad Space: A\n" \
275
+ "Shift: B Z: X C: Y\n" \
276
+ "Enter: Start Tab: Back\n" \
277
+ "Q/E: Shoulders F/G: Sticks"
278
+ @app.create_widget('ttk::label', parent: help_frame,
279
+ text: help_text, justify: :left)
280
+ .pack(padx: 4, pady: 2)
281
+ end
282
+
283
+ # ── Gamepad selection ──────────────────────────────────────────────────────
284
+
285
+ def refresh_gamepad_list
286
+ @gamepads = [['Virtual (Keyboard)', :virtual]]
287
+
288
+ # Probe device indices for connected gamepads
289
+ 8.times do |i|
290
+ gp = begin; GP.open(i); rescue; nil; end
291
+ next unless gp
292
+ @gamepads << [gp.name, i]
293
+ gp.close
294
+ end
295
+
296
+ values = @gamepads.map(&:first)
297
+ @combo.command(:configure, values: values)
298
+
299
+ # Select first if nothing selected
300
+ current = get_var(@combo_var)
301
+ if current.empty? || !values.include?(current)
302
+ set_var(@combo_var, values.first)
303
+ on_gamepad_selected
304
+ end
305
+ end
306
+
307
+ def on_gamepad_selected
308
+ name = get_var(@combo_var)
309
+ entry = @gamepads.find { |n, _| n == name }
310
+ return unless entry
311
+
312
+ _, mode = entry
313
+ switch_gamepad(mode)
314
+ end
315
+
316
+ def switch_gamepad(mode)
317
+ # Close current
318
+ @current_gp&.close unless @current_gp&.closed?
319
+ @current_gp = nil
320
+ GP.detach_virtual
321
+
322
+ @prev_buttons.clear
323
+ @prev_axes.clear
324
+
325
+ if mode == :virtual
326
+ idx = GP.attach_virtual
327
+ @current_gp = GP.open(idx)
328
+ @current_mode = :virtual
329
+ set_var(@status_var, "Virtual gamepad active \u2014 use keyboard")
330
+ else
331
+ begin
332
+ @current_gp = GP.open(mode)
333
+ @current_mode = mode
334
+ set_var(@status_var, "Connected: #{@current_gp.name}")
335
+ rescue => e
336
+ set_var(@status_var, "Error: #{e.message}")
337
+ @current_mode = nil
338
+ end
339
+ end
340
+ end
341
+
342
+ # ── Keyboard → Virtual gamepad ─────────────────────────────────────────────
343
+
344
+ def on_key_press(keysym)
345
+ return unless @current_mode == :virtual && @current_gp && !@current_gp.closed?
346
+
347
+ key = keysym.downcase
348
+
349
+ if (btn = KEY_MAP_BUTTONS[key])
350
+ @current_gp.set_virtual_button(btn, true)
351
+ end
352
+
353
+ if KEY_MAP_AXES[key]
354
+ @keys_held[key] = true
355
+ update_virtual_axes
356
+ end
357
+ end
358
+
359
+ def on_key_release(keysym)
360
+ return unless @current_mode == :virtual && @current_gp && !@current_gp.closed?
361
+
362
+ key = keysym.downcase
363
+
364
+ if (btn = KEY_MAP_BUTTONS[key])
365
+ @current_gp.set_virtual_button(btn, false)
366
+ end
367
+
368
+ if KEY_MAP_AXES[key]
369
+ @keys_held.delete(key)
370
+ update_virtual_axes
371
+ end
372
+ end
373
+
374
+ def update_virtual_axes
375
+ return unless @current_gp && !@current_gp.closed?
376
+
377
+ # Compute net axis values from held keys
378
+ axes = Hash.new(0)
379
+ @keys_held.each_key do |key|
380
+ ax, val = KEY_MAP_AXES[key]
381
+ axes[ax] = val
382
+ end
383
+
384
+ # Set each axis — reset to 0 if no key held for that axis
385
+ %i[left_x left_y right_x right_y].each do |ax|
386
+ @current_gp.set_virtual_axis(ax, axes[ax])
387
+ end
388
+ end
389
+
390
+ # ── Poll loop ──────────────────────────────────────────────────────────────
391
+
392
+ def start_poll_loop
393
+ @poll_timer = @app.every(50, on_error: ->(e) {
394
+ set_var(@status_var, "Poll error: #{e.message}")
395
+ }) {
396
+ GP.poll_events
397
+ if @current_gp && !@current_gp.closed?
398
+ update_buttons
399
+ update_axes
400
+ end
401
+ }
402
+ end
403
+
404
+ def update_buttons
405
+ GP.buttons.each do |btn|
406
+ pressed = @current_gp.button?(btn)
407
+ next if @prev_buttons[btn] == pressed
408
+
409
+ @prev_buttons[btn] = pressed
410
+ info = @btn_labels[btn]
411
+ next unless info
412
+
413
+ if pressed
414
+ set_var(info[:var], "[ #{btn} ]")
415
+ info[:label].command(:configure,
416
+ background: button_highlight_color(btn),
417
+ foreground: '#ffffff')
418
+ else
419
+ set_var(info[:var], btn.to_s)
420
+ info[:label].command(:configure,
421
+ background: info[:default_bg],
422
+ foreground: info[:default_fg])
423
+ end
424
+
425
+ tag = @highlight_items[btn]
426
+ @canvas.command(:itemconfigure, tag, state: pressed ? :normal : :hidden) if tag
427
+ end
428
+ end
429
+
430
+ def update_axes
431
+ GP.axes.each do |ax|
432
+ val = @current_gp.axis(ax)
433
+ next if @prev_axes[ax] == val
434
+
435
+ @prev_axes[ax] = val
436
+ set_var(@axis_labels[ax], val.to_s.rjust(6))
437
+ end
438
+
439
+ # Only move stick dots when their axes actually changed
440
+ lx = @current_gp.axis(:left_x); ly = @current_gp.axis(:left_y)
441
+ rx = @current_gp.axis(:right_x); ry = @current_gp.axis(:right_y)
442
+ move_stick_dot(:left, lx, ly) if @prev_axes[:left_x] != lx || @prev_axes[:left_y] != ly
443
+ move_stick_dot(:right, rx, ry) if @prev_axes[:right_x] != rx || @prev_axes[:right_y] != ry
444
+ end
445
+
446
+ def move_stick_dot(side, raw_x, raw_y)
447
+ cx, cy = STICK_CENTER[side]
448
+ max_offset = 15.0 # pixels of travel on screen
449
+ nx = raw_x.to_f / GP::AXIS_MAX
450
+ ny = raw_y.to_f / GP::AXIS_MAX
451
+ px = cx + (nx * max_offset)
452
+ py = cy + (ny * max_offset)
453
+ r = 6
454
+ @canvas.command(:coords, @stick_dots[side],
455
+ (px - r).round, (py - r).round,
456
+ (px + r).round, (py + r).round)
457
+ end
458
+
459
+ # ── Helpers ────────────────────────────────────────────────────────────────
460
+
461
+ def button_highlight_color(btn)
462
+ case btn
463
+ when :a then '#22cc44'
464
+ when :b then '#dd3333'
465
+ when :x then '#3388ee'
466
+ when :y then '#ee8822'
467
+ when :dpad_up, :dpad_down, :dpad_left, :dpad_right then '#44aaff'
468
+ when :left_shoulder, :right_shoulder then '#aa66dd'
469
+ when :left_stick, :right_stick then '#00ff88'
470
+ when :back, :start, :guide then '#ffaa22'
471
+ else '#cccccc'
472
+ end
473
+ end
474
+
475
+ # ── Calibration mode ────────────────────────────────────────────────────────
476
+
477
+ def setup_calibration_drag
478
+ @drag_btn = nil
479
+ @drag_offset = [0, 0]
480
+
481
+ @canvas.bind('ButtonPress-1', :x, :y) { |x, y| calibrate_press(x.to_i, y.to_i) }
482
+ @canvas.bind('B1-Motion', :x, :y) { |x, y| calibrate_drag(x.to_i, y.to_i) }
483
+ @canvas.bind('ButtonRelease-1') { @drag_btn = nil }
484
+
485
+ # Print final coordinates to console
486
+ @app.bind('all', 'KeyPress', :keysym) do |k|
487
+ print_calibrated_positions if k.downcase == 'return'
488
+ end
489
+
490
+ set_var(@status_var, 'Drag circles into position. Press Enter to print coordinates.')
491
+ end
492
+
493
+ def calibrate_press(mx, my)
494
+ # Find the closest button highlight to the click
495
+ @drag_btn = nil
496
+ best_dist = 999
497
+ @calibrate_pos.each do |btn, (cx, cy, _r)|
498
+ d = Math.sqrt((mx - cx)**2 + (my - cy)**2)
499
+ if d < best_dist
500
+ best_dist = d
501
+ @drag_btn = btn
502
+ @drag_offset = [mx - cx, my - cy]
503
+ end
504
+ end
505
+ @drag_btn = nil if best_dist > 40
506
+ end
507
+
508
+ def calibrate_drag(mx, my)
509
+ return unless @drag_btn
510
+
511
+ cx = mx - @drag_offset[0]
512
+ cy = my - @drag_offset[1]
513
+ _, _, r = @calibrate_pos[@drag_btn]
514
+ @calibrate_pos[@drag_btn] = [cx, cy, r]
515
+
516
+ tag = @highlight_items[@drag_btn]
517
+ @canvas.command(:coords, tag, cx - r, cy - r, cx + r, cy + r)
518
+ @canvas.command(:coords, "lbl_#{@drag_btn}", cx, cy - r - 8)
519
+ set_var(@mouse_var, "#{@drag_btn}: #{cx},#{cy}")
520
+ end
521
+
522
+ def print_calibrated_positions
523
+ puts "\n# Updated BUTTON_POS — paste into gamepad_viewer.rb"
524
+ puts "BUTTON_POS = {"
525
+ @calibrate_pos.each do |btn, (cx, cy, r)|
526
+ puts " %-17s [%3d, %3d, %2d]," % ["#{btn}:", cx, cy, r]
527
+ end
528
+ puts "}.freeze"
529
+
530
+ # Also print stick centers from the stick buttons
531
+ ls = @calibrate_pos[:left_stick]
532
+ rs = @calibrate_pos[:right_stick]
533
+ puts "\nSTICK_CENTER = {"
534
+ puts " left: [#{ls[0]}, #{ls[1]}],"
535
+ puts " right: [#{rs[0]}, #{rs[1]}],"
536
+ puts "}.freeze"
537
+ end
538
+
539
+ # Tcl variable helpers
540
+ def next_var
541
+ @var_counter += 1
542
+ "::gpv_#{@var_counter}"
543
+ end
544
+
545
+ def set_var(name, value)
546
+ @app.command(:set, name, value)
547
+ end
548
+
549
+ def get_var(name)
550
+ @app.command(:set, name)
551
+ end
552
+ end
553
+
554
+ GamepadViewer.new(calibrate: ARGV.include?('--calibrate')).run
Binary file