gemba 0.1.1 → 0.2.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 (285) hide show
  1. checksums.yaml +4 -4
  2. data/THIRD_PARTY_NOTICES +37 -2
  3. data/assets/placeholder_boxart.png +0 -0
  4. data/bin/gemba +2 -2
  5. data/ext/gemba/extconf.rb +23 -1
  6. data/ext/gemba/gemba_ext.c +436 -2
  7. data/ext/gemba/gemba_ext.h +2 -0
  8. data/gemba.gemspec +5 -3
  9. data/lib/gemba/achievements/achievement.rb +23 -0
  10. data/lib/gemba/achievements/backend.rb +186 -0
  11. data/lib/gemba/achievements/cache.rb +70 -0
  12. data/lib/gemba/achievements/credentials_presenter.rb +142 -0
  13. data/lib/gemba/achievements/fake_backend.rb +205 -0
  14. data/lib/gemba/achievements/null_backend.rb +11 -0
  15. data/lib/gemba/achievements/offline_backend.rb +168 -0
  16. data/lib/gemba/achievements/retro_achievements/backend.rb +453 -0
  17. data/lib/gemba/achievements/retro_achievements/cli_sync_requester.rb +64 -0
  18. data/lib/gemba/achievements/retro_achievements/ping_worker.rb +27 -0
  19. data/lib/gemba/achievements.rb +19 -0
  20. data/lib/gemba/achievements_window.rb +556 -0
  21. data/lib/gemba/app_controller.rb +1015 -0
  22. data/lib/gemba/bios.rb +54 -0
  23. data/lib/gemba/boxart_fetcher/libretro_backend.rb +39 -0
  24. data/lib/gemba/boxart_fetcher/null_backend.rb +12 -0
  25. data/lib/gemba/boxart_fetcher.rb +79 -0
  26. data/lib/gemba/bus_emitter.rb +13 -0
  27. data/lib/gemba/child_window.rb +24 -1
  28. data/lib/gemba/cli/commands/config_cmd.rb +83 -0
  29. data/lib/gemba/cli/commands/decode.rb +154 -0
  30. data/lib/gemba/cli/commands/patch.rb +78 -0
  31. data/lib/gemba/cli/commands/play.rb +78 -0
  32. data/lib/gemba/cli/commands/record.rb +114 -0
  33. data/lib/gemba/cli/commands/replay.rb +161 -0
  34. data/lib/gemba/cli/commands/retro_achievements.rb +213 -0
  35. data/lib/gemba/cli/commands/version.rb +22 -0
  36. data/lib/gemba/cli.rb +52 -364
  37. data/lib/gemba/config.rb +134 -1
  38. data/lib/gemba/data/gb_games.json +1 -0
  39. data/lib/gemba/data/gb_md5.json +1 -0
  40. data/lib/gemba/data/gba_games.json +1 -0
  41. data/lib/gemba/data/gba_md5.json +1 -0
  42. data/lib/gemba/data/gbc_games.json +1 -0
  43. data/lib/gemba/data/gbc_md5.json +1 -0
  44. data/lib/gemba/emulator_frame.rb +1060 -0
  45. data/lib/gemba/event_bus.rb +48 -0
  46. data/lib/gemba/frame_stack.rb +60 -0
  47. data/lib/gemba/game_index.rb +84 -0
  48. data/lib/gemba/game_picker_frame.rb +268 -0
  49. data/lib/gemba/gamepad_map.rb +103 -0
  50. data/lib/gemba/headless.rb +6 -5
  51. data/lib/gemba/headless_player.rb +33 -3
  52. data/lib/gemba/help_window.rb +61 -0
  53. data/lib/gemba/hotkey_map.rb +3 -1
  54. data/lib/gemba/input_recorder.rb +107 -0
  55. data/lib/gemba/input_replayer.rb +119 -0
  56. data/lib/gemba/keyboard_map.rb +90 -0
  57. data/lib/gemba/locales/en.yml +97 -5
  58. data/lib/gemba/locales/ja.yml +97 -5
  59. data/lib/gemba/main_window.rb +56 -0
  60. data/lib/gemba/modal_stack.rb +81 -0
  61. data/lib/gemba/patcher_window.rb +223 -0
  62. data/lib/gemba/platform/gb.rb +21 -0
  63. data/lib/gemba/platform/gba.rb +21 -0
  64. data/lib/gemba/platform/gbc.rb +23 -0
  65. data/lib/gemba/platform.rb +20 -0
  66. data/lib/gemba/platform_open.rb +19 -0
  67. data/lib/gemba/recorder.rb +4 -3
  68. data/lib/gemba/replay_player.rb +691 -0
  69. data/lib/gemba/rom_info.rb +57 -0
  70. data/lib/gemba/rom_info_window.rb +16 -3
  71. data/lib/gemba/rom_library.rb +106 -0
  72. data/lib/gemba/rom_overrides.rb +47 -0
  73. data/lib/gemba/rom_patcher/bps.rb +161 -0
  74. data/lib/gemba/rom_patcher/ips.rb +101 -0
  75. data/lib/gemba/rom_patcher/ups.rb +118 -0
  76. data/lib/gemba/rom_patcher.rb +109 -0
  77. data/lib/gemba/{rom_loader.rb → rom_resolver.rb} +7 -6
  78. data/lib/gemba/runtime.rb +59 -26
  79. data/lib/gemba/save_state_manager.rb +4 -7
  80. data/lib/gemba/save_state_picker.rb +17 -4
  81. data/lib/gemba/session_logger.rb +64 -0
  82. data/lib/gemba/settings/audio_tab.rb +77 -0
  83. data/lib/gemba/settings/gamepad_tab.rb +351 -0
  84. data/lib/gemba/settings/hotkeys_tab.rb +259 -0
  85. data/lib/gemba/settings/paths.rb +11 -0
  86. data/lib/gemba/settings/recording_tab.rb +83 -0
  87. data/lib/gemba/settings/save_states_tab.rb +91 -0
  88. data/lib/gemba/settings/system_tab.rb +362 -0
  89. data/lib/gemba/settings/video_tab.rb +318 -0
  90. data/lib/gemba/settings_window.rb +162 -1036
  91. data/lib/gemba/version.rb +1 -1
  92. data/lib/gemba/virtual_keyboard.rb +19 -0
  93. data/lib/gemba.rb +2 -12
  94. data/test/achievements_window/test_bulk_sync.rb +218 -0
  95. data/test/achievements_window/test_bus_events.rb +125 -0
  96. data/test/achievements_window/test_close_confirmation.rb +201 -0
  97. data/test/achievements_window/test_initial_state.rb +164 -0
  98. data/test/achievements_window/test_sorting.rb +227 -0
  99. data/test/achievements_window/test_tree_rendering.rb +133 -0
  100. data/test/fixtures/fake_bios.bin +0 -0
  101. data/test/fixtures/pong.gba +0 -0
  102. data/test/fixtures/test.gb +0 -0
  103. data/test/fixtures/test.gbc +0 -0
  104. data/test/fixtures/test_quicksave.ss +0 -0
  105. data/test/screenshots/no_focus.png +0 -0
  106. data/test/shared/teek_test_worker.rb +17 -1
  107. data/test/shared/tk_test_helper.rb +91 -4
  108. data/test/support/achievements_window_helpers.rb +18 -0
  109. data/test/support/fake_core.rb +25 -0
  110. data/test/support/fake_ra_runtime.rb +74 -0
  111. data/test/support/fake_requester.rb +68 -0
  112. data/test/support/player_helpers.rb +20 -5
  113. data/test/test_achievement.rb +32 -0
  114. data/test/{test_player.rb → test_app_controller.rb} +353 -85
  115. data/test/test_bios.rb +123 -0
  116. data/test/test_boxart_fetcher.rb +150 -0
  117. data/test/test_cli.rb +17 -265
  118. data/test/test_cli_config.rb +64 -0
  119. data/test/test_cli_decode.rb +97 -0
  120. data/test/test_cli_patch.rb +58 -0
  121. data/test/test_cli_play.rb +213 -0
  122. data/test/test_cli_ra.rb +175 -0
  123. data/test/test_cli_record.rb +69 -0
  124. data/test/test_cli_replay.rb +72 -0
  125. data/test/test_cli_sync_requester.rb +152 -0
  126. data/test/test_cli_version.rb +27 -0
  127. data/test/test_config.rb +2 -3
  128. data/test/test_config_ra.rb +69 -0
  129. data/test/test_core.rb +62 -1
  130. data/test/test_credentials_presenter.rb +192 -0
  131. data/test/test_event_bus.rb +100 -0
  132. data/test/test_fake_backend_achievements.rb +130 -0
  133. data/test/test_fake_backend_auth.rb +68 -0
  134. data/test/test_game_index.rb +77 -0
  135. data/test/test_game_picker_frame.rb +310 -0
  136. data/test/test_gamepad_map.rb +1 -3
  137. data/test/test_headless_player.rb +17 -3
  138. data/test/test_help_window.rb +82 -0
  139. data/test/test_hotkey_map.rb +22 -1
  140. data/test/test_input_recorder.rb +179 -0
  141. data/test/test_input_replay_determinism.rb +113 -0
  142. data/test/test_input_replayer.rb +162 -0
  143. data/test/test_keyboard_map.rb +1 -3
  144. data/test/test_libretro_backend.rb +41 -0
  145. data/test/test_locale.rb +1 -1
  146. data/test/test_logging.rb +123 -0
  147. data/test/test_null_backend.rb +42 -0
  148. data/test/test_offline_backend.rb +116 -0
  149. data/test/test_overlay_renderer.rb +1 -1
  150. data/test/test_platform.rb +149 -0
  151. data/test/test_ra_backend.rb +313 -0
  152. data/test/test_ra_backend_unlock_gate.rb +56 -0
  153. data/test/test_recorder.rb +0 -3
  154. data/test/test_replay_player.rb +316 -0
  155. data/test/test_rom_info.rb +149 -0
  156. data/test/test_rom_overrides.rb +86 -0
  157. data/test/test_rom_patcher.rb +382 -0
  158. data/test/{test_rom_loader.rb → test_rom_resolver.rb} +25 -26
  159. data/test/test_save_state_manager.rb +2 -4
  160. data/test/test_settings_audio.rb +107 -0
  161. data/test/test_settings_hotkeys.rb +83 -66
  162. data/test/test_settings_recording.rb +49 -0
  163. data/test/test_settings_save_states.rb +97 -0
  164. data/test/test_settings_system.rb +133 -0
  165. data/test/test_settings_video.rb +450 -0
  166. data/test/test_settings_window.rb +76 -507
  167. data/test/test_tip_service.rb +6 -6
  168. data/test/test_toast_overlay.rb +1 -1
  169. data/test/test_virtual_events.rb +156 -0
  170. data/test/test_virtual_keyboard.rb +1 -1
  171. data/vendor/rcheevos/CHANGELOG.md +495 -0
  172. data/vendor/rcheevos/LICENSE +21 -0
  173. data/vendor/rcheevos/Package.swift +33 -0
  174. data/vendor/rcheevos/README.md +67 -0
  175. data/vendor/rcheevos/include/module.modulemap +70 -0
  176. data/vendor/rcheevos/include/rc_api_editor.h +296 -0
  177. data/vendor/rcheevos/include/rc_api_info.h +280 -0
  178. data/vendor/rcheevos/include/rc_api_request.h +77 -0
  179. data/vendor/rcheevos/include/rc_api_runtime.h +417 -0
  180. data/vendor/rcheevos/include/rc_api_user.h +262 -0
  181. data/vendor/rcheevos/include/rc_client.h +877 -0
  182. data/vendor/rcheevos/include/rc_client_raintegration.h +101 -0
  183. data/vendor/rcheevos/include/rc_consoles.h +138 -0
  184. data/vendor/rcheevos/include/rc_error.h +59 -0
  185. data/vendor/rcheevos/include/rc_export.h +100 -0
  186. data/vendor/rcheevos/include/rc_hash.h +200 -0
  187. data/vendor/rcheevos/include/rc_runtime.h +148 -0
  188. data/vendor/rcheevos/include/rc_runtime_types.h +452 -0
  189. data/vendor/rcheevos/include/rc_util.h +51 -0
  190. data/vendor/rcheevos/include/rcheevos.h +8 -0
  191. data/vendor/rcheevos/src/rapi/rc_api_common.c +1379 -0
  192. data/vendor/rcheevos/src/rapi/rc_api_common.h +88 -0
  193. data/vendor/rcheevos/src/rapi/rc_api_editor.c +625 -0
  194. data/vendor/rcheevos/src/rapi/rc_api_info.c +587 -0
  195. data/vendor/rcheevos/src/rapi/rc_api_runtime.c +901 -0
  196. data/vendor/rcheevos/src/rapi/rc_api_user.c +483 -0
  197. data/vendor/rcheevos/src/rc_client.c +6941 -0
  198. data/vendor/rcheevos/src/rc_client_external.c +281 -0
  199. data/vendor/rcheevos/src/rc_client_external.h +177 -0
  200. data/vendor/rcheevos/src/rc_client_external_versions.h +171 -0
  201. data/vendor/rcheevos/src/rc_client_internal.h +409 -0
  202. data/vendor/rcheevos/src/rc_client_raintegration.c +566 -0
  203. data/vendor/rcheevos/src/rc_client_raintegration_internal.h +61 -0
  204. data/vendor/rcheevos/src/rc_client_types.natvis +396 -0
  205. data/vendor/rcheevos/src/rc_compat.c +251 -0
  206. data/vendor/rcheevos/src/rc_compat.h +121 -0
  207. data/vendor/rcheevos/src/rc_libretro.c +915 -0
  208. data/vendor/rcheevos/src/rc_libretro.h +98 -0
  209. data/vendor/rcheevos/src/rc_util.c +199 -0
  210. data/vendor/rcheevos/src/rc_version.c +11 -0
  211. data/vendor/rcheevos/src/rc_version.h +32 -0
  212. data/vendor/rcheevos/src/rcheevos/alloc.c +312 -0
  213. data/vendor/rcheevos/src/rcheevos/condition.c +754 -0
  214. data/vendor/rcheevos/src/rcheevos/condset.c +777 -0
  215. data/vendor/rcheevos/src/rcheevos/consoleinfo.c +1215 -0
  216. data/vendor/rcheevos/src/rcheevos/format.c +330 -0
  217. data/vendor/rcheevos/src/rcheevos/lboard.c +287 -0
  218. data/vendor/rcheevos/src/rcheevos/memref.c +805 -0
  219. data/vendor/rcheevos/src/rcheevos/operand.c +607 -0
  220. data/vendor/rcheevos/src/rcheevos/rc_internal.h +390 -0
  221. data/vendor/rcheevos/src/rcheevos/rc_runtime_types.natvis +541 -0
  222. data/vendor/rcheevos/src/rcheevos/rc_validate.c +1406 -0
  223. data/vendor/rcheevos/src/rcheevos/rc_validate.h +18 -0
  224. data/vendor/rcheevos/src/rcheevos/richpresence.c +922 -0
  225. data/vendor/rcheevos/src/rcheevos/runtime.c +852 -0
  226. data/vendor/rcheevos/src/rcheevos/runtime_progress.c +1073 -0
  227. data/vendor/rcheevos/src/rcheevos/trigger.c +344 -0
  228. data/vendor/rcheevos/src/rcheevos/value.c +935 -0
  229. data/vendor/rcheevos/src/rhash/aes.c +480 -0
  230. data/vendor/rcheevos/src/rhash/aes.h +49 -0
  231. data/vendor/rcheevos/src/rhash/cdreader.c +838 -0
  232. data/vendor/rcheevos/src/rhash/hash.c +1402 -0
  233. data/vendor/rcheevos/src/rhash/hash_disc.c +1340 -0
  234. data/vendor/rcheevos/src/rhash/hash_encrypted.c +566 -0
  235. data/vendor/rcheevos/src/rhash/hash_rom.c +426 -0
  236. data/vendor/rcheevos/src/rhash/hash_zip.c +460 -0
  237. data/vendor/rcheevos/src/rhash/md5.c +382 -0
  238. data/vendor/rcheevos/src/rhash/md5.h +91 -0
  239. data/vendor/rcheevos/src/rhash/rc_hash_internal.h +116 -0
  240. data/vendor/rcheevos/test/libretro.h +205 -0
  241. data/vendor/rcheevos/test/rapi/test_rc_api_common.c +941 -0
  242. data/vendor/rcheevos/test/rapi/test_rc_api_editor.c +931 -0
  243. data/vendor/rcheevos/test/rapi/test_rc_api_info.c +545 -0
  244. data/vendor/rcheevos/test/rapi/test_rc_api_runtime.c +2213 -0
  245. data/vendor/rcheevos/test/rapi/test_rc_api_user.c +998 -0
  246. data/vendor/rcheevos/test/rcheevos/mock_memory.h +32 -0
  247. data/vendor/rcheevos/test/rcheevos/test_condition.c +570 -0
  248. data/vendor/rcheevos/test/rcheevos/test_condset.c +5170 -0
  249. data/vendor/rcheevos/test/rcheevos/test_consoleinfo.c +203 -0
  250. data/vendor/rcheevos/test/rcheevos/test_format.c +112 -0
  251. data/vendor/rcheevos/test/rcheevos/test_lboard.c +746 -0
  252. data/vendor/rcheevos/test/rcheevos/test_memref.c +520 -0
  253. data/vendor/rcheevos/test/rcheevos/test_operand.c +692 -0
  254. data/vendor/rcheevos/test/rcheevos/test_rc_validate.c +502 -0
  255. data/vendor/rcheevos/test/rcheevos/test_richpresence.c +1564 -0
  256. data/vendor/rcheevos/test/rcheevos/test_runtime.c +1667 -0
  257. data/vendor/rcheevos/test/rcheevos/test_runtime_progress.c +1821 -0
  258. data/vendor/rcheevos/test/rcheevos/test_timing.c +166 -0
  259. data/vendor/rcheevos/test/rcheevos/test_trigger.c +2521 -0
  260. data/vendor/rcheevos/test/rcheevos/test_value.c +870 -0
  261. data/vendor/rcheevos/test/rcheevos-test.sln +46 -0
  262. data/vendor/rcheevos/test/rcheevos-test.vcxproj +239 -0
  263. data/vendor/rcheevos/test/rcheevos-test.vcxproj.filters +335 -0
  264. data/vendor/rcheevos/test/rhash/data.c +657 -0
  265. data/vendor/rcheevos/test/rhash/data.h +32 -0
  266. data/vendor/rcheevos/test/rhash/mock_filereader.c +236 -0
  267. data/vendor/rcheevos/test/rhash/mock_filereader.h +31 -0
  268. data/vendor/rcheevos/test/rhash/test_cdreader.c +920 -0
  269. data/vendor/rcheevos/test/rhash/test_hash.c +310 -0
  270. data/vendor/rcheevos/test/rhash/test_hash_disc.c +1450 -0
  271. data/vendor/rcheevos/test/rhash/test_hash_rom.c +899 -0
  272. data/vendor/rcheevos/test/rhash/test_hash_zip.c +551 -0
  273. data/vendor/rcheevos/test/test.c +113 -0
  274. data/vendor/rcheevos/test/test_framework.h +205 -0
  275. data/vendor/rcheevos/test/test_rc_client.c +10509 -0
  276. data/vendor/rcheevos/test/test_rc_client_external.c +2197 -0
  277. data/vendor/rcheevos/test/test_rc_client_raintegration.c +441 -0
  278. data/vendor/rcheevos/test/test_rc_libretro.c +952 -0
  279. data/vendor/rcheevos/test/test_types.natvis +9 -0
  280. data/vendor/rcheevos/validator/validator.c +658 -0
  281. data/vendor/rcheevos/validator/validator.vcxproj +152 -0
  282. data/vendor/rcheevos/validator/validator.vcxproj.filters +82 -0
  283. metadata +274 -11
  284. data/lib/gemba/input_mappings.rb +0 -214
  285. data/lib/gemba/player.rb +0 -1525
