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
@@ -0,0 +1,351 @@
1
+ # frozen_string_literal: true
2
+
3
+
4
+ module Gemba
5
+ module Settings
6
+ class GamepadTab
7
+ include Locale::Translatable
8
+ include BusEmitter
9
+
10
+ FRAME = "#{Paths::NB}.gamepad"
11
+ GAMEPAD_COMBO = "#{FRAME}.gp_row.gp_combo"
12
+ DEADZONE_SCALE = "#{FRAME}.dz_row.dz_scale"
13
+ RESET_BTN = "#{FRAME}.btn_bar.reset_btn"
14
+ UNDO_BTN = "#{FRAME}.btn_bar.undo_btn"
15
+
16
+ # GBA button widget paths (for remapping)
17
+ BTN_A = "#{FRAME}.row_a.btn"
18
+ BTN_B = "#{FRAME}.row_b.btn"
19
+ BTN_L = "#{FRAME}.row_l.btn"
20
+ BTN_R = "#{FRAME}.row_r.btn"
21
+ BTN_UP = "#{FRAME}.row_up.btn"
22
+ BTN_DOWN = "#{FRAME}.row_down.btn"
23
+ BTN_LEFT = "#{FRAME}.row_left.btn"
24
+ BTN_RIGHT = "#{FRAME}.row_right.btn"
25
+ BTN_START = "#{FRAME}.row_start.btn"
26
+ BTN_SELECT = "#{FRAME}.row_select.btn"
27
+
28
+ VAR_GAMEPAD = '::mgba_gamepad'
29
+ VAR_DEADZONE = '::mgba_deadzone'
30
+
31
+ # GBA button → widget path mapping
32
+ GBA_BUTTONS = {
33
+ a: BTN_A, b: BTN_B,
34
+ l: BTN_L, r: BTN_R,
35
+ up: BTN_UP, down: BTN_DOWN,
36
+ left: BTN_LEFT, right: BTN_RIGHT,
37
+ start: BTN_START, select: BTN_SELECT,
38
+ }.freeze
39
+
40
+ # GBA button → locale key mapping
41
+ LOCALE_KEYS = {
42
+ a: 'settings.gp_a', b: 'settings.gp_b',
43
+ l: 'settings.gp_l', r: 'settings.gp_r',
44
+ up: 'settings.gp_up', down: 'settings.gp_down',
45
+ left: 'settings.gp_left', right: 'settings.gp_right',
46
+ start: 'settings.gp_start', select: 'settings.gp_select',
47
+ }.freeze
48
+
49
+ # Default GBA → SDL gamepad mappings (display names)
50
+ DEFAULT_GP_LABELS = {
51
+ a: 'a', b: 'b',
52
+ l: 'left_shoulder', r: 'right_shoulder',
53
+ up: 'dpad_up', down: 'dpad_down',
54
+ left: 'dpad_left', right: 'dpad_right',
55
+ start: 'start', select: 'back',
56
+ }.freeze
57
+
58
+ # Default GBA → Tk keysym mappings (keyboard mode display names)
59
+ DEFAULT_KB_LABELS = {
60
+ a: 'z', b: 'x',
61
+ l: 'a', r: 's',
62
+ up: 'Up', down: 'Down',
63
+ left: 'Left', right: 'Right',
64
+ start: 'Return', select: 'BackSpace',
65
+ }.freeze
66
+
67
+ KEY_DISPLAY_LOCALE = {
68
+ 'Up' => 'settings.key_up', 'Down' => 'settings.key_down',
69
+ 'Left' => 'settings.key_left', 'Right' => 'settings.key_right',
70
+ }.freeze
71
+
72
+ LISTEN_TIMEOUT_MS = 10_000
73
+
74
+ def initialize(app, callbacks:, tips:, mark_dirty:, do_save:, show_key_conflict:)
75
+ @app = app
76
+ @callbacks = callbacks
77
+ @tips = tips
78
+ @mark_dirty = mark_dirty
79
+ @do_save = do_save
80
+ @show_key_conflict = show_key_conflict
81
+ @listening_for = nil
82
+ @listen_timer = nil
83
+ @keyboard_mode = true
84
+ @gp_labels = DEFAULT_KB_LABELS.dup
85
+ end
86
+
87
+ # @return [Symbol, nil] the GBA button currently listening for remap, or nil
88
+ attr_reader :listening_for
89
+
90
+ # @return [Boolean] true when editing keyboard bindings, false for gamepad
91
+ def keyboard_mode?
92
+ @keyboard_mode
93
+ end
94
+
95
+ def build
96
+ @app.command('ttk::frame', FRAME)
97
+ @app.command(Paths::NB, 'add', FRAME, text: translate('settings.gamepad'))
98
+
99
+ build_gamepad_selector
100
+ build_button_rows
101
+ build_bottom_bar
102
+ build_deadzone_slider
103
+
104
+ # Start in keyboard mode — dead zone disabled
105
+ set_deadzone_enabled(false)
106
+ end
107
+
108
+ def update_gamepad_list(names)
109
+ @app.command(GAMEPAD_COMBO, 'configure',
110
+ values: Teek.make_list(*names))
111
+ current = @app.get_variable(VAR_GAMEPAD)
112
+ unless names.include?(current)
113
+ @app.set_variable(VAR_GAMEPAD, names.first)
114
+ end
115
+ end
116
+
117
+ # Refresh the gamepad tab widgets from external state (e.g. after undo).
118
+ # @param labels [Hash{Symbol => String}] GBA button → gamepad button name
119
+ # @param dead_zone [Integer] dead zone percentage (0-50)
120
+ def refresh_gamepad(labels, dead_zone)
121
+ @gp_labels = labels.dup
122
+ GBA_BUTTONS.each do |gba_btn, widget|
123
+ style_btn(widget, btn_display(gba_btn), gp_customized?(gba_btn))
124
+ end
125
+ @app.command(DEADZONE_SCALE, 'set', dead_zone)
126
+ end
127
+
128
+ def capture_mapping(button)
129
+ return unless @listening_for
130
+
131
+ # In keyboard mode, reject keys that conflict with hotkeys
132
+ if @keyboard_mode
133
+ error = @callbacks[:on_validate_kb_mapping].call(button.to_s)
134
+ if error
135
+ @show_key_conflict.call(error)
136
+ cancel_listening
137
+ return
138
+ end
139
+ end
140
+
141
+ if @listen_timer
142
+ @app.command(:after, :cancel, @listen_timer)
143
+ @listen_timer = nil
144
+ end
145
+ unbind_keyboard_listen
146
+
147
+ gba_btn = @listening_for
148
+ @gp_labels[gba_btn] = button.to_s
149
+ widget = GBA_BUTTONS[gba_btn]
150
+ style_btn(widget, btn_display(gba_btn), gp_customized?(gba_btn))
151
+ @listening_for = nil
152
+
153
+ if @keyboard_mode
154
+ emit(:keyboard_map_changed, gba_btn, button)
155
+ else
156
+ emit(:gamepad_map_changed, gba_btn, button)
157
+ end
158
+ @app.command(UNDO_BTN, 'configure', state: :normal)
159
+ @mark_dirty.call
160
+ end
161
+
162
+ private
163
+
164
+ def build_gamepad_selector
165
+ gp_row = "#{FRAME}.gp_row"
166
+ @app.command('ttk::frame', gp_row)
167
+ @app.command(:pack, gp_row, fill: :x, padx: 10, pady: [8, 4])
168
+
169
+ @app.command('ttk::label', "#{gp_row}.lbl", text: translate('settings.gamepad') + ':')
170
+ @app.command(:pack, "#{gp_row}.lbl", side: :left)
171
+
172
+ @app.set_variable(VAR_GAMEPAD, translate('settings.keyboard_only'))
173
+ @app.command('ttk::combobox', GAMEPAD_COMBO,
174
+ textvariable: VAR_GAMEPAD, state: :readonly, width: 20)
175
+ @app.command(:pack, GAMEPAD_COMBO, side: :left, padx: 4)
176
+ @app.command(GAMEPAD_COMBO, 'configure',
177
+ values: Teek.make_list(translate('settings.keyboard_only')))
178
+
179
+ @app.command(:bind, GAMEPAD_COMBO, '<<ComboboxSelected>>',
180
+ proc { |*| switch_input_mode })
181
+ end
182
+
183
+ def build_button_rows
184
+ GBA_BUTTONS.each do |gba_btn, btn_path|
185
+ row = "#{FRAME}.row_#{gba_btn}"
186
+ @app.command('ttk::frame', row)
187
+ @app.command(:pack, row, fill: :x, padx: 10, pady: 2)
188
+
189
+ lbl_path = "#{row}.lbl"
190
+ @app.command('ttk::label', lbl_path, text: translate(LOCALE_KEYS[gba_btn]), width: 14, anchor: :w)
191
+ @app.command(:pack, lbl_path, side: :left)
192
+
193
+ @app.command('ttk::button', btn_path, text: btn_display(gba_btn), width: 12,
194
+ style: gp_customized?(gba_btn) ? 'Bold.TButton' : 'TButton',
195
+ command: proc { start_listening(gba_btn) })
196
+ @app.command(:pack, btn_path, side: :right)
197
+ end
198
+ end
199
+
200
+ def build_bottom_bar
201
+ btn_bar = "#{FRAME}.btn_bar"
202
+ @app.command('ttk::frame', btn_bar)
203
+ @app.command(:pack, btn_bar, fill: :x, side: :bottom, padx: 10, pady: [4, 8])
204
+
205
+ @app.command('ttk::button', UNDO_BTN, text: translate('settings.undo'),
206
+ state: :disabled, command: proc { do_undo_gamepad })
207
+ @app.command(:pack, UNDO_BTN, side: :left)
208
+
209
+ @app.command('ttk::button', RESET_BTN, text: translate('settings.reset_defaults'),
210
+ command: proc { confirm_reset_gamepad })
211
+ @app.command(:pack, RESET_BTN, side: :right)
212
+ end
213
+
214
+ def build_deadzone_slider
215
+ dz_row = "#{FRAME}.dz_row"
216
+ @app.command('ttk::frame', dz_row)
217
+ @app.command(:pack, dz_row, fill: :x, padx: 10, pady: [4, 8], side: :bottom)
218
+
219
+ @app.command('ttk::label', "#{dz_row}.lbl", text: translate('settings.dead_zone'))
220
+ @app.command(:pack, "#{dz_row}.lbl", side: :left)
221
+ @tips.register("#{dz_row}.lbl", translate('settings.tip_dead_zone'))
222
+
223
+ @dz_val_label = "#{dz_row}.dz_label"
224
+ @app.command('ttk::label', @dz_val_label, text: '25%', width: 5)
225
+ @app.command(:pack, @dz_val_label, side: :right)
226
+
227
+ @app.set_variable(VAR_DEADZONE, '25')
228
+ @app.command('ttk::scale', DEADZONE_SCALE,
229
+ orient: :horizontal, from: 0, to: 50, length: 150,
230
+ variable: VAR_DEADZONE,
231
+ command: proc { |v, *|
232
+ pct = v.to_f.round
233
+ @app.command(@dz_val_label, 'configure', text: "#{pct}%")
234
+ threshold = (pct / 100.0 * 32767).round
235
+ emit(:deadzone_changed, threshold)
236
+ @mark_dirty.call
237
+ })
238
+ @app.command(:pack, DEADZONE_SCALE, side: :right, padx: [5, 5])
239
+ end
240
+
241
+ def btn_display(gba_btn)
242
+ label = @gp_labels[gba_btn] || '?'
243
+ locale_key = KEY_DISPLAY_LOCALE[label]
244
+ locale_key ? translate(locale_key) : label
245
+ end
246
+
247
+ def gp_customized?(gba_btn)
248
+ defaults = @keyboard_mode ? DEFAULT_KB_LABELS : DEFAULT_GP_LABELS
249
+ @gp_labels[gba_btn] != defaults[gba_btn]
250
+ end
251
+
252
+ def style_btn(widget, text, bold)
253
+ @app.command(widget, 'configure', text: text, style: bold ? 'Bold.TButton' : 'TButton')
254
+ end
255
+
256
+ def start_listening(gba_btn)
257
+ cancel_listening
258
+ @listening_for = gba_btn
259
+ widget = GBA_BUTTONS[gba_btn]
260
+ @app.command(widget, 'configure', text: translate('settings.press'))
261
+ @listen_timer = @app.after(LISTEN_TIMEOUT_MS) { cancel_listening }
262
+
263
+ if @keyboard_mode
264
+ cb_id = @app.interp.register_callback(
265
+ proc { |keysym, *| capture_mapping(keysym) })
266
+ @app.tcl_eval("bind #{Paths::TOP} <Key> {ruby_callback #{cb_id} %K}")
267
+ end
268
+ end
269
+
270
+ def cancel_listening
271
+ if @listen_timer
272
+ @app.command(:after, :cancel, @listen_timer)
273
+ @listen_timer = nil
274
+ end
275
+ if @listening_for
276
+ unbind_keyboard_listen
277
+ widget = GBA_BUTTONS[@listening_for]
278
+ style_btn(widget, btn_display(@listening_for), gp_customized?(@listening_for))
279
+ @listening_for = nil
280
+ end
281
+ end
282
+
283
+ def unbind_keyboard_listen
284
+ @app.tcl_eval("bind #{Paths::TOP} <Key> {}")
285
+ end
286
+
287
+ def switch_input_mode
288
+ cancel_listening
289
+ selected = @app.get_variable(VAR_GAMEPAD)
290
+ @keyboard_mode = (selected == translate('settings.keyboard_only'))
291
+
292
+ if @keyboard_mode
293
+ @gp_labels = DEFAULT_KB_LABELS.dup
294
+ set_deadzone_enabled(false)
295
+ else
296
+ @gp_labels = DEFAULT_GP_LABELS.dup
297
+ set_deadzone_enabled(true)
298
+ end
299
+
300
+ GBA_BUTTONS.each do |gba_btn, widget|
301
+ style_btn(widget, btn_display(gba_btn), false)
302
+ end
303
+
304
+ @app.command(UNDO_BTN, 'configure', state: :disabled)
305
+ emit(:input_mode_changed, @keyboard_mode, selected)
306
+ end
307
+
308
+ def set_deadzone_enabled(enabled)
309
+ state = enabled ? :normal : :disabled
310
+ @app.command(DEADZONE_SCALE, 'configure', state: state)
311
+ end
312
+
313
+ def confirm_reset_gamepad
314
+ cancel_listening
315
+ confirmed = if @callbacks[:on_confirm_reset_gamepad]
316
+ @callbacks[:on_confirm_reset_gamepad].call
317
+ else
318
+ @app.command('tk_messageBox',
319
+ parent: Paths::TOP,
320
+ title: translate('dialog.reset_gamepad_title'),
321
+ message: translate('dialog.reset_gamepad_msg'),
322
+ type: :yesno,
323
+ icon: :question) == 'yes'
324
+ end
325
+ if confirmed
326
+ reset_gamepad_defaults
327
+ @do_save.call
328
+ end
329
+ end
330
+
331
+ def reset_gamepad_defaults
332
+ @gp_labels = (@keyboard_mode ? DEFAULT_KB_LABELS : DEFAULT_GP_LABELS).dup
333
+ GBA_BUTTONS.each do |gba_btn, widget|
334
+ style_btn(widget, btn_display(gba_btn), false)
335
+ end
336
+ @app.command(DEADZONE_SCALE, 'set', 25) unless @keyboard_mode
337
+ @app.command(UNDO_BTN, 'configure', state: :disabled)
338
+ if @keyboard_mode
339
+ emit(:keyboard_reset)
340
+ else
341
+ emit(:gamepad_reset)
342
+ end
343
+ end
344
+
345
+ def do_undo_gamepad
346
+ emit(:undo_gamepad)
347
+ @app.command(UNDO_BTN, 'configure', state: :disabled)
348
+ end
349
+ end
350
+ end
351
+ end
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+
4
+ module Gemba
5
+ module Settings
6
+ class HotkeysTab
7
+ include Locale::Translatable
8
+ include BusEmitter
9
+
10
+ FRAME = "#{Paths::NB}.hotkeys"
11
+ UNDO_BTN = "#{FRAME}.btn_bar.undo_btn"
12
+ RESET_BTN = "#{FRAME}.btn_bar.reset_btn"
13
+
14
+ # Action → widget path mapping for hotkey buttons
15
+ ACTIONS = {
16
+ quit: "#{FRAME}.row_quit.btn",
17
+ pause: "#{FRAME}.row_pause.btn",
18
+ fast_forward: "#{FRAME}.row_fast_forward.btn",
19
+ fullscreen: "#{FRAME}.row_fullscreen.btn",
20
+ show_fps: "#{FRAME}.row_show_fps.btn",
21
+ quick_save: "#{FRAME}.row_quick_save.btn",
22
+ quick_load: "#{FRAME}.row_quick_load.btn",
23
+ save_states: "#{FRAME}.row_save_states.btn",
24
+ screenshot: "#{FRAME}.row_screenshot.btn",
25
+ rewind: "#{FRAME}.row_rewind.btn",
26
+ record: "#{FRAME}.row_record.btn",
27
+ input_record: "#{FRAME}.row_input_record.btn",
28
+ open_rom: "#{FRAME}.row_open_rom.btn",
29
+ }.freeze
30
+
31
+ # Action → locale key mapping
32
+ LOCALE_KEYS = {
33
+ quit: 'settings.hk_quit', pause: 'settings.hk_pause',
34
+ fast_forward: 'settings.hk_fast_forward', fullscreen: 'settings.hk_fullscreen',
35
+ show_fps: 'settings.hk_show_fps', quick_save: 'settings.hk_quick_save',
36
+ quick_load: 'settings.hk_quick_load', save_states: 'settings.hk_save_states',
37
+ screenshot: 'settings.hk_screenshot',
38
+ rewind: 'settings.hk_rewind',
39
+ record: 'settings.hk_record',
40
+ input_record: 'settings.hk_input_record',
41
+ open_rom: 'settings.hk_open_rom',
42
+ }.freeze
43
+
44
+ LISTEN_TIMEOUT_MS = 10_000
45
+ MODIFIER_SETTLE_MS = 600
46
+
47
+ def initialize(app, callbacks:, mark_dirty:, do_save:, show_key_conflict:)
48
+ @app = app
49
+ @callbacks = callbacks
50
+ @mark_dirty = mark_dirty
51
+ @do_save = do_save
52
+ @show_key_conflict = show_key_conflict
53
+ @hk_listening_for = nil
54
+ @hk_listen_timer = nil
55
+ @hk_labels = HotkeyMap::DEFAULTS.dup
56
+ @hk_pending_modifiers = Set.new
57
+ @hk_mod_timer = nil
58
+ end
59
+
60
+ # @return [Symbol, nil] the hotkey action currently listening for remap
61
+ attr_reader :hk_listening_for
62
+
63
+ def build
64
+ @app.command('ttk::frame', FRAME)
65
+ @app.command(Paths::NB, 'add', FRAME, text: translate('settings.hotkeys'))
66
+
67
+ build_action_rows
68
+ build_bottom_bar
69
+ end
70
+
71
+ # Refresh the hotkeys tab widgets from external state (e.g. after undo).
72
+ # @param labels [Hash{Symbol => String}] action → keysym
73
+ def refresh_hotkeys(labels)
74
+ @hk_labels = labels.dup
75
+ ACTIONS.each do |action, widget|
76
+ style_btn(widget, hk_display(action), hk_customized?(action))
77
+ end
78
+ end
79
+
80
+ # Capture a hotkey during listen mode. Called by the Tk <Key>
81
+ # bind script, or directly by tests.
82
+ def capture_hk_mapping(keysym)
83
+ return unless @hk_listening_for
84
+
85
+ mod = HotkeyMap.normalize_modifier(keysym)
86
+ if mod
87
+ @hk_pending_modifiers << mod
88
+ cancel_mod_timer
89
+ @hk_mod_timer = @app.after(MODIFIER_SETTLE_MS) { finalize_hk(keysym) }
90
+ return
91
+ end
92
+
93
+ keysym = HotkeyMap.normalize_keysym(keysym)
94
+ cancel_mod_timer
95
+ if @hk_pending_modifiers.any?
96
+ hotkey = [*@hk_pending_modifiers.sort_by { |m| HotkeyMap::MODIFIER_ORDER.index(m) || 99 }, keysym]
97
+ @hk_pending_modifiers.clear
98
+ else
99
+ hotkey = keysym
100
+ end
101
+
102
+ finalize_hk(hotkey)
103
+ end
104
+
105
+ # Finalize a captured hotkey (plain key or combo).
106
+ def finalize_hk(hotkey)
107
+ return unless @hk_listening_for
108
+ cancel_mod_timer
109
+ @hk_pending_modifiers.clear
110
+
111
+ hotkey = HotkeyMap.normalize(hotkey)
112
+
113
+ unless hotkey.is_a?(Array)
114
+ error = @callbacks[:on_validate_hotkey].call(hotkey.to_s)
115
+ if error
116
+ @show_key_conflict.call(error)
117
+ cancel_hk_listening
118
+ return
119
+ end
120
+ end
121
+
122
+ if @hk_listen_timer
123
+ @app.command(:after, :cancel, @hk_listen_timer)
124
+ @hk_listen_timer = nil
125
+ end
126
+ unbind_keyboard_listen
127
+
128
+ action = @hk_listening_for
129
+ @hk_labels[action] = hotkey
130
+ widget = ACTIONS[action]
131
+ style_btn(widget, hk_display(action), hk_customized?(action))
132
+ @hk_listening_for = nil
133
+
134
+ emit(:hotkey_changed, action, hotkey)
135
+ @app.command(UNDO_BTN, 'configure', state: :normal)
136
+ @mark_dirty.call
137
+ end
138
+
139
+ private
140
+
141
+ def build_action_rows
142
+ ACTIONS.each do |action, btn_path|
143
+ row = "#{FRAME}.row_#{action}"
144
+ @app.command('ttk::frame', row)
145
+ @app.command(:pack, row, fill: :x, padx: 10, pady: 2)
146
+
147
+ lbl_path = "#{row}.lbl"
148
+ @app.command('ttk::label', lbl_path, text: translate(LOCALE_KEYS[action]), width: 14, anchor: :w)
149
+ @app.command(:pack, lbl_path, side: :left)
150
+
151
+ display = hk_display(action)
152
+ @app.command('ttk::button', btn_path, text: display, width: 12,
153
+ style: hk_customized?(action) ? 'Bold.TButton' : 'TButton',
154
+ command: proc { start_hk_listening(action) })
155
+ @app.command(:pack, btn_path, side: :right)
156
+ end
157
+ end
158
+
159
+ def build_bottom_bar
160
+ btn_bar = "#{FRAME}.btn_bar"
161
+ @app.command('ttk::frame', btn_bar)
162
+ @app.command(:pack, btn_bar, fill: :x, side: :bottom, padx: 10, pady: [4, 8])
163
+
164
+ @app.command('ttk::button', UNDO_BTN, text: translate('settings.undo'),
165
+ state: :disabled, command: proc { do_undo_hotkeys })
166
+ @app.command(:pack, UNDO_BTN, side: :left)
167
+
168
+ @app.command('ttk::button', RESET_BTN, text: translate('settings.hk_reset_defaults'),
169
+ command: proc { confirm_reset_hotkeys })
170
+ @app.command(:pack, RESET_BTN, side: :right)
171
+ end
172
+
173
+ def hk_customized?(action)
174
+ @hk_labels[action] != HotkeyMap::DEFAULTS[action]
175
+ end
176
+
177
+ def hk_display(action)
178
+ val = @hk_labels[action]
179
+ return '?' unless val
180
+ HotkeyMap.display_name(val)
181
+ end
182
+
183
+ def style_btn(widget, text, bold)
184
+ @app.command(widget, 'configure', text: text, style: bold ? 'Bold.TButton' : 'TButton')
185
+ end
186
+
187
+ def start_hk_listening(action)
188
+ cancel_hk_listening
189
+ @hk_listening_for = action
190
+ widget = ACTIONS[action]
191
+ @app.command(widget, 'configure', text: translate('settings.press'))
192
+ @hk_listen_timer = @app.after(LISTEN_TIMEOUT_MS) { cancel_hk_listening }
193
+
194
+ cb_id = @app.interp.register_callback(
195
+ proc { |keysym, *| capture_hk_mapping(keysym) })
196
+ @app.tcl_eval("bind #{Paths::TOP} <Key> {ruby_callback #{cb_id} %K}")
197
+ end
198
+
199
+ def cancel_hk_listening
200
+ cancel_mod_timer
201
+ @hk_pending_modifiers.clear
202
+ if @hk_listen_timer
203
+ @app.command(:after, :cancel, @hk_listen_timer)
204
+ @hk_listen_timer = nil
205
+ end
206
+ if @hk_listening_for
207
+ unbind_keyboard_listen
208
+ widget = ACTIONS[@hk_listening_for]
209
+ style_btn(widget, hk_display(@hk_listening_for), hk_customized?(@hk_listening_for))
210
+ @hk_listening_for = nil
211
+ end
212
+ end
213
+
214
+ def cancel_mod_timer
215
+ if @hk_mod_timer
216
+ @app.command(:after, :cancel, @hk_mod_timer)
217
+ @hk_mod_timer = nil
218
+ end
219
+ end
220
+
221
+ def unbind_keyboard_listen
222
+ @app.tcl_eval("bind #{Paths::TOP} <Key> {}")
223
+ end
224
+
225
+ def do_undo_hotkeys
226
+ emit(:undo_hotkeys)
227
+ @app.command(UNDO_BTN, 'configure', state: :disabled)
228
+ end
229
+
230
+ def confirm_reset_hotkeys
231
+ cancel_hk_listening
232
+ confirmed = if @callbacks[:on_confirm_reset_hotkeys]
233
+ @callbacks[:on_confirm_reset_hotkeys].call
234
+ else
235
+ @app.command('tk_messageBox',
236
+ parent: Paths::TOP,
237
+ title: translate('dialog.reset_hotkeys_title'),
238
+ message: translate('dialog.reset_hotkeys_msg'),
239
+ type: :yesno,
240
+ icon: :question) == 'yes'
241
+ end
242
+ if confirmed
243
+ reset_hotkey_defaults
244
+ @do_save.call
245
+ end
246
+ end
247
+
248
+ def reset_hotkey_defaults
249
+ cancel_hk_listening
250
+ @hk_labels = HotkeyMap::DEFAULTS.dup
251
+ ACTIONS.each do |action, widget|
252
+ style_btn(widget, hk_display(action), false)
253
+ end
254
+ @app.command(UNDO_BTN, 'configure', state: :disabled)
255
+ emit(:hotkey_reset)
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ module Settings
5
+ # Shared Tk widget path roots for all settings tabs.
6
+ module Paths
7
+ TOP = ".mgba_settings"
8
+ NB = "#{TOP}.nb"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+
4
+ module Gemba
5
+ module Settings
6
+ class RecordingTab
7
+ include Locale::Translatable
8
+ include BusEmitter
9
+
10
+ FRAME = "#{Paths::NB}.recording"
11
+ COMPRESSION_COMBO = "#{FRAME}.comp_row.comp_combo"
12
+ OPEN_DIR_BTN = "#{FRAME}.dir_row.open_btn"
13
+
14
+ VAR_COMPRESSION = '::mgba_rec_compression'
15
+
16
+ def initialize(app, tips:, mark_dirty:)
17
+ @app = app
18
+ @tips = tips
19
+ @mark_dirty = mark_dirty
20
+ end
21
+
22
+ def load_from_config(config)
23
+ @app.set_variable(VAR_COMPRESSION, config.recording_compression.to_s)
24
+ end
25
+
26
+ def build
27
+ @app.command('ttk::frame', FRAME)
28
+ @app.command(Paths::NB, 'add', FRAME, text: translate('settings.recording'))
29
+
30
+ # Compression level
31
+ comp_row = "#{FRAME}.comp_row"
32
+ @app.command('ttk::frame', comp_row)
33
+ @app.command(:pack, comp_row, fill: :x, padx: 10, pady: [15, 5])
34
+
35
+ @app.command('ttk::label', "#{comp_row}.lbl", text: translate('settings.recording_compression'))
36
+ @app.command(:pack, "#{comp_row}.lbl", side: :left)
37
+
38
+ comp_tip = "#{comp_row}.tip"
39
+ @app.command('ttk::label', comp_tip, text: '(?)')
40
+ @app.command(:pack, comp_tip, side: :left)
41
+ @tips.register(comp_tip, translate('settings.tip_recording_compression'))
42
+
43
+ comp_values = (1..9).map(&:to_s)
44
+ @app.set_variable(VAR_COMPRESSION, '1')
45
+ @app.command('ttk::combobox', COMPRESSION_COMBO,
46
+ textvariable: VAR_COMPRESSION,
47
+ values: Teek.make_list(*comp_values),
48
+ state: :readonly,
49
+ width: 5)
50
+ @app.command(:pack, COMPRESSION_COMBO, side: :right)
51
+
52
+ @app.command(:bind, COMPRESSION_COMBO, '<<ComboboxSelected>>',
53
+ proc { |*|
54
+ val = @app.get_variable(VAR_COMPRESSION).to_i
55
+ if val >= 1 && val <= 9
56
+ emit(:compression_changed, val)
57
+ @mark_dirty.call
58
+ end
59
+ })
60
+
61
+ # Open Recordings Folder button
62
+ dir_row = "#{FRAME}.dir_row"
63
+ @app.command('ttk::frame', dir_row)
64
+ @app.command(:pack, dir_row, fill: :x, padx: 10, pady: [15, 5])
65
+
66
+ @app.command('ttk::button', OPEN_DIR_BTN,
67
+ text: translate('settings.open_recordings_folder'),
68
+ command: proc { emit(:open_recordings_dir) })
69
+ @app.command(:pack, OPEN_DIR_BTN, side: :left)
70
+
71
+ # Open Recording Player button
72
+ replay_row = "#{FRAME}.replay_row"
73
+ @app.command('ttk::frame', replay_row)
74
+ @app.command(:pack, replay_row, fill: :x, padx: 10, pady: [5, 5])
75
+
76
+ @app.command('ttk::button', "#{replay_row}.open_btn",
77
+ text: translate('settings.open_replay_player'),
78
+ command: proc { emit(:open_replay_player) })
79
+ @app.command(:pack, "#{replay_row}.open_btn", side: :left)
80
+ end
81
+ end
82
+ end
83
+ end