gemba 0.2.0 → 0.2.1
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 +4 -4
- data/lib/gemba/achievements/backend.rb +4 -0
- data/lib/gemba/achievements/retro_achievements/backend.rb +69 -11
- data/lib/gemba/achievements/retro_achievements/ping_worker.rb +1 -0
- data/lib/gemba/achievements/retro_achievements/unlock_retry_worker.rb +35 -0
- data/lib/gemba/app_controller.rb +28 -7
- data/lib/gemba/config.rb +22 -2
- data/lib/gemba/emulator_frame.rb +24 -0
- data/lib/gemba/frame_stack.rb +10 -0
- data/lib/gemba/game_picker_frame.rb +49 -8
- data/lib/gemba/list_picker_frame.rb +271 -0
- data/lib/gemba/locales/en.yml +12 -0
- data/lib/gemba/locales/ja.yml +12 -0
- data/lib/gemba/rom_patcher/ups.rb +2 -1
- data/lib/gemba/settings/system_tab.rb +18 -3
- data/lib/gemba/version.rb +1 -1
- data/test/shared/tk_test_helper.rb +1 -0
- data/test/support/fake_requester.rb +14 -4
- data/test/test_config.rb +2 -1
- data/test/test_game_picker_frame.rb +22 -22
- data/test/test_list_picker_frame.rb +391 -0
- data/test/test_ra_backend_unlock_retry.rb +123 -0
- data/test/test_rom_patcher.rb +2 -1
- data/test/test_virtual_events.rb +65 -0
- metadata +6 -2
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
# Startup frame showing all library ROMs as a sortable treeview list.
|
|
5
|
+
#
|
|
6
|
+
# Alternative to GamePickerFrame (no boxart). Columns: Title, Last Played.
|
|
7
|
+
# Clicking a column header sorts; the active column shows a ▲/▼ indicator.
|
|
8
|
+
# Double-clicking a row emits :rom_selected. Right-clicking shows a context
|
|
9
|
+
# menu identical to the GamePickerFrame card menu.
|
|
10
|
+
# Pure Tk — no SDL2.
|
|
11
|
+
class ListPickerFrame
|
|
12
|
+
include BusEmitter
|
|
13
|
+
include Locale::Translatable
|
|
14
|
+
|
|
15
|
+
LIST_DEFAULT_W = 480
|
|
16
|
+
LIST_DEFAULT_H = 600
|
|
17
|
+
LIST_MIN_W = 320
|
|
18
|
+
LIST_MIN_H = 400
|
|
19
|
+
|
|
20
|
+
SORT_ASC = ' ▲'
|
|
21
|
+
SORT_DESC = ' ▼'
|
|
22
|
+
|
|
23
|
+
def default_geometry = [LIST_DEFAULT_W, LIST_DEFAULT_H]
|
|
24
|
+
def min_geometry = [LIST_MIN_W, LIST_MIN_H]
|
|
25
|
+
|
|
26
|
+
def initialize(app:, rom_library:, rom_overrides: nil)
|
|
27
|
+
@app = app
|
|
28
|
+
@rom_library = rom_library
|
|
29
|
+
@overrides = rom_overrides
|
|
30
|
+
@built = false
|
|
31
|
+
@sort_col = 'last_played'
|
|
32
|
+
@sort_asc = false # most-recent first by default
|
|
33
|
+
@row_data = {} # treeview item id => RomInfo
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def show
|
|
37
|
+
build_ui unless @built
|
|
38
|
+
refresh
|
|
39
|
+
@app.command(:pack, @outer, fill: :both, expand: 1)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def hide
|
|
43
|
+
@app.command(:pack, :forget, @outer) rescue nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def cleanup; end
|
|
47
|
+
|
|
48
|
+
def receive(event, **_args)
|
|
49
|
+
case event
|
|
50
|
+
when :refresh then refresh
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def aspect_ratio = nil
|
|
55
|
+
def rom_loaded? = false
|
|
56
|
+
def sdl2_ready? = false
|
|
57
|
+
def paused? = false
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def build_ui
|
|
62
|
+
@outer = '.list_picker'
|
|
63
|
+
@app.command('ttk::frame', @outer, padding: 8)
|
|
64
|
+
|
|
65
|
+
# Treeview + scrollbar
|
|
66
|
+
@tree = "#{@outer}.tree"
|
|
67
|
+
@scrollbar = "#{@outer}.scroll"
|
|
68
|
+
|
|
69
|
+
@app.command('ttk::treeview', @tree,
|
|
70
|
+
columns: Teek.make_list('title', 'last_played'),
|
|
71
|
+
show: :headings,
|
|
72
|
+
selectmode: :browse)
|
|
73
|
+
|
|
74
|
+
@app.command('ttk::scrollbar', @scrollbar, orient: :vertical,
|
|
75
|
+
command: "#{@tree} yview")
|
|
76
|
+
@app.command(@tree, :configure, yscrollcommand: "#{@scrollbar} set")
|
|
77
|
+
|
|
78
|
+
build_columns
|
|
79
|
+
bind_events
|
|
80
|
+
|
|
81
|
+
@app.command(:grid, @tree, row: 0, column: 0, sticky: :nsew)
|
|
82
|
+
@app.command(:grid, @scrollbar, row: 0, column: 1, sticky: :ns)
|
|
83
|
+
@app.command(:grid, :columnconfigure, @outer, 0, weight: 1)
|
|
84
|
+
@app.command(:grid, :rowconfigure, @outer, 0, weight: 1)
|
|
85
|
+
|
|
86
|
+
build_toolbar
|
|
87
|
+
|
|
88
|
+
@built = true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_columns
|
|
92
|
+
@app.command(@tree, :heading, 'title',
|
|
93
|
+
text: translate('list_picker.columns.title') + (@sort_col == 'title' ? sort_indicator : ''),
|
|
94
|
+
anchor: :w,
|
|
95
|
+
command: proc { sort_by('title') })
|
|
96
|
+
@app.command(@tree, :heading, 'last_played',
|
|
97
|
+
text: translate('list_picker.columns.last_played') + (@sort_col == 'last_played' ? sort_indicator : ''),
|
|
98
|
+
anchor: :w,
|
|
99
|
+
command: proc { sort_by('last_played') })
|
|
100
|
+
@app.command(@tree, :column, 'title', width: 280, stretch: 1)
|
|
101
|
+
@app.command(@tree, :column, 'last_played', width: 120, stretch: 0)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def bind_events
|
|
105
|
+
# Physical double-click fires virtual event so tests can trigger it
|
|
106
|
+
# directly without needing event generate <Double-Button-1> (forbidden in Tk 9).
|
|
107
|
+
@app.command(:bind, @tree, '<Double-Button-1>', proc {
|
|
108
|
+
@app.tcl_eval("event generate #{@tree} <<DoubleClick>>")
|
|
109
|
+
})
|
|
110
|
+
@app.command(:bind, @tree, '<<DoubleClick>>', proc {
|
|
111
|
+
iid = @app.tcl_eval("#{@tree} focus")
|
|
112
|
+
next if iid.to_s.empty?
|
|
113
|
+
rom_info = @row_data[iid]
|
|
114
|
+
emit(:rom_selected, rom_info.path) if rom_info
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
# Physical right-click: use %x/%y (widget-relative event coords) to
|
|
118
|
+
# identify the row, select and focus it, then fire the virtual event so
|
|
119
|
+
# tests can trigger the same code path without real pointer coordinates.
|
|
120
|
+
@app.tcl_eval(<<~TCL)
|
|
121
|
+
bind #{@tree} <Button-3> {+
|
|
122
|
+
set _iid [#{@tree} identify row %x %y]
|
|
123
|
+
if {$_iid ne {}} {
|
|
124
|
+
#{@tree} selection set $_iid
|
|
125
|
+
#{@tree} focus $_iid
|
|
126
|
+
event generate #{@tree} <<RightClick>>
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
TCL
|
|
130
|
+
|
|
131
|
+
# Virtual event reads the currently focused item. Decoupled from pointer
|
|
132
|
+
# position so tests can trigger it directly after setting focus.
|
|
133
|
+
@app.command(:bind, @tree, '<<RightClick>>', proc {
|
|
134
|
+
iid = @app.tcl_eval("#{@tree} focus")
|
|
135
|
+
rom_info = @row_data[iid.to_s]
|
|
136
|
+
post_row_menu(rom_info) if rom_info
|
|
137
|
+
})
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def build_toolbar
|
|
141
|
+
sep = "#{@outer}.sep"
|
|
142
|
+
@app.command('ttk::separator', sep, orient: :horizontal)
|
|
143
|
+
@app.command(:grid, sep, row: 1, column: 0, columnspan: 2, sticky: :ew, pady: [4, 0])
|
|
144
|
+
|
|
145
|
+
toolbar = "#{@outer}.toolbar"
|
|
146
|
+
@app.command('ttk::frame', toolbar, padding: [4, 2])
|
|
147
|
+
@app.command(:grid, toolbar, row: 2, column: 0, columnspan: 2, sticky: :ew)
|
|
148
|
+
|
|
149
|
+
gear_btn = "#{toolbar}.gear"
|
|
150
|
+
gear_menu = "#{toolbar}.gearmenu"
|
|
151
|
+
@app.command('ttk::button', gear_btn, text: "\u2699", width: 1,
|
|
152
|
+
command: proc { post_view_menu(gear_menu, gear_btn) })
|
|
153
|
+
@app.command(:pack, gear_btn, side: :right)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def refresh
|
|
157
|
+
@app.tcl_eval("#{@tree} delete [#{@tree} children {}]")
|
|
158
|
+
@row_data.clear
|
|
159
|
+
|
|
160
|
+
roms = sorted(@rom_library.all)
|
|
161
|
+
roms.each do |rom|
|
|
162
|
+
rom_info = RomInfo.from_rom(rom, overrides: @overrides)
|
|
163
|
+
lp = format_last_played(rom['last_played'])
|
|
164
|
+
iid = @app.tcl_eval(
|
|
165
|
+
"#{@tree} insert {} end -values [list #{Teek.make_list(rom_info.title, lp)}]"
|
|
166
|
+
)
|
|
167
|
+
@row_data[iid] = rom_info
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def sorted(roms)
|
|
172
|
+
sorted = roms.sort_by do |r|
|
|
173
|
+
case @sort_col
|
|
174
|
+
when 'title' then r['title'].to_s.downcase
|
|
175
|
+
when 'last_played' then r['last_played'] || r['added_at'] || ''
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
@sort_asc ? sorted : sorted.reverse
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def sort_by(col)
|
|
182
|
+
if @sort_col == col
|
|
183
|
+
@sort_asc = !@sort_asc
|
|
184
|
+
else
|
|
185
|
+
@sort_col = col
|
|
186
|
+
@sort_asc = (col == 'title') # title: asc first; date: newest first
|
|
187
|
+
end
|
|
188
|
+
update_headings
|
|
189
|
+
refresh
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def update_headings
|
|
193
|
+
['title', 'last_played'].each do |col|
|
|
194
|
+
label_key = col == 'title' ? 'list_picker.columns.title' : 'list_picker.columns.last_played'
|
|
195
|
+
indicator = @sort_col == col ? sort_indicator : ''
|
|
196
|
+
@app.command(@tree, :heading, col, text: translate(label_key) + indicator)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def sort_indicator
|
|
201
|
+
@sort_asc ? SORT_ASC : SORT_DESC
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def format_last_played(iso)
|
|
205
|
+
return translate('list_picker.never_played') if iso.to_s.empty?
|
|
206
|
+
require 'time'
|
|
207
|
+
Time.parse(iso).localtime.strftime('%b %-d, %Y')
|
|
208
|
+
rescue
|
|
209
|
+
iso.to_s
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def post_view_menu(menu, btn)
|
|
213
|
+
@app.command(:menu, menu, tearoff: 0) unless @app.tcl_eval("winfo exists #{menu}") == '1'
|
|
214
|
+
@app.command(menu, :delete, 0, :end)
|
|
215
|
+
current = Gemba.user_config.picker_view
|
|
216
|
+
@app.command(menu, :add, :command,
|
|
217
|
+
label: "#{current == 'grid' ? "\u2713 " : ' '}#{translate('picker.toolbar.boxart_view')}",
|
|
218
|
+
command: proc { emit(:picker_view_changed, view: 'grid') })
|
|
219
|
+
@app.command(menu, :add, :command,
|
|
220
|
+
label: "#{current == 'list' ? "\u2713 " : ' '}#{translate('picker.toolbar.list_view')}",
|
|
221
|
+
command: proc { emit(:picker_view_changed, view: 'list') })
|
|
222
|
+
x = @app.tcl_eval("winfo rootx #{btn}").to_i
|
|
223
|
+
y = @app.tcl_eval("winfo rooty #{btn}").to_i
|
|
224
|
+
h = @app.tcl_eval("winfo height #{btn}").to_i
|
|
225
|
+
@app.tcl_eval("tk_popup #{menu} #{x} #{y + h}")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def post_row_menu(rom_info)
|
|
229
|
+
menu = "#{@tree}.ctx"
|
|
230
|
+
@app.command(:menu, menu, tearoff: 0) unless @app.tcl_eval("winfo exists #{menu}") == '1'
|
|
231
|
+
@app.command(menu, :delete, 0, :end)
|
|
232
|
+
@app.command(menu, :add, :command,
|
|
233
|
+
label: translate('game_picker.menu.play'),
|
|
234
|
+
command: proc { emit(:rom_selected, rom_info.path) })
|
|
235
|
+
qs_slot = Gemba.user_config.quick_save_slot
|
|
236
|
+
qs_state = quick_save_exists?(rom_info, qs_slot)
|
|
237
|
+
@app.command(menu, :add, :command,
|
|
238
|
+
label: translate('game_picker.menu.quick_load'),
|
|
239
|
+
state: qs_state ? :normal : :disabled,
|
|
240
|
+
command: proc { emit(:rom_quick_load, path: rom_info.path, slot: qs_slot) })
|
|
241
|
+
@app.command(menu, :add, :command,
|
|
242
|
+
label: translate('game_picker.menu.set_boxart'),
|
|
243
|
+
command: proc { pick_custom_boxart(rom_info) })
|
|
244
|
+
@app.command(menu, :add, :separator)
|
|
245
|
+
@app.command(menu, :add, :command,
|
|
246
|
+
label: translate('game_picker.menu.remove'),
|
|
247
|
+
command: proc { remove_rom(rom_info) })
|
|
248
|
+
@app.tcl_eval("tk_popup #{menu} [winfo pointerx .] [winfo pointery .]")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def quick_save_exists?(rom_info, slot)
|
|
252
|
+
return false unless rom_info.rom_id
|
|
253
|
+
state_file = File.join(Gemba.user_config.states_dir, rom_info.rom_id, "state#{slot}.ss")
|
|
254
|
+
File.exist?(state_file)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def remove_rom(rom_info)
|
|
258
|
+
@rom_library.remove(rom_info.rom_id)
|
|
259
|
+
@rom_library.save!
|
|
260
|
+
refresh
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def pick_custom_boxart(rom_info)
|
|
264
|
+
return unless @overrides
|
|
265
|
+
filetypes = '{{PNG Images} {.png}}'
|
|
266
|
+
path = @app.tcl_eval("tk_getOpenFile -filetypes {#{filetypes}}")
|
|
267
|
+
return if path.to_s.strip.empty?
|
|
268
|
+
@overrides.set_custom_boxart(rom_info.rom_id, path)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
data/lib/gemba/locales/en.yml
CHANGED
|
@@ -142,6 +142,7 @@ settings:
|
|
|
142
142
|
ra_username_placeholder: "Username:"
|
|
143
143
|
ra_token_placeholder: "Password:"
|
|
144
144
|
ra_rich_presence: "Rich Presence (per-game)"
|
|
145
|
+
ra_screenshot_on_unlock: "Screenshot on achievement unlock (per-game)"
|
|
145
146
|
ra_hardcore: "Hardcore mode (disables save states and rewind)"
|
|
146
147
|
tip_ra_password: "Your password is only used to fetch an API token and is never saved."
|
|
147
148
|
ra_login: "Login"
|
|
@@ -185,6 +186,17 @@ game_picker:
|
|
|
185
186
|
set_boxart: "Set Boxart"
|
|
186
187
|
remove: "Remove from Library"
|
|
187
188
|
|
|
189
|
+
picker:
|
|
190
|
+
toolbar:
|
|
191
|
+
boxart_view: "Box art view"
|
|
192
|
+
list_view: "List view"
|
|
193
|
+
|
|
194
|
+
list_picker:
|
|
195
|
+
columns:
|
|
196
|
+
title: "Title"
|
|
197
|
+
last_played: "Last Played"
|
|
198
|
+
never_played: "Never"
|
|
199
|
+
|
|
188
200
|
rom_info:
|
|
189
201
|
title: "ROM Info"
|
|
190
202
|
field_title: "Title:"
|
data/lib/gemba/locales/ja.yml
CHANGED
|
@@ -142,6 +142,7 @@ settings:
|
|
|
142
142
|
ra_username_placeholder: "ユーザー名:"
|
|
143
143
|
ra_token_placeholder: "パスワード:"
|
|
144
144
|
ra_rich_presence: "リッチプレゼンス(ゲームごと)"
|
|
145
|
+
ra_screenshot_on_unlock: "実績解除時にスクリーンショット(ゲームごと)"
|
|
145
146
|
ra_hardcore: "ハードコアモード(セーブステートと巻き戻し無効)"
|
|
146
147
|
tip_ra_password: "パスワードはAPIトークン取得のみに使用され、保存されません。"
|
|
147
148
|
ra_login: "ログイン"
|
|
@@ -185,6 +186,17 @@ game_picker:
|
|
|
185
186
|
set_boxart: "カスタム画像を設定"
|
|
186
187
|
remove: "ライブラリから削除"
|
|
187
188
|
|
|
189
|
+
picker:
|
|
190
|
+
toolbar:
|
|
191
|
+
boxart_view: "ボックスアート表示"
|
|
192
|
+
list_view: "リスト表示"
|
|
193
|
+
|
|
194
|
+
list_picker:
|
|
195
|
+
columns:
|
|
196
|
+
title: "タイトル"
|
|
197
|
+
last_played: "最終プレイ日"
|
|
198
|
+
never_played: "未プレイ"
|
|
199
|
+
|
|
188
200
|
rom_info:
|
|
189
201
|
title: "ROM情報"
|
|
190
202
|
field_title: "タイトル:"
|
|
@@ -106,9 +106,10 @@ module Gemba
|
|
|
106
106
|
byte = io.read(1)
|
|
107
107
|
raise "Truncated UPS patch (varint read past end)" if byte.nil?
|
|
108
108
|
b = byte.getbyte(0)
|
|
109
|
-
value
|
|
109
|
+
value += (b & 0x7f) << shift
|
|
110
110
|
break if (b & 0x80) != 0
|
|
111
111
|
shift += 7
|
|
112
|
+
value += 1 << shift
|
|
112
113
|
end
|
|
113
114
|
value
|
|
114
115
|
end
|
|
@@ -25,6 +25,7 @@ module Gemba
|
|
|
25
25
|
RA_FEEDBACK_LABEL = "#{FRAME}.ra_feedback"
|
|
26
26
|
RA_HARDCORE_CHECK = "#{FRAME}.ra_hardcore_row.check"
|
|
27
27
|
RA_RICH_PRESENCE_CHECK = "#{FRAME}.ra_rich_presence_row.check"
|
|
28
|
+
RA_SCREENSHOT_CHECK = "#{FRAME}.ra_screenshot_row.check"
|
|
28
29
|
|
|
29
30
|
VAR_BIOS_PATH = '::gemba_bios_path'
|
|
30
31
|
VAR_SKIP_BIOS = '::gemba_skip_bios'
|
|
@@ -34,7 +35,8 @@ module Gemba
|
|
|
34
35
|
VAR_RA_HARDCORE = '::gemba_ra_hardcore'
|
|
35
36
|
VAR_RA_UNOFFICIAL = '::gemba_ra_unofficial'
|
|
36
37
|
VAR_RA_PASSWORD = '::gemba_ra_password'
|
|
37
|
-
VAR_RA_RICH_PRESENCE
|
|
38
|
+
VAR_RA_RICH_PRESENCE = '::gemba_ra_rich_presence'
|
|
39
|
+
VAR_RA_SCREENSHOT = '::gemba_ra_screenshot_on_unlock'
|
|
38
40
|
|
|
39
41
|
RA_UNOFFICIAL_CHECK = "#{FRAME}.ra_unofficial_row.check"
|
|
40
42
|
|
|
@@ -69,7 +71,8 @@ module Gemba
|
|
|
69
71
|
@app.set_variable(VAR_RA_USERNAME, config.ra_username.to_s)
|
|
70
72
|
@app.set_variable(VAR_RA_HARDCORE, config.ra_hardcore? ? '1' : '0')
|
|
71
73
|
@app.set_variable(VAR_RA_UNOFFICIAL, config.ra_unofficial? ? '1' : '0')
|
|
72
|
-
@app.set_variable(VAR_RA_RICH_PRESENCE,
|
|
74
|
+
@app.set_variable(VAR_RA_RICH_PRESENCE, config.ra_rich_presence? ? '1' : '0')
|
|
75
|
+
@app.set_variable(VAR_RA_SCREENSHOT, config.ra_screenshot_on_unlock? ? '1' : '0')
|
|
73
76
|
@app.set_variable(VAR_RA_PASSWORD, '')
|
|
74
77
|
|
|
75
78
|
@presenter&.dispose
|
|
@@ -85,7 +88,8 @@ module Gemba
|
|
|
85
88
|
config.ra_username = @presenter ? @presenter.username : @app.get_variable(VAR_RA_USERNAME).to_s.strip
|
|
86
89
|
config.ra_token = @presenter ? @presenter.token : ''
|
|
87
90
|
config.ra_hardcore = @app.get_variable(VAR_RA_HARDCORE) == '1'
|
|
88
|
-
config.ra_rich_presence
|
|
91
|
+
config.ra_rich_presence = @app.get_variable(VAR_RA_RICH_PRESENCE) == '1'
|
|
92
|
+
config.ra_screenshot_on_unlock = @app.get_variable(VAR_RA_SCREENSHOT) == '1'
|
|
89
93
|
# Password is never persisted — ephemeral field only
|
|
90
94
|
end
|
|
91
95
|
|
|
@@ -252,6 +256,17 @@ module Gemba
|
|
|
252
256
|
command: proc { @mark_dirty.call })
|
|
253
257
|
@app.command(:pack, RA_RICH_PRESENCE_CHECK, side: :left)
|
|
254
258
|
|
|
259
|
+
# Screenshot on achievement unlock (per-game)
|
|
260
|
+
ss_row = "#{FRAME}.ra_screenshot_row"
|
|
261
|
+
@app.command('ttk::frame', ss_row)
|
|
262
|
+
@app.command(:pack, ss_row, fill: :x, padx: 10, pady: [0, 4])
|
|
263
|
+
@app.set_variable(VAR_RA_SCREENSHOT, '1')
|
|
264
|
+
@app.command('ttk::checkbutton', RA_SCREENSHOT_CHECK,
|
|
265
|
+
text: translate('settings.ra_screenshot_on_unlock'),
|
|
266
|
+
variable: VAR_RA_SCREENSHOT,
|
|
267
|
+
command: proc { @mark_dirty.call })
|
|
268
|
+
@app.command(:pack, RA_SCREENSHOT_CHECK, side: :left)
|
|
269
|
+
|
|
255
270
|
# TODO: hardcore mode — not yet wired up, hidden until ready
|
|
256
271
|
# hardcore_row = "#{FRAME}.ra_hardcore_row"
|
|
257
272
|
# @app.command('ttk::frame', hardcore_row)
|
data/lib/gemba/version.rb
CHANGED
|
@@ -123,6 +123,7 @@ module TeekTestHelper
|
|
|
123
123
|
env['VISUAL'] = '1' if ENV['VISUAL']
|
|
124
124
|
env['COVERAGE'] = '1' if ENV['COVERAGE']
|
|
125
125
|
env['GEMBA_BGERROR_LOG'] = bgerror_path
|
|
126
|
+
env['GEMBA_CONFIG_DIR'] = Dir.mktmpdir('gemba-test-config')
|
|
126
127
|
|
|
127
128
|
# -rbundler/setup activates Bundler in the subprocess so path: gems
|
|
128
129
|
# (e.g. teek, teek-sdl2 from sibling repos) are on the load path.
|
|
@@ -37,22 +37,32 @@ class FakeRequester
|
|
|
37
37
|
attr_reader :requests
|
|
38
38
|
|
|
39
39
|
def initialize
|
|
40
|
-
@stubs = {} # r_string => [json_or_nil, ok_bool]
|
|
40
|
+
@stubs = {} # r_string => Array<[json_or_nil, ok_bool]>
|
|
41
41
|
@requests = [] # all params hashes, in call order
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
# Register a canned response for a given r= value.
|
|
45
|
+
# Overwrites any previous stub — the response is reused for every call
|
|
46
|
+
# until replaced. Use stub_queue for ordered sequential responses.
|
|
45
47
|
def stub(r:, body: nil, ok: true)
|
|
46
|
-
@stubs[r.to_s] = [body, ok]
|
|
48
|
+
@stubs[r.to_s] = [[body, ok]]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Enqueue an additional response for a given r= value.
|
|
52
|
+
# Each queued entry is consumed once; the last entry is reused once exhausted.
|
|
53
|
+
def stub_queue(r:, body: nil, ok: true)
|
|
54
|
+
(@stubs[r.to_s] ||= []) << [body, ok]
|
|
47
55
|
end
|
|
48
56
|
|
|
49
57
|
# Called by ra_request with the same signature as Teek::BackgroundWork.new.
|
|
50
58
|
# Ignores the block (which contains real Net::HTTP code) and returns a
|
|
51
59
|
# Result that fires on_progress synchronously with the canned response.
|
|
52
|
-
def call(_app, params, mode: nil, **_opts, &_block)
|
|
60
|
+
def call(_app, params, mode: nil, worker: nil, **_opts, &_block)
|
|
53
61
|
@requests << params.dup
|
|
54
62
|
r = (params[:r] || params["r"]).to_s
|
|
55
|
-
|
|
63
|
+
queue = @stubs.fetch(r, [[nil, false]])
|
|
64
|
+
result = queue.size > 1 ? queue.shift : queue.first
|
|
65
|
+
result = [result[1] ? true : false, params[:a].to_s] if worker
|
|
56
66
|
Result.new(result)
|
|
57
67
|
end
|
|
58
68
|
|
data/test/test_config.rb
CHANGED
|
@@ -746,7 +746,8 @@ class TestMGBAConfig < Minitest::Test
|
|
|
746
746
|
|
|
747
747
|
def test_per_game_settings_constant_keys
|
|
748
748
|
expected = %w[scale pixel_filter integer_scale color_correction frame_blending
|
|
749
|
-
volume muted turbo_speed quick_save_slot save_state_backup ra_rich_presence
|
|
749
|
+
volume muted turbo_speed quick_save_slot save_state_backup ra_rich_presence
|
|
750
|
+
ra_screenshot_on_unlock]
|
|
750
751
|
assert_equal expected.sort, Gemba::Config::PER_GAME_SETTINGS.keys.sort
|
|
751
752
|
end
|
|
752
753
|
|
|
@@ -22,13 +22,13 @@ class TestGamePickerFrame < Minitest::Test
|
|
|
22
22
|
picker.show
|
|
23
23
|
|
|
24
24
|
16.times do |i|
|
|
25
|
-
title = app.command(".game_picker.card#{i}.title", :cget, '-text')
|
|
25
|
+
title = app.command(".game_picker.cards.card#{i}.title", :cget, '-text')
|
|
26
26
|
assert_equal '', title, "Card #{i} title should be empty"
|
|
27
27
|
|
|
28
|
-
img = app.command(".game_picker.card#{i}.img", :cget, '-image')
|
|
28
|
+
img = app.command(".game_picker.cards.card#{i}.img", :cget, '-image')
|
|
29
29
|
assert_equal 'boxart_placeholder', img, "Card #{i} should show placeholder image"
|
|
30
30
|
|
|
31
|
-
bg = app.command(".game_picker.card#{i}", :cget, '-bg')
|
|
31
|
+
bg = app.command(".game_picker.cards.card#{i}", :cget, '-bg')
|
|
32
32
|
refute_equal '#2a2a2a', bg, "Card #{i} should not have populated background"
|
|
33
33
|
end
|
|
34
34
|
|
|
@@ -47,8 +47,8 @@ class TestGamePickerFrame < Minitest::Test
|
|
|
47
47
|
picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib)
|
|
48
48
|
picker.show
|
|
49
49
|
|
|
50
|
-
assert_equal 'Pokemon - Ruby Version (USA, Europe)', app.command('.game_picker.card0.title', :cget, '-text')
|
|
51
|
-
assert_equal 'GBA', app.command('.game_picker.card0.plat', :cget, '-text')
|
|
50
|
+
assert_equal 'Pokemon - Ruby Version (USA, Europe)', app.command('.game_picker.cards.card0.title', :cget, '-text')
|
|
51
|
+
assert_equal 'GBA', app.command('.game_picker.cards.card0.plat', :cget, '-text')
|
|
52
52
|
|
|
53
53
|
picker.cleanup
|
|
54
54
|
app.command(:destroy, '.game_picker') rescue nil
|
|
@@ -64,7 +64,7 @@ class TestGamePickerFrame < Minitest::Test
|
|
|
64
64
|
picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib)
|
|
65
65
|
picker.show
|
|
66
66
|
|
|
67
|
-
assert_equal 'GBC', app.command('.game_picker.card0.plat', :cget, '-text')
|
|
67
|
+
assert_equal 'GBC', app.command('.game_picker.cards.card0.plat', :cget, '-text')
|
|
68
68
|
|
|
69
69
|
picker.cleanup
|
|
70
70
|
app.command(:destroy, '.game_picker') rescue nil
|
|
@@ -80,7 +80,7 @@ class TestGamePickerFrame < Minitest::Test
|
|
|
80
80
|
picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib)
|
|
81
81
|
picker.show
|
|
82
82
|
|
|
83
|
-
assert_equal 'MY-ROM', app.command('.game_picker.card0.title', :cget, '-text')
|
|
83
|
+
assert_equal 'MY-ROM', app.command('.game_picker.cards.card0.title', :cget, '-text')
|
|
84
84
|
|
|
85
85
|
picker.cleanup
|
|
86
86
|
app.command(:destroy, '.game_picker') rescue nil
|
|
@@ -96,8 +96,8 @@ class TestGamePickerFrame < Minitest::Test
|
|
|
96
96
|
picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib)
|
|
97
97
|
picker.show
|
|
98
98
|
|
|
99
|
-
pop_bg = app.command('.game_picker.card0', :cget, '-bg')
|
|
100
|
-
hollow_bg = app.command('.game_picker.card1', :cget, '-bg')
|
|
99
|
+
pop_bg = app.command('.game_picker.cards.card0', :cget, '-bg')
|
|
100
|
+
hollow_bg = app.command('.game_picker.cards.card1', :cget, '-bg')
|
|
101
101
|
|
|
102
102
|
assert_equal '#2a2a2a', pop_bg, "Populated card background"
|
|
103
103
|
refute_equal pop_bg, hollow_bg, "Hollow card should differ from populated"
|
|
@@ -119,11 +119,11 @@ class TestGamePickerFrame < Minitest::Test
|
|
|
119
119
|
picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib)
|
|
120
120
|
picker.show
|
|
121
121
|
|
|
122
|
-
assert_equal 'Alpha', app.command('.game_picker.card0.title', :cget, '-text')
|
|
123
|
-
assert_equal 'GBA', app.command('.game_picker.card0.plat', :cget, '-text')
|
|
124
|
-
assert_equal 'Beta', app.command('.game_picker.card1.title', :cget, '-text')
|
|
125
|
-
assert_equal 'GBC', app.command('.game_picker.card1.plat', :cget, '-text')
|
|
126
|
-
assert_equal '', app.command('.game_picker.card2.title', :cget, '-text')
|
|
122
|
+
assert_equal 'Alpha', app.command('.game_picker.cards.card0.title', :cget, '-text')
|
|
123
|
+
assert_equal 'GBA', app.command('.game_picker.cards.card0.plat', :cget, '-text')
|
|
124
|
+
assert_equal 'Beta', app.command('.game_picker.cards.card1.title', :cget, '-text')
|
|
125
|
+
assert_equal 'GBC', app.command('.game_picker.cards.card1.plat', :cget, '-text')
|
|
126
|
+
assert_equal '', app.command('.game_picker.cards.card2.title', :cget, '-text')
|
|
127
127
|
|
|
128
128
|
picker.cleanup
|
|
129
129
|
app.command(:destroy, '.game_picker') rescue nil
|
|
@@ -154,7 +154,7 @@ class TestGamePickerFrame < Minitest::Test
|
|
|
154
154
|
picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib, boxart_fetcher: fetcher)
|
|
155
155
|
picker.show
|
|
156
156
|
|
|
157
|
-
img_name = app.command('.game_picker.card0.img', :cget, '-image')
|
|
157
|
+
img_name = app.command('.game_picker.cards.card0.img', :cget, '-image')
|
|
158
158
|
assert_equal "boxart_#{game_code}", img_name,
|
|
159
159
|
"Card should display cached boxart image, not the placeholder"
|
|
160
160
|
|
|
@@ -175,7 +175,7 @@ class TestGamePickerFrame < Minitest::Test
|
|
|
175
175
|
picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib)
|
|
176
176
|
picker.show
|
|
177
177
|
|
|
178
|
-
img_name = app.command('.game_picker.card0.img', :cget, '-image')
|
|
178
|
+
img_name = app.command('.game_picker.cards.card0.img', :cget, '-image')
|
|
179
179
|
assert_equal 'boxart_placeholder', img_name
|
|
180
180
|
|
|
181
181
|
picker.cleanup
|
|
@@ -206,12 +206,12 @@ class TestGamePickerFrame < Minitest::Test
|
|
|
206
206
|
# Suppress tk_popup so the right-click binding configures menu entries
|
|
207
207
|
# without posting the menu (no platform grab → no blocking app.update).
|
|
208
208
|
override_tk_popup do
|
|
209
|
-
app.tcl_eval("event generate .game_picker.card0 <Button-3> -x 10 -y 10")
|
|
209
|
+
app.tcl_eval("event generate .game_picker.cards.card0 <Button-3> -x 10 -y 10")
|
|
210
210
|
app.update
|
|
211
211
|
end
|
|
212
212
|
|
|
213
213
|
# index 1 = Quick Load
|
|
214
|
-
state = app.tcl_eval(".game_picker.card0.ctx entrycget 1 -state")
|
|
214
|
+
state = app.tcl_eval(".game_picker.cards.card0.ctx entrycget 1 -state")
|
|
215
215
|
assert_equal 'disabled', state
|
|
216
216
|
|
|
217
217
|
picker.cleanup
|
|
@@ -246,12 +246,12 @@ class TestGamePickerFrame < Minitest::Test
|
|
|
246
246
|
app.update
|
|
247
247
|
|
|
248
248
|
override_tk_popup do
|
|
249
|
-
app.tcl_eval("event generate .game_picker.card0 <Button-3> -x 10 -y 10")
|
|
249
|
+
app.tcl_eval("event generate .game_picker.cards.card0 <Button-3> -x 10 -y 10")
|
|
250
250
|
app.update
|
|
251
251
|
end
|
|
252
252
|
|
|
253
253
|
# index 1 = Quick Load
|
|
254
|
-
state = app.tcl_eval(".game_picker.card0.ctx entrycget 1 -state")
|
|
254
|
+
state = app.tcl_eval(".game_picker.cards.card0.ctx entrycget 1 -state")
|
|
255
255
|
assert_equal 'normal', state
|
|
256
256
|
|
|
257
257
|
picker.cleanup
|
|
@@ -290,13 +290,13 @@ class TestGamePickerFrame < Minitest::Test
|
|
|
290
290
|
app.update
|
|
291
291
|
|
|
292
292
|
override_tk_popup do
|
|
293
|
-
app.tcl_eval("event generate .game_picker.card0 <Button-3> -x 10 -y 10")
|
|
293
|
+
app.tcl_eval("event generate .game_picker.cards.card0 <Button-3> -x 10 -y 10")
|
|
294
294
|
app.update
|
|
295
295
|
end
|
|
296
296
|
|
|
297
297
|
# Menu was built but not shown — invoke the Quick Load entry directly.
|
|
298
298
|
# index 1 = Quick Load
|
|
299
|
-
app.tcl_eval(".game_picker.card0.ctx invoke 1")
|
|
299
|
+
app.tcl_eval(".game_picker.cards.card0.ctx invoke 1")
|
|
300
300
|
app.update
|
|
301
301
|
|
|
302
302
|
assert_equal rom_path, received[:path]
|