gemba 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 (65) hide show
  1. checksums.yaml +7 -0
  2. data/THIRD_PARTY_NOTICES +113 -0
  3. data/assets/JetBrainsMonoNL-Regular.ttf +0 -0
  4. data/assets/ark-pixel-12px-monospaced-ja.ttf +0 -0
  5. data/bin/gemba +14 -0
  6. data/ext/gemba/extconf.rb +185 -0
  7. data/ext/gemba/gemba_ext.c +1051 -0
  8. data/ext/gemba/gemba_ext.h +15 -0
  9. data/gemba.gemspec +38 -0
  10. data/lib/gemba/child_window.rb +62 -0
  11. data/lib/gemba/cli.rb +384 -0
  12. data/lib/gemba/config.rb +621 -0
  13. data/lib/gemba/core.rb +121 -0
  14. data/lib/gemba/headless.rb +12 -0
  15. data/lib/gemba/headless_player.rb +206 -0
  16. data/lib/gemba/hotkey_map.rb +202 -0
  17. data/lib/gemba/input_mappings.rb +214 -0
  18. data/lib/gemba/locale.rb +92 -0
  19. data/lib/gemba/locales/en.yml +157 -0
  20. data/lib/gemba/locales/ja.yml +157 -0
  21. data/lib/gemba/method_coverage_service.rb +265 -0
  22. data/lib/gemba/overlay_renderer.rb +109 -0
  23. data/lib/gemba/player.rb +1515 -0
  24. data/lib/gemba/recorder.rb +156 -0
  25. data/lib/gemba/recorder_decoder.rb +325 -0
  26. data/lib/gemba/rom_info_window.rb +346 -0
  27. data/lib/gemba/rom_loader.rb +100 -0
  28. data/lib/gemba/runtime.rb +39 -0
  29. data/lib/gemba/save_state_manager.rb +155 -0
  30. data/lib/gemba/save_state_picker.rb +199 -0
  31. data/lib/gemba/settings_window.rb +1173 -0
  32. data/lib/gemba/tip_service.rb +133 -0
  33. data/lib/gemba/toast_overlay.rb +128 -0
  34. data/lib/gemba/version.rb +5 -0
  35. data/lib/gemba.rb +17 -0
  36. data/test/fixtures/test.gba +0 -0
  37. data/test/fixtures/test.sav +0 -0
  38. data/test/shared/screenshot_helper.rb +113 -0
  39. data/test/shared/simplecov_config.rb +59 -0
  40. data/test/shared/teek_test_worker.rb +388 -0
  41. data/test/shared/tk_test_helper.rb +354 -0
  42. data/test/support/input_mocks.rb +61 -0
  43. data/test/support/player_helpers.rb +77 -0
  44. data/test/test_cli.rb +281 -0
  45. data/test/test_config.rb +897 -0
  46. data/test/test_core.rb +401 -0
  47. data/test/test_gamepad_map.rb +116 -0
  48. data/test/test_headless_player.rb +205 -0
  49. data/test/test_helper.rb +19 -0
  50. data/test/test_hotkey_map.rb +396 -0
  51. data/test/test_keyboard_map.rb +108 -0
  52. data/test/test_locale.rb +159 -0
  53. data/test/test_mgba.rb +26 -0
  54. data/test/test_overlay_renderer.rb +199 -0
  55. data/test/test_player.rb +903 -0
  56. data/test/test_recorder.rb +180 -0
  57. data/test/test_rom_loader.rb +149 -0
  58. data/test/test_save_state_manager.rb +289 -0
  59. data/test/test_settings_hotkeys.rb +434 -0
  60. data/test/test_settings_window.rb +1039 -0
  61. data/test/test_tip_service.rb +138 -0
  62. data/test/test_toast_overlay.rb +216 -0
  63. data/test/test_virtual_keyboard.rb +39 -0
  64. data/test/test_xor_delta.rb +61 -0
  65. metadata +234 -0
