teek 0.1.1 → 0.1.3

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +46 -0
  3. data/Rakefile +162 -5
  4. data/ext/teek/extconf.rb +1 -1
  5. data/ext/teek/tcltkbridge.c +9 -0
  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 +32 -4
  11. data/lib/teek/photo.rb +232 -0
  12. data/lib/teek/version.rb +1 -1
  13. data/lib/teek.rb +202 -5
  14. data/sample/gamepad_viewer/assets/controller.png +0 -0
  15. data/sample/gamepad_viewer/gamepad_viewer.rb +554 -0
  16. data/sample/optcarrot/thwaite.nes +0 -0
  17. data/sample/optcarrot/vendor/optcarrot/apu.rb +856 -0
  18. data/sample/optcarrot/vendor/optcarrot/config.rb +257 -0
  19. data/sample/optcarrot/vendor/optcarrot/cpu.rb +1162 -0
  20. data/sample/optcarrot/vendor/optcarrot/driver.rb +144 -0
  21. data/sample/optcarrot/vendor/optcarrot/mapper/cnrom.rb +14 -0
  22. data/sample/optcarrot/vendor/optcarrot/mapper/mmc1.rb +105 -0
  23. data/sample/optcarrot/vendor/optcarrot/mapper/mmc3.rb +153 -0
  24. data/sample/optcarrot/vendor/optcarrot/mapper/uxrom.rb +14 -0
  25. data/sample/optcarrot/vendor/optcarrot/nes.rb +105 -0
  26. data/sample/optcarrot/vendor/optcarrot/opt.rb +168 -0
  27. data/sample/optcarrot/vendor/optcarrot/pad.rb +92 -0
  28. data/sample/optcarrot/vendor/optcarrot/palette.rb +65 -0
  29. data/sample/optcarrot/vendor/optcarrot/ppu.rb +1468 -0
  30. data/sample/optcarrot/vendor/optcarrot/rom.rb +143 -0
  31. data/sample/optcarrot/vendor/optcarrot.rb +14 -0
  32. data/sample/optcarrot.rb +354 -0
  33. data/sample/paint/assets/bucket.png +0 -0
  34. data/sample/paint/assets/cursor.png +0 -0
  35. data/sample/paint/assets/eraser.png +0 -0
  36. data/sample/paint/assets/pencil.png +0 -0
  37. data/sample/paint/assets/spray.png +0 -0
  38. data/sample/paint/layer.rb +255 -0
  39. data/sample/paint/layer_manager.rb +179 -0
  40. data/sample/paint/paint_demo.rb +837 -0
  41. data/sample/paint/sparse_pixel_buffer.rb +202 -0
  42. data/sample/sdl2_demo.rb +318 -0
  43. data/sample/yam/assets/click.wav +0 -0
  44. data/sample/yam/assets/explosion.wav +0 -0
  45. data/sample/yam/assets/flag.wav +0 -0
  46. data/sample/yam/assets/music.mp3 +0 -0
  47. data/sample/yam/assets/sweep.wav +0 -0
  48. data/sample/{minesweeper/minesweeper.rb → yam/yam.rb} +147 -12
  49. metadata +50 -14
  50. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_0.png +0 -0
  51. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_1.png +0 -0
  52. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_2.png +0 -0
  53. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_3.png +0 -0
  54. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_4.png +0 -0
  55. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_5.png +0 -0
  56. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_6.png +0 -0
  57. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_7.png +0 -0
  58. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_8.png +0 -0
  59. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_F.png +0 -0
  60. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_M.png +0 -0
  61. /data/sample/{minesweeper → yam}/assets/MINESWEEPER_X.png +0 -0
data/lib/teek.rb CHANGED
@@ -4,6 +4,7 @@ require 'tcltklib'
4
4
  require_relative 'teek/version'
5
5
  require_relative 'teek/ractor_support'
6
6
  require_relative 'teek/widget'
7
+ require_relative 'teek/photo'
7
8
 
8
9
  # Ruby interface to Tcl/Tk. Provides a thin wrapper around a Tcl interpreter
9
10
  # with Ruby callbacks, event bindings, and background work support.
@@ -47,13 +48,15 @@ module Teek
47
48
 
48
49
  class App
49
50
  attr_reader :interp, :widgets, :debugger
51
+ attr_writer :_pending_exception # @api private
50
52
 
51
- def initialize(track_widgets: true, debug: false, &block)
53
+ def initialize(title: nil, track_widgets: true, debug: false, &block)
52
54
  @interp = Teek::Interp.new
53
55
  @interp.tcl_eval('package require Tk')
54
56
  hide
55
57
  @widgets = {}
56
58
  @widget_counters = Hash.new(0)
59
+ @_pending_exception = nil
57
60
  debug ||= !!ENV['TEEK_DEBUG']
58
61
  track_widgets = true if debug
59
62
  setup_widget_tracking if track_widgets
@@ -61,6 +64,7 @@ module Teek
61
64
  require_relative 'teek/debugger'
