teek-sdl2 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.
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teek
4
+ module SDL2
5
+ # GPU-resident pixel buffer backed by an +SDL_Texture+.
6
+ #
7
+ # Textures are created through a {Renderer}, not directly instantiated.
8
+ # Use the convenience constructors {.streaming} and {.target}, or call
9
+ # {Renderer#create_texture} directly.
10
+ #
11
+ # ## C-defined methods
12
+ #
13
+ # These are defined in the C extension (+sdl2surface.c+):
14
+ #
15
+ # - {#update} — upload pixel data from a String
16
+ # - {#width} — texture width in pixels
17
+ # - {#height} — texture height in pixels
18
+ # - {#destroy} — free GPU resources
19
+ # - {#destroyed?} — check if the texture has been destroyed
20
+ #
21
+ # @example Create and update a streaming texture
22
+ # tex = Teek::SDL2::Texture.streaming(renderer, 256, 224)
23
+ # tex.update(pixel_data_string)
24
+ # renderer.copy(tex)
25
+ #
26
+ # @see Renderer#create_texture
27
+ class Texture
28
+
29
+ # @!method update(pixel_data)
30
+ # Upload pixel data to the texture. The data must be a binary String
31
+ # of ARGB8888 pixels (4 bytes per pixel, width * height * 4 total).
32
+ # @param pixel_data [String] raw pixel bytes
33
+ # @return [self]
34
+
35
+ # @!method width
36
+ # @return [Integer] texture width in pixels
37
+
38
+ # @!method height
39
+ # @return [Integer] texture height in pixels
40
+
41
+ # @!method destroy
42
+ # Free this texture's GPU resources.
43
+ # @return [void]
44
+
45
+ # @!method destroyed?
46
+ # @return [Boolean] whether this texture has been destroyed
47
+
48
+ # Load an image file into a GPU texture via SDL2_image.
49
+ #
50
+ # @param renderer [Renderer] the renderer that owns this texture
51
+ # @param path [String] path to an image file (PNG, JPG, BMP, etc.)
52
+ # @return [Texture]
53
+ #
54
+ # @example
55
+ # sprite = Teek::SDL2::Texture.from_file(renderer, "assets/player.png")
56
+ # renderer.copy(sprite)
57
+ def self.from_file(renderer, path)
58
+ renderer.load_image(path)
59
+ end
60
+
61
+ # Create a streaming texture (lockable, CPU-updatable).
62
+ #
63
+ # @param renderer [Renderer] the renderer that owns this texture
64
+ # @param width [Integer] width in pixels
65
+ # @param height [Integer] height in pixels
66
+ # @return [Texture]
67
+ #
68
+ # @example
69
+ # tex = Teek::SDL2::Texture.streaming(renderer, 256, 224)
70
+ # tex.update(rgba_string)
71
+ def self.streaming(renderer, width, height)
72
+ renderer.create_texture(width, height, :streaming)
73
+ end
74
+
75
+ # Create a target texture (can be rendered to via +SDL_SetRenderTarget+).
76
+ #
77
+ # @param renderer [Renderer] the renderer that owns this texture
78
+ # @param width [Integer] width in pixels
79
+ # @param height [Integer] height in pixels
80
+ # @return [Texture]
81
+ def self.target(renderer, width, height)
82
+ renderer.create_texture(width, height, :target)
83
+ end
84
+
85
+ # @return [Array(Integer, Integer)] +[width, height]+
86
+ def size
87
+ [width, height]
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teek
4
+ module SDL2
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Teek
6
+ module SDL2
7
+ # An SDL2-accelerated rendering surface embedded in a Tk frame.
8
+ #
9
+ # Viewport creates a Tk frame, obtains its native window handle via
10
+ # the Tk C API, then embeds an SDL2 renderer inside it using
11
+ # +SDL_CreateWindowFrom+. All drawing goes through SDL2 with GPU
12
+ # acceleration — no Tk involvement in the rendering path.
13
+ #
14
+ # Keyboard input is tracked automatically via Tk bindings so you can
15
+ # poll key state with {#key_down?} in a game loop.
16
+ #
17
+ # @example Create a viewport and draw a red rectangle
18
+ # viewport = Teek::SDL2::Viewport.new(app, width: 800, height: 600)
19
+ # viewport.pack(fill: :both, expand: true)
20
+ #
21
+ # viewport.render do |r|
22
+ # r.clear(0, 0, 0)
23
+ # r.fill_rect(10, 10, 100, 50, 255, 0, 0)
24
+ # end
25
+ #
26
+ # @example Poll keyboard input
27
+ # if viewport.key_down?('left')
28
+ # player_x -= speed
29
+ # end
30
+ #
31
+ # @see Renderer
32
+ class Viewport
33
+ # @return [Teek::App] the Teek application
34
+ attr_reader :app
35
+
36
+ # @return [Teek::Widget] the underlying Tk frame
37
+ attr_reader :frame
38
+
39
+ # @return [Teek::SDL2::Renderer] the SDL2 renderer
40
+ attr_reader :renderer
41
+
42
+ # @return [Set<String>] currently held key names (lowercase keysyms)
43
+ attr_reader :keys_down
44
+
45
+ # @param app [Teek::App] the Teek application
46
+ # @param parent [Teek::Widget, String, nil] parent widget (nil for root)
47
+ # @param width [Integer] initial width in pixels
48
+ # @param height [Integer] initial height in pixels
49
+ def initialize(app, parent: nil, width: 640, height: 480)
50
+ @app = app
51
+ @destroyed = false
52
+
53
+ # Create a Tk frame to host the SDL2 window
54
+ @frame = app.create_widget('frame', parent: parent,
55
+ width: width, height: height)
56
+
57
+ # Pack with fixed size so the frame is managed, then force a
58
+ # full update. On X11, update_idletasks alone isn't enough —
59
+ # the window must process MapNotify to be usable by SDL2.
60
+ @frame.pack
61
+ app.tcl_eval('update')
62
+
63
+ # Get platform-native window handle via Tk C API
64
+ # (macOS: NSWindow*, X11: Window ID, Windows: HWND)
65
+ #
66
+ # NOTE: On macOS, Tk_MacOSXGetNSWindowForDrawable returns the NSWindow
67
+ # for the entire Tk toplevel, not just this frame. SDL_CreateWindowFrom
68
+ # therefore creates a renderer that covers the whole window. Tk widgets
69
+ # packed alongside the viewport will be painted over by SDL2 rendering.
70
+ # On X11 each frame has its own X Window, so embedding is frame-scoped.
71
+ # Workaround on macOS: use SDL2_ttf to draw overlay text on the surface
72
+ # rather than Tk widgets.
73
+ handle = app.interp.native_window_handle(@frame.path)
74
+
75
+ # Create SDL2 renderer embedded in the frame (Layer 2 → Layer 1)
76
+ @renderer = Teek::SDL2.create_renderer_from_handle(handle)
77
+
78
+ # Register SDL2 event source if this is the first viewport
79
+ Teek::SDL2.register_event_source
80
+
81
+ # Key state tracking for game-loop polling
82
+ @keys_down = Set.new
83
+ @frame.bind('KeyPress', :keysym) { |k| @keys_down.add(k.downcase) }
84
+ @frame.bind('KeyRelease', :keysym) { |k| @keys_down.delete(k.downcase) }
85
+
86
+ # Click-to-focus: Tk frames must have focus to receive key events
87
+ @frame.bind('ButtonPress-1') { focus }
88
+
89
+ # Bind cleanup on frame destroy
90
+ @frame.bind('<Destroy>') { _on_destroy }
91
+
92
+ # Track viewport count for event source lifecycle
93
+ Teek::SDL2._viewports << self
94
+ end
95
+
96
+ # Draw with the renderer in a block, auto-presenting at the end.
97
+ #
98
+ # @yield [renderer] the SDL2 renderer for this viewport
99
+ # @yieldparam renderer [Teek::SDL2::Renderer]
100
+ # @return [self]
101
+ # @raise [Teek::SDL2::Error] if the viewport has been destroyed
102
+ #
103
+ # @example
104
+ # viewport.render do |r|
105
+ # r.clear(0, 0, 0)
106
+ # r.fill_rect(10, 10, 100, 50, 255, 0, 0)
107
+ # end
108
+ def render(&block)
109
+ raise Teek::SDL2::Error, "viewport has been destroyed" if @destroyed
110
+ @renderer.render(&block)
111
+ end
112
+
113
+ # Pack the viewport into its parent using Tk's pack geometry manager.
114
+ #
115
+ # @param kwargs options passed to the Tk +pack+ command
116
+ # @return [self]
117
+ def pack(**kwargs)
118
+ @frame.pack(**kwargs)
119
+ self
120
+ end
121
+
122
+ # Grid the viewport into its parent using Tk's grid geometry manager.
123
+ #
124
+ # @param kwargs options passed to the Tk +grid+ command
125
+ # @return [self]
126
+ def grid(**kwargs)
127
+ @frame.grid(**kwargs)
128
+ self
129
+ end
130
+
131
+ # Check if a key is currently held down. Uses Tk keysym names (lowercase).
132
+ #
133
+ # @param keysym [String, Symbol] Tk keysym name (e.g. +'left'+, +'space'+, +'a'+)
134
+ # @return [Boolean]
135
+ #
136
+ # @example
137
+ # viewport.key_down?('left') # arrow key
138
+ # viewport.key_down?('space') # spacebar
139
+ # viewport.key_down?('a') # letter key
140
+ def key_down?(keysym)
141
+ @keys_down.include?(keysym.to_s.downcase)
142
+ end
143
+
144
+ # Give this viewport keyboard focus so it receives key events.
145
+ #
146
+ # @return [void]
147
+ def focus
148
+ @app.tcl_eval("focus #{@frame.path}")
149
+ end
150
+
151
+ # Bind a Tk event on the viewport frame.
152
+ #
153
+ # Automatically chains with internal behavior (key tracking,
154
+ # click-to-focus) so user callbacks don't clobber {#key_down?}.
155
+ #
156
+ # @param event [String] Tk event name (e.g. +'KeyPress'+, +'ButtonPress-1'+)
157
+ # @param subs [Array<Symbol, String>] Tk substitution codes
158
+ # @yield called when the event fires
159
+ # @return [void]
160
+ def bind(event, *subs, &block)
161
+ case event.to_s
162
+ when 'KeyPress'
163
+ @frame.bind(event, *subs) do |*args|
164
+ @keys_down.add(args.first.to_s.downcase) if args.first
165
+ block&.call(*args)
166
+ end
167
+ when 'KeyRelease'
168
+ @frame.bind(event, *subs) do |*args|
169
+ @keys_down.delete(args.first.to_s.downcase) if args.first
170
+ block&.call(*args)
171
+ end
172
+ when /ButtonPress/
173
+ @frame.bind(event, *subs) do |*args|
174
+ focus
175
+ block&.call(*args)
176
+ end
177
+ else
178
+ @frame.bind(event, *subs, &block)
179
+ end
180
+ end
181
+
182
+ # Destroy the viewport, its SDL2 renderer, and the Tk frame.
183
+ #
184
+ # @return [void]
185
+ def destroy
186
+ return if @destroyed
187
+ @renderer.destroy unless @renderer.destroyed?
188
+ @frame.destroy if @frame.exist?
189
+ _cleanup
190
+ end
191
+
192
+ # @return [Boolean] whether this viewport has been destroyed
193
+ def destroyed?
194
+ @destroyed
195
+ end
196
+
197
+ def inspect
198
+ "#<Teek::SDL2::Viewport #{@frame.path} #{destroyed? ? 'DESTROYED' : 'active'}>"
199
+ end
200
+
201
+ private
202
+
203
+ def _on_destroy
204
+ return if @destroyed
205
+ @renderer.destroy unless @renderer.destroyed?
206
+ _cleanup
207
+ end
208
+
209
+ def _cleanup
210
+ @destroyed = true
211
+ Teek::SDL2._viewports.delete(self)
212
+
213
+ # Unregister event source when last viewport is gone
214
+ if Teek::SDL2._viewports.empty?
215
+ Teek::SDL2.unregister_event_source
216
+ end
217
+ end
218
+ end
219
+
220
+ # Internal viewport tracking for event source lifecycle
221
+ @_viewports = []
222
+
223
+ class << self
224
+ # @api private
225
+ def _viewports
226
+ @_viewports
227
+ end
228
+ end
229
+ end
230
+ end
data/lib/teek/sdl2.rb ADDED
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "teek"
4
+ require_relative "sdl2/version"
5
+ require "teek_sdl2"
6
+
7
+ module Teek
8
+ # GPU-accelerated 2D rendering via SDL2, embedded inside Tk windows.
9
+ #
10
+ # Teek::SDL2 lets you drop an SDL2 hardware-accelerated surface into any
11
+ # Tk application. The surface lives inside a Tk frame so it coexists with
12
+ # normal Tk widgets (buttons, labels, menus) while all pixel work is
13
+ # GPU-driven.
14
+ #
15
+ # The main entry point is {Viewport}, which creates a Tk frame, obtains
16
+ # its native window handle, and hands it to SDL2 for rendering.
17
+ #
18
+ # @example Basic usage
19
+ # require 'teek'
20
+ # require 'teek/sdl2'
21
+ #
22
+ # app = Teek::App.new
23
+ # vp = Teek::SDL2::Viewport.new(app, width: 800, height: 600)
24
+ # vp.render do |r|
25
+ # r.clear(0, 0, 0)
26
+ # r.fill_rect(10, 10, 100, 50, 255, 0, 0)
27
+ # end
28
+ # app.mainloop
29
+ #
30
+ # @see Viewport
31
+ # @see Renderer
32
+ # @see Texture
33
+ # @see Font
34
+ module SDL2
35
+ @event_source = nil
36
+
37
+ # Register SDL2 as a Tcl event source. Called automatically when the
38
+ # first {Viewport} is created. Uses a C function pointer for the hot
39
+ # path — no Ruby in the poll loop.
40
+ #
41
+ # @param interval_ms [Integer] polling interval in milliseconds
42
+ # @return [void]
43
+ # @api private
44
+ def self.register_event_source(interval_ms: 16)
45
+ return if @event_source&.registered?
46
+
47
+ fn_ptr = _event_check_fn_ptr # C function address from sdl2bridge.c
48
+ @event_source = Teek._register_event_source(fn_ptr, 0, interval_ms)
49
+ end
50
+
51
+ # Remove SDL2 from Tcl's event loop. Called automatically when the last
52
+ # {Viewport} is destroyed.
53
+ #
54
+ # @return [void]
55
+ # @api private
56
+ def self.unregister_event_source
57
+ @event_source&.unregister
58
+ @event_source = nil
59
+ end
60
+ end
61
+ end
62
+
63
+ # Ruby convenience layers (reopen C-defined classes)
64
+ require_relative "sdl2/renderer"
65
+ require_relative "sdl2/texture"
66
+ require_relative "sdl2/font"
67
+
68
+ # Tk bridge (embeds SDL2 surface into a Tk frame)
69
+ require_relative "sdl2/viewport"
data/teek-sdl2.gemspec ADDED
@@ -0,0 +1,28 @@
1
+ require_relative "lib/teek/sdl2/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "teek-sdl2"
5
+ spec.version = Teek::SDL2::VERSION
6
+ spec.authors = ["James Cook"]
7
+ spec.email = ["jcook.rubyist@gmail.com"]
8
+
9
+ spec.summary = "GPU-accelerated SDL2 rendering for teek (Tk)"
10
+ spec.description = "Embeds an SDL2 renderer inside a Tk frame for GPU-accelerated drawing"
11
+ spec.homepage = "https://github.com/jamescook/teek"
12
+ spec.licenses = ["MIT"]
13
+
14
+ spec.files = Dir.glob("{lib,ext,test}/**/*").select { |f|
15
+ File.file?(f) && f !~ /\.(bundle|so|o|log)$/
16
+ } + %w[teek-sdl2.gemspec]
17
+ spec.require_paths = ["lib"]
18
+ spec.extensions = ["ext/teek_sdl2/extconf.rb"]
19
+ spec.required_ruby_version = ">= 3.2"
20
+
21
+ spec.add_dependency "teek", ">= 0.1.2"
22
+
23
+ spec.add_development_dependency "rake", "~> 13.0"
24
+ spec.add_development_dependency "rake-compiler", "~> 1.0"
25
+ spec.add_development_dependency "minitest", "~> 6.0"
26
+
27
+ spec.requirements << "SDL2 development headers (libsdl2-dev or sdl2 via Homebrew)"
28
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require_relative "../../test/tk_test_helper"
5
+
6
+ class TestCallbackTeardown < Minitest::Test
7
+ include TeekTestHelper
8
+
9
+ def test_viewport_destroy_no_bgerror
10
+ assert_tk_app("destroying viewport does not trigger bgerror") do
11
+ require "teek/sdl2"
12
+ app.show
13
+ app.update
14
+
15
+ # Capture any bgerror that Tcl would normally show in a dialog
16
+ app.set_variable("_bgerror_msg", "")
17
+ app.tcl_eval('proc bgerror {msg} { set ::_bgerror_msg $msg }')
18
+
19
+ vp = Teek::SDL2::Viewport.new(app, width: 200, height: 200)
20
+ vp.pack
21
+ app.update
22
+
23
+ vp.render do |r|
24
+ r.clear(30, 30, 30)
25
+ r.fill(20, 20, 80, 60, r: 200, g: 50, b: 50)
26
+ end
27
+
28
+ vp.destroy
29
+ app.update
30
+
31
+ err = app.get_variable("_bgerror_msg")
32
+ assert_equal "", err, "bgerror fired during viewport destroy: #{err}"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ if ENV['COVERAGE']
4
+ require 'simplecov'
5
+ require_relative '../../test/simplecov_config'
6
+
7
+ coverage_name = ENV['COVERAGE_NAME'] || 'sdl2'
8
+ SimpleCov.coverage_dir "#{SimpleCovConfig::PROJECT_ROOT}/coverage/results/#{coverage_name}"
9
+ SimpleCov.command_name "sdl2:#{coverage_name}"
10
+ SimpleCov.print_error_status = false
11
+ SimpleCov.formatter SimpleCov::Formatter::SimpleFormatter
12
+
13
+ SimpleCov.start do
14
+ SimpleCovConfig.apply_filters(self)
15
+ track_files "#{SimpleCovConfig::PROJECT_ROOT}/lib/**/*.rb"
16
+ end
17
+ end
18
+
19
+ require "minitest/autorun"
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require_relative "../../test/tk_test_helper"
5
+
6
+ class TestImage < Minitest::Test
7
+ include TeekTestHelper
8
+
9
+ def test_load_image
10
+ assert_tk_app("load_image returns a texture with correct dimensions") do
11
+ require "teek/sdl2"
12
+
13
+ png = fixture_path("teek-sdl2/assets/test_red_8x8.png")
14
+ app.show
15
+ app.update
16
+ viewport = Teek::SDL2::Viewport.new(app, width: 200, height: 200)
17
+
18
+ tex = viewport.renderer.load_image(png)
19
+ assert_kind_of Teek::SDL2::Texture, tex
20
+ assert_equal 8, tex.width
21
+ assert_equal 8, tex.height
22
+ assert_equal [8, 8], tex.size
23
+ refute tex.destroyed?
24
+
25
+ tex.destroy
26
+ assert tex.destroyed?
27
+ viewport.destroy
28
+ end
29
+ end
30
+
31
+ def test_load_image_and_render
32
+ assert_tk_app("load_image texture can be rendered") do
33
+ require "teek/sdl2"
34
+
35
+ png = fixture_path("teek-sdl2/assets/test_red_8x8.png")
36
+ app.show
37
+ app.update
38
+ viewport = Teek::SDL2::Viewport.new(app, width: 200, height: 200)
39
+ viewport.pack
40
+
41
+ tex = viewport.renderer.load_image(png)
42
+
43
+ viewport.render do |r|
44
+ r.clear(0, 0, 0)
45
+ r.copy(tex, nil, [0, 0, tex.width * 4, tex.height * 4])
46
+ end
47
+
48
+ tex.destroy
49
+ viewport.destroy
50
+ end
51
+ end
52
+
53
+ def test_texture_from_file
54
+ assert_tk_app("Texture.from_file convenience works") do
55
+ require "teek/sdl2"
56
+
57
+ png = fixture_path("teek-sdl2/assets/test_red_8x8.png")
58
+ app.show
59
+ app.update
60
+ viewport = Teek::SDL2::Viewport.new(app, width: 200, height: 200)
61
+
62
+ tex = Teek::SDL2::Texture.from_file(viewport.renderer, png)
63
+ assert_kind_of Teek::SDL2::Texture, tex
64
+ assert_equal 8, tex.width
65
+ assert_equal 8, tex.height
66
+
67
+ tex.destroy
68
+ viewport.destroy
69
+ end
70
+ end
71
+
72
+ def test_load_image_bad_path
73
+ assert_tk_app("load_image raises on missing file") do
74
+ require "teek/sdl2"
75
+
76
+ app.show
77
+ app.update
78
+ viewport = Teek::SDL2::Viewport.new(app, width: 200, height: 200)
79
+
80
+ assert_raises(RuntimeError) do
81
+ viewport.renderer.load_image("/nonexistent/path/to/image.png")
82
+ end
83
+
84
+ viewport.destroy
85
+ end
86
+ end
87
+ end