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,418 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle/import"
4
+ require "uri"
5
+ require "open3"
6
+ require_relative "../../ffi/library"
7
+ require_relative "../../gpu/open_gl"
8
+ require_relative "appearance_aware"
9
+ require_relative "displays"
10
+
11
+ module Zaniah
12
+ module Platform
13
+ module Linux
14
+ module Types
15
+ extend Fiddle::Importer
16
+ Visual = struct ["void *visual", "unsigned long visualid", "int screen", "int depth", "int c_class", "unsigned long red_mask", "unsigned long green_mask", "unsigned long blue_mask", "int colormap_size", "int bits_per_rgb"]
17
+ Attributes = struct ["unsigned long background_pixmap", "unsigned long background_pixel", "unsigned long border_pixmap", "unsigned long border_pixel", "int bit_gravity", "int win_gravity", "int backing_store", "unsigned long backing_planes", "unsigned long backing_pixel", "int save_under", "long event_mask", "long do_not_propagate_mask", "int override_redirect", "unsigned long colormap", "unsigned long cursor"]
18
+ end
19
+
20
+ # X11 works on Xorg and through XWayland on Wayland sessions.
21
+ class Window < Headless::Window
22
+ include AppearanceAware
23
+ def displays = Linux.displays(display_server: :x11)
24
+ I, L, P, V = Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOID
25
+ attr_reader :handle, :display
26
+ def self.new(display_server: :auto, **options)
27
+ raise ArgumentError, "unknown Linux display server" unless [:auto, :x11, :wayland].include?(display_server)
28
+ if display_server == :wayland || (display_server == :auto && ENV["WAYLAND_DISPLAY"])
29
+ begin
30
+ require_relative "wayland_window"
31
+ return WaylandWindow.new(**options)
32
+ rescue LoadError, Error => error
33
+ raise if display_server == :wayland || !ENV["DISPLAY"]
34
+ warn "Zaniah UI: Wayland unavailable (#{error.message}); trying X11"
35
+ end
36
+ end
37
+ super(**options)
38
+ end
39
+ def initialize(gpu: :opengl, **options)
40
+ super(**options)
41
+ @x = FFI::Library.new("libX11.so.6")
42
+ @gl = FFI::Library.new("libGL.so.1")
43
+ @display = x(:XOpenDisplay, [P], P, 0)
44
+ raise Error, "cannot open X display; set DISPLAY (Wayland requires XWayland)" if @display.null?
45
+ @screen = x(:XDefaultScreen, [P], I, @display)
46
+ @root = x(:XRootWindow, [P, I], L, @display, @screen)
47
+ attributes = [0x8012, 1, 0x8010, 1, 0x8011, 1, 8, 8, 9, 8, 10, 8, 11, 8, 5, 1, 0].pack("i*")
48
+ count = [0].pack("i")
49
+ configs = @gl.fn(:glXChooseFBConfig, [P, I, P, P], P).call(@display, @screen, attributes, count)
50
+ raise Error, "no GLX framebuffer configuration" if configs.null? || count.unpack1("i").zero?
51
+ @config = Fiddle::Pointer.new(configs)[0, Fiddle::SIZEOF_VOIDP].unpack1("J")
52
+ visual_ptr = @gl.fn(:glXGetVisualFromFBConfig, [P, P], P).call(@display, @config)
53
+ visual = Types::Visual.new(visual_ptr)
54
+ @colormap = x(:XCreateColormap, [P, L, P, I], L, @display, @root, visual.visual, 0)
55
+ attrs = Types::Attributes.malloc(Fiddle::RUBY_FREE)
56
+ attrs.to_ptr[0, Types::Attributes.size] = "\0" * Types::Attributes.size
57
+ attrs.colormap = @colormap
58
+ attrs.event_mask = (1 << 0) | (1 << 1) | (1 << 2) | (1 << 3) | (1 << 6) | (1 << 15) | (1 << 17) | (1 << 21) | (1 << 22)
59
+ @handle = x(:XCreateWindow, [P, L, I, I, I, I, I, I, I, P, L, P], L, @display, @root, 0, 0, content_size.width.to_i, content_size.height.to_i, 0, visual.depth, 1, visual.visual, (1 << 11) | (1 << 13), attrs)
60
+ x(:XFree, [P], I, visual_ptr)
61
+ x(:XFree, [P], I, configs)
62
+ raise Error, "XCreateWindow failed" if @handle.zero?
63
+ @atoms = {}
64
+ protocol = [atom("WM_DELETE_WINDOW")].pack("L!")
65
+ x(:XSetWMProtocols, [P, L, P, I], I, @display, @handle, protocol, 1)
66
+ property(@handle, atom("XdndAware"), 4, [5].pack("L!"), format: 32)
67
+ self.title = @title
68
+ dpi = @x.fn(:XResourceManagerString, [P], P).call(@display)
69
+ @scale_factor = dpi.null? ? 1.0 : (dpi.to_s[/Xft\.dpi:\s*(\d+(?:\.\d+)?)/, 1]&.to_f || 96) / 96.0
70
+ @scale_factor = @scale_factor.clamp(1.0, 4.0)
71
+ @content_size = Size.new(content_size.width / @scale_factor, content_size.height / @scale_factor)
72
+ @device.release
73
+ @device = create_device(gpu)
74
+ x(:XMapWindow, [P, L], I, @display, @handle)
75
+ x(:XFlush, [P], I, @display)
76
+ setup_ime
77
+ setup_display_events
78
+ end
79
+ def x(name, types, result, *values) = @x.fn(name, types, result).call(*values)
80
+ def atom(name) = @atoms[name] ||= x(:XInternAtom, [P, P, I], L, @display, name, 0)
81
+ def create_device(backend)
82
+ raise ArgumentError, "X11 supports :opengl" unless [:opengl, :gl].include?(backend)
83
+ resolver = ->(name) { @gl.fn(:glXGetProcAddressARB, [P], P).call(name) }
84
+ address = resolver.call("glXCreateContextAttribsARB")
85
+ raise Error, "GLX_ARB_create_context unavailable" if address.null?
86
+ create = Fiddle::Function.new(address, [P, P, P, I, P], P)
87
+ attributes = [0x2091, 3, 0x2092, 3, 0x9126, 1, 0].pack("i*")
88
+ @context = create.call(@display, @config, 0, 1, attributes)
89
+ raise Error, "OpenGL 3.3 context unavailable" if @context.null?
90
+ make_current
91
+ extension = @gl.fn(:glXQueryExtensionsString, [P, I], P).call(@display, @screen).to_s
92
+ if extension.split.include?("GLX_EXT_swap_control")
93
+ Fiddle::Function.new(resolver.call("glXSwapIntervalEXT"), [P, L, I], V).call(@display, @handle, 1)
94
+ end
95
+ GPU::OpenGL.new(self, library: @gl, resolver: resolver)
96
+ end
97
+ def make_current
98
+ ok = @gl.fn(:glXMakeCurrent, [P, L, P], I).call(@display, @handle, @context)
99
+ raise Error, "glXMakeCurrent failed" if ok.zero?
100
+ end
101
+ def swap_buffers = @gl.fn(:glXSwapBuffers, [P, L], V).call(@display, @handle)
102
+ def title=(title)
103
+ super
104
+ return unless @handle
105
+ x(:XStoreName, [P, L, P], I, @display, @handle, title)
106
+ property(@handle, atom("_NET_WM_NAME"), atom("UTF8_STRING"), title.encode("UTF-8"))
107
+ end
108
+ def tick
109
+ poll_events unless closed?
110
+ poll_appearance unless closed?
111
+ raise @native_error if @native_error
112
+ super
113
+ end
114
+ def run
115
+ fd = x(:XConnectionNumber, [P], I, @display)
116
+ io = IO.for_fd(fd, autoclose: false)
117
+ until closed?
118
+ tick
119
+ IO.select([io], nil, nil, 0.05) unless dirty? || closed?
120
+ end
121
+ end
122
+ def poll_events
123
+ event = "\0".b * 192
124
+ while !closed? && x(:XPending, [P], I, @display).positive?
125
+ x(:XNextEvent, [P, P], I, @display, event)
126
+ # XIM protocol replies target a private Xlib window, not our view.
127
+ # None preserves the original event target for the registered filter.
128
+ next if x(:XFilterEvent, [P, L], I, event, 0) != 0
129
+ type = event.unpack1("i")
130
+ if @randr_event && [@randr_event, @randr_event + 1].include?(type)
131
+ refresh_scale
132
+ request_frame
133
+ next
134
+ end
135
+ case type
136
+ when 2, 3 then key_event(event, type)
137
+ when 4, 5, 6 then mouse_event(event, type)
138
+ when 9 then x(:XSetICFocus, [P], V, @ic) if @ic
139
+ when 10 then x(:XUnsetICFocus, [P], V, @ic) if @ic
140
+ when 12 then request_frame
141
+ when 22
142
+ width, height = event[56, 8].unpack("i2")
143
+ resize(width / @scale_factor, height / @scale_factor) if width.positive? && height.positive?
144
+ @on_moved&.call(event[48, 8].unpack("i2"))
145
+ when 28 then refresh_scale if event[40, 8].unpack1("L!") == atom("RESOURCE_MANAGER")
146
+ when 30 then selection_request(event)
147
+ when 31
148
+ name = event[56, 8].unpack1("L!")
149
+ event[40, 8].unpack1("L!") == atom("XdndSelection") ? finish_drop(name) : @selection_received = name
150
+ when 33 then client_message(event)
151
+ end
152
+ end
153
+ end
154
+ def modifiers(state) = [[4, "ctrl"], [8, "alt"], [1, "shift"], [64, "cmd"]].filter_map { |mask, name| name unless (state & mask).zero? }
155
+ def key_event(event, type)
156
+ state = event[80, 4].unpack1("I")
157
+ keysym = x(:XLookupKeysym, [P, I], L, event, 0)
158
+ name = x(:XKeysymToString, [L], P, keysym)
159
+ key = name.null? ? "" : name.to_s.downcase
160
+ key = {"return" => "enter", "escape" => "esc", "backspace" => "backspace", "prior" => "pageup", "next" => "pagedown"}.fetch(key, key)
161
+ key = (modifiers(state) + [key]).join("-")
162
+ input(type == 2 ? Input::KeyDown.new(key, false) : Input::KeyUp.new(key)) unless type == 2 && @preedit && !@preedit.empty?
163
+ return unless type == 2 && (state & (4 | 64)).zero?
164
+ bytes, symbol, status = "\0".b * 4096, [0].pack("L!"), [0].pack("i")
165
+ length = if @ic
166
+ x(:Xutf8LookupString, [P, P, P, I, P, P], I, @ic, event, bytes, bytes.bytesize, symbol, status)
167
+ else
168
+ x(:XLookupString, [P, P, I, P, P], I, event, bytes, bytes.bytesize, symbol, 0)
169
+ end
170
+ if length > bytes.bytesize
171
+ bytes = "\0".b * length
172
+ length = x(:Xutf8LookupString, [P, P, P, I, P, P], I, @ic, event, bytes, bytes.bytesize, symbol, status)
173
+ end
174
+ text = bytes.byteslice(0, [length, 0].max).force_encoding("UTF-8").scrub
175
+ input(Input::TextInput.new(text)) unless text.empty? || text.match?(/\A[\x00-\x1f\x7f]+\z/)
176
+ end
177
+ def mouse_event(event, type)
178
+ px, py = event[64, 8].unpack("i2")
179
+ position = Point.new(px / @scale_factor, py / @scale_factor)
180
+ state, button = event[80, 8].unpack("I2")
181
+ mods = modifiers(state)
182
+ if type == 4 && (4..7).cover?(button)
183
+ delta = button <= 5 ? Point.new(0, button == 4 ? -40 : 40) : Point.new(button == 6 ? -40 : 40, 0)
184
+ input(Input::ScrollWheel.new(position, delta, :changed, mods))
185
+ elsif !(4..7).cover?(button) || type == 6
186
+ button = {1 => :left, 2 => :middle, 3 => :right}.fetch(button, :other)
187
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
188
+ count = @last_click && @last_click[0] == button && now - @last_click[1] < 0.4 && (position.x - @last_click[2].x).abs < 4 && (position.y - @last_click[2].y).abs < 4 ? 2 : 1
189
+ @last_click = [button, now, position] if type == 4
190
+ input(type == 6 ? Input::MouseMove.new(position, mods) : type == 4 ? Input::MouseDown.new(position, button, mods, count) : Input::MouseUp.new(position, button, mods))
191
+ end
192
+ end
193
+ def setup_ime
194
+ libc = FFI::Library.new(nil)
195
+ libc.fn(:setlocale, [I, P], P).call(0, "")
196
+ x(:XSetLocaleModifiers, [P], P, "")
197
+ @im = x(:XOpenIM, [P] * 4, P, @display, 0, 0, 0)
198
+ return if @im.null?
199
+ function = @x.fn(:XCreateIC, [P, Fiddle::TYPE_VARIADIC], P)
200
+ @preedit, @ime_callbacks = "", []
201
+ callbacks = {
202
+ "preeditStartCallback" => [I, ->(_data) { @preedit = ""; -1 }],
203
+ "preeditDoneCallback" => [V, ->(_data) { @preedit = ""; input(Input::Composition.new("", [0, 0])) }],
204
+ "preeditDrawCallback" => [V, ->(data) { preedit_draw(data) }],
205
+ "preeditCaretCallback" => [V, ->(data) { input(Input::Composition.new(@preedit, [Fiddle::Pointer.new(data)[0, 4].unpack1("i"), 0])) }]
206
+ }.map do |name, (result, handler)|
207
+ callback = Fiddle::Closure::BlockCaller.new(result, [P, P, P]) do |_ic, _client, data|
208
+ begin
209
+ handler.call(data)
210
+ rescue StandardError => error
211
+ @native_error = error
212
+ result == I ? 0 : nil
213
+ end
214
+ end
215
+ record = [0, callback.to_i].pack("J2")
216
+ @ime_callbacks << [callback, record]
217
+ [P, name, P, record]
218
+ end.flatten
219
+ nested = @x.fn(:XVaCreateNestedList, [I, Fiddle::TYPE_VARIADIC], P).call(0, *callbacks, P, 0)
220
+ @ic = function.call(@im, P, "inputStyle", L, 0x0402, P, "clientWindow", L, @handle, P, "focusWindow", L, @handle, P, "preeditAttributes", P, nested, P, 0)
221
+ x(:XFree, [P], I, nested) unless nested.null?
222
+ @ic = function.call(@im, P, "inputStyle", L, 0x0408, P, "clientWindow", L, @handle, P, "focusWindow", L, @handle, P, 0) if @ic.null?
223
+ @ic = nil if @ic.null?
224
+ end
225
+ def preedit_draw(data)
226
+ pointer = Fiddle::Pointer.new(data)
227
+ caret, first, length = pointer[0, 12].unpack("i3")
228
+ native_text = pointer[16, 8].unpack1("J")
229
+ text = ""
230
+ unless native_text.zero?
231
+ descriptor = Fiddle::Pointer.new(native_text)
232
+ count = descriptor[0, 2].unpack1("S")
233
+ wide = descriptor[16, 4].unpack1("i") != 0
234
+ string = Fiddle::Pointer.new(descriptor[24, 8].unpack1("J"))
235
+ text = wide ? string[0, count * 4].unpack("I*").pack("U*") : string.to_s.force_encoding("UTF-8").scrub unless string.null?
236
+ end
237
+ characters = @preedit.chars
238
+ characters[first, length] = text.chars
239
+ @preedit = characters.join
240
+ input(Input::Composition.new(@preedit, [caret, 0]))
241
+ end
242
+ def ime_state=(bounds)
243
+ @ime_state = bounds
244
+ return unless @ic && bounds.is_a?(Bounds)
245
+ point = [(bounds.x * @scale_factor).round.clamp(-32_768, 32_767), (bounds.bottom * @scale_factor).round.clamp(-32_768, 32_767)].pack("s2")
246
+ nested = @x.fn(:XVaCreateNestedList, [I, Fiddle::TYPE_VARIADIC], P).call(0, P, "spotLocation", P, point, P, 0)
247
+ @x.fn(:XSetICValues, [P, Fiddle::TYPE_VARIADIC], P).call(@ic, P, "preeditAttributes", P, nested, P, 0)
248
+ x(:XFree, [P], I, nested)
249
+ end
250
+
251
+ def property(window, name, type, bytes, format: 8)
252
+ x(:XChangeProperty, [P, L, L, L, I, I, P, I], I, @display, window, name, type, format, 0, bytes, format == 8 ? bytes.bytesize : bytes.bytesize / Fiddle::SIZEOF_LONG)
253
+ end
254
+ def setup_display_events
255
+ x(:XSelectInput, [P, L, L], I, @display, @root, 1 << 22)
256
+ @randr = FFI::Library.new("libXrandr.so.2")
257
+ base, error = [0].pack("i"), [0].pack("i")
258
+ if @randr.fn(:XRRQueryExtension, [P, P, P], I).call(@display, base, error) != 0
259
+ @randr_event = base.unpack1("i")
260
+ @randr.fn(:XRRSelectInput, [P, L, I], V).call(@display, @root, 1 | 2 | 4 | 64)
261
+ end
262
+ rescue LoadError
263
+ # X11 without RandR still gets global Xft DPI property notifications.
264
+ @randr = nil
265
+ end
266
+ def refresh_scale
267
+ resource = read_property(@root, atom("RESOURCE_MANAGER"), delete: false)
268
+ factor = ((resource[/Xft\.dpi:\s*(\d+(?:\.\d+)?)/, 1]&.to_f || 96) / 96.0).clamp(1.0, 4.0)
269
+ return if factor == @scale_factor
270
+ width, height = content_size.width * @scale_factor, content_size.height * @scale_factor
271
+ @scale_factor = factor
272
+ resize(width / factor, height / factor)
273
+ end
274
+ def clipboard=(value)
275
+ @clipboard = value.to_s.encode("UTF-8")
276
+ x(:XSetSelectionOwner, [P, L, L, L], I, @display, atom("CLIPBOARD"), @handle, 0)
277
+ x(:XFlush, [P], I, @display)
278
+ end
279
+ def clipboard = selection("UTF8_STRING")
280
+ def clipboard_paths
281
+ self.class.file_paths(selection("text/uri-list"))
282
+ end
283
+ def self.file_paths(bytes)
284
+ bytes.lines.filter_map do |line|
285
+ next if line.start_with?("#")
286
+ uri = URI.parse(line.strip) rescue nil
287
+ next unless uri&.scheme == "file" && [nil, "", "localhost"].include?(uri.host)
288
+ path = URI::DEFAULT_PARSER.unescape(uri.path).force_encoding("UTF-8")
289
+ path if path.start_with?("/") && !path.include?("\0") && path.valid_encoding?
290
+ end
291
+ end
292
+ def selection(target)
293
+ return @clipboard.to_s if x(:XGetSelectionOwner, [P, L], L, @display, atom("CLIPBOARD")) == @handle
294
+ @selection_received = nil
295
+ name = atom("ZANIAH_SELECTION")
296
+ x(:XConvertSelection, [P, L, L, L, L, L], I, @display, atom("CLIPBOARD"), atom(target), name, @handle, 0)
297
+ x(:XFlush, [P], I, @display)
298
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
299
+ while @selection_received.nil? && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
300
+ poll_events
301
+ IO.select([IO.for_fd(x(:XConnectionNumber, [P], I, @display), autoclose: false)], nil, nil, 0.01)
302
+ end
303
+ return "" unless @selection_received && @selection_received != 0
304
+ read_property(@handle, name).force_encoding("UTF-8").scrub
305
+ end
306
+ def read_property(window, name, delete: true)
307
+ actual_type, format, length, remaining, pointer = [0].pack("L!"), [0].pack("i"), [0].pack("L!"), [0].pack("L!"), [0].pack("J")
308
+ x(:XGetWindowProperty, [P, L, L, L, L, I, L, P, P, P, P, P], I, @display, window, name, 0, 4_194_304, delete ? 1 : 0, 0, actual_type, format, length, remaining, pointer)
309
+ data = Fiddle::Pointer.new(pointer.unpack1("J"))
310
+ return "" if data.null?
311
+ raise Error, "incremental X11 clipboard transfer is not supported" if actual_type.unpack1("L!") == atom("INCR")
312
+ raise Error, "X11 selection exceeds 16 MiB" unless remaining.unpack1("L!").zero?
313
+ size = {8 => 1, 16 => 2, 32 => Fiddle::SIZEOF_LONG}.fetch(format.unpack1("i"), 0)
314
+ data[0, length.unpack1("L!") * size]
315
+ ensure
316
+ x(:XFree, [P], I, data) if data && !data.null?
317
+ end
318
+ def client_message(event)
319
+ type, data = event[40, 8].unpack1("L!"), event[56, 40].unpack("L!5")
320
+ case type
321
+ when atom("WM_PROTOCOLS") then close if data[0] == atom("WM_DELETE_WINDOW")
322
+ when atom("XdndEnter")
323
+ @drag_source = data[0]
324
+ types = data[1].odd? ? read_property(@drag_source, atom("XdndTypeList"), delete: false).unpack("L!*") : data[2, 3]
325
+ @drag_accept = types.include?(atom("text/uri-list"))
326
+ when atom("XdndLeave") then @drag_source = nil
327
+ when atom("XdndPosition")
328
+ return unless data[0] == @drag_source
329
+ root_x, root_y = [data[2] >> 16, data[2] & 0xffff].map { |n| n >= 0x8000 ? n - 0x10000 : n }
330
+ left, top, child = [0].pack("i"), [0].pack("i"), [0].pack("L!")
331
+ x(:XTranslateCoordinates, [P, L, L, I, I, P, P, P], I, @display, @root, @handle, root_x, root_y, left, top, child)
332
+ @drag_position = Point.new(left.unpack1("i") / @scale_factor, top.unpack1("i") / @scale_factor)
333
+ send_client_message(@drag_source, "XdndStatus", [@handle, @drag_accept ? 3 : 2, 0, 0, @drag_accept ? atom("XdndActionCopy") : 0])
334
+ when atom("XdndDrop")
335
+ return unless data[0] == @drag_source
336
+ return finish_drop(0) unless @drag_accept
337
+ x(:XConvertSelection, [P, L, L, L, L, L], I, @display, atom("XdndSelection"), atom("text/uri-list"), atom("ZANIAH_DROP"), @handle, data[2])
338
+ end
339
+ end
340
+ def finish_drop(property_name)
341
+ return unless @drag_source
342
+ paths = property_name.zero? ? [] : self.class.file_paths(read_property(@handle, property_name))
343
+ send_client_message(@drag_source, "XdndFinished", [@handle, paths.empty? ? 0 : 1, paths.empty? ? 0 : atom("XdndActionCopy"), 0, 0])
344
+ @drag_source = nil
345
+ input(Input::FileDrop.new(paths.freeze, @drag_position || Point.new(0, 0))) unless paths.empty?
346
+ end
347
+ def send_client_message(window, name, data)
348
+ event = "\0".b * 192
349
+ event[0, 4] = [33].pack("i")
350
+ event[24, 24] = [@display.to_i, window, atom(name)].pack("J3")
351
+ event[48, 4] = [32].pack("i")
352
+ event[56, 40] = data.pack("L!5")
353
+ x(:XSendEvent, [P, L, I, L, P], I, @display, window, 0, 0, event)
354
+ x(:XFlush, [P], I, @display)
355
+ end
356
+ def selection_request(event)
357
+ requestor, selection, target, prop, time = event[40, 40].unpack("L!5")
358
+ prop = target if prop.zero?
359
+ if target == atom("TARGETS")
360
+ property(requestor, prop, 4, [atom("TARGETS"), atom("UTF8_STRING"), atom("STRING")].pack("L!3"), format: 32)
361
+ elsif [atom("UTF8_STRING"), atom("STRING")].include?(target)
362
+ property(requestor, prop, target, @clipboard.to_s)
363
+ else
364
+ prop = 0
365
+ end
366
+ reply = "\0".b * 192
367
+ reply[0, 4] = [31].pack("i")
368
+ reply[24, 8] = [@display.to_i].pack("J")
369
+ reply[32, 40] = [requestor, selection, target, prop, time].pack("L!5")
370
+ x(:XSendEvent, [P, L, I, L, P], I, @display, requestor, 0, 0, reply)
371
+ x(:XFlush, [P], I, @display)
372
+ end
373
+ def cursor_style=(style)
374
+ number = {arrow: 68, text: 152, pointer: 60, crosshair: 34, resize_horizontal: 108, resize_vertical: 116}.fetch(style)
375
+ cursor = x(:XCreateFontCursor, [P, I], L, @display, number)
376
+ x(:XDefineCursor, [P, L, L], I, @display, @handle, cursor)
377
+ x(:XFreeCursor, [P, L], I, @display, cursor)
378
+ end
379
+ def prompt_for_paths(multiple: false, directories: false, save: false)
380
+ command = ["zenity", "--file-selection", "--separator=\n"]
381
+ command << "--multiple" if multiple
382
+ command << "--directory" if directories
383
+ command << "--save" << "--confirm-overwrite" if save
384
+ output, status = Open3.capture2(*command)
385
+ status.success? ? output.lines.map(&:chomp) : []
386
+ rescue Errno::ENOENT
387
+ raise Error, "file selection requires the desktop's zenity command"
388
+ end
389
+ def open_url(url)
390
+ Process.detach(Process.spawn("xdg-open", url.to_s, out: File::NULL, err: File::NULL))
391
+ true
392
+ end
393
+ def toggle_fullscreen
394
+ event = "\0".b * 192
395
+ event[0, 4] = [33].pack("i")
396
+ event[24, 8] = [@display.to_i].pack("J")
397
+ event[32, 8] = [@handle].pack("L!")
398
+ event[40, 8] = [atom("_NET_WM_STATE")].pack("L!")
399
+ event[48, 4] = [32].pack("i")
400
+ event[56, 24] = [2, atom("_NET_WM_STATE_FULLSCREEN"), 0].pack("L!3")
401
+ x(:XSendEvent, [P, L, I, L, P], I, @display, @root, 0, (1 << 19) | (1 << 20), event)
402
+ end
403
+ def close
404
+ return false unless super
405
+ close_appearance
406
+ x(:XDestroyIC, [P], V, @ic) if @ic
407
+ x(:XCloseIM, [P], I, @im) if @im && !@im.null?
408
+ @gl.fn(:glXMakeCurrent, [P, L, P], I).call(@display, 0, 0)
409
+ @gl.fn(:glXDestroyContext, [P, P], V).call(@display, @context)
410
+ x(:XDestroyWindow, [P, L], I, @display, @handle)
411
+ x(:XFreeColormap, [P, L], I, @display, @colormap)
412
+ x(:XCloseDisplay, [P], I, @display)
413
+ true
414
+ end
415
+ end
416
+ end
417
+ end
418
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "linux/window"
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Platform
5
+ module Mac
6
+ class App
7
+ EVENT_MASK = (1 << 64) - 1
8
+ EVENT_ARGUMENTS = [:ulong, :pointer, :pointer, :bool].freeze
9
+ POINTER_ARGUMENT = [:pointer].freeze
10
+ WAIT_ARGUMENT = [:double].freeze
11
+
12
+ attr_reader :handle
13
+ def self.instance = @instance ||= new
14
+ def initialize
15
+ raise Error, "Cocoa must run on the main Ruby thread" unless Thread.current == Thread.main
16
+ @handle = O.send(O.klass("NSApplication"), "sharedApplication")
17
+ @run_loop_mode = O.send(O.string("kCFRunLoopDefaultMode"), "retain")
18
+ O.subclass("ZaniahApplicationDelegate", "NSObject", protocols: ["NSApplicationDelegate"]) do |klass|
19
+ O.method(klass, "applicationShouldTerminate:", args: [:pointer], result: :ulong, encoding: "Q@:@") do |_delegate, _, _application|
20
+ WINDOWS.values.uniq.each { |window| break unless window.close }
21
+ # Ruby's window loop owns shutdown and ensures; Cocoa must not exit
22
+ # the process before close handlers have checked unsaved buffers.
23
+ 0
24
+ end
25
+ end
26
+ @delegate = O.new("ZaniahApplicationDelegate")
27
+ O.send(@handle, "setDelegate:", @delegate, args: [:pointer], result: :void)
28
+ O.send(@handle, "setActivationPolicy:", 0, args: [:long], result: :bool)
29
+ menu, item, submenu = O.new("NSMenu"), O.new("NSMenuItem"), O.new("NSMenu")
30
+ quit = O.send(O.alloc("NSMenuItem"), "initWithTitle:action:keyEquivalent:", O.string("Quit"), O.selector("terminate:"), O.string("q"), args: [:pointer] * 3)
31
+ O.send(submenu, "addItem:", quit, args: [:pointer], result: :void)
32
+ O.send(item, "setSubmenu:", submenu, args: [:pointer], result: :void)
33
+ O.send(menu, "addItem:", item, args: [:pointer], result: :void)
34
+ O.send(@handle, "setMainMenu:", menu, args: [:pointer], result: :void)
35
+ [quit, submenu, item, menu].each { |object| O.release(object) }
36
+ O.send(@handle, "finishLaunching", result: :void)
37
+ O.send(@handle, "activateIgnoringOtherApps:", 1, args: [:bool], result: :void)
38
+ end
39
+
40
+ def poll(wait: 0)
41
+ pool = O.new("NSAutoreleasePool")
42
+ # Cocoa treats a nil deadline as distantPast: drain queued events
43
+ # without allocating an NSDate for each nonblocking poll.
44
+ deadline = wait.zero? ? 0 : O.send(O.klass("NSDate"), "dateWithTimeIntervalSinceNow:", wait, args: WAIT_ARGUMENT)
45
+ loop do
46
+ event = O.send(@handle, "nextEventMatchingMask:untilDate:inMode:dequeue:", EVENT_MASK,
47
+ deadline, @run_loop_mode, 1, args: EVENT_ARGUMENTS)
48
+ break if event.zero?
49
+ O.send(@handle, "sendEvent:", event, args: POINTER_ARGUMENT, result: :void)
50
+ deadline = 0
51
+ end
52
+ O.send(@handle, "updateWindows", result: :void)
53
+ ensure
54
+ O.release(pool) if pool
55
+ end
56
+
57
+ def run
58
+ yield self if block_given?
59
+ until WINDOWS.values.uniq.all?(&:closed?)
60
+ poll(wait: WINDOWS.values.any?(&:dirty?) ? 0 : 0.05)
61
+ WINDOWS.values.uniq.each { |window| window.tick(poll_events: false) }
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../ffi/struct"
4
+
5
+ module Zaniah
6
+ module Platform
7
+ module Mac
8
+ class CoreText
9
+ P, L, D, V = Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG, Fiddle::TYPE_DOUBLE, Fiddle::TYPE_VOID
10
+ def initialize
11
+ require "alhena"
12
+ @cf = FFI::Library.new("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")
13
+ @ct = FFI::Library.new("/System/Library/Frameworks/CoreText.framework/CoreText")
14
+ @cg = FFI::Library.new("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")
15
+ @descriptors, @fonts = {}, {}
16
+ @bounds = FFI::Struct::Signature.new([:pointer], :rect)
17
+ end
18
+ def cache_key
19
+ require "etc"
20
+ @cache_key ||= "coretext/#{RUBY_PLATFORM}/#{Etc.uname.values_at(:release, :version).join('/')}".freeze
21
+ end
22
+ def native_font(font, size)
23
+ @fonts[[font, size]] ||= begin
24
+ descriptor = @descriptors[font] ||= begin
25
+ bytes = font.data
26
+ data = @cf.fn(:CFDataCreate, [P, P, L], P).call(0, bytes, bytes.bytesize)
27
+ list = @ct.fn(:CTFontManagerCreateFontDescriptorsFromData, [P], P).call(data)
28
+ raise Error, "CoreText cannot read font data" if list.null?
29
+ count = @cf.fn(:CFArrayGetCount, [P], L).call(list)
30
+ raise Error, "CoreText font collection index out of range" unless font.index < count
31
+ item = @cf.fn(:CFArrayGetValueAtIndex, [P, L], P).call(list, font.index)
32
+ item = @cf.fn(:CFRetain, [P], P).call(item)
33
+ @cf.fn(:CFRelease, [P], V).call(list)
34
+ @cf.fn(:CFRelease, [P], V).call(data)
35
+ item
36
+ end
37
+ handle = @ct.fn(:CTFontCreateWithFontDescriptor, [P, D, P], P).call(descriptor, size, 0)
38
+ font.axis_values.empty? ? handle : varied_font(handle, font.axis_values)
39
+ end
40
+ end
41
+ def varied_font(handle, axes)
42
+ descriptor = @ct.fn(:CTFontCopyFontDescriptor, [P], P).call(handle)
43
+ axes.each do |tag, value|
44
+ key = @cf.fn(:CFNumberCreate, [P, Fiddle::TYPE_INT, P], P).call(0, 3, [tag.unpack1("N")].pack("i"))
45
+ next_descriptor = @ct.fn(:CTFontDescriptorCreateCopyWithVariation, [P, P, D], P).call(descriptor, key, value)
46
+ @cf.fn(:CFRelease, [P], V).call(key)
47
+ @cf.fn(:CFRelease, [P], V).call(descriptor)
48
+ descriptor = next_descriptor
49
+ end
50
+ result = @ct.fn(:CTFontCreateCopyWithAttributes, [P, D, P, P], P).call(handle, 0, 0, descriptor)
51
+ raise Error, "CoreText rejected font variations" if result.null?
52
+ result
53
+ ensure
54
+ @cf.fn(:CFRelease, [P], V).call(handle)
55
+ @cf.fn(:CFRelease, [P], V).call(descriptor) if descriptor
56
+ end
57
+ def rasterize(font, glyph, size:, subpixel_x: 0, **_options)
58
+ raise ArgumentError, "invalid font size" unless size.is_a?(Numeric) && size.finite? && size.positive?
59
+ raise ArgumentError, "invalid glyph ID" unless glyph.is_a?(Integer) && glyph.between?(0, font.glyph_count - 1)
60
+ raise ArgumentError, "invalid subpixel position" unless subpixel_x.is_a?(Numeric) && subpixel_x.finite?
61
+ handle = native_font(font, size)
62
+ path = @ct.fn(:CTFontCreatePathForGlyph, [P, Fiddle::TYPE_USHORT, P], P).call(handle, glyph, 0)
63
+ return Alhena::Bitmap.new(width: 0, height: 0, coverage: "") if path.null?
64
+ x, y, width, height = @bounds.call(@cg.handle["CGPathGetBoundingBox"], path)
65
+ left, bottom = (x + subpixel_x).floor, y.floor
66
+ right, top = (x + width + subpixel_x).ceil, (y + height).ceil
67
+ width, height = right - left, top - bottom
68
+ return Alhena::Bitmap.new(width: 0, height: 0, coverage: "") if width.zero? || height.zero?
69
+ raise Error, "CoreText bitmap exceeds size limit" if width * height > 16_777_216
70
+ bytes = "\0".b * (width * height)
71
+ context = @cg.fn(:CGBitmapContextCreate, [P, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_SIZE_T, P, Fiddle::TYPE_UINT], P).call(bytes, width, height, 8, width, 0, 7)
72
+ raise Error, "CoreGraphics alpha bitmap context unavailable" if context.null?
73
+ @cg.fn(:CGContextSetShouldAntialias, [P, Fiddle::TYPE_BOOL], V).call(context, 1)
74
+ @cg.fn(:CGContextSetGrayFillColor, [P, D, D], V).call(context, 1, 1)
75
+ @cg.fn(:CGContextTranslateCTM, [P, D, D], V).call(context, -left + subpixel_x, -bottom)
76
+ # Filling the CoreText outline avoids CTFontDrawGlyphs pixel snapping
77
+ # and preserves the requested fractional position in an R8 context.
78
+ @cg.fn(:CGContextAddPath, [P, P], V).call(context, path)
79
+ @cg.fn(:CGContextFillPath, [P], V).call(context)
80
+ Alhena::Bitmap.new(width: width, height: height, left: left, top: top, coverage: bytes)
81
+ ensure
82
+ @cg.fn(:CGContextRelease, [P], V).call(context) if context && !context.null?
83
+ @cg.fn(:CGPathRelease, [P], V).call(path) if path && !path.null?
84
+ end
85
+ def close
86
+ @fonts.each_value { |font| @cf.fn(:CFRelease, [P], V).call(font) }
87
+ @descriptors.each_value { |descriptor| @cf.fn(:CFRelease, [P], V).call(descriptor) }
88
+ @fonts.clear
89
+ @descriptors.clear
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Platform
5
+ module Mac
6
+ def self.displays
7
+ list = O.send(O.klass("NSScreen"), "screens")
8
+ count = O.send(list, "count", result: :ulong)
9
+ return [] if count.zero?
10
+ first = O.send(list, "objectAtIndex:", 0, args: [:ulong])
11
+ primary_height = O.send(first, "frame", result: :rect)[3]
12
+ Array.new(count) do |index|
13
+ screen = O.send(list, "objectAtIndex:", index, args: [:ulong])
14
+ x, y, width, height = O.send(screen, "frame", result: :rect)
15
+ description = O.send(screen, "deviceDescription")
16
+ identifier = O.send(description, "objectForKey:", O.string("NSScreenNumber"), args: [:pointer])
17
+ Display.new(O.send(identifier, "unsignedIntValue", result: :uint), O.text(O.send(screen, "localizedName")),
18
+ Bounds.new(x, primary_height - y - height, width, height), O.send(screen, "backingScaleFactor", result: :double), index.zero?)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end