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,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../ffi/library"
4
+
5
+ module Zaniah
6
+ module Platform
7
+ module Windows
8
+ # A native ConPTY process. Output is UTF-8 VT data, compatible with the
9
+ # same terminal parser used by Unix PTYs. Empty means idle, nil means EOF.
10
+ class Terminal
11
+ P, I, U, N, V = Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_UINT, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_VOID
12
+ attr_reader :pid
13
+ def initialize(command: nil, columns: 80, rows: 24, cwd: nil, env: {})
14
+ raise Error, "ConPTY requires 64-bit Windows Ruby" unless Fiddle::SIZEOF_VOIDP == 8
15
+ @kernel = FFI::Library.new("kernel32.dll")
16
+ @output, @pending, @closed, @eof = Queue.new, "".b, false, false
17
+ input_read, @input_write = pipe
18
+ @output_read, output_write = pipe
19
+ console = [0].pack("J")
20
+ check_hresult(@kernel.fn(:CreatePseudoConsole, [I, P, P, U, P], I).call(coordinate(columns, rows), input_read, output_write, 0, console))
21
+ @console = console.unpack1("J")
22
+ size = [0].pack("J")
23
+ @kernel.fn(:InitializeProcThreadAttributeList, [P, U, U, P], I).call(0, 1, 0, size)
24
+ attributes = Fiddle::Pointer.malloc(size.unpack1("J"), Fiddle::RUBY_FREE)
25
+ check(@kernel.fn(:InitializeProcThreadAttributeList, [P, U, U, P], I).call(attributes, 1, 0, size), "InitializeProcThreadAttributeList")
26
+ check(@kernel.fn(:UpdateProcThreadAttribute, [P, U, N, P, N, P, P], I).call(attributes, 0, 0x20016, @console, 8, 0, 0), "UpdateProcThreadAttribute")
27
+ startup = "\0".b * 112
28
+ startup[0, 4] = [112].pack("I")
29
+ startup[60, 4] = [0x100].pack("I") # STARTF_USESTDHANDLES
30
+ startup[104, 8] = [attributes.to_i].pack("J")
31
+ info = "\0".b * 24
32
+ command ||= ENV.fetch("COMSPEC", "cmd.exe")
33
+ line = command.is_a?(Array) ? command.map { |part| self.class.quote_argument(part.to_s) }.join(" ") : command.to_s
34
+ line = wide(line)
35
+ directory = cwd ? wide(File.expand_path(cwd)) : nil
36
+ environment = env.empty? ? nil : (ENV.to_h.merge(env.transform_keys(&:to_s).transform_values(&:to_s)).sort_by { |key, _| key.downcase }.map { |key, value| "#{key}=#{value}\0" }.join + "\0").encode("UTF-16LE").b
37
+ check(@kernel.fn(:CreateProcessW, [P, P, P, P, I, U, P, P, P, P], I).call(0, line, 0, 0, 0, 0x00080400, environment || 0, directory || 0, startup, info), "CreateProcessW")
38
+ close_handle(input_read)
39
+ close_handle(output_write)
40
+ input_read = output_write = nil
41
+ @process, thread, @pid = info.unpack("JJI")
42
+ close_handle(thread)
43
+ @reader = Thread.new do
44
+ loop do
45
+ bytes, count = "\0".b * 65_536, [0].pack("I")
46
+ ok = @kernel.fn(:ReadFile, [P, P, U, P, P], I, need_gvl: false).call(@output_read, bytes, bytes.bytesize, count, 0)
47
+ break if ok.zero? || count.unpack1("I").zero?
48
+ @output << bytes.byteslice(0, count.unpack1("I"))
49
+ end
50
+ ensure
51
+ @output << nil
52
+ end
53
+ ensure
54
+ @kernel.fn(:DeleteProcThreadAttributeList, [P], V).call(attributes) if attributes
55
+ close_handle(input_read) if input_read
56
+ close_handle(output_write) if output_write
57
+ end
58
+
59
+ # CommandLineToArgvW/CRT escaping: backslashes before quotes and the
60
+ # closing delimiter must be doubled; other backslashes are literal.
61
+ def self.quote_argument(value)
62
+ return value unless value.empty? || value.match?(/[\s"]/)
63
+ '"' + value.gsub(/(\\*)"/) { Regexp.last_match(1) * 2 + '\\"' }.sub(/(\\+)\z/) { Regexp.last_match(1) * 2 } + '"'
64
+ end
65
+ def wide(text) = text.encode("UTF-16LE").b + "\0\0"
66
+ def coordinate(columns, rows)
67
+ raise ArgumentError, "invalid terminal size" unless columns.between?(1, 32_767) && rows.between?(1, 32_767)
68
+ [columns, rows].pack("s2").unpack1("l")
69
+ end
70
+ def pipe
71
+ read_handle, write_handle = [0].pack("J"), [0].pack("J")
72
+ check(@kernel.fn(:CreatePipe, [P, P, P, U], I).call(read_handle, write_handle, 0, 0), "CreatePipe")
73
+ [read_handle.unpack1("J"), write_handle.unpack1("J")]
74
+ end
75
+ def read_available(limit: 65_536)
76
+ raise ArgumentError, "limit must be positive" unless limit.positive?
77
+ until @output.empty?
78
+ bytes = @output.pop
79
+ if bytes.nil?
80
+ @eof = true
81
+ break
82
+ end
83
+ @pending << bytes
84
+ end
85
+ return nil if @pending.empty? && @eof
86
+ @pending.slice!(0, limit)
87
+ end
88
+ def write(bytes)
89
+ raise IOError, "terminal closed" if @closed
90
+ offset = 0
91
+ while offset < bytes.bytesize
92
+ chunk = bytes.byteslice(offset, bytes.bytesize - offset)
93
+ count = [0].pack("I")
94
+ check(@kernel.fn(:WriteFile, [P, P, U, P, P], I, need_gvl: false).call(@input_write, chunk, chunk.bytesize, count, 0), "WriteFile")
95
+ written = count.unpack1("I")
96
+ raise IOError, "ConPTY write made no progress" if written.zero?
97
+ offset += written
98
+ end
99
+ offset
100
+ end
101
+ def resize(columns, rows)
102
+ check_hresult(@kernel.fn(:ResizePseudoConsole, [P, I], I).call(@console, coordinate(columns, rows)))
103
+ end
104
+ def alive? = !@closed && @kernel.fn(:WaitForSingleObject, [P, U], U).call(@process, 0) == 258
105
+ def close
106
+ return if @closed
107
+ @closed = true
108
+ close_handle(@input_write)
109
+ # ReadFile releases the GVL so the reader drains ConPTY while closing.
110
+ @kernel.fn(:ClosePseudoConsole, [P], V, need_gvl: false).call(@console)
111
+ @reader.join(2)
112
+ close_handle(@output_read)
113
+ close_handle(@process)
114
+ end
115
+ def close_handle(handle) = @kernel.fn(:CloseHandle, [P], I).call(handle)
116
+ def check(result, name)
117
+ raise SystemCallError.new(name, Fiddle.last_error) if result.zero?
118
+ result
119
+ end
120
+ def check_hresult(result)
121
+ raise Error, "ConPTY failure 0x#{(result & 0xffffffff).to_s(16)}" unless (result & 0x80000000).zero?
122
+ result
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,91 @@
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 Windows
9
+ class Watcher
10
+ P, I, U, V = Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_UINT, Fiddle::TYPE_VOID
11
+ def initialize(paths, latency: 0.05)
12
+ @kernel, @handles, @closed = FFI::Library.new("kernel32.dll"), [], false
13
+ paths = Array(paths).map { |path| File.expand_path(path) }
14
+ raise ArgumentError, "watch paths required" if paths.empty?
15
+ paths.map { |path| File.directory?(path) ? path : File.dirname(path) }.uniq.each do |path|
16
+ handle = @kernel.fn(:CreateFileW, [P, U, U, P, U, U, P], P).call(path.encode("UTF-16LE").b + "\0\0", 1, 7, 0, 3, 0x42000000, 0)
17
+ raise SystemCallError.new("CreateFileW #{path}", Fiddle.last_error) if handle.to_i == -1 || handle.to_i == (1 << 64) - 1
18
+ event = @kernel.fn(:CreateEventW, [P, I, I, P], P).call(0, 1, 0, 0)
19
+ raise Error, "CreateEventW failed" if event.null?
20
+ overlapped = Fiddle::Pointer.malloc(32, Fiddle::RUBY_FREE)
21
+ overlapped[0, 32] = "\0" * 32
22
+ overlapped[24, 8] = [event.to_i].pack("J")
23
+ entry = [path, handle, event, overlapped, Fiddle::Pointer.malloc(65_536, Fiddle::RUBY_FREE)]
24
+ @handles << entry
25
+ issue(entry)
26
+ end
27
+ end
28
+ def issue(entry)
29
+ _, handle, event, overlapped, buffer = entry
30
+ @kernel.fn(:ResetEvent, [P], I).call(event)
31
+ ok = @kernel.fn(:ReadDirectoryChangesW, [P, P, U, I, U, P, P, P], I).call(handle, buffer, 65_536, 1, 0x17f, 0, overlapped, 0)
32
+ raise SystemCallError.new("ReadDirectoryChangesW", Fiddle.last_error) if ok.zero? && Fiddle.last_error != 997
33
+ end
34
+ def poll(timeout: 0)
35
+ return [] if @closed
36
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
37
+ events = []
38
+ loop do
39
+ @handles.each do |entry|
40
+ root, handle, event, overlapped, buffer = entry
41
+ next unless @kernel.fn(:WaitForSingleObject, [P, U], U).call(event, 0).zero?
42
+ count = [0].pack("I")
43
+ ok = @kernel.fn(:GetOverlappedResult, [P, P, P, I], I).call(handle, overlapped, count, 0)
44
+ size = count.unpack1("I")
45
+ if ok.zero? || size.zero?
46
+ events << FileEvent.new(:overflow, root, nil)
47
+ else
48
+ offset, old_path = 0, nil
49
+ loop do
50
+ following, action, length = buffer[offset, 12].unpack("I3")
51
+ break if offset + 12 + length > size
52
+ name = buffer[offset + 12, length].force_encoding("UTF-16LE").encode("UTF-8")
53
+ path = File.join(root, name.tr("\\", "/"))
54
+ if action == 4
55
+ old_path = path
56
+ else
57
+ kind = {1 => :created, 2 => :deleted, 3 => :modified, 5 => :renamed}.fetch(action, :modified)
58
+ events << FileEvent.new(kind, path, action == 5 ? old_path : nil)
59
+ old_path = nil
60
+ end
61
+ break if following.zero?
62
+ offset += following
63
+ end
64
+ end
65
+ issue(entry)
66
+ end
67
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
68
+ break if !events.empty? || remaining <= 0
69
+ # Wait on groups supported by Win32 instead of starting Ruby watcher threads.
70
+ @handles.each_slice(64) do |entries|
71
+ handles = entries.map { |entry| entry[2].to_i }.pack("J*")
72
+ @kernel.fn(:WaitForMultipleObjects, [U, P, I, U], U).call(entries.length, handles, 0, [(remaining * 1000).ceil, 50].min)
73
+ end
74
+ end
75
+ events.uniq
76
+ end
77
+ def close
78
+ return if @closed
79
+ @closed = true
80
+ @handles.each do |_, handle, event, overlapped, _|
81
+ @kernel.fn(:CancelIoEx, [P, P], I).call(handle, overlapped)
82
+ @kernel.fn(:GetOverlappedResult, [P, P, P, I], I, need_gvl: false).call(handle, overlapped, [0].pack("I"), 1)
83
+ @kernel.fn(:CloseHandle, [P], I).call(handle)
84
+ @kernel.fn(:CloseHandle, [P], I).call(event)
85
+ end
86
+ @handles.clear
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,407 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle/import"
4
+ require_relative "../../ffi/library"
5
+ require_relative "../../gpu/open_gl"
6
+
7
+ module Zaniah
8
+ module Platform
9
+ module Windows
10
+ def self.displays
11
+ user, scale = FFI::Library.new("user32.dll"), FFI::Library.new("shcore.dll")
12
+ ptype, int, uint = Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_UINT
13
+ result, error = [], nil
14
+ callback = Fiddle::Closure::BlockCaller.new(int, [ptype, ptype, ptype, Fiddle::TYPE_INTPTR_T]) do |monitor, _dc, _rect, _data|
15
+ begin
16
+ info = [104].pack("I") + "\0".b * 100
17
+ raise Error, "GetMonitorInfoW failed" if user.fn(:GetMonitorInfoW, [ptype, ptype], int).call(monitor, info).zero?
18
+ x, y, right, bottom = info[4, 16].unpack("l4")
19
+ dx, dy = [96].pack("I"), [96].pack("I")
20
+ scale.fn(:GetDpiForMonitor, [ptype, int, ptype, ptype], int).call(monitor, 0, dx, dy)
21
+ factor = dx.unpack1("I") / 96.0
22
+ name = info[40, 64].force_encoding("UTF-16LE").encode("UTF-8").split("\0", 2).first
23
+ result << Display.new(monitor.to_i, name, Bounds.new(x / factor, y / factor, (right - x) / factor, (bottom - y) / factor), factor, (info[36, 4].unpack1("I") & 1) != 0)
24
+ 1
25
+ rescue StandardError => exception
26
+ error = exception
27
+ 0
28
+ end
29
+ end
30
+ user.fn(:EnumDisplayMonitors, [ptype, ptype, ptype, Fiddle::TYPE_INTPTR_T], int).call(0, 0, callback, 0)
31
+ raise error if error
32
+ result
33
+ end
34
+ module Types
35
+ extend Fiddle::Importer
36
+ WindowClass = struct ["unsigned int size", "unsigned int style", "void *procedure", "int class_extra", "int window_extra", "void *instance", "void *icon", "void *cursor", "void *background", "void *menu_name", "void *class_name", "void *small_icon"]
37
+ PixelFormat = struct ["unsigned short size", "unsigned short version", "unsigned int flags", "unsigned char pixel_type", "unsigned char color_bits", "unsigned char red_bits", "unsigned char red_shift", "unsigned char green_bits", "unsigned char green_shift", "unsigned char blue_bits", "unsigned char blue_shift", "unsigned char alpha_bits", "unsigned char alpha_shift", "unsigned char accum_bits", "unsigned char accum_red_bits", "unsigned char accum_green_bits", "unsigned char accum_blue_bits", "unsigned char accum_alpha_bits", "unsigned char depth_bits", "unsigned char stencil_bits", "unsigned char aux_buffers", "unsigned char layer_type", "unsigned char reserved", "unsigned int layer_mask", "unsigned int visible_mask", "unsigned int damage_mask"]
38
+ OpenFileName = struct ["unsigned int size", "void *owner", "void *instance", "void *filter", "void *custom_filter", "unsigned int max_custom_filter", "unsigned int filter_index", "void *file", "unsigned int max_file", "void *file_title", "unsigned int max_file_title", "void *initial_dir", "void *title", "unsigned int flags", "unsigned short file_offset", "unsigned short extension_offset", "void *default_extension", "intptr_t custom_data", "void *hook", "void *template_name", "void *reserved", "unsigned int reserved_size", "unsigned int flags_ex"]
39
+ end
40
+
41
+ class Window < Headless::Window
42
+ def displays = Windows.displays
43
+ I, U, P, N, V = Fiddle::TYPE_INT, Fiddle::TYPE_UINT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INTPTR_T, Fiddle::TYPE_VOID
44
+ INSTANCES = {}
45
+ attr_reader :handle
46
+
47
+ def initialize(gpu: :opengl, **options)
48
+ raise Error, "native Windows backend requires a 64-bit Ruby" unless Fiddle::SIZEOF_VOIDP == 8
49
+ super(**options)
50
+ @user, @kernel, @gdi = FFI::Library.new("user32.dll"), FFI::Library.new("kernel32.dll"), FFI::Library.new("gdi32.dll")
51
+ @shell, @imm, @gl = FFI::Library.new("shell32.dll"), FFI::Library.new("imm32.dll"), FFI::Library.new("opengl32.dll")
52
+ @user.fn(:SetProcessDpiAwarenessContext, [P], I).call(-4)
53
+ instance = @kernel.fn(:GetModuleHandleW, [P], P).call(0)
54
+ @class_name = wide("ZaniahWindow#{object_id}")
55
+ @procedure = Fiddle::Closure::BlockCaller.new(N, [P, U, N, N]) do |handle, message, wparam, lparam|
56
+ window = INSTANCES[handle.to_i]
57
+ if window
58
+ window.native_callback { window.message(message, wparam, lparam) }
59
+ else
60
+ @user.fn(:DefWindowProcW, [P, U, N, N], N).call(handle, message, wparam, lparam)
61
+ end
62
+ end
63
+ definition = Types::WindowClass.malloc(Fiddle::RUBY_FREE)
64
+ definition.to_ptr[0, Types::WindowClass.size] = "\0" * Types::WindowClass.size
65
+ definition.size, definition.style, definition.procedure = Types::WindowClass.size, 0x0023, @procedure
66
+ definition.instance, definition.class_name = instance, Fiddle::Pointer[@class_name]
67
+ definition.cursor = @user.fn(:LoadCursorW, [P, P], P).call(0, 32512)
68
+ raise Error, "RegisterClassExW failed" if @user.fn(:RegisterClassExW, [P], I).call(definition).zero?
69
+ @instance = instance
70
+ @handle = @user.fn(:CreateWindowExW, [U, P, P, U, I, I, I, I, P, P, P, P], P).call(0, @class_name, wide(@title), 0x00CF0000, 100, 100, content_size.width.to_i, content_size.height.to_i, 0, 0, instance, 0)
71
+ raise Error, "CreateWindowExW failed" if @handle.null?
72
+ INSTANCES[@handle.to_i] = self
73
+ @scale_factor = @user.fn(:GetDpiForWindow, [P], U).call(@handle) / 96.0
74
+ client = "\0" * 16
75
+ @user.fn(:GetClientRect, [P, P], I).call(@handle, client)
76
+ _, _, width, height = client.unpack("l4")
77
+ @content_size = Size.new(width / @scale_factor, height / @scale_factor)
78
+ @dc = @user.fn(:GetDC, [P], P).call(@handle)
79
+ @device.release
80
+ @device = create_device(gpu)
81
+ @shell.fn(:DragAcceptFiles, [P, I], V).call(@handle, 1)
82
+ @user.fn(:ShowWindow, [P, I], I).call(@handle, 5)
83
+ @user.fn(:UpdateWindow, [P], I).call(@handle)
84
+ end
85
+ def wide(text) = text.to_s.encode("UTF-16LE").b + "\0\0".b
86
+ def create_device(backend)
87
+ raise ArgumentError, "Win32 supports :opengl" unless [:opengl, :gl].include?(backend)
88
+ descriptor = Types::PixelFormat.malloc(Fiddle::RUBY_FREE)
89
+ descriptor.to_ptr[0, Types::PixelFormat.size] = "\0" * Types::PixelFormat.size
90
+ descriptor.size, descriptor.version, descriptor.flags = Types::PixelFormat.size, 1, 0x25
91
+ descriptor.color_bits, descriptor.alpha_bits = 32, 8
92
+ format = @gdi.fn(:ChoosePixelFormat, [P, P], I).call(@dc, descriptor)
93
+ raise Error, "ChoosePixelFormat failed" if format.zero?
94
+ raise Error, "SetPixelFormat failed" if @gdi.fn(:SetPixelFormat, [P, I, P], I).call(@dc, format, descriptor).zero?
95
+ temporary = @gl.fn(:wglCreateContext, [P], P).call(@dc)
96
+ raise Error, "wglCreateContext failed" if temporary.null?
97
+ @gl.fn(:wglMakeCurrent, [P, P], I).call(@dc, temporary)
98
+ resolver = ->(name) { @gl.fn(:wglGetProcAddress, [P], P).call(name) }
99
+ address = resolver.call("wglCreateContextAttribsARB")
100
+ raise Error, "WGL_ARB_create_context unavailable" if address.to_i <= 3 || address.to_i == -1
101
+ attributes = [0x2091, 3, 0x2092, 3, 0x9126, 1, 0].pack("i*")
102
+ @context = Fiddle::Function.new(address, [P, P, P], P).call(@dc, 0, attributes)
103
+ @gl.fn(:wglMakeCurrent, [P, P], I).call(0, 0)
104
+ @gl.fn(:wglDeleteContext, [P], I).call(temporary)
105
+ raise Error, "OpenGL 3.3 core context unavailable" if @context.null?
106
+ make_current
107
+ swap = resolver.call("wglSwapIntervalEXT")
108
+ Fiddle::Function.new(swap, [I], I).call(1) if swap.to_i > 3
109
+ GPU::OpenGL.new(self, library: @gl, resolver: resolver)
110
+ end
111
+ def make_current
112
+ raise Error, "wglMakeCurrent failed" if @gl.fn(:wglMakeCurrent, [P, P], I).call(@dc, @context).zero?
113
+ end
114
+ def swap_buffers = @gdi.fn(:SwapBuffers, [P], I).call(@dc)
115
+ def title=(title)
116
+ super
117
+ @user.fn(:SetWindowTextW, [P, P], I).call(@handle, wide(title)) if @handle
118
+ end
119
+ def tick
120
+ event = "\0" * 48
121
+ while !closed? && @user.fn(:PeekMessageW, [P, P, U, U, U], I).call(event, 0, 0, 0, 1) != 0
122
+ @user.fn(:TranslateMessage, [P], I).call(event)
123
+ @user.fn(:DispatchMessageW, [P], N).call(event)
124
+ end
125
+ raise @native_error if @native_error
126
+ super
127
+ end
128
+ def run
129
+ until closed?
130
+ tick
131
+ @user.fn(:MsgWaitForMultipleObjectsEx, [U, P, U, U, U], U).call(0, 0, 50, 0x04FF, 4) unless dirty? || closed?
132
+ end
133
+ end
134
+ def native_callback
135
+ yield
136
+ rescue StandardError => error
137
+ @native_error = error
138
+ 0
139
+ end
140
+ def modifiers
141
+ [[0x11, "ctrl"], [0x12, "alt"], [0x10, "shift"], [0x5B, "cmd"]].filter_map do |code, name|
142
+ name unless (@user.fn(:GetKeyState, [I], Fiddle::TYPE_SHORT).call(code) & 0x8000).zero?
143
+ end
144
+ end
145
+ KEYS = {8 => "backspace", 9 => "tab", 13 => "enter", 27 => "esc", 32 => "space", 33 => "pageup", 34 => "pagedown", 35 => "end", 36 => "home", 37 => "left", 38 => "up", 39 => "right", 40 => "down", 46 => "delete"}.freeze
146
+ def message(message, wparam, lparam)
147
+ case message
148
+ when 0x0010 then close; return 0
149
+ when 0x0233
150
+ dropped_files(wparam)
151
+ return 0
152
+ when 0x001a, 0x031a
153
+ @on_appearance&.call(appearance)
154
+ request_frame
155
+ when 0x0005
156
+ width, height = lparam & 0xffff, (lparam >> 16) & 0xffff
157
+ resize(width / @scale_factor, height / @scale_factor) if width.positive? && height.positive?
158
+ when 0x0003 then @on_moved&.call([signed16(lparam), signed16(lparam >> 16)])
159
+ when 0x02e0
160
+ @scale_factor = (wparam & 0xffff) / 96.0
161
+ left, top, right, bottom = Fiddle::Pointer.new(lparam)[0, 16].unpack("l4")
162
+ @user.fn(:SetWindowPos, [P, P, I, I, I, I, U], I).call(@handle, 0, left, top, right - left, bottom - top, 0x0014)
163
+ when 0x000f
164
+ paint = "\0" * 128
165
+ @user.fn(:BeginPaint, [P, P], P).call(@handle, paint)
166
+ @user.fn(:EndPaint, [P, P], I).call(@handle, paint)
167
+ request_frame
168
+ return 0
169
+ when 0x0100, 0x0101, 0x0104, 0x0105
170
+ character = @user.fn(:MapVirtualKeyW, [U, U], U).call(wparam, 2) & 0x7fffffff
171
+ name = KEYS[wparam] || ((0x70..0x87).cover?(wparam) ? "f#{wparam - 0x6f}" : character.between?(32, 0x10ffff) ? character.chr(Encoding::UTF_8).downcase : "key#{wparam}")
172
+ stroke = (modifiers + [name]).join("-")
173
+ input([0x0100, 0x0104].include?(message) ? Input::KeyDown.new(stroke, (lparam & (1 << 30)) != 0) : Input::KeyUp.new(stroke))
174
+ return 0 unless [0x0104, 0x0105].include?(message)
175
+ when 0x0102
176
+ if (0xd800..0xdbff).cover?(wparam)
177
+ @high_surrogate = wparam
178
+ elsif (0xdc00..0xdfff).cover?(wparam) && @high_surrogate
179
+ commit_character(0x10000 + ((@high_surrogate - 0xd800) << 10) + wparam - 0xdc00)
180
+ @high_surrogate = nil
181
+ else
182
+ @high_surrogate = nil
183
+ commit_character(wparam) unless (0xd800..0xdfff).cover?(wparam)
184
+ end
185
+ return 0
186
+ when 0x0109
187
+ return 1 if wparam == 0xffff
188
+ commit_character(wparam)
189
+ return 0
190
+ when 0x010d, 0x010e then input(Input::Composition.new("", [0, 0]))
191
+ when 0x010f
192
+ with_ime do |context|
193
+ if (lparam & 0x800) != 0
194
+ input(Input::Composition.new("", [0, 0]))
195
+ input(Input::TextInput.new(composition_string(context, 0x800)))
196
+ end
197
+ if (lparam & 8) != 0
198
+ text = composition_string(context, 8)
199
+ cursor = @imm.fn(:ImmGetCompositionStringW, [P, U, P, U], I).call(context, 0x80, 0, 0)
200
+ input(Input::Composition.new(text, [[cursor, 0].max, 0]))
201
+ end
202
+ end
203
+ return 0
204
+ when 0x0200, 0x0201, 0x0202, 0x0203, 0x0204, 0x0205, 0x0206, 0x0207, 0x0208, 0x0209, 0x020a, 0x020e
205
+ mouse_message(message, wparam, lparam)
206
+ return 0
207
+ end
208
+ @user.fn(:DefWindowProcW, [P, U, N, N], N).call(@handle, message, wparam, lparam)
209
+ end
210
+ def signed16(number) = (number & 0xffff) >= 0x8000 ? (number & 0xffff) - 0x10000 : number & 0xffff
211
+ def commit_character(code)
212
+ input(Input::TextInput.new(code.chr(Encoding::UTF_8))) if code >= 32 && code != 127 && code <= 0x10ffff
213
+ end
214
+ def mouse_message(message, wparam, lparam)
215
+ x, y = signed16(lparam), signed16(lparam >> 16)
216
+ if [0x020a, 0x020e].include?(message)
217
+ point = [x, y].pack("l2")
218
+ @user.fn(:ScreenToClient, [P, P], I).call(@handle, point)
219
+ x, y = point.unpack("l2")
220
+ end
221
+ position, mods = Point.new(x / @scale_factor, y / @scale_factor), modifiers
222
+ if [0x020a, 0x020e].include?(message)
223
+ amount = signed16(wparam >> 16) / 3.0
224
+ input(Input::ScrollWheel.new(position, message == 0x020a ? Point.new(0, -amount) : Point.new(amount, 0), :changed, mods))
225
+ elsif message == 0x0200
226
+ input(Input::MouseMove.new(position, mods))
227
+ else
228
+ double = [0x0203, 0x0206, 0x0209].include?(message)
229
+ down = double || [0x0201, 0x0204, 0x0207].include?(message)
230
+ button = message <= 0x0203 ? :left : message <= 0x0206 ? :right : :middle
231
+ down ? @user.fn(:SetCapture, [P], P).call(@handle) : @user.fn(:ReleaseCapture, [], I).call
232
+ input(down ? Input::MouseDown.new(position, button, mods, double ? 2 : 1) : Input::MouseUp.new(position, button, mods))
233
+ end
234
+ end
235
+ def with_ime
236
+ context = @imm.fn(:ImmGetContext, [P], P).call(@handle)
237
+ yield context unless context.null?
238
+ ensure
239
+ @imm.fn(:ImmReleaseContext, [P, P], I).call(@handle, context) if context && !context.null?
240
+ end
241
+ def composition_string(context, kind)
242
+ size = @imm.fn(:ImmGetCompositionStringW, [P, U, P, U], I).call(context, kind, 0, 0)
243
+ return "" unless size.positive?
244
+ bytes = "\0" * size
245
+ @imm.fn(:ImmGetCompositionStringW, [P, U, P, U], I).call(context, kind, bytes, size)
246
+ bytes.force_encoding("UTF-16LE").encode("UTF-8", invalid: :replace)
247
+ end
248
+ def ime_state=(bounds)
249
+ @ime_state = bounds
250
+ return unless @handle && bounds.is_a?(Bounds)
251
+ with_ime do |context|
252
+ point = [(bounds.x * @scale_factor).to_i, (bounds.bottom * @scale_factor).to_i]
253
+ form = [0x20, *point, 0, 0, 0, 0].pack("l7")
254
+ @imm.fn(:ImmSetCompositionWindow, [P, P], I).call(context, form)
255
+ candidate = [0, 0x40, *point, 0, 0, 0, 0].pack("l8")
256
+ @imm.fn(:ImmSetCandidateWindow, [P, P], I).call(context, candidate)
257
+ end
258
+ end
259
+ def clipboard
260
+ with_clipboard do
261
+ memory = @user.fn(:GetClipboardData, [U], P).call(13)
262
+ next "" if memory.null?
263
+ pointer = @kernel.fn(:GlobalLock, [P], P).call(memory)
264
+ next "" if pointer.null?
265
+ size = @kernel.fn(:GlobalSize, [P], Fiddle::TYPE_SIZE_T).call(memory)
266
+ values = pointer[0, size].unpack("v*").take_while { |value| !value.zero? }
267
+ @kernel.fn(:GlobalUnlock, [P], I).call(memory)
268
+ values.pack("v*").force_encoding("UTF-16LE").encode("UTF-8", invalid: :replace)
269
+ end
270
+ end
271
+ def clipboard=(text)
272
+ bytes = wide(text)
273
+ with_clipboard do
274
+ memory = @kernel.fn(:GlobalAlloc, [U, Fiddle::TYPE_SIZE_T], P).call(2, bytes.bytesize)
275
+ raise NoMemoryError, "clipboard allocation failed" if memory.null?
276
+ pointer = @kernel.fn(:GlobalLock, [P], P).call(memory)
277
+ pointer[0, bytes.bytesize] = bytes
278
+ @kernel.fn(:GlobalUnlock, [P], I).call(memory)
279
+ @user.fn(:EmptyClipboard, [], I).call
280
+ result = @user.fn(:SetClipboardData, [U, P], P).call(13, memory)
281
+ if result.null?
282
+ @kernel.fn(:GlobalFree, [P], P).call(memory)
283
+ raise Error, "SetClipboardData failed"
284
+ end
285
+ end
286
+ end
287
+ def with_clipboard
288
+ raise Error, "clipboard is busy" if @user.fn(:OpenClipboard, [P], I).call(@handle).zero?
289
+ begin
290
+ yield
291
+ ensure
292
+ @user.fn(:CloseClipboard, [], I).call
293
+ end
294
+ end
295
+ def clipboard_paths
296
+ with_clipboard do
297
+ drop = @user.fn(:GetClipboardData, [U], P).call(15)
298
+ next [] if drop.null?
299
+ count = @shell.fn(:DragQueryFileW, [P, U, P, U], U).call(drop, 0xffffffff, 0, 0)
300
+ Array.new(count) do |i|
301
+ size = @shell.fn(:DragQueryFileW, [P, U, P, U], U).call(drop, i, 0, 0)
302
+ bytes = "\0" * ((size + 1) * 2)
303
+ @shell.fn(:DragQueryFileW, [P, U, P, U], U).call(drop, i, bytes, size + 1)
304
+ bytes.byteslice(0, size * 2).force_encoding("UTF-16LE").encode("UTF-8")
305
+ end
306
+ end
307
+ end
308
+ def dropped_files(drop)
309
+ count = @shell.fn(:DragQueryFileW, [P, U, P, U], U).call(drop, 0xffffffff, 0, 0)
310
+ paths = Array.new(count) do |index|
311
+ size = @shell.fn(:DragQueryFileW, [P, U, P, U], U).call(drop, index, 0, 0)
312
+ bytes = "\0" * ((size + 1) * 2)
313
+ @shell.fn(:DragQueryFileW, [P, U, P, U], U).call(drop, index, bytes, size + 1)
314
+ bytes.byteslice(0, size * 2).force_encoding("UTF-16LE").encode("UTF-8")
315
+ end
316
+ point = "\0" * 8
317
+ @shell.fn(:DragQueryPoint, [P, P], I).call(drop, point)
318
+ x, y = point.unpack("l2")
319
+ input(Input::FileDrop.new(paths.freeze, Point.new(x / @scale_factor, y / @scale_factor)))
320
+ ensure
321
+ @shell.fn(:DragFinish, [P], V).call(drop)
322
+ end
323
+ def appearance
324
+ registry = FFI::Library.new("advapi32.dll")
325
+ value, size = [1].pack("I"), [4].pack("I")
326
+ result = registry.fn(:RegGetValueW, [P, P, P, U, P, P, P], I).call(-2147483647,
327
+ wide("Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"), wide("AppsUseLightTheme"), 0x10, 0, value, size)
328
+ result.zero? && value.unpack1("I").zero? ? :dark : :light
329
+ end
330
+ def on_appearance(&block) = @on_appearance = block
331
+ def context_menu(items, position: nil)
332
+ menu = @user.fn(:CreatePopupMenu, [], P).call
333
+ raise Error, "CreatePopupMenu failed" if menu.null?
334
+ items.each_with_index do |(label, action), index|
335
+ @user.fn(:AppendMenuW, [P, U, N, P], I).call(menu, action ? 0 : 1, index + 1, wide(label))
336
+ end
337
+ point = position ? [(position.x * @scale_factor).round, (position.y * @scale_factor).round].pack("l2") : "\0" * 8
338
+ position ? @user.fn(:ClientToScreen, [P, P], I).call(@handle, point) : @user.fn(:GetCursorPos, [P], I).call(point)
339
+ x, y = point.unpack("l2")
340
+ selected = @user.fn(:TrackPopupMenuEx, [P, U, I, I, P, P], U).call(menu, 0x182, x, y, @handle, 0)
341
+ items[selected - 1][1]&.call if selected.positive?
342
+ ensure
343
+ @user.fn(:DestroyMenu, [P], I).call(menu) if menu && !menu.null?
344
+ end
345
+ def cursor_style=(style)
346
+ id = {arrow: 32512, text: 32513, pointer: 32649, crosshair: 32515, resize_horizontal: 32644, resize_vertical: 32645}.fetch(style)
347
+ cursor = @user.fn(:LoadCursorW, [P, P], P).call(0, id)
348
+ @user.fn(:SetCursor, [P], P).call(cursor)
349
+ end
350
+ def prompt_for_paths(multiple: false, directories: false, save: false)
351
+ return directory_dialog if directories
352
+ dialog = Types::OpenFileName.malloc(Fiddle::RUBY_FREE)
353
+ dialog.to_ptr[0, Types::OpenFileName.size] = "\0" * Types::OpenFileName.size
354
+ bytes = "\0" * 131_072
355
+ dialog.size, dialog.owner, dialog.file, dialog.max_file = Types::OpenFileName.size, @handle, Fiddle::Pointer[bytes], bytes.bytesize / 2
356
+ dialog.flags = 0x00080000 | 0x00000800 | (multiple ? 0x200 : 0) | (save ? 2 : 0x1000)
357
+ library = FFI::Library.new("comdlg32.dll")
358
+ return [] if library.fn(save ? :GetSaveFileNameW : :GetOpenFileNameW, [P], I).call(dialog).zero?
359
+ entries = bytes.force_encoding("UTF-16LE").encode("UTF-8").split("\0").take_while { |text| !text.empty? }
360
+ entries.length > 1 ? entries.drop(1).map { |file| File.join(entries.first, file) } : entries
361
+ end
362
+ def directory_dialog
363
+ # BROWSEINFOW is pointer-only except flags and the final image index.
364
+ title = wide("Choose a directory")
365
+ info = [@handle.to_i, 0, 0, Fiddle::Pointer[title].to_i, 0x41, 0, 0, 0].pack("J4Ix4J2Ix4")
366
+ @shell.fn(:SHBrowseForFolderW, [P], P).call(info).then do |pidl|
367
+ return [] if pidl.null?
368
+ bytes = "\0" * 65_536
369
+ ok = @shell.fn(:SHGetPathFromIDListEx, [P, P, U, U], I).call(pidl, bytes, bytes.bytesize / 2, 0)
370
+ FFI::Library.new("ole32.dll").fn(:CoTaskMemFree, [P], V).call(pidl)
371
+ ok.zero? ? [] : [bytes.force_encoding("UTF-16LE").encode("UTF-8").split("\0", 2).first]
372
+ end
373
+ end
374
+ def open_url(url)
375
+ @shell.fn(:ShellExecuteW, [P, P, P, P, P, I], P).call(@handle, wide("open"), wide(url), 0, 0, 1).to_i > 32
376
+ end
377
+ def toggle_fullscreen
378
+ if @restore_rect
379
+ @user.fn(:SetWindowLongPtrW, [P, I, N], N).call(@handle, -16, 0x00CF0000)
380
+ left, top, right, bottom = @restore_rect.unpack("l4")
381
+ @user.fn(:SetWindowPos, [P, P, I, I, I, I, U], I).call(@handle, 0, left, top, right - left, bottom - top, 0x0020)
382
+ @restore_rect = nil
383
+ else
384
+ @restore_rect = "\0" * 16
385
+ @user.fn(:GetWindowRect, [P, P], I).call(@handle, @restore_rect)
386
+ monitor = @user.fn(:MonitorFromWindow, [P, U], P).call(@handle, 2)
387
+ info = [40].pack("I") + "\0" * 36
388
+ @user.fn(:GetMonitorInfoW, [P, P], I).call(monitor, info)
389
+ left, top, right, bottom = info[4, 16].unpack("l4")
390
+ @user.fn(:SetWindowLongPtrW, [P, I, N], N).call(@handle, -16, 0x80000000)
391
+ @user.fn(:SetWindowPos, [P, P, I, I, I, I, U], I).call(@handle, 0, left, top, right - left, bottom - top, 0x0020)
392
+ end
393
+ end
394
+ def close
395
+ return false unless super
396
+ @gl.fn(:wglMakeCurrent, [P, P], I).call(0, 0)
397
+ @gl.fn(:wglDeleteContext, [P], I).call(@context)
398
+ @user.fn(:ReleaseDC, [P, P], I).call(@handle, @dc)
399
+ INSTANCES.delete(@handle.to_i)
400
+ @user.fn(:DestroyWindow, [P], I).call(@handle)
401
+ @user.fn(:UnregisterClassW, [P, P], I).call(@class_name, @instance)
402
+ true
403
+ end
404
+ end
405
+ end
406
+ end
407
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "windows/window"
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "platform/display"
4
+ require_relative "platform/headless/window"
5
+ require_relative "platform/tui/window"
6
+
7
+ module Zaniah
8
+ module Platform
9
+ def self.displays(backend: :auto, **options)
10
+ backend = RUBY_PLATFORM.include?("darwin") ? :mac : RUBY_PLATFORM.match?(/mswin|mingw/) ? :windows : :linux if backend == :auto
11
+ return [] if [:headless, :tui].include?(backend)
12
+ raise ArgumentError, "unknown platform #{backend}" unless [:mac, :linux, :windows].include?(backend)
13
+ require_relative "platform/#{backend}"
14
+ const_get(backend.to_s.capitalize).displays(**options)
15
+ end
16
+
17
+ def self.watch(paths, latency: 0.05)
18
+ platform = RUBY_PLATFORM.include?("darwin") ? :mac : RUBY_PLATFORM.match?(/mswin|mingw/) ? :windows : :linux
19
+ require_relative "platform/#{platform}/watcher"
20
+ const_get(platform.to_s.capitalize)::Watcher.new(paths, latency: latency)
21
+ end
22
+
23
+ def self.open_window(backend: :headless, **options)
24
+ case backend
25
+ when :headless then Headless::Window.new(**options)
26
+ when :tui then TUI::Window.new(**options)
27
+ when :mac, :linux, :windows
28
+ require_relative "platform/#{backend}"
29
+ const_get(backend.to_s.capitalize)::Window.new(**options)
30
+ else raise ArgumentError, "unknown platform #{backend}"
31
+ end
32
+ end
33
+ end
34
+ end