gemba 0.1.1 → 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/THIRD_PARTY_NOTICES +37 -2
- data/assets/placeholder_boxart.png +0 -0
- data/bin/gemba +2 -2
- data/ext/gemba/extconf.rb +23 -1
- data/ext/gemba/gemba_ext.c +436 -2
- data/ext/gemba/gemba_ext.h +2 -0
- data/gemba.gemspec +5 -3
- data/lib/gemba/achievements/achievement.rb +23 -0
- data/lib/gemba/achievements/backend.rb +190 -0
- data/lib/gemba/achievements/cache.rb +70 -0
- data/lib/gemba/achievements/credentials_presenter.rb +142 -0
- data/lib/gemba/achievements/fake_backend.rb +205 -0
- data/lib/gemba/achievements/null_backend.rb +11 -0
- data/lib/gemba/achievements/offline_backend.rb +168 -0
- data/lib/gemba/achievements/retro_achievements/backend.rb +511 -0
- data/lib/gemba/achievements/retro_achievements/cli_sync_requester.rb +64 -0
- data/lib/gemba/achievements/retro_achievements/ping_worker.rb +28 -0
- data/lib/gemba/achievements/retro_achievements/unlock_retry_worker.rb +35 -0
- data/lib/gemba/achievements.rb +19 -0
- data/lib/gemba/achievements_window.rb +556 -0
- data/lib/gemba/app_controller.rb +1036 -0
- data/lib/gemba/bios.rb +54 -0
- data/lib/gemba/boxart_fetcher/libretro_backend.rb +39 -0
- data/lib/gemba/boxart_fetcher/null_backend.rb +12 -0
- data/lib/gemba/boxart_fetcher.rb +79 -0
- data/lib/gemba/bus_emitter.rb +13 -0
- data/lib/gemba/child_window.rb +24 -1
- data/lib/gemba/cli/commands/config_cmd.rb +83 -0
- data/lib/gemba/cli/commands/decode.rb +154 -0
- data/lib/gemba/cli/commands/patch.rb +78 -0
- data/lib/gemba/cli/commands/play.rb +78 -0
- data/lib/gemba/cli/commands/record.rb +114 -0
- data/lib/gemba/cli/commands/replay.rb +161 -0
- data/lib/gemba/cli/commands/retro_achievements.rb +213 -0
- data/lib/gemba/cli/commands/version.rb +22 -0
- data/lib/gemba/cli.rb +52 -364
- data/lib/gemba/config.rb +154 -1
- data/lib/gemba/data/gb_games.json +1 -0
- data/lib/gemba/data/gb_md5.json +1 -0
- data/lib/gemba/data/gba_games.json +1 -0
- data/lib/gemba/data/gba_md5.json +1 -0
- data/lib/gemba/data/gbc_games.json +1 -0
- data/lib/gemba/data/gbc_md5.json +1 -0
- data/lib/gemba/emulator_frame.rb +1084 -0
- data/lib/gemba/event_bus.rb +48 -0
- data/lib/gemba/frame_stack.rb +70 -0
- data/lib/gemba/game_index.rb +84 -0
- data/lib/gemba/game_picker_frame.rb +309 -0
- data/lib/gemba/gamepad_map.rb +103 -0
- data/lib/gemba/headless.rb +6 -5
- data/lib/gemba/headless_player.rb +33 -3
- data/lib/gemba/help_window.rb +61 -0
- data/lib/gemba/hotkey_map.rb +3 -1
- data/lib/gemba/input_recorder.rb +107 -0
- data/lib/gemba/input_replayer.rb +119 -0
- data/lib/gemba/keyboard_map.rb +90 -0
- data/lib/gemba/list_picker_frame.rb +271 -0
- data/lib/gemba/locales/en.yml +109 -5
- data/lib/gemba/locales/ja.yml +109 -5
- data/lib/gemba/main_window.rb +56 -0
- data/lib/gemba/modal_stack.rb +81 -0
- data/lib/gemba/patcher_window.rb +223 -0
- data/lib/gemba/platform/gb.rb +21 -0
- data/lib/gemba/platform/gba.rb +21 -0
- data/lib/gemba/platform/gbc.rb +23 -0
- data/lib/gemba/platform.rb +20 -0
- data/lib/gemba/platform_open.rb +19 -0
- data/lib/gemba/recorder.rb +4 -3
- data/lib/gemba/replay_player.rb +691 -0
- data/lib/gemba/rom_info.rb +57 -0
- data/lib/gemba/rom_info_window.rb +16 -3
- data/lib/gemba/rom_library.rb +106 -0
- data/lib/gemba/rom_overrides.rb +47 -0
- data/lib/gemba/rom_patcher/bps.rb +161 -0
- data/lib/gemba/rom_patcher/ips.rb +101 -0
- data/lib/gemba/rom_patcher/ups.rb +119 -0
- data/lib/gemba/rom_patcher.rb +109 -0
- data/lib/gemba/{rom_loader.rb → rom_resolver.rb} +7 -6
- data/lib/gemba/runtime.rb +59 -26
- data/lib/gemba/save_state_manager.rb +4 -7
- data/lib/gemba/save_state_picker.rb +17 -4
- data/lib/gemba/session_logger.rb +64 -0
- data/lib/gemba/settings/audio_tab.rb +77 -0
- data/lib/gemba/settings/gamepad_tab.rb +351 -0
- data/lib/gemba/settings/hotkeys_tab.rb +259 -0
- data/lib/gemba/settings/paths.rb +11 -0
- data/lib/gemba/settings/recording_tab.rb +83 -0
- data/lib/gemba/settings/save_states_tab.rb +91 -0
- data/lib/gemba/settings/system_tab.rb +377 -0
- data/lib/gemba/settings/video_tab.rb +318 -0
- data/lib/gemba/settings_window.rb +162 -1036
- data/lib/gemba/version.rb +1 -1
- data/lib/gemba/virtual_keyboard.rb +19 -0
- data/lib/gemba.rb +2 -12
- data/test/achievements_window/test_bulk_sync.rb +218 -0
- data/test/achievements_window/test_bus_events.rb +125 -0
- data/test/achievements_window/test_close_confirmation.rb +201 -0
- data/test/achievements_window/test_initial_state.rb +164 -0
- data/test/achievements_window/test_sorting.rb +227 -0
- data/test/achievements_window/test_tree_rendering.rb +133 -0
- data/test/fixtures/fake_bios.bin +0 -0
- data/test/fixtures/pong.gba +0 -0
- data/test/fixtures/test.gb +0 -0
- data/test/fixtures/test.gbc +0 -0
- data/test/fixtures/test_quicksave.ss +0 -0
- data/test/screenshots/no_focus.png +0 -0
- data/test/shared/teek_test_worker.rb +17 -1
- data/test/shared/tk_test_helper.rb +92 -4
- data/test/support/achievements_window_helpers.rb +18 -0
- data/test/support/fake_core.rb +25 -0
- data/test/support/fake_ra_runtime.rb +74 -0
- data/test/support/fake_requester.rb +78 -0
- data/test/support/player_helpers.rb +20 -5
- data/test/test_achievement.rb +32 -0
- data/test/{test_player.rb → test_app_controller.rb} +353 -85
- data/test/test_bios.rb +123 -0
- data/test/test_boxart_fetcher.rb +150 -0
- data/test/test_cli.rb +17 -265
- data/test/test_cli_config.rb +64 -0
- data/test/test_cli_decode.rb +97 -0
- data/test/test_cli_patch.rb +58 -0
- data/test/test_cli_play.rb +213 -0
- data/test/test_cli_ra.rb +175 -0
- data/test/test_cli_record.rb +69 -0
- data/test/test_cli_replay.rb +72 -0
- data/test/test_cli_sync_requester.rb +152 -0
- data/test/test_cli_version.rb +27 -0
- data/test/test_config.rb +3 -3
- data/test/test_config_ra.rb +69 -0
- data/test/test_core.rb +62 -1
- data/test/test_credentials_presenter.rb +192 -0
- data/test/test_event_bus.rb +100 -0
- data/test/test_fake_backend_achievements.rb +130 -0
- data/test/test_fake_backend_auth.rb +68 -0
- data/test/test_game_index.rb +77 -0
- data/test/test_game_picker_frame.rb +310 -0
- data/test/test_gamepad_map.rb +1 -3
- data/test/test_headless_player.rb +17 -3
- data/test/test_help_window.rb +82 -0
- data/test/test_hotkey_map.rb +22 -1
- data/test/test_input_recorder.rb +179 -0
- data/test/test_input_replay_determinism.rb +113 -0
- data/test/test_input_replayer.rb +162 -0
- data/test/test_keyboard_map.rb +1 -3
- data/test/test_libretro_backend.rb +41 -0
- data/test/test_list_picker_frame.rb +391 -0
- data/test/test_locale.rb +1 -1
- data/test/test_logging.rb +123 -0
- data/test/test_null_backend.rb +42 -0
- data/test/test_offline_backend.rb +116 -0
- data/test/test_overlay_renderer.rb +1 -1
- data/test/test_platform.rb +149 -0
- data/test/test_ra_backend.rb +313 -0
- data/test/test_ra_backend_unlock_gate.rb +56 -0
- data/test/test_ra_backend_unlock_retry.rb +123 -0
- data/test/test_recorder.rb +0 -3
- data/test/test_replay_player.rb +316 -0
- data/test/test_rom_info.rb +149 -0
- data/test/test_rom_overrides.rb +86 -0
- data/test/test_rom_patcher.rb +383 -0
- data/test/{test_rom_loader.rb → test_rom_resolver.rb} +25 -26
- data/test/test_save_state_manager.rb +2 -4
- data/test/test_settings_audio.rb +107 -0
- data/test/test_settings_hotkeys.rb +83 -66
- data/test/test_settings_recording.rb +49 -0
- data/test/test_settings_save_states.rb +97 -0
- data/test/test_settings_system.rb +133 -0
- data/test/test_settings_video.rb +450 -0
- data/test/test_settings_window.rb +76 -507
- data/test/test_tip_service.rb +6 -6
- data/test/test_toast_overlay.rb +1 -1
- data/test/test_virtual_events.rb +221 -0
- data/test/test_virtual_keyboard.rb +1 -1
- data/vendor/rcheevos/CHANGELOG.md +495 -0
- data/vendor/rcheevos/LICENSE +21 -0
- data/vendor/rcheevos/Package.swift +33 -0
- data/vendor/rcheevos/README.md +67 -0
- data/vendor/rcheevos/include/module.modulemap +70 -0
- data/vendor/rcheevos/include/rc_api_editor.h +296 -0
- data/vendor/rcheevos/include/rc_api_info.h +280 -0
- data/vendor/rcheevos/include/rc_api_request.h +77 -0
- data/vendor/rcheevos/include/rc_api_runtime.h +417 -0
- data/vendor/rcheevos/include/rc_api_user.h +262 -0
- data/vendor/rcheevos/include/rc_client.h +877 -0
- data/vendor/rcheevos/include/rc_client_raintegration.h +101 -0
- data/vendor/rcheevos/include/rc_consoles.h +138 -0
- data/vendor/rcheevos/include/rc_error.h +59 -0
- data/vendor/rcheevos/include/rc_export.h +100 -0
- data/vendor/rcheevos/include/rc_hash.h +200 -0
- data/vendor/rcheevos/include/rc_runtime.h +148 -0
- data/vendor/rcheevos/include/rc_runtime_types.h +452 -0
- data/vendor/rcheevos/include/rc_util.h +51 -0
- data/vendor/rcheevos/include/rcheevos.h +8 -0
- data/vendor/rcheevos/src/rapi/rc_api_common.c +1379 -0
- data/vendor/rcheevos/src/rapi/rc_api_common.h +88 -0
- data/vendor/rcheevos/src/rapi/rc_api_editor.c +625 -0
- data/vendor/rcheevos/src/rapi/rc_api_info.c +587 -0
- data/vendor/rcheevos/src/rapi/rc_api_runtime.c +901 -0
- data/vendor/rcheevos/src/rapi/rc_api_user.c +483 -0
- data/vendor/rcheevos/src/rc_client.c +6941 -0
- data/vendor/rcheevos/src/rc_client_external.c +281 -0
- data/vendor/rcheevos/src/rc_client_external.h +177 -0
- data/vendor/rcheevos/src/rc_client_external_versions.h +171 -0
- data/vendor/rcheevos/src/rc_client_internal.h +409 -0
- data/vendor/rcheevos/src/rc_client_raintegration.c +566 -0
- data/vendor/rcheevos/src/rc_client_raintegration_internal.h +61 -0
- data/vendor/rcheevos/src/rc_client_types.natvis +396 -0
- data/vendor/rcheevos/src/rc_compat.c +251 -0
- data/vendor/rcheevos/src/rc_compat.h +121 -0
- data/vendor/rcheevos/src/rc_libretro.c +915 -0
- data/vendor/rcheevos/src/rc_libretro.h +98 -0
- data/vendor/rcheevos/src/rc_util.c +199 -0
- data/vendor/rcheevos/src/rc_version.c +11 -0
- data/vendor/rcheevos/src/rc_version.h +32 -0
- data/vendor/rcheevos/src/rcheevos/alloc.c +312 -0
- data/vendor/rcheevos/src/rcheevos/condition.c +754 -0
- data/vendor/rcheevos/src/rcheevos/condset.c +777 -0
- data/vendor/rcheevos/src/rcheevos/consoleinfo.c +1215 -0
- data/vendor/rcheevos/src/rcheevos/format.c +330 -0
- data/vendor/rcheevos/src/rcheevos/lboard.c +287 -0
- data/vendor/rcheevos/src/rcheevos/memref.c +805 -0
- data/vendor/rcheevos/src/rcheevos/operand.c +607 -0
- data/vendor/rcheevos/src/rcheevos/rc_internal.h +390 -0
- data/vendor/rcheevos/src/rcheevos/rc_runtime_types.natvis +541 -0
- data/vendor/rcheevos/src/rcheevos/rc_validate.c +1406 -0
- data/vendor/rcheevos/src/rcheevos/rc_validate.h +18 -0
- data/vendor/rcheevos/src/rcheevos/richpresence.c +922 -0
- data/vendor/rcheevos/src/rcheevos/runtime.c +852 -0
- data/vendor/rcheevos/src/rcheevos/runtime_progress.c +1073 -0
- data/vendor/rcheevos/src/rcheevos/trigger.c +344 -0
- data/vendor/rcheevos/src/rcheevos/value.c +935 -0
- data/vendor/rcheevos/src/rhash/aes.c +480 -0
- data/vendor/rcheevos/src/rhash/aes.h +49 -0
- data/vendor/rcheevos/src/rhash/cdreader.c +838 -0
- data/vendor/rcheevos/src/rhash/hash.c +1402 -0
- data/vendor/rcheevos/src/rhash/hash_disc.c +1340 -0
- data/vendor/rcheevos/src/rhash/hash_encrypted.c +566 -0
- data/vendor/rcheevos/src/rhash/hash_rom.c +426 -0
- data/vendor/rcheevos/src/rhash/hash_zip.c +460 -0
- data/vendor/rcheevos/src/rhash/md5.c +382 -0
- data/vendor/rcheevos/src/rhash/md5.h +91 -0
- data/vendor/rcheevos/src/rhash/rc_hash_internal.h +116 -0
- data/vendor/rcheevos/test/libretro.h +205 -0
- data/vendor/rcheevos/test/rapi/test_rc_api_common.c +941 -0
- data/vendor/rcheevos/test/rapi/test_rc_api_editor.c +931 -0
- data/vendor/rcheevos/test/rapi/test_rc_api_info.c +545 -0
- data/vendor/rcheevos/test/rapi/test_rc_api_runtime.c +2213 -0
- data/vendor/rcheevos/test/rapi/test_rc_api_user.c +998 -0
- data/vendor/rcheevos/test/rcheevos/mock_memory.h +32 -0
- data/vendor/rcheevos/test/rcheevos/test_condition.c +570 -0
- data/vendor/rcheevos/test/rcheevos/test_condset.c +5170 -0
- data/vendor/rcheevos/test/rcheevos/test_consoleinfo.c +203 -0
- data/vendor/rcheevos/test/rcheevos/test_format.c +112 -0
- data/vendor/rcheevos/test/rcheevos/test_lboard.c +746 -0
- data/vendor/rcheevos/test/rcheevos/test_memref.c +520 -0
- data/vendor/rcheevos/test/rcheevos/test_operand.c +692 -0
- data/vendor/rcheevos/test/rcheevos/test_rc_validate.c +502 -0
- data/vendor/rcheevos/test/rcheevos/test_richpresence.c +1564 -0
- data/vendor/rcheevos/test/rcheevos/test_runtime.c +1667 -0
- data/vendor/rcheevos/test/rcheevos/test_runtime_progress.c +1821 -0
- data/vendor/rcheevos/test/rcheevos/test_timing.c +166 -0
- data/vendor/rcheevos/test/rcheevos/test_trigger.c +2521 -0
- data/vendor/rcheevos/test/rcheevos/test_value.c +870 -0
- data/vendor/rcheevos/test/rcheevos-test.sln +46 -0
- data/vendor/rcheevos/test/rcheevos-test.vcxproj +239 -0
- data/vendor/rcheevos/test/rcheevos-test.vcxproj.filters +335 -0
- data/vendor/rcheevos/test/rhash/data.c +657 -0
- data/vendor/rcheevos/test/rhash/data.h +32 -0
- data/vendor/rcheevos/test/rhash/mock_filereader.c +236 -0
- data/vendor/rcheevos/test/rhash/mock_filereader.h +31 -0
- data/vendor/rcheevos/test/rhash/test_cdreader.c +920 -0
- data/vendor/rcheevos/test/rhash/test_hash.c +310 -0
- data/vendor/rcheevos/test/rhash/test_hash_disc.c +1450 -0
- data/vendor/rcheevos/test/rhash/test_hash_rom.c +899 -0
- data/vendor/rcheevos/test/rhash/test_hash_zip.c +551 -0
- data/vendor/rcheevos/test/test.c +113 -0
- data/vendor/rcheevos/test/test_framework.h +205 -0
- data/vendor/rcheevos/test/test_rc_client.c +10509 -0
- data/vendor/rcheevos/test/test_rc_client_external.c +2197 -0
- data/vendor/rcheevos/test/test_rc_client_raintegration.c +441 -0
- data/vendor/rcheevos/test/test_rc_libretro.c +952 -0
- data/vendor/rcheevos/test/test_types.natvis +9 -0
- data/vendor/rcheevos/validator/validator.c +658 -0
- data/vendor/rcheevos/validator/validator.vcxproj +152 -0
- data/vendor/rcheevos/validator/validator.vcxproj.filters +82 -0
- metadata +277 -10
- data/lib/gemba/input_mappings.rb +0 -214
- data/lib/gemba/player.rb +0 -1525
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Gemba
|
|
7
|
+
module Achievements
|
|
8
|
+
module RetroAchievements
|
|
9
|
+
# Synchronous HTTP requester for CLI use.
|
|
10
|
+
#
|
|
11
|
+
# Implements the same interface as FakeRequester and the DEFAULT_REQUESTER
|
|
12
|
+
# lambda in Backend — call() returns a Result that fires on_progress
|
|
13
|
+
# synchronously — but makes a real blocking Net::HTTP POST instead of
|
|
14
|
+
# delegating to Teek::BackgroundWork.
|
|
15
|
+
#
|
|
16
|
+
# This means CLI commands get their result back in-line with no event loop.
|
|
17
|
+
class CliSyncRequester
|
|
18
|
+
# Mirrors FakeRequester::Result so the calling code (ra_request) is
|
|
19
|
+
# identical regardless of whether it's running in CLI or GUI mode.
|
|
20
|
+
class Result
|
|
21
|
+
def initialize(value)
|
|
22
|
+
@value = value
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def on_progress(&block)
|
|
26
|
+
block.call(@value)
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def on_done(&block)
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Called by ra_request with the same signature as the DEFAULT_REQUESTER
|
|
36
|
+
# lambda. Ignores the BackgroundWork block (which uses t.yield / Ractor
|
|
37
|
+
# protocol) and performs a direct synchronous HTTP POST instead.
|
|
38
|
+
def call(_app, params, mode: nil, **_opts, &_block)
|
|
39
|
+
Result.new(perform(params))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def perform(params)
|
|
45
|
+
uri = URI::HTTPS.build(host: Backend::RA_HOST, path: Backend::RA_PATH)
|
|
46
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
47
|
+
http.use_ssl = true
|
|
48
|
+
http.read_timeout = 15
|
|
49
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
50
|
+
req.set_form_data(params.transform_keys(&:to_s).transform_values(&:to_s))
|
|
51
|
+
resp = http.request(req)
|
|
52
|
+
if resp.is_a?(Net::HTTPSuccess)
|
|
53
|
+
[JSON.parse(resp.body), true]
|
|
54
|
+
else
|
|
55
|
+
[nil, false]
|
|
56
|
+
end
|
|
57
|
+
rescue => e
|
|
58
|
+
$stderr.puts "RA request error (#{params[:r]}): #{e.class} #{e.message}"
|
|
59
|
+
[nil, false]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
module Achievements
|
|
5
|
+
module RetroAchievements
|
|
6
|
+
# Background worker for the RA session ping heartbeat.
|
|
7
|
+
#
|
|
8
|
+
# Defined as a named class (not a closure) so it is Ractor-safe on
|
|
9
|
+
# Ruby 4+. All state is passed through the data hash; no captured
|
|
10
|
+
# variables.
|
|
11
|
+
class PingWorker
|
|
12
|
+
def call(t, data)
|
|
13
|
+
require "net/http"
|
|
14
|
+
uri = URI::HTTPS.build(host: data[:host], path: data[:path])
|
|
15
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
16
|
+
http.use_ssl = true
|
|
17
|
+
http.read_timeout = 10
|
|
18
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
19
|
+
req['User-Agent'] = "gemba/#{Gemba::VERSION} (https://github.com/jamescook/gemba)"
|
|
20
|
+
req.set_form_data(data[:params])
|
|
21
|
+
t.yield(http.request(req).is_a?(Net::HTTPSuccess))
|
|
22
|
+
rescue
|
|
23
|
+
t.yield(false)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
module Achievements
|
|
5
|
+
# Build the appropriate backend based on config.
|
|
6
|
+
# Returns NullBackend if RA is disabled.
|
|
7
|
+
# Requires app: (Teek app) for RetroAchievements::Backend's BackgroundWork HTTP calls.
|
|
8
|
+
#
|
|
9
|
+
# @param config [Config]
|
|
10
|
+
# @param app [Teek::App, nil]
|
|
11
|
+
# @return [Backend]
|
|
12
|
+
def self.backend_for(config, app: nil)
|
|
13
|
+
return NullBackend.new unless config.ra_enabled?
|
|
14
|
+
return NullBackend.new unless app
|
|
15
|
+
|
|
16
|
+
RetroAchievements::Backend.new(app: app)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
# Displays achievements for the currently loaded GBA game.
|
|
5
|
+
#
|
|
6
|
+
# Non-modal window accessible from View > Achievements. Shows a treeview
|
|
7
|
+
# of all achievements with name, points, and earned date. A Sync button
|
|
8
|
+
# pulls the latest earned state from the RA server. Only the currently
|
|
9
|
+
# loaded game has live data; other GBA games in the library show empty.
|
|
10
|
+
class AchievementsWindow
|
|
11
|
+
include ChildWindow
|
|
12
|
+
include Locale::Translatable
|
|
13
|
+
|
|
14
|
+
TOP = '.gemba_achievements'
|
|
15
|
+
|
|
16
|
+
VAR_UNOFFICIAL = '::gemba_ach_unofficial'
|
|
17
|
+
|
|
18
|
+
def initialize(app:, rom_library:, config:, callbacks: {})
|
|
19
|
+
@app = app
|
|
20
|
+
@rom_library = rom_library
|
|
21
|
+
@config = config
|
|
22
|
+
@callbacks = callbacks
|
|
23
|
+
@built = false
|
|
24
|
+
@backend = nil
|
|
25
|
+
@current_rom_id = nil
|
|
26
|
+
@game_entries = []
|
|
27
|
+
@tree_items = []
|
|
28
|
+
@item_descriptions = {}
|
|
29
|
+
@tip_item = nil
|
|
30
|
+
@tip_timer = nil
|
|
31
|
+
@current_list = []
|
|
32
|
+
@sort_col = nil # nil = default order
|
|
33
|
+
@sort_asc = true
|
|
34
|
+
@bulk_syncing = false
|
|
35
|
+
@bulk_cancelled = false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Called by AppController when a ROM loads or the backend is swapped.
|
|
39
|
+
# Updates internal state and refreshes the window if it's visible.
|
|
40
|
+
def update_game(rom_id:, backend:)
|
|
41
|
+
@current_rom_id = rom_id
|
|
42
|
+
@backend = backend
|
|
43
|
+
return unless @built
|
|
44
|
+
|
|
45
|
+
refresh_game_list
|
|
46
|
+
select_game(rom_id)
|
|
47
|
+
populate_tree
|
|
48
|
+
update_title
|
|
49
|
+
update_rich_presence
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Called by AppController when on_achievements_changed fires.
|
|
53
|
+
def refresh(backend = @backend)
|
|
54
|
+
@backend = backend
|
|
55
|
+
@display_list = nil # live backend data takes precedence over cached sync
|
|
56
|
+
return unless @built
|
|
57
|
+
|
|
58
|
+
populate_tree
|
|
59
|
+
update_rich_presence
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def show
|
|
63
|
+
build_ui unless @built
|
|
64
|
+
refresh_game_list
|
|
65
|
+
select_game(@current_rom_id)
|
|
66
|
+
# For non-current games (or when no game is loaded), seed from cache so
|
|
67
|
+
# previously-synced data appears without requiring another network hit.
|
|
68
|
+
if @display_list.nil? && @current_rom_id
|
|
69
|
+
cached = Achievements::Cache.read(@current_rom_id)
|
|
70
|
+
@display_list = cached if cached && @backend&.achievement_list.to_a.empty?
|
|
71
|
+
end
|
|
72
|
+
populate_tree
|
|
73
|
+
refresh_auth_state unless @backend&.authenticated?
|
|
74
|
+
update_title
|
|
75
|
+
show_window(modal: false)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def hide
|
|
79
|
+
if @bulk_syncing
|
|
80
|
+
result = @app.command('tk_messageBox',
|
|
81
|
+
parent: TOP,
|
|
82
|
+
title: translate('dialog.cancel_bulk_sync_title'),
|
|
83
|
+
message: translate('dialog.cancel_bulk_sync_msg'),
|
|
84
|
+
type: :yesno,
|
|
85
|
+
icon: :warning)
|
|
86
|
+
return unless result == 'yes'
|
|
87
|
+
@bulk_cancelled = true
|
|
88
|
+
@bulk_syncing = false
|
|
89
|
+
unlock_ui_after_bulk_sync
|
|
90
|
+
end
|
|
91
|
+
hide_window(modal: false)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# ModalStack protocol (non-modal — no grab)
|
|
95
|
+
def show_modal(**_args)
|
|
96
|
+
build_ui unless @built
|
|
97
|
+
refresh_game_list
|
|
98
|
+
select_game(@current_rom_id)
|
|
99
|
+
populate_tree
|
|
100
|
+
update_title
|
|
101
|
+
position_near_parent
|
|
102
|
+
@app.command(:wm, 'deiconify', TOP)
|
|
103
|
+
@app.command(:raise, TOP)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def withdraw
|
|
107
|
+
@app.command(:wm, 'withdraw', TOP)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def build_ui
|
|
113
|
+
build_toplevel(translate('achievements.title'), geometry: '560x440') do
|
|
114
|
+
build_toolbar
|
|
115
|
+
build_tree
|
|
116
|
+
build_status
|
|
117
|
+
end
|
|
118
|
+
setup_bus_subscriptions
|
|
119
|
+
@built = true
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def setup_bus_subscriptions
|
|
123
|
+
Gemba.bus.on(:ra_sync_started) do
|
|
124
|
+
@app.command(@sync_btn, :configure, state: :disabled)
|
|
125
|
+
set_status(translate('achievements.sync_pending'))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
Gemba.bus.on(:ra_sync_done) do |ok:, reason: nil, **|
|
|
129
|
+
# Re-enable only if still authenticated; logout during an in-flight
|
|
130
|
+
# request should leave the button disabled.
|
|
131
|
+
refresh_auth_state
|
|
132
|
+
unless ok
|
|
133
|
+
key = case reason
|
|
134
|
+
when :no_game then 'achievements.sync_no_game'
|
|
135
|
+
when :timeout then 'achievements.sync_timeout'
|
|
136
|
+
else 'achievements.sync_failed'
|
|
137
|
+
end
|
|
138
|
+
set_status(translate(key))
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
Gemba.bus.on(:ra_auth_result) do |status:, **|
|
|
143
|
+
refresh_auth_state
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
Gemba.bus.on(:ra_rich_presence_changed) do |message:, **|
|
|
147
|
+
next unless @rp_lbl
|
|
148
|
+
@app.command(@rp_lbl, :configure, text: message)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def refresh_auth_state
|
|
153
|
+
authenticated = @backend&.authenticated?
|
|
154
|
+
state = authenticated ? :normal : :disabled
|
|
155
|
+
@app.command(@sync_btn, :configure, state: state)
|
|
156
|
+
@app.command(@unofficial_check, :configure, state: state) if @unofficial_check
|
|
157
|
+
set_status(translate('achievements.not_logged_in')) unless authenticated
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def build_toolbar
|
|
161
|
+
f = "#{TOP}.toolbar"
|
|
162
|
+
@app.command('ttk::frame', f, padding: [8, 8, 8, 4])
|
|
163
|
+
@app.command(:pack, f, fill: :x)
|
|
164
|
+
|
|
165
|
+
lbl = "#{f}.lbl"
|
|
166
|
+
@app.command('ttk::label', lbl, text: translate('achievements.game_label'))
|
|
167
|
+
@app.command(:pack, lbl, side: :left, padx: [0, 4])
|
|
168
|
+
|
|
169
|
+
@combo = "#{f}.combo"
|
|
170
|
+
@app.command('ttk::combobox', @combo, state: :readonly, width: 36)
|
|
171
|
+
@app.command(:pack, @combo, side: :left)
|
|
172
|
+
@app.command(:bind, @combo, '<<ComboboxSelected>>', proc { |*| on_game_selected })
|
|
173
|
+
|
|
174
|
+
@sync_btn = "#{f}.sync"
|
|
175
|
+
@app.command('ttk::button', @sync_btn,
|
|
176
|
+
text: translate('achievements.sync'),
|
|
177
|
+
state: @backend&.authenticated? ? :normal : :disabled,
|
|
178
|
+
command: proc { sync })
|
|
179
|
+
@app.command(:pack, @sync_btn, side: :left, padx: [8, 0])
|
|
180
|
+
|
|
181
|
+
@unofficial_check = "#{f}.unofficial"
|
|
182
|
+
@app.set_variable(VAR_UNOFFICIAL, @config.ra_unofficial? ? '1' : '0')
|
|
183
|
+
@app.command('ttk::checkbutton', @unofficial_check,
|
|
184
|
+
text: translate('achievements.include_unofficial'),
|
|
185
|
+
variable: VAR_UNOFFICIAL,
|
|
186
|
+
command: proc { on_unofficial_toggled })
|
|
187
|
+
@app.command(:pack, @unofficial_check, side: :left, padx: [12, 0])
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def build_tree
|
|
191
|
+
f = "#{TOP}.tf"
|
|
192
|
+
@app.command('ttk::frame', f, padding: [8, 0, 8, 4])
|
|
193
|
+
@app.command(:pack, f, fill: :both, expand: 1)
|
|
194
|
+
|
|
195
|
+
@tree = "#{f}.tree"
|
|
196
|
+
@scrollbar = "#{f}.sb"
|
|
197
|
+
|
|
198
|
+
@app.command('ttk::treeview', @tree,
|
|
199
|
+
columns: Teek.make_list('name', 'points', 'earned'),
|
|
200
|
+
show: :headings,
|
|
201
|
+
height: 16,
|
|
202
|
+
selectmode: :browse)
|
|
203
|
+
|
|
204
|
+
@app.command(@tree, :heading, 'name',
|
|
205
|
+
text: translate('achievements.name_col'), anchor: :w,
|
|
206
|
+
command: proc { sort_tree('name') })
|
|
207
|
+
@app.command(@tree, :heading, 'points',
|
|
208
|
+
text: translate('achievements.points_col'),
|
|
209
|
+
command: proc { sort_tree('points') })
|
|
210
|
+
@app.command(@tree, :heading, 'earned',
|
|
211
|
+
text: translate('achievements.earned_col'),
|
|
212
|
+
command: proc { sort_tree('earned') })
|
|
213
|
+
|
|
214
|
+
@app.command(@tree, :column, 'name', width: 270)
|
|
215
|
+
@app.command(@tree, :column, 'points', width: 55)
|
|
216
|
+
@app.command(@tree, :column, 'earned', width: 145)
|
|
217
|
+
|
|
218
|
+
@app.command('ttk::scrollbar', @scrollbar, orient: :vertical,
|
|
219
|
+
command: "#{@tree} yview")
|
|
220
|
+
@app.command(@tree, :configure, yscrollcommand: "#{@scrollbar} set")
|
|
221
|
+
|
|
222
|
+
@app.command(:pack, @scrollbar, side: :right, fill: :y)
|
|
223
|
+
@app.command(:pack, @tree, side: :left, fill: :both, expand: 1)
|
|
224
|
+
|
|
225
|
+
setup_tree_tooltip
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def build_status
|
|
229
|
+
bar = "#{TOP}.status_bar"
|
|
230
|
+
@app.command('ttk::frame', bar)
|
|
231
|
+
@app.command(:pack, bar, fill: :x)
|
|
232
|
+
|
|
233
|
+
@status_lbl = "#{bar}.status"
|
|
234
|
+
@app.command('ttk::label', @status_lbl,
|
|
235
|
+
text: translate('achievements.none'),
|
|
236
|
+
anchor: :w, padding: [8, 2, 8, 6])
|
|
237
|
+
@app.command(:pack, @status_lbl, side: :left)
|
|
238
|
+
|
|
239
|
+
@rp_lbl = "#{bar}.rich_presence"
|
|
240
|
+
@app.command('ttk::label', @rp_lbl,
|
|
241
|
+
text: '',
|
|
242
|
+
anchor: :e, padding: [8, 2, 8, 6],
|
|
243
|
+
foreground: '#666666')
|
|
244
|
+
@app.command(:pack, @rp_lbl, side: :right)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def refresh_game_list
|
|
248
|
+
entries = @rom_library.all.select { |r| r['platform']&.downcase == 'gba' }
|
|
249
|
+
@game_entries = entries
|
|
250
|
+
titles = entries.map { |r|
|
|
251
|
+
GameIndex.lookup(r['game_code']) || r['title'] || File.basename(r['path'].to_s, '.*')
|
|
252
|
+
}
|
|
253
|
+
@app.command(@combo, :configure, values: Teek.make_list(*titles))
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def select_game(rom_id)
|
|
257
|
+
idx = @game_entries.index { |r| r['rom_id'] == rom_id }
|
|
258
|
+
return unless idx
|
|
259
|
+
|
|
260
|
+
@app.command(@combo, :current, idx)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def on_game_selected
|
|
264
|
+
@display_list = nil
|
|
265
|
+
idx = @app.command(@combo, :current).to_i
|
|
266
|
+
selected_id = @game_entries.dig(idx, 'rom_id')
|
|
267
|
+
|
|
268
|
+
if selected_id == @current_rom_id
|
|
269
|
+
populate_tree
|
|
270
|
+
else
|
|
271
|
+
@display_list = Achievements::Cache.read(selected_id)
|
|
272
|
+
populate_tree
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def populate_tree
|
|
277
|
+
@current_list = @display_list || @backend&.achievement_list || []
|
|
278
|
+
@sort_col = nil # reset to default order when data changes
|
|
279
|
+
@sort_asc = true
|
|
280
|
+
update_heading_indicators
|
|
281
|
+
render_list(default_sorted(@current_list))
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def sort_tree(col)
|
|
285
|
+
return if @current_list.empty?
|
|
286
|
+
if @sort_col == col
|
|
287
|
+
@sort_asc = !@sort_asc
|
|
288
|
+
else
|
|
289
|
+
@sort_col = col
|
|
290
|
+
@sort_asc = col != 'earned' # earned defaults desc (newest first)
|
|
291
|
+
end
|
|
292
|
+
update_heading_indicators
|
|
293
|
+
render_list(apply_sort(@current_list))
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def render_list(list)
|
|
297
|
+
clear_tree
|
|
298
|
+
|
|
299
|
+
if list.empty?
|
|
300
|
+
update_status(0, 0)
|
|
301
|
+
return
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
list.each do |ach|
|
|
305
|
+
earned_str = ach.earned? && ach.earned_at ?
|
|
306
|
+
ach.earned_at.strftime('%Y-%m-%d %H:%M') : ''
|
|
307
|
+
item_id = @app.command(@tree, :insert, '', :end,
|
|
308
|
+
values: Teek.make_list(ach.title, ach.points.to_s, earned_str)).to_s
|
|
309
|
+
@tree_items << item_id
|
|
310
|
+
@item_descriptions[item_id] = ach.description unless ach.description.to_s.empty?
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
earned_count = list.count(&:earned?)
|
|
314
|
+
update_status(earned_count, list.size)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def default_sorted(list)
|
|
318
|
+
earned, unearned = list.partition(&:earned?)
|
|
319
|
+
earned.sort_by! { |a| -(a.earned_at&.to_i || 0) }
|
|
320
|
+
unearned.sort_by!(&:title)
|
|
321
|
+
earned + unearned
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def apply_sort(list)
|
|
325
|
+
case @sort_col
|
|
326
|
+
when 'name'
|
|
327
|
+
sorted = list.sort_by { |a| a.title.downcase }
|
|
328
|
+
@sort_asc ? sorted : sorted.reverse
|
|
329
|
+
when 'points'
|
|
330
|
+
sorted = list.sort_by { |a| a.points }
|
|
331
|
+
@sort_asc ? sorted : sorted.reverse
|
|
332
|
+
when 'earned'
|
|
333
|
+
# Nulls (unearned) always last regardless of direction
|
|
334
|
+
earned, unearned = list.partition(&:earned?)
|
|
335
|
+
sorted_earned = earned.sort_by { |a| a.earned_at.to_i }
|
|
336
|
+
sorted_earned.reverse! unless @sort_asc
|
|
337
|
+
sorted_earned + unearned
|
|
338
|
+
else
|
|
339
|
+
default_sorted(list)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
SORT_ASC = ' ▲'
|
|
344
|
+
SORT_DESC = ' ▼'
|
|
345
|
+
|
|
346
|
+
def update_heading_indicators
|
|
347
|
+
{
|
|
348
|
+
'name' => translate('achievements.name_col'),
|
|
349
|
+
'points' => translate('achievements.points_col'),
|
|
350
|
+
'earned' => translate('achievements.earned_col'),
|
|
351
|
+
}.each do |col, base_text|
|
|
352
|
+
indicator = if @sort_col == col
|
|
353
|
+
@sort_asc ? SORT_ASC : SORT_DESC
|
|
354
|
+
else
|
|
355
|
+
''
|
|
356
|
+
end
|
|
357
|
+
@app.command(@tree, :heading, col, text: "#{base_text}#{indicator}")
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def clear_tree
|
|
362
|
+
return if @tree_items.empty?
|
|
363
|
+
|
|
364
|
+
hide_tip
|
|
365
|
+
@app.command(@tree, :delete, Teek.make_list(*@tree_items))
|
|
366
|
+
@tree_items.clear
|
|
367
|
+
@item_descriptions.clear
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def update_status(earned, total)
|
|
371
|
+
text = if total == 0
|
|
372
|
+
translate('achievements.none')
|
|
373
|
+
else
|
|
374
|
+
translate('achievements.earned_label', earned: earned, total: total)
|
|
375
|
+
end
|
|
376
|
+
set_status(text)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def set_status(text)
|
|
380
|
+
return unless @status_lbl
|
|
381
|
+
@app.command(@status_lbl, :configure, text: text)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def update_rich_presence
|
|
385
|
+
return unless @rp_lbl
|
|
386
|
+
msg = @backend&.rich_presence_message.to_s
|
|
387
|
+
@app.command(@rp_lbl, :configure, text: msg)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def update_title
|
|
391
|
+
entry = @game_entries.find { |r| r['rom_id'] == @current_rom_id }
|
|
392
|
+
game_title = entry && (GameIndex.lookup(entry['game_code']) || entry['title'])
|
|
393
|
+
window_title = if game_title
|
|
394
|
+
"#{translate('achievements.title')} \u2014 #{game_title}"
|
|
395
|
+
else
|
|
396
|
+
translate('achievements.title')
|
|
397
|
+
end
|
|
398
|
+
@app.command(:wm, 'title', TOP, window_title)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def selected_rom_info
|
|
402
|
+
idx = @app.command(@combo, :current).to_i
|
|
403
|
+
entry = @game_entries[idx]
|
|
404
|
+
return nil unless entry
|
|
405
|
+
RomInfo.from_rom(entry)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
SYNC_TIMEOUT_MS = 60_000
|
|
409
|
+
|
|
410
|
+
def sync
|
|
411
|
+
return unless @backend
|
|
412
|
+
rom_info = selected_rom_info
|
|
413
|
+
return unless rom_info
|
|
414
|
+
|
|
415
|
+
Gemba.log(:info) { "Achievements: sync started for #{rom_info.title} (#{rom_info.rom_id})" }
|
|
416
|
+
Gemba.bus.emit(:ra_sync_started)
|
|
417
|
+
|
|
418
|
+
@sync_timeout = @app.after(SYNC_TIMEOUT_MS) do
|
|
419
|
+
Gemba.log(:warn) { "Achievements: sync timed out after #{SYNC_TIMEOUT_MS / 1000}s" }
|
|
420
|
+
@sync_timeout = nil
|
|
421
|
+
Gemba.bus.emit(:ra_sync_done, ok: false, reason: :timeout)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
@backend.fetch_for_display(rom_info: rom_info) do |list|
|
|
425
|
+
@app.after_cancel(@sync_timeout) if @sync_timeout
|
|
426
|
+
@sync_timeout = nil
|
|
427
|
+
Gemba.log(list ? :info : :warn) {
|
|
428
|
+
"Achievements: fetch_for_display returned #{list ? "#{list.size} achievements" : 'nil'}"
|
|
429
|
+
}
|
|
430
|
+
Achievements::Cache.write(rom_info.rom_id, list) if list
|
|
431
|
+
@display_list = list
|
|
432
|
+
populate_tree
|
|
433
|
+
Gemba.bus.emit(:ra_sync_done, ok: !list.nil?)
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# -- Include unofficial toggle ------------------------------------------
|
|
438
|
+
|
|
439
|
+
def on_unofficial_toggled
|
|
440
|
+
return unless @backend&.authenticated?
|
|
441
|
+
|
|
442
|
+
value = @app.get_variable(VAR_UNOFFICIAL) == '1'
|
|
443
|
+
Gemba.bus.emit(:ra_unofficial_changed, value: value)
|
|
444
|
+
|
|
445
|
+
# Bulk re-sync every library game that has an MD5
|
|
446
|
+
games = @rom_library.all.select { |r|
|
|
447
|
+
!r['md5'].to_s.empty?
|
|
448
|
+
}
|
|
449
|
+
return if games.empty?
|
|
450
|
+
|
|
451
|
+
lock_ui_for_bulk_sync
|
|
452
|
+
sync_games_sequentially(games, 0)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def lock_ui_for_bulk_sync
|
|
456
|
+
@bulk_syncing = true
|
|
457
|
+
@bulk_cancelled = false
|
|
458
|
+
@app.command(@sync_btn, :configure, state: :disabled)
|
|
459
|
+
@app.command(@unofficial_check, :configure, state: :disabled)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def unlock_ui_after_bulk_sync
|
|
463
|
+
@bulk_syncing = false
|
|
464
|
+
refresh_auth_state
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def sync_games_sequentially(games, idx)
|
|
468
|
+
return if @bulk_cancelled
|
|
469
|
+
|
|
470
|
+
if idx >= games.size
|
|
471
|
+
unlock_ui_after_bulk_sync
|
|
472
|
+
# Refresh display for the currently selected game
|
|
473
|
+
on_game_selected
|
|
474
|
+
set_status(translate('achievements.bulk_sync_done', count: games.size))
|
|
475
|
+
return
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
rom = games[idx]
|
|
479
|
+
rom_info = RomInfo.from_rom(rom)
|
|
480
|
+
title = rom_info.title
|
|
481
|
+
set_status(translate('achievements.bulk_syncing',
|
|
482
|
+
title: title, n: idx + 1, total: games.size))
|
|
483
|
+
|
|
484
|
+
@backend.fetch_for_display(rom_info: rom_info) do |list|
|
|
485
|
+
next if @bulk_cancelled
|
|
486
|
+
Achievements::Cache.write(rom_info.rom_id, list) if list
|
|
487
|
+
sync_games_sequentially(games, idx + 1)
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# -- Achievement description tooltip ------------------------------------
|
|
492
|
+
|
|
493
|
+
TIP_DELAY_MS = 500
|
|
494
|
+
TIP_PATH = "#{TOP}.__tip"
|
|
495
|
+
TIP_BG = '#FFFFEE'
|
|
496
|
+
TIP_FG = '#333333'
|
|
497
|
+
TIP_BORDER = '#999999'
|
|
498
|
+
|
|
499
|
+
def setup_tree_tooltip
|
|
500
|
+
@app.command(:bind, @tree, '<Motion>', proc {
|
|
501
|
+
# Identify treeview row under the pointer
|
|
502
|
+
px = @app.tcl_eval("winfo pointerx #{@tree}").to_i
|
|
503
|
+
py = @app.tcl_eval("winfo pointery #{@tree}").to_i
|
|
504
|
+
tx = @app.tcl_eval("winfo rootx #{@tree}").to_i
|
|
505
|
+
ty = @app.tcl_eval("winfo rooty #{@tree}").to_i
|
|
506
|
+
item = @app.tcl_eval("#{@tree} identify row #{px - tx} #{py - ty}").strip
|
|
507
|
+
|
|
508
|
+
next if item == @tip_item
|
|
509
|
+
@tip_item = item
|
|
510
|
+
hide_tip
|
|
511
|
+
|
|
512
|
+
next if item.empty?
|
|
513
|
+
desc = @item_descriptions[item]
|
|
514
|
+
next unless desc
|
|
515
|
+
|
|
516
|
+
cancel_tip_timer
|
|
517
|
+
@tip_timer = @app.after(TIP_DELAY_MS) { show_tip(desc) }
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
@app.command(:bind, @tree, '<Leave>', proc {
|
|
521
|
+
@tip_item = nil
|
|
522
|
+
cancel_tip_timer
|
|
523
|
+
hide_tip
|
|
524
|
+
})
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def show_tip(text)
|
|
528
|
+
hide_tip
|
|
529
|
+
px = @app.tcl_eval("winfo pointerx .").to_i
|
|
530
|
+
py = @app.tcl_eval("winfo pointery .").to_i
|
|
531
|
+
wx = @app.tcl_eval("winfo rootx #{TOP}").to_i
|
|
532
|
+
wy = @app.tcl_eval("winfo rooty #{TOP}").to_i
|
|
533
|
+
rel_x = px - wx + 14
|
|
534
|
+
rel_y = py - wy + 18
|
|
535
|
+
|
|
536
|
+
@app.command(:frame, TIP_PATH, background: TIP_BORDER, borderwidth: 0)
|
|
537
|
+
@app.command(:label, "#{TIP_PATH}.l",
|
|
538
|
+
text: text, background: TIP_BG, foreground: TIP_FG,
|
|
539
|
+
padx: 8, pady: 5, justify: :left, wraplength: 320)
|
|
540
|
+
@app.command(:pack, "#{TIP_PATH}.l", padx: 1, pady: 1)
|
|
541
|
+
@app.command(:place, TIP_PATH, x: rel_x, y: rel_y)
|
|
542
|
+
@app.command(:raise, TIP_PATH)
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def hide_tip
|
|
546
|
+
cancel_tip_timer
|
|
547
|
+
@app.tcl_eval("catch {destroy #{TIP_PATH}}")
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def cancel_tip_timer
|
|
551
|
+
return unless @tip_timer
|
|
552
|
+
@app.command(:after, :cancel, @tip_timer)
|
|
553
|
+
@tip_timer = nil
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
end
|