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.
- checksums.yaml +7 -0
- data/THIRD_PARTY_NOTICES +113 -0
- data/assets/JetBrainsMonoNL-Regular.ttf +0 -0
- data/assets/ark-pixel-12px-monospaced-ja.ttf +0 -0
- data/bin/gemba +14 -0
- data/ext/gemba/extconf.rb +185 -0
- data/ext/gemba/gemba_ext.c +1051 -0
- data/ext/gemba/gemba_ext.h +15 -0
- data/gemba.gemspec +38 -0
- data/lib/gemba/child_window.rb +62 -0
- data/lib/gemba/cli.rb +384 -0
- data/lib/gemba/config.rb +621 -0
- data/lib/gemba/core.rb +121 -0
- data/lib/gemba/headless.rb +12 -0
- data/lib/gemba/headless_player.rb +206 -0
- data/lib/gemba/hotkey_map.rb +202 -0
- data/lib/gemba/input_mappings.rb +214 -0
- data/lib/gemba/locale.rb +92 -0
- data/lib/gemba/locales/en.yml +157 -0
- data/lib/gemba/locales/ja.yml +157 -0
- data/lib/gemba/method_coverage_service.rb +265 -0
- data/lib/gemba/overlay_renderer.rb +109 -0
- data/lib/gemba/player.rb +1515 -0
- data/lib/gemba/recorder.rb +156 -0
- data/lib/gemba/recorder_decoder.rb +325 -0
- data/lib/gemba/rom_info_window.rb +346 -0
- data/lib/gemba/rom_loader.rb +100 -0
- data/lib/gemba/runtime.rb +39 -0
- data/lib/gemba/save_state_manager.rb +155 -0
- data/lib/gemba/save_state_picker.rb +199 -0
- data/lib/gemba/settings_window.rb +1173 -0
- data/lib/gemba/tip_service.rb +133 -0
- data/lib/gemba/toast_overlay.rb +128 -0
- data/lib/gemba/version.rb +5 -0
- data/lib/gemba.rb +17 -0
- data/test/fixtures/test.gba +0 -0
- data/test/fixtures/test.sav +0 -0
- data/test/shared/screenshot_helper.rb +113 -0
- data/test/shared/simplecov_config.rb +59 -0
- data/test/shared/teek_test_worker.rb +388 -0
- data/test/shared/tk_test_helper.rb +354 -0
- data/test/support/input_mocks.rb +61 -0
- data/test/support/player_helpers.rb +77 -0
- data/test/test_cli.rb +281 -0
- data/test/test_config.rb +897 -0
- data/test/test_core.rb +401 -0
- data/test/test_gamepad_map.rb +116 -0
- data/test/test_headless_player.rb +205 -0
- data/test/test_helper.rb +19 -0
- data/test/test_hotkey_map.rb +396 -0
- data/test/test_keyboard_map.rb +108 -0
- data/test/test_locale.rb +159 -0
- data/test/test_mgba.rb +26 -0
- data/test/test_overlay_renderer.rb +199 -0
- data/test/test_player.rb +903 -0
- data/test/test_recorder.rb +180 -0
- data/test/test_rom_loader.rb +149 -0
- data/test/test_save_state_manager.rb +289 -0
- data/test/test_settings_hotkeys.rb +434 -0
- data/test/test_settings_window.rb +1039 -0
- data/test/test_tip_service.rb +138 -0
- data/test/test_toast_overlay.rb +216 -0
- data/test/test_virtual_keyboard.rb +39 -0
- data/test/test_xor_delta.rb +61 -0
- 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
|