@@ -1,169 +1,115 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "child_window"
4
- require_relative "hotkey_map"
5
- require_relative "locale"
6
- require_relative "tip_service"
7
3
 
8
4
  module Gemba
9
5
  # Settings window for the mGBA Player.
10
6
  #
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).
7
+ # Thin coordinator that builds a Toplevel with a ttk::notebook, delegates
8
+ # each tab to its own class under Settings::*, and manages shared concerns
9
+ # (per-game bar, save button, key-conflict dialog).
18
10
  class SettingsWindow
19
11
  include ChildWindow
20
12
  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
13
+ include BusEmitter
14
+
15
+ TOP = Settings::Paths::TOP
16
+ NB = Settings::Paths::NB
17
+
18
+ # Video tab widget paths (re-exported from Settings::VideoTab)
19
+ SCALE_COMBO = Settings::VideoTab::SCALE_COMBO
20
+ TURBO_COMBO = Settings::VideoTab::TURBO_COMBO
21
+ ASPECT_CHECK = Settings::VideoTab::ASPECT_CHECK
22
+ SHOW_FPS_CHECK = Settings::VideoTab::SHOW_FPS_CHECK
23
+ TOAST_COMBO = Settings::VideoTab::TOAST_COMBO
24
+ FILTER_COMBO = Settings::VideoTab::FILTER_COMBO
25
+ INTEGER_SCALE_CHECK = Settings::VideoTab::INTEGER_SCALE_CHECK
26
+ COLOR_CORRECTION_CHECK = Settings::VideoTab::COLOR_CORRECTION_CHECK
27
+ FRAME_BLENDING_CHECK = Settings::VideoTab::FRAME_BLENDING_CHECK
28
+ REWIND_CHECK = Settings::VideoTab::REWIND_CHECK
29
+ VOLUME_SCALE = Settings::AudioTab::VOLUME_SCALE
30
+ MUTE_CHECK = Settings::AudioTab::MUTE_CHECK
31
+
32
+ # Gamepad tab widget paths (re-exported from Settings::GamepadTab)
33
+ GAMEPAD_TAB = Settings::GamepadTab::FRAME
34
+ GAMEPAD_COMBO = Settings::GamepadTab::GAMEPAD_COMBO
35
+ DEADZONE_SCALE = Settings::GamepadTab::DEADZONE_SCALE
36
+ GP_RESET_BTN = Settings::GamepadTab::RESET_BTN
37
+ GP_UNDO_BTN = Settings::GamepadTab::UNDO_BTN
38
+
39
+ GP_BTN_A = Settings::GamepadTab::BTN_A
40
+ GP_BTN_B = Settings::GamepadTab::BTN_B
41
+ GP_BTN_L = Settings::GamepadTab::BTN_L
42
+ GP_BTN_R = Settings::GamepadTab::BTN_R
43
+ GP_BTN_UP = Settings::GamepadTab::BTN_UP
44
+ GP_BTN_DOWN = Settings::GamepadTab::BTN_DOWN
45
+ GP_BTN_LEFT = Settings::GamepadTab::BTN_LEFT
46
+ GP_BTN_RIGHT = Settings::GamepadTab::BTN_RIGHT
47
+ GP_BTN_START = Settings::GamepadTab::BTN_START
48
+ GP_BTN_SELECT = Settings::GamepadTab::BTN_SELECT
49
+
50
+ # Hotkeys tab widget paths (re-exported from Settings::HotkeysTab)
51
+ HK_TAB = Settings::HotkeysTab::FRAME
52
+ HK_UNDO_BTN = Settings::HotkeysTab::UNDO_BTN
53
+ HK_RESET_BTN = Settings::HotkeysTab::RESET_BTN
54
+ HK_ACTIONS = Settings::HotkeysTab::ACTIONS
55
+
56
+ # Locale key mappings (re-exported)
57
+ HK_LOCALE_KEYS = Settings::HotkeysTab::LOCALE_KEYS
58
+ GP_LOCALE_KEYS = Settings::GamepadTab::LOCALE_KEYS
97
59
 