62
65
  @debugger = Teek::Debugger.new(self)
63
66
  end
67
+ set_window_title(title) if title
64
68
  instance_eval(&block) if block
65
69
  end
66
70
 
@@ -118,14 +122,24 @@ module Teek
118
122
 
119
123
  # Schedule a one-shot timer. Calls the block after +ms+ milliseconds.
120
124
  # @param ms [Integer] delay in milliseconds
125
+ # @param on_error [:raise, Proc, nil] error handling strategy:
126
+ # - +:raise+ (default) — exception propagates to Tcl background error handler.
127
+ # - +Proc+ — called with the exception; error is swallowed.
128
+ # - +nil+ — error is silently swallowed.
121
129
  # @yield block to call when the timer fires
122
130
  # @return [String] timer ID, pass to {#after_cancel} to cancel
123
131
  # @see https://www.tcl-lang.org/man/tcl8.6/TclCmd/after.htm#M5 after ms
124
- def after(ms, &block)
132
+ def after(ms, on_error: :raise, &block)
125
133
  cb_id = nil
126
134
  cb_id = @interp.register_callback(proc { |*|
127
- block.call
128
- @interp.unregister_callback(cb_id)
135
+ begin
136
+ block.call
137
+ rescue => e
138
+ raise if on_error == :raise
139
+ on_error.call(e) if on_error.is_a?(Proc)
140
+ ensure
141
+ @interp.unregister_callback(cb_id)
142
+ end
129
143
  })
130
144
  after_id = @interp.tcl_eval("after #{ms.to_i} {ruby_callback #{cb_id}}")
131
145
  after_id.instance_variable_set(:@cb_id, cb_id)
@@ -147,6 +161,33 @@ module Teek
147
161
  after_id
148
162
  end
149
163
 
164
+ # Schedule a repeating timer. Calls the block every +ms+ milliseconds
165
+ # until cancelled. The block runs on the main thread in the event loop,
166
+ # so it must be fast (don't block the UI).
167
+ #
168
+ # @param ms [Integer] interval in milliseconds
169
+ # @param on_error [:raise, Proc, nil] error handling strategy:
170
+ # - +:raise+ (default) — cancels the timer and raises the exception
171
+ # from the next call to {#update}.
172
+ # - +Proc+ — called with the exception; timer keeps running.
173
+ # - +nil+ — cancels the timer silently; error stored in {RepeatingTimer#last_error}.
174
+ # @yield block to call on each tick
175
+ # @return [RepeatingTimer] cancel handle
176
+ #
177
+ # @example Basic polling loop
178
+ # timer = app.every(50) { update_display }
179
+ # timer.cancel # stop later
180
+ #
181
+ # @example With error handler (timer keeps running)
182
+ # timer = app.every(100, on_error: ->(e) { log(e) }) { risky_work }
183
+ #
184
+ # @example Silent cancel on error
185
+ # timer = app.every(50, on_error: nil) { maybe_fails }
186
+ # timer.last_error # => check later
187
+ def every(ms, on_error: :raise, &block)
188
+ RepeatingTimer.new(self, ms, on_error: on_error, &block)
189
+ end
190
+
150
191
  # Cancel a pending {#after} or {#after_idle} timer.
151
192
  # @param after_id [String] timer ID returned by {#after} or {#after_idle}
152
193
  # @return [void]
@@ -313,7 +354,8 @@ module Teek
313
354
  # @param widget [String] Tk widget path (e.g. ".frame1")
314
355
  # @return [void]
315
356
  # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/destroy.htm destroy
316
- def destroy(widget)
357
+ def destroy(widget = '.')
358
+ raise ArgumentError, 'widget path cannot be nil' if widget.nil?
317
359
  tcl_eval("destroy #{widget}")
318
360
  end
319
361
 
@@ -388,6 +430,10 @@ module Teek
388
430
  # @see https://www.tcl-lang.org/man/tcl8.6/TclCmd/update.htm update
389
431
  def update
390
432
  @interp.tcl_eval('update')
433
+ if (e = @_pending_exception)
434
+ @_pending_exception = nil
435
+ raise e
436
+ end
391
437
  end
392
438
 
393
439
  # Process only pending idle callbacks (e.g. geometry redraws), then return.
@@ -413,6 +459,44 @@ module Teek
413
459
  @interp.tcl_eval("wm withdraw #{window}")
414
460
  end
415
461
 
