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,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../ffi/library"
4
+ require_relative "../file_event"
5
+
6
+ module Zaniah
7
+ module Platform
8
+ module Linux
9
+ class Watcher
10
+ I, P = Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP
11
+ def initialize(paths, latency: 0.05)
12
+ @lib = FFI::Library.new(nil)
13
+ fd = @lib.fn(:inotify_init1, [I], I).call(0x800 | 0x80000)
14
+ raise SystemCallError.new("inotify_init1", Fiddle.last_error) if fd.negative?
15
+ @io, @directories, @moves, @closed = IO.for_fd(fd), {}, {}, false
16
+ paths = Array(paths).map { |path| File.expand_path(path) }
17
+ raise ArgumentError, "watch paths required" if paths.empty?
18
+ paths.each { |path| add_tree(File.directory?(path) ? path : File.dirname(path)) }
19
+ end
20
+ def add_tree(path)
21
+ ([path] + Dir.glob(File.join(path, "**", "*"), File::FNM_DOTMATCH).select { |entry| File.basename(entry) != "." && File.directory?(entry) && !File.symlink?(entry) }).each do |directory|
22
+ descriptor = @lib.fn(:inotify_add_watch, [I, P, I], I).call(@io.fileno, directory, 0x00000fff)
23
+ raise SystemCallError.new("inotify_add_watch #{directory}", Fiddle.last_error) if descriptor.negative?
24
+ @directories[descriptor] = directory
25
+ end
26
+ end
27
+ def poll(timeout: 0)
28
+ return [] if @closed || !IO.select([@io], nil, nil, timeout)
29
+ events = []
30
+ while (bytes = @io.read_nonblock(65_536, exception: false)).is_a?(String)
31
+ offset = 0
32
+ while offset + 16 <= bytes.bytesize
33
+ descriptor, mask, cookie, length = bytes.byteslice(offset, 16).unpack("iIII")
34
+ name = bytes.byteslice(offset + 16, length).split("\0", 2).first.to_s
35
+ offset += 16 + length
36
+ if (mask & 0x4000) != 0
37
+ events << FileEvent.new(:overflow, nil, nil)
38
+ next
39
+ end
40
+ directory = @directories[descriptor]
41
+ next unless directory
42
+ path = name.empty? ? directory : File.join(directory, name)
43
+ if (mask & 0x8000) != 0
44
+ @directories.delete(descriptor)
45
+ elsif (mask & 0x40) != 0
46
+ @moves[cookie] = path
47
+ events << FileEvent.new(:deleted, path, nil)
48
+ elsif (mask & 0x80) != 0
49
+ old = @moves.delete(cookie)
50
+ events << FileEvent.new(old ? :renamed : :created, path, old)
51
+ elsif (mask & 0x300) != 0
52
+ events << FileEvent.new((mask & 0x200) != 0 ? :deleted : :created, path, nil)
53
+ elsif (mask & (2 | 4 | 8 | 0x400 | 0x800)) != 0
54
+ events << FileEvent.new(:modified, path, nil)
55
+ end
56
+ add_tree(path) if (mask & 0x40000000) != 0 && (mask & 0x180) != 0 && File.directory?(path)
57
+ end
58
+ end
59
+ @moves.clear
60
+ events.uniq
61
+ end
62
+ def close
63
+ return if @closed
64
+ @closed = true
65
+ @io.close
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../ffi/wayland"
4
+
5
+ module Zaniah
6
+ module Platform
7
+ module Linux
8
+ module WaylandDisplayDiscovery
9
+ module_function
10
+
11
+ def read
12
+ ptype, int, uint = Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_UINT
13
+ connection, outputs, manager = FFI::Wayland.new, {}, nil
14
+ registry = connection.request(connection.display, 1, 0, new_interface: "wl_registry", version: 1)
15
+ connection.listen(registry, [
16
+ [[uint, ptype, uint], ->(_registry, id, name, version) do
17
+ name = name.to_s
18
+ if name == "wl_output"
19
+ output = connection.request(registry, 0, id, name, [version, 2].min, 0, new_interface: name, version: [version, 2].min)
20
+ state = outputs[id] = {proxy: output, x: 0, y: 0, width: 0, height: 0, scale: 1, name: "Display #{id}"}
21
+ connection.listen(output, [
22
+ [[int, int, int, int, int, ptype, ptype, int], ->(_output, x, y, _mw, _mh, _subpixel, make, model, transform) { state.merge!(x: x, y: y, name: "#{make.to_s} #{model.to_s}", transform: transform) }],
23
+ [[uint, int, int, int], ->(_output, flags, width, height, _refresh) { state.merge!(width: width, height: height) if (flags & 1) != 0 }],
24
+ [[], nil], [[int], ->(_output, scale) { state[:scale] = [scale, 1].max }]
25
+ ])
26
+ elsif name == "zxdg_output_manager_v1"
27
+ manager = connection.request(registry, 0, id, name, [version, 3].min, 0, new_interface: name, version: [version, 3].min)
28
+ end
29
+ end],
30
+ [[uint], ->(_registry, id) { outputs.delete(id) }]
31
+ ])
32
+ connection.roundtrip
33
+ connection.roundtrip
34
+ if manager
35
+ outputs.each_value do |state|
36
+ output = connection.request(manager, 1, 0, state[:proxy], new_interface: "zxdg_output_v1")
37
+ connection.listen(output, [
38
+ [[int, int], ->(_output, x, y) { state[:logical_position] = [x, y] }],
39
+ [[int, int], ->(_output, width, height) { state[:logical_size] = [width, height] }],
40
+ [[], nil], [[ptype], ->(_output, name) { state[:name] = name.to_s }], [[ptype], nil]
41
+ ])
42
+ end
43
+ connection.roundtrip
44
+ end
45
+ outputs.map do |id, state|
46
+ scale = state[:scale]
47
+ x, y = state[:logical_position] || [state[:x] / scale.to_f, state[:y] / scale.to_f]
48
+ width, height = state[:logical_size] || [state[:width] / scale.to_f, state[:height] / scale.to_f]
49
+ if !state[:logical_size] && state.fetch(:transform, 0).odd?
50
+ width, height = height, width
51
+ end
52
+ Display.new(id, state[:name], Bounds.new(x, y, width, height), scale, false)
53
+ end
54
+ ensure
55
+ connection&.close
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,369 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../ffi/wayland"
4
+ require_relative "../../gpu/open_gl"
5
+ require_relative "appearance_aware"
6
+ require "open3"
7
+ require "uri"
8
+
9
+ module Zaniah
10
+ module Platform
11
+ module Linux
12
+ class WaylandWindow < Headless::Window
13
+ include AppearanceAware
14
+ def displays = Linux.displays(display_server: :wayland)
15
+ P, I, U, V = Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_UINT, Fiddle::TYPE_VOID
16
+ GLOBALS = {"wl_compositor" => 4, "wl_shm" => 1, "wl_seat" => 5, "wl_output" => 2,
17
+ "wl_data_device_manager" => 3, "xdg_wm_base" => 1,
18
+ "zwp_text_input_manager_v3" => 1, "zxdg_decoration_manager_v1" => 1}.freeze
19
+ attr_reader :handle
20
+ def initialize(gpu: :opengl, **options)
21
+ super(**options)
22
+ @connection, @globals, @outputs, @offers = FFI::Wayland.new, {}, {}, {}
23
+ @offer_actions = {}
24
+ @entered_outputs = []
25
+ @position, @modifiers, @serial = Point.new(0, 0), [], 0
26
+ registry = @connection.request(@connection.display, 1, 0, new_interface: "wl_registry", version: 1)
27
+ @connection.listen(registry, [
28
+ [[U, P, U], ->(_registry, name, interface, version) { registry_global(registry, name, interface.to_s, version) }],
29
+ [[U], ->(_registry, name) { @outputs.delete(name) }]
30
+ ])
31
+ @connection.roundtrip
32
+ %w[wl_compositor xdg_wm_base].each { |name| raise Error, "Wayland compositor lacks #{name}" unless @globals[name] }
33
+ @handle = @connection.request(@globals["wl_compositor"], 0, 0, new_interface: "wl_surface")
34
+ @connection.listen(@handle, [
35
+ [[P], ->(_surface, output) { @entered_outputs |= [output.to_i]; output_scale }],
36
+ [[P], ->(_surface, output) { @entered_outputs.delete(output.to_i); output_scale }]
37
+ ])
38
+ @shell_surface = @connection.request(@globals["xdg_wm_base"], 2, 0, @handle, new_interface: "xdg_surface")
39
+ @connection.listen(@shell_surface, [[[U], ->(surface, serial) { @connection.request(surface, 4, serial); @configured = true; request_frame }]])
40
+ @toplevel = @connection.request(@shell_surface, 1, 0, new_interface: "xdg_toplevel")
41
+ @connection.listen(@toplevel, [
42
+ [[I, I, P], ->(_toplevel, width, height, _states) { resize(width, height) if width.positive? && height.positive? }],
43
+ [[], ->(_toplevel) { @close_requested = true }]
44
+ ])
45
+ @connection.request(@toplevel, 2, @title)
46
+ @connection.request(@toplevel, 3, "org.zaniah.app")
47
+ if (manager = @globals["zxdg_decoration_manager_v1"])
48
+ @decoration = @connection.request(manager, 1, 0, @toplevel, new_interface: "zxdg_toplevel_decoration_v1")
49
+ @connection.listen(@decoration, [[[U], nil]])
50
+ @connection.request(@decoration, 1, 2)
51
+ end
52
+ setup_text_input
53
+ setup_clipboard
54
+ @connection.request(@handle, 6)
55
+ @connection.roundtrip
56
+ raise Error, "Wayland did not configure the initial surface" unless @configured
57
+ @device.release
58
+ @device = create_device(gpu)
59
+ rescue StandardError, LoadError
60
+ cleanup_native
61
+ raise
62
+ end
63
+ def registry_global(registry, name, interface, version)
64
+ return unless GLOBALS.key?(interface)
65
+ object = @connection.request(registry, 0, name, interface, [version, GLOBALS[interface]].min, 0,
66
+ new_interface: interface, version: [version, GLOBALS[interface]].min)
67
+ if interface == "wl_output"
68
+ @outputs[name] = [object, 1]
69
+ @connection.listen(object, [
70
+ [[I, I, I, I, I, P, P, I], nil], [[U, I, I, I], nil], [[], nil],
71
+ [[I], ->(_output, scale) { @outputs[name][1] = scale; output_scale if @handle }]
72
+ ])
73
+ else
74
+ @globals[interface] = object
75
+ end
76
+ case interface
77
+ when "xdg_wm_base"
78
+ @connection.listen(object, [[[U], ->(shell, serial) { @connection.request(shell, 3, serial) }]])
79
+ when "wl_seat"
80
+ @connection.listen(object, [[[U], ->(seat, capabilities) { seat_capabilities(seat, capabilities) }], [[P], nil]])
81
+ end
82
+ end
83
+ def output_scale
84
+ active = @outputs.values.select { |value| @entered_outputs.include?(value[0].to_i) }
85
+ return if active.empty? || !@handle
86
+ scale = [active.map(&:last).max, 1].max
87
+ return if scale == @scale_factor
88
+ @scale_factor = scale
89
+ @connection.request(@handle, 8, scale)
90
+ resize(content_size.width, content_size.height)
91
+ end
92
+ def resize(width, height)
93
+ if @egl_window
94
+ @wayland_egl.fn(:wl_egl_window_resize, [P, I, I, I, I], V).call(@egl_window, (width * @scale_factor).round, (height * @scale_factor).round, 0, 0)
95
+ end
96
+ super
97
+ end
98
+ def create_device(backend)
99
+ raise ArgumentError, "Wayland supports :opengl" unless [:gl, :opengl].include?(backend)
100
+ @egl, @wayland_egl = FFI::Library.new("libEGL.so.1"), FFI::Library.new("libwayland-egl.so.1")
101
+ @egl_display = @egl.fn(:eglGetDisplay, [P], P).call(@connection.display)
102
+ major, minor = [0].pack("i"), [0].pack("i")
103
+ egl_check(@egl.fn(:eglInitialize, [P, P, P], U).call(@egl_display, major, minor), "eglInitialize")
104
+ egl_check(@egl.fn(:eglBindAPI, [U], U).call(0x30A2), "eglBindAPI OpenGL")
105
+ attrs = [0x3024, 8, 0x3023, 8, 0x3022, 8, 0x3021, 8, 0x3033, 4, 0x3040, 8, 0x3038].pack("i*")
106
+ config, count = [0].pack("J"), [0].pack("i")
107
+ egl_check(@egl.fn(:eglChooseConfig, [P, P, P, I, P], U).call(@egl_display, attrs, config, 1, count), "eglChooseConfig")
108
+ raise Error, "no EGL OpenGL window configuration" if count.unpack1("i").zero?
109
+ config = config.unpack1("J")
110
+ attributes = [0x3098, 3, 0x30fb, 3, 0x30fd, 1, 0x3038].pack("i*")
111
+ @egl_context = @egl.fn(:eglCreateContext, [P, P, P, P], P).call(@egl_display, config, 0, attributes)
112
+ raise Error, "EGL OpenGL 3.3 core context unavailable" if @egl_context.null?
113
+ @egl_window = @wayland_egl.fn(:wl_egl_window_create, [P, I, I], P).call(@handle, (content_size.width * @scale_factor).round, (content_size.height * @scale_factor).round)
114
+ @egl_surface = @egl.fn(:eglCreateWindowSurface, [P, P, P, P], P).call(@egl_display, config, @egl_window, 0)
115
+ raise Error, "eglCreateWindowSurface failed" if @egl_surface.null?
116
+ make_current
117
+ @egl.fn(:eglSwapInterval, [P, I], U).call(@egl_display, 1)
118
+ GPU::OpenGL.new(self, library: FFI::Library.new("libGL.so.1"), resolver: ->(name) { @egl.fn(:eglGetProcAddress, [P], P).call(name) })
119
+ end
120
+ def egl_check(value, name)
121
+ raise Error, "#{name}: EGL error 0x#{@egl.fn(:eglGetError, [], U).call.to_s(16)}" if value.zero?
122
+ end
123
+ def make_current = egl_check(@egl.fn(:eglMakeCurrent, [P] * 4, U).call(@egl_display, @egl_surface, @egl_surface, @egl_context), "eglMakeCurrent")
124
+ def swap_buffers = egl_check(@egl.fn(:eglSwapBuffers, [P, P], U).call(@egl_display, @egl_surface), "eglSwapBuffers")
125
+ def title=(title)
126
+ super
127
+ @connection.request(@toplevel, 2, title) if @toplevel
128
+ end
129
+ def tick
130
+ return if closed?
131
+ @connection.poll
132
+ poll_appearance
133
+ return close if @close_requested
134
+ finish_drop if @pending_drop
135
+ if @repeat_key && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @repeat_at
136
+ keyboard_key(@repeat_key, 1, held: true)
137
+ @repeat_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1.0 / @repeat_rate
138
+ end
139
+ super
140
+ end
141
+ def run
142
+ until closed?
143
+ @connection.poll(timeout: dirty? ? 0 : 0.05)
144
+ tick
145
+ end
146
+ end
147
+ def seat_capabilities(seat, capabilities)
148
+ if (capabilities & 1) != 0 && !@pointer
149
+ @pointer = @connection.request(seat, 0, 0, new_interface: "wl_pointer")
150
+ @connection.listen(@pointer, [
151
+ [[U, P, I, I], ->(_pointer, serial, _surface, x, y) { @serial = @pointer_serial = serial; @position = Point.new(x / 256.0, y / 256.0); self.cursor_style = :arrow }],
152
+ [[U, P], nil],
153
+ [[U, I, I], ->(_pointer, _time, x, y) { @position = Point.new(x / 256.0, y / 256.0); input(Input::MouseMove.new(@position, @modifiers)) }],
154
+ [[U, U, U, U], ->(_pointer, serial, _time, button, state) { @serial = serial; pointer_button(button, state) }],
155
+ [[U, U, I], ->(_pointer, _time, axis, value) { input(Input::ScrollWheel.new(@position, axis.zero? ? Point.new(0, value / 256.0) : Point.new(value / 256.0, 0), :changed, @modifiers)) }],
156
+ [[], nil], [[U], nil], [[U, U], nil], [[U, I], nil]
157
+ ])
158
+ end
159
+ if (capabilities & 2) != 0 && !@keyboard
160
+ @keyboard = @connection.request(seat, 1, 0, new_interface: "wl_keyboard")
161
+ @xkb = FFI::Library.new("libxkbcommon.so.0")
162
+ @connection.listen(@keyboard, [
163
+ [[U, I, U], ->(_keyboard, format, fd, size) { keyboard_map(format, fd, size) }],
164
+ [[U, P, P], ->(_keyboard, serial, _surface, _keys) { @serial = serial }],
165
+ [[U, P], ->(_keyboard, _serial, _surface) { @repeat_key = nil; @modifiers = [] }],
166
+ [[U, U, U, U], ->(_keyboard, serial, _time, key, state) { @serial = serial; keyboard_key(key, state) }],
167
+ [[U, U, U, U, U], ->(_keyboard, serial, depressed, latched, locked, group) { @serial = serial; keyboard_modifiers(depressed, latched, locked, group) }],
168
+ [[I, I], ->(_keyboard, rate, delay) { @repeat_rate, @repeat_delay = rate, delay }]
169
+ ])
170
+ end
171
+ end
172
+ def pointer_button(code, state)
173
+ button = {0x110 => :left, 0x111 => :right, 0x112 => :middle}.fetch(code, :other)
174
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
175
+ count = @last_click && @last_click[0] == code && now - @last_click[1] < 0.4 && (@position.x - @last_click[2].x).abs < 4 && (@position.y - @last_click[2].y).abs < 4 ? 2 : 1
176
+ @last_click = [code, now, @position] if state == 1
177
+ input(state == 1 ? Input::MouseDown.new(@position, button, @modifiers, count) : Input::MouseUp.new(@position, button, @modifiers))
178
+ end
179
+ def keyboard_map(format, fd, size)
180
+ io = IO.for_fd(fd)
181
+ return unless format == 1
182
+ raise Error, "Wayland keymap exceeds size limit" if size > 16_777_216
183
+ source = io.read(size)
184
+ @xkb.fn(:xkb_state_unref, [P], V).call(@key_state) if @key_state
185
+ @xkb.fn(:xkb_keymap_unref, [P], V).call(@keymap) if @keymap
186
+ @key_context ||= @xkb.fn(:xkb_context_new, [I], P).call(0)
187
+ @keymap = @xkb.fn(:xkb_keymap_new_from_string, [P, P, I, I], P).call(@key_context, source, 1, 0)
188
+ raise Error, "invalid Wayland XKB keymap" if @keymap.null?
189
+ @key_state = @xkb.fn(:xkb_state_new, [P], P).call(@keymap)
190
+ ensure
191
+ io&.close
192
+ end
193
+ def keyboard_modifiers(depressed, latched, locked, group)
194
+ return unless @key_state
195
+ @xkb.fn(:xkb_state_update_mask, [P, U, U, U, U, U, U], U).call(@key_state, depressed, latched, locked, 0, 0, group)
196
+ @modifiers = {"Control" => "ctrl", "Mod1" => "alt", "Shift" => "shift", "Mod4" => "cmd"}.filter_map do |native, key|
197
+ key if @xkb.fn(:xkb_state_mod_name_is_active, [P, P, U], I).call(@key_state, native, 8) == 1
198
+ end
199
+ end
200
+ def keyboard_key(code, state, held: false)
201
+ return unless @key_state
202
+ unless held
203
+ if state == 1 && @repeat_rate.to_i.positive? && @xkb.fn(:xkb_keymap_key_repeats, [P, U], I).call(@keymap, code + 8) == 1
204
+ @repeat_key, @repeat_at = code, Process.clock_gettime(Process::CLOCK_MONOTONIC) + @repeat_delay / 1000.0
205
+ elsif state.zero? && @repeat_key == code
206
+ @repeat_key = nil
207
+ end
208
+ end
209
+ symbol = @xkb.fn(:xkb_state_key_get_one_sym, [P, U], U).call(@key_state, code + 8)
210
+ bytes = "\0" * 128
211
+ @xkb.fn(:xkb_keysym_get_name, [U, P, Fiddle::TYPE_SIZE_T], I).call(symbol, bytes, bytes.bytesize)
212
+ key = bytes.split("\0", 2).first.downcase
213
+ key = {"return" => "enter", "escape" => "esc", "prior" => "pageup", "next" => "pagedown"}.fetch(key, key)
214
+ stroke = (@modifiers + [key]).join("-")
215
+ input(state == 1 ? Input::KeyDown.new(stroke, held) : Input::KeyUp.new(stroke)) unless state == 1 && @composing
216
+ return unless state == 1 && !@composing && (@modifiers & %w[ctrl cmd]).empty?
217
+ length = @xkb.fn(:xkb_state_key_get_utf8, [P, U, P, Fiddle::TYPE_SIZE_T], I).call(@key_state, code + 8, bytes, bytes.bytesize)
218
+ text = bytes.byteslice(0, [length, 0].max).force_encoding("UTF-8")
219
+ input(Input::TextInput.new(text)) unless text.empty? || text.match?(/\A[\x00-\x1f\x7f]+\z/)
220
+ end
221
+ def setup_text_input
222
+ manager, seat = @globals.values_at("zwp_text_input_manager_v3", "wl_seat")
223
+ return unless manager && seat
224
+ @text_input = @connection.request(manager, 1, 0, seat, new_interface: "zwp_text_input_v3")
225
+ @text_events = []
226
+ @connection.listen(@text_input, [
227
+ [[P], ->(text_input, surface) { if surface.to_i == @handle.to_i; @connection.request(text_input, 1); @connection.request(text_input, 5, 0, 0); @connection.request(text_input, 7); end }],
228
+ [[P], ->(text_input, _surface) { @connection.request(text_input, 2); @connection.request(text_input, 7); @composing = false }],
229
+ [[P, I, I], ->(_text_input, text, start, finish) { value = text.null? ? "" : text.to_s.force_encoding("UTF-8"); @text_events << Input::Composition.new(value, [start, finish - start]); @composing = !value.empty? }],
230
+ [[P], ->(_text_input, text) { @text_events << Input::TextInput.new(text.to_s.force_encoding("UTF-8")) unless text.null? }],
231
+ [[U, U], ->(_text_input, before, after) { @delete_surrounding = [before, after] }],
232
+ [[U], ->(_text_input, _serial) { @on_delete_surrounding&.call(*@delete_surrounding) if @delete_surrounding; @delete_surrounding = nil; @text_events.each { |event| input(event) }; @text_events.clear }]
233
+ ])
234
+ end
235
+ def on_delete_surrounding(&block) = @on_delete_surrounding = block
236
+ def surrounding_text(text, cursor:, anchor: cursor)
237
+ return unless @text_input
238
+ raise ArgumentError, "Wayland surrounding text is limited to 4000 bytes" if text.bytesize > 4000
239
+ @connection.request(@text_input, 3, text, cursor, anchor)
240
+ @connection.request(@text_input, 7)
241
+ end
242
+ def ime_state=(bounds)
243
+ @ime_state = bounds
244
+ return unless @text_input && bounds.is_a?(Bounds)
245
+ @connection.request(@text_input, 6, bounds.x.to_i, bounds.y.to_i, bounds.width.ceil, bounds.height.ceil)
246
+ @connection.request(@text_input, 7)
247
+ end
248
+ def setup_clipboard
249
+ manager, seat = @globals.values_at("wl_data_device_manager", "wl_seat")
250
+ return unless manager && seat
251
+ @data_device = @connection.request(manager, 1, 0, seat, new_interface: "wl_data_device")
252
+ @connection.listen(@data_device, [
253
+ [[P], ->(_device, offer) { @offers[offer.to_i] = []; @connection.listen(offer, [[[P], ->(_offer, mime) { @offers[offer.to_i] << mime.to_s }], [[U], nil], [[U], ->(_offer, action) { @offer_actions[offer.to_i] = action }]]) }],
254
+ [[U, P, I, I, P], ->(_device, serial, _surface, x, y, offer) { drag_enter(serial, x, y, offer) }],
255
+ [[], ->(_device) { destroy_offer(@drag_offer) unless @pending_drop; @drag_offer = nil unless @pending_drop }],
256
+ [[U, I, I], ->(_device, _time, x, y) { @drag_position = Point.new(x / 256.0, y / 256.0) }],
257
+ [[], ->(_device) { @pending_drop = !!@drag_offer }],
258
+ [[P], ->(_device, offer) { destroy_offer(@selection); @selection = offer.null? ? nil : offer }]
259
+ ])
260
+ end
261
+ def clipboard=(text)
262
+ raise Error, "Wayland clipboard requires a focused input seat" unless @data_device && @serial.positive?
263
+ @clipboard = text.to_s.encode("UTF-8")
264
+ @source = @connection.request(@globals["wl_data_device_manager"], 0, 0, new_interface: "wl_data_source")
265
+ @connection.listen(@source, [
266
+ [[P], nil], [[P, I], ->(_source, _mime, fd) { io = IO.for_fd(fd); begin; io.write(@clipboard); rescue Errno::EPIPE; ensure; io.close; end }],
267
+ [[], ->(_source) { @clipboard = nil }], [[], nil], [[], nil], [[U], nil]
268
+ ])
269
+ @connection.request(@source, 0, "text/plain;charset=utf-8")
270
+ @connection.request(@source, 0, "text/plain")
271
+ @connection.request(@data_device, 1, @source, @serial)
272
+ end
273
+ def clipboard = @clipboard || read_selection(["text/plain;charset=utf-8", "UTF8_STRING", "text/plain"])
274
+ def clipboard_paths
275
+ Window.file_paths(read_selection(["text/uri-list"]))
276
+ end
277
+ def drag_enter(serial, x, y, offer)
278
+ return if offer.null?
279
+ @drag_offer, @drag_position = offer, Point.new(x / 256.0, y / 256.0)
280
+ accept = @offers.fetch(offer.to_i, []).include?("text/uri-list")
281
+ @connection.request(offer, 0, serial, accept ? "text/uri-list" : 0)
282
+ @connection.request(offer, 4, accept ? 1 : 0, accept ? 1 : 0) if @connection.version(offer) >= 3
283
+ end
284
+ def finish_drop
285
+ @pending_drop = false
286
+ offer = @drag_offer
287
+ accepted = @connection.version(offer) < 3 || @offer_actions[offer.to_i] == 1
288
+ paths = accepted ? Window.file_paths(read_offer(offer, ["text/uri-list"])) : []
289
+ @connection.request(offer, 3) if @connection.version(offer) >= 3 && !paths.empty?
290
+ destroy_offer(offer)
291
+ @drag_offer = nil
292
+ input(Input::FileDrop.new(paths.freeze, @drag_position)) unless paths.empty?
293
+ end
294
+ def destroy_offer(offer)
295
+ return unless offer
296
+ @connection.request(offer, 2, destroy: true)
297
+ @offers.delete(offer.to_i)
298
+ @offer_actions.delete(offer.to_i)
299
+ end
300
+ def read_selection(types) = read_offer(@selection, types)
301
+ def read_offer(offer, types)
302
+ return "" unless offer
303
+ mime = types.find { |type| @offers.fetch(offer.to_i, []).include?(type) }
304
+ return "" unless mime
305
+ reader, writer = IO.pipe
306
+ @connection.request(offer, 1, mime, writer.fileno)
307
+ @connection.library.fn(:wl_display_flush, [P], I).call(@connection.display)
308
+ writer.close
309
+ result, deadline = "".b, Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
310
+ loop do
311
+ @connection.poll
312
+ raise Error, "Wayland selection transfer timed out" unless IO.select([reader], nil, nil, [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
313
+ data = reader.read_nonblock(65_536, exception: false)
314
+ break if data.nil?
315
+ result << data if data.is_a?(String)
316
+ raise Error, "clipboard exceeds 16 MiB" if result.bytesize > 16_777_216
317
+ end
318
+ result.force_encoding("UTF-8").scrub
319
+ ensure
320
+ reader&.close
321
+ writer&.close unless writer&.closed?
322
+ end
323
+ def cursor_style=(style)
324
+ return unless @pointer_serial && @globals["wl_shm"]
325
+ @cursors ||= FFI::Library.new("libwayland-cursor.so.0")
326
+ @cursor_theme ||= @cursors.fn(:wl_cursor_theme_load, [P, I, P], P).call(0, 24, @globals["wl_shm"])
327
+ name = {arrow: "left_ptr", text: "xterm", pointer: "hand2", crosshair: "crosshair", resize_horizontal: "sb_h_double_arrow", resize_vertical: "sb_v_double_arrow"}.fetch(style)
328
+ cursor = @cursors.fn(:wl_cursor_theme_get_cursor, [P, P], P).call(@cursor_theme, name)
329
+ return if cursor.null?
330
+ images = cursor[8, 8].unpack1("J")
331
+ image = Fiddle::Pointer.new(Fiddle::Pointer.new(images)[0, 8].unpack1("J"))
332
+ _, _, hotspot_x, hotspot_y = image[0, 16].unpack("I4")
333
+ buffer = @cursors.fn(:wl_cursor_image_get_buffer, [P], P).call(image)
334
+ @cursor_surface ||= @connection.request(@globals["wl_compositor"], 0, 0, new_interface: "wl_surface")
335
+ @connection.request(@pointer, 0, @pointer_serial, @cursor_surface, hotspot_x, hotspot_y)
336
+ @connection.request(@cursor_surface, 1, buffer, 0, 0)
337
+ @connection.request(@cursor_surface, 2, 0, 0, 0x7fffffff, 0x7fffffff)
338
+ @connection.request(@cursor_surface, 6)
339
+ end
340
+ def prompt_for_paths(**options) = Linux::Window.instance_method(:prompt_for_paths).bind_call(self, **options)
341
+ def open_url(url) = Linux::Window.instance_method(:open_url).bind_call(self, url)
342
+ def toggle_fullscreen
343
+ @fullscreen = !@fullscreen
344
+ @fullscreen ? @connection.request(@toplevel, 11, 0) : @connection.request(@toplevel, 12)
345
+ end
346
+ def close
347
+ return false unless super
348
+ close_appearance
349
+ cleanup_native
350
+ true
351
+ end
352
+ def cleanup_native
353
+ if @egl_display && !@egl_display.null?
354
+ @egl.fn(:eglMakeCurrent, [P] * 4, U).call(@egl_display, 0, 0, 0)
355
+ @egl.fn(:eglDestroySurface, [P, P], U).call(@egl_display, @egl_surface) if @egl_surface
356
+ @egl.fn(:eglDestroyContext, [P, P], U).call(@egl_display, @egl_context) if @egl_context
357
+ @egl.fn(:eglTerminate, [P], U).call(@egl_display)
358
+ end
359
+ @wayland_egl.fn(:wl_egl_window_destroy, [P], V).call(@egl_window) if @egl_window
360
+ @cursors.fn(:wl_cursor_theme_destroy, [P], V).call(@cursor_theme) if @cursor_theme
361
+ @xkb.fn(:xkb_state_unref, [P], V).call(@key_state) if @key_state
362
+ @xkb.fn(:xkb_keymap_unref, [P], V).call(@keymap) if @keymap
363
+ @xkb.fn(:xkb_context_unref, [P], V).call(@key_context) if @key_context
364
+ @connection&.close
365
+ end
366
+ end
367
+ end
368
+ end
369
+ end