98
60
  # Per-game settings bar (above notebook, shown/hidden based on active tab)
99
61
  PER_GAME_BAR = "#{TOP}.per_game_bar"
100
62
  PER_GAME_CHECK = "#{PER_GAME_BAR}.check"
101
63
 
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"
64
+ # Recording tab widget paths (re-exported from Settings::RecordingTab)
65
+ REC_TAB = Settings::RecordingTab::FRAME
66
+ REC_COMPRESSION_COMBO = Settings::RecordingTab::COMPRESSION_COMBO
67
+ REC_OPEN_DIR_BTN = Settings::RecordingTab::OPEN_DIR_BTN
68
+
69
+ # Save States tab widget paths (re-exported from Settings::SaveStatesTab)
70
+ SS_TAB = Settings::SaveStatesTab::FRAME
71
+ SS_SLOT_COMBO = Settings::SaveStatesTab::SLOT_COMBO
72
+ SS_BACKUP_CHECK = Settings::SaveStatesTab::BACKUP_CHECK
73
+ SS_OPEN_DIR_BTN = Settings::SaveStatesTab::OPEN_DIR_BTN
106
74
 
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"
75
+ # System tab widget paths (re-exported from Settings::SystemTab)
76
+ SYSTEM_TAB = Settings::SystemTab::FRAME
77
+ BIOS_ENTRY = Settings::SystemTab::BIOS_ENTRY
78
+ BIOS_STATUS = Settings::SystemTab::BIOS_STATUS
79
+ SKIP_BIOS_CHECK = Settings::SystemTab::SKIP_BIOS_CHECK
80
+
81
+ VAR_BIOS_PATH = Settings::SystemTab::VAR_BIOS_PATH
82
+ VAR_SKIP_BIOS = Settings::SystemTab::VAR_SKIP_BIOS
112
83
 