462
+ # Enable the Tk debug console. The console starts hidden and can be
463
+ # toggled with the given keyboard shortcut (default: F12).
464
+ #
465
+ # The Tk console is a built-in interactive Tcl shell — useful for
466
+ # inspecting variables, running Tcl commands, and debugging widget
467
+ # layouts at runtime. It is available on macOS and Windows only;
468
+ # on Linux this method is a no-op (Linux has the real terminal).
469
+ #
470
+ # @param keybinding [String] Tk event to toggle the console
471
+ # (default: "<F12>")
472
+ # @return [Boolean] true if the console was created, false if
473
+ # unavailable on this platform
474
+ # @example
475
+ # app = Teek::App.new
476
+ # app.add_debug_console # F12 toggles console
477
+ # app.add_debug_console("<F11>") # custom key
478
+ # @see https://www.tcl-lang.org/man/tcl8.6/TkCmd/console.htm console
479
+ def add_debug_console(keybinding = '<F12>')
480
+ @interp.create_console
481
+ @_console_visible = false
482
+
483
+ toggle = proc do |*|
484
+ if @_console_visible
485
+ tcl_eval('console hide')
486
+ @_console_visible = false
487
+ else
488
+ tcl_eval('console show')
489
+ @_console_visible = true
490
+ end
491
+ end
492
+
493
+ command(:bind, '.', keybinding, toggle)
494
+ true
495
+ rescue TclError => e
496
+ warn "Teek: debug console not available on this platform (#{e.message})"
497
+ false
498
+ end
499
+
416
500
  # Set a window's title.
417
501
  # @param title [String] new title
418
502
  # @param window [String] Tk window path
@@ -673,9 +757,122 @@ module Teek
673
757
  "{ruby_callback #{id}}"
674
758
  when Symbol
675
759
  value.to_s
760
+ when Array
761
+ "{#{value.map { |v| tcl_value(v) }.join(' ')}}"
676
762
  else
677
763
  "{#{value}}"
678
764
  end
679
765
  end
680
766
  end
767
+
768
+ # A cancellable repeating timer that fires on the main thread.
769
+ #
770
+ # Created via {App#every}. Reschedules itself after each tick using
771
+ # Tcl's +after+ command. The block runs in the event loop, so it
772
+ # must complete quickly to avoid blocking the UI.
773
+ #
774
+ # Tracks timing drift: if a tick fires significantly late (more than
775
+ # 2x the interval), a warning is printed to stderr. This helps catch
776
+ # blocks that are too slow for the requested interval.
777
+ #
778
+ # @see App#every
779
+ class RepeatingTimer
780
+ # @return [Integer] interval in milliseconds
781
+ attr_reader :interval
782
+
783
+ # @return [Exception, nil] the last error if the timer stopped due to an
784
+ # unhandled exception, nil otherwise
785
+ attr_reader :last_error
786
+
787
+ # @return [Integer] number of ticks that fired late (> 2x interval)
788
+ attr_reader :late_ticks
789
+
790
+ # @api private
791
+ def initialize(app, ms, on_error: nil, &block)
792
+ raise ArgumentError, "interval must be positive, got #{ms}" if ms <= 0
793
+
794
+ @app = app
795
+ @interval = ms
796
+ @block = block
797
+ @on_error = on_error
798
+ @cancelled = false
799
+ @after_id = nil
800
+ @last_error = nil
801
+ @late_ticks = 0
802
+ @next_expected = nil
803
+ schedule
804
+ end
805
+
806
+ # Stop the timer. Safe to call multiple times.
807
+ # @return [void]
808
+ def cancel
809
+ return if @cancelled
810
+ @cancelled = true
811
+ @app.after_cancel(@after_id) if @after_id
812
+ @after_id = nil
813
+ end
814
+
815
+ # @return [Boolean] true if the timer has been cancelled
816
+ def cancelled?
817
+ @cancelled
818
+ end
819
+
820
+ # Change the interval. Takes effect on the next tick.
821
+ # @param ms [Integer] new interval in milliseconds
822
+ def interval=(ms)
823
+ raise ArgumentError, "interval must be positive, got #{ms}" if ms <= 0
824
+ @interval = ms
825
+ end
826
+
827
+ private
828
+
829
+ def schedule
830
+ return if @cancelled
831
+ @next_expected = now_ms + @interval
832
+ @after_id = @app.after(@interval) { tick }
833
+ end
834
+
835
+ def tick
836
+ return if @cancelled
837
+ check_drift
838
+ @block.call
839
+ schedule
840
+ rescue => e
841
+ @last_error = e
842
+ case @on_error
843
+ when :raise
844
+ @cancelled = true
845
+ # Store on App so it raises from the next app.update call.
846
+ # Don't re-raise here — that would go through rb_protect → bgerror.
847
+ @app._pending_exception = e
848
+ when Proc
849
+ begin
850
+ @on_error.call(e)
851
+ rescue => handler_err
852
+ @last_error = handler_err
853
+ @cancelled = true
854
+ @app._pending_exception = handler_err
855
+ return
856
+ end
857
+ schedule
858
+ when nil
859
+ @cancelled = true
860
+ end
861
+ end
862
+
863
+ def check_drift
864
+ return unless @next_expected
865
+ actual = now_ms
866
+ drift = actual - @next_expected
867
+ if drift > @interval
868
+ @late_ticks += 1
869
+ warn "Teek::RepeatingTimer: tick #{@late_ticks} fired #{drift.round}ms late " \
870
+ "(interval=#{@interval}ms)"
871
+ end
872
+ end
873
+
874
+ def now_ms
875
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
876
+ end
877
+ end
681
878
  end