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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/THIRD_PARTY_NOTICES +113 -0
  3. data/assets/JetBrainsMonoNL-Regular.ttf +0 -0
  4. data/assets/ark-pixel-12px-monospaced-ja.ttf +0 -0
  5. data/bin/gemba +14 -0
  6. data/ext/gemba/extconf.rb +185 -0
  7. data/ext/gemba/gemba_ext.c +1051 -0
  8. data/ext/gemba/gemba_ext.h +15 -0
  9. data/gemba.gemspec +38 -0
  10. data/lib/gemba/child_window.rb +62 -0
  11. data/lib/gemba/cli.rb +384 -0
  12. data/lib/gemba/config.rb +621 -0
  13. data/lib/gemba/core.rb +121 -0
  14. data/lib/gemba/headless.rb +12 -0
  15. data/lib/gemba/headless_player.rb +206 -0
  16. data/lib/gemba/hotkey_map.rb +202 -0
  17. data/lib/gemba/input_mappings.rb +214 -0
  18. data/lib/gemba/locale.rb +92 -0
  19. data/lib/gemba/locales/en.yml +157 -0
  20. data/lib/gemba/locales/ja.yml +157 -0
  21. data/lib/gemba/method_coverage_service.rb +265 -0
  22. data/lib/gemba/overlay_renderer.rb +109 -0
  23. data/lib/gemba/player.rb +1515 -0
  24. data/lib/gemba/recorder.rb +156 -0
  25. data/lib/gemba/recorder_decoder.rb +325 -0
  26. data/lib/gemba/rom_info_window.rb +346 -0
  27. data/lib/gemba/rom_loader.rb +100 -0
  28. data/lib/gemba/runtime.rb +39 -0
  29. data/lib/gemba/save_state_manager.rb +155 -0
  30. data/lib/gemba/save_state_picker.rb +199 -0
  31. data/lib/gemba/settings_window.rb +1173 -0
  32. data/lib/gemba/tip_service.rb +133 -0
  33. data/lib/gemba/toast_overlay.rb +128 -0
  34. data/lib/gemba/version.rb +5 -0
  35. data/lib/gemba.rb +17 -0
  36. data/test/fixtures/test.gba +0 -0
  37. data/test/fixtures/test.sav +0 -0
  38. data/test/shared/screenshot_helper.rb +113 -0
  39. data/test/shared/simplecov_config.rb +59 -0
  40. data/test/shared/teek_test_worker.rb +388 -0
  41. data/test/shared/tk_test_helper.rb +354 -0
  42. data/test/support/input_mocks.rb +61 -0
  43. data/test/support/player_helpers.rb +77 -0
  44. data/test/test_cli.rb +281 -0
  45. data/test/test_config.rb +897 -0
  46. data/test/test_core.rb +401 -0
  47. data/test/test_gamepad_map.rb +116 -0
  48. data/test/test_headless_player.rb +205 -0
  49. data/test/test_helper.rb +19 -0
  50. data/test/test_hotkey_map.rb +396 -0
  51. data/test/test_keyboard_map.rb +108 -0
  52. data/test/test_locale.rb +159 -0
  53. data/test/test_mgba.rb +26 -0
  54. data/test/test_overlay_renderer.rb +199 -0
  55. data/test/test_player.rb +903 -0
  56. data/test/test_recorder.rb +180 -0
  57. data/test/test_rom_loader.rb +149 -0
  58. data/test/test_save_state_manager.rb +289 -0
  59. data/test/test_settings_hotkeys.rb +434 -0
  60. data/test/test_settings_window.rb +1039 -0
  61. data/test/test_tip_service.rb +138 -0
  62. data/test/test_toast_overlay.rb +216 -0
  63. data/test/test_virtual_keyboard.rb +39 -0
  64. data/test/test_xor_delta.rb +61 -0
  65. 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