113
84
  # Bottom bar
114
85
  SAVE_BTN = "#{TOP}.save_btn"
115
86
 
116
87
  # Tcl variable names
117
88
  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'
89
+ VAR_SCALE = Settings::VideoTab::VAR_SCALE
90
+ VAR_TURBO = Settings::VideoTab::VAR_TURBO
91
+ VAR_VOLUME = Settings::AudioTab::VAR_VOLUME
92
+ VAR_MUTE = Settings::AudioTab::VAR_MUTE
93
+ VAR_GAMEPAD = Settings::GamepadTab::VAR_GAMEPAD
94
+ VAR_DEADZONE = Settings::GamepadTab::VAR_DEADZONE
95
+ VAR_ASPECT_RATIO = Settings::VideoTab::VAR_ASPECT_RATIO
96
+ VAR_SHOW_FPS = Settings::VideoTab::VAR_SHOW_FPS
97
+ VAR_TOAST_DURATION = Settings::VideoTab::VAR_TOAST_DURATION
98
+ VAR_FILTER = Settings::VideoTab::VAR_FILTER
99
+ VAR_INTEGER_SCALE = Settings::VideoTab::VAR_INTEGER_SCALE
100
+ VAR_COLOR_CORRECTION = Settings::VideoTab::VAR_COLOR_CORRECTION
101
+ VAR_FRAME_BLENDING = Settings::VideoTab::VAR_FRAME_BLENDING
102
+ VAR_REWIND_ENABLED = Settings::VideoTab::VAR_REWIND_ENABLED
103
+ VAR_QUICK_SLOT = Settings::SaveStatesTab::VAR_QUICK_SLOT
104
+ VAR_SS_BACKUP = Settings::SaveStatesTab::VAR_BACKUP
105
+ VAR_REC_COMPRESSION = Settings::RecordingTab::VAR_COMPRESSION
106
+ VAR_PAUSE_FOCUS = Settings::VideoTab::VAR_PAUSE_FOCUS
107
+
108
+ # GBA button → widget path mapping (re-exported from Settings::GamepadTab)
109
+ GBA_BUTTONS = Settings::GamepadTab::GBA_BUTTONS
110
+ DEFAULT_GP_LABELS = Settings::GamepadTab::DEFAULT_GP_LABELS
111
+ DEFAULT_KB_LABELS = Settings::GamepadTab::DEFAULT_KB_LABELS
136
112
 
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
113
  CALLBACK_DEFAULTS = {
168
114
  on_validate_hotkey: ->(_) { nil },
169
115
  on_validate_kb_mapping: ->(_) { nil },
@@ -173,27 +119,27 @@ module Gemba
173
119
  @app = app
174
120
  @callbacks = CALLBACK_DEFAULTS.merge(callbacks)
175
121
  @tip_dismiss_ms = tip_dismiss_ms
176
- @listening_for = nil
177
- @listen_timer = nil
178
- @keyboard_mode = true
179
122
  @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
123
 
187
- build_toplevel(translate('menu.settings'), geometry: '700x560') { setup_ui }
124
+ build_toplevel(translate('menu.settings'), geometry: '780x600') { setup_ui }
125
+ subscribe_to_bus
188
126
  end
189
127
 
190
- # @return [Symbol, nil] the GBA button currently listening for remap, or nil
191
- attr_reader :listening_for
128
+ # Exposes SystemTab for AppController save_config
129
+ def system_tab = @system_tab
192
130
 
193
- # @return [Boolean] true when editing keyboard bindings, false for gamepad
194
- def keyboard_mode?
195
- @keyboard_mode
196
- end
131
+ # Delegates to GamepadTab
132
+ def listening_for = @gamepad_tab.listening_for
133
+ def keyboard_mode? = @gamepad_tab.keyboard_mode?
134
+ def update_gamepad_list(names) = @gamepad_tab.update_gamepad_list(names)
135
+ def refresh_gamepad(labels, dead_zone) = @gamepad_tab.refresh_gamepad(labels, dead_zone)
136
+ def capture_mapping(button) = @gamepad_tab.capture_mapping(button)
137
+
138
+ # Delegates to HotkeysTab
139
+ def hk_listening_for = @hotkeys_tab.hk_listening_for
140
+ def capture_hk_mapping(keysym) = @hotkeys_tab.capture_hk_mapping(keysym)
141
+ def finalize_hk(hotkey) = @hotkeys_tab.finalize_hk(hotkey)
142
+ def refresh_hotkeys(labels) = @hotkeys_tab.refresh_hotkeys(labels)
197
143
 
198
144
  # @param tab [String, nil] widget path of the tab to select (e.g. SS_TAB)
199
145
  def show(tab: nil)
@@ -201,6 +147,12 @@ module Gemba
201
147
  show_window
202
148
  end
203
149
 
150
+ # ModalStack protocol
151
+ def show_modal(tab: nil, **_)
152
+ @app.command(NB, 'select', tab) if tab
153
+ super()
154
+ end
155
+
204
156
  # Tab widget paths keyed by locale key (caller uses translate to get display name)
205
157
  TABS = {
206
158
  'settings.video' => "#{NB}.video",
@@ -209,30 +161,35 @@ module Gemba
209
161
  'settings.hotkeys' => HK_TAB,
210
162
  'settings.recording' => REC_TAB,
211
163
  'settings.save_states' => SS_TAB,
164
+ 'settings.system' => SYSTEM_TAB,
212
165
  }.freeze
213
166
 
214
167
  # Tabs that show the per-game settings checkbox
215
- PER_GAME_TABS = Set.new(["#{NB}.video", "#{NB}.audio", SS_TAB]).freeze
168
+ PER_GAME_TABS = Set.new(["#{NB}.video", "#{NB}.audio", SS_TAB, SYSTEM_TAB]).freeze
216
169
 
217
170
  def hide
218
171
  @tips&.hide
219
172
  hide_window
220
173
  end
221
174
 
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
175
  # Enable the Save button (called when any setting changes)
232
176
  def mark_dirty
233
177
  @app.command(SAVE_BTN, 'configure', state: :normal)
234
178
  end
235
179
 
180
+ # Subscribe to bus events this object cares about.
181
+ def subscribe_to_bus
182
+ Gemba.bus.on(:rom_loaded) do |**|
183
+ set_per_game_available(true)
184
+ set_per_game_active(Gemba.user_config.per_game_settings?)
185
+ end
186
+ Gemba.bus.on(:config_loaded) do |config:|
187
+ [@video_tab, @audio_tab, @recording_tab, @save_states_tab, @system_tab].each do |tab|
188
+ tab.load_from_config(config)
189
+ end
190
+ end
191
+ end
192
+
236
193
  # Enable/disable the per-game checkbox (called when ROM loads/unloads).
237
194
  def set_per_game_available(enabled)
238
195
  @per_game_enabled = enabled
@@ -252,7 +209,7 @@ module Gemba
252
209
  private
253
210
 
254
211
  def do_save
255
- @callbacks[:on_save]&.call
212
+ emit(:settings_save)
256
213
  @app.command(SAVE_BTN, 'configure', state: :disabled)
257
214
  end
258
215
 
@@ -265,6 +222,19 @@ module Gemba
265
222
  end
266
223
  end
267
224
 
225
+ def show_key_conflict(message)
226
+ if @callbacks[:on_key_conflict]
227
+ @callbacks[:on_key_conflict].call(message)
228
+ else
229
+ @app.command('tk_messageBox',
230
+ parent: TOP,
231
+ title: translate('dialog.key_conflict_title'),
232
+ message: message,
233
+ type: :ok,
234
+ icon: :warning)
235
+ end
236
+ end
237
+
268
238
  def setup_ui
269
239
  # Bold button style for customized mappings
270
240
  @app.tcl_eval("ttk::style configure Bold.TButton -font [list {*}[font actual TkDefaultFont] -weight bold]")
@@ -280,7 +250,7 @@ module Gemba
280
250
  state: :disabled,
281
251
  command: proc { |*|
282
252
  enabled = @app.get_variable(VAR_PER_GAME) == '1'
283
- @callbacks[:on_per_game_toggle]&.call(enabled)
253
+ emit(:per_game_toggled, enabled)
284
254
  mark_dirty
285
255
  })
