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
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ # Click-to-show tooltip service for Tk widgets.
5
+ #
6
+ # Labels registered with {#register} get an underlined font (like HTML
7
+ # <abbr>). Clicking them shows a tooltip popup below the label. Only one
8
+ # tooltip is visible at a time; it auto-dismisses after {#dismiss_ms}
9
+ # unless the mouse hovers over the tip or its label.
10
+ #
11
+ # The tooltip is rendered as a frame inside the parent window (not a
12
+ # toplevel), so it draws as a true rectangle on all platforms.
13
+ #
14
+ # @example
15
+ # tips = TipService.new(app, parent: '.settings')
16
+ # tips.register('.settings.nb.video.lbl_color', 'Adjusts GBA LCD colors')
17
+ class TipService
18
+ DEFAULT_DISMISS_MS = 4000
19
+
20
+ # Tooltip colors (pale yellow with gray border, dark text)
21
+ TIP_BG = '#FFFFEE'
22
+ TIP_FG = '#333333'
23
+ TIP_BORDER = '#999999'
24
+
25
+ # @param app [Teek::App]
26
+ # @param parent [String] parent Toplevel path (for unique tooltip path)
27
+ # @param dismiss_ms [Integer] auto-dismiss delay in milliseconds
28
+ def initialize(app, parent: '.', dismiss_ms: DEFAULT_DISMISS_MS)
29
+ @app = app
30
+ @parent = parent
31
+ @tip_path = parent == '.' ? '.__tip' : "#{parent}.__tip"
32
+ @dismiss_ms = dismiss_ms
33
+ @target = nil
34
+ @timer = nil
35
+ @click_guard = false
36
+
37
+ # Underlined font for registered labels
38
+ @font_name = "__tip_font_#{parent.tr('.', '_')}"
39
+ @app.tcl_eval("catch {font create #{@font_name} {*}[font actual TkDefaultFont] -underline 1}")
40
+
41
+ # Click anywhere in the parent window dismisses the tooltip,
42
+ # unless the click was on a registered label (guard prevents that).
43
+ @app.command(:bind, @parent, '<Button-1>', proc {
44
+ if @click_guard
45
+ @click_guard = false
46
+ elsif showing?
47
+ hide
48
+ end
49
+ })
50
+ end
51
+
52
+ # @return [Integer] auto-dismiss delay in milliseconds
53
+ attr_accessor :dismiss_ms
54
+
55
+ # @return [String, nil] widget path of the currently showing tip's label
56
+ attr_reader :target
57
+
58
+ # Register a widget for click-to-show tooltip.
59
+ # @param widget_path [String] Tk widget path (typically a label)
60
+ # @param text [String] tooltip text (may contain \n for line breaks)
61
+ def register(widget_path, text)
62
+ @app.command(widget_path, 'configure', font: @font_name)
63
+ @app.command(:bind, widget_path, '<Button-1>', proc { toggle(widget_path, text) })
64
+ @app.command(:bind, widget_path, '<Enter>', proc { cancel_dismiss })
65
+ @app.command(:bind, widget_path, '<Leave>', proc { schedule_dismiss })
66
+ end
67
+
68
+ # Show a tooltip below the given widget. Hides any existing tip first.
69
+ # @param widget_path [String]
70
+ # @param text [String]
71
+ def show(widget_path, text)
72
+ hide
73
+
74
+ @target = widget_path
75
+
76
+ # Position relative to the parent toplevel
77
+ lx, ly, _lw, lh = @app.interp.window_geometry(widget_path)
78
+ px, py, _pw, _ph = @app.interp.window_geometry(@parent)
79
+ rel_x = lx - px
80
+ rel_y = ly - py + lh + 4
81
+
82
+ # Border frame (1px border effect via padding)
83
+ @app.command(:frame, @tip_path, background: TIP_BORDER, borderwidth: 0)
84
+
85
+ @app.command(:label, "#{@tip_path}.l",
86
+ text: text, background: TIP_BG, foreground: TIP_FG,
87
+ padx: 8, pady: 6, justify: :left)
88
+ @app.command(:pack, "#{@tip_path}.l", padx: 1, pady: 1)
89
+
90
+ @app.command(:place, @tip_path, x: rel_x, y: rel_y)
91
+ @app.command(:raise, @tip_path)
92
+
93
+ # Pause auto-dismiss while hovering the tooltip itself
94
+ @app.command(:bind, @tip_path, '<Enter>', proc { cancel_dismiss })
95
+ @app.command(:bind, @tip_path, '<Leave>', proc { schedule_dismiss })
96
+ end
97
+
98
+ # Hide the current tooltip.
99
+ def hide
100
+ cancel_dismiss
101
+ @target = nil
102
+ @app.tcl_eval("catch {destroy #{@tip_path}}")
103
+ end
104
+
105
+ # @return [Boolean] true if a tooltip is currently visible
106
+ def showing?
107
+ !!@target
108
+ end
109
+
110
+ private
111
+
112
+ def toggle(widget_path, text)
113
+ @click_guard = true
114
+ if @target == widget_path
115
+ hide
116
+ else
117
+ show(widget_path, text)
118
+ end
119
+ end
120
+
121
+ def schedule_dismiss
122
+ cancel_dismiss
123
+ @timer = @app.after(@dismiss_ms) { hide }
124
+ end
125
+
126
+ def cancel_dismiss
127
+ if @timer
128
+ @app.command(:after, :cancel, @timer)
129
+ @timer = nil
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ # Renders timed notification toasts at the bottom of the game viewport.
5
+ #
6
+ # One toast at a time; showing a new one replaces the old. The background
7
+ # is a pre-rendered anti-aliased rounded rectangle (generated in C).
8
+ #
9
+ # All SDL2 objects (renderer, font, textures) are injected or created
10
+ # internally, so the class can be tested with lightweight mocks.
11
+ #
12
+ # @example
13
+ # toast = ToastOverlay.new(renderer: vp.renderer, font: font, duration: 1.5)
14
+ # toast.show("State saved to slot 1")
15
+ # # inside render loop:
16
+ # toast.draw(r, dest_rect)
17
+ class ToastOverlay
18
+ PAD_X = 14
19
+ PAD_Y = 8
20
+ RADIUS = 8
21
+
22
+ # @param renderer [Teek::SDL2::Renderer] creates background textures
23
+ # @param font [Teek::SDL2::Font] renders toast text
24
+ # @param duration [Float] default display time in seconds
25
+ # @param bg_fn [#call] generates ARGB pixel data for the rounded-rect
26
+ # background; signature: bg_fn.call(w, h, radius) → String.
27
+ # Defaults to the C-implemented {Gemba.toast_background}.
28
+ def initialize(renderer:, font:, duration: 1.5, bg_fn: Gemba.method(:toast_background))
29
+ @renderer = renderer
30
+ @font = font
31
+ @duration = duration
32
+ @bg_fn = bg_fn
33
+ @crop_h = compute_crop_h(font)
34
+ @bg_tex = nil
35
+ @text_tex = nil
36
+ end
37
+
38
+ # @return [Float] default display duration in seconds
39
+ attr_accessor :duration
40
+
41
+ # Whether a toast is currently visible.
42
+ # @return [Boolean]
43
+ def visible?
44
+ !!@bg_tex
45
+ end
46
+
47
+ # Display a toast message. Replaces any existing toast.
48
+ #
49
+ # @param message [String]
50
+ # @param duration [Float, nil] seconds; nil uses the default
51
+ # @param permanent [Boolean] stays until {#destroy} is called
52
+ def show(message, duration: nil, permanent: false)
53
+ destroy
54
+
55
+ @text_tex = @font.render_text(message, 255, 255, 255)
56
+ tw = @text_tex.width
57
+ th = @crop_h || @text_tex.height
58
+
59
+ box_w = tw + PAD_X * 2
60
+ box_h = th + PAD_Y * 2
61
+
62
+ bg_pixels = @bg_fn.call(box_w, box_h, RADIUS)
63
+ @bg_tex = @renderer.create_texture(box_w, box_h, :streaming)
64
+ @bg_tex.update(bg_pixels)
65
+ @bg_tex.blend_mode = :blend
66
+
67
+ @box_w = box_w
68
+ @box_h = box_h
69
+ @text_w = tw
70
+ @text_h = th
71
+ @permanent = permanent
72
+ @expires = permanent ? nil : Process.clock_gettime(Process::CLOCK_MONOTONIC) + (duration || @duration)
73
+ end
74
+
75
+ # Draw the toast centered at the bottom of the game area.
76
+ #
77
+ # @param r [Teek::SDL2::Renderer]
78
+ # @param dest [Array(Integer,Integer,Integer,Integer), nil] game area rect
79
+ def draw(r, dest)
80
+ return unless @bg_tex
81
+ unless @permanent
82
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
83
+ if now >= @expires
84
+ destroy
85
+ return
86
+ end
87
+ end
88
+
89
+ # Position: bottom-center of game area, 12px from bottom
90
+ if dest
91
+ cx = dest[0] + dest[2] / 2
92
+ by = dest[1] + dest[3] - 12 - @box_h
93
+ else
94
+ out_w, out_h = r.output_size
95
+ cx = out_w / 2
96
+ by = out_h - 12 - @box_h
97
+ end
98
+ bx = cx - @box_w / 2
99
+
100
+ # Background (pre-rendered with AA rounded corners)
101
+ r.copy(@bg_tex, nil, [bx, by, @box_w, @box_h])
102
+ # White text centered in the box
103
+ tx = bx + (@box_w - @text_w) / 2
104
+ ty = by + (@box_h - @text_h) / 2
105
+ r.copy(@text_tex, [0, 0, @text_w, @text_h],
106
+ [tx, ty, @text_w, @text_h])
107
+ end
108
+
109
+ # Remove the current toast and free textures.
110
+ def destroy
111
+ @bg_tex&.destroy
112
+ @bg_tex = nil
113
+ @text_tex&.destroy
114
+ @text_tex = nil
115
+ end
116
+
117
+ private
118
+
119
+ # Crop height: ascent + partial descender. Excludes the very bottom
120
+ # rows where TTF anti-alias residue causes visible white-line artifacts.
121
+ def compute_crop_h(font)
122
+ return nil unless font
123
+ ascent = font.ascent
124
+ full_h = font.measure('p')[1]
125
+ [ascent + (full_h - ascent) / 2, full_h - 1].min
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ VERSION = "0.1.0"
5
+ end
data/lib/gemba.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "teek"
4
+ require "teek/sdl2"
5
+ require_relative "gemba/runtime"
6
+ require_relative "gemba/child_window"
7
+ require_relative "gemba/tip_service"
8
+ require_relative "gemba/settings_window"
9
+ require_relative "gemba/rom_info_window"
10
+ require_relative "gemba/save_state_picker"
11
+ require_relative "gemba/save_state_manager"
12
+ require_relative "gemba/toast_overlay"
13
+ require_relative "gemba/overlay_renderer"
14
+ require_relative "gemba/input_mappings"
15
+ require_relative "gemba/hotkey_map"
16
+ require_relative "gemba/recorder"
17
+ require_relative "gemba/player"
Binary file
File without changes
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Screenshot comparison helpers for SDL2 visual regression testing.
4
+ #
5
+ # Uses Renderer#read_pixels to capture GPU framebuffer output and
6
+ # ImageMagick to convert raw RGBA to PNG and compare against blessed baselines.
7
+ #
8
+ # Directory layout:
9
+ # screenshots/blessed/{platform}/ — committed gold images
10
+ # screenshots/unverified/{platform}/ — generated during test (gitignored)
11
+ # screenshots/diffs/{platform}/ — diff images on failure (gitignored)
12
+
13
+ require 'fileutils'
14
+ require 'open3'
15
+ require 'teek/platform'
16
+
17
+ module ScreenshotHelper
18
+ SCREENSHOTS_ROOT = File.expand_path('../screenshots', __dir__)
19
+
20
+ PLATFORM = Teek.platform.to_s
21
+
22
+ # Default pixel difference threshold for ImageMagick compare (AE metric).
23
+ # GPU drivers may produce minor anti-aliasing variations across runs.
24
+ THRESHOLD = Integer(ENV.fetch('SCREENSHOT_THRESHOLD', 100))
25
+
26
+ def self.blessed_dir
27
+ File.join(SCREENSHOTS_ROOT, 'blessed', PLATFORM)
28
+ end
29
+
30
+ def self.unverified_dir
31
+ File.join(SCREENSHOTS_ROOT, 'unverified', PLATFORM)
32
+ end
33
+
34
+ def self.diffs_dir
35
+ File.join(SCREENSHOTS_ROOT, 'diffs', PLATFORM)
36
+ end
37
+
38
+ def self.setup_dirs
39
+ FileUtils.mkdir_p(blessed_dir)
40
+ FileUtils.mkdir_p(unverified_dir)
41
+ FileUtils.mkdir_p(diffs_dir)
42
+ end
43
+
44
+ # Check if ImageMagick is available.
45
+ def self.imagemagick?
46
+ return @imagemagick if defined?(@imagemagick)
47
+ _, _, status = Open3.capture3('magick', '-version')
48
+ @imagemagick = status.success?
49
+ rescue Errno::ENOENT
50
+ @imagemagick = false
51
+ end
52
+
53
+ # Save raw RGBA pixels as PNG via ImageMagick.
54
+ def self.save_png(pixels, width, height, path)
55
+ cmd = ['magick', '-size', "#{width}x#{height}", '-depth', '8', 'rgba:-', path]
56
+ IO.popen(cmd, 'wb') { |io| io.write(pixels) }
57
+ raise "magick convert failed for #{path}" unless $?.success?
58
+ end
59
+
60
+ # Compare two PNGs with ImageMagick compare (AE metric).
61
+ # Returns [passed, pixel_diff, output].
62
+ def self.compare(expected, actual, diff_output)
63
+ cmd = ['magick', 'compare', '-metric', 'AE', expected, actual, diff_output]
64
+ stdout, stderr, status = Open3.capture3(*cmd)
65
+ output = stdout + stderr
66
+
67
+ pixel_diff = output[/(\d+)/]&.to_i
68
+ passed = pixel_diff ? pixel_diff <= THRESHOLD : status.success?
69
+
70
+ [passed, pixel_diff, output.strip]
71
+ end
72
+
73
+ # Assert that the current renderer output matches the blessed screenshot.
74
+ #
75
+ # Captures pixels from the renderer, saves to unverified/{platform}/{name}.png,
76
+ # then compares against blessed/{platform}/{name}.png.
77
+ #
78
+ # assert_sdl2_screenshot(renderer, "red_rect")
79
+ #
80
+ def assert_sdl2_screenshot(renderer, name, message: nil)
81
+ ScreenshotHelper.setup_dirs
82
+
83
+ unless ScreenshotHelper.imagemagick?
84
+ skip "ImageMagick not installed — skipping screenshot comparison"
85
+ end
86
+
87
+ w, h = renderer.output_size
88
+ pixels = renderer.read_pixels
89
+
90
+ unverified = File.join(ScreenshotHelper.unverified_dir, "#{name}.png")
91
+ ScreenshotHelper.save_png(pixels, w, h, unverified)
92
+
93
+ blessed = File.join(ScreenshotHelper.blessed_dir, "#{name}.png")
94
+
95
+ unless File.exist?(blessed)
96
+ flunk "No blessed screenshot for '#{name}'. " \
97
+ "Inspect #{unverified} and run: rake screenshots:bless"
98
+ end
99
+
100
+ diff = File.join(ScreenshotHelper.diffs_dir, "#{name}_diff.png")
101
+ passed, pixel_diff, output = ScreenshotHelper.compare(blessed, unverified, diff)
102
+
103
+ if passed
104
+ FileUtils.rm_f(diff)
105
+ else
106
+ msg = message || "Screenshot '#{name}' differs by #{pixel_diff} pixels (threshold: #{ScreenshotHelper::THRESHOLD})"
107
+ msg += "\n Blessed: #{blessed}"
108
+ msg += "\n Unverified: #{unverified}"
109
+ msg += "\n Diff: #{diff}"
110
+ flunk msg
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared SimpleCov configuration for all test contexts:
4
+ # - Main test process
5
+ # - Teek::TestWorker subprocess
6
+ # - Collation (Rakefile)
7
+ # - Subprocess preamble (tk_test_helper.rb)
8
+
9
+ module SimpleCovConfig
10
+ PROJECT_ROOT = File.expand_path('../..', __dir__)
11
+
12
+ FILTERS = [
13
+ '/test/',
14
+ %r{^/ext/},
15
+ 'lib/teek/method_coverage_service.rb',
16
+ 'lib/teek/background_none.rb',
17
+ 'lib/teek/demo_support.rb',
18
+ 'lib/teek/version.rb',
19
+ ].freeze
20
+
21
+ def self.apply_filters(simplecov_context)
22
+ FILTERS.each { |f| simplecov_context.add_filter(f) }
23
+ end
24
+
25
+ def self.apply_groups(simplecov_context)
26
+ simplecov_context.add_group 'Core', 'lib/teek.rb'
27
+ simplecov_context.add_group 'SDL2', 'lib/teek/sdl2'
28
+ end
29
+
30
+ # Generate add_filter code lines from FILTERS array (for subprocess preamble)
31
+ def self.filters_as_code
32
+ FILTERS.map do |f|
33
+ case f
34
+ when Regexp then "add_filter #{f.inspect}"
35
+ when String then "add_filter '#{f}'"
36
+ end
37
+ end.join("\n ")
38
+ end
39
+
40
+ # Ruby code string for subprocess SimpleCov setup
41
+ def self.subprocess_preamble(project_root: PROJECT_ROOT)
42
+ <<~RUBY
43
+ if ENV['COVERAGE']
44
+ require 'simplecov'
45
+
46
+ coverage_name = ENV['COVERAGE_NAME'] || 'default'
47
+ SimpleCov.coverage_dir "#{project_root}/coverage/results/\#{coverage_name}_sub_\#{Process.pid}"
48
+ SimpleCov.command_name "subprocess:\#{Process.pid}"
49
+ SimpleCov.print_error_status = false
50
+ SimpleCov.formatter SimpleCov::Formatter::SimpleFormatter
51
+
52
+ SimpleCov.start do
53
+ #{filters_as_code}
54
+ track_files "#{project_root}/lib/**/*.rb"
55
+ end
56
+ end
57
+ RUBY
58
+ end
59
+ end