teek 0.1.3 → 0.1.4

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +21 -0
  3. data/Rakefile +120 -22
  4. data/ext/teek/extconf.rb +19 -1
  5. data/ext/teek/tcltkbridge.c +38 -2
  6. data/ext/teek/tcltkbridge.h +3 -0
  7. data/ext/teek/tkdrop.c +66 -0
  8. data/ext/teek/tkdrop.h +26 -0
  9. data/ext/teek/tkdrop_macos.m +141 -0
  10. data/ext/teek/tkdrop_win.c +232 -0
  11. data/ext/teek/tkdrop_x11.c +337 -0
  12. data/ext/teek/tkwin.c +42 -0
  13. data/lib/teek/platform.rb +29 -0
  14. data/lib/teek/version.rb +1 -1
  15. data/lib/teek.rb +49 -3
  16. data/teek.gemspec +3 -2
  17. metadata +7 -53
  18. data/sample/calculator.rb +0 -255
  19. data/sample/debug_demo.rb +0 -43
  20. data/sample/gamepad_viewer/assets/controller.png +0 -0
  21. data/sample/gamepad_viewer/gamepad_viewer.rb +0 -554
  22. data/sample/goldberg.rb +0 -1803
  23. data/sample/goldberg_helpers.rb +0 -170
  24. data/sample/optcarrot/thwaite.nes +0 -0
  25. data/sample/optcarrot/vendor/optcarrot/apu.rb +0 -856
  26. data/sample/optcarrot/vendor/optcarrot/config.rb +0 -257
  27. data/sample/optcarrot/vendor/optcarrot/cpu.rb +0 -1162
  28. data/sample/optcarrot/vendor/optcarrot/driver.rb +0 -144
  29. data/sample/optcarrot/vendor/optcarrot/mapper/cnrom.rb +0 -14
  30. data/sample/optcarrot/vendor/optcarrot/mapper/mmc1.rb +0 -105
  31. data/sample/optcarrot/vendor/optcarrot/mapper/mmc3.rb +0 -153
  32. data/sample/optcarrot/vendor/optcarrot/mapper/uxrom.rb +0 -14
  33. data/sample/optcarrot/vendor/optcarrot/nes.rb +0 -105
  34. data/sample/optcarrot/vendor/optcarrot/opt.rb +0 -168
  35. data/sample/optcarrot/vendor/optcarrot/pad.rb +0 -92
  36. data/sample/optcarrot/vendor/optcarrot/palette.rb +0 -65
  37. data/sample/optcarrot/vendor/optcarrot/ppu.rb +0 -1468
  38. data/sample/optcarrot/vendor/optcarrot/rom.rb +0 -143
  39. data/sample/optcarrot/vendor/optcarrot.rb +0 -14
  40. data/sample/optcarrot.rb +0 -354
  41. data/sample/paint/assets/bucket.png +0 -0
  42. data/sample/paint/assets/cursor.png +0 -0
  43. data/sample/paint/assets/eraser.png +0 -0
  44. data/sample/paint/assets/pencil.png +0 -0
  45. data/sample/paint/assets/spray.png +0 -0
  46. data/sample/paint/layer.rb +0 -255
  47. data/sample/paint/layer_manager.rb +0 -179
  48. data/sample/paint/paint_demo.rb +0 -837
  49. data/sample/paint/sparse_pixel_buffer.rb +0 -202
  50. data/sample/sdl2_demo.rb +0 -318
  51. data/sample/threading_demo.rb +0 -494
  52. data/sample/yam/assets/MINESWEEPER_0.png +0 -0
  53. data/sample/yam/assets/MINESWEEPER_1.png +0 -0
  54. data/sample/yam/assets/MINESWEEPER_2.png +0 -0
  55. data/sample/yam/assets/MINESWEEPER_3.png +0 -0
  56. data/sample/yam/assets/MINESWEEPER_4.png +0 -0
  57. data/sample/yam/assets/MINESWEEPER_5.png +0 -0
  58. data/sample/yam/assets/MINESWEEPER_6.png +0 -0
  59. data/sample/yam/assets/MINESWEEPER_7.png +0 -0
  60. data/sample/yam/assets/MINESWEEPER_8.png +0 -0
  61. data/sample/yam/assets/MINESWEEPER_F.png +0 -0
  62. data/sample/yam/assets/MINESWEEPER_M.png +0 -0
  63. data/sample/yam/assets/MINESWEEPER_X.png +0 -0
  64. data/sample/yam/assets/click.wav +0 -0
  65. data/sample/yam/assets/explosion.wav +0 -0
  66. data/sample/yam/assets/flag.wav +0 -0
  67. data/sample/yam/assets/music.mp3 +0 -0
  68. data/sample/yam/assets/sweep.wav +0 -0
  69. data/sample/yam/yam.rb +0 -587