286
256
  @app.command(:pack, PER_GAME_CHECK, side: :left, padx: 5)
@@ -293,12 +263,26 @@ module Gemba
293
263
  @app.command('ttk::notebook', NB)
294
264
  @app.command(:pack, NB, fill: :both, expand: 1, padx: 5, pady: [5, 0])
295
265
 
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
266
+ @video_tab = Settings::VideoTab.new(@app, tips: @tips, mark_dirty: method(:mark_dirty))
267
+ @video_tab.build
268
+ @audio_tab = Settings::AudioTab.new(@app, tips: @tips, mark_dirty: method(:mark_dirty))
269
+ @audio_tab.build
270
+ @gamepad_tab = Settings::GamepadTab.new(@app,
271
+ callbacks: @callbacks.slice(:on_validate_kb_mapping, :on_confirm_reset_gamepad),
272
+ tips: @tips, mark_dirty: method(:mark_dirty), do_save: method(:do_save),
273
+ show_key_conflict: method(:show_key_conflict))
274
+ @gamepad_tab.build
275
+ @hotkeys_tab = Settings::HotkeysTab.new(@app,
276
+ callbacks: @callbacks.slice(:on_validate_hotkey, :on_confirm_reset_hotkeys),
277
+ mark_dirty: method(:mark_dirty), do_save: method(:do_save),
278
+ show_key_conflict: method(:show_key_conflict))
279
+ @hotkeys_tab.build
280
+ @recording_tab = Settings::RecordingTab.new(@app, tips: @tips, mark_dirty: method(:mark_dirty))
281
+ @recording_tab.build
282
+ @save_states_tab = Settings::SaveStatesTab.new(@app, tips: @tips, mark_dirty: method(:mark_dirty))
283
+ @save_states_tab.build
284
+ @system_tab = Settings::SystemTab.new(@app, tips: @tips, mark_dirty: method(:mark_dirty))
285
+ @system_tab.build
302
286
 
303
287
  # Show/hide per-game bar based on active tab
304
288
  @app.command(:bind, NB, '<<NotebookTabChanged>>', proc { update_per_game_bar })
@@ -311,863 +295,5 @@ module Gemba
311
295
  @app.command(:pack, SAVE_BTN, side: :bottom, pady: [0, 8])
312
296
  end
313
297
 
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
298
  end
1173
299
  end