metaco 0.2.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56719f087b9757be4e9f9b47df20a2be5e48e30fffe781c130c982a4d5da0593
4
- data.tar.gz: 9be3b5f19b8d255953964097538d705fbd00ca184b7301ecc51acbeeecad6f49
3
+ metadata.gz: 338aed008f83c6aed4d0d9e14d2e39176337962507a6e76351f75bfd7c6bb6e2
4
+ data.tar.gz: 9c3a9abed42ab01969d8a57d627d4b6ef9789e7947f0721cb585748ff65cf99c
5
5
  SHA512:
6
- metadata.gz: fb8667d5665d9392cd1d8efec9128211f83be708e82f2a8eebcfe3623fc2deb3dd4785daf8756e286d6edd378cfe316d2898fd7def72e94a777d846cee13a0d1
7
- data.tar.gz: c83bd6e0bf4164c9af4028e7103219d4005a7bd6df06fab57dc8db365381692fd986ba5a9c8d2d5740fa82b835470df504c1eecd8fb054dc98e0c7dcf3ecb810
6
+ metadata.gz: 30e20aa8707d371a3ff2836877bebef4afcb2343bd63cf5a915a0add4b339baf35bd200d5e5daf979b34acecf5549c016fa3823f49d0f38a8562233d36e7211a
7
+ data.tar.gz: 8be006a897ee591c15a7d8e3e3e96c5719e6357fdffe2d78000f3f1c2cfa1f39abac050d68d6ba7c25c1274cc2dc15cab40cd9986a8dbad29cfad21ea168e1fc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.3.0 - 2026-09-25
6
+
7
+ - Add resizable windows, logical and framebuffer size reporting, Retina-aware dimensions, resize events, and resize-safe pixel/compute resources.
8
+ - Add native Metal input textures, pixel readback, and complete extension API stubs.
9
+ - Release native resources promptly when windows are explicitly destroyed or collected.
10
+ - Wait for outstanding Metal commands before teardown.
11
+ - Clarify that GC cleanup guarantees native resource release while AppKit may retain a closed window object temporarily.
12
+
3
13
  ## 0.2.0 - 2026-09-09
4
14
 
5
15
  - Replace integer window pointers with GC-managed `Metaco::Window` handles. Validate closed handles, main-thread access, dimensions, and pixel buffer capacity.
data/README.md CHANGED
@@ -1,12 +1,25 @@
1
- # metaco
1
+ <h1 align="center">metaco</h1>
2
2
 
3
- A Ruby C extension providing native macOS window management and Metal GPU acceleration for graphics applications.
3
+ <p align="center">Native macOS window management and Metal GPU acceleration for Ruby.</p>
4
4
 
5
- ## Requirements
5
+ <p align="center">
6
+ <a href="https://rubygems.org/gems/metaco"><img src="https://badge.fury.io/rb/metaco.svg" alt="Gem Version"></a>
7
+ <a href="https://rubygems.org/gems/metaco"><img src="https://img.shields.io/gem/dt/metaco?label=downloads" alt="Downloads"></a>
8
+ <a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/ruby-%3E%3D3.0-CC342D?logo=ruby&amp;logoColor=white" alt="Ruby Version"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-750014.svg" alt="License"></a>
10
+ </p>
6
11
 