@@ -1,837 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # teek-record: title=Paint Demo
3
- # frozen_string_literal: true
4
-
5
- # Paint Demo - Simple MS Paint-style drawing application
6
- #
7
- # Demonstrates what's possible with Teek beyond "hello world":
8
- # - Teek::Photo for fast CPU-side pixel manipulation (flood fill, spray paint)
9
- # - Canvas for vector drawing (strokes, shapes)
10
- # - Layers with sparse pixel storage and photo image backing
11
- # - Multi-window UI (tools palette, color palette, main canvas)
12
- # - Undo/redo system
13
- # - Menu bars and keyboard shortcuts
14
- #
15
- # This is NOT meant to be a production paint app -- it's a showcase of
16
- # Teek's capabilities for anyone wondering "what can I actually build?"
17
- #
18
- # Tool icons in assets/ from Lucide (https://lucide.dev, MIT license)
19
- # and Iconoir (https://iconoir.com, MIT license).
20
-
21
- require_relative '../../lib/teek'
22
- require_relative 'layer_manager'
23
-
24
- class PaintDemo
25
- # Classic 16-color palette (Windows/VGA style)
26
- COLORS = [
27
- '#000000', '#808080', '#800000', '#808000',
28
- '#008000', '#008080', '#000080', '#800080',
29
- '#FFFFFF', '#C0C0C0', '#FF0000', '#FFFF00',
30
- '#00FF00', '#00FFFF', '#0000FF', '#FF00FF'
31
- ].freeze
32
-
33
- MAX_UNDO = 10
34
-
35
- PHOTO_WIDTH = 800
36
- PHOTO_HEIGHT = 600
37
- ASSETS_DIR = File.join(__dir__, 'assets').freeze
38
-
39
- def initialize(app)
40
- @app = app
41
- @brush_color = '#000000'
42
- @bg_color_hex = '#FFFFFF'
43
- @brush_size = 1
44
- @spray_density = 3
45
- @canvas_width = PHOTO_WIDTH
46
- @canvas_height = PHOTO_HEIGHT
47
- @last_x = nil
48
- @last_y = nil
49
-
50
- # Undo/redo stacks
51
- @undo_stack = []
52
- @redo_stack = []
53
- @current_stroke_items = []
54
-
55
- # Layer manager (created after canvas in setup_main_window)
56
- @layers = nil
57
-
58
- setup_main_window
59
- setup_tools_window
60
- setup_palette_window
61
- end
62
-
63
- def setup_main_window
64
- @app.set_window_title('Paint')
65
- @app.set_window_geometry("#{PHOTO_WIDTH}x#{PHOTO_HEIGHT + 40}")
66
-
67
- # Menu bar
68
- @app.command(:menu, '.menubar')
69
- @app.command('.', :configure, menu: '.menubar')
70
- create_edit_menu('.menubar')
71
- create_layer_menu('.menubar')
72
- create_window_menu('.menubar')
73
-
74
- # Status bar (packed first so canvas gets remaining space)
75
- status_frame = @app.create_widget('ttk::frame')
76
- status_frame.pack(side: :bottom, fill: :x)
77
-
78
- # Canvas fills the rest of the window
79
- @canvas = @app.create_widget(:canvas, background: :gray, cursor: :crosshair)
80
- @canvas.pack(fill: :both, expand: true)
81
-
82
- # Layer manager handles photo images and pixel buffers
83
- @layers = LayerManager.new(@app, @canvas, PHOTO_WIDTH, PHOTO_HEIGHT)
84
- @layers.active_layer.ensure_photo!
85
- @layers.active_layer.refresh_display
86
-
87
- # Drawing bindings
88
- @canvas.bind('ButtonPress-1', :x, :y) { |x, y| start_stroke(x.to_i, y.to_i) }
89
- @canvas.bind('B1-Motion', :x, :y) { |x, y| continue_stroke(x.to_i, y.to_i) }
90
- @canvas.bind('ButtonRelease-1') { end_stroke }
91
-
92
- # Keyboard shortcuts
93
- @app.bind('.', 'c') { clear_active_layer }
94
- @app.bind('.', 'Escape') { @app.destroy('.') }
95
- @app.bind('.', 'Control-z') { undo }
96
- @app.bind('.', 'Control-Z') { redo_action }
97
- @app.bind('.', 'Control-y') { redo_action }
98
-
99
- # Tool shortcuts
100
- @app.bind('.', 'b') { select_tool(:brush) }
101
- @app.bind('.', 'e') { select_tool(:eraser) }
102
- @app.bind('.', 'g') { select_tool(:bucket) }
103
- @app.bind('.', 's') { select_tool(:spray) }
104
-
105
- # Layer shortcuts
106
- @app.bind('.', 'Control-N') { add_layer }
107
- @app.bind('.', 'Control-period') { toggle_layer_visibility }
108
- (1..9).each do |n|
109
- @app.bind('.', "Key-#{n}") { select_layer_by_number(n - 1) }
110
- end
111
-
112
- @color_indicator = @app.create_widget(:canvas, parent: status_frame,
113
- width: 20, height: 20, highlightthickness: 1)
114
- @color_indicator.pack(side: :left, padx: 5, pady: 3)
115
- update_color_indicator
116
-
117
- @layer_var = 'paint_layer_info'
118
- @app.set_variable(@layer_var, '[0] Background')
119
- @app.create_widget('ttk::label', parent: status_frame,
120
- textvariable: @layer_var, width: 20).pack(side: :left, padx: 5)
121
-
122
- # Brush size control
123
- @app.create_widget('ttk::label', parent: status_frame,
124
- text: 'Size:').pack(side: :left, padx: 5)
125
- @brush_size_var = 'paint_brush_size'
126
- @app.set_variable(@brush_size_var, @brush_size.to_s)
127
- size_spinbox = @app.create_widget('ttk::spinbox', parent: status_frame,
128
- from: 1, to: 10, width: 3,
129
- textvariable: @brush_size_var,
130
- command: proc { update_brush_size })
131
- size_spinbox.pack(side: :left)
132
- size_spinbox.bind('KeyRelease') { update_brush_size }
133
-
134
- # Spray density control (only visible when spray tool selected)
135
- @density_label = @app.create_widget('ttk::label', parent: status_frame,
136
- text: 'Density:')
137
- @spray_density_var = 'paint_spray_density'
138
- @app.set_variable(@spray_density_var, @spray_density.to_s)
139
- @density_spinbox = @app.create_widget('ttk::spinbox', parent: status_frame,
140
- from: 1, to: 20, width: 3,
141
- textvariable: @spray_density_var,
142
- command: proc { update_spray_density })
143
- @density_spinbox.bind('KeyRelease') { update_spray_density }
144
- # Hidden by default (shown when spray tool is selected)
145
-
146
- @coords_var = 'paint_coords'
147
- @app.set_variable(@coords_var, '0, 0')
148
- @app.create_widget('ttk::label', parent: status_frame,
149
- textvariable: @coords_var, width: 12).pack(side: :left, padx: 10)
150
-
151
- @app.create_widget('ttk::label', parent: status_frame,
152
- text: "Ruby #{RUBY_VERSION}").pack(side: :right, padx: 10)
153
-
154
- # Track mouse position
155
- @last_coords_update = 0
156
- @canvas.bind('Motion', :x, :y) do |x, y|
157
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
158
- if (now - @last_coords_update) >= 0.005
159
- @app.set_variable(@coords_var, "#{x}, #{y}")
160
- @last_coords_update = now
161
- end
162
- end
163
-
164
- # Resize layers when canvas resizes
165
- @canvas.bind('Configure', :width, :height) do |w, h|
166
- new_w = w.to_i
167
- new_h = h.to_i
168
- if new_w > 0 && new_h > 0 && (new_w != @canvas_width || new_h != @canvas_height)
169
- @canvas_width = new_w
170
- @canvas_height = new_h
171
- @layers.resize(new_w, new_h)
172
- end
173
- end
174
-
175
- update_title
176
- end
177
-
178
- def setup_tools_window
179
- @tools_path = '.tools'
180
- @app.command(:toplevel, @tools_path)
181
- @app.command(:wm, :title, @tools_path, 'Tools')
182
- @app.command(:wm, :geometry, @tools_path, '50x200+910+300')
183
- @app.command(:wm, :resizable, @tools_path, 0, 0)
184
-
185
- @current_tool = :brush
186
-
187
- # Load PNG icons from assets
188
- @tool_icons = {}
189
- { brush: 'pencil', eraser: 'eraser', bucket: 'bucket', spray: 'spray' }.each do |tool, file|
190
- path = File.join(ASSETS_DIR, "#{file}.png")
191
- @tool_icons[tool] = Teek::Photo.new(@app, file: path)
192
- end
193
-
194
- tool_defs = [
195
- [:brush, 'Brush (B)'],
196
- [:eraser, 'Eraser (E)'],
197
- [:bucket, 'Fill (G)'],
198
- [:spray, 'Spray (S)']
199
- ]
200
-
201
- @tool_buttons = {}
202
- tool_defs.each do |tool, tip|
203
- btn = @app.create_widget(:canvas, "#{@tools_path}.#{tool}",
204
- width: 36, height: 36, background: :white,
205
- highlightthickness: 2, highlightbackground: :gray)
206
- btn.pack(padx: 4, pady: 4)
207
- @app.command(btn, :create, :image, 18, 18,
208
- image: @tool_icons[tool].name, anchor: :center)
209
- btn.bind('ButtonPress-1') { select_tool(tool) }
210
- add_tooltip(btn, tip)
211
- @tool_buttons[tool] = btn
212
- end
213
-
214
- select_tool(:brush)
215
-
216
- @app.command(:wm, :protocol, @tools_path, 'WM_DELETE_WINDOW',
217
- proc { @app.command(:wm, :withdraw, @tools_path) })
218
- end
219
-
220
- def setup_palette_window
221
- @palette_path = '.palette'
222
- @app.command(:toplevel, @palette_path)
223
- @app.command(:wm, :title, @palette_path, 'Colors')
224
- @app.command(:wm, :geometry, @palette_path, '170x160+910+100')
225
- @app.command(:wm, :resizable, @palette_path, 0, 0)
226
-
227
- # Grid of color buttons (4x4)
228
- COLORS.each_with_index do |color, i|
229
- row = i / 4
230
- col = i % 4
231
-
232
- btn = @app.create_widget(:canvas, parent: @palette_path,
233
- width: 32, height: 32, background: color,
234
- highlightthickness: 2, highlightbackground: :gray)
235
- btn.grid(row: row, column: col, padx: 2, pady: 2)
236
- btn.bind('ButtonPress-1') { select_color(color) }
237
- end
238
-
239
- @app.command(:wm, :protocol, @palette_path, 'WM_DELETE_WINDOW',
240
- proc { @app.command(:wm, :withdraw, @palette_path) })
241
- end
242
-
243
- def create_edit_menu(menubar)
244
- @app.command(:menu, "#{menubar}.edit", tearoff: 0)
245
- @app.command(menubar, :add, :cascade, label: 'Edit', menu: "#{menubar}.edit")
246
- @app.command("#{menubar}.edit", :add, :command,
247
- label: 'Undo', accelerator: 'Ctrl+Z', command: proc { undo })
248
- @app.command("#{menubar}.edit", :add, :command,
249
- label: 'Redo', accelerator: 'Ctrl+Shift+Z', command: proc { redo_action })
250
- @app.command("#{menubar}.edit", :add, :separator)
251
- @app.command("#{menubar}.edit", :add, :command,
252
- label: 'Clear Layer', command: proc { clear_active_layer })
253
- @app.command("#{menubar}.edit", :add, :command,
254
- label: 'Clear All Layers', command: proc { clear_canvas })
255
- end
256
-
257
- def create_layer_menu(menubar)
258
- @app.command(:menu, "#{menubar}.layer", tearoff: 0)
259
- @app.command(menubar, :add, :cascade, label: 'Layer', menu: "#{menubar}.layer")
260
- @app.command("#{menubar}.layer", :add, :command,
261
- label: 'Add Layer', command: proc { add_layer })
262
- @app.command("#{menubar}.layer", :add, :command,
263
- label: 'Delete Layer', command: proc { delete_layer })
264
- @app.command("#{menubar}.layer", :add, :separator)
265
- @app.command("#{menubar}.layer", :add, :command,
266
- label: 'Toggle Visibility', command: proc { toggle_layer_visibility })
267
- @app.command("#{menubar}.layer", :add, :separator)
268
- @app.command("#{menubar}.layer", :add, :command,
269
- label: 'Flatten All', command: proc { flatten_layers })
270
- end
271
-
272
- def create_window_menu(menubar)
273
- @app.command(:menu, "#{menubar}.window", tearoff: 0)
274
- @app.command(menubar, :add, :cascade, label: 'Window', menu: "#{menubar}.window")
275
- @app.command("#{menubar}.window", :add, :command,
276
- label: 'Show Tools', command: proc { @app.command(:wm, :deiconify, @tools_path) })
277
- @app.command("#{menubar}.window", :add, :command,
278
- label: 'Show Colors', command: proc { @app.command(:wm, :deiconify, @palette_path) })
279
- end
280
-
281
- def add_tooltip(widget, text)
282
- widget.bind('Enter') do
283
- @app.tcl_eval('catch {destroy .tooltip}')
284
- @app.command(:toplevel, '.tooltip', background: '#FFFFE0')
285
- @app.command(:wm, :overrideredirect, '.tooltip', 1)
286
- @app.tcl_eval('catch {wm attributes .tooltip -type tooltip}')
287
- @app.tcl_eval('catch {wm attributes .tooltip -transparent true}')
288
- x = @app.tcl_eval('winfo pointerx .').to_i + 15
289
- y = @app.tcl_eval('winfo pointery .').to_i + 10
290
- @app.command(:wm, :geometry, '.tooltip', "+#{x}+#{y}")
291
- @app.create_widget(:frame, '.tooltip.f',
292
- background: '#FFFFE0', relief: :solid,
293
- borderwidth: 1).pack(fill: :both, expand: true)
294
- @app.create_widget(:label, '.tooltip.f.l', text: text,
295
- background: '#FFFFE0', foreground: '#000000',
296
- padx: 4, pady: 2).pack
297
- end
298
- widget.bind('Leave') do
299
- @app.tcl_eval('catch {destroy .tooltip}')
300
- end
301
- end
302
-
303
- def select_tool(tool)
304
- @current_tool = tool
305
- @tool_buttons.each do |_name, btn|
306
- btn.command(:configure, background: :white, highlightbackground: :gray, highlightthickness: 2)
307
- end
308
- @tool_buttons[tool]&.command(:configure, background: '#ADD8E6',
309
- highlightbackground: :black, highlightthickness: 3)
310
-
311
- cursor = case tool
312
- when :brush then :crosshair
313
- when :eraser then :dotbox
314
- when :bucket then :target
315
- when :spray then :spraycan
316
- else :crosshair
317
- end
318
- @canvas.command(:configure, cursor: cursor)
319
-
320
- # Show/hide spray density control
321
- if tool == :spray
322
- @density_label.pack(side: :left, padx: 5)
323
- @density_spinbox.pack(side: :left)
324
- else
325
- @app.command(:pack, :forget, @density_label) rescue nil
326
- @app.command(:pack, :forget, @density_spinbox) rescue nil
327
- end
328
- end
329
-
330
- def select_color(color)
331
- @brush_color = color
332
- update_color_indicator
333
- end
334
-
335
- def update_color_indicator
336
- @color_indicator.command(:configure, background: @brush_color)
337
- end
338
-
339
- def update_brush_size
340
- size = @app.get_variable(@brush_size_var).to_i
341
- size = 1 if size < 1
342
- size = 10 if size > 10
343
- @brush_size = size
344
- end
345
-
346
- def update_spray_density
347
- d = @app.get_variable(@spray_density_var).to_i
348
- d = 1 if d < 1
349
- d = 20 if d > 20
350
- @spray_density = d
351
- end
352
-
353
- # -- Drawing operations ---------------------------------------------------
354
-
355
- def start_stroke(x, y)
356
- if @current_tool == :bucket
357
- flood_fill(x, y)
358
- return
359
- end
360
-
361
- if @current_tool == :spray
362
- layer = @layers.active_layer
363
- @spray_old_pixels = layer.snapshot_pixels
364
- spray_paint(x, y)
365
- return
366
- end
367
-
368
- return unless @current_tool == :brush || @current_tool == :eraser
369
- @current_stroke_items = []
370
- @last_x = x
371
- @last_y = y
372
- draw_point(x, y)
373
- end
374
-
375
- def continue_stroke(x, y)
376
- if @current_tool == :spray
377
- spray_paint(x, y)
378
- return
379
- end
380
-
381
- return unless @current_tool == :brush || @current_tool == :eraser
382
- return unless @last_x && @last_y
383
-
384
- color = @current_tool == :eraser ? @bg_color_hex : @brush_color
385
- size = @current_tool == :eraser ? @brush_size * 3 : @brush_size
386
-
387
- item = @app.command(@canvas, :create, :line, @last_x, @last_y, x, y,
388
- fill: color, width: size, capstyle: :round, joinstyle: :round)
389
- @current_stroke_items << item
390
-
391
- @last_x = x
392
- @last_y = y
393
- end
394
-
395
- def end_stroke
396
- if @current_tool == :spray
397
- layer = @layers.active_layer
398
- if @spray_old_pixels
399
- push_undo(LayerPixelsCommand.new(layer, @spray_old_pixels, layer.snapshot_pixels))
400
- end
401
- @spray_old_pixels = nil
402
- return
403
- end
404
-
405
- if @current_stroke_items && @current_stroke_items.any?
406
- push_undo(StrokeCommand.new(@app, @canvas, @current_stroke_items.dup))
407
- end
408
- @current_stroke_items = []
409
- @last_x = nil
410
- @last_y = nil
411
- end
412
-
413
- def draw_point(x, y)
414
- color = @current_tool == :eraser ? @bg_color_hex : @brush_color
415
- size = @current_tool == :eraser ? @brush_size * 3 : @brush_size
416
- r = size / 2.0
417
- item = @app.command(@canvas, :create, :oval, x - r, y - r, x + r, y + r,
418
- fill: color, outline: color)
419
- @current_stroke_items << item if @current_stroke_items
420
- end
421
-
422
- # -- Layer operations -----------------------------------------------------
423
-
424
- def clear_canvas
425
- @layers.clear_all
426
- @layers.refresh_all
427
- end
428
-
429
- def clear_active_layer
430
- layer = @layers.active_layer
431
- return unless layer
432
- layer.clear
433
- layer.refresh_display
434
- end
435
-
436
- def add_layer
437
- @layers.add_layer
438
- update_title
439
- end
440
-
441
- def delete_layer
442
- return if @layers.layers.size <= 1
443
- @layers.remove_layer(@layers.active_index)
444
- @layers.refresh_all
445
- update_title
446
- end
447
-
448
- def toggle_layer_visibility
449
- layer = @layers.active_layer
450
- return unless layer
451
- layer.toggle_visibility
452
- end
453
-
454
- def flatten_layers
455
- @layers.flatten
456
- update_title
457
- end
458
-
459
- def update_title
460
- layer = @layers.active_layer
461
- layer_info = layer ? "[#{@layers.active_index}] #{layer.name}" : ""
462
- @app.set_window_title("Paint - #{layer_info}")
463
- @app.set_variable(@layer_var, layer_info) if @layer_var
464
- end
465
-
466
- def select_layer_by_number(index)
467
- return unless index >= 0 && index < @layers.layers.size
468
- @layers.active_index = index
469
- update_title
470
- end
471
-
472
- # -- Pixel operations -----------------------------------------------------
473
-
474
- def get_pixel(x, y)
475
- @layers.active_layer&.get_rgba(x, y)
476
- end
477
-
478
- def set_pixel(x, y, rgba)
479
- layer = @layers.active_layer
480
- return unless layer
481
- layer.set_rgba(x, y, *rgba)
482
- end
483
-
484
- def parse_hex_color(hex)
485
- hex = hex.delete('#')
486
- r = hex[0, 2].to_i(16)
487
- g = hex[2, 2].to_i(16)
488
- b = hex[4, 2].to_i(16)
489
- [r, g, b, 255]
490
- end
491
-
492
- def colors_match?(c1, c2, tolerance = 0)
493
- return false unless c1 && c2
494
- (c1[0] - c2[0]).abs <= tolerance &&
495
- (c1[1] - c2[1]).abs <= tolerance &&
496
- (c1[2] - c2[2]).abs <= tolerance
497
- end
498
-
499
- # -- Flood fill -----------------------------------------------------------
500
-
501
- def flood_fill(x, y)
502
- x = x.to_i
503
- y = y.to_i
504
- layer = @layers.active_layer
505
- return unless layer
506
- return if x < 0 || x >= @canvas_width || y < 0 || y >= @canvas_height
507
-
508
- target_color = get_pixel(x, y)
509
- fill_color = parse_hex_color(@brush_color)
510
-
511
- return if colors_match?(target_color, fill_color)
512
-
513
- old_pixels = layer.snapshot_pixels
514
- scanline_fill(x, y, target_color, fill_color)
515
- layer.refresh_display
516
- push_undo(LayerPixelsCommand.new(layer, old_pixels, layer.snapshot_pixels))
517
- end
518
-
519
- def scanline_fill(start_x, start_y, target_color, fill_color)
520
- stack = [[start_x, start_y]]
521
-
522
- while !stack.empty?
523
- x, y = stack.pop
524
- next if y < 0 || y >= @canvas_height
525
-
526
- lx = x
527
- while lx > 0 && colors_match?(get_pixel(lx - 1, y), target_color)
528
- lx -= 1
529
- end
530
-
531
- span_above = false
532
- span_below = false
533
-
534
- while lx < @canvas_width && colors_match?(get_pixel(lx, y), target_color)
535
- set_pixel(lx, y, fill_color)
536
-
537
- if y > 0
538
- above_matches = colors_match?(get_pixel(lx, y - 1), target_color)
539
- if !span_above && above_matches
540
- stack.push([lx, y - 1])
541
- span_above = true
542
- elsif span_above && !above_matches
543
- span_above = false
544
- end
545
- end
546
-
547
- if y < @canvas_height - 1
548
- below_matches = colors_match?(get_pixel(lx, y + 1), target_color)
549
- if !span_below && below_matches
550
- stack.push([lx, y + 1])
551
- span_below = true
552
- elsif span_below && !below_matches
553
- span_below = false
554
- end
555
- end
556
-
557
- lx += 1
558
- end
559
- end
560
- end
561
-
562
- # -- Spray paint ----------------------------------------------------------
563
-
564
- def spray_paint(x, y)
565
- x = x.to_i
566
- y = y.to_i
567
- layer = @layers.active_layer
568
- return unless layer
569
-
570
- fill_color = parse_hex_color(@brush_color)
571
- radius = @brush_size * 5
572
- pixels_per_spray = @brush_size * @spray_density
573
-
574
- pixels_per_spray.times do
575
- angle = rand * 2 * Math::PI
576
- r = rand * radius
577
- px = x + (r * Math.cos(angle)).to_i
578
- py = y + (r * Math.sin(angle)).to_i
579
- set_pixel(px, py, fill_color)
580
- end
581
-
582
- layer.refresh_display
583
- end
584
-
585
- # -- Undo/Redo ------------------------------------------------------------
586
-
587
- def push_undo(command)
588
- @undo_stack << command
589
- @undo_stack.shift if @undo_stack.size > MAX_UNDO
590
- @redo_stack.clear
591
- end
592
-
593
- def undo
594
- return if @undo_stack.empty?
595
- command = @undo_stack.pop
596
- command.undo
597
- @redo_stack << command
598
- end
599
-
600
- def redo_action
601
- return if @redo_stack.empty?
602
- command = @redo_stack.pop
603
- command.redo
604
- @undo_stack << command
605
- end
606
-
607
- # Command classes for undo/redo
608
- class StrokeCommand
609
- def initialize(app, canvas, items)
610
- @app = app
611
- @canvas = canvas
612
- @items = items
613
- @configs = items.map do |item|
614
- type = @app.command(@canvas, :type, item)
615
- coords = @app.split_list(@app.command(@canvas, :coords, item))
616
- {
617
- type: type,
618
- coords: coords,
619
- fill: (@app.command(@canvas, :itemcget, item, '-fill') rescue nil),
620
- width: (@app.command(@canvas, :itemcget, item, '-width') rescue nil),
621
- outline: (@app.command(@canvas, :itemcget, item, '-outline') rescue nil),
622
- capstyle: (@app.command(@canvas, :itemcget, item, '-capstyle') rescue nil),
623
- joinstyle: (@app.command(@canvas, :itemcget, item, '-joinstyle') rescue nil)
624
- }
625
- end
626
- end
627
-
628
- def undo
629
- @items.each { |item| @app.command(@canvas, :delete, item) }
630
- end
631
-
632
- def redo
633
- @items = @configs.map do |cfg|
634
- case cfg[:type]
635
- when 'line'
636
- opts = { fill: cfg[:fill], width: cfg[:width] }
637
- opts[:capstyle] = cfg[:capstyle] if cfg[:capstyle] && cfg[:capstyle] != ''
638
- opts[:joinstyle] = cfg[:joinstyle] if cfg[:joinstyle] && cfg[:joinstyle] != ''
639
- @app.command(@canvas, :create, :line, *cfg[:coords], **opts)
640
- when 'oval'
641
- @app.command(@canvas, :create, :oval, *cfg[:coords],
642
- fill: cfg[:fill], outline: cfg[:outline] || cfg[:fill])
643
- when 'rectangle'
644
- @app.command(@canvas, :create, :rectangle, *cfg[:coords],
645
- outline: cfg[:outline], width: cfg[:width])
646
- end
647
- end
648
- end
649
- end
650
-
651
- class LayerPixelsCommand
652
- def initialize(layer, old_pixels, new_pixels)
653
- @layer = layer
654
- @old_pixels = old_pixels
655
- @new_pixels = new_pixels
656
- end
657
-
658
- def undo
659
- @layer.restore_pixels(@old_pixels)
660
- end
661
-
662
- def redo
663
- @layer.restore_pixels(@new_pixels)
664
- end
665
- end
666
-
667
- # -- Auto-paint demo (for TeekDemo) --------------------------------------
668
- # Simulates real user interaction via virtual mouse events.
669
- # Actions are chained sequentially so the event loop can process display
670
- # updates between each one.
671
-
672
- def run_auto_demo
673
- # Move tools window onto the canvas so it's visible in the recording
674
- @app.command(:wm, :geometry, @tools_path, '+10+80')
675
- @app.command(:wm, :deiconify, @tools_path)
676
-
677
- @demo_queue = []
678
- @demo_canvas_path = @canvas.path
679
- @demo_interval = TeekDemo.delay(test: 1, record: 15)
680
- @demo_action_num = 0
681
-
682
- # Helper: queue a virtual mouse event
683
- q_mouse = proc do |event, x, y|
684
- @demo_queue << proc {
685
- @app.tcl_eval("event generate #{@demo_canvas_path} <#{event}> -x #{x} -y #{y}")
686
- }
687
- end
688
-
689
- # Helper: queue a UI action
690
- q_act = proc do |&block|
691
- @demo_queue << block
692
- end
693
-
694
- # -- Fill background sky blue with bucket tool --
695
- q_act.call { select_color('#87CEEB') }
696
- q_act.call { select_tool(:bucket) }
697
- q_mouse.call('ButtonPress-1', 400, 300)
698
- q_mouse.call('ButtonRelease-1', 400, 300)
699
-
700
- # -- Spray green ground --
701
- q_act.call { select_color('#228B22') }
702
- q_act.call { select_tool(:spray) }
703
- q_act.call do
704
- @brush_size = 10
705
- @app.set_variable(@brush_size_var, '10')
706
- @spray_density = 20
707
- @app.set_variable(@spray_density_var, '20')
708
- end
709
- q_mouse.call('ButtonPress-1', 50, 500)
710
- (100..750).step(40) do |x|
711
- q_mouse.call('B1-Motion', x, 480 + rand(40))
712
- end
713
- q_mouse.call('ButtonRelease-1', 750, 510)
714
- q_mouse.call('ButtonPress-1', 750, 550)
715
- (710..50).step(-40) do |x|
716
- q_mouse.call('B1-Motion', x, 530 + rand(40))
717
- end
718
- q_mouse.call('ButtonRelease-1', 50, 560)
719
-
720
- # -- Spray white clouds --
721
- q_act.call { select_color('#FFFFFF') }
722
- q_act.call do
723
- @brush_size = 8
724
- @app.set_variable(@brush_size_var, '8')
725
- @spray_density = 8
726
- @app.set_variable(@spray_density_var, '8')
727
- end
728
- q_mouse.call('ButtonPress-1', 180, 100)
729
- [[195, 90], [210, 85], [225, 90], [240, 100]].each { |x, y| q_mouse.call('B1-Motion', x, y) }
730
- q_mouse.call('ButtonRelease-1', 240, 100)
731
- q_mouse.call('ButtonPress-1', 520, 110)
732
- [[540, 100], [560, 95], [580, 100], [595, 110]].each { |x, y| q_mouse.call('B1-Motion', x, y) }
733
- q_mouse.call('ButtonRelease-1', 595, 110)
734
-
735
- # -- Spray golden sun --
736
- q_act.call { select_color('#FFD700') }
737
- q_act.call do
738
- @brush_size = 10
739
- @app.set_variable(@brush_size_var, '10')
740
- @spray_density = 10
741
- @app.set_variable(@spray_density_var, '10')
742
- end
743
- q_mouse.call('ButtonPress-1', 660, 80)
744
- [[670, 70], [680, 85], [665, 90], [675, 75]].each { |x, y| q_mouse.call('B1-Motion', x, y) }
745
- q_mouse.call('ButtonRelease-1', 670, 80)
746
-
747
- # -- Brush strokes: winding path --
748
- q_act.call { select_color('#8B6914') }
749
- q_act.call { select_tool(:brush) }
750
- q_act.call do
751
- @brush_size = 5
752
- @app.set_variable(@brush_size_var, '5')
753
- end
754
- path = [[100, 550], [200, 520], [320, 530], [450, 510], [550, 520], [680, 500], [780, 510]]
755
- q_mouse.call('ButtonPress-1', *path.first)
756
- path[1..].each { |x, y| q_mouse.call('B1-Motion', x, y) }
757
- q_mouse.call('ButtonRelease-1', *path.last)
758
-
759
- # -- Brush strokes: tree trunks and canopy --
760
- [[160, 440], [620, 430]].each do |tx, ty|
761
- q_act.call { select_color('#8B4513') }
762
- q_mouse.call('ButtonPress-1', tx, ty)
763
- q_mouse.call('B1-Motion', tx, ty + 70)
764
- q_mouse.call('ButtonRelease-1', tx, ty + 70)
765
- q_act.call do
766
- select_color('#006400')
767
- @brush_size = 8
768
- @app.set_variable(@brush_size_var, '8')
769
- end
770
- [[-20, -10], [0, -25], [20, -10], [-10, -18], [10, -18]].each do |dx, dy|
771
- q_mouse.call('ButtonPress-1', tx + dx, ty + dy)
772
- q_mouse.call('ButtonRelease-1', tx + dx, ty + dy)
773
- end
774
- end
775
-
776
- # -- Eraser demo: zigzag sweep so it's clearly erasing --
777
- q_act.call { select_tool(:eraser) }
778
- q_act.call do
779
- @brush_size = 6
780
- @app.set_variable(@brush_size_var, '6')
781
- end
782
- q_mouse.call('ButtonPress-1', 300, 280)
783
- [[330, 320], [360, 270], [390, 320], [420, 270],
784
- [450, 320], [480, 270], [510, 320]].each do |x, y|
785
- q_mouse.call('B1-Motion', x, y)
786
- end
787
- q_mouse.call('ButtonRelease-1', 510, 320)
788
-
789
- # -- Reset and finish --
790
- q_act.call do
791
- select_tool(:brush)
792
- @brush_size = 1
793
- @app.set_variable(@brush_size_var, '1')
794
- end
795
-
796
- $stdout.puts "[paint-demo] queued #{@demo_queue.size} actions"
797
- $stdout.flush
798
- run_next_demo_action
799
- end
800
-
801
- def run_next_demo_action
802
- if @demo_queue.empty?
803
- $stdout.puts "[paint-demo] all actions complete"
804
- $stdout.flush
805
- @app.after(@demo_interval) { TeekDemo.finish } if defined?(TeekDemo) && TeekDemo.active?
806
- return
807
- end
808
-
809
- action = @demo_queue.shift
810
- @demo_action_num += 1
811
- if (@demo_action_num % 20).zero?
812
- $stdout.puts "[paint-demo] action #{@demo_action_num}..."
813
- $stdout.flush
814
- end
815
- action.call
816
- @app.after(@demo_interval) { run_next_demo_action }
817
- end
818
- end
819
-
820
- # -- Main ------------------------------------------------------------------
821
-
822
- app = Teek::App.new(track_widgets: false)
823
- app.show
824
-
825
- paint = PaintDemo.new(app)
826
-
827
- # Automated demo support
828
- require_relative '../../lib/teek/demo_support'
829
- TeekDemo.app = app
830
-
831
- if TeekDemo.active?
832
- TeekDemo.on_visible do
833
- app.after(200) { paint.run_auto_demo }
834
- end
835
- end
836
-
837
- app.mainloop