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
data/lib/gemba/core.rb
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
# GBA emulator core wrapping libmgba's mCore API.
|
|
5
|
+
#
|
|
6
|
+
# Core loads a GBA ROM, emulates one frame at a time, and provides
|
|
7
|
+
# access to the video and audio output buffers. Pair with
|
|
8
|
+
# {Teek::SDL2::Renderer} for display and {Teek::SDL2::AudioStream}
|
|
9
|
+
# for sound.
|
|
10
|
+
#
|
|
11
|
+
# @example Run a frame and grab pixels
|
|
12
|
+
# core = Gemba::Core.new("game.gba")
|
|
13
|
+
# core.run_frame
|
|
14
|
+
# pixels = core.video_buffer # 240*160*4 bytes RGBA
|
|
15
|
+
# core.destroy
|
|
16
|
+
class Core
|
|
17
|
+
|
|
18
|
+
# @!method initialize(rom_path, save_dir = nil)
|
|
19
|
+
# Load a GBA ROM and initialize the emulator core.
|
|
20
|
+
# Detects the platform (GBA/GB/GBC) from the file extension,
|
|
21
|
+
# allocates video and audio buffers, resets the CPU, and autoloads
|
|
22
|
+
# the battery save file (.sav).
|
|
23
|
+
#
|
|
24
|
+
# @param rom_path [String] path to the ROM file (.gba, .gb, .gbc)
|
|
25
|
+
# @param save_dir [String, nil] directory for .sav files.
|
|
26
|
+
# When +nil+, saves are stored alongside the ROM.
|
|
27
|
+
# @raise [ArgumentError] if the ROM format is unrecognized or the file cannot be opened
|
|
28
|
+
# @raise [RuntimeError] if core initialization or ROM loading fails
|
|
29
|
+
|
|
30
|
+
# @!method run_frame
|
|
31
|
+
# Advance the emulation by one video frame (~16.7 ms of GBA time).
|
|
32
|
+
# Releases the GVL so other Ruby threads can run during emulation.
|
|
33
|
+
# After this call, {#video_buffer} and {#audio_buffer} contain
|
|
34
|
+
# the new frame's output.
|
|
35
|
+
# @return [nil]
|
|
36
|
+
|
|
37
|
+
# @!method video_buffer
|
|
38
|
+
# Raw pixel data for the current frame.
|
|
39
|
+
# Returns a binary String of +width * height * 4+ bytes in mGBA's
|
|
40
|
+
# native color format (ABGR8888 — R in low bits of each uint32).
|
|
41
|
+
# @return [String] binary pixel data
|
|
42
|
+
# @see #video_buffer_argb
|
|
43
|
+
|
|
44
|
+
# @!method video_buffer_argb
|
|
45
|
+
# Pixel data converted to ARGB8888 for SDL2 textures.
|
|
46
|
+
# Same dimensions as {#video_buffer} but with R and B channels
|
|
47
|
+
# swapped so the data can be passed directly to
|
|
48
|
+
# {Teek::SDL2::Texture#update}.
|
|
49
|
+
# @return [String] binary pixel data in ARGB8888 format
|
|
50
|
+
|
|
51
|
+
# @!method audio_buffer
|
|
52
|
+
# Drain the audio output for the most recent frame(s).
|
|
53
|
+
# Returns interleaved stereo signed 16-bit PCM samples (L R L R ...).
|
|
54
|
+
# The number of samples varies per frame (~548 at 32768 Hz).
|
|
55
|
+
# @return [String] binary PCM data (packed int16, little-endian)
|
|
56
|
+
|
|
57
|
+
# @!method set_keys(bitmask)
|
|
58
|
+
# Set the currently pressed buttons as a bitmask.
|
|
59
|
+
# Combine key constants with bitwise OR:
|
|
60
|
+
# +core.set_keys(Gemba::KEY_A | Gemba::KEY_START)+
|
|
61
|
+
# Pass +0+ to release all buttons.
|
|
62
|
+
# @param bitmask [Integer] bitwise OR of +KEY_*+ constants
|
|
63
|
+
# @return [nil]
|
|
64
|
+
|
|
65
|
+
# @!method width
|
|
66
|
+
# Video output width in pixels (240 for GBA).
|
|
67
|
+
# @return [Integer]
|
|
68
|
+
|
|
69
|
+
# @!method height
|
|
70
|
+
# Video output height in pixels (160 for GBA).
|
|
71
|
+
# @return [Integer]
|
|
72
|
+
|
|
73
|
+
# @!method title
|
|
74
|
+
# Internal ROM title (up to 12 characters for GBA).
|
|
75
|
+
# @return [String]
|
|
76
|
+
|
|
77
|
+
# @!method game_code
|
|
78
|
+
# Game code from the ROM header, prefixed with platform
|
|
79
|
+
# (e.g. "AGB-BTKE" for GBA, "CGB-XXXX" for GBC).
|
|
80
|
+
# @return [String]
|
|
81
|
+
|
|
82
|
+
# @!method maker_code
|
|
83
|
+
# 2-character maker/publisher code from the GBA ROM header
|
|
84
|
+
# at offset 0xB0 (e.g. "01" for Nintendo).
|
|
85
|
+
# Returns empty string for non-GBA ROMs.
|
|
86
|
+
# @return [String]
|
|
87
|
+
|
|
88
|
+
# @!method checksum
|
|
89
|
+
# CRC32 checksum of the loaded ROM.
|
|
90
|
+
# @return [Integer]
|
|
91
|
+
|
|
92
|
+
# @!method platform
|
|
93
|
+
# Platform string: "GBA", "GB", or "Unknown".
|
|
94
|
+
# @return [String]
|
|
95
|
+
|
|
96
|
+
# @!method rom_size
|
|
97
|
+
# Size of the loaded ROM in bytes.
|
|
98
|
+
# @return [Integer]
|
|
99
|
+
|
|
100
|
+
# @!method save_state_to_file(path)
|
|
101
|
+
# Save the complete emulator state (CPU, memory, audio, video) to a file.
|
|
102
|
+
# Includes battery save data and RTC state.
|
|
103
|
+
# @param path [String] destination file path
|
|
104
|
+
# @return [Boolean] true on success
|
|
105
|
+
# @raise [RuntimeError] if the file cannot be opened for writing
|
|
106
|
+
|
|
107
|
+
# @!method load_state_from_file(path)
|
|
108
|
+
# Restore emulator state from a previously saved state file.
|
|
109
|
+
# @param path [String] state file path
|
|
110
|
+
# @return [Boolean] true on success, false if file doesn't exist or is invalid
|
|
111
|
+
|
|
112
|
+
# @!method destroy
|
|
113
|
+
# Shut down the emulator core and free all resources.
|
|
114
|
+
# Further method calls will raise.
|
|
115
|
+
# @return [nil]
|
|
116
|
+
|
|
117
|
+
# @!method destroyed?
|
|
118
|
+
# Whether the core has been destroyed.
|
|
119
|
+
# @return [Boolean]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Lightweight entry point for headless (no GUI) usage of gemba.
|
|
4
|
+
# Loads only the C extension and pure-Ruby modules — no Tk, no SDL2.
|
|
5
|
+
#
|
|
6
|
+
# require "gemba/headless"
|
|
7
|
+
# Gemba::HeadlessPlayer.open("game.gba") { |p| p.step(60) }
|
|
8
|
+
|
|
9
|
+
require_relative "runtime"
|
|
10
|
+
require_relative "recorder"
|
|
11
|
+
require_relative "recorder_decoder"
|
|
12
|
+
require_relative "headless_player"
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'zlib'
|
|
5
|
+
|
|
6
|
+
module Gemba
|
|
7
|
+
# Headless mGBA player for scripting and automated testing.
|
|
8
|
+
# Wraps Core with a simple API — no Tk, no SDL2, no event loop.
|
|
9
|
+
#
|
|
10
|
+
# @example Run 60 frames and inspect the video buffer
|
|
11
|
+
# HeadlessPlayer.open("game.gba") do |player|
|
|
12
|
+
# player.step(60)
|
|
13
|
+
# pixels = player.video_buffer_argb # 240*160*4 bytes
|
|
14
|
+
# end
|
|
15
|
+
class HeadlessPlayer
|
|
16
|
+
# @param rom_path [String] path to ROM file (.gba, .gb, .gbc, .zip)
|
|
17
|
+
# @param config [Config, nil] config object (uses default if nil)
|
|
18
|
+
def initialize(rom_path, config: nil)
|
|
19
|
+
@config = config || Gemba.user_config
|
|
20
|
+
rom_path = RomLoader.resolve(rom_path)
|
|
21
|
+
|
|
22
|
+
saves = @config.saves_dir
|
|
23
|
+
FileUtils.mkdir_p(saves) unless File.directory?(saves)
|
|
24
|
+
@core = Core.new(rom_path, saves)
|
|
25
|
+
@keys = 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Open a HeadlessPlayer, yield it, and close when done.
|
|
29
|
+
# @param rom_path [String]
|
|
30
|
+
# @param opts [Hash] passed to {#initialize}
|
|
31
|
+
# @yield [HeadlessPlayer]
|
|
32
|
+
# @return result of block
|
|
33
|
+
def self.open(rom_path, **opts)
|
|
34
|
+
player = new(rom_path, **opts)
|
|
35
|
+
begin
|
|
36
|
+
yield player
|
|
37
|
+
ensure
|
|
38
|
+
player.close
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Run one or more frames. Captures to recorder if recording.
|
|
43
|
+
# @param n [Integer] number of frames to advance (default 1)
|
|
44
|
+
# @yield [Integer] frame number (1-based) after each frame, if block given
|
|
45
|
+
def step(n = 1)
|
|
46
|
+
check_open!
|
|
47
|
+
if block_given?
|
|
48
|
+
n.times do |i|
|
|
49
|
+
@core.run_frame
|
|
50
|
+
@recorder&.capture(@core.video_buffer_argb, @core.audio_buffer) if @recorder&.recording?
|
|
51
|
+
yield i + 1
|
|
52
|
+
end
|
|
53
|
+
else
|
|
54
|
+
n.times do
|
|
55
|
+
@core.run_frame
|
|
56
|
+
@recorder&.capture(@core.video_buffer_argb, @core.audio_buffer) if @recorder&.recording?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Set currently pressed buttons as a bitmask.
|
|
62
|
+
# Use KEY_* constants: `player.press(KEY_A | KEY_START)`
|
|
63
|
+
# @param keys [Integer] bitwise OR of KEY_* constants
|
|
64
|
+
def press(keys)
|
|
65
|
+
check_open!
|
|
66
|
+
@keys = keys
|
|
67
|
+
@core.set_keys(@keys)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Release all buttons.
|
|
71
|
+
def release_all
|
|
72
|
+
check_open!
|
|
73
|
+
@keys = 0
|
|
74
|
+
@core.set_keys(0)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [String] raw ARGB8888 pixel data (240*160*4 bytes for GBA)
|
|
78
|
+
def video_buffer_argb
|
|
79
|
+
check_open!
|
|
80
|
+
@core.video_buffer_argb
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [String] raw interleaved stereo PCM audio data
|
|
84
|
+
def audio_buffer
|
|
85
|
+
check_open!
|
|
86
|
+
@core.audio_buffer
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [Integer] video width in pixels
|
|
90
|
+
def width
|
|
91
|
+
check_open!
|
|
92
|
+
@core.width
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [Integer] video height in pixels
|
|
96
|
+
def height
|
|
97
|
+
check_open!
|
|
98
|
+
@core.height
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @!group ROM metadata
|
|
102
|
+
|
|
103
|
+
def title; check_open!; @core.title; end
|
|
104
|
+
def game_code; check_open!; @core.game_code; end
|
|
105
|
+
def maker_code; check_open!; @core.maker_code; end
|
|
106
|
+
def checksum; check_open!; @core.checksum; end
|
|
107
|
+
def platform; check_open!; @core.platform; end
|
|
108
|
+
def rom_size; check_open!; @core.rom_size; end
|
|
109
|
+
|
|
110
|
+
# @!endgroup
|
|
111
|
+
|
|
112
|
+
# @!group Save states
|
|
113
|
+
|
|
114
|
+
# @param path [String] destination file path
|
|
115
|
+
# @return [Boolean] true on success
|
|
116
|
+
def save_state(path)
|
|
117
|
+
check_open!
|
|
118
|
+
@core.save_state_to_file(path)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @param path [String] state file path
|
|
122
|
+
# @return [Boolean] true on success
|
|
123
|
+
def load_state(path)
|
|
124
|
+
check_open!
|
|
125
|
+
@core.load_state_from_file(path)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @!endgroup
|
|
129
|
+
|
|
130
|
+
# @!group Rewind
|
|
131
|
+
|
|
132
|
+
def rewind_init(seconds)
|
|
133
|
+
check_open!
|
|
134
|
+
@core.rewind_init(seconds)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def rewind_push
|
|
138
|
+
check_open!
|
|
139
|
+
@core.rewind_push
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [Boolean] true if a snapshot was loaded
|
|
143
|
+
def rewind_pop
|
|
144
|
+
check_open!
|
|
145
|
+
@core.rewind_pop
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @return [Integer] number of saved snapshots
|
|
149
|
+
def rewind_count
|
|
150
|
+
check_open!
|
|
151
|
+
@core.rewind_count
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def rewind_deinit
|
|
155
|
+
check_open!
|
|
156
|
+
@core.rewind_deinit
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @!endgroup
|
|
160
|
+
|
|
161
|
+
# @!group Recording
|
|
162
|
+
|
|
163
|
+
# Start recording video + audio to a .grec file.
|
|
164
|
+
# @param path [String] output file path
|
|
165
|
+
# @param compression [Integer] zlib level 1-9 (default 1 = fastest)
|
|
166
|
+
def start_recording(path, compression: Zlib::BEST_SPEED)
|
|
167
|
+
check_open!
|
|
168
|
+
raise "Already recording" if recording?
|
|
169
|
+
@recorder = Recorder.new(path, width: @core.width, height: @core.height,
|
|
170
|
+
compression: compression)
|
|
171
|
+
@recorder.start
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Stop recording and finalize the file.
|
|
175
|
+
def stop_recording
|
|
176
|
+
@recorder&.stop
|
|
177
|
+
@recorder = nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @return [Boolean] true if currently recording
|
|
181
|
+
def recording?
|
|
182
|
+
@recorder&.recording? || false
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# @!endgroup
|
|
186
|
+
|
|
187
|
+
# Shut down the core and free resources.
|
|
188
|
+
def close
|
|
189
|
+
return if closed?
|
|
190
|
+
stop_recording if recording?
|
|
191
|
+
@core.destroy
|
|
192
|
+
@core = nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# @return [Boolean] true if the player has been closed
|
|
196
|
+
def closed?
|
|
197
|
+
@core.nil? || @core.destroyed?
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private
|
|
201
|
+
|
|
202
|
+
def check_open!
|
|
203
|
+
raise "HeadlessPlayer is closed" if closed?
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Gemba
|
|
6
|
+
# Maps player actions (quit, pause, etc.) to keyboard hotkeys.
|
|
7
|
+
#
|
|
8
|
+
# A hotkey is either a plain keysym String ("F5") or an Array of
|
|
9
|
+
# modifier(s) + key (["Control", "s"]). Provides reverse lookup for
|
|
10
|
+
# efficient dispatch in the input loop.
|
|
11
|
+
class HotkeyMap
|
|
12
|
+
ACTIONS = %i[quit pause fast_forward fullscreen show_fps
|
|
13
|
+
quick_save quick_load save_states screenshot rewind
|
|
14
|
+
record].freeze
|
|
15
|
+
|
|
16
|
+
DEFAULTS = {
|
|
17
|
+
quit: 'q', pause: 'p', fast_forward: 'Tab',
|
|
18
|
+
fullscreen: 'F11', show_fps: 'F3',
|
|
19
|
+
quick_save: 'F5', quick_load: 'F8',
|
|
20
|
+
save_states: 'F6', screenshot: 'F9',
|
|
21
|
+
rewind: ['Shift', 'Tab'],
|
|
22
|
+
record: 'F10',
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Tk keysyms that are modifier keys → normalized name
|
|
26
|
+
MODIFIER_KEYSYMS = {
|
|
27
|
+
'Control_L' => 'Control', 'Control_R' => 'Control',
|
|
28
|
+
'Shift_L' => 'Shift', 'Shift_R' => 'Shift',
|
|
29
|
+
'Alt_L' => 'Alt', 'Alt_R' => 'Alt',
|
|
30
|
+
'Meta_L' => 'Alt', 'Meta_R' => 'Alt',
|
|
31
|
+
'Super_L' => 'Super', 'Super_R' => 'Super',
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
# Tk event state bitmask → modifier name
|
|
35
|
+
STATE_BITS = { 1 => 'Shift', 4 => 'Control', 8 => 'Alt' }.freeze
|
|
36
|
+
|
|
37
|
+
# Display-friendly modifier names
|
|
38
|
+
MODIFIER_DISPLAY = { 'Control' => 'Ctrl', 'Shift' => 'Shift', 'Alt' => 'Alt', 'Super' => 'Super' }.freeze
|
|
39
|
+
|
|
40
|
+
# Canonical sort order for modifiers
|
|
41
|
+
MODIFIER_ORDER = %w[Control Shift Alt Super].freeze
|
|
42
|
+
|
|
43
|
+
# Tk keysym aliases — modifier combos can produce variant keysyms
|
|
44
|
+
# that must be normalized for both lookup and capture.
|
|
45
|
+
#
|
|
46
|
+
# Known cases:
|
|
47
|
+
# Shift+Tab → ISO_Left_Tab
|
|
48
|
+
# Shift+1 → exclam (US layout)
|
|
49
|
+
# Shift+a → A (universal — handled dynamically in normalize_keysym)
|
|
50
|
+
KEYSYM_ALIASES = {
|
|
51
|
+
'ISO_Left_Tab' => 'Tab',
|
|
52
|
+
# Shift+number (US keyboard layout)
|
|
53
|
+
'exclam' => '1', 'at' => '2', 'numbersign' => '3',
|
|
54
|
+
'dollar' => '4', 'percent' => '5', 'asciicircum' => '6',
|
|
55
|
+
'ampersand' => '7', 'asterisk' => '8', 'parenleft' => '9',
|
|
56
|
+
'parenright' => '0',
|
|
57
|
+
# Shift+punctuation (US keyboard layout)
|
|
58
|
+
'underscore' => 'minus', 'plus' => 'equal',
|
|
59
|
+
'braceleft' => 'bracketleft', 'braceright' => 'bracketright',
|
|
60
|
+
'bar' => 'backslash', 'colon' => 'semicolon',
|
|
61
|
+
'quotedbl' => 'apostrophe', 'less' => 'comma',
|
|
62
|
+
'greater' => 'period', 'question' => 'slash',
|
|
63
|
+
'asciitilde' => 'grave',
|
|
64
|
+
}.freeze
|
|
65
|
+
|
|
66
|
+
def initialize(config)
|
|
67
|
+
@config = config
|
|
68
|
+
@map = DEFAULTS.dup
|
|
69
|
+
load_config
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param action [Symbol] e.g. :quit, :pause
|
|
73
|
+
# @return [String, Array] hotkey for this action
|
|
74
|
+
def key_for(action)
|
|
75
|
+
@map[action]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Look up which action matches a keysym + active modifiers.
|
|
79
|
+
# @param keysym [String] e.g. 'q', 'F5'
|
|
80
|
+
# @param modifiers [Set<String>, nil] active modifier names (e.g. Set["Control"])
|
|
81
|
+
# @return [Symbol, nil] action bound to this hotkey, or nil
|
|
82
|
+
def action_for(keysym, modifiers: nil)
|
|
83
|
+
keysym = self.class.normalize_keysym(keysym)
|
|
84
|
+
mods = modifiers && !modifiers.empty? ? modifiers : nil
|
|
85
|
+
|
|
86
|
+
@map.each do |action, hk|
|
|
87
|
+
if hk.is_a?(Array)
|
|
88
|
+
hk_mods = hk[0...-1]
|
|
89
|
+
hk_key = hk.last
|
|
90
|
+
next unless mods && hk_key == keysym
|
|
91
|
+
next unless hk_mods.size == mods.size && hk_mods.all? { |m| mods.include?(m) }
|
|
92
|
+
return action
|
|
93
|
+
else
|
|
94
|
+
return action if hk == keysym && mods.nil?
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Rebind an action to a new hotkey. Clears any existing action
|
|
101
|
+
# using the same hotkey to prevent conflicts.
|
|
102
|
+
# @param action [Symbol]
|
|
103
|
+
# @param hotkey [String, Array]
|
|
104
|
+
def set(action, hotkey)
|
|
105
|
+
normalized = self.class.normalize(hotkey)
|
|
106
|
+
@map.delete_if { |_, v| self.class.normalize(v) == normalized }
|
|
107
|
+
@map[action] = normalized
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Restore all bindings to defaults.
|
|
111
|
+
def reset!
|
|
112
|
+
@map = DEFAULTS.dup
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Load hotkeys from config. Falls back to defaults for missing keys.
|
|
116
|
+
def load_config
|
|
117
|
+
cfg = @config.hotkeys
|
|
118
|
+
return if cfg.empty?
|
|
119
|
+
|
|
120
|
+
@map = DEFAULTS.dup
|
|
121
|
+
cfg.each do |action_str, hk|
|
|
122
|
+
action = action_str.to_sym
|
|
123
|
+
@map[action] = self.class.normalize(hk) if ACTIONS.include?(action)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Re-read config from disk, then reload bindings.
|
|
128
|
+
def reload!
|
|
129
|
+
@config.reload!
|
|
130
|
+
load_config
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Write current hotkeys to config (does not call save!).
|
|
134
|
+
def save_to_config
|
|
135
|
+
@map.each do |action, hk|
|
|
136
|
+
@config.set_hotkey(action, hk)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Hash{Symbol => String, Array}] action → raw hotkey
|
|
141
|
+
def labels
|
|
142
|
+
@map.dup
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Normalize a hotkey: sort modifiers canonically.
|
|
146
|
+
# @param hotkey [String, Array] e.g. "F5" or ["Shift", "Control", "s"]
|
|
147
|
+
# @return [String, Array]
|
|
148
|
+
def self.normalize(hotkey)
|
|
149
|
+
return hotkey unless hotkey.is_a?(Array)
|
|
150
|
+
return hotkey.last if hotkey.size == 1
|
|
151
|
+
|
|
152
|
+
key = hotkey.last
|
|
153
|
+
mods = hotkey[0...-1].sort_by { |m| MODIFIER_ORDER.index(m) || 99 }
|
|
154
|
+
[*mods, key]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Human-readable display name for a hotkey.
|
|
158
|
+
# @param hotkey [String, Array]
|
|
159
|
+
# @return [String] e.g. "F5", "Ctrl+S"
|
|
160
|
+
def self.display_name(hotkey)
|
|
161
|
+
return hotkey unless hotkey.is_a?(Array)
|
|
162
|
+
|
|
163
|
+
parts = hotkey[0...-1].map { |m| MODIFIER_DISPLAY[m] || m }
|
|
164
|
+
parts << hotkey.last.capitalize
|
|
165
|
+
parts.join('+')
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Normalize variant Tk keysyms to their canonical form.
|
|
169
|
+
# Handles: ISO_Left_Tab → Tab, Shift+letter uppercase → lowercase,
|
|
170
|
+
# Shift+number → number (US layout), Shift+punctuation → base key.
|
|
171
|
+
# @param keysym [String] e.g. 'ISO_Left_Tab', 'Q'
|
|
172
|
+
# @return [String] canonical keysym e.g. 'Tab', 'q'
|
|
173
|
+
def self.normalize_keysym(keysym)
|
|
174
|
+
return KEYSYM_ALIASES[keysym] if KEYSYM_ALIASES.key?(keysym)
|
|
175
|
+
# Shift+letter: single uppercase ASCII letter → lowercase
|
|
176
|
+
return keysym.downcase if keysym.length == 1 && keysym.match?(/\A[A-Z]\z/)
|
|
177
|
+
keysym
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @param keysym [String] Tk keysym
|
|
181
|
+
# @return [Boolean] true if the keysym is a modifier key
|
|
182
|
+
def self.modifier_key?(keysym)
|
|
183
|
+
MODIFIER_KEYSYMS.key?(keysym)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Normalize a Tk modifier keysym (e.g. "Control_L" → "Control").
|
|
187
|
+
# @param keysym [String]
|
|
188
|
+
# @return [String, nil] normalized modifier name, or nil if not a modifier
|
|
189
|
+
def self.normalize_modifier(keysym)
|
|
190
|
+
MODIFIER_KEYSYMS[keysym]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Extract active modifier names from a Tk event state bitmask.
|
|
194
|
+
# @param state [Integer] Tk %s value
|
|
195
|
+
# @return [Set<String>]
|
|
196
|
+
def self.modifiers_from_state(state)
|
|
197
|
+
result = Set.new
|
|
198
|
+
STATE_BITS.each { |bit, name| result << name if (state & bit) != 0 }
|
|
199
|
+
result
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|