@@ -0,0 +1,1173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "child_window"
4
+ require_relative "hotkey_map"
5
+ require_relative "locale"
6
+ require_relative "tip_service"
7
+
8
+ module Gemba
9
+ # Settings window for the mGBA Player.
10
+ #
11
+ # Opens a Toplevel with a ttk::notebook containing Video, Audio, and
12
+ # Gamepad tabs. Closing the window hides it (withdraw) rather than
13
+ # destroying it.
14
+ #
15
+ # Widget paths and Tcl variable names are exposed as constants so tests
16
+ # can interact with the UI the same way a user would (set variable,
17
+ # generate event, assert result).
18
+ class SettingsWindow
19
+ include ChildWindow
20
+ include Locale::Translatable
21
+
22
+ TOP = ".mgba_settings"
23
+ NB = "#{TOP}.nb"
24
+
25
+ # Widget paths for test interaction
26
+ SCALE_COMBO = "#{NB}.video.scale_row.scale_combo"
27
+ TURBO_COMBO = "#{NB}.video.turbo_row.turbo_combo"
28
+ ASPECT_CHECK = "#{NB}.video.aspect_row.aspect"
29
+ SHOW_FPS_CHECK = "#{NB}.video.fps_row.fps_check"
30
+ TOAST_COMBO = "#{NB}.video.toast_row.toast_combo"
31
+ FILTER_COMBO = "#{NB}.video.filter_row.filter_combo"
32
+ INTEGER_SCALE_CHECK = "#{NB}.video.intscale_row.intscale"
33
+ COLOR_CORRECTION_CHECK = "#{NB}.video.colorcorr_row.colorcorr"
34
+ FRAME_BLENDING_CHECK = "#{NB}.video.frameblend_row.frameblend"
35
+ REWIND_CHECK = "#{NB}.video.rewind_row.rewind"
36
+ VOLUME_SCALE = "#{NB}.audio.vol_row.vol_scale"
37
+ MUTE_CHECK = "#{NB}.audio.mute_row.mute"
38
+
39
+ # Gamepad tab widget paths
40
+ GAMEPAD_TAB = "#{NB}.gamepad"
41
+ GAMEPAD_COMBO = "#{GAMEPAD_TAB}.gp_row.gp_combo"
42
+ DEADZONE_SCALE = "#{GAMEPAD_TAB}.dz_row.dz_scale"
43
+ GP_RESET_BTN = "#{GAMEPAD_TAB}.btn_bar.reset_btn"
44
+ GP_UNDO_BTN = "#{GAMEPAD_TAB}.btn_bar.undo_btn"
45
+
46
+ # GBA button widget paths (for remapping)
47
+ GP_BTN_A = "#{GAMEPAD_TAB}.row_a.btn"
48
+ GP_BTN_B = "#{GAMEPAD_TAB}.row_b.btn"
49
+ GP_BTN_L = "#{GAMEPAD_TAB}.row_l.btn"
50
+ GP_BTN_R = "#{GAMEPAD_TAB}.row_r.btn"
51
+ GP_BTN_UP = "#{GAMEPAD_TAB}.row_up.btn"
52
+ GP_BTN_DOWN = "#{GAMEPAD_TAB}.row_down.btn"
53
+ GP_BTN_LEFT = "#{GAMEPAD_TAB}.row_left.btn"
54
+ GP_BTN_RIGHT = "#{GAMEPAD_TAB}.row_right.btn"
55
+ GP_BTN_START = "#{GAMEPAD_TAB}.row_start.btn"
56
+ GP_BTN_SELECT = "#{GAMEPAD_TAB}.row_select.btn"
57
+
58
+ # Hotkeys tab widget paths
59
+ HK_TAB = "#{NB}.hotkeys"
60
+ HK_UNDO_BTN = "#{HK_TAB}.btn_bar.undo_btn"
61
+ HK_RESET_BTN = "#{HK_TAB}.btn_bar.reset_btn"
62
+
63
+ # Action → widget path mapping for hotkey buttons
64
+ HK_ACTIONS = {
65
+ quit: "#{HK_TAB}.row_quit.btn",
66
+ pause: "#{HK_TAB}.row_pause.btn",
67
+ fast_forward: "#{HK_TAB}.row_fast_forward.btn",
68
+ fullscreen: "#{HK_TAB}.row_fullscreen.btn",
69
+ show_fps: "#{HK_TAB}.row_show_fps.btn",
70
+ quick_save: "#{HK_TAB}.row_quick_save.btn",
71
+ quick_load: "#{HK_TAB}.row_quick_load.btn",
72
+ save_states: "#{HK_TAB}.row_save_states.btn",
73
+ screenshot: "#{HK_TAB}.row_screenshot.btn",
74
+ rewind: "#{HK_TAB}.row_rewind.btn",
75
+ record: "#{HK_TAB}.row_record.btn",
76
+ }.freeze
77
+
78
+ # Action → locale key mapping
79
+ HK_LOCALE_KEYS = {
80
+ quit: 'settings.hk_quit', pause: 'settings.hk_pause',
81
+ fast_forward: 'settings.hk_fast_forward', fullscreen: 'settings.hk_fullscreen',
82
+ show_fps: 'settings.hk_show_fps', quick_save: 'settings.hk_quick_save',
83
+ quick_load: 'settings.hk_quick_load', save_states: 'settings.hk_save_states',
84
+ screenshot: 'settings.hk_screenshot',
85
+ rewind: 'settings.hk_rewind',
86
+ record: 'settings.hk_record',
87
+ }.freeze
88
+
89
+ # GBA button → locale key mapping
90
+ GP_LOCALE_KEYS = {
91
+ a: 'settings.gp_a', b: 'settings.gp_b',
92
+ l: 'settings.gp_l', r: 'settings.gp_r',
93
+ up: 'settings.gp_up', down: 'settings.gp_down',
94
+ left: 'settings.gp_left', right: 'settings.gp_right',
95
+ start: 'settings.gp_start', select: 'settings.gp_select',
96
+ }.freeze
97
+
98
+ # Per-game settings bar (above notebook, shown/hidden based on active tab)
99
+ PER_GAME_BAR = "#{TOP}.per_game_bar"
100
+ PER_GAME_CHECK = "#{PER_GAME_BAR}.check"
101
+
102
+ # Recording tab widget paths
103
+ REC_TAB = "#{NB}.recording"
104
+ REC_COMPRESSION_COMBO = "#{REC_TAB}.comp_row.comp_combo"
105
+ REC_OPEN_DIR_BTN = "#{REC_TAB}.dir_row.open_btn"
106
+
107
+ # Save States tab widget paths
108
+ SS_TAB = "#{NB}.savestates"
109
+ SS_SLOT_COMBO = "#{SS_TAB}.slot_row.slot_combo"
110
+ SS_BACKUP_CHECK = "#{SS_TAB}.backup_row.backup_check"
111
+ SS_OPEN_DIR_BTN = "#{SS_TAB}.dir_row.open_btn"
112
+
113
+ # Bottom bar
114
+ SAVE_BTN = "#{TOP}.save_btn"
115
+
116
+ # Tcl variable names
117
+ VAR_PER_GAME = '::mgba_per_game'
118
+ VAR_SCALE = '::mgba_scale'
119
+ VAR_TURBO = '::mgba_turbo'
120
+ VAR_VOLUME = '::mgba_volume'
121
+ VAR_MUTE = '::mgba_mute'
122
+ VAR_GAMEPAD = '::mgba_gamepad'
123
+ VAR_DEADZONE = '::mgba_deadzone'
124
+ VAR_ASPECT_RATIO = '::mgba_aspect_ratio'
125
+ VAR_SHOW_FPS = '::mgba_show_fps'
126
+ VAR_TOAST_DURATION = '::mgba_toast_duration'
127
+ VAR_FILTER = '::mgba_filter'
128
+ VAR_INTEGER_SCALE = '::mgba_integer_scale'
129
+ VAR_COLOR_CORRECTION = '::mgba_color_correction'
130
+ VAR_FRAME_BLENDING = '::mgba_frame_blending'
131
+ VAR_REWIND_ENABLED = '::mgba_rewind_enabled'
132
+ VAR_QUICK_SLOT = '::mgba_quick_slot'
133
+ VAR_SS_BACKUP = '::mgba_ss_backup'
134
+ VAR_REC_COMPRESSION = '::mgba_rec_compression'
135
+ VAR_PAUSE_FOCUS = '::gemba_pause_focus_loss'
136
+
137
+ # GBA button → widget path mapping
138
+ GBA_BUTTONS = {
139
+ a: GP_BTN_A, b: GP_BTN_B,
140
+ l: GP_BTN_L, r: GP_BTN_R,
141
+ up: GP_BTN_UP, down: GP_BTN_DOWN,
142
+ left: GP_BTN_LEFT, right: GP_BTN_RIGHT,
143
+ start: GP_BTN_START, select: GP_BTN_SELECT,
144
+ }.freeze
145
+
146
+ # Default GBA → SDL gamepad mappings (display names)
147
+ DEFAULT_GP_LABELS = {
148
+ a: 'a', b: 'b',
149
+ l: 'left_shoulder', r: 'right_shoulder',
150
+ up: 'dpad_up', down: 'dpad_down',
151
+ left: 'dpad_left', right: 'dpad_right',
152
+ start: 'start', select: 'back',
153
+ }.freeze
154
+
155
+ # Default GBA → Tk keysym mappings (keyboard mode display names)
156
+ DEFAULT_KB_LABELS = {
157
+ a: 'z', b: 'x',
158
+ l: 'a', r: 's',
159
+ up: 'Up', down: 'Down',
160
+ left: 'Left', right: 'Right',
161
+ start: 'Return', select: 'BackSpace',
162
+ }.freeze
163
+
164
+ # @param app [Teek::App]
165
+ # @param callbacks [Hash] :on_scale_change, :on_volume_change, :on_mute_change,
166
+ # :on_gamepad_map_change, :on_deadzone_change
167
+ CALLBACK_DEFAULTS = {
168
+ on_validate_hotkey: ->(_) { nil },
169
+ on_validate_kb_mapping: ->(_) { nil },
170
+ }.freeze
171
+
172
+ def initialize(app, callbacks: {}, tip_dismiss_ms: TipService::DEFAULT_DISMISS_MS)
173
+ @app = app
174
+ @callbacks = CALLBACK_DEFAULTS.merge(callbacks)
175
+ @tip_dismiss_ms = tip_dismiss_ms
176
+ @listening_for = nil
177
+ @listen_timer = nil
178
+ @keyboard_mode = true
179
+ @per_game_enabled = false
180
+ @gp_labels = DEFAULT_KB_LABELS.dup
181
+ @hk_listening_for = nil
182
+ @hk_listen_timer = nil
183
+ @hk_labels = HotkeyMap::DEFAULTS.dup
184
+ @hk_pending_modifiers = Set.new
185
+ @hk_mod_timer = nil
186
+
187
+ build_toplevel(translate('menu.settings'), geometry: '700x560') { setup_ui }
188
+ end
189
+
190
+ # @return [Symbol, nil] the GBA button currently listening for remap, or nil
191
+ attr_reader :listening_for
192
+
193
+ # @return [Boolean] true when editing keyboard bindings, false for gamepad
194
+ def keyboard_mode?
195
+ @keyboard_mode
196
+ end
197
+
198
+ # @param tab [String, nil] widget path of the tab to select (e.g. SS_TAB)
199
+ def show(tab: nil)
200
+ @app.command(NB, 'select', tab) if tab
201
+ show_window
202
+ end
203
+
204
+ # Tab widget paths keyed by locale key (caller uses translate to get display name)
205
+ TABS = {
206
+ 'settings.video' => "#{NB}.video",
207
+ 'settings.audio' => "#{NB}.audio",
208
+ 'settings.gamepad' => GAMEPAD_TAB,
209
+ 'settings.hotkeys' => HK_TAB,
210
+ 'settings.recording' => REC_TAB,
211
+ 'settings.save_states' => SS_TAB,
212
+ }.freeze
213
+
214
+ # Tabs that show the per-game settings checkbox
215
+ PER_GAME_TABS = Set.new(["#{NB}.video", "#{NB}.audio", SS_TAB]).freeze
216
+
217
+ def hide
218
+ @tips&.hide
219
+ hide_window
220
+ end
221
+
222
+ def update_gamepad_list(names)
223
+ @app.command(GAMEPAD_COMBO, 'configure',
224
+ values: Teek.make_list(*names))
225
+ current = @app.get_variable(VAR_GAMEPAD)
226
+ unless names.include?(current)
227
+ @app.set_variable(VAR_GAMEPAD, names.first)
228
+ end
229
+ end
230
+
231
+ # Enable the Save button (called when any setting changes)
232
+ def mark_dirty
233
+ @app.command(SAVE_BTN, 'configure', state: :normal)
234
+ end
235
+
236
+ # Enable/disable the per-game checkbox (called when ROM loads/unloads).
237
+ def set_per_game_available(enabled)
238
+ @per_game_enabled = enabled
239
+ current = @app.command(NB, 'select') rescue nil
240
+ if enabled && PER_GAME_TABS.include?(current)
241
+ @app.command(PER_GAME_CHECK, 'configure', state: :normal)
242
+ else
243
+ @app.command(PER_GAME_CHECK, 'configure', state: :disabled)
244
+ end
245
+ end
246
+
247
+ # Sync the per-game checkbox to the current config state.
248
+ def set_per_game_active(active)
249
+ @app.set_variable(VAR_PER_GAME, active ? '1' : '0')
250
+ end
251
+
252
+ private
253
+
254
+ def do_save
255
+ @callbacks[:on_save]&.call
256
+ @app.command(SAVE_BTN, 'configure', state: :disabled)
257
+ end
258
+
259
+ def update_per_game_bar
260
+ current = @app.command(NB, 'select')
261
+ if PER_GAME_TABS.include?(current)
262
+ @app.command(PER_GAME_CHECK, 'configure', state: @per_game_enabled ? :normal : :disabled)
263
+ else
264
+ @app.command(PER_GAME_CHECK, 'configure', state: :disabled)
265
+ end
266
+ end
267
+
268
+ def setup_ui
269
+ # Bold button style for customized mappings
270
+ @app.tcl_eval("ttk::style configure Bold.TButton -font [list {*}[font actual TkDefaultFont] -weight bold]")
271
+
272
+ @tips = TipService.new(@app, parent: TOP, dismiss_ms: @tip_dismiss_ms)
273
+
274
+ # Per-game settings bar (above notebook, initially hidden)
275
+ @app.command('ttk::frame', PER_GAME_BAR)
276
+ @app.set_variable(VAR_PER_GAME, '0')
277
+ @app.command('ttk::checkbutton', PER_GAME_CHECK,
278
+ text: translate('settings.per_game'),
279
+ variable: VAR_PER_GAME,
280
+ state: :disabled,
281
+ command: proc { |*|
282
+ enabled = @app.get_variable(VAR_PER_GAME) == '1'
283
+ @callbacks[:on_per_game_toggle]&.call(enabled)
284
+ mark_dirty
285
+ })
286
+ @app.command(:pack, PER_GAME_CHECK, side: :left, padx: 5)
287
+
288
+ per_game_tip = "#{PER_GAME_BAR}.tip"
289
+ @app.command('ttk::label', per_game_tip, text: '(?)')
290
+ @app.command(:pack, per_game_tip, side: :left)
291
+ @tips.register(per_game_tip, translate('settings.tip_per_game'))
292
+
293
+ @app.command('ttk::notebook', NB)
294
+ @app.command(:pack, NB, fill: :both, expand: 1, padx: 5, pady: [5, 0])
295
+
296
+ setup_video_tab
297
+ setup_audio_tab
298
+ setup_gamepad_tab
299
+ setup_hotkeys_tab
300
+ setup_recording_tab
301
+ setup_save_states_tab
302
+
303
+ # Show/hide per-game bar based on active tab
304
+ @app.command(:bind, NB, '<<NotebookTabChanged>>', proc { update_per_game_bar })
305
+ # Show bar initially (video tab is default)
306
+ @app.command(:pack, PER_GAME_BAR, fill: :x, padx: 5, pady: [5, 0], before: NB)
307
+
308
+ # Save button — disabled until a setting changes
309
+ @app.command('ttk::button', SAVE_BTN, text: translate('settings.save'), state: :disabled,
310
+ command: proc { do_save })
311
+ @app.command(:pack, SAVE_BTN, side: :bottom, pady: [0, 8])
312
+ end
313
+
314
+ def setup_video_tab
315
+ frame = "#{NB}.video"
316
+ @app.command('ttk::frame', frame)
317
+ @app.command(NB, 'add', frame, text: translate('settings.video'))
318
+
319
+ # Window Scale
320
+ row = "#{frame}.scale_row"
321
+ @app.command('ttk::frame', row)
322
+ @app.command(:pack, row, fill: :x, padx: 10, pady: [15, 5])
323
+
324
+ @app.command('ttk::label', "#{row}.lbl", text: translate('settings.window_scale'))
325
+ @app.command(:pack, "#{row}.lbl", side: :left)
326
+
327
+ @app.set_variable(VAR_SCALE, '3x')
328
+ @app.command('ttk::combobox', SCALE_COMBO,
329
+ textvariable: VAR_SCALE,
330
+ values: Teek.make_list('1x', '2x', '3x', '4x'),
331
+ state: :readonly,
332
+ width: 5)
333
+ @app.command(:pack, SCALE_COMBO, side: :right)
334
+
335
+ @app.command(:bind, SCALE_COMBO, '<<ComboboxSelected>>',
336
+ proc { |*|
337
+ val = @app.get_variable(VAR_SCALE)
338
+ scale = val.to_i
339
+ if scale > 0
340
+ @callbacks[:on_scale_change]&.call(scale)
341
+ mark_dirty
342
+ end
343
+ })
344
+
345
+ # Turbo Speed
346
+ turbo_row = "#{frame}.turbo_row"
347
+ @app.command('ttk::frame', turbo_row)
348
+ @app.command(:pack, turbo_row, fill: :x, padx: 10, pady: 5)
349
+
350
+ @app.command('ttk::label', "#{turbo_row}.lbl", text: translate('settings.turbo_speed'))
351
+ @app.command(:pack, "#{turbo_row}.lbl", side: :left)
352
+ @tips.register("#{turbo_row}.lbl", translate('settings.tip_turbo_speed'))
353
+
354
+ @app.set_variable(VAR_TURBO, '2x')
355
+ @app.command('ttk::combobox', TURBO_COMBO,
356
+ textvariable: VAR_TURBO,
357
+ values: Teek.make_list('2x', '3x', '4x', translate('settings.uncapped')),
358
+ state: :readonly,
359
+ width: 10)
360
+ @app.command(:pack, TURBO_COMBO, side: :right)
361
+
362
+ @app.command(:bind, TURBO_COMBO, '<<ComboboxSelected>>',
363
+ proc { |*|
364
+ val = @app.get_variable(VAR_TURBO)
365
+ speed = val == translate('settings.uncapped') ? 0 : val.to_i
366
+ @callbacks[:on_turbo_speed_change]&.call(speed)
367
+ mark_dirty
368
+ })
369
+
370
+ # Aspect ratio checkbox
371
+ aspect_row = "#{frame}.aspect_row"
372
+ @app.command('ttk::frame', aspect_row)
373
+ @app.command(:pack, aspect_row, fill: :x, padx: 10, pady: 5)
374
+
375
+ @app.set_variable(VAR_ASPECT_RATIO, '1')
376
+ @app.command('ttk::checkbutton', ASPECT_CHECK,
377
+ text: translate('settings.maintain_aspect'),
378
+ variable: VAR_ASPECT_RATIO,
379
+ command: proc { |*|
380
+ keep = @app.get_variable(VAR_ASPECT_RATIO) == '1'
381
+ @callbacks[:on_aspect_ratio_change]&.call(keep)
382
+ mark_dirty
383
+ })
384
+ @app.command(:pack, ASPECT_CHECK, side: :left)
385
+
386
+ # Show FPS checkbox
387
+ fps_row = "#{frame}.fps_row"
388
+ @app.command('ttk::frame', fps_row)
389
+ @app.command(:pack, fps_row, fill: :x, padx: 10, pady: 5)
390
+
391
+ @app.set_variable(VAR_SHOW_FPS, '1')
392
+ @app.command('ttk::checkbutton', SHOW_FPS_CHECK,
393
+ text: translate('settings.show_fps'),
394
+ variable: VAR_SHOW_FPS,
395
+ command: proc { |*|
396
+ show = @app.get_variable(VAR_SHOW_FPS) == '1'
397
+ @callbacks[:on_show_fps_change]&.call(show)
398
+ mark_dirty
399
+ })
400
+ @app.command(:pack, SHOW_FPS_CHECK, side: :left)
401
+
402
+ # Pause on focus loss checkbox
403
+ pause_focus_row = "#{frame}.pause_focus_row"
404
+ @app.command('ttk::frame', pause_focus_row)
405
+ @app.command(:pack, pause_focus_row, fill: :x, padx: 10, pady: 5)
406
+
407
+ @app.set_variable(VAR_PAUSE_FOCUS, '1')
408
+ @app.command('ttk::checkbutton', "#{pause_focus_row}.check",
409
+ text: translate('settings.pause_on_focus_loss'),
410
+ variable: VAR_PAUSE_FOCUS,
411
+ command: proc { |*|
412
+ val = @app.get_variable(VAR_PAUSE_FOCUS) == '1'
413
+ @callbacks[:on_pause_on_focus_loss_change]&.call(val)
414
+ mark_dirty
415
+ })
416
+ @app.command(:pack, "#{pause_focus_row}.check", side: :left)
417
+
418
+ # Toast duration
419
+ toast_row = "#{frame}.toast_row"
420
+ @app.command('ttk::frame', toast_row)
421
+ @app.command(:pack, toast_row, fill: :x, padx: 10, pady: 5)
422
+
423
+ @app.command('ttk::label', "#{toast_row}.lbl", text: translate('settings.toast_duration'))
424
+ @app.command(:pack, "#{toast_row}.lbl", side: :left)
425
+ @tips.register("#{toast_row}.lbl", translate('settings.tip_toast_duration'))
426
+
427
+ @app.set_variable(VAR_TOAST_DURATION, '1.5s')
428
+ @app.command('ttk::combobox', TOAST_COMBO,
429
+ textvariable: VAR_TOAST_DURATION,
430
+ values: Teek.make_list('0.5s', '1s', '1.5s', '2s', '3s', '5s', '10s'),
431
+ state: :readonly,
432
+ width: 5)
433
+ @app.command(:pack, TOAST_COMBO, side: :right)
434
+
435
+ @app.command(:bind, TOAST_COMBO, '<<ComboboxSelected>>',
436
+ proc { |*|
437
+ val = @app.get_variable(VAR_TOAST_DURATION)
438
+ secs = val.to_f
439
+ if secs > 0
440
+ @callbacks[:on_toast_duration_change]&.call(secs)
441
+ mark_dirty
442
+ end
443
+ })
444
+
445
+ # Pixel Filter
446
+ filter_row = "#{frame}.filter_row"
447
+ @app.command('ttk::frame', filter_row)
448
+ @app.command(:pack, filter_row, fill: :x, padx: 10, pady: 5)
449
+
450
+ @app.command('ttk::label', "#{filter_row}.lbl", text: translate('settings.pixel_filter'))
451
+ @app.command(:pack, "#{filter_row}.lbl", side: :left)
452
+ @tips.register("#{filter_row}.lbl", translate('settings.tip_pixel_filter'))
453
+
454
+ @app.set_variable(VAR_FILTER, translate('settings.filter_nearest'))
455
+ @app.command('ttk::combobox', FILTER_COMBO,
456
+ textvariable: VAR_FILTER,
457
+ values: Teek.make_list(translate('settings.filter_nearest'), translate('settings.filter_linear')),
458
+ state: :readonly,
459
+ width: 18)
460
+ @app.command(:pack, FILTER_COMBO, side: :right)
461
+
462
+ @app.command(:bind, FILTER_COMBO, '<<ComboboxSelected>>',
463
+ proc { |*|
464
+ val = @app.get_variable(VAR_FILTER)
465
+ filter = val == translate('settings.filter_nearest') ? 'nearest' : 'linear'
466
+ @callbacks[:on_filter_change]&.call(filter)
467
+ mark_dirty
468
+ })
469
+
470
+ # Integer scaling checkbox
471
+ intscale_row = "#{frame}.intscale_row"
472
+ @app.command('ttk::frame', intscale_row)
473
+ @app.command(:pack, intscale_row, fill: :x, padx: 10, pady: 5)
474
+
475
+ @app.set_variable(VAR_INTEGER_SCALE, '0')
476
+ @app.command('ttk::checkbutton', INTEGER_SCALE_CHECK,
477
+ text: translate('settings.integer_scale'),
478
+ variable: VAR_INTEGER_SCALE,
479
+ command: proc { |*|
480
+ enabled = @app.get_variable(VAR_INTEGER_SCALE) == '1'
481
+ @callbacks[:on_integer_scale_change]&.call(enabled)
482
+ mark_dirty
483
+ })
484
+ @app.command(:pack, INTEGER_SCALE_CHECK, side: :left)
485
+ intscale_tip = "#{intscale_row}.tip"
486
+ @app.command('ttk::label', intscale_tip, text: '(?)')
487
+ @app.command(:pack, intscale_tip, side: :left)
488
+ @tips.register(intscale_tip, translate('settings.tip_integer_scale'))
489
+
490
+ # Color correction checkbox
491
+ colorcorr_row = "#{frame}.colorcorr_row"
492
+ @app.command('ttk::frame', colorcorr_row)
493
+ @app.command(:pack, colorcorr_row, fill: :x, padx: 10, pady: 5)
494
+
495
+ @app.set_variable(VAR_COLOR_CORRECTION, '0')
496
+ @app.command('ttk::checkbutton', COLOR_CORRECTION_CHECK,
497
+ text: translate('settings.color_correction'),
498
+ variable: VAR_COLOR_CORRECTION,
499
+ command: proc { |*|
500
+ enabled = @app.get_variable(VAR_COLOR_CORRECTION) == '1'
501
+ @callbacks[:on_color_correction_change]&.call(enabled)
502
+ mark_dirty
503
+ })
504
+ @app.command(:pack, COLOR_CORRECTION_CHECK, side: :left)
505
+ colorcorr_tip = "#{colorcorr_row}.tip"
506
+ @app.command('ttk::label', colorcorr_tip, text: '(?)')
507
+ @app.command(:pack, colorcorr_tip, side: :left)
508
+ @tips.register(colorcorr_tip, translate('settings.tip_color_correction'))
509
+
510
+ # Frame blending checkbox
511
+ frameblend_row = "#{frame}.frameblend_row"
512
+ @app.command('ttk::frame', frameblend_row)
513
+ @app.command(:pack, frameblend_row, fill: :x, padx: 10, pady: 5)
514
+
515
+ @app.set_variable(VAR_FRAME_BLENDING, '0')
516
+ @app.command('ttk::checkbutton', FRAME_BLENDING_CHECK,
517
+ text: translate('settings.frame_blending'),
518
+ variable: VAR_FRAME_BLENDING,
519
+ command: proc { |*|
520
+ enabled = @app.get_variable(VAR_FRAME_BLENDING) == '1'
521
+ @callbacks[:on_frame_blending_change]&.call(enabled)
522
+ mark_dirty
523
+ })
524
+ @app.command(:pack, FRAME_BLENDING_CHECK, side: :left)
525
+ frameblend_tip = "#{frameblend_row}.tip"
526
+ @app.command('ttk::label', frameblend_tip, text: '(?)')
527
+ @app.command(:pack, frameblend_tip, side: :left)
528
+ @tips.register(frameblend_tip, translate('settings.tip_frame_blending'))
529
+
530
+ # Rewind checkbox
531
+ rewind_row = "#{frame}.rewind_row"
532
+ @app.command('ttk::frame', rewind_row)
533
+ @app.command(:pack, rewind_row, fill: :x, padx: 10, pady: 5)
534
+
535
+ @app.set_variable(VAR_REWIND_ENABLED, '1')
536
+ @app.command('ttk::checkbutton', REWIND_CHECK,
537
+ text: translate('settings.rewind'),
538
+ variable: VAR_REWIND_ENABLED,
539
+ command: proc { |*|
540
+ enabled = @app.get_variable(VAR_REWIND_ENABLED) == '1'
541
+ @callbacks[:on_rewind_toggle]&.call(enabled)
542
+ mark_dirty
543
+ })
544
+ @app.command(:pack, REWIND_CHECK, side: :left)
545
+ rewind_tip = "#{rewind_row}.tip"
546
+ @app.command('ttk::label', rewind_tip, text: '(?)')
547
+ @app.command(:pack, rewind_tip, side: :left)
548
+ @tips.register(rewind_tip, translate('settings.tip_rewind'))
549
+ end
550
+
551
+ def setup_audio_tab
552
+ frame = "#{NB}.audio"
553
+ @app.command('ttk::frame', frame)
554
+ @app.command(NB, 'add', frame, text: translate('settings.audio'))
555
+
556
+ # Volume slider
557
+ vol_row = "#{frame}.vol_row"
558
+ @app.command('ttk::frame', vol_row)
559
+ @app.command(:pack, vol_row, fill: :x, padx: 10, pady: [15, 5])
560
+
561
+ @app.command('ttk::label', "#{vol_row}.lbl", text: translate('settings.volume'))
562
+ @app.command(:pack, "#{vol_row}.lbl", side: :left)
563
+
564
+ @vol_val_label = "#{vol_row}.vol_label"
565
+ @app.command('ttk::label', @vol_val_label, text: '100%', width: 5)
566
+ @app.command(:pack, @vol_val_label, side: :right)
567
+
568
+ @app.set_variable(VAR_VOLUME, '100')
569
+ @app.command('ttk::scale', VOLUME_SCALE,
570
+ orient: :horizontal,
571
+ from: 0,
572
+ to: 100,
573
+ length: 150,
574
+ variable: VAR_VOLUME,
575
+ command: proc { |v, *|
576
+ pct = v.to_f.round
577
+ @app.command(@vol_val_label, 'configure', text: "#{pct}%")
578
+ @callbacks[:on_volume_change]&.call(pct / 100.0)
579
+ mark_dirty
580
+ })
581
+ @app.command(:pack, VOLUME_SCALE, side: :right, padx: [5, 5])
582
+
583
+ # Mute checkbox
584
+ mute_row = "#{frame}.mute_row"
585
+ @app.command('ttk::frame', mute_row)
586
+ @app.command(:pack, mute_row, fill: :x, padx: 10, pady: 5)
587
+
588
+ @app.set_variable(VAR_MUTE, '0')
589
+ @app.command('ttk::checkbutton', MUTE_CHECK,
590
+ text: translate('settings.mute'),
591
+ variable: VAR_MUTE,
592
+ command: proc { |*|
593
+ muted = @app.get_variable(VAR_MUTE) == '1'
594
+ @callbacks[:on_mute_change]&.call(muted)
595
+ mark_dirty
596
+ })
597
+ @app.command(:pack, MUTE_CHECK, side: :left)
598
+ end
599
+ def setup_gamepad_tab
600
+ frame = GAMEPAD_TAB
601
+ @app.command('ttk::frame', frame)
602
+ @app.command(NB, 'add', frame, text: translate('settings.gamepad'))
603
+
604
+ # Gamepad selector row
605
+ gp_row = "#{frame}.gp_row"
606
+ @app.command('ttk::frame', gp_row)
607
+ @app.command(:pack, gp_row, fill: :x, padx: 10, pady: [8, 4])
608
+
609
+ @app.command('ttk::label', "#{gp_row}.lbl", text: translate('settings.gamepad') + ':')
610
+ @app.command(:pack, "#{gp_row}.lbl", side: :left)
611
+
612
+ @app.set_variable(VAR_GAMEPAD, translate('settings.keyboard_only'))
613
+ @app.command('ttk::combobox', GAMEPAD_COMBO,
614
+ textvariable: VAR_GAMEPAD, state: :readonly, width: 20)
615
+ @app.command(:pack, GAMEPAD_COMBO, side: :left, padx: 4)
616
+ @app.command(GAMEPAD_COMBO, 'configure',
617
+ values: Teek.make_list(translate('settings.keyboard_only')))
618
+
619
+ @app.command(:bind, GAMEPAD_COMBO, '<<ComboboxSelected>>',
620
+ proc { |*| switch_input_mode })
621
+
622
+ # GBA button rows (vertical list, matching hotkeys tab style)
623
+ GBA_BUTTONS.each do |gba_btn, btn_path|
624
+ row = "#{frame}.row_#{gba_btn}"
625
+ @app.command('ttk::frame', row)
626
+ @app.command(:pack, row, fill: :x, padx: 10, pady: 2)
627
+
628
+ lbl_path = "#{row}.lbl"
629
+ @app.command('ttk::label', lbl_path, text: translate(GP_LOCALE_KEYS[gba_btn]), width: 14, anchor: :w)
630
+ @app.command(:pack, lbl_path, side: :left)
631
+
632
+ @app.command('ttk::button', btn_path, text: btn_display(gba_btn), width: 12,
633
+ style: gp_customized?(gba_btn) ? 'Bold.TButton' : 'TButton',
634
+ command: proc { start_listening(gba_btn) })
635
+ @app.command(:pack, btn_path, side: :right)
636
+ end
637
+
638
+ # Bottom bar: Undo (left) | Reset to Defaults (right)
639
+ btn_bar = "#{frame}.btn_bar"
640
+ @app.command('ttk::frame', btn_bar)
641
+ @app.command(:pack, btn_bar, fill: :x, side: :bottom, padx: 10, pady: [4, 8])
642
+
643
+ @app.command('ttk::button', GP_UNDO_BTN, text: translate('settings.undo'),
644
+ state: :disabled, command: proc { do_undo_gamepad })
645
+ @app.command(:pack, GP_UNDO_BTN, side: :left)
646
+
647
+ @app.command('ttk::button', GP_RESET_BTN, text: translate('settings.reset_defaults'),
648
+ command: proc { confirm_reset_gamepad })
649
+ @app.command(:pack, GP_RESET_BTN, side: :right)
650
+
651
+ # Dead zone slider (disabled in keyboard mode)
652
+ dz_row = "#{frame}.dz_row"
653
+ @app.command('ttk::frame', dz_row)
654
+ @app.command(:pack, dz_row, fill: :x, padx: 10, pady: [4, 8], side: :bottom)
655
+
656
+ @app.command('ttk::label', "#{dz_row}.lbl", text: translate('settings.dead_zone'))
657
+ @app.command(:pack, "#{dz_row}.lbl", side: :left)
658
+ @tips.register("#{dz_row}.lbl", translate('settings.tip_dead_zone'))
659
+
660
+ @dz_val_label = "#{dz_row}.dz_label"
661
+ @app.command('ttk::label', @dz_val_label, text: '25%', width: 5)
662
+ @app.command(:pack, @dz_val_label, side: :right)
663
+
664
+ @app.set_variable(VAR_DEADZONE, '25')
665
+ @app.command('ttk::scale', DEADZONE_SCALE,
666
+ orient: :horizontal, from: 0, to: 50, length: 150,
667
+ variable: VAR_DEADZONE,
668
+ command: proc { |v, *|
669
+ pct = v.to_f.round
670
+ @app.command(@dz_val_label, 'configure', text: "#{pct}%")
671
+ threshold = (pct / 100.0 * 32767).round
672
+ @callbacks[:on_deadzone_change]&.call(threshold)
673
+ mark_dirty
674
+ })
675
+ @app.command(:pack, DEADZONE_SCALE, side: :right, padx: [5, 5])
676
+
677
+ # Start in keyboard mode — dead zone disabled
678
+ set_deadzone_enabled(false)
679
+ end
680
+
681
+ def setup_hotkeys_tab
682
+ frame = HK_TAB
683
+ @app.command('ttk::frame', frame)
684
+ @app.command(NB, 'add', frame, text: translate('settings.hotkeys'))
685
+
686
+ # Scrollable list of action rows
687
+ HK_ACTIONS.each do |action, btn_path|
688
+ row = "#{frame}.row_#{action}"
689
+ @app.command('ttk::frame', row)
690
+ @app.command(:pack, row, fill: :x, padx: 10, pady: 2)
691
+
692
+ lbl_path = "#{row}.lbl"
693
+ @app.command('ttk::label', lbl_path, text: translate(HK_LOCALE_KEYS[action]), width: 14, anchor: :w)
694
+ @app.command(:pack, lbl_path, side: :left)
695
+
696
+ display = hk_display(action)
697
+ @app.command('ttk::button', btn_path, text: display, width: 12,
698
+ style: hk_customized?(action) ? 'Bold.TButton' : 'TButton',
699
+ command: proc { start_hk_listening(action) })
700
+ @app.command(:pack, btn_path, side: :right)
701
+ end
702
+
703
+ # Bottom bar: Undo (left) | Reset to Defaults (right)
704
+ btn_bar = "#{frame}.btn_bar"
705
+ @app.command('ttk::frame', btn_bar)
706
+ @app.command(:pack, btn_bar, fill: :x, side: :bottom, padx: 10, pady: [4, 8])
707
+
708
+ @app.command('ttk::button', HK_UNDO_BTN, text: translate('settings.undo'),
709
+ state: :disabled, command: proc { do_undo_hotkeys })
710
+ @app.command(:pack, HK_UNDO_BTN, side: :left)
711
+
712
+ @app.command('ttk::button', HK_RESET_BTN, text: translate('settings.hk_reset_defaults'),
713
+ command: proc { confirm_reset_hotkeys })
714
+ @app.command(:pack, HK_RESET_BTN, side: :right)
715
+ end
716
+
717
+ def setup_recording_tab
718
+ frame = REC_TAB
719
+ @app.command('ttk::frame', frame)
720
+ @app.command(NB, 'add', frame, text: translate('settings.recording'))
721
+
722
+ # Compression level
723
+ comp_row = "#{frame}.comp_row"
724
+ @app.command('ttk::frame', comp_row)
725
+ @app.command(:pack, comp_row, fill: :x, padx: 10, pady: [15, 5])
726
+
727
+ @app.command('ttk::label', "#{comp_row}.lbl", text: translate('settings.recording_compression'))
728
+ @app.command(:pack, "#{comp_row}.lbl", side: :left)
729
+
730
+ comp_tip = "#{comp_row}.tip"
731
+ @app.command('ttk::label', comp_tip, text: '(?)')
732
+ @app.command(:pack, comp_tip, side: :left)
733
+ @tips.register(comp_tip, translate('settings.tip_recording_compression'))
734
+
735
+ comp_values = (1..9).map(&:to_s)
736
+ @app.set_variable(VAR_REC_COMPRESSION, '1')
737
+ @app.command('ttk::combobox', REC_COMPRESSION_COMBO,
738
+ textvariable: VAR_REC_COMPRESSION,
739
+ values: Teek.make_list(*comp_values),
740
+ state: :readonly,
741
+ width: 5)
742
+ @app.command(:pack, REC_COMPRESSION_COMBO, side: :right)
743
+
744
+ @app.command(:bind, REC_COMPRESSION_COMBO, '<<ComboboxSelected>>',
745
+ proc { |*|
746
+ val = @app.get_variable(VAR_REC_COMPRESSION).to_i
747
+ if val >= 1 && val <= 9
748
+ @callbacks[:on_compression_change]&.call(val)
749
+ mark_dirty
750
+ end
751
+ })
752
+
753
+ # Open Recordings Folder button
754
+ dir_row = "#{frame}.dir_row"
755
+ @app.command('ttk::frame', dir_row)
756
+ @app.command(:pack, dir_row, fill: :x, padx: 10, pady: [15, 5])
757
+
758
+ @app.command('ttk::button', REC_OPEN_DIR_BTN,
759
+ text: translate('settings.open_recordings_folder'),
760
+ command: proc { @callbacks[:on_open_recordings_dir]&.call })
761
+ @app.command(:pack, REC_OPEN_DIR_BTN, side: :left)
762
+ end
763
+
764
+ def setup_save_states_tab
765
+ frame = SS_TAB
766
+ @app.command('ttk::frame', frame)
767
+ @app.command(NB, 'add', frame, text: translate('settings.save_states'))
768
+
769
+ # Quick Save Slot
770
+ slot_row = "#{frame}.slot_row"
771
+ @app.command('ttk::frame', slot_row)
772
+ @app.command(:pack, slot_row, fill: :x, padx: 10, pady: [15, 5])
773
+
774
+ @app.command('ttk::label', "#{slot_row}.lbl", text: translate('settings.quick_save_slot'))
775
+ @app.command(:pack, "#{slot_row}.lbl", side: :left)
776
+
777
+ slot_values = (1..10).map(&:to_s)
778
+ @app.set_variable(VAR_QUICK_SLOT, '1')
779
+ @app.command('ttk::combobox', SS_SLOT_COMBO,
780
+ textvariable: VAR_QUICK_SLOT,
781
+ values: Teek.make_list(*slot_values),
782
+ state: :readonly,
783
+ width: 5)
784
+ @app.command(:pack, SS_SLOT_COMBO, side: :right)
785
+
786
+ @app.command(:bind, SS_SLOT_COMBO, '<<ComboboxSelected>>',
787
+ proc { |*|
788
+ val = @app.get_variable(VAR_QUICK_SLOT).to_i
789
+ if val >= 1 && val <= 10
790
+ @callbacks[:on_quick_slot_change]&.call(val)
791
+ mark_dirty
792
+ end
793
+ })
794
+
795
+ # Backup rotation checkbox
796
+ backup_row = "#{frame}.backup_row"
797
+ @app.command('ttk::frame', backup_row)
798
+ @app.command(:pack, backup_row, fill: :x, padx: 10, pady: 5)
799
+
800
+ @app.set_variable(VAR_SS_BACKUP, '1')
801
+ @app.command('ttk::checkbutton', SS_BACKUP_CHECK,
802
+ text: translate('settings.keep_backup'),
803
+ variable: VAR_SS_BACKUP,
804
+ command: proc { |*|
805
+ enabled = @app.get_variable(VAR_SS_BACKUP) == '1'
806
+ @callbacks[:on_backup_change]&.call(enabled)
807
+ mark_dirty
808
+ })
809
+ @app.command(:pack, SS_BACKUP_CHECK, side: :left)
810
+ backup_tip = "#{backup_row}.tip"
811
+ @app.command('ttk::label', backup_tip, text: '(?)')
812
+ @app.command(:pack, backup_tip, side: :left)
813
+ @tips.register(backup_tip, translate('settings.tip_keep_backup'))
814
+
815
+ # Open Config Folder button
816
+ dir_row = "#{frame}.dir_row"
817
+ @app.command('ttk::frame', dir_row)
818
+ @app.command(:pack, dir_row, fill: :x, padx: 10, pady: [15, 5])
819
+
820
+ @app.command('ttk::button', SS_OPEN_DIR_BTN,
821
+ text: translate('settings.open_config_folder'),
822
+ command: proc { @callbacks[:on_open_config_dir]&.call })
823
+ @app.command(:pack, SS_OPEN_DIR_BTN, side: :left)
824
+ end
825
+
826
+ KEY_DISPLAY_LOCALE = {
827
+ 'Up' => 'settings.key_up', 'Down' => 'settings.key_down',
828
+ 'Left' => 'settings.key_left', 'Right' => 'settings.key_right',
829
+ }.freeze
830
+
831
+ def btn_display(gba_btn)
832
+ label = @gp_labels[gba_btn] || '?'
833
+ locale_key = KEY_DISPLAY_LOCALE[label]
834
+ locale_key ? translate(locale_key) : label
835
+ end
836
+
837
+ def gp_customized?(gba_btn)
838
+ defaults = @keyboard_mode ? DEFAULT_KB_LABELS : DEFAULT_GP_LABELS
839
+ @gp_labels[gba_btn] != defaults[gba_btn]
840
+ end
841
+
842
+ def hk_customized?(action)
843
+ @hk_labels[action] != HotkeyMap::DEFAULTS[action]
844
+ end
845
+
846
+ # Display-friendly text for a hotkey button.
847
+ def hk_display(action)
848
+ val = @hk_labels[action]
849
+ return '?' unless val
850
+ HotkeyMap.display_name(val)
851
+ end
852
+
853
+ # Update a mapping button's text and bold style.
854
+ def style_btn(widget, text, bold)
855
+ @app.command(widget, 'configure', text: text, style: bold ? 'Bold.TButton' : 'TButton')
856
+ end
857
+
858
+ def confirm_reset_gamepad
859
+ cancel_listening
860
+ confirmed = if @callbacks[:on_confirm_reset_gamepad]
861
+ @callbacks[:on_confirm_reset_gamepad].call
862
+ else
863
+ @app.command('tk_messageBox',
864
+ parent: TOP,
865
+ title: translate('dialog.reset_gamepad_title'),
866
+ message: translate('dialog.reset_gamepad_msg'),
867
+ type: :yesno,
868
+ icon: :question) == 'yes'
869
+ end
870
+ if confirmed
871
+ reset_gamepad_defaults
872
+ do_save
873
+ end
874
+ end
875
+
876
+ def reset_gamepad_defaults
877
+ @gp_labels = (@keyboard_mode ? DEFAULT_KB_LABELS : DEFAULT_GP_LABELS).dup
878
+ GBA_BUTTONS.each do |gba_btn, widget|
879
+ style_btn(widget, btn_display(gba_btn), false)
880
+ end
881
+ @app.command(DEADZONE_SCALE, 'set', 25) unless @keyboard_mode
882
+ @app.command(GP_UNDO_BTN, 'configure', state: :disabled)
883
+ if @keyboard_mode
884
+ @callbacks[:on_keyboard_reset]&.call
885
+ else
886
+ @callbacks[:on_gamepad_reset]&.call
887
+ end
888
+ end
889
+
890
+ def do_undo_gamepad
891
+ @callbacks[:on_undo_gamepad]&.call
892
+ @app.command(GP_UNDO_BTN, 'configure', state: :disabled)
893
+ end
894
+
895
+ def switch_input_mode
896
+ cancel_listening
897
+ selected = @app.get_variable(VAR_GAMEPAD)
898
+ @keyboard_mode = (selected == translate('settings.keyboard_only'))
899
+
900
+ if @keyboard_mode
901
+ @gp_labels = DEFAULT_KB_LABELS.dup
902
+ set_deadzone_enabled(false)
903
+ else
904
+ @gp_labels = DEFAULT_GP_LABELS.dup
905
+ set_deadzone_enabled(true)
906
+ end
907
+
908
+ GBA_BUTTONS.each do |gba_btn, widget|
909
+ style_btn(widget, btn_display(gba_btn), false)
910
+ end
911
+
912
+ @app.command(GP_UNDO_BTN, 'configure', state: :disabled)
913
+ @callbacks[:on_input_mode_change]&.call(@keyboard_mode, selected)
914
+ end
915
+
916
+ def set_deadzone_enabled(enabled)
917
+ state = enabled ? :normal : :disabled
918
+ @app.command(DEADZONE_SCALE, 'configure', state: state)
919
+ end
920
+
921
+ LISTEN_TIMEOUT_MS = 10_000
922
+ MODIFIER_SETTLE_MS = 600
923
+
924
+ def start_listening(gba_btn)
925
+ cancel_listening
926
+ @listening_for = gba_btn
927
+ widget = GBA_BUTTONS[gba_btn]
928
+ @app.command(widget, 'configure', text: translate('settings.press'))
929
+ @listen_timer = @app.after(LISTEN_TIMEOUT_MS) { cancel_listening }
930
+
931
+ if @keyboard_mode
932
+ # Use tcl_eval directly because Teek's command() wraps each arg in
933
+ # braces, which breaks Tk event substitutions like %K in bind scripts.
934
+ cb_id = @app.interp.register_callback(
935
+ proc { |keysym, *| capture_mapping(keysym) })
936
+ @app.tcl_eval("bind #{TOP} <Key> {ruby_callback #{cb_id} %K}")
937
+ end
938
+ end
939
+
940
+ def cancel_listening
941
+ if @listen_timer
942
+ @app.command(:after, :cancel, @listen_timer)
943
+ @listen_timer = nil
944
+ end
945
+ if @listening_for
946
+ unbind_keyboard_listen
947
+ widget = GBA_BUTTONS[@listening_for]
948
+ style_btn(widget, btn_display(@listening_for), gp_customized?(@listening_for))
949
+ @listening_for = nil
950
+ end
951
+ end
952
+
953
+ def unbind_keyboard_listen
954
+ @app.tcl_eval("bind #{TOP} <Key> {}")
955
+ end
956
+
957
+ # Called by the player's poll loop when a gamepad button is detected
958
+ # during listen mode.
959
+ public
960
+
961
+ # Refresh the gamepad tab widgets from external state (e.g. after undo).
962
+ # @param labels [Hash{Symbol => String}] GBA button → gamepad button name
963
+ # @param dead_zone [Integer] dead zone percentage (0-50)
964
+ def refresh_gamepad(labels, dead_zone)
965
+ @gp_labels = labels.dup
966
+ GBA_BUTTONS.each do |gba_btn, widget|
967
+ style_btn(widget, btn_display(gba_btn), gp_customized?(gba_btn))
968
+ end
969
+ @app.command(DEADZONE_SCALE, 'set', dead_zone)
970
+ end
971
+
972
+ def capture_mapping(button)
973
+ return unless @listening_for
974
+
975
+ # In keyboard mode, reject keys that conflict with hotkeys
976
+ if @keyboard_mode
977
+ error = @callbacks[:on_validate_kb_mapping].call(button.to_s)
978
+ if error
979
+ show_key_conflict(error)
980
+ cancel_listening
981
+ return
982
+ end
983
+ end
984
+
985
+ if @listen_timer
986
+ @app.command(:after, :cancel, @listen_timer)
987
+ @listen_timer = nil
988
+ end
989
+ unbind_keyboard_listen
990
+
991
+ gba_btn = @listening_for
992
+ @gp_labels[gba_btn] = button.to_s
993
+ widget = GBA_BUTTONS[gba_btn]
994
+ style_btn(widget, btn_display(gba_btn), gp_customized?(gba_btn))
995
+ @listening_for = nil
996
+
997
+ if @keyboard_mode
998
+ @callbacks[:on_keyboard_map_change]&.call(gba_btn, button)
999
+ else
1000
+ @callbacks[:on_gamepad_map_change]&.call(gba_btn, button)
1001
+ end
1002
+ @app.command(GP_UNDO_BTN, 'configure', state: :normal)
1003
+ mark_dirty
1004
+ end
1005
+
1006
+ # Refresh the hotkeys tab widgets from external state (e.g. after undo).
1007
+ # @param labels [Hash{Symbol => String}] action → keysym
1008
+ def refresh_hotkeys(labels)
1009
+ @hk_labels = labels.dup
1010
+ HK_ACTIONS.each do |action, widget|
1011
+ style_btn(widget, hk_display(action), hk_customized?(action))
1012
+ end
1013
+ end
1014
+
1015
+ # @return [Symbol, nil] the hotkey action currently listening for remap
1016
+ attr_reader :hk_listening_for
1017
+
1018
+ # Capture a hotkey during listen mode. Called by the Tk <Key>
1019
+ # bind script, or directly by tests.
1020
+ #
1021
+ # Modifier keys (Ctrl, Shift, Alt) start a pending combo — if a
1022
+ # non-modifier key follows within MODIFIER_SETTLE_MS, the combo is
1023
+ # captured. If the timer expires, the modifier alone is captured.
1024
+ #
1025
+ # @param keysym [String] Tk keysym (e.g. "Control_L", "k")
1026
+ def capture_hk_mapping(keysym)
1027
+ return unless @hk_listening_for
1028
+
1029
+ mod = HotkeyMap.normalize_modifier(keysym)
1030
+ if mod
1031
+ # Modifier pressed — accumulate and wait for a non-modifier key
1032
+ @hk_pending_modifiers << mod
1033
+ cancel_mod_timer
1034
+ @hk_mod_timer = @app.after(MODIFIER_SETTLE_MS) { finalize_hk(keysym) }
1035
+ return
1036
+ end
1037
+
1038
+ # Non-modifier key arrived — normalize variant keysyms
1039
+ # (e.g. Shift+Tab produces ISO_Left_Tab on many platforms)
1040
+ keysym = HotkeyMap.normalize_keysym(keysym)
1041
+ cancel_mod_timer
1042
+ if @hk_pending_modifiers.any?
1043
+ hotkey = [*@hk_pending_modifiers.sort_by { |m| HotkeyMap::MODIFIER_ORDER.index(m) || 99 }, keysym]
1044
+ @hk_pending_modifiers.clear
1045
+ else
1046
+ hotkey = keysym
1047
+ end
1048
+
1049
+ finalize_hk(hotkey)
1050
+ end
1051
+
1052
+ # Finalize a captured hotkey (plain key or combo). Also called by
1053
+ # tests that want to bypass the modifier settle timer.
1054
+ # @param hotkey [String, Array]
1055
+ def finalize_hk(hotkey)
1056
+ return unless @hk_listening_for
1057
+ cancel_mod_timer
1058
+ @hk_pending_modifiers.clear
1059
+
1060
+ hotkey = HotkeyMap.normalize(hotkey)
1061
+
1062
+ # Reject hotkeys that conflict with keyboard gamepad mappings
1063
+ # (only plain keys can conflict — combos with modifiers are fine)
1064
+ unless hotkey.is_a?(Array)
1065
+ error = @callbacks[:on_validate_hotkey].call(hotkey.to_s)
1066
+ if error
1067
+ show_key_conflict(error)
1068
+ cancel_hk_listening
1069
+ return
1070
+ end
1071
+ end
1072
+
1073
+ if @hk_listen_timer
1074
+ @app.command(:after, :cancel, @hk_listen_timer)
1075
+ @hk_listen_timer = nil
1076
+ end
1077
+ unbind_keyboard_listen
1078
+
1079
+ action = @hk_listening_for
1080
+ @hk_labels[action] = hotkey
1081
+ widget = HK_ACTIONS[action]
1082
+ style_btn(widget, hk_display(action), hk_customized?(action))
1083
+ @hk_listening_for = nil
1084
+
1085
+ @callbacks[:on_hotkey_change]&.call(action, hotkey)
1086
+ @app.command(HK_UNDO_BTN, 'configure', state: :normal)
1087
+ mark_dirty
1088
+ end
1089
+
1090
+ private
1091
+
1092
+ def start_hk_listening(action)
1093
+ cancel_hk_listening
1094
+ @hk_listening_for = action
1095
+ widget = HK_ACTIONS[action]
1096
+ @app.command(widget, 'configure', text: translate('settings.press'))
1097
+ @hk_listen_timer = @app.after(LISTEN_TIMEOUT_MS) { cancel_hk_listening }
1098
+
1099
+ cb_id = @app.interp.register_callback(
1100
+ proc { |keysym, *| capture_hk_mapping(keysym) })
1101
+ @app.tcl_eval("bind #{TOP} <Key> {ruby_callback #{cb_id} %K}")
1102
+ end
1103
+
1104
+ def cancel_hk_listening
1105
+ cancel_mod_timer
1106
+ @hk_pending_modifiers.clear
1107
+ if @hk_listen_timer
1108
+ @app.command(:after, :cancel, @hk_listen_timer)
1109
+ @hk_listen_timer = nil
1110
+ end
1111
+ if @hk_listening_for
1112
+ unbind_keyboard_listen
1113
+ widget = HK_ACTIONS[@hk_listening_for]
1114
+ style_btn(widget, hk_display(@hk_listening_for), hk_customized?(@hk_listening_for))
1115
+ @hk_listening_for = nil
1116
+ end
1117
+ end
1118
+
1119
+ def cancel_mod_timer
1120
+ if @hk_mod_timer
1121
+ @app.command(:after, :cancel, @hk_mod_timer)
1122
+ @hk_mod_timer = nil
1123
+ end
1124
+ end
1125
+
1126
+ def show_key_conflict(message)
1127
+ if @callbacks[:on_key_conflict]
1128
+ @callbacks[:on_key_conflict].call(message)
1129
+ else
1130
+ @app.command('tk_messageBox',
1131
+ parent: TOP,
1132
+ title: translate('dialog.key_conflict_title'),
1133
+ message: message,
1134
+ type: :ok,
1135
+ icon: :warning)
1136
+ end
1137
+ end
1138
+
1139
+ def do_undo_hotkeys
1140
+ @callbacks[:on_undo_hotkeys]&.call
1141
+ @app.command(HK_UNDO_BTN, 'configure', state: :disabled)
1142
+ end
1143
+
1144
+ def confirm_reset_hotkeys
1145
+ cancel_hk_listening
1146
+ confirmed = if @callbacks[:on_confirm_reset_hotkeys]
1147
+ @callbacks[:on_confirm_reset_hotkeys].call
1148
+ else
1149
+ @app.command('tk_messageBox',
1150
+ parent: TOP,
1151
+ title: translate('dialog.reset_hotkeys_title'),
1152
+ message: translate('dialog.reset_hotkeys_msg'),
1153
+ type: :yesno,
1154
+ icon: :question) == 'yes'
1155
+ end
1156
+ if confirmed
1157
+ reset_hotkey_defaults
1158
+ do_save
1159
+ end
1160
+ end
1161
+
1162
+ def reset_hotkey_defaults
1163
+ cancel_hk_listening
1164
+ @hk_labels = HotkeyMap::DEFAULTS.dup
1165
+ HK_ACTIONS.each do |action, widget|
1166
+ style_btn(widget, hk_display(action), false)
1167
+ end
1168
+ @app.command(HK_UNDO_BTN, 'configure', state: :disabled)
1169
+ @callbacks[:on_hotkey_reset]&.call
1170
+ end
1171
+
1172
+ end
1173
+ end