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.
Files changed (166) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +70 -0
  5. data/assets/fonts/Abel-Regular.ttf +0 -0
  6. data/assets/fonts/OFL.txt +93 -0
  7. data/docs/adr/000-template.md +24 -0
  8. data/docs/adr/001-native-gpu-backends.md +36 -0
  9. data/docs/adr/002-objective-c-aggregates.md +34 -0
  10. data/docs/adr/003-fiddle-call-boundary.md +33 -0
  11. data/docs/adr/README.md +10 -0
  12. data/docs/native.md +66 -0
  13. data/docs/process_pool.md +67 -0
  14. data/docs/text.md +90 -0
  15. data/docs/vector_and_list.md +52 -0
  16. data/examples/ffi_bench.rb +16 -0
  17. data/examples/linux_smoke.rb +53 -0
  18. data/examples/mac_event_check.rb +61 -0
  19. data/examples/mac_poll_bench.rb +24 -0
  20. data/examples/metal_state_check.rb +72 -0
  21. data/examples/native_bench.rb +18 -0
  22. data/examples/native_drop.rb +82 -0
  23. data/examples/native_font.rb +15 -0
  24. data/examples/native_ime.rb +50 -0
  25. data/examples/native_smoke.rb +75 -0
  26. data/examples/native_text_bench.rb +83 -0
  27. data/examples/native_watch.rb +25 -0
  28. data/examples/vulkan_triangle.rb +20 -0
  29. data/examples/word_count_worker.rb +8 -0
  30. data/lib/zaniah/anchored.rb +10 -0
  31. data/lib/zaniah/app.rb +130 -0
  32. data/lib/zaniah/bounds.rb +22 -0
  33. data/lib/zaniah/canvas.rb +15 -0
  34. data/lib/zaniah/color.rb +35 -0
  35. data/lib/zaniah/configuration.rb +5 -0
  36. data/lib/zaniah/corners.rb +7 -0
  37. data/lib/zaniah/data_compat.rb +25 -0
  38. data/lib/zaniah/device_pixels.rb +7 -0
  39. data/lib/zaniah/div.rb +5 -0
  40. data/lib/zaniah/edges.rb +7 -0
  41. data/lib/zaniah/element.rb +106 -0
  42. data/lib/zaniah/entity.rb +9 -0
  43. data/lib/zaniah/entity_context.rb +17 -0
  44. data/lib/zaniah/error.rb +5 -0
  45. data/lib/zaniah/ffi/com.rb +24 -0
  46. data/lib/zaniah/ffi/library.rb +29 -0
  47. data/lib/zaniah/ffi/objc.rb +86 -0
  48. data/lib/zaniah/ffi/struct/signature.rb +46 -0
  49. data/lib/zaniah/ffi/struct.rb +61 -0
  50. data/lib/zaniah/ffi/wayland.rb +140 -0
  51. data/lib/zaniah/frame_context.rb +14 -0
  52. data/lib/zaniah/geometry.rb +16 -0
  53. data/lib/zaniah/gpu/buffer.rb +23 -0
  54. data/lib/zaniah/gpu/frame_encoder.rb +43 -0
  55. data/lib/zaniah/gpu/instance_packing.rb +53 -0
  56. data/lib/zaniah/gpu/metal.rb +209 -0
  57. data/lib/zaniah/gpu/open_gl.rb +210 -0
  58. data/lib/zaniah/gpu/pipeline.rb +7 -0
  59. data/lib/zaniah/gpu/software.rb +126 -0
  60. data/lib/zaniah/gpu/texture.rb +33 -0
  61. data/lib/zaniah/gpu/vulkan.rb +233 -0
  62. data/lib/zaniah/gpu.rb +18 -0
  63. data/lib/zaniah/hsla.rb +14 -0
  64. data/lib/zaniah/image.rb +19 -0
  65. data/lib/zaniah/input/action_registry.rb +12 -0
  66. data/lib/zaniah/input/composition.rb +7 -0
  67. data/lib/zaniah/input/context_predicate.rb +87 -0
  68. data/lib/zaniah/input/dispatcher.rb +59 -0
  69. data/lib/zaniah/input/file_drop.rb +7 -0
  70. data/lib/zaniah/input/focus_handle.rb +24 -0
  71. data/lib/zaniah/input/key_down.rb +7 -0
  72. data/lib/zaniah/input/key_up.rb +7 -0
  73. data/lib/zaniah/input/keymap/binding.rb +9 -0
  74. data/lib/zaniah/input/keymap.rb +55 -0
  75. data/lib/zaniah/input/keystroke.rb +20 -0
  76. data/lib/zaniah/input/mouse_down.rb +7 -0
  77. data/lib/zaniah/input/mouse_move.rb +7 -0
  78. data/lib/zaniah/input/mouse_up.rb +7 -0
  79. data/lib/zaniah/input/scroll_wheel.rb +7 -0
  80. data/lib/zaniah/input/text_input.rb +7 -0
  81. data/lib/zaniah/input.rb +17 -0
  82. data/lib/zaniah/layout/available_space.rb +11 -0
  83. data/lib/zaniah/layout/engine.rb +214 -0
  84. data/lib/zaniah/layout/node.rb +36 -0
  85. data/lib/zaniah/layout/style.rb +19 -0
  86. data/lib/zaniah/layout/tree.rb +16 -0
  87. data/lib/zaniah/layout.rb +11 -0
  88. data/lib/zaniah/length.rb +14 -0
  89. data/lib/zaniah/length_units.rb +9 -0
  90. data/lib/zaniah/list/height_index.rb +68 -0
  91. data/lib/zaniah/list.rb +101 -0
  92. data/lib/zaniah/overlay.rb +10 -0
  93. data/lib/zaniah/platform/display.rb +7 -0
  94. data/lib/zaniah/platform/file_event.rb +7 -0
  95. data/lib/zaniah/platform/headless/window.rb +185 -0
  96. data/lib/zaniah/platform/linux/appearance_aware.rb +60 -0
  97. data/lib/zaniah/platform/linux/displays.rb +55 -0
  98. data/lib/zaniah/platform/linux/free_type.rb +80 -0
  99. data/lib/zaniah/platform/linux/watcher.rb +70 -0
  100. data/lib/zaniah/platform/linux/wayland_display_discovery.rb +60 -0
  101. data/lib/zaniah/platform/linux/wayland_window.rb +369 -0
  102. data/lib/zaniah/platform/linux/window.rb +418 -0
  103. data/lib/zaniah/platform/linux.rb +3 -0
  104. data/lib/zaniah/platform/mac/app.rb +67 -0
  105. data/lib/zaniah/platform/mac/core_text.rb +94 -0
  106. data/lib/zaniah/platform/mac/displays.rb +23 -0
  107. data/lib/zaniah/platform/mac/watcher.rb +63 -0
  108. data/lib/zaniah/platform/mac/window.rb +298 -0
  109. data/lib/zaniah/platform/mac.rb +19 -0
  110. data/lib/zaniah/platform/tui/input_decoder.rb +145 -0
  111. data/lib/zaniah/platform/tui/text_renderer.rb +40 -0
  112. data/lib/zaniah/platform/tui/window.rb +91 -0
  113. data/lib/zaniah/platform/windows/terminal.rb +127 -0
  114. data/lib/zaniah/platform/windows/watcher.rb +91 -0
  115. data/lib/zaniah/platform/windows/window.rb +407 -0
  116. data/lib/zaniah/platform/windows.rb +3 -0
  117. data/lib/zaniah/platform.rb +34 -0
  118. data/lib/zaniah/png.rb +118 -0
  119. data/lib/zaniah/point.rb +8 -0
  120. data/lib/zaniah/process_pool/worker.rb +7 -0
  121. data/lib/zaniah/process_pool.rb +239 -0
  122. data/lib/zaniah/process_wire.rb +127 -0
  123. data/lib/zaniah/quad_packer.rb +15 -0
  124. data/lib/zaniah/scene/sprite_batch.rb +7 -0
  125. data/lib/zaniah/scene.rb +167 -0
  126. data/lib/zaniah/scene_renderer.rb +8 -0
  127. data/lib/zaniah/size.rb +5 -0
  128. data/lib/zaniah/subscription.rb +14 -0
  129. data/lib/zaniah/svg/path.rb +134 -0
  130. data/lib/zaniah/svg/rasterizable.rb +186 -0
  131. data/lib/zaniah/svg.rb +295 -0
  132. data/lib/zaniah/task/cancelled.rb +7 -0
  133. data/lib/zaniah/task/timeout.rb +7 -0
  134. data/lib/zaniah/task.rb +51 -0
  135. data/lib/zaniah/task_executor.rb +105 -0
  136. data/lib/zaniah/text.rb +24 -0
  137. data/lib/zaniah/text_system/atlas/entry.rb +9 -0
  138. data/lib/zaniah/text_system/atlas.rb +102 -0
  139. data/lib/zaniah/text_system/atlas_cache.rb +146 -0
  140. data/lib/zaniah/text_system/font_db/face.rb +9 -0
  141. data/lib/zaniah/text_system/font_db.rb +193 -0
  142. data/lib/zaniah/text_system/glyph.rb +7 -0
  143. data/lib/zaniah/text_system/line_layout.rb +20 -0
  144. data/lib/zaniah/text_system/renderer.rb +196 -0
  145. data/lib/zaniah/text_system/shaper.rb +430 -0
  146. data/lib/zaniah/text_system/typesetter/copy_error.rb +9 -0
  147. data/lib/zaniah/text_system/typesetter.rb +168 -0
  148. data/lib/zaniah/text_system.rb +9 -0
  149. data/lib/zaniah/unicode.rb +13 -0
  150. data/lib/zaniah/uniform_list.rb +30 -0
  151. data/lib/zaniah/version.rb +5 -0
  152. data/lib/zaniah/view.rb +13 -0
  153. data/lib/zaniah.rb +29 -0
  154. data/sig/app.rbs +80 -0
  155. data/sig/elements.rbs +129 -0
  156. data/sig/gpu.rbs +108 -0
  157. data/sig/input.rbs +114 -0
  158. data/sig/layout.rbs +53 -0
  159. data/sig/native.rbs +88 -0
  160. data/sig/platform.rbs +94 -0
  161. data/sig/scene.rbs +52 -0
  162. data/sig/shaper.rbs +8 -0
  163. data/sig/text.rbs +137 -0
  164. data/sig/zaniah.rbs +120 -0
  165. data/tools/check_dependencies.rb +52 -0
  166. metadata +249 -0
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "rbconfig"
5
+
6
+ Dir.mktmpdir("zaniah-linux-") do |directory|
7
+ log = File.open(File.join(directory, "display.log"), "w")
8
+ ENV["LIBGL_ALWAYS_SOFTWARE"] = "1"
9
+ wayland = ARGV.include?("--wayland")
10
+ if wayland
11
+ ENV["XDG_RUNTIME_DIR"] = directory
12
+ ENV["WAYLAND_DISPLAY"] = "wayland-zaniah"
13
+ ENV.delete("DISPLAY")
14
+ pid = Process.spawn("weston", "--backend=headless-backend.so", "--renderer=gl", "--socket=wayland-zaniah", "--idle-time=0", out: log, err: log)
15
+ socket = File.join(directory, "wayland-zaniah")
16
+ else
17
+ ENV["ZANIAH_CHECK_INPUT"] = "1"
18
+ ENV["ZANIAH_CHECK_DPI"] = "1"
19
+ ENV.delete("WAYLAND_DISPLAY")
20
+ number = 90 + Process.pid % 100
21
+ ENV["DISPLAY"] = ":#{number}"
22
+ pid = Process.spawn("Xvfb", ENV["DISPLAY"], "-screen", "0", "1280x800x24", "+extension", "GLX", out: log, err: log)
23
+ socket = "/tmp/.X11-unix/X#{number}"
24
+ end
25
+ begin
26
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 10
27
+ until File.socket?(socket)
28
+ raise File.read(log.path) if Process.waitpid(pid, Process::WNOHANG)
29
+ raise "display startup timeout: #{File.read(log.path)}" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
30
+ sleep 0.05
31
+ end
32
+ if ARGV.include?("--ibus")
33
+ raise "IBus check requires dbus-run-session and X11" if wayland || !ENV["DBUS_SESSION_BUS_ADDRESS"]
34
+ ENV["XMODIFIERS"] = "@im=ibus"
35
+ ENV["LANG"] = "C.UTF-8"
36
+ system("ibus-daemon", "--daemonize", "--xim", "--panel=disable") or raise "IBus startup failed"
37
+ sleep 1
38
+ raise "native Japanese IME failed" unless system(RbConfig.ruby, File.join(__dir__, "native_ime.rb"))
39
+ ENV["XMODIFIERS"] = "@im=none"
40
+ end
41
+ success = system(RbConfig.ruby, File.join(__dir__, "native_smoke.rb"), "--gl", "--check")
42
+ raise "native rendering failed\n#{File.read(log.path)}" unless success
43
+ raise "native XDND transfer failed" unless wayland || system(RbConfig.ruby, File.join(__dir__, "native_drop.rb"))
44
+ raise "native watcher failed" unless system(RbConfig.ruby, File.join(__dir__, "native_watch.rb"))
45
+ if ARGV.include?("--editor")
46
+ raise "native editor integration failed" unless system(RbConfig.ruby, "--yjit", File.expand_path("../../canopus/tools/native_check.rb", __dir__), "--idle")
47
+ end
48
+ ensure
49
+ Process.kill("TERM", pid) rescue Errno::ESRCH
50
+ Process.wait(pid) rescue Errno::ECHILD
51
+ log.close
52
+ end
53
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "zaniah"
5
+ abort "Cocoa requires macOS" unless RUBY_PLATFORM.include?("darwin")
6
+
7
+ window = Zaniah::Platform.open_window(backend: :mac, width: 320, height: 200, title: "Cocoa input and resize check")
8
+ objc = Zaniah::FFI::ObjC
9
+ app = Zaniah::Platform::Mac::App.instance
10
+ events = []
11
+ window.on_input { |event| events << event }
12
+ window.tick
13
+
14
+ # Exercise the actual NSTextInputClient callbacks without changing the user's
15
+ # selected keyboard input source. Interactive IME conversion remains separate.
16
+ window.ime_state = Zaniah::Bounds.new(20, 30, 1, 18)
17
+ objc.send(window.view, "setMarkedText:selectedRange:replacementRange:", objc.string("にほん"),
18
+ [3, 0], [Zaniah::Platform::Mac::NS_NOT_FOUND, 0], args: [:pointer, :range, :range], result: :void)
19
+ raise "preedit callback missing" unless events.last.is_a?(Zaniah::Input::Composition) && events.last.text == "にほん"
20
+ candidate = -> { objc.send(window.view, "firstRectForCharacterRange:actualRange:", [0, 1], 0, args: [:range, :pointer], result: :rect) }
21
+ before = candidate.call
22
+ frame = objc.send(window.handle, "frame", result: :rect)
23
+ objc.send(window.handle, "setFrameOrigin:", [frame[0] + 25, frame[1] + 30], args: [:point], result: :void)
24
+ window.tick
25
+ moved = candidate.call
26
+ raise "IME candidate did not follow window move" unless (moved[0] - before[0] - 25).abs < 0.01 && (moved[1] - before[1] - 30).abs < 0.01
27
+ frame = objc.send(window.handle, "frame", result: :rect)
28
+ objc.send(window.handle, "setContentSize:", [400, 260], args: [:size], result: :void)
29
+ window.tick
30
+ raise "native resize was not delivered" unless window.content_size == Zaniah::Size.new(400, 260)
31
+ after_frame = objc.send(window.handle, "frame", result: :rect)
32
+ resized = candidate.call
33
+ raise "IME candidate did not follow resize" unless (resized[1] - moved[1] - (after_frame[1] - frame[1]) - 60).abs < 0.01 && resized.last(2) == [1, 18]
34
+ objc.send(window.view, "insertText:replacementRange:", objc.string("日本"),
35
+ [Zaniah::Platform::Mac::NS_NOT_FOUND, 0], args: [:pointer, :range], result: :void)
36
+ raise "commit callback missing" unless events.last.is_a?(Zaniah::Input::TextInput) && events.last.text == "日本"
37
+
38
+ number = objc.send(window.handle, "windowNumber", result: :long)
39
+ post_key = lambda do |type|
40
+ event = objc.send(objc.klass("NSEvent"), "keyEventWithType:location:modifierFlags:timestamp:windowNumber:context:characters:charactersIgnoringModifiers:isARepeat:keyCode:",
41
+ type, [0, 0], 0, Process.clock_gettime(Process::CLOCK_MONOTONIC), number, 0,
42
+ objc.string("a"), objc.string("a"), 0, 0,
43
+ args: [:ulong, :point, :ulong, :double, :long, :pointer, :pointer, :pointer, :bool, :uint])
44
+ objc.send(app.handle, "postEvent:atStart:", event, 0, args: [:pointer, :bool], result: :void)
45
+ end
46
+
47
+ # A standalone tick still drains events. The run loop also drains the entire
48
+ # queue before its tick callback, with no second blocking/nonblocking poll.
49
+ post_key.call(10)
50
+ window.tick
51
+ raise "standalone tick did not deliver key down" unless events.grep(Zaniah::Input::KeyDown).any? { |event| event.keystroke == "a" }
52
+ post_key.call(11)
53
+ post_key.call(10)
54
+ post_key.call(11)
55
+ window.on_tick do
56
+ raise "queued key events were delayed" unless events.grep(Zaniah::Input::KeyUp).count { |event| event.keystroke == "a" } == 2
57
+ raise "queued key down duplicated" unless events.grep(Zaniah::Input::KeyDown).count { |event| event.keystroke == "a" } == 2
58
+ window.close
59
+ end
60
+ window.run
61
+ puts "Cocoa: queued key down/up, standalone tick, run, Japanese composition/commit and moved/resized IME rectangles passed"
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Measures native event retrieval only, not whole-application idle CPU.
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+ require "zaniah"
6
+ require "json"
7
+ abort "Cocoa requires macOS" unless RUBY_PLATFORM.include?("darwin")
8
+
9
+ window = Zaniah::Platform.open_window(backend: :mac, width: 320, height: 200, title: "Cocoa event retrieval benchmark")
10
+ app = Zaniah::Platform::Mac::App.instance
11
+ 100.times { app.poll }
12
+ samples = Array.new(15) do
13
+ GC.start
14
+ allocations = GC.stat(:total_allocated_objects)
15
+ cpu = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
16
+ 200.times { app.poll }
17
+ elapsed = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) - cpu
18
+ [(GC.stat(:total_allocated_objects) - allocations) / 200.0, elapsed * 1_000_000 / 200]
19
+ end
20
+ puts JSON.generate(ruby: RUBY_DESCRIPTION, yjit: defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?,
21
+ samples: samples.length, polls_per_sample: 200,
22
+ median_objects_per_poll: samples.map(&:first).sort[samples.length / 2],
23
+ median_cpu_microseconds_per_poll: samples.map(&:last).sort[samples.length / 2])
24
+ window.close
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "zaniah"
5
+ abort "This check needs macOS and a graphical login" unless RUBY_PLATFORM.include?("darwin")
6
+
7
+ window = Zaniah::Platform.open_window(backend: :mac, gpu: :metal, width: 96, height: 72, title: "Metal state reuse check")
8
+ begin
9
+ device, scene, checks = window.device, Zaniah::Scene.new, 0
10
+ descriptor = device.instance_variable_get(:@render_pass)
11
+ check = lambda do |pixels, x, y, expected|
12
+ scale = window.scale_factor
13
+ width = (device.width * scale).round
14
+ actual = pixels.byteslice(((y * scale).to_i * width + (x * scale).to_i) * 4, 4).unpack("C4")
15
+ raise "pixel (#{x}, #{y}): #{actual.inspect}, expected #{expected.inspect}" unless actual.zip(expected).all? { |a, b| (a - b).abs <= 2 }
16
+ checks += 1
17
+ end
18
+ render = lambda do |clear|
19
+ device.render(scene, clear: clear)
20
+ raise "render-pass descriptor was not reused" unless descriptor == device.instance_variable_get(:@render_pass)
21
+ device.pixels
22
+ end
23
+
24
+ clear = +"#ff000080"
25
+ check.call(render.call(clear), 4, 4, [255, 0, 0, 128])
26
+ clear.replace("#00ff0080")
27
+ check.call(render.call(clear), 4, 4, [0, 255, 0, 128])
28
+ clear = [0.0, 0.0, 1.0, 1.0]
29
+ check.call(render.call(clear), 4, 4, [0, 0, 255, 255])
30
+ clear[0], clear[2] = 1.0, 0.0
31
+ check.call(render.call(clear), 4, 4, [255, 0, 0, 255])
32
+ begin
33
+ render.call(nil)
34
+ raise "nil clear color was accepted"
35
+ rescue ArgumentError
36
+ checks += 1
37
+ end
38
+
39
+ scene.clip(Zaniah::Bounds.new(-5, -5, 15, 15)) { scene.quad(0, 0, 96, 72, color: "#f00") }
40
+ scene.clip(Zaniah::Bounds.new(999, 999, 10, 10)) { scene.quad(0, 0, 96, 72, color: "#0f0") }
41
+ scene.quad(20, 20, 12, 12, color: "#00f")
42
+ scene.clip(Zaniah::Bounds.new(40, 4, 20, 12)) do
43
+ scene.clip(Zaniah::Bounds.new(48, 0, 6, 16)) { scene.quad(0, 0, 96, 72, color: "#0f0") }
44
+ end
45
+ pixels = render.call("#000")
46
+ check.call(pixels, 2, 2, [255, 0, 0, 255])
47
+ check.call(pixels, 11, 11, [0, 0, 0, 255])
48
+ check.call(pixels, 24, 24, [0, 0, 255, 255])
49
+ check.call(pixels, 50, 8, [0, 255, 0, 255])
50
+ check.call(pixels, 42, 8, [0, 0, 0, 255])
51
+ check.call(pixels, 55, 8, [0, 0, 0, 255])
52
+
53
+ texture = device.create_texture(1, 1, data: [255, 0, 0, 255].pack("C4"))
54
+ scene.clear.sprite(36, 28, 8, 8, texture: texture)
55
+ check.call(render.call("#000"), 40, 32, [255, 0, 0, 255])
56
+ texture.upload(0, 0, 1, 1, [0, 255, 0, 255].pack("C4"))
57
+ check.call(render.call("#000"), 40, 32, [0, 255, 0, 255])
58
+ scene.clear.quad(8, 8, 32, 32, color: "#00f")
59
+ scene.quad(8, 8, 32, 32, color: "#ff000080")
60
+ check.call(render.call("#000"), 16, 16, [128, 0, 127, 255])
61
+
62
+ [[120, 80], [64, 48]].each do |width, height|
63
+ device.resize(width, height)
64
+ scene.clear.quad(0, 0, width, height, color: "#123")
65
+ pixels = render.call("#000")
66
+ check.call(pixels, 2, 2, [17, 34, 51, 255])
67
+ check.call(pixels, width - 2, height - 2, [17, 34, 51, 255])
68
+ end
69
+ puts "Metal state reuse: #{checks} checks passed, scale=#{window.scale_factor}"
70
+ ensure
71
+ window.close
72
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "zaniah"
5
+
6
+ backend = RUBY_PLATFORM.include?("darwin") ? :mac : RUBY_PLATFORM.match?(/mingw|mswin/) ? :windows : :linux
7
+ window = Zaniah::Platform.open_window(backend: backend, gpu: ARGV.include?("--gl") ? :opengl : backend == :mac ? :metal : :opengl, width: 800, height: 600)
8
+ scene = Zaniah::Scene.new
9
+ 10_000.times { |i| scene.quad((i % 100) * 8, (i / 100) * 6, 7, 5, color: "#49c", radius: 1) }
10
+ window.draw { scene }
11
+ samples = 40.times.map do
12
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
13
+ window.request_frame
14
+ window.tick
15
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
16
+ end.drop(5).sort
17
+ puts "10,000 quads: median=#{samples[samples.length / 2].round(2)}ms, p95=#{samples[(samples.length * 0.95).floor].round(2)}ms, draw_calls=#{window.device.draw_calls}"
18
+ window.close
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "zaniah"
5
+
6
+ backend = RUBY_PLATFORM.include?("darwin") ? :mac : :linux
7
+ window = Zaniah::Platform.open_window(backend: backend, width: 320, height: 180, title: "Native file drop check")
8
+ events = []
9
+ window.on_input { |event| events << event }
10
+ expected = "/tmp/zaniah 日本.txt"
11
+ if backend == :mac
12
+ objc = Zaniah::FFI::ObjC
13
+ board = objc.send(objc.klass("NSPasteboard"), "pasteboardWithUniqueName")
14
+ objc.send(board, "setString:forType:", objc.string("file:///tmp/zaniah%20%E6%97%A5%E6%9C%AC.txt"), objc.string("public.file-url"), args: [:pointer, :pointer], result: :bool)
15
+ objc.subclass("ZaniahDropFixture", "NSObject") do |klass|
16
+ objc.method(klass, "draggingPasteboard", result: :pointer, encoding: "@@:") { board }
17
+ objc.method(klass, "draggingLocation", result: :point, encoding: "{CGPoint=dd}@:") { [40.0, 40.0] }
18
+ end
19
+ dragging = objc.new("ZaniahDropFixture")
20
+ accepted = objc.send(window.view, "performDragOperation:", dragging, args: [:pointer], result: :bool)
21
+ raise "Cocoa rejected file drop" if accepted.zero?
22
+ window.tooltip = "Native tooltip"
23
+ raise "tooltip not installed" unless objc.text(objc.send(window.view, "toolTip")) == "Native tooltip"
24
+ changes = []
25
+ window.on_appearance { |value| changes << value }
26
+ %w[NSAppearanceNameDarkAqua NSAppearanceNameAqua].each do |name|
27
+ # Exported symbol names are not their string values (DarkAqua/Aqua).
28
+ native = objc.send(objc.klass("NSAppearance"), "appearanceNamed:", objc.string(name.delete_prefix("NSAppearanceName")), args: [:pointer])
29
+ objc.send(window.view, "setAppearance:", native, args: [:pointer], result: :void)
30
+ window.tick
31
+ end
32
+ raise "appearance notification missing: #{changes.inspect}" unless changes.include?(:dark) && changes.include?(:light)
33
+ objc.send(window.view, "setAppearance:", 0, args: [:pointer], result: :void)
34
+ objc.release(dragging)
35
+ objc.send(board, "releaseGlobally", result: :void)
36
+ else
37
+ raise "run this protocol check under X11" unless window.is_a?(Zaniah::Platform::Linux::Window)
38
+ klass = Zaniah::Platform::Linux::Window
39
+ ptype, long, int = klass::P, klass::L, klass::I
40
+ source = klass.allocate
41
+ source.instance_variable_set(:@x, Zaniah::FFI::Library.new("libX11.so.6"))
42
+ source.instance_variable_set(:@atoms, {})
43
+ display = source.x(:XOpenDisplay, [ptype], ptype, 0)
44
+ source.instance_variable_set(:@display, display)
45
+ root = source.x(:XDefaultRootWindow, [ptype], long, display)
46
+ handle = source.x(:XCreateSimpleWindow, [ptype, long, int, int, int, int, int, long, long], long, display, root, 0, 0, 40, 40, 0, 0, 0)
47
+ source.instance_variable_set(:@handle, handle)
48
+ source.x(:XSetSelectionOwner, [ptype, long, long, long], int, display, source.atom("XdndSelection"), handle, 0)
49
+ source.send_client_message(window.handle, "XdndEnter", [handle, 5 << 24, source.atom("text/uri-list"), 0, 0])
50
+ source.send_client_message(window.handle, "XdndPosition", [handle, 0, (20 << 16) | 30, 0, source.atom("XdndActionCopy")])
51
+ source.send_client_message(window.handle, "XdndDrop", [handle, 0, 0, 0, 0])
52
+ finished, deadline = false, Process.clock_gettime(Process::CLOCK_MONOTONIC) + 3
53
+ until finished || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
54
+ window.tick
55
+ while source.x(:XPending, [ptype], int, display).positive?
56
+ event = "\0".b * 192
57
+ source.x(:XNextEvent, [ptype, ptype], int, display, event)
58
+ if event.unpack1("i") == 30
59
+ requestor, selection, target, property, time = event[40, 40].unpack("L!5")
60
+ warn "XDND request #{[requestor, selection, target, property, time].inspect}; target=#{window.handle}, source=#{handle}" if ENV["ZANIAH_DROP_DEBUG"]
61
+ source.property(requestor, property, target, "# file offer\r\nfile:///tmp/zaniah%20%E6%97%A5%E6%9C%AC.txt\r\n")
62
+ reply = "\0".b * 192
63
+ reply[0, 4] = [31].pack("i")
64
+ reply[24, 8] = [display.to_i].pack("J")
65
+ reply[32, 40] = [requestor, selection, target, property, time].pack("L!5")
66
+ source.x(:XSendEvent, [ptype, long, int, long, ptype], int, display, requestor, 0, 0, reply)
67
+ source.x(:XFlush, [ptype], int, display)
68
+ elsif event.unpack1("i") == 33 && event[40, 8].unpack1("L!") == source.atom("XdndFinished")
69
+ raise "XDND source did not receive success" unless event[64, 8].unpack1("L!") == 1
70
+ finished = true
71
+ end
72
+ end
73
+ sleep 0.005 unless finished
74
+ end
75
+ raise "XDND handshake timed out" unless finished
76
+ source.x(:XDestroyWindow, [ptype, long], int, display, handle)
77
+ source.x(:XCloseDisplay, [ptype], int, display)
78
+ end
79
+ drop = events.find { |event| event.is_a?(Zaniah::Input::FileDrop) }
80
+ raise "file drop missing or corrupted: #{events.inspect}" unless drop&.paths == [expected]
81
+ puts "#{window.class}: #{drop.inspect}"
82
+ window.close
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "zaniah"
5
+ require "alhena"
6
+
7
+ provider_name = ARGV.delete("--freetype") ? :freetype : :coretext
8
+ require "zaniah/platform/#{provider_name == :freetype ? 'linux/free_type' : 'mac/core_text'}"
9
+ provider = provider_name == :freetype ? Zaniah::Platform::Linux::FreeType.new : Zaniah::Platform::Mac::CoreText.new
10
+ font = Alhena::Font.open(ARGV.fetch(0))
11
+ bitmap = provider.rasterize(font, font.glyph_id("A"), size: 32)
12
+ raise "empty native font bitmap" unless bitmap.width.positive? && bitmap.height.positive? && bitmap.coverage.bytes.any?(&:positive?)
13
+ puts "#{provider_name}: #{bitmap.width}x#{bitmap.height}, bearing=#{bitmap.left},#{bitmap.top}"
14
+ puts bitmap.to_ascii
15
+ provider.close
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Run inside a disposable D-Bus/X11 session with IBus and Mozc installed.
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+ require "zaniah"
6
+ require "open3"
7
+
8
+ window = Zaniah::Platform.open_window(backend: :linux, display_server: :x11, width: 400, height: 240, title: "IBus Japanese input check")
9
+ events = []
10
+ window.on_input { |event| events << event }
11
+ window.ime_state = Zaniah::Bounds.new(20, 30, 1, 20)
12
+ system("xdotool", "windowfocus", window.handle.to_s) or raise "focus failed"
13
+ ic = window.instance_variable_get(:@ic)
14
+ raise "XIM input context unavailable" unless ic
15
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1
16
+ while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
17
+ window.tick
18
+ sleep 0.01
19
+ end
20
+ selector = Process.spawn("ibus", "engine", "mozc-on")
21
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
22
+ until (result = Process.waitpid2(selector, Process::WNOHANG))
23
+ raise "IBus engine selection timed out" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
24
+ window.tick
25
+ sleep 0.01
26
+ end
27
+ current, = Open3.capture2e("ibus", "engine")
28
+ # IBus CLI may return 1 after success when Mozc's layout is "default".
29
+ # Its active engine, not setxkbmap's exit status, determines readiness.
30
+ raise "Mozc engine selection failed: #{result.last.inspect}, current=#{current.inspect}" unless current.strip == "mozc-on"
31
+ sender = Process.spawn("xdotool", "type", "--delay", "100", "nihon")
32
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 3
33
+ until Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
34
+ window.tick
35
+ sleep 0.01
36
+ end
37
+ Process.wait(sender)
38
+ sender = Process.spawn("xdotool", "key", "space", "Return")
39
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
40
+ until Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
41
+ window.tick
42
+ sleep 0.01
43
+ end
44
+ Process.wait(sender)
45
+ preedit = events.grep(Zaniah::Input::Composition).map(&:text)
46
+ committed = events.grep(Zaniah::Input::TextInput).map(&:text).join
47
+ raise "Japanese preedit missing: #{events.inspect}" unless preedit.any? { |text| text.match?(/[にほ日]/) }
48
+ raise "Japanese commit missing or duplicated: #{events.inspect}" unless ["日本", "にほん"].include?(committed)
49
+ puts "IBus/Mozc: preedit=#{preedit.inspect}, committed=#{committed.inspect}"
50
+ window.close
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "zaniah"
5
+
6
+ backend = RUBY_PLATFORM.include?("darwin") ? :mac : RUBY_PLATFORM.match?(/mingw|mswin/) ? :windows : :linux
7
+ gpu = ARGV.include?("--gl") ? :opengl : backend == :mac ? :metal : :opengl
8
+ window = Zaniah::Platform.open_window(backend: backend, gpu: gpu, width: 640, height: 400, title: "Zaniah UI native rendering")
9
+ scene = Zaniah::Scene.new
10
+ scene.quad(0, 0, 640, 400, color: "#18202b")
11
+ scene.quad(24, 24, 280, 130, color: "#46a0d8", radius: [20, 8, 20, 8], border_width: 3, border_color: "#fff")
12
+ scene.quad(180, 80, 260, 130, color: "#d35b7790", radius: 24)
13
+ texture = window.device.create_texture(2, 2, data: [255, 255, 255, 255, 80, 140, 255, 255, 80, 220, 160, 255, 0, 0, 0, 255].pack("C*"))
14
+ scene.sprite(28, 210, 120, 120, texture: texture)
15
+ scene.triangle([330, 260, 450, 340, 530, 220], color: "#f8ca72")
16
+ window.draw { scene }
17
+ window.on_input do |event|
18
+ puts event.inspect
19
+ window.close if event.is_a?(Zaniah::Input::KeyDown) && event.keystroke == "esc"
20
+ end
21
+ if ARGV.include?("--check")
22
+ screens = window.displays
23
+ raise "native display enumeration failed: #{screens.inspect}" unless screens.any? { |screen| screen.bounds.width.positive? && screen.scale_factor.positive? }
24
+ puts "Displays: #{screens.inspect}"
25
+ events = []
26
+ window.on_input { |event| events << event }
27
+ if backend == :mac
28
+ objc = Zaniah::FFI::ObjC
29
+ objc.send(window.view, "setMarkedText:selectedRange:replacementRange:", objc.string("にほん"), [3, 0], [(1 << 63) - 1, 0], args: [:pointer, :range, :range], result: :void)
30
+ raise "marked text callback failed" unless events.last.is_a?(Zaniah::Input::Composition) && events.last.text == "にほん"
31
+ objc.send(window.view, "insertText:replacementRange:", objc.string("日本"), [(1 << 63) - 1, 0], args: [:pointer, :range], result: :void)
32
+ raise "committed text callback failed" unless events.last.is_a?(Zaniah::Input::TextInput) && events.last.text == "日本"
33
+ window.ime_state = Zaniah::Bounds.new(20, 30, 1, 18)
34
+ candidate = objc.send(window.view, "firstRectForCharacterRange:actualRange:", [0, 1], 0, args: [:range, :pointer], result: :rect)
35
+ raise "IME candidate rectangle failed" unless candidate.last(2) == [1, 18]
36
+ elsif backend == :linux && window.is_a?(Zaniah::Platform::Linux::Window) && ENV["ZANIAH_CHECK_INPUT"]
37
+ system("xdotool", "windowfocus", window.handle.to_s, "key", "a", "mousemove", "--window", window.handle.to_s, "40", "40", "click", "1", "click", "5") or raise "xdotool input failed"
38
+ 3.times { window.tick }
39
+ raise "X11 keyboard text missing: #{events.inspect}" unless events.any? { |event| event.is_a?(Zaniah::Input::TextInput) && event.text == "a" }
40
+ raise "X11 mouse button missing" unless events.any? { |event| event.is_a?(Zaniah::Input::MouseDown) }
41
+ raise "X11 scroll-down direction reversed" unless events.any? { |event| event.is_a?(Zaniah::Input::ScrollWheel) && event.delta.y.positive? }
42
+ end
43
+ 3.times { window.request_frame; window.tick }
44
+ output = ARGV.find { |argument| argument.end_with?(".png") }
45
+ window.write_png(output) if output
46
+ scale = window.scale_factor
47
+ pixels = window.device.pixels
48
+ pixel_width = (window.content_size.width * scale).round
49
+ actual = pixels.byteslice(((40 * scale).to_i * pixel_width + (40 * scale).to_i) * 4, 4).unpack("C4")
50
+ expected = [70, 160, 216, 255]
51
+ raise "GPU pixel mismatch: #{actual.inspect}" unless actual.zip(expected).all? { |a, b| (a - b).abs <= 2 }
52
+ puts "#{window.class}/#{gpu}: pixel=#{actual.inspect}, draw_calls=#{window.device.draw_calls}, scale=#{scale}"
53
+ if backend == :linux && window.is_a?(Zaniah::Platform::Linux::Window) && ENV["ZANIAH_CHECK_DPI"]
54
+ # linux_smoke owns this disposable X server. Restore its resource property.
55
+ root, property = window.instance_variable_get(:@root), window.atom("RESOURCE_MANAGER")
56
+ previous = window.read_property(root, property, delete: false)
57
+ begin
58
+ changed = previous.gsub(/^Xft\.dpi:.*$/, "") + "\nXft.dpi: 192\n"
59
+ window.property(root, property, window.atom("STRING"), changed)
60
+ window.x(:XFlush, [Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT, window.display)
61
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1
62
+ until window.scale_factor == 2
63
+ raise "X11 DPI change was not delivered" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
64
+ window.tick
65
+ end
66
+ puts "X11 live DPI: #{scale} -> #{window.scale_factor}"
67
+ ensure
68
+ window.property(root, property, window.atom("STRING"), previous)
69
+ window.x(:XFlush, [Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT, window.display)
70
+ end
71
+ end
72
+ window.close
73
+ else
74
+ window.run
75
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "zaniah"
5
+ require "zaniah/gpu/instance_packing"
6
+
7
+ backend = RUBY_PLATFORM.include?("darwin") ? :mac : RUBY_PLATFORM.match?(/mingw|mswin/) ? :windows : :linux
8
+ gpu = ARGV.include?("--gl") ? :opengl : backend == :mac ? :metal : :opengl
9
+ font_path = File.expand_path("../assets/fonts/Abel-Regular.ttf", __dir__)
10
+ database = Zaniah::TextSystem::FontDB.new(paths: [font_path])
11
+ system = Zaniah::TextSystem::Renderer.new(font_db: database)
12
+ window = Zaniah::Platform.open_window(backend: backend, gpu: gpu, width: 800, height: 640, title: "Cached 10,000-glyph text check")
13
+ system.scale_factor = window.scale_factor
14
+ texts = Array.new(100) { |i| (format("%03d", i) + "Abcdefghij" * 10).byteslice(0, 100).freeze }
15
+ scene = Zaniah::Scene.new
16
+ window.draw do
17
+ scene.clear
18
+ texts.each_with_index do |text, i|
19
+ system.paint_line(scene, system.layout_line(text, size: 7), x: 4, y: 8 + i * 6, color: "#fff")
20
+ end
21
+ scene
22
+ end
23
+
24
+ samples, allocations = [], []
25
+ 40.times do |i|
26
+ before = GC.stat(:total_allocated_objects)
27
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
28
+ window.request_frame
29
+ window.tick
30
+ elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
31
+ objects = GC.stat(:total_allocated_objects) - before
32
+ if i >= 5
33
+ samples << elapsed
34
+ allocations << objects
35
+ end
36
+ end
37
+ bytes, batches = Zaniah::GPU::InstancePacking.pack(scene)
38
+ raise "expected 10,000 packed glyphs" unless bytes.bytesize == 10_000 * 96
39
+ pixels = window.device.pixels
40
+ ink, offset = 0, 0
41
+ while offset < pixels.bytesize
42
+ ink += 1 if pixels.getbyte(offset) > 20
43
+ offset += 4
44
+ end
45
+ raise "native packed text readback was empty" unless ink > 1000
46
+ output = ARGV.find { |argument| argument.end_with?(".png") }
47
+ window.write_png(output) if output
48
+ calls = 0
49
+ trace = TracePoint.new(:c_call) { |event| calls += 1 if event.method_id == :call && event.defined_class == Fiddle::Function }
50
+ trace.enable { window.request_frame; window.tick }
51
+ puts "#{gpu}: 10,000 glyphs, present median=#{samples.sort[samples.length / 2].round(3)}ms p95=#{samples.sort[(samples.length * 0.95).floor].round(3)}ms allocations=#{allocations.sort[allocations.length / 2]} Fiddle calls=#{calls}"
52
+ puts "readback=#{ink} ink pixels, #{bytes.bytesize} instance bytes, batches=#{batches.length}, draw_calls=#{window.device.draw_calls}, scale=#{window.scale_factor}"
53
+ require "digest"
54
+ puts "rgba_sha256=#{Digest::SHA256.hexdigest(pixels)}"
55
+ if ARGV.include?("--allocations")
56
+ require "objspace"
57
+ GC.start
58
+ generation = GC.count
59
+ previously_disabled = GC.disable
60
+ begin
61
+ ObjectSpace.trace_object_allocations_start
62
+ window.request_frame
63
+ window.tick
64
+ ObjectSpace.trace_object_allocations_stop
65
+ sites = Hash.new(0)
66
+ ObjectSpace.each_object do |object|
67
+ file = ObjectSpace.allocation_sourcefile(object)
68
+ next unless file && ObjectSpace.allocation_generation(object) == generation
69
+ sites[[file, ObjectSpace.allocation_sourceline(object), object.class]] += 1
70
+ end
71
+ sites.sort_by { |_, count| -count }.first(25).each { |(file, line, type), count| puts "#{count} #{type} #{file}:#{line}" }
72
+ ensure
73
+ ObjectSpace.trace_object_allocations_stop
74
+ ObjectSpace.trace_object_allocations_clear
75
+ GC.enable unless previously_disabled
76
+ end
77
+ end
78
+ system.close
79
+ window.close
80
+ if ARGV.include?("--budget")
81
+ raise "native cached text frame exceeds 8ms median" if samples.sort[samples.length / 2] > 8
82
+ raise "native cached text frame exceeds 200 objects" if allocations.sort[allocations.length / 2] >= 200
83
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ require "zaniah"
5
+ require "tmpdir"
6
+
7
+ platform = RUBY_PLATFORM.include?("darwin") ? :mac : RUBY_PLATFORM.match?(/mingw|mswin/) ? :windows : :linux
8
+ require "zaniah/platform/#{platform}/watcher"
9
+ Dir.mktmpdir("zaniah-watch-") do |directory|
10
+ watcher = Zaniah::Platform.const_get(platform.to_s.capitalize)::Watcher.new(directory)
11
+ begin
12
+ path = File.join(directory, "native-event.txt")
13
+ File.write(path, "filesystem notification")
14
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
15
+ events = []
16
+ loop do
17
+ events.concat(watcher.poll(timeout: 0.05))
18
+ break if events.any? { |event| event.path&.end_with?("native-event.txt") }
19
+ raise "native watcher received no file event: #{events.inspect}" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
20
+ end
21
+ puts events.inspect
22
+ ensure
23
+ watcher.close
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
4
+ $LOAD_PATH.unshift(File.expand_path("../../electra/lib", __dir__))
5
+ require "zaniah"
6
+ require "zaniah/gpu/vulkan"
7
+ require_relative "../../electra/examples/shaders"
8
+
9
+ device = Zaniah::GPU::Vulkan.new(width: 128, height: 128)
10
+ begin
11
+ pixels = device.render_triangle(vertex_spirv: ExampleShaders.triangle_vertex, fragment_spirv: ExampleShaders.triangle_fragment)
12
+ center = pixels.byteslice((64 * 128 + 64) * 4, 4).unpack("C4")
13
+ outside = pixels.byteslice(0, 4).unpack("C4")
14
+ raise "Vulkan triangle pixel mismatch: #{center.inspect}" unless center.zip([255, 64, 0, 255]).all? { |actual, expected| (actual - expected).abs <= 1 }
15
+ raise "Vulkan clear pixel mismatch: #{outside.inspect}" unless outside == [0, 0, 0, 255]
16
+ device.write_png(ARGV.first) if ARGV.first
17
+ puts "Vulkan #{device.device_name}: triangle=#{center.inspect}, clear=#{outside.inspect}"
18
+ ensure
19
+ device.release
20
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Standalone, fixed entrypoint loaded by ProcessPool in each spawned Ruby.
4
+ module WordCountWorker
5
+ def self.call(data)
6
+ {"words" => data.fetch("text").split.length, "pid" => Process.pid}
7
+ end
8
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ class Anchored < Div
5
+ def initialize(anchor:, offset: Point.new(0, 0))
6
+ super()
7
+ style(position: :absolute, left: anchor.x + offset.x, top: anchor.y + offset.y)
8
+ end
9
+ end
10
+ end