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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b7478522afdccb445e067ff5e812f499cf7e8aec5f0708d4d12c9ee81224f4a
4
- data.tar.gz: 83ae429789c3beb4c6cb173a8bffdfd5d5a70c7a3451621e21507ef040ccb484
3
+ metadata.gz: 0244df20f8115eff6f521e2294f607a724c9612593fb5602517265b5a3ca3251
4
+ data.tar.gz: 1c29a7ce92023f3efa2ff72c29073e28baa2e620f97caf456adc0e92e3c56447
5
5
  SHA512:
6
- metadata.gz: dbe2fdbf8ce938a595df9c071aeae5f61d15be4cf18a5e3d2cf1331ca3d4a65ac2af931b2828f777c2fa9661de73b2ea7b5f135e35732a040ddf420ef7d4a4d5
7
- data.tar.gz: ff4e86a1702acf27651991fe7f43a66272287dcf801fe58baab3bf0ebb76e8cc0988168ff6c6ae281f67006d611232319bdcbdad583cae95de3d0365562ce152
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 = (RUBY_VERSION >= "4.0" ? :ractor : :thread).freeze
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 = runtime || Gemba::RARuntime.new
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&.dig("Error") || "Token invalid"
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
- fire_auth_change(:error, json&.dig("Error") || "Token invalid")
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
- # Best-effort unlock submission fires and forgets, result only logged.
411
- def submit_unlock(achievement_id, hardcore: false)
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
- if ok && json&.dig("Success")
415
- Gemba.log(:info) { "RA: submitted unlock for achievement #{achievement_id}" }
416
- else
417
- Gemba.log(:warn) { "RA: unlock submission failed for #{achievement_id}: #{json&.dig("Error")}" }
418
- end
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
@@ -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
- @frame_stack.push(:picker, @game_picker)
104
- @window.set_geometry(GamePickerFrame::PICKER_DEFAULT_W, GamePickerFrame::PICKER_DEFAULT_H)
105
- @window.set_minsize(GamePickerFrame::PICKER_MIN_W, GamePickerFrame::PICKER_MIN_H)
106
- apply_frame_aspect(@game_picker)
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
- @window.set_geometry(GamePickerFrame::PICKER_DEFAULT_W, GamePickerFrame::PICKER_DEFAULT_H)
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' => false,
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' => 'settings.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
@@ -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
@@ -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, @grid, fill: :both, expand: 1)
49
+ @app.command(:pack, @outer, fill: :both, expand: 1)
47
50
  end
48
51
 
49
52
  def hide
50
- @app.command(:pack, :forget, @grid) rescue nil
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
- @grid = '.game_picker'
73
- @app.command('ttk::frame', @grid, padding: 16)
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 = "#{@grid}.card#{i}"
88
- @app.command(:frame, cell, relief: :groove, borderwidth: 2,
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, @grid, c, weight: 1) }
118
- ROWS.times { |r| @app.command(:grid, :rowconfigure, @grid, r, weight: 1) }
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