7
- - macOS
8
- - Ruby 3.1+
9
- - Xcode Command Line Tools
12
+ [Features](#features) · [Installation](#installation) · [Usage](#usage) · [API Reference](#api-reference)
13
+
14
+ ***
15
+
16
+ Metaco is a Ruby C extension for Cocoa window management, pixel presentation, input events, and Metal GPU compute.
17
+
18
+ ## Features
19
+
20
+ - Native Cocoa window lifecycle and RGBA pixel presentation.
21
+ - Keyboard, mouse, scroll, focus, and resize event polling.
22
+ - Metal compute shaders, texture upload, and pixel readback.
10
23
 
11
24
  ## Installation
12
25
 
@@ -28,6 +41,12 @@ Or install it yourself as:
28
41
  gem install metaco
29
42
  ```
30
43
 
44
+ ### Requirements
45
+
46
+ - macOS
47
+ - Ruby 3.0 or newer
48
+ - Xcode Command Line Tools
49
+
31
50
  ## Usage
32
51
 
33
52
  ### Basic Window
@@ -39,7 +58,7 @@ require "metaco"
39
58
  Metaco.init
40
59
 
41
60
  # Create a window
42
- handle = Metaco.window_create(800, 600, "My Window")
61
+ handle = Metaco.window_create(800, 600, "My Window", resizable: true, high_dpi: true)
43
62
 
44
63
  # Main loop
45
64
  until Metaco.should_close?(handle)
@@ -115,16 +134,19 @@ Metaco.window_destroy(handle)
115
134
  | Method | Description |
116
135
  |--------|-------------|
117
136
  | `init` | Initialize Cocoa application |
118
- | `window_create(width, height, title)` | Create a new window, returns a `Metaco::Window` handle |
137
+ | `window_create(width, height, title, resizable: false, high_dpi: false)` | Create a new window, returns a `Metaco::Window` handle |
119
138
  | `window_destroy(handle)` | Close and release the window; repeated calls are safe |
120
139
  | `should_close?(handle)` | Check if window should close |
121
140
  | `poll_events(handle)` | Poll and return pending events |
141
+ | `window_size(handle)` | Return the current logical `[width, height]` |
142
+ | `framebuffer_size(handle)` | Return the current drawable pixel `[width, height]` |
122
143
 
123
144
  ### Rendering
124
145
 
125
146
  | Method | Description |
126
147
  |--------|-------------|
127
148
  | `set_pixels(handle, buffer, width, height)` | Set pixel data (RGBA format) |
149
+ | `read_pixels(handle, source: :compute)` | Read top-down RGBA8 pixels from `:compute` or `:set_pixels` |
128
150
  | `present(handle)` | Present the frame and wait for GPU completion |
129
151
 
130
152
  ### Compute Shaders
@@ -136,6 +158,9 @@ Metaco.window_destroy(handle)
136
158
  | `dispatch_compute(handle, uniforms)` | Execute compute shader and wait for GPU completion |
137
159
  | `present_compute(handle)` | Present compute shader output and wait for GPU completion |
138
160
  | `has_compute_shader?(handle)` | Check if shader is compiled |
161
+ | `texture_create(handle, width, height, bytes, filter: :linear, wrap: :clamp)` | Upload RGBA8 pixels as a Metal texture |
162
+ | `texture_update(texture, bytes)` / `texture_destroy(texture)` | Replace pixels or release a texture |
163
+ | `bind_compute_texture(handle, index, texture)` | Bind input texture at `texture(index + 1)` |
139
164
 
140
165
  ### Event Types
141
166
 
@@ -144,17 +169,24 @@ Metaco.window_destroy(handle)
144
169
  - `:mouse_press` - Mouse button pressed (`:x`, `:y`, `:button`)
145
170
  - `:mouse_release` - Mouse button released (`:x`, `:y`, `:button`)
146
171
  - `:mouse_move` - Mouse moved (`:x`, `:y`)
172
+ - `:scroll` - Scroll distance in pixels (`:dx`, `:dy`, `:modifiers`). Wheel ticks are scaled by 10; positive `:dy` follows Cocoa's upward convention.
173
+ - `:focus` / `:blur` - Window key focus changed
174
+ - `:resize` - Window size changed (`:width`, `:height`, `:framebuffer_width`, `:framebuffer_height`)
175
+
176
+ Key and mouse events include `:modifiers`, an array of `:shift`, `:control`, `:option`, and `:command`.
147
177
 
148
178
  ### API contracts
149
179
 
150
180
  - Call `init` before creating windows. All native APIs must run on the process's main thread; worker-thread calls raise `ThreadError`. GPU completion waits release Ruby's GVL.
151
181
  - Handles are opaque `Metaco::Window` objects, replacing the integer pointers returned by 0.1.0. Pass them unchanged to Metaco methods. A closed handle raises `ArgumentError` on operations other than `window_destroy`; unrelated objects raise `TypeError`.
152
- - Release windows in an `ensure` block. GC also releases abandoned windows, scheduling AppKit cleanup on the main thread when necessary; `poll_events` services this queue.
153
- - Width and height must be between 1 and 16,384. `set_pixels` requires the original window dimensions and at least `width * height * 4` bytes of row-major RGBA data, with unpremultiplied alpha. Extra trailing bytes are ignored. Invalid dimensions or short buffers raise `ArgumentError`.
182
+ - Release windows in an `ensure` block. GC also schedules AppKit cleanup on the main thread when necessary and releases abandoned window resources; `poll_events` services this queue.
183
+ - Width and height must be between 1 and 16,384. `resizable:` is false by default. `window_size` reports logical points; `framebuffer_size` reports drawable pixels, including the backing scale when `high_dpi: true`. `set_pixels` requires the current framebuffer dimensions and at least `width * height * 4` bytes of row-major RGBA data, with unpremultiplied alpha. Extra trailing bytes are ignored. Invalid dimensions or short buffers raise `ArgumentError`.
154
184
  - Titles and shader sources must contain valid UTF-8. Key event `:char` strings use UTF-8 and preserve embedded NUL characters. Mouse buttons are numbered 0 (left), 1 (right), and 2 (middle); dragging produces `:mouse_move` events.
155
185
  - Compute shaders use the entry point `compute_shader`, output texture 0, and a uniform buffer at index 0. Uniforms may contain 0–256 bytes; remaining bytes are zeroed on every dispatch. Larger inputs raise `ArgumentError`. Shader uniform structures must fit within 256 bytes.
156
186
  - Failed compilation preserves the previous shader and its resources. Dispatch and compute presentation require a compiled shader. GPU command failures raise `RuntimeError` after native resources have been cleaned up.
157
187
  - Dispatch covers exactly the window's pixels. Threadgroup dimensions may vary by pipeline and image width; shaders must not assume a fixed group size. The example's bounds check also makes it safe with other dispatch implementations.
188
+ - `read_pixels` copies the selected texture into CPU-visible memory before returning. It requires an open window and a completed render. GPU readback has not been verified on a Metal device in headless CI.
189
+ - Textures belong to the window that created them. Uploads require exactly `width * height * 4` RGBA8 bytes. Input textures use linear sampling and clamp addressing; other options raise `ArgumentError`. Destroyed textures cannot be updated or bound.
158
190
  - Presentation is synchronous so pixel uploads cannot overwrite an in-flight frame. An occluded window may have no drawable, in which case presentation is skipped. Rendering resource allocation failures select the bitmap fallback; compute availability then returns `false`.
159
191
  - On unsupported platforms both compute availability queries return `false`, and other APIs raise `LoadError`. On macOS, native extension loading errors retain their original diagnostics.
160
192
 
@@ -178,6 +210,10 @@ The test task includes a separate native test extension for GPU pixel readback,
178
210
 
179
211
  The optional `run_metal` workflow-dispatch input runs the same suite with mandatory GPU execution on a self-hosted runner labeled `macOS` and `metal`. It requires a logged-in GUI session and a Metal device.
180
212
 
213
+ ## Contributing
214
+
215
+ Bug reports and pull requests are welcome at [rbgfx/metaco](https://github.com/rbgfx/metaco).
216
+
181
217
  ## License
182
218
 
183
219
  The gem is available as open source under the terms of the [MIT License](LICENSE).
data/Rakefile CHANGED
@@ -30,3 +30,4 @@ end
30
30
 
31
31
  task test: [:compile, :test_native]
32
32
  task default: :test
33
+ task verify: :test
data/docs/index.html ADDED
@@ -0,0 +1,38 @@
1
+ <!doctype html>
2
+ <html lang="en"><head>
3
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
4
+ <meta name="theme-color" content="#020617"><meta name="description" content="A Ruby C extension for macOS windows, pixel presentation, event polling, and Metal compute.">
5
+ <meta property="og:title" content="Metaco — A native window, from Ruby."><meta property="og:description" content="A Ruby C extension for macOS windows, pixel presentation, event polling, and Metal compute."><meta property="og:type" content="website"><meta property="og:url" content="https://rbgfx.github.io/metaco/">
6
+ <meta name="twitter:card" content="summary"><link rel="canonical" href="https://rbgfx.github.io/metaco/"><title>Metaco — A native window, from Ruby.</title>
7
+ <style>
8
+ :root{color-scheme:dark;--bg:#020617;--panel:#0f172a;--line:#334155;--ink:#e2e8f0;--muted:#94a3b8;--a:#38bdf8;--mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;--sans:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--w:min(100% - 2.5rem,76rem)}
9
+ *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.65 var(--sans)}a{color:inherit;text-underline-offset:.22em}a:hover{color:var(--a)}a:focus-visible{outline:3px solid var(--a);outline-offset:4px}h1,h2,h3,p,figure,pre{margin:0}h1,h2,h3{line-height:1.08;text-wrap:balance}p{text-wrap:pretty}code,pre,.mono{font-family:var(--mono);font-variant-numeric:tabular-nums}.wrap{width:var(--w);margin:auto}.skip{position:fixed;left:1rem;top:1rem;z-index:1;transform:translateY(-160%);padding:.7rem 1rem;background:var(--ink);color:var(--bg)}.skip:focus{transform:none}
10
+ header{height:4.75rem;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between}.brand{display:flex;align-items:center;gap:.7rem;font-weight:750;text-decoration:none}.mark{width:2rem;height:2rem;display:grid;place-items:center;border:1px solid var(--a);color:var(--a);font:.85rem var(--mono)}nav{display:flex;gap:1.5rem;font-size:.9rem}nav a{text-decoration:none;color:var(--muted)}nav a:last-child{color:var(--ink)}
11
+ .hero{display:grid;grid-template-columns:.88fr 1.12fr;align-items:center;gap:clamp(2rem,6vw,6rem);padding-block:clamp(4rem,9vw,8rem)}.kicker,.visual figcaption,.num{font:.76rem/1.4 var(--mono);color:var(--a)}h1{max-width:11ch;margin-top:1rem;font-size:clamp(3rem,7.5vw,6.5rem);letter-spacing:-.065em}.lede{max-width:40rem;margin-top:1.5rem;color:var(--muted);font-size:1.15rem}.actions{display:flex;flex-wrap:wrap;gap:.8rem;margin-top:2rem}.button{min-height:2.8rem;padding:.65rem 1rem;border:1px solid var(--line);display:inline-flex;align-items:center;text-decoration:none;font-weight:650}.button.primary{background:var(--a);border-color:var(--a);color:var(--bg)}.install{margin-top:1.4rem;color:var(--muted);font:.86rem var(--mono)}.install code{color:var(--ink)}
12
+ .visual{border:1px solid var(--line);background:var(--panel)}.art{min-height:18rem;padding:1.25rem;display:grid;place-items:center;overflow:hidden}.art svg{width:100%;max-height:23rem}.visual figcaption{display:flex;justify-content:space-between;gap:1rem;padding:.75rem 1rem;border-top:1px solid var(--line);color:var(--muted)}.visual figcaption span:last-child{color:var(--a)}
13
+ section.info{padding-block:4.5rem;border-top:1px solid var(--line)}.section-head{display:grid;grid-template-columns:.72fr 1.28fr;gap:2rem;align-items:end;margin-bottom:2rem}h2{font-size:clamp(2rem,4vw,3.25rem);letter-spacing:-.045em}.section-head p,.feature p,.try-copy p{color:var(--muted)}.features{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--line)}.feature{padding:1.25rem 1.2rem 1.4rem 0;border-bottom:1px solid var(--line)}.feature+.feature{padding-left:1.2rem;border-left:1px solid var(--line)}.feature h3{margin-top:.8rem;font-size:1.1rem}.feature p{margin-top:.5rem;font-size:.94rem}.try{display:grid;grid-template-columns:.75fr 1.25fr;align-items:center;gap:clamp(2rem,7vw,7rem)}.try-copy p{margin-top:1rem}.try-copy a{color:var(--ink)}pre{overflow-x:auto;padding:1.25rem;border:1px solid var(--line);background:var(--panel);font-size:.82rem;line-height:1.8;tab-size:2}
14
+ footer{display:flex;justify-content:space-between;gap:1rem;padding-block:1.6rem;border-top:1px solid var(--line);color:var(--muted);font-size:.85rem}footer div{display:flex;gap:1.25rem}
15
+ @media(max-width:800px){.hero{grid-template-columns:1fr;gap:2.5rem}.hero-copy{max-width:42rem}.section-head,.try{grid-template-columns:1fr;gap:1rem}}@media(max-width:560px){:root{--w:min(100% - 1.5rem,76rem)}header{height:4.2rem}nav{gap:.8rem;font-size:.8rem}nav a:first-child{display:none}h1{font-size:clamp(3rem,15vw,5rem)}.features{grid-template-columns:1fr}.feature,.feature+.feature{padding:1.1rem 0;border-left:0}.num{display:inline-block}footer{flex-direction:column}.art{min-height:14rem}section.info{padding-block:3.5rem}pre{font-size:.75rem;padding:1rem}}
16
+ @media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
17
+ .window{width:100%;max-width:32rem;border:1px solid #475569;background:#020617}.window header{height:auto;padding:.7rem;border-bottom:1px solid #334155;font:.7rem var(--mono);color:#94a3b8}.window svg{display:block;padding:1rem}.window footer{justify-content:center;padding:.7rem;border-top:1px solid #334155;font:.7rem var(--mono);color:#fb923c}
18
+ </style>
19
+ </head><body style="--a:#fb923c"><a class="skip" href="#main">Skip to content</a><div class="wrap">
20
+ <header><a class="brand" href="#top" aria-label="Metaco home"><span class="mark" aria-hidden="true">M</span><span>Metaco</span></a><nav aria-label="Primary navigation"><a href="#features">Overview</a><a href="#quick-start">Quick start</a><a href="https://github.com/rbgfx/metaco">Source ↗</a></nav></header>
21
+ <main id="main"><section class="hero" id="top" aria-labelledby="title"><div class="hero-copy"><p class="kicker">Native macOS · Cocoa · Metal</p><h1 id="title">A native window, from Ruby.</h1><p class="lede">A Ruby C extension for macOS windows, pixel presentation, event polling, and Metal compute.</p><div class="actions"><a class="button primary" href="#quick-start">Get started</a><a class="button" href="https://rubygems.org/gems/metaco">RubyGems ↗</a></div><p class="install"><span aria-hidden="true">$</span> <code>gem install metaco</code></p></div>
22
+ <figure class="visual" aria-labelledby="cap-metaco"><div class="art"><div class="window"><header><span>● ● ●</span><span>Ruby Graphics</span><span>macOS</span></header><svg viewBox="0 0 360 190" aria-hidden="true"><rect width="360" height="190" fill="#111b2d"/><path d="M180 28L314 162H46Z" fill="#431407" stroke="#fb923c" stroke-width="2"/><path d="M180 28L180 162M46 162L247 95M314 162L113 95" stroke="#7c2d12"/></svg><footer>Ruby → Cocoa → Metal</footer></div></div><figcaption id="cap-metaco"><span>Cocoa window</span><span>Ruby calls → native APIs</span></figcaption></figure></section>
23
+ <section class="info" id="features" aria-labelledby="feat"><div class="section-head"><h2 id="feat">A focused bridge to the host.</h2><p>Create a native window, push RGBA pixels, receive input, or run Metal compute.</p></div><div class="features"><article class="feature"><span class="num">01</span><h3>Open a Cocoa window</h3><p>Create, update, present, and destroy windows from Ruby.</p></article><article class="feature"><span class="num">02</span><h3>Handle events</h3><p>Poll keyboard and mouse data in the application loop.</p></article><article class="feature"><span class="num">03</span><h3>Use Metal compute</h3><p>Check support, compile MSL, and dispatch compute work.</p></article></div></section>
24
+ <section class="info try" id="quick-start" aria-labelledby="quick"><div class="try-copy"><p class="kicker">A small first step</p><h2 id="quick">Start with one example.</h2><p>Install the gem, then follow the project guide for platform requirements and the full API.</p><p><a href="https://github.com/rbgfx/metaco#installation">Read the installation guide ↗</a></p></div><pre><code>require &quot;metaco&quot;
25
+
26
+ Metaco.init
27
+ handle = Metaco.window_create(
28
+ 800, 600, &quot;My Window&quot;,
29
+ resizable: true, high_dpi: true
30
+ )
31
+ begin
32
+ # rgba_bytes contains width * height RGBA pixels.
33
+ Metaco.set_pixels(handle, rgba_bytes, 800, 600)
34
+ Metaco.present(handle)
35
+ ensure
36
+ Metaco.window_destroy(handle)
37
+ end</code></pre></section></main>
38
+ <footer><span>Metaco · Ruby 3.0+ · MIT</span><div><a href="https://github.com/rbgfx/metaco">GitHub</a><a href="https://github.com/rbgfx/metaco/blob/main/LICENSE">License</a></div></footer></div></body></html>
data/ext/metaco/metaco.m CHANGED
@@ -8,6 +8,7 @@
8
8
  #import <ruby.h>
9
9
  #import <ruby/thread.h>
10
10
  #include <limits.h>
11
+ #include <math.h>
11
12
  #include <pthread.h>
12
13
  #include <stdint.h>
13
14
 
@@ -27,13 +28,33 @@ static void native_error(NSError **error, NSString *message) {
27
28
  @property (nonatomic, strong) id<MTLComputePipelineState> computePipeline;
28
29
  @property (nonatomic, strong) id<MTLBuffer> uniformBuffer;
29
30
  @property (nonatomic, strong) id<MTLTexture> outputTexture;
31
+ @property (nonatomic, strong) NSMutableDictionary<NSNumber *, id<MTLTexture>> *computeTextures;
32
+ @property (nonatomic, strong) id<MTLCommandBuffer> lastCommandBuffer;
30
33
  @property (nonatomic, readonly) BOOL hasComputeShader;
34
+ - (BOOL)resizeToWidth:(int)width height:(int)height error:(NSError **)error;
35
+ - (void)waitForLastCommand;
36
+ - (void)releaseResources;
31
37
  @end
32
38
 
33
39
  @implementation MetacoMetalView
34
40
 
35
41
  - (BOOL)hasComputeShader { return self.computePipeline != nil; }
36
42
 
43
+ - (id<MTLTexture>)newTextureWithWidth:(int)width height:(int)height
44
+ usage:(MTLTextureUsage)usage storageMode:(MTLStorageMode)storageMode {
45
+ MTLTextureDescriptor *desc = [MTLTextureDescriptor
46
+ texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA8Unorm
47
+ width:width height:height mipmapped:NO];
48
+ desc.usage = usage;
49
+ desc.storageMode = storageMode;
50
+ if (@available(macOS 10.15, *)) {
51
+ if (storageMode == MTLStorageModeManaged && self.device.hasUnifiedMemory) {
52
+ desc.storageMode = MTLStorageModeShared;
53
+ }
54
+ }
55
+ return [self.device newTextureWithDescriptor:desc];
56
+ }
57
+
37
58
  - (instancetype)initWithFrame:(NSRect)frame device:(id<MTLDevice>)device width:(int)w height:(int)h {
38
59
  self = [super initWithFrame:frame];
39
60
  if (self) {
@@ -42,6 +63,7 @@ static void native_error(NSError **error, NSString *message) {
42
63
  self.texHeight = h;
43
64
  self.commandQueue = [device newCommandQueue];
44
65
  if (!self.commandQueue) return nil;
66
+ self.computeTextures = [NSMutableDictionary dictionary];
45
67
 
46
68
  // A render pass preserves RGBA channel meaning when writing a BGRA drawable.
47
69
  NSString *source = @"#include <metal_stdlib>\n"
@@ -133,12 +155,9 @@ static void native_error(NSError **error, NSString *message) {
133
155
  id<MTLComputePipelineState> pipeline = [self.device newComputePipelineStateWithFunction:function error:error];
134
156
  if (!pipeline) return NO;
135
157
 
136
- MTLTextureDescriptor *desc = [MTLTextureDescriptor
137
- texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA8Unorm
138
- width:self.texWidth height:self.texHeight mipmapped:NO];
139
- desc.usage = MTLTextureUsageShaderWrite | MTLTextureUsageShaderRead;
140
- desc.storageMode = MTLStorageModePrivate;
141
- id<MTLTexture> output = [self.device newTextureWithDescriptor:desc];
158
+ id<MTLTexture> output = [self newTextureWithWidth:self.texWidth height:self.texHeight
159
+ usage:MTLTextureUsageShaderWrite | MTLTextureUsageShaderRead
160
+ storageMode:MTLStorageModePrivate];
142
161
  id<MTLBuffer> uniforms = [self.device newBufferWithLength:256 options:MTLResourceStorageModeShared];
143
162
  if (!output || !uniforms || !uniforms.contents) {
144
163
  native_error(error, @"Failed to allocate compute resources");
@@ -152,6 +171,52 @@ static void native_error(NSError **error, NSString *message) {
152
171
  return YES;
153
172
  }
154
173
 
174
+ - (BOOL)resizeToWidth:(int)width height:(int)height error:(NSError **)error {
175
+ if (width == self.texWidth && height == self.texHeight) return YES;
176
+
177
+ id<MTLTexture> input = [self newTextureWithWidth:width height:height
178
+ usage:MTLTextureUsageShaderRead
179
+ storageMode:MTLStorageModeManaged];
180
+ id<MTLTexture> output = nil;
181
+ if (self.hasComputeShader) {
182
+ output = [self newTextureWithWidth:width height:height
183
+ usage:MTLTextureUsageShaderWrite | MTLTextureUsageShaderRead
184
+ storageMode:MTLStorageModePrivate];
185
+ }
186
+ if (!input || (self.hasComputeShader && !output)) {
187
+ native_error(error, @"Failed to resize Metal textures");
188
+ return NO;
189
+ }
190
+
191
+ self.texture = input;
192
+ self.outputTexture = output;
193
+ self.texWidth = width;
194
+ self.texHeight = height;
195
+ self.metalLayer.drawableSize = CGSizeMake(width, height);
196
+ return YES;
197
+ }
198
+
199
+ - (void)releaseResources {
200
+ [self waitForLastCommand];
201
+ self.metalLayer.delegate = nil;
202
+ [self.metalLayer removeFromSuperlayer];
203
+ self.layer = nil;
204
+ self.metalLayer = nil;
205
+ self.computeTextures = nil;
206
+ self.outputTexture = nil;
207
+ self.uniformBuffer = nil;
208
+ self.computePipeline = nil;
209
+ self.renderPipeline = nil;
210
+ self.texture = nil;
211
+ self.commandQueue = nil;
212
+ self.device = nil;
213
+ }
214
+
215
+ - (void)waitForLastCommand {
216
+ [self.lastCommandBuffer waitUntilCompleted];
217
+ self.lastCommandBuffer = nil;
218
+ }
219
+
155
220
  - (id<MTLCommandBuffer>)dispatchComputeWithUniforms:(const void *)data length:(NSUInteger)length
156
221
  error:(NSError **)error {
157
222
  if (!self.hasComputeShader || length > 256) {
@@ -168,6 +233,9 @@ static void native_error(NSError **error, NSString *message) {
168
233
  }
169
234
  [encoder setComputePipelineState:self.computePipeline];
170
235
  [encoder setTexture:self.outputTexture atIndex:0];
236
+ for (NSNumber *index in self.computeTextures) {
237
+ [encoder setTexture:self.computeTextures[index] atIndex:1 + index.unsignedIntegerValue];
238
+ }
171
239
  [encoder setBuffer:self.uniformBuffer offset:0 atIndex:0];
172
240
 
173
241
  NSUInteger limit = MIN(self.computePipeline.maxTotalThreadsPerThreadgroup,
@@ -188,11 +256,26 @@ static void native_error(NSError **error, NSString *message) {
188
256
 
189
257
  @end
190
258
 
259
+ static NSArray<NSString *> *event_modifiers(NSEvent *event) {
260
+ NSEventModifierFlags flags = event.modifierFlags;
261
+ NSMutableArray<NSString *> *result = [NSMutableArray array];
262
+ if (flags & NSEventModifierFlagShift) [result addObject:@"shift"];
263
+ if (flags & NSEventModifierFlagControl) [result addObject:@"control"];
264
+ if (flags & NSEventModifierFlagOption) [result addObject:@"option"];
265
+ if (flags & NSEventModifierFlagCommand) [result addObject:@"command"];
266
+ return result;
267
+ }
268
+
191
269
  @interface MetacoWindow : NSWindow <NSWindowDelegate>
192
270
  @property (nonatomic, assign) BOOL shouldClose;
193
271
  @property (nonatomic, strong) NSMutableArray *pendingEvents;
194
272
  @property (nonatomic, strong) MetacoMetalView *metalView;
195
273
  @property (nonatomic, assign) BOOL useMetal;
274
+ @property (nonatomic, assign) BOOL highDPI;
275
+ @property (nonatomic, assign) int initialWidth;
276
+ @property (nonatomic, assign) int initialHeight;
277
+ @property (nonatomic, assign) BOOL initializing;
278
+ @property (nonatomic, assign) BOOL hasResized;
196
279
  // Fallback for non-Metal
197
280
  @property (nonatomic, strong) NSBitmapImageRep *bitmapRep;
198
281
  @property (nonatomic, strong) NSImageView *imageView;
@@ -200,18 +283,24 @@ static void native_error(NSError **error, NSString *message) {
200
283
 
201
284
  @implementation MetacoWindow
202
285
 
203
- - (instancetype)initWithWidth:(int)width height:(int)height title:(NSString *)title {
286
+ - (instancetype)initWithWidth:(int)width height:(int)height title:(NSString *)title
287
+ resizable:(BOOL)resizable highDPI:(BOOL)highDPI {
204
288
  NSRect frame = NSMakeRect(100, 100, width, height);
289
+ NSUInteger style = NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable;
290
+ if (resizable) style |= NSWindowStyleMaskResizable;
205
291
  self = [super initWithContentRect:frame
206
- styleMask:(NSWindowStyleMaskTitled |
207
- NSWindowStyleMaskClosable |
208
- NSWindowStyleMaskMiniaturizable)
292
+ styleMask:style
209
293
  backing:NSBackingStoreBuffered
210
294
  defer:NO];
211
295
  if (self) {
212
- self.releasedWhenClosed = NO;
296
+ self.releasedWhenClosed = YES;
213
297
  self.shouldClose = NO;
214
298
  self.pendingEvents = [NSMutableArray array];
299
+ self.highDPI = highDPI;
300
+ self.initialWidth = width;
301
+ self.initialHeight = height;
302
+ self.initializing = YES;
303
+ self.hasResized = NO;
215
304
  [self setTitle:title];
216
305
  [self setDelegate:self];
217
306
 
@@ -226,6 +315,7 @@ static void native_error(NSError **error, NSString *message) {
226
315
  self.useMetal = self.metalView != nil;
227
316
  if (self.useMetal) {
228
317
  [self setContentView:self.metalView];
318
+ self.metalView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
229
319
  } else {
230
320
  // Device or rendering resource creation failed: use the bitmap path.
231
321
  self.bitmapRep = [[NSBitmapImageRep alloc]
@@ -255,10 +345,13 @@ static void native_error(NSError **error, NSString *message) {
255
345
  }
256
346
  [self.imageView setImage:image];
257
347
  [self.imageView setImageScaling:NSImageScaleAxesIndependently];
348
+ self.imageView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
258
349
  [self setContentView:self.imageView];
259
350
  }
260
351
 
352
+ [self setContentSize:NSMakeSize(width, height)];
261
353
  [self setAcceptsMouseMovedEvents:YES];
354
+ self.initializing = NO;
262
355
  [self makeKeyAndOrderFront:nil];
263
356
  }
264
357
  return self;
@@ -269,11 +362,44 @@ static void native_error(NSError **error, NSString *message) {
269
362
  return NO;
270
363
  }
271
364
 
365
+ - (void)windowDidBecomeKey:(NSNotification *)notification {
366
+ [self.pendingEvents addObject:@{@"type": @"focus"}];
367
+ }
368
+
369
+ - (void)windowDidResignKey:(NSNotification *)notification {
370
+ [self.pendingEvents addObject:@{@"type": @"blur"}];
371
+ }
372
+
373
+ - (void)windowDidResize:(NSNotification *)notification {
374
+ if (self.initializing) return;
375
+ NSRect content = [self contentRectForFrameRect:self.frame];
376
+ if (!self.hasResized && content.size.width <= 1.0 && content.size.height <= 1.0 &&
377
+ (self.initialWidth > 1 || self.initialHeight > 1)) return;
378
+ self.hasResized = YES;
379
+ NSRect backing = [self convertRectToBacking:content];
380
+ int width = MAX(1, (int)llround(content.size.width));
381
+ int height = MAX(1, (int)llround(content.size.height));
382
+ int framebufferWidth = self.highDPI ? MAX(1, (int)llround(backing.size.width)) : width;
383
+ int framebufferHeight = self.highDPI ? MAX(1, (int)llround(backing.size.height)) : height;
384
+ [self.pendingEvents addObject:@{
385
+ @"type": @"resize",
386
+ @"width": @(width),
387
+ @"height": @(height),
388
+ @"framebuffer_width": @(framebufferWidth),
389
+ @"framebuffer_height": @(framebufferHeight)
390
+ }];
391
+ }
392
+
393
+ - (void)windowDidChangeBackingProperties:(NSNotification *)notification {
394
+ [self windowDidResize:notification];
395
+ }
396
+
272
397
  - (void)keyDown:(NSEvent *)event {
273
398
  NSDictionary *eventDict = @{
274
399
  @"type": @"key_press",
275
400
  @"key": @([event keyCode]),
276
- @"char": [event characters] ?: @""
401
+ @"char": [event characters] ?: @"",
402
+ @"modifiers": event_modifiers(event)
277
403
  };
278
404
  [self.pendingEvents addObject:eventDict];
279
405
  }
@@ -281,7 +407,8 @@ static void native_error(NSError **error, NSString *message) {
281
407
  - (void)keyUp:(NSEvent *)event {
282
408
  NSDictionary *eventDict = @{
283
409
  @"type": @"key_release",
284
- @"key": @([event keyCode])
410
+ @"key": @([event keyCode]),
411
+ @"modifiers": event_modifiers(event)
285
412
  };
286
413
  [self.pendingEvents addObject:eventDict];
287
414
  }
@@ -292,7 +419,8 @@ static void native_error(NSError **error, NSString *message) {
292
419
  @"type": @"mouse_press",
293
420
  @"x": @(loc.x),
294
421
  @"y": @(loc.y),
295
- @"button": @([event buttonNumber])
422
+ @"button": @([event buttonNumber]),
423
+ @"modifiers": event_modifiers(event)
296
424
  };
297
425
  [self.pendingEvents addObject:eventDict];
298
426
  }
@@ -303,7 +431,8 @@ static void native_error(NSError **error, NSString *message) {
303
431
  @"type": @"mouse_release",
304
432
  @"x": @(loc.x),
305
433
  @"y": @(loc.y),
306
- @"button": @([event buttonNumber])
434
+ @"button": @([event buttonNumber]),
435
+ @"modifiers": event_modifiers(event)
307
436
  };
308
437
  [self.pendingEvents addObject:eventDict];
309
438
  }
@@ -313,11 +442,23 @@ static void native_error(NSError **error, NSString *message) {
313
442
  NSDictionary *eventDict = @{
314
443
  @"type": @"mouse_move",
315
444
  @"x": @(loc.x),
316
- @"y": @(loc.y)
445
+ @"y": @(loc.y),
446
+ @"modifiers": event_modifiers(event)
317
447
  };
318
448
  [self.pendingEvents addObject:eventDict];
319
449
  }
320
450
 
451
+ - (void)scrollWheel:(NSEvent *)event {
452
+ // Cocoa reports wheel ticks for non-precise devices; expose both as pixels.
453
+ double scale = event.hasPreciseScrollingDeltas ? 1.0 : 10.0;
454
+ [self.pendingEvents addObject:@{
455
+ @"type": @"scroll",
456
+ @"dx": @(event.scrollingDeltaX * scale),
457
+ @"dy": @(event.scrollingDeltaY * scale),
458
+ @"modifiers": event_modifiers(event)
459
+ }];
460
+ }
461
+
321
462
  - (void)rightMouseDown:(NSEvent *)event { [self mouseDown:event]; }
322
463
  - (void)rightMouseUp:(NSEvent *)event { [self mouseUp:event]; }
323
464
  - (void)otherMouseDown:(NSEvent *)event { [self mouseDown:event]; }
@@ -334,11 +475,22 @@ typedef struct {
334
475
  void *window;
335
476
  int width;
336
477
  int height;
478
+ int logical_width;
479
+ int logical_height;
337
480
  size_t byte_length;
338
481
  BOOL busy;
339
482
  } MetacoHandle;
340
483
 
484
+ typedef struct {
485
+ void *texture;
486
+ VALUE owner;
487
+ int width;
488
+ int height;
489
+ size_t byte_length;
490
+ } MetacoTextureHandle;
491
+
341
492
  static VALUE cMetacoWindow;
493
+ static VALUE cMetacoTexture;
342
494
 
343
495
  static void require_main_thread(void) {
344
496
  if (!pthread_main_np()) rb_raise(rb_eThreadError, "Metaco must be called on the main thread");
@@ -355,10 +507,79 @@ static size_t pixel_byte_length(int width, int height) {
355
507
  return (size_t)width * (size_t)height * 4;
356
508
  }
357
509
 
510
+ static void window_dimensions(MetacoWindow *window, int *logical_width, int *logical_height,
511
+ int *framebuffer_width, int *framebuffer_height) {
512
+ NSRect content = [window contentRectForFrameRect:window.frame];
513
+ NSRect backing = [window convertRectToBacking:content];
514
+ *logical_width = MAX(1, (int)llround(content.size.width));
515
+ *logical_height = MAX(1, (int)llround(content.size.height));
516
+ if (!window.hasResized) {
517
+ *logical_width = window.initialWidth;
518
+ *logical_height = window.initialHeight;
519
+ }
520
+ if (window.highDPI) {
521
+ *framebuffer_width = MAX(*logical_width, (int)llround(backing.size.width));
522
+ *framebuffer_height = MAX(*logical_height, (int)llround(backing.size.height));
523
+ } else {
524
+ *framebuffer_width = *logical_width;
525
+ *framebuffer_height = *logical_height;
526
+ }
527
+ }
528
+
529
+ static BOOL resize_bitmap(MetacoWindow *window, int width, int height, NSError **error) {
530
+ NSBitmapImageRep *bitmap = [[NSBitmapImageRep alloc]
531
+ initWithBitmapDataPlanes:NULL
532
+ pixelsWide:width
533
+ pixelsHigh:height
534
+ bitsPerSample:8
535
+ samplesPerPixel:4
536
+ hasAlpha:YES
537
+ isPlanar:NO
538
+ colorSpaceName:NSDeviceRGBColorSpace
539
+ bitmapFormat:NSBitmapFormatAlphaNonpremultiplied
540
+ bytesPerRow:(size_t)width * 4
541
+ bitsPerPixel:32];
542
+ NSImage *image = [[NSImage alloc] initWithSize:NSMakeSize(width, height)];
543
+ if (!bitmap || !bitmap.bitmapData || !image) {
544
+ native_error(error, @"Failed to resize bitmap resources");
545
+ return NO;
546
+ }
547
+ [image addRepresentation:bitmap];
548
+ window.bitmapRep = bitmap;
549
+ [window.imageView setFrame:window.contentView.bounds];
550
+ [window.imageView setImage:image];
551
+ return YES;
552
+ }
553
+
554
+ static BOOL sync_handle_dimensions(MetacoHandle *handle, NSError **error) {
555
+ MetacoWindow *window = (__bridge MetacoWindow *)handle->window;
556
+ int logical_width, logical_height, framebuffer_width, framebuffer_height;
557
+ window_dimensions(window, &logical_width, &logical_height, &framebuffer_width, &framebuffer_height);
558
+ if (framebuffer_width != handle->width || framebuffer_height != handle->height) {
559
+ BOOL resized = window.useMetal
560
+ ? [window.metalView resizeToWidth:framebuffer_width height:framebuffer_height error:error]
561
+ : resize_bitmap(window, framebuffer_width, framebuffer_height, error);
562
+ if (!resized) return NO;
563
+ handle->width = framebuffer_width;
564
+ handle->height = framebuffer_height;
565
+ handle->byte_length = pixel_byte_length(framebuffer_width, framebuffer_height);
566
+ }
567
+ handle->logical_width = logical_width;
568
+ handle->logical_height = logical_height;
569
+ return YES;
570
+ }
571
+
358
572
  static void release_window(void *ptr) {
359
573
  @autoreleasepool {
360
574
  MetacoWindow *window = (__bridge_transfer MetacoWindow *)ptr;
361
575
  window.delegate = nil;
576
+ [window orderOut:nil];
577
+ [window setContentView:nil];
578
+ [window.metalView releaseResources];
579
+ window.metalView = nil;
580
+ window.imageView = nil;
581
+ window.bitmapRep = nil;
582
+ window.pendingEvents = nil;
362
583
  [window close];
363
584
  }
364
585
  }
@@ -389,6 +610,34 @@ static MetacoHandle *get_handle(VALUE value, BOOL allow_closed) {
389
610
  return handle;
390
611
  }
391
612
 
613
+ static void texture_mark(void *ptr) {
614
+ MetacoTextureHandle *texture = ptr;
615
+ if (texture) rb_gc_mark(texture->owner);
616
+ }
617
+
618
+ static void texture_free(void *ptr) {
619
+ MetacoTextureHandle *texture = ptr;
620
+ if (!texture) return;
621
+ if (texture->texture) CFRelease((CFTypeRef)texture->texture);
622
+ ruby_xfree(texture);
623
+ }
624
+
625
+ static size_t texture_size(const void *ptr) { return sizeof(MetacoTextureHandle); }
626
+
627
+ static const rb_data_type_t texture_type = {
628
+ .wrap_struct_name = "Metaco::Texture",
629
+ .function = {.dmark = texture_mark, .dfree = texture_free, .dsize = texture_size},
630
+ .flags = RUBY_TYPED_FREE_IMMEDIATELY
631
+ };
632
+
633
+ static MetacoTextureHandle *get_texture(VALUE value, BOOL allow_destroyed) {
634
+ require_main_thread();
635
+ MetacoTextureHandle *texture;
636
+ TypedData_Get_Struct(value, MetacoTextureHandle, &texture_type, texture);
637
+ if (!allow_destroyed && !texture->texture) rb_raise(rb_eArgError, "Texture is destroyed");
638
+ return texture;
639
+ }
640
+
392
641
  static VALUE cocoa_init(VALUE self) {
393
642
  require_main_thread();
394
643
  @autoreleasepool {
@@ -399,7 +648,8 @@ static VALUE cocoa_init(VALUE self) {
399
648
  return Qnil;
400
649
  }
401
650
 
402
- static VALUE cocoa_window_create(VALUE self, VALUE width, VALUE height, VALUE title) {
651
+ static VALUE cocoa_window_create_native(VALUE self, VALUE width, VALUE height, VALUE title,
652
+ VALUE resizable, VALUE high_dpi) {
403
653
  require_main_thread();
404
654
  int w = NUM2INT(width);
405
655
  int h = NUM2INT(height);
@@ -409,8 +659,11 @@ static VALUE cocoa_window_create(VALUE self, VALUE width, VALUE height, VALUE ti
409
659
 
410
660
  MetacoHandle *handle;
411
661
  VALUE result = TypedData_Make_Struct(cMetacoWindow, MetacoHandle, &handle_type, handle);
662
+ handle->window = NULL;
412
663
  handle->width = w;
413
664
  handle->height = h;
665
+ handle->logical_width = w;
666
+ handle->logical_height = h;
414
667
  handle->byte_length = length;
415
668
  BOOL valid_title;
416
669
  @autoreleasepool {
@@ -419,16 +672,40 @@ static VALUE cocoa_window_create(VALUE self, VALUE width, VALUE height, VALUE ti
419
672
  encoding:NSUTF8StringEncoding];
420
673
  valid_title = text != nil;
421
674
  if (valid_title) {
422
- MetacoWindow *window = [[MetacoWindow alloc] initWithWidth:w height:h title:text];
675
+ MetacoWindow *window = [[MetacoWindow alloc] initWithWidth:w height:h title:text
676
+ resizable:RTEST(resizable)
677
+ highDPI:RTEST(high_dpi)];
423
678
  handle->window = (__bridge_retained void *)window;
424
679
  }
425
680
  }
426
681
  RB_GC_GUARD(title);
427
682
  if (!valid_title) rb_raise(rb_eArgError, "Title must contain valid UTF-8");
428
683
  if (!handle->window) rb_raise(rb_eRuntimeError, "Failed to create window resources");
684
+ NSError *error = nil;
685
+ if (!sync_handle_dimensions(handle, &error)) {
686
+ void *ptr = handle->window;
687
+ handle->window = NULL;
688
+ if (ptr) release_window(ptr);
689
+ rb_raise(rb_eRuntimeError, "%s", error.localizedDescription.UTF8String);
690
+ }
429
691
  return result;
430
692
  }
431
693
 
694
+ static VALUE cocoa_window_create(int argc, VALUE *argv, VALUE self) {
695
+ VALUE width, height, title, keywords;
696
+ rb_scan_args(argc, argv, "3:", &width, &height, &title, &keywords);
697
+ VALUE resizable = Qfalse;
698
+ VALUE high_dpi = Qfalse;
699
+ if (!NIL_P(keywords)) {
700
+ ID names[] = {rb_intern("resizable"), rb_intern("high_dpi")};
701
+ VALUE values[] = {Qfalse, Qfalse};
702
+ rb_get_kwargs(keywords, names, 0, 2, values);
703
+ resizable = values[0] == Qundef ? Qfalse : values[0];
704
+ high_dpi = values[1] == Qundef ? Qfalse : values[1];
705
+ }
706
+ return cocoa_window_create_native(self, width, height, title, resizable, high_dpi);
707
+ }
708
+
432
709
  static VALUE cocoa_window_destroy(VALUE self, VALUE value) {
433
710
  MetacoHandle *handle = get_handle(value, YES);
434
711
  void *ptr = handle->window;
@@ -445,6 +722,10 @@ static VALUE cocoa_set_pixels(VALUE self, VALUE value, VALUE buffer, VALUE width
445
722
  size_t length = pixel_byte_length(w, h);
446
723
  Check_Type(buffer, T_STRING);
447
724
  MetacoHandle *handle = get_handle(value, NO);
725
+ NSError *resize_error = nil;
726
+ if (!sync_handle_dimensions(handle, &resize_error)) {
727
+ rb_raise(rb_eRuntimeError, "%s", resize_error.localizedDescription.UTF8String);
728
+ }
448
729
  if (w != handle->width || h != handle->height) {
449
730
  rb_raise(rb_eArgError, "Pixel dimensions must match the window");
450
731
  }
@@ -496,6 +777,10 @@ static VALUE string_to_ruby(VALUE ptr) {
496
777
 
497
778
  static VALUE cocoa_present_frame(VALUE value, BOOL compute) {
498
779
  MetacoHandle *handle = get_handle(value, NO);
780
+ NSError *resize_error = nil;
781
+ if (!sync_handle_dimensions(handle, &resize_error)) {
782
+ rb_raise(rb_eRuntimeError, "%s", resize_error.localizedDescription.UTF8String);
783
+ }
499
784
  VALUE message = Qnil;
500
785
  int state = 0;
501
786
  handle->busy = YES;
@@ -507,7 +792,9 @@ static VALUE cocoa_present_frame(VALUE value, BOOL compute) {
507
792
  } else if (window.useMetal) {
508
793
  id<MTLTexture> texture = compute ? window.metalView.outputTexture : window.metalView.texture;
509
794
  id<MTLCommandBuffer> command = [window.metalView presentTexture:texture error:&error];
795
+ window.metalView.lastCommandBuffer = command;
510
796
  finish_command(command, &state, &error);
797
+ if (!state) window.metalView.lastCommandBuffer = nil;
511
798
  } else {
512
799
  [window.imageView setNeedsDisplay:YES];
513
800
  [window displayIfNeeded];
@@ -553,6 +840,25 @@ static VALUE events_to_ruby(VALUE ptr) {
553
840
  if (dict[@"button"]) {
554
841
  rb_hash_aset(hash, ID2SYM(rb_intern("button")), INT2NUM([dict[@"button"] intValue]));
555
842
  }
843
+ if (dict[@"dx"]) rb_hash_aset(hash, ID2SYM(rb_intern("dx")), DBL2NUM([dict[@"dx"] doubleValue]));
844
+ if (dict[@"dy"]) rb_hash_aset(hash, ID2SYM(rb_intern("dy")), DBL2NUM([dict[@"dy"] doubleValue]));
845
+ if (dict[@"width"]) rb_hash_aset(hash, ID2SYM(rb_intern("width")), INT2NUM([dict[@"width"] intValue]));
846
+ if (dict[@"height"]) rb_hash_aset(hash, ID2SYM(rb_intern("height")), INT2NUM([dict[@"height"] intValue]));
847
+ if (dict[@"framebuffer_width"]) {
848
+ rb_hash_aset(hash, ID2SYM(rb_intern("framebuffer_width")),
849
+ INT2NUM([dict[@"framebuffer_width"] intValue]));
850
+ }
851
+ if (dict[@"framebuffer_height"]) {
852
+ rb_hash_aset(hash, ID2SYM(rb_intern("framebuffer_height")),
853
+ INT2NUM([dict[@"framebuffer_height"] intValue]));
854
+ }
855
+ if (dict[@"modifiers"]) {
856
+ VALUE modifiers = rb_ary_new();
857
+ for (__unsafe_unretained NSString *modifier in dict[@"modifiers"]) {
858
+ rb_ary_push(modifiers, ID2SYM(rb_intern(modifier.UTF8String)));
859
+ }
860
+ rb_hash_aset(hash, ID2SYM(rb_intern("modifiers")), modifiers);
861
+ }
556
862
  rb_ary_push(events, hash);
557
863
  }
558
864
  return events;
@@ -609,6 +915,10 @@ static VALUE cocoa_metal_compute_available(VALUE self, VALUE value) {
609
915
  static VALUE cocoa_compile_compute_shader(VALUE self, VALUE value, VALUE msl_source) {
610
916
  Check_Type(msl_source, T_STRING);
611
917
  MetacoHandle *handle = get_handle(value, NO);
918
+ NSError *resize_error = nil;
919
+ if (!sync_handle_dimensions(handle, &resize_error)) {
920
+ rb_raise(rb_eRuntimeError, "%s", resize_error.localizedDescription.UTF8String);
921
+ }
612
922
  VALUE message = Qnil;
613
923
  int state = 0;
614
924
  handle->busy = YES;
@@ -641,6 +951,10 @@ static VALUE cocoa_dispatch_compute(VALUE self, VALUE value, VALUE uniform_data)
641
951
  Check_Type(uniform_data, T_STRING);
642
952
  if (RSTRING_LEN(uniform_data) > 256) rb_raise(rb_eArgError, "Uniform data must be at most 256 bytes");
643
953
  MetacoHandle *handle = get_handle(value, NO);
954
+ NSError *resize_error = nil;
955
+ if (!sync_handle_dimensions(handle, &resize_error)) {
956
+ rb_raise(rb_eRuntimeError, "%s", resize_error.localizedDescription.UTF8String);
957
+ }
644
958
  VALUE message = Qnil;
645
959
  int state = 0;
646
960
  handle->busy = YES;
@@ -653,7 +967,9 @@ static VALUE cocoa_dispatch_compute(VALUE self, VALUE value, VALUE uniform_data)
653
967
  id<MTLCommandBuffer> command = [window.metalView dispatchComputeWithUniforms:RSTRING_PTR(uniform_data)
654
968
  length:RSTRING_LEN(uniform_data)
655
969
  error:&error];
970
+ window.metalView.lastCommandBuffer = command;
656
971
  finish_command(command, &state, &error);
972
+ if (!state) window.metalView.lastCommandBuffer = nil;
657
973
  }
658
974
  if (error && !state) {
659
975
  message = rb_protect(string_to_ruby, (VALUE)(__bridge void *)error.localizedDescription, &state);
@@ -678,14 +994,194 @@ static VALUE cocoa_has_compute_shader(VALUE self, VALUE value) {
678
994
  return result ? Qtrue : Qfalse;
679
995
  }
680
996
 
997
+ static VALUE cocoa_window_size(VALUE self, VALUE value) {
998
+ MetacoHandle *handle = get_handle(value, NO);
999
+ NSError *resize_error = nil;
1000
+ if (!sync_handle_dimensions(handle, &resize_error)) {
1001
+ rb_raise(rb_eRuntimeError, "%s", resize_error.localizedDescription.UTF8String);
1002
+ }
1003
+ VALUE dimensions = rb_ary_new_from_args(2, INT2NUM(handle->logical_width), INT2NUM(handle->logical_height));
1004
+ RB_GC_GUARD(value);
1005
+ return dimensions;
1006
+ }
1007
+
1008
+ static VALUE cocoa_framebuffer_size(VALUE self, VALUE value) {
1009
+ MetacoHandle *handle = get_handle(value, NO);
1010
+ NSError *resize_error = nil;
1011
+ if (!sync_handle_dimensions(handle, &resize_error)) {
1012
+ rb_raise(rb_eRuntimeError, "%s", resize_error.localizedDescription.UTF8String);
1013
+ }
1014
+ VALUE dimensions = rb_ary_new_from_args(2, INT2NUM(handle->width), INT2NUM(handle->height));
1015
+ RB_GC_GUARD(value);
1016
+ return dimensions;
1017
+ }
1018
+
1019
+ static VALUE cocoa_texture_create_native(VALUE self, VALUE value, VALUE width, VALUE height, VALUE bytes) {
1020
+ int w = NUM2INT(width);
1021
+ int h = NUM2INT(height);
1022
+ size_t length = pixel_byte_length(w, h);
1023
+ Check_Type(bytes, T_STRING);
1024
+ if ((size_t)RSTRING_LEN(bytes) != length) rb_raise(rb_eArgError, "Texture buffer size mismatch");
1025
+ MetacoHandle *owner = get_handle(value, NO);
1026
+ MetacoWindow *window = (__bridge MetacoWindow *)owner->window;
1027
+ if (!window.useMetal) rb_raise(rb_eRuntimeError, "Metal is not available");
1028
+ MetacoTextureHandle *texture;
1029
+ VALUE result = TypedData_Make_Struct(cMetacoTexture, MetacoTextureHandle, &texture_type, texture);
1030
+ texture->owner = value;
1031
+ texture->width = w;
1032
+ texture->height = h;
1033
+ texture->byte_length = length;
1034
+ @autoreleasepool {
1035
+ MTLTextureDescriptor *desc = [MTLTextureDescriptor
1036
+ texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA8Unorm width:w height:h mipmapped:NO];
1037
+ desc.usage = MTLTextureUsageShaderRead;
1038
+ desc.storageMode = MTLStorageModeManaged;
1039
+ if (@available(macOS 10.15, *)) {
1040
+ if (window.metalView.device.hasUnifiedMemory) desc.storageMode = MTLStorageModeShared;
1041
+ }
1042
+ id<MTLTexture> metalTexture = [window.metalView.device newTextureWithDescriptor:desc];
1043
+ if (metalTexture) {
1044
+ [metalTexture replaceRegion:MTLRegionMake2D(0, 0, w, h) mipmapLevel:0
1045
+ withBytes:RSTRING_PTR(bytes) bytesPerRow:(size_t)w * 4];
1046
+ texture->texture = (__bridge_retained void *)metalTexture;
1047
+ }
1048
+ }
1049
+ RB_GC_GUARD(bytes);
1050
+ RB_GC_GUARD(value);
1051
+ if (!texture->texture) rb_raise(rb_eRuntimeError, "Failed to allocate Metal texture");
1052
+ return result;
1053
+ }
1054
+
1055
+ static VALUE cocoa_texture_update(VALUE self, VALUE value, VALUE bytes) {
1056
+ MetacoTextureHandle *texture = get_texture(value, NO);
1057
+ get_handle(texture->owner, NO);
1058
+ Check_Type(bytes, T_STRING);
1059
+ if ((size_t)RSTRING_LEN(bytes) != texture->byte_length) rb_raise(rb_eArgError, "Texture buffer size mismatch");
1060
+ @autoreleasepool {
1061
+ id<MTLTexture> metalTexture = (__bridge id<MTLTexture>)texture->texture;
1062
+ [metalTexture replaceRegion:MTLRegionMake2D(0, 0, texture->width, texture->height)
1063
+ mipmapLevel:0 withBytes:RSTRING_PTR(bytes)
1064
+ bytesPerRow:(size_t)texture->width * 4];
1065
+ }
1066
+ RB_GC_GUARD(bytes);
1067
+ RB_GC_GUARD(value);
1068
+ return Qnil;
1069
+ }
1070
+
1071
+ static VALUE cocoa_texture_destroy(VALUE self, VALUE value) {
1072
+ MetacoTextureHandle *texture = get_texture(value, YES);
1073
+ if (!texture->texture) return Qnil;
1074
+ MetacoHandle *owner;
1075
+ TypedData_Get_Struct(texture->owner, MetacoHandle, &handle_type, owner);
1076
+ @autoreleasepool {
1077
+ id<MTLTexture> metalTexture = (__bridge id<MTLTexture>)texture->texture;
1078
+ if (owner->window) {
1079
+ MetacoWindow *window = (__bridge MetacoWindow *)owner->window;
1080
+ NSArray *keys = [window.metalView.computeTextures allKeysForObject:metalTexture];
1081
+ [window.metalView.computeTextures removeObjectsForKeys:keys];
1082
+ }
1083
+ CFRelease((CFTypeRef)texture->texture);
1084
+ texture->texture = NULL;
1085
+ }
1086
+ RB_GC_GUARD(value);
1087
+ return Qnil;
1088
+ }
1089
+
1090
+ static VALUE cocoa_bind_compute_texture(VALUE self, VALUE value, VALUE index, VALUE texture_value) {
1091
+ MetacoHandle *handle = get_handle(value, NO);
1092
+ int slot = NUM2INT(index);
1093
+ if (slot < 0 || slot > 15) rb_raise(rb_eArgError, "Texture index must be between 0 and 15");
1094
+ MetacoTextureHandle *texture = get_texture(texture_value, NO);
1095
+ if (texture->owner != value) rb_raise(rb_eArgError, "Texture belongs to another window");
1096
+ MetacoWindow *window = (__bridge MetacoWindow *)handle->window;
1097
+ if (!window.useMetal) rb_raise(rb_eRuntimeError, "Metal is not available");
1098
+ @autoreleasepool {
1099
+ window.metalView.computeTextures[@(slot)] = (__bridge id<MTLTexture>)texture->texture;
1100
+ }
1101
+ RB_GC_GUARD(value);
1102
+ RB_GC_GUARD(texture_value);
1103
+ return Qnil;
1104
+ }
1105
+
1106
+ static VALUE cocoa_read_pixels_native(VALUE self, VALUE value, VALUE source) {
1107
+ if (!SYMBOL_P(source)) rb_raise(rb_eTypeError, "source must be a Symbol");
1108
+ ID source_id = SYM2ID(source);
1109
+ BOOL compute = source_id == rb_intern("compute");
1110
+ if (!compute && source_id != rb_intern("set_pixels")) {
1111
+ rb_raise(rb_eArgError, "source must be :compute or :set_pixels");
1112
+ }
1113
+ MetacoHandle *handle = get_handle(value, NO);
1114
+ NSError *resize_error = nil;
1115
+ if (!sync_handle_dimensions(handle, &resize_error)) {
1116
+ rb_raise(rb_eRuntimeError, "%s", resize_error.localizedDescription.UTF8String);
1117
+ }
1118
+ VALUE output = rb_str_new(NULL, handle->byte_length);
1119
+ VALUE message = Qnil;
1120
+ int state = 0;
1121
+ handle->busy = YES;
1122
+ @autoreleasepool {
1123
+ MetacoWindow *window = (__bridge MetacoWindow *)handle->window;
1124
+ NSError *error = nil;
1125
+ if (!window.useMetal) {
1126
+ if (compute) native_error(&error, @"Metal compute is not available");
1127
+ else memcpy(RSTRING_PTR(output), window.bitmapRep.bitmapData, handle->byte_length);
1128
+ } else {
1129
+ id<MTLTexture> sourceTexture = compute ? window.metalView.outputTexture : window.metalView.texture;
1130
+ if (!sourceTexture) {
1131
+ native_error(&error, @"Pixel source is not available");
1132
+ } else {
1133
+ MTLTextureDescriptor *desc = [MTLTextureDescriptor
1134
+ texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA8Unorm
1135
+ width:handle->width height:handle->height mipmapped:NO];
1136
+ desc.storageMode = MTLStorageModeManaged;
1137
+ if (@available(macOS 10.15, *)) {
1138
+ if (window.metalView.device.hasUnifiedMemory) desc.storageMode = MTLStorageModeShared;
1139
+ }
1140
+ desc.usage = MTLTextureUsageShaderRead;
1141
+ id<MTLTexture> staging = [window.metalView.device newTextureWithDescriptor:desc];
1142
+ id<MTLCommandBuffer> command = [window.metalView.commandQueue commandBuffer];
1143
+ id<MTLBlitCommandEncoder> blit = [command blitCommandEncoder];
1144
+ if (!staging || !command || !blit) {
1145
+ native_error(&error, @"Failed to allocate readback resources");
1146
+ } else {
1147
+ [blit copyFromTexture:sourceTexture sourceSlice:0 sourceLevel:0
1148
+ sourceOrigin:MTLOriginMake(0, 0, 0)
1149
+ sourceSize:MTLSizeMake(handle->width, handle->height, 1)
1150
+ toTexture:staging destinationSlice:0 destinationLevel:0
1151
+ destinationOrigin:MTLOriginMake(0, 0, 0)];
1152
+ if (desc.storageMode == MTLStorageModeManaged) [blit synchronizeResource:staging];
1153
+ [blit endEncoding];
1154
+ finish_command(command, &state, &error);
1155
+ if (!state && !error) {
1156
+ [staging getBytes:RSTRING_PTR(output) bytesPerRow:(size_t)handle->width * 4
1157
+ fromRegion:MTLRegionMake2D(0, 0, handle->width, handle->height)
1158
+ mipmapLevel:0];
1159
+ }
1160
+ }
1161
+ }
1162
+ }
1163
+ if (error && !state) {
1164
+ message = rb_protect(string_to_ruby, (VALUE)(__bridge void *)error.localizedDescription, &state);
1165
+ }
1166
+ }
1167
+ handle->busy = NO;
1168
+ RB_GC_GUARD(value);
1169
+ if (state) rb_jump_tag(state);
1170
+ if (!NIL_P(message)) rb_exc_raise(rb_exc_new_str(rb_eRuntimeError, message));
1171
+ return output;
1172
+ }
1173
+
681
1174
  void Init_metaco(void) {
682
1175
  VALUE mMetaco = rb_define_module("Metaco");
683
1176
  cMetacoWindow = rb_define_class_under(mMetaco, "Window", rb_cObject);
684
1177
  rb_global_variable(&cMetacoWindow);
685
1178
  rb_undef_alloc_func(cMetacoWindow);
1179
+ cMetacoTexture = rb_define_class_under(mMetaco, "Texture", rb_cObject);
1180
+ rb_global_variable(&cMetacoTexture);
1181
+ rb_undef_alloc_func(cMetacoTexture);
686
1182
 
687
1183
  rb_define_module_function(mMetaco, "init", cocoa_init, 0);
688
- rb_define_module_function(mMetaco, "window_create", cocoa_window_create, 3);
1184
+ rb_define_module_function(mMetaco, "window_create", cocoa_window_create, -1);
689
1185
  rb_define_module_function(mMetaco, "window_destroy", cocoa_window_destroy, 1);
690
1186
  rb_define_module_function(mMetaco, "set_pixels", cocoa_set_pixels, 4);
691
1187
  rb_define_module_function(mMetaco, "present", cocoa_present, 1);
@@ -698,4 +1194,11 @@ void Init_metaco(void) {
698
1194
  rb_define_module_function(mMetaco, "dispatch_compute", cocoa_dispatch_compute, 2);
699
1195
  rb_define_module_function(mMetaco, "present_compute", cocoa_present_compute, 1);
700
1196
  rb_define_module_function(mMetaco, "has_compute_shader?", cocoa_has_compute_shader, 1);
1197
+ rb_define_module_function(mMetaco, "window_size", cocoa_window_size, 1);
1198
+ rb_define_module_function(mMetaco, "framebuffer_size", cocoa_framebuffer_size, 1);
1199
+ rb_define_module_function(mMetaco, "read_pixels_native", cocoa_read_pixels_native, 2);
1200
+ rb_define_module_function(mMetaco, "texture_create_native", cocoa_texture_create_native, 4);
1201
+ rb_define_module_function(mMetaco, "texture_update", cocoa_texture_update, 2);
1202
+ rb_define_module_function(mMetaco, "texture_destroy", cocoa_texture_destroy, 1);
1203
+ rb_define_module_function(mMetaco, "bind_compute_texture", cocoa_bind_compute_texture, 3);
701
1204
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Metaco
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/metaco.rb CHANGED
@@ -4,13 +4,25 @@ require_relative "metaco/version"
4
4
 
5
5
  if RUBY_PLATFORM.include?("darwin")
6
6
  require "metaco/metaco"
7
+ module Metaco
8
+ def self.texture_create(handle, width, height, bytes, filter: :linear, wrap: :clamp)
9
+ raise ArgumentError, "filter must be :linear" unless filter == :linear
10
+ raise ArgumentError, "wrap must be :clamp" unless wrap == :clamp
11
+
12
+ texture_create_native(handle, width, height, bytes)
13
+ end
14
+
15
+ def self.read_pixels(handle, source: :compute)
16
+ read_pixels_native(handle, source)
17
+ end
18
+ end
7
19
  else
8
20
  module Metaco
9
21
  def self.init
10
22
  raise LoadError, "Metaco is only available on macOS"
11
23
  end
12
24
 
13
- def self.window_create(_w, _h, _t)
25
+ def self.window_create(_w, _h, _t, resizable: false, high_dpi: false)
14
26
  raise LoadError, "Metaco is only available on macOS"
15
27
  end
16
28
 
@@ -53,5 +65,33 @@ else
53
65
  def self.present_compute(_handle)
54
66
  raise LoadError, "Metaco is only available on macOS"
55
67
  end
68
+
69
+ def self.texture_create(_handle, _width, _height, _bytes, filter: :linear, wrap: :clamp)
70
+ raise LoadError, "Metaco is only available on macOS"
71
+ end
72
+
73
+ def self.texture_update(_texture, _bytes)
74
+ raise LoadError, "Metaco is only available on macOS"
75
+ end
76
+
77
+ def self.texture_destroy(_texture)
78
+ raise LoadError, "Metaco is only available on macOS"
79
+ end
80
+
81
+ def self.bind_compute_texture(_handle, _index, _texture)
82
+ raise LoadError, "Metaco is only available on macOS"
83
+ end
84
+
85
+ def self.read_pixels(_handle, source: :compute)
86
+ raise LoadError, "Metaco is only available on macOS"
87
+ end
88
+
89
+ def self.window_size(_handle)
90
+ raise LoadError, "Metaco is only available on macOS"
91
+ end
92
+
93
+ def self.framebuffer_size(_handle)
94
+ raise LoadError, "Metaco is only available on macOS"
95
+ end
56
96
  end
57
97
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: metaco
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-25 00:00:00.000000000 Z
11
12
  dependencies: []
12
13
  description: A Ruby C extension providing native macOS window management and Metal
13
14
  GPU acceleration for graphics applications.
@@ -22,6 +23,7 @@ files:
22
23
  - LICENSE
23
24
  - README.md
24
25
  - Rakefile
26
+ - docs/index.html
25
27
  - ext/metaco/extconf.rb
26
28
  - ext/metaco/metaco.m
27
29
  - lib/metaco.rb
@@ -32,6 +34,7 @@ licenses:
32
34
  metadata:
33
35
  source_code_uri: https://github.com/rbgfx/metaco
34
36
  changelog_uri: https://github.com/rbgfx/metaco/blob/main/CHANGELOG.md
37
+ post_install_message:
35
38
  rdoc_options: []
36
39
  require_paths:
37
40
  - lib
@@ -46,7 +49,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
46
49
  - !ruby/object:Gem::Version
47
50
  version: '0'
48
51
  requirements: []
49
- rubygems_version: 4.0.19
52
+ rubygems_version: 3.4.19
53
+ signing_key:
50
54
  specification_version: 4
51
55
  summary: Native macOS Cocoa/Metal bridge for Ruby graphics applications
52
56
  test_files: []