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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0244df20f8115eff6f521e2294f607a724c9612593fb5602517265b5a3ca3251
|
|
4
|
+
data.tar.gz: 1c29a7ce92023f3efa2ff72c29073e28baa2e620f97caf456adc0e92e3c56447
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74cf0852173d9ee5c8606e81af756fc2ea65baf6fc222d89f78541ae985023db80b1b14e20954b5c7f7b48da0ff769fe95a30124362c68b3a086ec0e2fc87103
|
|
7
|
+
data.tar.gz: f995335c304686b32c62d67ef4d0f7ee0320961bf935d560dfab087fd78ce64c245a86a244f500be6888610afaf067e03ccb0a472d12adb5682e273f78892c83
|
|
@@ -138,6 +138,10 @@ module Gemba
|
|
|
138
138
|
# completion.
|
|
139
139
|
def sync_unlocks; end
|
|
140
140
|
|
|
141
|
+
# Called on app exit. Backends with pending network state should flush
|
|
142
|
+
# or log anything that couldn't be delivered.
|
|
143
|
+
def shutdown; end
|
|
144
|
+
|
|
141
145
|
# Fetch the full achievement list for a given ROM (by RomInfo) purely for
|
|
142
146
|
# display — does not affect live game state. Calls the block with
|
|
143
147
|
# Array<Achievement> on success, or nil on failure/unsupported.
|
|
@@ -48,13 +48,17 @@ module Gemba
|
|
|
48
48
|
include Achievements::Backend
|
|
49
49
|
|
|
50
50
|
RA_HOST = "retroachievements.org"
|
|
51
|
+
RA_USER_AGENT = "gemba/#{Gemba::VERSION} (https://github.com/jamescook/gemba)"
|
|
51
52
|
RA_PATH = "/dorequest.php"
|
|
52
|
-
PING_BG_MODE
|
|
53
|
+
PING_BG_MODE = (RUBY_VERSION >= "4.0" ? :ractor : :thread).freeze
|
|
54
|
+
UNLOCK_RETRY_BG_MODE = (RUBY_VERSION >= "4.0" ? :ractor : :thread).freeze
|
|
53
55
|
|
|
54
56
|
# Frames between Rich Presence evaluations (~4 s at 60 fps).
|
|
55
57
|
RP_EVAL_INTERVAL = 240
|
|
56
58
|
# Seconds between session ping heartbeats.
|
|
57
59
|
PING_INTERVAL_SEC = 120
|
|
60
|
+
# Seconds between unlock retry sweeps.
|
|
61
|
+
UNLOCK_RETRY_INTERVAL_SEC = 30
|
|
58
62
|
|
|
59
63
|
# Default requester: delegates to Teek::BackgroundWork.
|
|
60
64
|
# Extracted so tests can inject a synchronous fake with the same interface.
|
|
@@ -76,12 +80,15 @@ module Gemba
|
|
|
76
80
|
@rich_presence_message = nil
|
|
77
81
|
@rp_eval_frame = 0
|
|
78
82
|
@ping_last_at = nil
|
|
79
|
-
@ra_runtime
|
|
83
|
+
@ra_runtime = runtime || Gemba::RARuntime.new
|
|
84
|
+
@unlock_queue = []
|
|
85
|
+
@unlock_retry_last_at = nil
|
|
80
86
|
end
|
|
81
87
|
|
|
82
88
|
attr_writer :include_unofficial
|
|
83
89
|
attr_writer :rich_presence_enabled
|
|
84
90
|
attr_reader :rich_presence_message
|
|
91
|
+
attr_reader :unlock_queue
|
|
85
92
|
|
|
86
93
|
# -- Authentication -------------------------------------------------------
|
|
87
94
|
|
|
@@ -111,7 +118,7 @@ module Gemba
|
|
|
111
118
|
fire_auth_change(:ok, nil)
|
|
112
119
|
else
|
|
113
120
|
@authenticated = false
|
|
114
|
-
msg = json
|
|
121
|
+
msg = json ? (json.dig("Error") || "Token invalid") : "Could not connect to RetroAchievements"
|
|
115
122
|
Gemba.log(:warn) { "RA: token verification failed for #{username}: #{msg}" }
|
|
116
123
|
fire_auth_change(:error, msg)
|
|
117
124
|
end
|
|
@@ -124,7 +131,8 @@ module Gemba
|
|
|
124
131
|
fire_auth_change(:ok, nil)
|
|
125
132
|
else
|
|
126
133
|
@authenticated = false
|
|
127
|
-
|
|
134
|
+
msg = json ? (json.dig("Error") || "Token invalid") : "Could not connect to RetroAchievements"
|
|
135
|
+
fire_auth_change(:error, msg)
|
|
128
136
|
end
|
|
129
137
|
end
|
|
130
138
|
end
|
|
@@ -218,6 +226,14 @@ module Gemba
|
|
|
218
226
|
submit_unlock(id)
|
|
219
227
|
end
|
|
220
228
|
|
|
229
|
+
if @unlock_queue.any?
|
|
230
|
+
now = Time.now
|
|
231
|
+
if @unlock_retry_last_at.nil? || now - @unlock_retry_last_at >= UNLOCK_RETRY_INTERVAL_SEC
|
|
232
|
+
@unlock_retry_last_at = now
|
|
233
|
+
drain_unlock_queue
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
221
237
|
return unless @rich_presence_enabled
|
|
222
238
|
|
|
223
239
|
@rp_eval_frame = (@rp_eval_frame + 1) % RP_EVAL_INTERVAL
|
|
@@ -301,6 +317,41 @@ module Gemba
|
|
|
301
317
|
end
|
|
302
318
|
end
|
|
303
319
|
|
|
320
|
+
def drain_unlock_queue
|
|
321
|
+
Gemba.log(:info) { "RA: retrying #{@unlock_queue.size} queued unlock(s)" }
|
|
322
|
+
@unlock_queue.dup.each do |entry|
|
|
323
|
+
data = {
|
|
324
|
+
r: "awardachievement", u: @username, t: @token,
|
|
325
|
+
a: entry[:id], h: entry[:hardcore] ? 1 : 0,
|
|
326
|
+
}
|
|
327
|
+
data = Ractor.make_shareable(data) if UNLOCK_RETRY_BG_MODE == :ractor
|
|
328
|
+
@requester.call(@app, data, mode: UNLOCK_RETRY_BG_MODE, worker: UnlockRetryWorker)
|
|
329
|
+
.on_progress do |result|
|
|
330
|
+
ok, id = result
|
|
331
|
+
if ok
|
|
332
|
+
Gemba.log(:info) { "RA: retry succeeded for achievement #{id}" }
|
|
333
|
+
@unlock_queue.delete_if { |e| e[:id] == id }
|
|
334
|
+
else
|
|
335
|
+
Gemba.log(:warn) { "RA: retry failed for achievement #{id}" }
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Submit an unlock to RA. On failure, queues for background retry.
|
|
342
|
+
def submit_unlock(achievement_id, hardcore: false)
|
|
343
|
+
do_unlock_request(achievement_id, hardcore: hardcore) do |ok|
|
|
344
|
+
enqueue_for_retry(achievement_id, hardcore: hardcore) unless ok
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Call on app exit — logs any unconfirmed unlocks that never drained.
|
|
349
|
+
def shutdown
|
|
350
|
+
return if @unlock_queue.empty?
|
|
351
|
+
ids = @unlock_queue.map { |e| e[:id] }.join(", ")
|
|
352
|
+
Gemba.log(:warn) { "RA: #{@unlock_queue.size} unlock(s) never confirmed — dropped on exit: #{ids}" }
|
|
353
|
+
end
|
|
354
|
+
|
|
304
355
|
private
|
|
305
356
|
|
|
306
357
|
# Fetch patch data (achievement definitions). Does NOT activate the runtime
|
|
@@ -407,18 +458,24 @@ module Gemba
|
|
|
407
458
|
.on_progress { |ok| Gemba.log(ok ? :info : :warn) { "RA: ping g=#{game_id} ok=#{ok}" } }
|
|
408
459
|
end
|
|
409
460
|
|
|
410
|
-
#
|
|
411
|
-
def
|
|
461
|
+
# Fire the awardachievement HTTP request and yield ok (true/false) to block.
|
|
462
|
+
def do_unlock_request(achievement_id, hardcore:, &on_complete)
|
|
412
463
|
ra_request(r: "awardachievement", u: @username, t: @token,
|
|
413
464
|
a: achievement_id, h: hardcore ? 1 : 0) do |json, ok|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
465
|
+
success = ok && json&.dig("Success")
|
|
466
|
+
Gemba.log(success ? :info : :warn) {
|
|
467
|
+
success ? "RA: submitted unlock for achievement #{achievement_id}" \
|
|
468
|
+
: "RA: unlock submission failed for #{achievement_id}: #{json&.dig("Error")}"
|
|
469
|
+
}
|
|
470
|
+
on_complete.call(success)
|
|
419
471
|
end
|
|
420
472
|
end
|
|
421
473
|
|
|
474
|
+
def enqueue_for_retry(achievement_id, hardcore:)
|
|
475
|
+
@unlock_queue << { id: achievement_id, hardcore: hardcore }
|
|
476
|
+
Gemba.log(:info) { "RA: queued achievement #{achievement_id} for retry" }
|
|
477
|
+
end
|
|
478
|
+
|
|
422
479
|
# POST to dorequest.php via @requester (BackgroundWork in production,
|
|
423
480
|
# a synchronous fake in tests). Calls on_done with (json_or_nil, ok_bool).
|
|
424
481
|
def ra_request(params, &on_done)
|
|
@@ -428,6 +485,7 @@ module Gemba
|
|
|
428
485
|
http.use_ssl = true
|
|
429
486
|
http.read_timeout = 15
|
|
430
487
|
req = Net::HTTP::Post.new(uri.path)
|
|
488
|
+
req['User-Agent'] = RA_USER_AGENT
|
|
431
489
|
safe = req_params.reject { |k, _| [:t, :p, "t", "p"].include?(k) }
|
|
432
490
|
Gemba.log(:info) { "RA request: r=#{params[:r]} #{safe.map { |k, v| "#{k}=#{v}" }.join(" ")}" }
|
|
433
491
|
req.set_form_data(req_params.transform_keys(&:to_s).transform_values(&:to_s))
|
|
@@ -16,6 +16,7 @@ module Gemba
|
|
|
16
16
|
http.use_ssl = true
|
|
17
17
|
http.read_timeout = 10
|
|
18
18
|
req = Net::HTTP::Post.new(uri.path)
|
|
19
|
+
req['User-Agent'] = "gemba/#{Gemba::VERSION} (https://github.com/jamescook/gemba)"
|
|
19
20
|
req.set_form_data(data[:params])
|
|
20
21
|
t.yield(http.request(req).is_a?(Net::HTTPSuccess))
|
|
21
22
|
rescue
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
module Achievements
|
|
5
|
+
module RetroAchievements
|
|
6
|
+
# Background worker for retrying failed achievement unlock submissions.
|
|
7
|
+
#
|
|
8
|
+
# Defined as a named class (not a closure) so it is Ractor-safe on
|
|
9
|
+
# Ruby 4+. Receives the same flat params hash used by ra_request
|
|
10
|
+
# (keys: :r, :u, :t, :a, :h). Yields [ok, achievement_id] back to
|
|
11
|
+
# the main thread via on_progress.
|
|
12
|
+
class UnlockRetryWorker
|
|
13
|
+
RA_HOST = "retroachievements.org"
|
|
14
|
+
RA_PATH = "/dorequest.php"
|
|
15
|
+
|
|
16
|
+
def call(t, data)
|
|
17
|
+
require "net/http"
|
|
18
|
+
require "json"
|
|
19
|
+
uri = URI::HTTPS.build(host: RA_HOST, path: RA_PATH)
|
|
20
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
21
|
+
http.use_ssl = true
|
|
22
|
+
http.read_timeout = 15
|
|
23
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
24
|
+
req['User-Agent'] = "gemba/#{Gemba::VERSION} (https://github.com/jamescook/gemba)"
|
|
25
|
+
req.set_form_data(data.transform_keys(&:to_s).transform_values(&:to_s))
|
|
26
|
+
resp = http.request(req)
|
|
27
|
+
ok = resp.is_a?(Net::HTTPSuccess) && JSON.parse(resp.body)["Success"]
|
|
28
|
+
t.yield([ok ? true : false, data[:a].to_s])
|
|
29
|
+
rescue
|
|
30
|
+
t.yield([false, data[:a].to_s])
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/gemba/app_controller.rb
CHANGED
|
@@ -100,10 +100,11 @@ module Gemba
|
|
|
100
100
|
@rom_overrides = RomOverrides.new
|
|
101
101
|
@game_picker = GamePickerFrame.new(app: @app, rom_library: @rom_library,
|
|
102
102
|
boxart_fetcher: @boxart_fetcher, rom_overrides: @rom_overrides)
|
|
103
|
-
@
|
|
104
|
-
|
|
105
|
-
@
|
|
106
|
-
|
|
103
|
+
@list_picker = ListPickerFrame.new(app: @app, rom_library: @rom_library,
|
|
104
|
+
rom_overrides: @rom_overrides)
|
|
105
|
+
@active_picker = @config.picker_view == 'list' ? @list_picker : @game_picker
|
|
106
|
+
@frame_stack.push(:picker, @active_picker)
|
|
107
|
+
apply_picker_window(@active_picker)
|
|
107
108
|
|
|
108
109
|
@help_auto_paused = false
|
|
109
110
|
@cursor_hidden = false
|
|
@@ -188,6 +189,7 @@ module Gemba
|
|
|
188
189
|
bus.on(:open_config_dir) { open_config_dir }
|
|
189
190
|
bus.on(:open_recordings_dir) { open_recordings_dir }
|
|
190
191
|
bus.on(:open_replay_player) { show_replay_player }
|
|
192
|
+
bus.on(:picker_view_changed) { |view:| switch_picker_view(view) }
|
|
191
193
|
|
|
192
194
|
# Frame → controller events
|
|
193
195
|
bus.on(:pause_changed) do |paused|
|
|
@@ -200,6 +202,7 @@ module Gemba
|
|
|
200
202
|
bus.on(:request_quit) { self.running = false if confirm_quit }
|
|
201
203
|
bus.on(:achievement_unlocked) do |achievement:|
|
|
202
204
|
frame&.receive(:show_toast, message: "#{achievement.title} (#{achievement.points}pts)")
|
|
205
|
+
frame&.receive(:take_achievement_screenshot, achievement: achievement) if @config.ra_screenshot_on_unlock?
|
|
203
206
|
end
|
|
204
207
|
bus.on(:ra_login) do |username:, password:|
|
|
205
208
|
achievement_backend.login_with_password(username: username, password: password)
|
|
@@ -420,9 +423,7 @@ module Gemba
|
|
|
420
423
|
@emulator_frame = nil
|
|
421
424
|
@rom_path = nil
|
|
422
425
|
@window.set_title("gemba")
|
|
423
|
-
@
|
|
424
|
-
@window.set_minsize(GamePickerFrame::PICKER_MIN_W, GamePickerFrame::PICKER_MIN_H)
|
|
425
|
-
apply_frame_aspect(@game_picker)
|
|
426
|
+
apply_picker_window(@active_picker)
|
|
426
427
|
@app.command(@view_menu, :entryconfigure, 0, state: :disabled)
|
|
427
428
|
set_event_loop_speed(:idle)
|
|
428
429
|
end
|
|
@@ -1004,12 +1005,32 @@ module Gemba
|
|
|
1004
1005
|
Gemba.open_directory(Config.default_logs_dir)
|
|
1005
1006
|
end
|
|
1006
1007
|
|
|
1008
|
+
def apply_picker_window(picker)
|
|
1009
|
+
w, h = picker.default_geometry
|
|
1010
|
+
mn_w, mn_h = picker.min_geometry
|
|
1011
|
+
@window.set_geometry(w, h)
|
|
1012
|
+
@window.set_minsize(mn_w, mn_h)
|
|
1013
|
+
apply_frame_aspect(picker)
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
def switch_picker_view(view)
|
|
1017
|
+
return if @config.picker_view == view
|
|
1018
|
+
new_picker = view == 'list' ? @list_picker : @game_picker
|
|
1019
|
+
@frame_stack.replace_current(new_picker)
|
|
1020
|
+
@active_picker = new_picker
|
|
1021
|
+
@config.picker_view = view
|
|
1022
|
+
save_config
|
|
1023
|
+
apply_picker_window(new_picker)
|
|
1024
|
+
end
|
|
1025
|
+
|
|
1007
1026
|
def cleanup
|
|
1008
1027
|
return if @cleaned_up
|
|
1009
1028
|
@cleaned_up = true
|
|
1010
1029
|
@emulator_frame&.cleanup
|
|
1011
1030
|
@game_picker&.cleanup
|
|
1031
|
+
@list_picker&.cleanup
|
|
1012
1032
|
RomResolver.cleanup_temp
|
|
1033
|
+
@achievement_backend&.shutdown
|
|
1013
1034
|
end
|
|
1014
1035
|
end
|
|
1015
1036
|
end
|
data/lib/gemba/config.rb
CHANGED
|
@@ -57,7 +57,9 @@ module Gemba
|
|
|
57
57
|
'ra_token' => '',
|
|
58
58
|
'ra_hardcore' => false,
|
|
59
59
|
'ra_unofficial' => false,
|
|
60
|
-
'ra_rich_presence'
|
|
60
|
+
'ra_rich_presence' => false,
|
|
61
|
+
'ra_screenshot_on_unlock' => true,
|
|
62
|
+
'picker_view' => 'grid',
|
|
61
63
|
}.freeze
|
|
62
64
|
|
|
63
65
|
# Settings that can be overridden per ROM. Maps config key → locale key.
|
|
@@ -73,7 +75,8 @@ module Gemba
|
|
|
73
75
|
'turbo_speed' => 'settings.turbo_speed',
|
|
74
76
|
'quick_save_slot' => 'settings.quick_save_slot',
|
|
75
77
|
'save_state_backup' => 'settings.keep_backup',
|
|
76
|
-
'ra_rich_presence'
|
|
78
|
+
'ra_rich_presence' => 'settings.ra_rich_presence',
|
|
79
|
+
'ra_screenshot_on_unlock' => 'settings.ra_screenshot_on_unlock',
|
|
77
80
|
}.freeze
|
|
78
81
|
|
|
79
82
|
PER_GAME_KEYS = PER_GAME_SETTINGS.keys.to_set.freeze
|
|
@@ -496,6 +499,23 @@ module Gemba
|
|
|
496
499
|
global['ra_rich_presence'] = val ? true : false
|
|
497
500
|
end
|
|
498
501
|
|
|
502
|
+
def ra_screenshot_on_unlock?
|
|
503
|
+
global['ra_screenshot_on_unlock']
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def ra_screenshot_on_unlock=(val)
|
|
507
|
+
global['ra_screenshot_on_unlock'] = val ? true : false
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# @return [String] preferred picker view ('grid' or 'list')
|
|
511
|
+
def picker_view
|
|
512
|
+
global['picker_view'] || 'grid'
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def picker_view=(val)
|
|
516
|
+
global['picker_view'] = val.to_s
|
|
517
|
+
end
|
|
518
|
+
|
|
499
519
|
# @return [String] directory for .grec recording files
|
|
500
520
|
def recordings_dir
|
|
501
521
|
global['recordings_dir'] || self.class.default_recordings_dir
|
data/lib/gemba/emulator_frame.rb
CHANGED
|
@@ -492,6 +492,30 @@ module Gemba
|
|
|
492
492
|
@toast&.show(translate('toast.screenshot_failed'))
|
|
493
493
|
end
|
|
494
494
|
|
|
495
|
+
def take_achievement_screenshot(achievement)
|
|
496
|
+
return unless @core && !@core.destroyed?
|
|
497
|
+
|
|
498
|
+
dir = Config.default_screenshots_dir
|
|
499
|
+
FileUtils.mkdir_p(dir)
|
|
500
|
+
|
|
501
|
+
stamp = Time.now.strftime('%Y%m%d_%H%M%S')
|
|
502
|
+
name = "achievement_#{achievement.id}_#{stamp}.png"
|
|
503
|
+
path = File.join(dir, name)
|
|
504
|
+
|
|
505
|
+
pixels = @core.video_buffer_argb
|
|
506
|
+
photo_name = "__gemba_ach_ss_#{object_id}"
|
|
507
|
+
out_w = @platform.width * @scale
|
|
508
|
+
out_h = @platform.height * @scale
|
|
509
|
+
@app.command(:image, :create, :photo, photo_name, width: out_w, height: out_h)
|
|
510
|
+
@app.interp.photo_put_zoomed_block(photo_name, pixels, @platform.width, @platform.height,
|
|
511
|
+
zoom_x: @scale, zoom_y: @scale, format: :argb)
|
|
512
|
+
@app.command(photo_name, :write, path, format: :png)
|
|
513
|
+
@app.command(:image, :delete, photo_name)
|
|
514
|
+
rescue StandardError => e
|
|
515
|
+
Gemba.log(:warn) { "achievement screenshot failed: #{e.message} (#{e.class})" }
|
|
516
|
+
@app.command(:image, :delete, photo_name) rescue nil
|
|
517
|
+
end
|
|
518
|
+
|
|
495
519
|
# -- Recording --------------------------------------------------------------
|
|
496
520
|
|
|
497
521
|
def toggle_recording
|
data/lib/gemba/frame_stack.rb
CHANGED
|
@@ -48,6 +48,16 @@ module Gemba
|
|
|
48
48
|
frame.show
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# Replace the current frame in-place without changing the stack depth.
|
|
52
|
+
#
|
|
53
|
+
# The existing frame is hidden; the new one is shown under the same name.
|
|
54
|
+
def replace_current(frame)
|
|
55
|
+
return unless (entry = @stack.last)
|
|
56
|
+
entry.frame.hide
|
|
57
|
+
@stack[-1] = Entry.new(name: entry.name, frame: frame)
|
|
58
|
+
frame.show
|
|
59
|
+
end
|
|
60
|
+
|
|
51
61
|
# Pop the current frame off the stack.
|
|
52
62
|
#
|
|
53
63
|
# The popped frame is hidden. If there's a previous frame, it is re-shown.
|
|
@@ -30,6 +30,9 @@ module Gemba
|
|
|
30
30
|
PICKER_MIN_W = 576
|
|
31
31
|
PICKER_MIN_H = 768
|
|
32
32
|
|
|
33
|
+
def default_geometry = [PICKER_DEFAULT_W, PICKER_DEFAULT_H]
|
|
34
|
+
def min_geometry = [PICKER_MIN_W, PICKER_MIN_H]
|
|
35
|
+
|
|
33
36
|
def initialize(app:, rom_library:, boxart_fetcher: nil, rom_overrides: nil)
|
|
34
37
|
@app = app
|
|
35
38
|
@rom_library = rom_library
|
|
@@ -43,11 +46,11 @@ module Gemba
|
|
|
43
46
|
def show
|
|
44
47
|
build_ui unless @built
|
|
45
48
|
refresh
|
|
46
|
-
@app.command(:pack, @
|
|
49
|
+
@app.command(:pack, @outer, fill: :both, expand: 1)
|
|
47
50
|
end
|
|
48
51
|
|
|
49
52
|
def hide
|
|
50
|
-
@app.command(:pack, :forget, @
|
|
53
|
+
@app.command(:pack, :forget, @outer) rescue nil
|
|
51
54
|
end
|
|
52
55
|
|
|
53
56
|
def cleanup
|
|
@@ -69,8 +72,12 @@ module Gemba
|
|
|
69
72
|
private
|
|
70
73
|
|
|
71
74
|
def build_ui
|
|
72
|
-
@
|
|
73
|
-
@app.command('ttk::frame', @
|
|
75
|
+
@outer = '.game_picker'
|
|
76
|
+
@app.command('ttk::frame', @outer, padding: 0)
|
|
77
|
+
|
|
78
|
+
@cards_frame = "#{@outer}.cards"
|
|
79
|
+
@app.command('ttk::frame', @cards_frame, padding: 16)
|
|
80
|
+
@app.command(:pack, @cards_frame, fill: :both, expand: 1)
|
|
74
81
|
|
|
75
82
|
# Capture the system window background color so hollow cards blend in
|
|
76
83
|
# rather than appearing as stark black rectangles.
|
|
@@ -84,8 +91,8 @@ module Gemba
|
|
|
84
91
|
row = i / COLS
|
|
85
92
|
col = i % COLS
|
|
86
93
|
|
|
87
|
-
cell = "#{@
|
|
88
|
-
@app.command(:frame, cell, relief: :groove, borderwidth:
|
|
94
|
+
cell = "#{@cards_frame}.card#{i}"
|
|
95
|
+
@app.command(:frame, cell, relief: :groove, borderwidth: 1,
|
|
89
96
|
padx: 4, pady: 4, bg: '#2a2a2a')
|
|
90
97
|
@app.command(:grid, cell, row: row, column: col, padx: 6, pady: 6, sticky: :nsew)
|
|
91
98
|
|
|
@@ -114,12 +121,46 @@ module Gemba
|
|
|
114
121
|
end
|
|
115
122
|
|
|
116
123
|
# Make columns and rows expand evenly
|
|
117
|
-
COLS.times { |c| @app.command(:grid, :columnconfigure, @
|
|
118
|
-
ROWS.times { |r| @app.command(:grid, :rowconfigure, @
|
|
124
|
+
COLS.times { |c| @app.command(:grid, :columnconfigure, @cards_frame, c, weight: 1) }
|
|
125
|
+
ROWS.times { |r| @app.command(:grid, :rowconfigure, @cards_frame, r, weight: 1) }
|
|
126
|
+
|
|
127
|
+
build_toolbar
|
|
119
128
|
|
|
120
129
|
@built = true
|
|
121
130
|
end
|
|
122
131
|
|
|
132
|
+
def build_toolbar
|
|
133
|
+
sep = "#{@outer}.sep"
|
|
134
|
+
@app.command('ttk::separator', sep, orient: :horizontal)
|
|
135
|
+
@app.command(:pack, sep, fill: :x)
|
|
136
|
+
|
|
137
|
+
toolbar = "#{@outer}.toolbar"
|
|
138
|
+
@app.command('ttk::frame', toolbar, padding: [4, 2])
|
|
139
|
+
@app.command(:pack, toolbar, fill: :x)
|
|
140
|
+
|
|
141
|
+
gear_btn = "#{toolbar}.gear"
|
|
142
|
+
gear_menu = "#{toolbar}.gearmenu"
|
|
143
|
+
@app.command('ttk::button', gear_btn, text: "\u2699", width: 1,
|
|
144
|
+
command: proc { post_view_menu(gear_menu, gear_btn) })
|
|
145
|
+
@app.command(:pack, gear_btn, side: :right)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def post_view_menu(menu, btn)
|
|
149
|
+
@app.command(:menu, menu, tearoff: 0) unless @app.tcl_eval("winfo exists #{menu}") == '1'
|
|
150
|
+
@app.command(menu, :delete, 0, :end)
|
|
151
|
+
current = Gemba.user_config.picker_view
|
|
152
|
+
@app.command(menu, :add, :command,
|
|
153
|
+
label: "#{current == 'grid' ? "\u2713 " : ' '}#{translate('picker.toolbar.boxart_view')}",
|
|
154
|
+
command: proc { emit(:picker_view_changed, view: 'grid') })
|
|
155
|
+
@app.command(menu, :add, :command,
|
|
156
|
+
label: "#{current == 'list' ? "\u2713 " : ' '}#{translate('picker.toolbar.list_view')}",
|
|
157
|
+
command: proc { emit(:picker_view_changed, view: 'list') })
|
|
158
|
+
x = @app.tcl_eval("winfo rootx #{btn}").to_i
|
|
159
|
+
y = @app.tcl_eval("winfo rooty #{btn}").to_i
|
|
160
|
+
h = @app.tcl_eval("winfo height #{btn}").to_i
|
|
161
|
+
@app.tcl_eval("tk_popup #{menu} #{x} #{y + h}")
|
|
162
|
+
end
|
|
163
|
+
|
|
123
164
|
def refresh
|
|
124
165
|
roms = @rom_library.all.first(SLOTS)
|
|
125
166
|
|