teek 0.1.0 → 0.1.2

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +99 -15
  3. data/Rakefile +201 -2
  4. data/ext/teek/extconf.rb +1 -1
  5. data/ext/teek/tcltkbridge.c +3 -110
  6. data/ext/teek/tcltkbridge.h +3 -0
  7. data/ext/teek/tkeventsource.c +195 -0
  8. data/ext/teek/tkphoto.c +169 -5
  9. data/ext/teek/tkwin.c +84 -0
  10. data/lib/teek/background_ractor4x.rb +35 -6
  11. data/lib/teek/debugger.rb +37 -32
  12. data/lib/teek/method_coverage_service.rb +265 -0
  13. data/lib/teek/photo.rb +232 -0
  14. data/lib/teek/ractor_support.rb +1 -1
  15. data/lib/teek/version.rb +1 -1
  16. data/lib/teek/widget.rb +104 -0
  17. data/lib/teek.rb +144 -1
  18. data/sample/calculator.rb +16 -21
  19. data/sample/debug_demo.rb +20 -22
  20. data/sample/optcarrot/vendor/optcarrot/apu.rb +856 -0
  21. data/sample/optcarrot/vendor/optcarrot/config.rb +257 -0
  22. data/sample/optcarrot/vendor/optcarrot/cpu.rb +1162 -0
  23. data/sample/optcarrot/vendor/optcarrot/driver.rb +144 -0
  24. data/sample/optcarrot/vendor/optcarrot/mapper/cnrom.rb +14 -0
  25. data/sample/optcarrot/vendor/optcarrot/mapper/mmc1.rb +105 -0
  26. data/sample/optcarrot/vendor/optcarrot/mapper/mmc3.rb +153 -0
  27. data/sample/optcarrot/vendor/optcarrot/mapper/uxrom.rb +14 -0
  28. data/sample/optcarrot/vendor/optcarrot/nes.rb +105 -0
  29. data/sample/optcarrot/vendor/optcarrot/opt.rb +168 -0
  30. data/sample/optcarrot/vendor/optcarrot/pad.rb +92 -0
  31. data/sample/optcarrot/vendor/optcarrot/palette.rb +65 -0
  32. data/sample/optcarrot/vendor/optcarrot/ppu.rb +1468 -0
  33. data/sample/optcarrot/vendor/optcarrot/rom.rb +143 -0
  34. data/sample/optcarrot/vendor/optcarrot.rb +14 -0
  35. data/sample/optcarrot.rb +354 -0
  36. data/sample/paint/assets/bucket.png +0 -0
  37. data/sample/paint/assets/cursor.png +0 -0
  38. data/sample/paint/assets/eraser.png +0 -0
  39. data/sample/paint/assets/pencil.png +0 -0
  40. data/sample/paint/assets/spray.png +0 -0
  41. data/sample/paint/layer.rb +255 -0
  42. data/sample/paint/layer_manager.rb +179 -0
  43. data/sample/paint/paint_demo.rb +837 -0
  44. data/sample/paint/sparse_pixel_buffer.rb +202 -0
  45. data/sample/sdl2_demo.rb +318 -0
  46. data/sample/threading_demo.rb +127 -132
  47. metadata +31 -1
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Sparse storage for pixel data - only stores non-default pixels.
4
+ # Memory efficient for layers with limited drawing.
5
+ #
6
+ # Stores pixels as 4-byte binary strings to avoid pack/unpack overhead
7
+ # when interfacing with TkPhotoImage#put_block.
8
+ #
9
+ class SparsePixelBuffer
10
+ # Default: fully transparent
11
+ DEFAULT_PIXEL = "\x00\x00\x00\x00".b.freeze
12
+ PIXEL_SIZE = 4
13
+
14
+ attr_reader :width, :height, :default_pixel
15
+
16
+ def initialize(width, height, default: DEFAULT_PIXEL)
17
+ @width = width
18
+ @height = height
19
+ @default_pixel = default.frozen? ? default : default.dup.freeze
20
+ @pixels = {} # {linear_index => 4-byte binary string}
21
+ @bbox = nil # [min_x, min_y, max_x, max_y] or nil if empty
22
+ end
23
+
24
+ def get_pixel(x, y)
25
+ return nil if out_of_bounds?(x, y)
26
+ @pixels[y * @width + x] || @default_pixel
27
+ end
28
+
29
+ def set_pixel(x, y, rgba_bytes)
30
+ return if out_of_bounds?(x, y)
31
+
32
+ key = y * @width + x
33
+
34
+ if rgba_bytes == @default_pixel
35
+ @pixels.delete(key)
36
+ recalculate_bbox if @pixels.empty? || bbox_edge?(x, y)
37
+ else
38
+ @pixels[key] = rgba_bytes.frozen? ? rgba_bytes : rgba_bytes.dup
39
+ expand_bbox(x, y)
40
+ end
41
+ end
42
+
43
+ # Set pixel from RGBA integers (convenience method)
44
+ def set_rgba(x, y, r, g, b, a = 255)
45
+ set_pixel(x, y, [r, g, b, a].pack('CCCC'))
46
+ end
47
+
48
+ # Get pixel as RGBA integers (convenience method)
49
+ def get_rgba(x, y)
50
+ pixel = get_pixel(x, y)
51
+ pixel&.unpack('CCCC')
52
+ end
53
+
54
+ def empty?
55
+ @pixels.empty?
56
+ end
57
+
58
+ def pixel_count
59
+ @pixels.size
60
+ end
61
+
62
+ def bbox
63
+ @bbox&.dup
64
+ end
65
+
66
+ # Returns [x, y, width, height] for the bounding box
67
+ def bbox_xywh
68
+ return nil unless @bbox
69
+ [@bbox[0], @bbox[1], @bbox[2] - @bbox[0] + 1, @bbox[3] - @bbox[1] + 1]
70
+ end
71
+
72
+ # Materialize a region to a contiguous RGBA buffer for put_block
73
+ def materialize(x: 0, y: 0, width: @width, height: @height)
74
+ # Clamp to valid region
75
+ x = x.clamp(0, @width - 1)
76
+ y = y.clamp(0, @height - 1)
77
+ width = [width, @width - x].min
78
+ height = [height, @height - y].min
79
+
80
+ # Allocate buffer filled with default pixel
81
+ buffer = (@default_pixel * (width * height)).dup
82
+
83
+ # Patch in non-default pixels
84
+ @pixels.each do |key, rgba|
85
+ px = key % @width
86
+ py = key / @width
87
+
88
+ # Skip if outside requested region
89
+ next unless px >= x && px < x + width && py >= y && py < y + height
90
+
91
+ # Calculate offset in output buffer
92
+ offset = ((py - y) * width + (px - x)) * PIXEL_SIZE
93
+ buffer[offset, PIXEL_SIZE] = rgba
94
+ end
95
+
96
+ buffer
97
+ end
98
+
99
+ # Materialize only the bounding box (returns nil if empty)
100
+ def materialize_bbox
101
+ return nil if empty? || @bbox.nil?
102
+ bx, by, bw, bh = bbox_xywh
103
+ materialize(x: bx, y: by, width: bw, height: bh)
104
+ end
105
+
106
+ # Resize the buffer, preserving pixels that fit within new bounds
107
+ def resize(new_width, new_height)
108
+ return if new_width == @width && new_height == @height
109
+
110
+ new_pixels = {}
111
+ @pixels.each do |key, rgba|
112
+ x = key % @width
113
+ y = key / @width
114
+ next if x >= new_width || y >= new_height
115
+ new_pixels[y * new_width + x] = rgba
116
+ end
117
+
118
+ @width = new_width
119
+ @height = new_height
120
+ @pixels = new_pixels
121
+ recalculate_bbox
122
+ end
123
+
124
+ # Clear all pixels
125
+ def clear
126
+ @pixels.clear
127
+ @bbox = nil
128
+ end
129
+
130
+ # Create a full copy
131
+ def dup
132
+ copy = SparsePixelBuffer.new(@width, @height, default: @default_pixel)
133
+ copy.instance_variable_set(:@pixels, @pixels.dup)
134
+ copy.instance_variable_set(:@bbox, @bbox&.dup)
135
+ copy
136
+ end
137
+
138
+ # Memory usage estimate in bytes
139
+ def memory_usage
140
+ # Hash overhead (~40 bytes) + per-entry (~50 bytes: key + value + hash bucket)
141
+ 40 + (@pixels.size * 50)
142
+ end
143
+
144
+ # Density as fraction of total pixels
145
+ def density
146
+ @pixels.size.to_f / (@width * @height)
147
+ end
148
+
149
+ # Iterate over non-default pixels
150
+ def each_pixel
151
+ return enum_for(:each_pixel) unless block_given?
152
+
153
+ @pixels.each do |key, rgba|
154
+ x = key % @width
155
+ y = key / @width
156
+ yield x, y, rgba
157
+ end
158
+ end
159
+
160
+ private
161
+
162
+ def out_of_bounds?(x, y)
163
+ x < 0 || x >= @width || y < 0 || y >= @height
164
+ end
165
+
166
+ def expand_bbox(x, y)
167
+ if @bbox.nil?
168
+ @bbox = [x, y, x, y]
169
+ else
170
+ @bbox[0] = x if x < @bbox[0]
171
+ @bbox[1] = y if y < @bbox[1]
172
+ @bbox[2] = x if x > @bbox[2]
173
+ @bbox[3] = y if y > @bbox[3]
174
+ end
175
+ end
176
+
177
+ def bbox_edge?(x, y)
178
+ return false unless @bbox
179
+ x == @bbox[0] || x == @bbox[2] || y == @bbox[1] || y == @bbox[3]
180
+ end
181
+
182
+ def recalculate_bbox
183
+ if @pixels.empty?
184
+ @bbox = nil
185
+ return
186
+ end
187
+
188
+ min_x = min_y = Float::INFINITY
189
+ max_x = max_y = -Float::INFINITY
190
+
191
+ @pixels.each_key do |key|
192
+ x = key % @width
193
+ y = key / @width
194
+ min_x = x if x < min_x
195
+ max_x = x if x > max_x
196
+ min_y = y if y < min_y
197
+ max_y = y if y > max_y
198
+ end
199
+
200
+ @bbox = [min_x, min_y, max_x, max_y]
201
+ end
202
+ end
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ # teek-record: title=SDL2 Demo
4
+
5
+ # SDL2 Demo - GPU-accelerated rendering embedded in a Tk frame
6
+ #
7
+ # Demonstrates:
8
+ # - Teek::SDL2::Viewport with animated rectangles
9
+ # - SDL2_image: loading a PNG sprite (ruby gem)
10
+ # - SDL2_ttf: text rendering
11
+ # - Keyboard/mouse input via Tk event bindings
12
+ # - Separate Tk event log window proving bidirectional event flow
13
+ #
14
+ # Ruby gem image: CC0 from https://purepng.com/photo/27996/clipart-ruby-gem
15
+ #
16
+ # Run: ruby -Ilib -Iteek-sdl2/lib sample/sdl2_demo.rb
17
+
18
+ $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
19
+ $LOAD_PATH.unshift File.expand_path('../teek-sdl2/lib', __dir__)
20
+ require 'teek'
21
+ require 'teek/sdl2'
22
+
23
+ FONT_PATH = File.join(__dir__, '..', 'teek-sdl2', 'assets', 'JetBrainsMonoNL-Regular.ttf')
24
+ RUBY_GEM_PATH = File.join(__dir__, '..', 'teek-sdl2', 'assets', 'ruby_gem_64.png')
25
+
26
+ class SDL2Demo
27
+ attr_reader :app
28
+
29
+ COLORS = [
30
+ [255, 60, 60], # red
31
+ [60, 200, 60], # green
32
+ [60, 100, 255], # blue
33
+ [255, 200, 40], # yellow
34
+ [200, 60, 255], # purple
35
+ ].freeze
36
+
37
+ MAX_KEYSTROKES = 8
38
+ MAX_PARTICLES = 60
39
+
40
+ def initialize
41
+ @app = Teek::App.new
42
+ @app.show
43
+ @app.set_window_title('SDL2 Demo')
44
+ @app.set_window_geometry('640x520')
45
+
46
+ @title = 'SDL2 Demo'
47
+
48
+ # SDL2 viewport
49
+ @viewport = Teek::SDL2::Viewport.new(@app, width: 640, height: 480)
50
+ @viewport.pack(fill: :both, expand: true)
51
+
52
+ # Load font for text rendering
53
+ @font = @viewport.renderer.load_font(FONT_PATH, 18)
54
+ @font_small = @viewport.renderer.load_font(FONT_PATH, 12)
55
+
56
+ # Load ruby gem sprite via SDL2_image
57
+ @gem_tex = @viewport.renderer.load_image(RUBY_GEM_PATH)
58
+
59
+ # Bouncing boxes (last one is the gem sprite)
60
+ @boxes = COLORS.each_with_index.map do |color, i|
61
+ {
62
+ x: 40 + i * 100, y: 40 + i * 60,
63
+ w: 60, h: 40,
64
+ dx: 2 + i, dy: 1 + i,
65
+ r: color[0], g: color[1], b: color[2]
66
+ }
67
+ end
68
+ @boxes << {
69
+ x: 300, y: 200,
70
+ w: @gem_tex.width, h: @gem_tex.height,
71
+ dx: 3, dy: 2,
72
+ sprite: @gem_tex
73
+ }
74
+
75
+ # Input state
76
+ @recent_keys = [] # [{text:, age:}, ...]
77
+ @particles = [] # [{x:, y:, dx:, dy:, age:, r:, g:, b:}, ...]
78
+ @has_focus = false
79
+
80
+ # Wire up input
81
+ setup_input
82
+ setup_event_log
83
+
84
+ @frame_count = 0
85
+ @fps_frames = 0
86
+ @fps_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
87
+ @fps_text = '-- fps'
88
+ @running = true
89
+ end
90
+
91
+ def setup_input
92
+ # Key events (viewport tracks state internally, we also show them)
93
+ @viewport.bind('KeyPress', :keysym) do |k|
94
+ @recent_keys.unshift({ text: k, age: 0 })
95
+ @recent_keys.pop if @recent_keys.size > MAX_KEYSTROKES
96
+ log_event("KEY DOWN: #{k}")
97
+ end
98
+
99
+ @viewport.bind('KeyRelease', :keysym) do |k|
100
+ log_event("KEY UP: #{k}")
101
+ end
102
+
103
+ # Mouse particles
104
+ @viewport.bind('Motion', :x, :y) do |x, y|
105
+ spawn_particle(x.to_i, y.to_i)
106
+ log_event("MOUSE: #{x},#{y}")
107
+ end
108
+
109
+ @viewport.bind('ButtonPress-1', :x, :y) do |x, y|
110
+ 5.times { spawn_particle(x.to_i, y.to_i) }
111
+ log_event("CLICK: #{x},#{y}")
112
+ end
113
+
114
+ # Focus tracking
115
+ @viewport.bind('FocusIn') { @has_focus = true }
116
+ @viewport.bind('FocusOut') { @has_focus = false }
117
+ end
118
+
119
+ def setup_event_log
120
+ @log_path = '.evlog'
121
+ @app.command(:toplevel, @log_path)
122
+ @app.command(:wm, :title, @log_path, 'Event Log')
123
+ @app.command(:wm, :geometry, @log_path, '300x400+660+0')
124
+
125
+ @log_text = @app.create_widget(:text, @log_path + '.log',
126
+ width: 40, height: 25, font: '{TkFixedFont} 10',
127
+ state: :disabled, background: '#1e1e1e', foreground: '#cccccc')
128
+ @log_text.pack(fill: :both, expand: true)
129
+
130
+ @app.command(:wm, :protocol, @log_path, 'WM_DELETE_WINDOW',
131
+ proc { @app.command(:wm, :withdraw, @log_path) })
132
+ end
133
+
134
+ def log_event(msg)
135
+ @app.command(@log_text, :configure, state: :normal)
136
+ @app.command(@log_text, :insert, 'end', msg + "\n")
137
+ @app.command(@log_text, :see, 'end')
138
+ @app.command(@log_text, :configure, state: :disabled)
139
+
140
+ # Keep last 200 lines
141
+ count = @app.command(@log_text, :count, '-lines', '1.0', 'end').to_i
142
+ if count > 200
143
+ @app.command(@log_text, :configure, state: :normal)
144
+ @app.command(@log_text, :delete, '1.0', "#{count - 200}.0")
145
+ @app.command(@log_text, :configure, state: :disabled)
146
+ end
147
+ end
148
+
149
+ def spawn_particle(x, y)
150
+ angle = rand * 2 * Math::PI
151
+ speed = 1 + rand * 3
152
+ color = COLORS.sample
153
+ @particles << {
154
+ x: x.to_f, y: y.to_f,
155
+ dx: Math.cos(angle) * speed, dy: Math.sin(angle) * speed,
156
+ age: 0, r: color[0], g: color[1], b: color[2]
157
+ }
158
+ @particles.shift if @particles.size > MAX_PARTICLES
159
+ end
160
+
161
+ def tick
162
+ return unless @running
163
+
164
+ w, h = @viewport.renderer.output_size
165
+
166
+ # Move boxes
167
+ @boxes.each do |box|
168
+ box[:x] += box[:dx]
169
+ box[:y] += box[:dy]
170
+
171
+ if box[:x] <= 0 || box[:x] + box[:w] >= w
172
+ box[:dx] = -box[:dx]
173
+ box[:x] = box[:x].clamp(0, w - box[:w])
174
+ end
175
+ if box[:y] <= 0 || box[:y] + box[:h] >= h
176
+ box[:dy] = -box[:dy]
177
+ box[:y] = box[:y].clamp(0, h - box[:h])
178
+ end
179
+ end
180
+
181
+ # Age particles
182
+ @particles.each { |p| p[:age] += 1; p[:x] += p[:dx]; p[:y] += p[:dy] }
183
+ @particles.reject! { |p| p[:age] > 30 }
184
+
185
+ # Age keystrokes
186
+ @recent_keys.each { |k| k[:age] += 1 }
187
+ @recent_keys.reject! { |k| k[:age] > 120 }
188
+
189
+ # Draw
190
+ @viewport.render do |r|
191
+ r.clear(20, 20, 30)
192
+
193
+ # Bouncing boxes (+ gem sprite)
194
+ @boxes.each do |box|
195
+ if box[:sprite]
196
+ r.copy(box[:sprite], nil, [box[:x], box[:y], box[:w], box[:h]])
197
+ else
198
+ r.fill_rect(box[:x], box[:y], box[:w], box[:h],
199
+ box[:r], box[:g], box[:b])
200
+ end
201
+ end
202
+
203
+ # Particles
204
+ @particles.each do |p|
205
+ alpha = ((1.0 - p[:age] / 30.0) * 255).to_i
206
+ size = [4 - p[:age] / 10, 1].max
207
+ r.fill_rect(p[:x].to_i, p[:y].to_i, size, size,
208
+ p[:r], p[:g], p[:b], alpha)
209
+ end
210
+
211
+ # Keystrokes in bottom-right
212
+ @recent_keys.each_with_index do |k, i|
213
+ alpha = ((1.0 - k[:age] / 120.0) * 255).to_i
214
+ r.draw_text(w - 150, h - 30 - i * 22, k[:text],
215
+ font: @font, r: 255, g: 255, b: 255, a: alpha)
216
+ end
217
+
218
+ # FPS top-left
219
+ r.draw_text(8, 8, @fps_text, font: @font_small, r: 180, g: 180, b: 180)
220
+
221
+ # Focus hint
222
+ unless @has_focus
223
+ r.draw_text(w / 2 - 60, h / 2, "click to focus",
224
+ font: @font_small, r: 100, g: 100, b: 100)
225
+ end
226
+ end
227
+
228
+ @frame_count += 1
229
+ @fps_frames += 1
230
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
231
+ elapsed = now - @fps_time
232
+ if elapsed >= 0.5
233
+ @fps_text = "#{(@fps_frames / elapsed).round(1)} fps"
234
+ @fps_frames = 0
235
+ @fps_time = now
236
+ end
237
+ end
238
+
239
+ def animate(interval: 16, &on_done)
240
+ tick
241
+ if @running
242
+ @app.after(interval) { animate(interval: interval, &on_done) }
243
+ else
244
+ on_done&.call
245
+ end
246
+ end
247
+
248
+ def stop
249
+ @running = false
250
+ end
251
+
252
+ def run
253
+ animate
254
+ @app.mainloop
255
+ end
256
+ end
257
+
258
+ demo = SDL2Demo.new
259
+
260
+ # Automated demo support (testing and recording)
261
+ require_relative '../lib/teek/demo_support'
262
+ TeekDemo.app = demo.app
263
+
264
+ if TeekDemo.recording?
265
+ demo.app.set_window_geometry('+0+0')
266
+ demo.app.tcl_eval('. configure -cursor none')
267
+ TeekDemo.signal_recording_ready
268
+ end
269
+
270
+ if TeekDemo.active?
271
+ vp = demo.instance_variable_get(:@viewport)
272
+ fp = vp.frame.path
273
+
274
+ TeekDemo.after_idle do
275
+ d = TeekDemo.method(:delay)
276
+ app = demo.app
277
+ gen = proc { |ev, **opts|
278
+ args = opts.map { |k, v| "-#{k} #{v}" }.join(' ')
279
+ app.tcl_eval("event generate #{fp} <#{ev}> #{args}")
280
+ }
281
+
282
+ demo.animate
283
+
284
+ # Give focus (-force needed on X11/xvfb for event generate to deliver key events)
285
+ app.after(d.call(test: 1, record: 300)) { app.tcl_eval("focus -force #{fp}") }
286
+
287
+ # Click around to show particles
288
+ clicks = [[200, 150], [400, 300], [100, 350], [500, 200], [300, 100]]
289
+ clicks.each_with_index do |(x, y), i|
290
+ t = d.call(test: 1, record: 600) * (i + 1) + d.call(test: 1, record: 500)
291
+ app.after(t) {
292
+ gen.call('ButtonPress-1', x: x, y: y)
293
+ app.after(50) { gen.call('ButtonRelease-1', x: x, y: y) }
294
+ }
295
+ end
296
+
297
+ # Type some keys (overlaps with clicks)
298
+ keys = %w[H e l l o space S D L 2]
299
+ base = d.call(test: 1, record: 2000)
300
+ keys.each_with_index do |k, i|
301
+ t = base + d.call(test: 1, record: 150) * (i + 1)
302
+ app.after(t) {
303
+ gen.call('KeyPress', keysym: k)
304
+ app.after(80) { gen.call('KeyRelease', keysym: k) }
305
+ }
306
+ end
307
+
308
+ # Finish after ~5s recording / immediately in test
309
+ app.after(d.call(test: 50, record: 5000)) {
310
+ demo.stop
311
+ TeekDemo.finish
312
+ }
313
+ end
314
+ else
315
+ demo.animate
316
+ end
317
+
318
+ demo.app.mainloop