zaniah 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +70 -0
- data/assets/fonts/Abel-Regular.ttf +0 -0
- data/assets/fonts/OFL.txt +93 -0
- data/docs/adr/000-template.md +24 -0
- data/docs/adr/001-native-gpu-backends.md +36 -0
- data/docs/adr/002-objective-c-aggregates.md +34 -0
- data/docs/adr/003-fiddle-call-boundary.md +33 -0
- data/docs/adr/README.md +10 -0
- data/docs/native.md +66 -0
- data/docs/process_pool.md +67 -0
- data/docs/text.md +90 -0
- data/docs/vector_and_list.md +52 -0
- data/examples/ffi_bench.rb +16 -0
- data/examples/linux_smoke.rb +53 -0
- data/examples/mac_event_check.rb +61 -0
- data/examples/mac_poll_bench.rb +24 -0
- data/examples/metal_state_check.rb +72 -0
- data/examples/native_bench.rb +18 -0
- data/examples/native_drop.rb +82 -0
- data/examples/native_font.rb +15 -0
- data/examples/native_ime.rb +50 -0
- data/examples/native_smoke.rb +75 -0
- data/examples/native_text_bench.rb +83 -0
- data/examples/native_watch.rb +25 -0
- data/examples/vulkan_triangle.rb +20 -0
- data/examples/word_count_worker.rb +8 -0
- data/lib/zaniah/anchored.rb +10 -0
- data/lib/zaniah/app.rb +130 -0
- data/lib/zaniah/bounds.rb +22 -0
- data/lib/zaniah/canvas.rb +15 -0
- data/lib/zaniah/color.rb +35 -0
- data/lib/zaniah/configuration.rb +5 -0
- data/lib/zaniah/corners.rb +7 -0
- data/lib/zaniah/data_compat.rb +25 -0
- data/lib/zaniah/device_pixels.rb +7 -0
- data/lib/zaniah/div.rb +5 -0
- data/lib/zaniah/edges.rb +7 -0
- data/lib/zaniah/element.rb +106 -0
- data/lib/zaniah/entity.rb +9 -0
- data/lib/zaniah/entity_context.rb +17 -0
- data/lib/zaniah/error.rb +5 -0
- data/lib/zaniah/ffi/com.rb +24 -0
- data/lib/zaniah/ffi/library.rb +29 -0
- data/lib/zaniah/ffi/objc.rb +86 -0
- data/lib/zaniah/ffi/struct/signature.rb +46 -0
- data/lib/zaniah/ffi/struct.rb +61 -0
- data/lib/zaniah/ffi/wayland.rb +140 -0
- data/lib/zaniah/frame_context.rb +14 -0
- data/lib/zaniah/geometry.rb +16 -0
- data/lib/zaniah/gpu/buffer.rb +23 -0
- data/lib/zaniah/gpu/frame_encoder.rb +43 -0
- data/lib/zaniah/gpu/instance_packing.rb +53 -0
- data/lib/zaniah/gpu/metal.rb +209 -0
- data/lib/zaniah/gpu/open_gl.rb +210 -0
- data/lib/zaniah/gpu/pipeline.rb +7 -0
- data/lib/zaniah/gpu/software.rb +126 -0
- data/lib/zaniah/gpu/texture.rb +33 -0
- data/lib/zaniah/gpu/vulkan.rb +233 -0
- data/lib/zaniah/gpu.rb +18 -0
- data/lib/zaniah/hsla.rb +14 -0
- data/lib/zaniah/image.rb +19 -0
- data/lib/zaniah/input/action_registry.rb +12 -0
- data/lib/zaniah/input/composition.rb +7 -0
- data/lib/zaniah/input/context_predicate.rb +87 -0
- data/lib/zaniah/input/dispatcher.rb +59 -0
- data/lib/zaniah/input/file_drop.rb +7 -0
- data/lib/zaniah/input/focus_handle.rb +24 -0
- data/lib/zaniah/input/key_down.rb +7 -0
- data/lib/zaniah/input/key_up.rb +7 -0
- data/lib/zaniah/input/keymap/binding.rb +9 -0
- data/lib/zaniah/input/keymap.rb +55 -0
- data/lib/zaniah/input/keystroke.rb +20 -0
- data/lib/zaniah/input/mouse_down.rb +7 -0
- data/lib/zaniah/input/mouse_move.rb +7 -0
- data/lib/zaniah/input/mouse_up.rb +7 -0
- data/lib/zaniah/input/scroll_wheel.rb +7 -0
- data/lib/zaniah/input/text_input.rb +7 -0
- data/lib/zaniah/input.rb +17 -0
- data/lib/zaniah/layout/available_space.rb +11 -0
- data/lib/zaniah/layout/engine.rb +214 -0
- data/lib/zaniah/layout/node.rb +36 -0
- data/lib/zaniah/layout/style.rb +19 -0
- data/lib/zaniah/layout/tree.rb +16 -0
- data/lib/zaniah/layout.rb +11 -0
- data/lib/zaniah/length.rb +14 -0
- data/lib/zaniah/length_units.rb +9 -0
- data/lib/zaniah/list/height_index.rb +68 -0
- data/lib/zaniah/list.rb +101 -0
- data/lib/zaniah/overlay.rb +10 -0
- data/lib/zaniah/platform/display.rb +7 -0
- data/lib/zaniah/platform/file_event.rb +7 -0
- data/lib/zaniah/platform/headless/window.rb +185 -0
- data/lib/zaniah/platform/linux/appearance_aware.rb +60 -0
- data/lib/zaniah/platform/linux/displays.rb +55 -0
- data/lib/zaniah/platform/linux/free_type.rb +80 -0
- data/lib/zaniah/platform/linux/watcher.rb +70 -0
- data/lib/zaniah/platform/linux/wayland_display_discovery.rb +60 -0
- data/lib/zaniah/platform/linux/wayland_window.rb +369 -0
- data/lib/zaniah/platform/linux/window.rb +418 -0
- data/lib/zaniah/platform/linux.rb +3 -0
- data/lib/zaniah/platform/mac/app.rb +67 -0
- data/lib/zaniah/platform/mac/core_text.rb +94 -0
- data/lib/zaniah/platform/mac/displays.rb +23 -0
- data/lib/zaniah/platform/mac/watcher.rb +63 -0
- data/lib/zaniah/platform/mac/window.rb +298 -0
- data/lib/zaniah/platform/mac.rb +19 -0
- data/lib/zaniah/platform/tui/input_decoder.rb +145 -0
- data/lib/zaniah/platform/tui/text_renderer.rb +40 -0
- data/lib/zaniah/platform/tui/window.rb +91 -0
- data/lib/zaniah/platform/windows/terminal.rb +127 -0
- data/lib/zaniah/platform/windows/watcher.rb +91 -0
- data/lib/zaniah/platform/windows/window.rb +407 -0
- data/lib/zaniah/platform/windows.rb +3 -0
- data/lib/zaniah/platform.rb +34 -0
- data/lib/zaniah/png.rb +118 -0
- data/lib/zaniah/point.rb +8 -0
- data/lib/zaniah/process_pool/worker.rb +7 -0
- data/lib/zaniah/process_pool.rb +239 -0
- data/lib/zaniah/process_wire.rb +127 -0
- data/lib/zaniah/quad_packer.rb +15 -0
- data/lib/zaniah/scene/sprite_batch.rb +7 -0
- data/lib/zaniah/scene.rb +167 -0
- data/lib/zaniah/scene_renderer.rb +8 -0
- data/lib/zaniah/size.rb +5 -0
- data/lib/zaniah/subscription.rb +14 -0
- data/lib/zaniah/svg/path.rb +134 -0
- data/lib/zaniah/svg/rasterizable.rb +186 -0
- data/lib/zaniah/svg.rb +295 -0
- data/lib/zaniah/task/cancelled.rb +7 -0
- data/lib/zaniah/task/timeout.rb +7 -0
- data/lib/zaniah/task.rb +51 -0
- data/lib/zaniah/task_executor.rb +105 -0
- data/lib/zaniah/text.rb +24 -0
- data/lib/zaniah/text_system/atlas/entry.rb +9 -0
- data/lib/zaniah/text_system/atlas.rb +102 -0
- data/lib/zaniah/text_system/atlas_cache.rb +146 -0
- data/lib/zaniah/text_system/font_db/face.rb +9 -0
- data/lib/zaniah/text_system/font_db.rb +193 -0
- data/lib/zaniah/text_system/glyph.rb +7 -0
- data/lib/zaniah/text_system/line_layout.rb +20 -0
- data/lib/zaniah/text_system/renderer.rb +196 -0
- data/lib/zaniah/text_system/shaper.rb +430 -0
- data/lib/zaniah/text_system/typesetter/copy_error.rb +9 -0
- data/lib/zaniah/text_system/typesetter.rb +168 -0
- data/lib/zaniah/text_system.rb +9 -0
- data/lib/zaniah/unicode.rb +13 -0
- data/lib/zaniah/uniform_list.rb +30 -0
- data/lib/zaniah/version.rb +5 -0
- data/lib/zaniah/view.rb +13 -0
- data/lib/zaniah.rb +29 -0
- data/sig/app.rbs +80 -0
- data/sig/elements.rbs +129 -0
- data/sig/gpu.rbs +108 -0
- data/sig/input.rbs +114 -0
- data/sig/layout.rbs +53 -0
- data/sig/native.rbs +88 -0
- data/sig/platform.rbs +94 -0
- data/sig/scene.rbs +52 -0
- data/sig/shaper.rbs +8 -0
- data/sig/text.rbs +137 -0
- data/sig/zaniah.rbs +120 -0
- data/tools/check_dependencies.rb +52 -0
- metadata +249 -0
data/lib/zaniah/png.rb
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module Zaniah
|
|
6
|
+
# Runtime headless output uses only the Ruby standard distribution.
|
|
7
|
+
module PNG
|
|
8
|
+
SIGNATURE = "\x89PNG\r\n\x1a\n".b.freeze
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def encode(width, height, rgba)
|
|
12
|
+
raise ArgumentError, "invalid image dimensions" unless width.positive? && height.positive? && rgba.bytesize == width * height * 4
|
|
13
|
+
scanlines = String.new(encoding: Encoding::BINARY)
|
|
14
|
+
height.times { |row| scanlines << "\0" << rgba.byteslice(row * width * 4, width * 4) }
|
|
15
|
+
SIGNATURE + chunk("IHDR", [width, height, 8, 6, 0, 0, 0].pack("NNC5")) +
|
|
16
|
+
chunk("IDAT", Zlib::Deflate.deflate(scanlines)) + chunk("IEND", "".b)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def write(path, width, height, rgba) = File.binwrite(path, encode(width, height, rgba))
|
|
20
|
+
|
|
21
|
+
def decode(bytes, max_pixels: 16_777_216)
|
|
22
|
+
raise ArgumentError, "not a PNG" unless bytes.start_with?(SIGNATURE)
|
|
23
|
+
|
|
24
|
+
header, compressed = parse_chunks(bytes)
|
|
25
|
+
width, height, type, channels = decode_header(header, max_pixels)
|
|
26
|
+
stride = width * channels
|
|
27
|
+
raw = inflate(compressed, (stride + 1) * height)
|
|
28
|
+
[width, height, decode_scanlines(raw, width, height, type, channels)]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def parse_chunks(bytes)
|
|
32
|
+
offset, compressed, header, ended = 8, String.new(encoding: Encoding::BINARY), nil, false
|
|
33
|
+
while offset + 12 <= bytes.bytesize
|
|
34
|
+
size = bytes.byteslice(offset, 4).unpack1("N")
|
|
35
|
+
raise ArgumentError, "truncated PNG" if offset + 12 + size > bytes.bytesize
|
|
36
|
+
kind = bytes.byteslice(offset + 4, 4)
|
|
37
|
+
data = bytes.byteslice(offset + 8, size)
|
|
38
|
+
crc = bytes.byteslice(offset + 8 + size, 4).unpack1("N")
|
|
39
|
+
raise ArgumentError, "PNG checksum mismatch" unless Zlib.crc32(kind + data) == crc
|
|
40
|
+
case kind
|
|
41
|
+
when "IHDR" then header = data.unpack("NNC5")
|
|
42
|
+
when "IDAT" then compressed << data
|
|
43
|
+
when "IEND" then ended = true; break
|
|
44
|
+
end
|
|
45
|
+
offset += size + 12
|
|
46
|
+
end
|
|
47
|
+
raise ArgumentError, "missing PNG header or end" unless header && ended
|
|
48
|
+
|
|
49
|
+
[header, compressed]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def decode_header(header, max_pixels)
|
|
53
|
+
width, height, depth, type, compression, filter, interlace = header
|
|
54
|
+
raise ArgumentError, "unsupported PNG encoding" unless depth == 8 && compression == 0 && filter == 0 && interlace == 0 && [0, 2, 4, 6].include?(type)
|
|
55
|
+
raise ArgumentError, "PNG too large" unless width.positive? && height.positive? && width * height <= max_pixels
|
|
56
|
+
channels = {0 => 1, 2 => 3, 4 => 2, 6 => 4}.fetch(type)
|
|
57
|
+
|
|
58
|
+
[width, height, type, channels]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def inflate(compressed, expected)
|
|
62
|
+
raw = String.new(encoding: Encoding::BINARY)
|
|
63
|
+
inflater = Zlib::Inflate.new
|
|
64
|
+
begin
|
|
65
|
+
inflater.inflate(compressed) do |part|
|
|
66
|
+
raise ArgumentError, "excess PNG data" if raw.bytesize + part.bytesize > expected
|
|
67
|
+
raw << part
|
|
68
|
+
end
|
|
69
|
+
ensure
|
|
70
|
+
inflater.close
|
|
71
|
+
end
|
|
72
|
+
raise ArgumentError, "incorrect PNG data length" unless raw.bytesize == expected
|
|
73
|
+
|
|
74
|
+
raw
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def decode_scanlines(raw, width, height, type, channels)
|
|
78
|
+
stride = width * channels
|
|
79
|
+
previous = Array.new(stride, 0)
|
|
80
|
+
rgba = String.new(capacity: width * height * 4, encoding: Encoding::BINARY)
|
|
81
|
+
height.times do |row|
|
|
82
|
+
method = raw.getbyte(row * (stride + 1))
|
|
83
|
+
raise ArgumentError, "invalid PNG filter" unless (0..4).cover?(method)
|
|
84
|
+
current = raw.byteslice(row * (stride + 1) + 1, stride).bytes
|
|
85
|
+
stride.times do |i|
|
|
86
|
+
left = i < channels ? 0 : current[i - channels]
|
|
87
|
+
up, upper_left = previous[i], i < channels ? 0 : previous[i - channels]
|
|
88
|
+
prediction = case method
|
|
89
|
+
when 0 then 0
|
|
90
|
+
when 1 then left
|
|
91
|
+
when 2 then up
|
|
92
|
+
when 3 then (left + up) / 2
|
|
93
|
+
when 4
|
|
94
|
+
p = left + up - upper_left
|
|
95
|
+
[left, up, upper_left].min_by { |v| (p - v).abs }
|
|
96
|
+
end
|
|
97
|
+
current[i] = (current[i] + prediction) & 255
|
|
98
|
+
end
|
|
99
|
+
current.each_slice(channels) do |pixel|
|
|
100
|
+
rgba << case type
|
|
101
|
+
when 0 then [pixel[0], pixel[0], pixel[0], 255].pack("C4")
|
|
102
|
+
when 2 then [*pixel, 255].pack("C4")
|
|
103
|
+
when 4 then [pixel[0], pixel[0], pixel[0], pixel[1]].pack("C4")
|
|
104
|
+
when 6 then pixel.pack("C4")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
previous = current
|
|
108
|
+
end
|
|
109
|
+
rgba
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def chunk(kind, data)
|
|
113
|
+
[data.bytesize].pack("N") + kind + data + [Zlib.crc32(kind + data)].pack("N")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private_class_method :parse_chunks, :decode_header, :inflate, :decode_scanlines
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/zaniah/point.rb
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
require_relative "process_wire"
|
|
5
|
+
|
|
6
|
+
module Zaniah
|
|
7
|
+
# Fixed parallelism, bounded data-only IPC, portable spawn or legacy fork.
|
|
8
|
+
class ProcessPool
|
|
9
|
+
def initialize(workers: 2, handler: nil, requires: [], load_paths: [], max_pending: workers * 2, max_bytes: 16 << 20, &block)
|
|
10
|
+
raise ArgumentError, "workers must be in 1..32" unless workers.is_a?(Integer) && workers.between?(1, 32)
|
|
11
|
+
raise ArgumentError, "max_pending must be in 1..1024" unless max_pending.is_a?(Integer) && max_pending.between?(1, 1024)
|
|
12
|
+
raise ArgumentError, "max_bytes must be in 4096..67108864" unless max_bytes.is_a?(Integer) && max_bytes.between?(4096, 64 << 20)
|
|
13
|
+
raise ArgumentError, "provide a named handler or a block, not both" if !!handler == !!block
|
|
14
|
+
if handler
|
|
15
|
+
raise ArgumentError, "handler must be a constant name" unless handler.is_a?(String) && handler.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/)
|
|
16
|
+
elsif !Process.respond_to?(:fork)
|
|
17
|
+
raise Error, "block workers require fork; use handler: and requires: on this platform"
|
|
18
|
+
end
|
|
19
|
+
@requires = checked_paths(requires, file: true)
|
|
20
|
+
@load_paths = checked_paths(load_paths, file: false)
|
|
21
|
+
@handler, @block, @max_pending, @max_bytes = handler&.dup&.freeze, block, max_pending, max_bytes
|
|
22
|
+
@mutex, @condition, @queue, @pending, @control = Mutex.new, ConditionVariable.new, [], {}, Queue.new
|
|
23
|
+
@workers = Array.new(workers) { Worker.new }
|
|
24
|
+
@supervisor = Thread.new { supervise }
|
|
25
|
+
@workers.each { |entry| entry.thread = Thread.new { run_worker(entry) } }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Snapshot before enqueueing, so later caller mutations cannot race IPC.
|
|
29
|
+
# Large snapshots should be submitted from a TaskExecutor background task.
|
|
30
|
+
def submit(data)
|
|
31
|
+
@mutex.synchronize { ensure_accepting_tasks }
|
|
32
|
+
bytes = ProcessWire.encode_frame(data, @max_bytes)
|
|
33
|
+
task = Task.new
|
|
34
|
+
task.on_complete do
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
@pending.delete(task)
|
|
37
|
+
@queue.delete_if { |queued, _| queued.equal?(task) }
|
|
38
|
+
end
|
|
39
|
+
@control << task # Cancellation never waits on a pipe or child process.
|
|
40
|
+
end
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
ensure_accepting_tasks
|
|
43
|
+
@pending[task] = true
|
|
44
|
+
@queue << [task, bytes]
|
|
45
|
+
@condition.signal
|
|
46
|
+
end
|
|
47
|
+
task
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def shutdown(timeout: 2)
|
|
51
|
+
raise ArgumentError, "timeout must be finite and positive" unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
|
|
52
|
+
tasks = @mutex.synchronize do
|
|
53
|
+
@closed = true
|
|
54
|
+
saved = @pending.keys
|
|
55
|
+
@queue.clear
|
|
56
|
+
@condition.broadcast
|
|
57
|
+
saved
|
|
58
|
+
end
|
|
59
|
+
@control << :shutdown
|
|
60
|
+
tasks.each { |task| complete_task(task, error: Task::Cancelled.new("process pool shut down")) }
|
|
61
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
62
|
+
[@supervisor, *@workers.map(&:thread)].each do |thread|
|
|
63
|
+
next if thread.equal?(Thread.current)
|
|
64
|
+
remaining = [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
|
|
65
|
+
raise Task::Timeout, "process pool cleanup is still running" unless thread.join(remaining)
|
|
66
|
+
end
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def checked_paths(paths, file:)
|
|
73
|
+
raise ArgumentError, "worker paths must be an array" unless paths.is_a?(Array)
|
|
74
|
+
paths.map do |path|
|
|
75
|
+
raise ArgumentError, "worker paths must be absolute existing paths" unless path.is_a?(String) &&
|
|
76
|
+
File.absolute_path(path) == path && (file ? File.file?(path) : File.directory?(path))
|
|
77
|
+
path.dup.freeze
|
|
78
|
+
end.freeze
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def ensure_accepting_tasks
|
|
82
|
+
raise Error, "process pool is shut down" if @closed
|
|
83
|
+
raise Error, "process pool queue is full" if @pending.length >= @max_pending
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def take_job(entry)
|
|
87
|
+
@mutex.synchronize do
|
|
88
|
+
@condition.wait(@mutex) while @queue.empty? && !@closed
|
|
89
|
+
return if @closed
|
|
90
|
+
task, bytes = @queue.shift
|
|
91
|
+
entry.task = task
|
|
92
|
+
[task, bytes]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def run_worker(entry)
|
|
97
|
+
while (job = take_job(entry))
|
|
98
|
+
task, bytes = job
|
|
99
|
+
value = error = nil
|
|
100
|
+
begin
|
|
101
|
+
unless task.done?
|
|
102
|
+
start_worker(entry) unless entry.pid
|
|
103
|
+
unless task.done? || @closed
|
|
104
|
+
entry.writer.write(bytes)
|
|
105
|
+
body = ProcessWire.read_payload(entry.reader, @max_bytes)
|
|
106
|
+
raise Error, "process worker exited without a response" unless body
|
|
107
|
+
response = ProcessWire.decode_payload(body)
|
|
108
|
+
raise Error, "invalid process worker response" unless response.is_a?(Array) && response.length == 2 && [true, false].include?(response[0])
|
|
109
|
+
unless response[0]
|
|
110
|
+
raise Error, "invalid process worker error" unless response[1].is_a?(String)
|
|
111
|
+
raise Error, response[1]
|
|
112
|
+
end
|
|
113
|
+
value = response[1]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
rescue StandardError => failure
|
|
117
|
+
error = failure
|
|
118
|
+
ensure
|
|
119
|
+
retire = @mutex.synchronize do
|
|
120
|
+
entry.task = nil
|
|
121
|
+
@pending.delete(task)
|
|
122
|
+
entry.retire || @closed || task.done? || error
|
|
123
|
+
end
|
|
124
|
+
stop_worker(entry) if retire
|
|
125
|
+
end
|
|
126
|
+
complete_task(task, value, error: error)
|
|
127
|
+
end
|
|
128
|
+
ensure
|
|
129
|
+
stop_worker(entry)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def complete_task(task, value = nil, error: nil)
|
|
133
|
+
task.resolve(value, error: error)
|
|
134
|
+
rescue StandardError => failure
|
|
135
|
+
begin
|
|
136
|
+
warn "Process task callback failed: #{ProcessWire.error_message(failure)}"
|
|
137
|
+
rescue StandardError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def supervise
|
|
143
|
+
loop do
|
|
144
|
+
task = @control.pop
|
|
145
|
+
@mutex.synchronize do
|
|
146
|
+
@workers.each do |entry|
|
|
147
|
+
next unless task == :shutdown || entry.task.equal?(task)
|
|
148
|
+
next if entry.retire
|
|
149
|
+
entry.retire = true
|
|
150
|
+
terminate_worker_process(entry.pid) if entry.pid
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
break if task == :shutdown
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def start_worker(entry)
|
|
158
|
+
child_in, writer = IO.pipe
|
|
159
|
+
reader, child_out = IO.pipe
|
|
160
|
+
[reader, writer, child_in, child_out].each(&:binmode)
|
|
161
|
+
writer.sync = true
|
|
162
|
+
pid = if @handler
|
|
163
|
+
jit = defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? ["--yjit"] : []
|
|
164
|
+
command = [RbConfig.ruby, *jit, *@load_paths.flat_map { |path| ["-I", path] },
|
|
165
|
+
File.expand_path("process_wire.rb", __dir__), @handler, @max_bytes.to_s, *@requires]
|
|
166
|
+
options = {in: child_in, out: child_out, err: STDERR, close_others: true}
|
|
167
|
+
options[windows? ? :new_pgroup : :pgroup] = true
|
|
168
|
+
Process.spawn({"RUBYOPT" => nil, "RUBYLIB" => nil}, *command, **options)
|
|
169
|
+
else
|
|
170
|
+
Process.fork do
|
|
171
|
+
Process.setpgrp
|
|
172
|
+
reader.close
|
|
173
|
+
writer.close
|
|
174
|
+
@workers.each { |other| [other.reader, other.writer].compact.each { |io| io.close unless io.closed? } }
|
|
175
|
+
begin
|
|
176
|
+
ProcessWire.serve(child_in, child_out, @max_bytes, @block)
|
|
177
|
+
exit! 0
|
|
178
|
+
rescue Exception
|
|
179
|
+
exit! 1
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
@mutex.synchronize do
|
|
184
|
+
entry.pid, entry.reader, entry.writer, entry.retire = pid, reader, writer, false
|
|
185
|
+
terminate_worker_process(pid) if @closed || entry.task.done?
|
|
186
|
+
end
|
|
187
|
+
rescue Exception
|
|
188
|
+
[reader, writer].compact.each { |io| io.close unless io.closed? }
|
|
189
|
+
raise
|
|
190
|
+
ensure
|
|
191
|
+
[child_in, child_out].compact.each { |io| io.close unless io.closed? }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def windows? = /mswin|mingw/.match?(RbConfig::CONFIG.fetch("host_os"))
|
|
195
|
+
|
|
196
|
+
def terminate_worker_process(pid)
|
|
197
|
+
Process.kill("KILL", windows? ? pid : -pid)
|
|
198
|
+
rescue Errno::EPERM => error
|
|
199
|
+
# macOS can report EPERM for a group whose last process just exited.
|
|
200
|
+
# Do not mask a real permission failure against a still-existing child.
|
|
201
|
+
begin
|
|
202
|
+
windows? ? Process.kill(0, pid) : Process.getpgid(pid)
|
|
203
|
+
rescue Errno::ESRCH
|
|
204
|
+
return
|
|
205
|
+
end
|
|
206
|
+
raise error
|
|
207
|
+
rescue Errno::ESRCH
|
|
208
|
+
begin
|
|
209
|
+
Process.kill("KILL", pid) # A just-forked child may not have set its group yet.
|
|
210
|
+
rescue Errno::ESRCH
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def stop_worker(entry)
|
|
216
|
+
pid = @mutex.synchronize do
|
|
217
|
+
saved, entry.pid = entry.pid, nil
|
|
218
|
+
saved
|
|
219
|
+
end
|
|
220
|
+
return unless pid
|
|
221
|
+
unless Process.waitpid(pid, Process::WNOHANG)
|
|
222
|
+
terminate_worker_process(pid)
|
|
223
|
+
Process.wait(pid)
|
|
224
|
+
end
|
|
225
|
+
rescue Errno::ECHILD
|
|
226
|
+
nil
|
|
227
|
+
rescue StandardError
|
|
228
|
+
# Retain the exact child identity if the OS denies termination/reaping;
|
|
229
|
+
# a later shutdown must not mistake a failed cleanup for no child.
|
|
230
|
+
@mutex.synchronize { entry.pid = pid } if pid
|
|
231
|
+
raise
|
|
232
|
+
ensure
|
|
233
|
+
[entry.reader, entry.writer].compact.each { |io| io.close unless io.closed? }
|
|
234
|
+
entry.reader = entry.writer = nil
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
require_relative "process_pool/worker"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Zaniah
|
|
6
|
+
# Only primitive JSON data crosses this local, trusted-code boundary. No
|
|
7
|
+
# Marshal hooks, constant reconstruction, source text or eval is involved.
|
|
8
|
+
module ProcessWire
|
|
9
|
+
MAX_DEPTH = 64
|
|
10
|
+
MAX_NODES = 1_000_000
|
|
11
|
+
|
|
12
|
+
def self.validate_payload(value, depth = 0, budget = [MAX_NODES, nil, nil], freeze: false)
|
|
13
|
+
raise ArgumentError, "IPC data is too deeply nested" if depth > MAX_DEPTH
|
|
14
|
+
raise ArgumentError, "IPC data has too many values" if (budget[0] -= 1).negative?
|
|
15
|
+
case value.class.name
|
|
16
|
+
when "NilClass", "TrueClass" then charge_budget(budget, 4)
|
|
17
|
+
when "FalseClass" then charge_budget(budget, 5)
|
|
18
|
+
when "Integer"
|
|
19
|
+
raise ArgumentError, "IPC integer exceeds byte budget" if budget[1] && value.bit_length > budget[1] * 4
|
|
20
|
+
charge_budget(budget, value.to_s.bytesize)
|
|
21
|
+
when "Float"
|
|
22
|
+
raise ArgumentError, "IPC numbers must be finite" unless value.finite?
|
|
23
|
+
charge_budget(budget, value.to_s.bytesize)
|
|
24
|
+
when "String"
|
|
25
|
+
raise ArgumentError, "IPC strings must be valid UTF-8" unless value.valid_encoding? &&
|
|
26
|
+
(value.encoding == Encoding::UTF_8 || value.ascii_only?)
|
|
27
|
+
if budget[1]
|
|
28
|
+
charge_budget(budget, value.bytesize + 2)
|
|
29
|
+
# JSON escapes quotes/backslashes and control characters. Count in
|
|
30
|
+
# native String scans before generating any potentially large JSON.
|
|
31
|
+
charge_budget(budget, value.count('"') + value.count("\\") +
|
|
32
|
+
value.count("\x00-\x1f") * 5 - value.count("\b\t\n\f\r") * 4)
|
|
33
|
+
end
|
|
34
|
+
when "Array"
|
|
35
|
+
charge_budget(budget, 2 + [value.length - 1, 0].max)
|
|
36
|
+
value.each { |item| validate_payload(item, depth + 1, budget, freeze: freeze) }
|
|
37
|
+
when "Hash"
|
|
38
|
+
charge_budget(budget, 2 + value.length + [value.length - 1, 0].max)
|
|
39
|
+
value.each do |key, item|
|
|
40
|
+
raise ArgumentError, "IPC object keys must be strings" unless key.instance_of?(String)
|
|
41
|
+
validate_payload(key, depth + 1, budget, freeze: freeze)
|
|
42
|
+
validate_payload(item, depth + 1, budget, freeze: freeze)
|
|
43
|
+
end
|
|
44
|
+
else
|
|
45
|
+
raise ArgumentError, "IPC values must be plain JSON data"
|
|
46
|
+
end
|
|
47
|
+
value.freeze if freeze
|
|
48
|
+
value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.charge_budget(budget, bytes)
|
|
52
|
+
return unless budget[1]
|
|
53
|
+
raise ArgumentError, "IPC frame exceeds #{budget[2]} bytes" if (budget[1] -= bytes).negative?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.encode_frame(value, max_bytes)
|
|
57
|
+
validate_payload(value, 0, [MAX_NODES, max_bytes, max_bytes])
|
|
58
|
+
body = JSON.generate(value, max_nesting: MAX_DEPTH)
|
|
59
|
+
raise ArgumentError, "IPC frame exceeds #{max_bytes} bytes" if body.bytesize > max_bytes
|
|
60
|
+
[body.bytesize].pack("N") + body.b
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.read_payload(io, max_bytes)
|
|
64
|
+
header = io.read(4)
|
|
65
|
+
return if header.nil?
|
|
66
|
+
raise IOError, "truncated IPC header" unless header.bytesize == 4
|
|
67
|
+
length = header.unpack1("N")
|
|
68
|
+
raise IOError, "invalid IPC frame length" unless length.between?(1, max_bytes)
|
|
69
|
+
body = io.read(length)
|
|
70
|
+
raise IOError, "truncated IPC payload" unless body && body.bytesize == length
|
|
71
|
+
body.force_encoding(Encoding::UTF_8)
|
|
72
|
+
raise IOError, "invalid IPC UTF-8" unless body.valid_encoding?
|
|
73
|
+
body
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.decode_payload(body)
|
|
77
|
+
validate_payload(JSON.parse(body, max_nesting: MAX_DEPTH, create_additions: false), freeze: true)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.error_message(error)
|
|
81
|
+
"#{error.class}: #{error.message}".encode(Encoding::UTF_8, invalid: :replace, undef: :replace).byteslice(0, 2048).scrub("")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private_class_method :validate_payload, :charge_budget
|
|
85
|
+
|
|
86
|
+
def self.serve(input, output, max_bytes, handler)
|
|
87
|
+
input.binmode
|
|
88
|
+
output.binmode
|
|
89
|
+
output.sync = true
|
|
90
|
+
while (body = read_payload(input, max_bytes))
|
|
91
|
+
response = begin
|
|
92
|
+
encode_frame([true, handler.call(decode_payload(body))], max_bytes)
|
|
93
|
+
rescue StandardError => error
|
|
94
|
+
encode_frame([false, error_message(error)], max_bytes)
|
|
95
|
+
end
|
|
96
|
+
output.write(response)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.run
|
|
101
|
+
# Keep protocol output private; even a required file's `puts` is harmless.
|
|
102
|
+
output = STDOUT.dup
|
|
103
|
+
STDOUT.reopen(STDERR)
|
|
104
|
+
$stdout = STDERR
|
|
105
|
+
name, maximum, *requires = ARGV
|
|
106
|
+
raise ArgumentError, "invalid worker handler" unless name&.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/)
|
|
107
|
+
max_bytes = Integer(maximum)
|
|
108
|
+
requires.each { |path| require path }
|
|
109
|
+
handler = name.split("::").reduce(Object) { |scope, part| scope.const_get(part, false) }
|
|
110
|
+
raise ArgumentError, "worker handler must respond to .call" unless handler.is_a?(Module) && handler.respond_to?(:call)
|
|
111
|
+
serve(STDIN, output, max_bytes, handler)
|
|
112
|
+
rescue StandardError => error
|
|
113
|
+
output.write(encode_frame([false, error_message(error)], max_bytes)) if output && max_bytes
|
|
114
|
+
ensure
|
|
115
|
+
output&.close
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
if $PROGRAM_NAME == __FILE__
|
|
121
|
+
# Ruby does not put its entry script in $LOADED_FEATURES. A handler may load
|
|
122
|
+
# zaniah, which requires this file again through ProcessPool; register it
|
|
123
|
+
# before loading handlers so that require cannot re-enter worker startup.
|
|
124
|
+
path = File.realpath(__FILE__)
|
|
125
|
+
$LOADED_FEATURES << path unless $LOADED_FEATURES.include?(path)
|
|
126
|
+
Zaniah::ProcessWire.run
|
|
127
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
class QuadPacker
|
|
5
|
+
attr_reader :bytes
|
|
6
|
+
|
|
7
|
+
def initialize = @bytes = String.new(capacity: 1 << 20, encoding: Encoding::BINARY)
|
|
8
|
+
|
|
9
|
+
def pack(scene)
|
|
10
|
+
@bytes.clear
|
|
11
|
+
@bytes << scene.quads.pack("f*")
|
|
12
|
+
@bytes
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/zaniah/scene.rb
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
# Primitive payloads are flat arrays; command order preserves alpha compositing.
|
|
5
|
+
class Scene
|
|
6
|
+
QUAD_STRIDE = 17
|
|
7
|
+
SPRITE_INSTANCE_BYTES = 24 * 4
|
|
8
|
+
attr_reader :quads, :paths, :commands, :textures, :sprite_batches
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@quads, @sprites, @paths, @commands, @textures = [], [], [], [], []
|
|
12
|
+
@sprite_batches, @expanded_batches = [], {}
|
|
13
|
+
@clips, @layers = [], [0]
|
|
14
|
+
@colors, @last_layer, @ordered = {}, -Float::INFINITY, true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def clear
|
|
18
|
+
[@quads, @sprites, @paths, @commands, @textures, @clips].each(&:clear)
|
|
19
|
+
@sprite_batches.clear
|
|
20
|
+
@expanded_batches.clear
|
|
21
|
+
@layers.replace([0])
|
|
22
|
+
@last_layer, @ordered = -Float::INFINITY, true
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def quad(x, y, width, height, color:, radius: 0, border_width: 0, border_color: "#0000")
|
|
27
|
+
return self if width <= 0 || height <= 0
|
|
28
|
+
offset = @quads.length
|
|
29
|
+
if radius.is_a?(Numeric)
|
|
30
|
+
@quads.push(x, y, width, height, *color_values(color), radius, radius, radius, radius, border_width, *color_values(border_color))
|
|
31
|
+
else
|
|
32
|
+
corners = radius.to_a
|
|
33
|
+
raise ArgumentError, "four radii required" unless corners.length == 4
|
|
34
|
+
@quads.push(x, y, width, height, *color_values(color), *corners, border_width, *color_values(border_color))
|
|
35
|
+
end
|
|
36
|
+
command(:quad, offset)
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def push_quad(x, y, width, height, bg, radius, border_width, border_color, order = 0)
|
|
41
|
+
layer(order) { quad(x, y, width, height, color: bg, radius: radius,
|
|
42
|
+
border_width: border_width, border_color: border_color) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def sprite(x, y, width, height, texture:, color: "#fff", source: nil)
|
|
46
|
+
source ||= Bounds.new(0, 0, texture.width, texture.height)
|
|
47
|
+
id = @textures.index(texture) || @textures.push(texture).length - 1
|
|
48
|
+
offset = @sprites.length
|
|
49
|
+
@sprites.push(x, y, width, height, *color_values(color), id,
|
|
50
|
+
source.x, source.y, source.width, source.height)
|
|
51
|
+
command(:sprite, offset)
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Prepacked GPU instances retain ordinary sprite alpha/clip/layer ordering.
|
|
56
|
+
def sprite_batch(bytes, texture: nil)
|
|
57
|
+
batch = bytes if bytes.is_a?(SpriteBatch)
|
|
58
|
+
bytes, texture = batch.bytes, batch.texture if batch
|
|
59
|
+
raise ArgumentError, "invalid packed sprite bytes" unless bytes.is_a?(String) && bytes.bytesize % SPRITE_INSTANCE_BYTES == 0
|
|
60
|
+
raise ArgumentError, "packed sprites need a texture" unless texture.is_a?(GPU::Texture)
|
|
61
|
+
return self if bytes.empty?
|
|
62
|
+
@textures << texture unless @textures.include?(texture)
|
|
63
|
+
offset = @sprite_batches.length
|
|
64
|
+
@sprite_batches << (batch && bytes.frozen? ? batch : SpriteBatch.new(bytes.frozen? ? bytes : bytes.dup.freeze, texture))
|
|
65
|
+
command(:sprite_batch, offset)
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Preserve the public flat-sprite inspection API; native packing does not
|
|
70
|
+
# materialize these arrays, while the software renderer can reuse them.
|
|
71
|
+
def sprites
|
|
72
|
+
@sprite_batches.each_index { |index| expand_sprite_batch(index) }
|
|
73
|
+
@sprites
|
|
74
|
+
end
|
|
75
|
+
def sprite_data = @sprites
|
|
76
|
+
def expand_sprite_batch(index)
|
|
77
|
+
@expanded_batches[index] ||= begin
|
|
78
|
+
batch = @sprite_batches.fetch(index)
|
|
79
|
+
texture = batch.texture
|
|
80
|
+
id = @textures.index(texture)
|
|
81
|
+
offset = @sprites.length
|
|
82
|
+
batch.bytes.unpack("f*").each_slice(24) do |values|
|
|
83
|
+
@sprites.push(*values[0, 8], id, values[20] * texture.width, values[21] * texture.height,
|
|
84
|
+
values[22] * texture.width, values[23] * texture.height)
|
|
85
|
+
end
|
|
86
|
+
[offset, batch.bytes.bytesize / SPRITE_INSTANCE_BYTES]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def triangle(points, color:)
|
|
91
|
+
raise ArgumentError, "three points required" unless points.length == 6
|
|
92
|
+
offset = @paths.length
|
|
93
|
+
@paths.push(*points, *color_values(color))
|
|
94
|
+
command(:triangle, offset)
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def shadow(x, y, width, height, color: "#0006", blur: 8, radius: 0)
|
|
99
|
+
raise ArgumentError, "blur must be positive" unless blur.positive?
|
|
100
|
+
# A small sequence of expanding translucent rounded rectangles approximates
|
|
101
|
+
# a Gaussian convolution without a temporary render target.
|
|
102
|
+
8.downto(1) do |step|
|
|
103
|
+
spread = blur * step / 8.0
|
|
104
|
+
alpha = Math.exp(-2.0 * (step / 8.0)**2) / 10.0
|
|
105
|
+
quad(x - spread, y - spread, width + 2 * spread, height + 2 * spread,
|
|
106
|
+
color: Color.parse(color).opacity(alpha), radius: radius + spread)
|
|
107
|
+
end
|
|
108
|
+
self
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def underline(x, y, width, color:, thickness: 1, wave: false)
|
|
112
|
+
return quad(x, y, width, thickness, color: color) unless wave
|
|
113
|
+
width.ceil.times do |i|
|
|
114
|
+
quad(x + i, y + Math.sin(i * Math::PI / 4) * thickness, 1, thickness, color: color)
|
|
115
|
+
end
|
|
116
|
+
self
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def clip(bounds)
|
|
120
|
+
@clips << (@clips.empty? ? bounds : @clips.last.intersect(bounds))
|
|
121
|
+
yield
|
|
122
|
+
ensure
|
|
123
|
+
@clips.pop
|
|
124
|
+
end
|
|
125
|
+
def current_clip = @clips.last
|
|
126
|
+
|
|
127
|
+
def layer(order)
|
|
128
|
+
@layers << order
|
|
129
|
+
yield
|
|
130
|
+
ensure
|
|
131
|
+
@layers.pop
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def each_command
|
|
135
|
+
return enum_for(__method__) unless block_given?
|
|
136
|
+
if @ordered
|
|
137
|
+
index = 0
|
|
138
|
+
while index < @commands.length
|
|
139
|
+
yield @commands[index], @commands[index + 1], @commands[index + 3]
|
|
140
|
+
index += 4
|
|
141
|
+
end
|
|
142
|
+
return
|
|
143
|
+
end
|
|
144
|
+
indices = (0...@commands.length).step(4).to_a
|
|
145
|
+
indices.sort_by! { |i| [@commands[i + 2], i] }
|
|
146
|
+
indices.each { |i| yield @commands[i], @commands[i + 1], @commands[i + 3] }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def color_values(value)
|
|
152
|
+
@colors.clear if @colors.length > 256
|
|
153
|
+
@colors[value] ||= Color.parse(value).to_a.freeze
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def command(kind, offset)
|
|
157
|
+
@ordered = false if @layers.last < @last_layer
|
|
158
|
+
@last_layer = @layers.last
|
|
159
|
+
@commands.push(kind, offset, @layers.last, @clips.last)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
require_relative "scene/sprite_batch"
|
|
166
|
+
require_relative "quad_packer"
|
|
167
|
+
require_relative "scene_renderer"
|