gemba 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/THIRD_PARTY_NOTICES +113 -0
  3. data/assets/JetBrainsMonoNL-Regular.ttf +0 -0
  4. data/assets/ark-pixel-12px-monospaced-ja.ttf +0 -0
  5. data/bin/gemba +14 -0
  6. data/ext/gemba/extconf.rb +185 -0
  7. data/ext/gemba/gemba_ext.c +1051 -0
  8. data/ext/gemba/gemba_ext.h +15 -0
  9. data/gemba.gemspec +38 -0
  10. data/lib/gemba/child_window.rb +62 -0
  11. data/lib/gemba/cli.rb +384 -0
  12. data/lib/gemba/config.rb +621 -0
  13. data/lib/gemba/core.rb +121 -0
  14. data/lib/gemba/headless.rb +12 -0
  15. data/lib/gemba/headless_player.rb +206 -0
  16. data/lib/gemba/hotkey_map.rb +202 -0
  17. data/lib/gemba/input_mappings.rb +214 -0
  18. data/lib/gemba/locale.rb +92 -0
  19. data/lib/gemba/locales/en.yml +157 -0
  20. data/lib/gemba/locales/ja.yml +157 -0
  21. data/lib/gemba/method_coverage_service.rb +265 -0
  22. data/lib/gemba/overlay_renderer.rb +109 -0
  23. data/lib/gemba/player.rb +1515 -0
  24. data/lib/gemba/recorder.rb +156 -0
  25. data/lib/gemba/recorder_decoder.rb +325 -0
  26. data/lib/gemba/rom_info_window.rb +346 -0
  27. data/lib/gemba/rom_loader.rb +100 -0
  28. data/lib/gemba/runtime.rb +39 -0
  29. data/lib/gemba/save_state_manager.rb +155 -0
  30. data/lib/gemba/save_state_picker.rb +199 -0
  31. data/lib/gemba/settings_window.rb +1173 -0
  32. data/lib/gemba/tip_service.rb +133 -0
  33. data/lib/gemba/toast_overlay.rb +128 -0
  34. data/lib/gemba/version.rb +5 -0
  35. data/lib/gemba.rb +17 -0
  36. data/test/fixtures/test.gba +0 -0
  37. data/test/fixtures/test.sav +0 -0
  38. data/test/shared/screenshot_helper.rb +113 -0
  39. data/test/shared/simplecov_config.rb +59 -0
  40. data/test/shared/teek_test_worker.rb +388 -0
  41. data/test/shared/tk_test_helper.rb +354 -0
  42. data/test/support/input_mocks.rb +61 -0
  43. data/test/support/player_helpers.rb +77 -0
  44. data/test/test_cli.rb +281 -0
  45. data/test/test_config.rb +897 -0
  46. data/test/test_core.rb +401 -0
  47. data/test/test_gamepad_map.rb +116 -0
  48. data/test/test_headless_player.rb +205 -0
  49. data/test/test_helper.rb +19 -0
  50. data/test/test_hotkey_map.rb +396 -0
  51. data/test/test_keyboard_map.rb +108 -0
  52. data/test/test_locale.rb +159 -0
  53. data/test/test_mgba.rb +26 -0
  54. data/test/test_overlay_renderer.rb +199 -0
  55. data/test/test_player.rb +903 -0
  56. data/test/test_recorder.rb +180 -0
  57. data/test/test_rom_loader.rb +149 -0
  58. data/test/test_save_state_manager.rb +289 -0
  59. data/test/test_settings_hotkeys.rb +434 -0
  60. data/test/test_settings_window.rb +1039 -0
  61. data/test/test_tip_service.rb +138 -0
  62. data/test/test_toast_overlay.rb +216 -0
  63. data/test/test_virtual_keyboard.rb +39 -0
  64. data/test/test_xor_delta.rb +61 -0
  65. metadata +234 -0
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "child_window"
4
+ require_relative "locale"
5
+
6
+ module Gemba
7
+ # Grid picker window for save state slots.
8
+ #
9
+ # Displays up to 10 slots in a 2×5 grid. Each cell shows a PNG
10
+ # thumbnail screenshot, the slot number, and the save timestamp —
11
+ # or an "Empty" placeholder. Left-click a populated slot to load,
12
+ # click an empty slot to save. The current quick-save slot is
13
+ # highlighted with a distinct border.
14
+ #
15
+ # Uses native Tk photo images for thumbnails (loaded via
16
+ # `image create photo -file`). Pure Tk — no SDL2.
17
+ class SaveStatePicker
18
+ include ChildWindow
19
+ include Locale::Translatable
20
+
21
+ TOP = ".mgba_state_picker"
22
+
23
+ # Thumbnail size: half of native GBA resolution (240×160)
24
+ THUMB_W = 120
25
+ THUMB_H = 80
26
+ COLS = 5
27
+ ROWS = 2
28
+ SLOTS = 10
29
+
30
+ # @param app [Teek::App]
31
+ # @param callbacks [Hash] :on_save, :on_load — called with slot number
32
+ def initialize(app, callbacks: {})
33
+ @app = app
34
+ @callbacks = callbacks
35
+ @built = false
36
+ @photos = {} # slot => photo image name
37
+ @cells = {} # slot => { frame:, thumb:, label:, time: }
38
+ @quick_slot = 1
39
+ end
40
+
41
+ # @param state_dir [String] path to per-ROM state directory
42
+ # @param quick_slot [Integer] current quick-save slot (1-10)
43
+ def show(state_dir:, quick_slot: 1)
44
+ @state_dir = state_dir
45
+ @quick_slot = quick_slot
46
+ build_ui unless @built
47
+ refresh
48
+ show_window
49
+ end
50
+
51
+ def hide
52
+ hide_window
53
+ cleanup_photos
54
+ end
55
+
56
+ private
57
+
58
+ def build_ui
59
+ build_toplevel(translate('picker.title'), geometry: '700x380') do
60
+ build_grid
61
+ end
62
+ @built = true
63
+ end
64
+
65
+ def build_grid
66
+ grid = "#{TOP}.grid"
67
+ @app.command('ttk::frame', grid, padding: 8)
68
+ @app.command(:pack, grid, fill: :x)
69
+
70
+ # Blank image keeps the label in pixel-sizing mode even without a screenshot
71
+ @blank_thumb = "__gemba_ss_blank"
72
+ @app.tcl_eval("image create photo #{@blank_thumb} -width #{THUMB_W} -height #{THUMB_H}")
73
+
74
+ SLOTS.times do |i|
75
+ slot = i + 1
76
+ row = i / COLS
77
+ col = i % COLS
78
+
79
+ cell = "#{grid}.slot#{slot}"
80
+ @app.command('ttk::frame', cell, relief: :groove, borderwidth: 2, padding: 4)
81
+ @app.command(:grid, cell, row: row, column: col, padx: 4, pady: 4, sticky: :new)
82
+
83
+ # Thumbnail label (shows image or "Empty" text overlay)
84
+ thumb = "#{cell}.thumb"
85
+ @app.command(:label, thumb,
86
+ image: @blank_thumb, compound: :center,
87
+ bg: '#1a1a2e', fg: '#666666',
88
+ text: translate('picker.empty'), anchor: :center,
89
+ font: '{TkDefaultFont} 9')
90
+ @app.command(:pack, thumb, pady: [0, 4])
91
+
92
+ # Slot number + timestamp
93
+ info = "#{cell}.info"
94
+ @app.command('ttk::label', info, text: translate('picker.slot', n: slot), anchor: :center)
95
+ @app.command(:pack, info, fill: :x)
96
+
97
+ time_lbl = "#{cell}.time"
98
+ @app.command('ttk::label', time_lbl, text: '', anchor: :center,
99
+ font: '{TkDefaultFont} 8')
100
+ @app.command(:pack, time_lbl, fill: :x)
101
+
102
+ # Bind click on the whole cell + thumbnail
103
+ click = proc { on_slot_click(slot) }
104
+ @app.command(:bind, cell, '<Button-1>', click)
105
+ @app.command(:bind, thumb, '<Button-1>', click)
106
+ @app.command(:bind, info, '<Button-1>', click)
107
+ @app.command(:bind, time_lbl, '<Button-1>', click)
108
+
109
+ @cells[slot] = { frame: cell, thumb: thumb, info: info, time: time_lbl }
110
+ end
111
+
112
+ # Make columns expand evenly
113
+ COLS.times { |c| @app.command(:grid, :columnconfigure, grid, c, weight: 1) }
114
+
115
+ # Spacer absorbs leftover vertical space so cells don't stretch
116
+ spacer = "#{TOP}.spacer"
117
+ @app.command('ttk::frame', spacer)
118
+ @app.command(:pack, spacer, fill: :both, expand: 1)
119
+
120
+ # Close button
121
+ close_btn = "#{TOP}.close_btn"
122
+ @app.command('ttk::button', close_btn, text: translate('picker.close'), command: proc { hide })
123
+ @app.command(:pack, close_btn, pady: [0, 8])
124
+ end
125
+
126
+ def refresh
127
+ cleanup_photos
128
+
129
+ SLOTS.times do |i|
130
+ slot = i + 1
131
+ cell = @cells[slot]
132
+ ss_path = File.join(@state_dir, "state#{slot}.ss")
133
+ png_path = File.join(@state_dir, "state#{slot}.png")
134
+
135
+ populated = File.exist?(ss_path)
136
+
137
+ if populated && File.exist?(png_path)
138
+ load_thumbnail(slot, png_path)
139
+ else
140
+ # Clear thumbnail — show Empty or just slot text on blank image
141
+ @app.command(cell[:thumb], :configure,
142
+ image: @blank_thumb, compound: :center,
143
+ text: populated ? translate('picker.no_preview') : translate('picker.empty'))
144
+ end
145
+
146
+ # Timestamp
147
+ if populated
148
+ mtime = File.mtime(ss_path)
149
+ @app.command(cell[:time], :configure, text: mtime.strftime('%b %d %H:%M'))
150
+ else
151
+ @app.command(cell[:time], :configure, text: '')
152
+ end
153
+
154
+ # Highlight quick-save slot
155
+ border = slot == @quick_slot ? 'solid' : 'groove'
156
+ color_opt = slot == @quick_slot ? { borderwidth: 3 } : { borderwidth: 2 }
157
+ @app.command(cell[:frame], :configure, relief: border, **color_opt)
158
+ end
159
+ end
160
+
161
+ def load_thumbnail(slot, png_path)
162
+ photo_name = "__gemba_ss_thumb_#{slot}"
163
+
164
+ # Load full-size PNG, then subsample to thumbnail size
165
+ src_name = "__gemba_ss_src_#{slot}"
166
+ @app.tcl_eval("image create photo #{src_name} -file {#{png_path}}")
167
+ @app.tcl_eval("image create photo #{photo_name} -width #{THUMB_W} -height #{THUMB_H}")
168
+ @app.tcl_eval("#{photo_name} copy #{src_name} -subsample 2 2")
169
+ @app.tcl_eval("image delete #{src_name}")
170
+
171
+ @app.command(@cells[slot][:thumb], :configure,
172
+ image: photo_name, compound: :none, text: '')
173
+ @photos[slot] = photo_name
174
+ rescue StandardError => e
175
+ warn "SaveStatePicker: failed to load thumbnail for slot #{slot}: #{e.message}"
176
+ @app.tcl_eval("image delete #{src_name}") rescue nil
177
+ @app.tcl_eval("image delete #{photo_name}") rescue nil
178
+ @app.command(@cells[slot][:thumb], :configure,
179
+ image: @blank_thumb, compound: :center, text: translate('picker.no_preview'))
180
+ end
181
+
182
+ def cleanup_photos
183
+ @photos.each_value do |name|
184
+ @app.tcl_eval("image delete #{name}") rescue nil
185
+ end
186
+ @photos.clear
187
+ end
188
+
189
+ def on_slot_click(slot)
190
+ ss_path = File.join(@state_dir, "state#{slot}.ss")
191
+ if File.exist?(ss_path)
192
+ @callbacks[:on_load]&.call(slot)
193
+ else
194
+ @callbacks[:on_save]&.call(slot)
195
+ end
196
+ hide
197
+ end
198
+ end
199
+ end