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,63 @@
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 Mac
9
+ class Watcher
10
+ P, I, L, D, V = Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_LONG_LONG, Fiddle::TYPE_DOUBLE, Fiddle::TYPE_VOID
11
+ def initialize(paths, latency: 0.05)
12
+ @cf = FFI::Library.new("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")
13
+ @fs = FFI::Library.new("/System/Library/Frameworks/CoreServices.framework/CoreServices")
14
+ @events, @closed = [], false
15
+ @thread = Thread.current
16
+ paths = Array(paths).map { |path| File.expand_path(path) }
17
+ raise ArgumentError, "watch paths required" if paths.empty?
18
+ strings = paths.map do |path|
19
+ @cf.fn(:CFStringCreateWithCString, [P, P, I], P).call(0, path, 0x08000100)
20
+ end
21
+ pointers = strings.map(&:to_i).pack("J*")
22
+ array = @cf.fn(:CFArrayCreate, [P, P, Fiddle::TYPE_LONG, P], P).call(0, pointers, strings.length, @cf.handle["kCFTypeArrayCallBacks"])
23
+ @callback = Fiddle::Closure::BlockCaller.new(V, [P, P, Fiddle::TYPE_SIZE_T, P, P, P]) do |_stream, _info, count, native_paths, flags, _ids|
24
+ count.times do |index|
25
+ address = Fiddle::Pointer.new(native_paths)[index * Fiddle::SIZEOF_VOIDP, Fiddle::SIZEOF_VOIDP].unpack1("J")
26
+ path = Fiddle::Pointer.new(address).to_s.force_encoding("UTF-8")
27
+ mask = Fiddle::Pointer.new(flags)[index * 4, 4].unpack1("I")
28
+ kind = if (mask & 0x7) != 0 then :overflow
29
+ elsif (mask & 0x200) != 0 then :deleted
30
+ elsif (mask & 0x100) != 0 then :created
31
+ else :modified
32
+ end
33
+ @events << FileEvent.new(kind, path, nil)
34
+ end
35
+ end
36
+ @stream = @fs.fn(:FSEventStreamCreate, [P, P, P, P, L, D, I], P).call(0, @callback, 0, array, -1, latency, 0x16)
37
+ raise Error, "FSEventStreamCreate failed" if @stream.null?
38
+ @loop = @cf.fn(:CFRunLoopGetCurrent, [], P).call
39
+ @mode = Fiddle::Pointer.new(@cf.handle["kCFRunLoopDefaultMode"])[0, Fiddle::SIZEOF_VOIDP].unpack1("J")
40
+ @fs.fn(:FSEventStreamScheduleWithRunLoop, [P, P, P], V).call(@stream, @loop, @mode)
41
+ raise Error, "FSEventStreamStart failed" if @fs.fn(:FSEventStreamStart, [P], Fiddle::TYPE_BOOL).call(@stream) == 0
42
+ ensure
43
+ strings&.each { |string| @cf.fn(:CFRelease, [P], V).call(string) }
44
+ @cf.fn(:CFRelease, [P], V).call(array) if array
45
+ end
46
+ def poll(timeout: 0)
47
+ return [] if @closed
48
+ raise Error, "FSEvents must be polled on its creating thread" unless Thread.current == @thread
49
+ @cf.fn(:CFRunLoopRunInMode, [P, D, Fiddle::TYPE_BOOL], I).call(@mode, timeout, 1)
50
+ events, @events = @events, []
51
+ events
52
+ end
53
+ def close
54
+ return if @closed
55
+ @closed = true
56
+ @fs.fn(:FSEventStreamStop, [P], V).call(@stream)
57
+ @fs.fn(:FSEventStreamInvalidate, [P], V).call(@stream)
58
+ @fs.fn(:FSEventStreamRelease, [P], V).call(@stream)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Platform
5
+ module Mac
6
+ class Window < Headless::Window
7
+ def displays = Mac.displays
8
+ attr_reader :handle, :view, :layer
9
+
10
+ def initialize(gpu: :metal, **options)
11
+ super(**options)
12
+ App.instance
13
+ self.class.install_classes
14
+ @handle = O.send(O.alloc("NSWindow"), "initWithContentRect:styleMask:backing:defer:",
15
+ [0, 0, content_size.width, content_size.height], 15, 2, 0,
16
+ args: [:rect, :ulong, :ulong, :bool])
17
+ raise Error, "NSWindow creation failed" if @handle.zero?
18
+ O.send(@handle, "setReleasedWhenClosed:", 0, args: [:bool], result: :void)
19
+ @view = O.send(O.alloc("ZaniahNativeView"), "initWithFrame:", [0, 0, content_size.width, content_size.height], args: [:rect])
20
+ @delegate = O.new("ZaniahWindowDelegate")
21
+ WINDOWS[@handle] = WINDOWS[@view] = WINDOWS[@delegate] = self
22
+ O.send(@view, "setAutoresizingMask:", 18, args: [:ulong], result: :void)
23
+ O.send(@handle, "setContentView:", @view, args: [:pointer], result: :void)
24
+ O.send(@handle, "setDelegate:", @delegate, args: [:pointer], result: :void)
25
+ O.send(@handle, "setAcceptsMouseMovedEvents:", 1, args: [:bool], result: :void)
26
+ O.send(@handle, "makeFirstResponder:", @view, args: [:pointer], result: :bool)
27
+ types = [O.string("NSFilenamesPboardType"), O.string("public.file-url")].pack("J2")
28
+ list = O.send(O.klass("NSArray"), "arrayWithObjects:count:", Fiddle::Pointer[types], 2, args: [:pointer, :ulong])
29
+ O.send(@view, "registerForDraggedTypes:", list, args: [:pointer], result: :void)
30
+ self.title = @title
31
+ O.send(@handle, "center", result: :void)
32
+ @scale_factor = O.send(@handle, "backingScaleFactor", result: :double)
33
+ @device.release
34
+ @device = create_device(gpu)
35
+ O.send(@handle, "makeKeyAndOrderFront:", 0, args: [:pointer], result: :void)
36
+ end
37
+
38
+ def self.install_classes
39
+ O.subclass("ZaniahNativeView", "NSView", protocols: ["NSTextInputClient"]) do |klass|
40
+ O.method(klass, "acceptsFirstResponder", result: :bool, encoding: "B@:") { 1 }
41
+ O.method(klass, "isFlipped", result: :bool, encoding: "B@:") { 1 }
42
+ O.method(klass, "viewDidChangeEffectiveAppearance", encoding: "v@:") do |receiver, _|
43
+ WINDOWS[receiver]&.native_callback { WINDOWS[receiver].appearance_changed }
44
+ end
45
+ %w[draggingEntered: draggingUpdated:].each do |name|
46
+ O.method(klass, name, args: [:pointer], result: :ulong, encoding: "Q@:@") { 1 }
47
+ end
48
+ O.method(klass, "performDragOperation:", args: [:pointer], result: :bool, encoding: "B@:@") do |receiver, _, dragging|
49
+ WINDOWS[receiver]&.native_callback { WINDOWS[receiver].file_drop(dragging) } ? 1 : 0
50
+ end
51
+ %w[keyDown: keyUp: flagsChanged: mouseDown: mouseUp: rightMouseDown: rightMouseUp: otherMouseDown: otherMouseUp: mouseMoved: mouseDragged: rightMouseDragged: otherMouseDragged: scrollWheel:].each do |name|
52
+ O.method(klass, name, args: [:pointer], encoding: "v@:@") do |receiver, _, event|
53
+ WINDOWS[receiver]&.native_callback { WINDOWS[receiver].native_input(name, event) }
54
+ end
55
+ end
56
+ O.method(klass, "insertText:replacementRange:", args: [:pointer, :range], encoding: "v@:@{_NSRange=QQ}") do |receiver, _, value, _range|
57
+ WINDOWS[receiver]&.native_callback { WINDOWS[receiver].commit_text(value) }
58
+ end
59
+ O.method(klass, "setMarkedText:selectedRange:replacementRange:", args: [:pointer, :range, :range], encoding: "v@:@{_NSRange=QQ}{_NSRange=QQ}") do |receiver, _, value, selection, _replacement|
60
+ WINDOWS[receiver]&.native_callback { WINDOWS[receiver].marked_text(value, selection) }
61
+ end
62
+ O.method(klass, "unmarkText", encoding: "v@:") { |receiver, _| WINDOWS[receiver]&.marked_text(0, [0, 0]) }
63
+ O.method(klass, "hasMarkedText", result: :bool, encoding: "B@:") { |receiver, _| WINDOWS[receiver]&.composing? ? 1 : 0 }
64
+ O.method(klass, "markedRange", result: :range, encoding: "{_NSRange=QQ}@:") { |receiver, _| WINDOWS[receiver]&.marked_range || [NS_NOT_FOUND, 0] }
65
+ O.method(klass, "selectedRange", result: :range, encoding: "{_NSRange=QQ}@:") { |receiver, _| WINDOWS[receiver]&.selected_range || [0, 0] }
66
+ O.method(klass, "validAttributesForMarkedText", result: :pointer, encoding: "@@:") { O.send(O.klass("NSArray"), "array") }
67
+ O.method(klass, "attributedSubstringForProposedRange:actualRange:", args: [:range, :pointer], result: :pointer, encoding: "@@:{_NSRange=QQ}^{_NSRange=QQ}") { 0 }
68
+ O.method(klass, "characterIndexForPoint:", args: [:point], result: :ulong, encoding: "Q@:{CGPoint=dd}") { 0 }
69
+ O.method(klass, "firstRectForCharacterRange:actualRange:", args: [:range, :pointer], result: :rect, encoding: "{CGRect={CGPoint=dd}{CGSize=dd}}@:{_NSRange=QQ}^{_NSRange=QQ}") do |receiver, _, range, actual|
70
+ Fiddle::Pointer.new(actual)[0, 16] = range.pack("Q2") unless actual.zero?
71
+ WINDOWS[receiver]&.candidate_rect || [0, 0, 0, 0]
72
+ end
73
+ O.method(klass, "doCommandBySelector:", args: [:pointer], encoding: "v@::") { nil }
74
+ end
75
+ O.subclass("ZaniahWindowDelegate", "NSObject", protocols: ["NSWindowDelegate"]) do |klass|
76
+ O.method(klass, "contextMenuAction:", args: [:pointer], encoding: "v@:@") do |receiver, _, item|
77
+ WINDOWS[receiver]&.native_callback { WINDOWS[receiver].context_action(O.send(item, "tag", result: :long)) }
78
+ end
79
+ %w[windowDidResize: windowDidChangeBackingProperties: windowDidMove:].each do |name|
80
+ O.method(klass, name, args: [:pointer], encoding: "v@:@") do |receiver, _, _notification|
81
+ WINDOWS[receiver]&.native_callback { WINDOWS[receiver].native_resize(moved: name == "windowDidMove:") }
82
+ end
83
+ end
84
+ O.method(klass, "windowShouldClose:", args: [:pointer], result: :bool, encoding: "B@:@") do |receiver, _, _window|
85
+ WINDOWS[receiver]&.native_callback { WINDOWS[receiver].close }
86
+ 0
87
+ end
88
+ end
89
+ end
90
+
91
+ def create_device(backend)
92
+ case backend
93
+ when :metal
94
+ require_relative "../../gpu/metal"
95
+ @layer ||= O.new("CAMetalLayer")
96
+ O.send(@view, "setWantsLayer:", 1, args: [:bool], result: :void)
97
+ O.send(@view, "setLayer:", @layer, args: [:pointer], result: :void)
98
+ GPU::Metal.new(self)
99
+ when :opengl, :gl
100
+ require_relative "../../gpu/open_gl"
101
+ O.send(@view, "setWantsBestResolutionOpenGLSurface:", 1, args: [:bool], result: :void)
102
+ attributes = [99, 0x4100, 5, 8, 24, 11, 8, 0].pack("I*")
103
+ format = O.send(O.alloc("NSOpenGLPixelFormat"), "initWithAttributes:", Fiddle::Pointer[attributes], args: [:pointer])
104
+ raise Error, "OpenGL 4.1 core pixel format unavailable" if format.zero?
105
+ @gl_context = O.send(O.alloc("NSOpenGLContext"), "initWithFormat:shareContext:", format, 0, args: [:pointer, :pointer])
106
+ O.release(format)
107
+ O.send(@gl_context, "setView:", @view, args: [:pointer], result: :void)
108
+ interval = [1].pack("i")
109
+ O.send(@gl_context, "setValues:forParameter:", Fiddle::Pointer[interval], 222, args: [:pointer, :int], result: :void)
110
+ GPU::OpenGL.new(self, library: FFI::Library.new("/System/Library/Frameworks/OpenGL.framework/OpenGL"))
111
+ else raise ArgumentError, "unsupported Cocoa GPU backend #{backend}"
112
+ end
113
+ end
114
+ def make_current = O.send(@gl_context, "makeCurrentContext", result: :void)
115
+ def swap_buffers = O.send(@gl_context, "flushBuffer", result: :void)
116
+ def title=(title)
117
+ super
118
+ O.send(@handle, "setTitle:", O.string(title), args: [:pointer], result: :void) if @handle
119
+ end
120
+ def tick(poll_events: true)
121
+ App.instance.poll if poll_events
122
+ raise @native_error if @native_error
123
+ super()
124
+ end
125
+ def run
126
+ until closed?
127
+ App.instance.poll(wait: dirty? ? 0 : 0.05)
128
+ tick(poll_events: false)
129
+ end
130
+ end
131
+ def close
132
+ return false unless super
133
+ WINDOWS.delete_if { |_, window| window.equal?(self) }
134
+ O.send(@handle, "setDelegate:", 0, args: [:pointer], result: :void)
135
+ O.send(@handle, "close", result: :void)
136
+ [@gl_context, @layer, @delegate, @view, @handle].compact.each { |object| O.release(object) }
137
+ true
138
+ end
139
+ def native_callback
140
+ yield
141
+ rescue StandardError => error
142
+ @native_error = error
143
+ end
144
+ def native_resize(moved: false)
145
+ return if closed?
146
+ bounds = O.send(@view, "bounds", result: :rect)
147
+ @scale_factor = O.send(@handle, "backingScaleFactor", result: :double)
148
+ resize(bounds[2], bounds[3]) if bounds[2].positive? && bounds[3].positive?
149
+ O.send(@gl_context, "update", result: :void) if @gl_context
150
+ @on_moved&.call(O.send(@handle, "frame", result: :rect).first(2)) if moved
151
+ end
152
+ def toggle_fullscreen = O.send(@handle, "toggleFullScreen:", 0, args: [:pointer], result: :void)
153
+
154
+ KEYS = {36 => "enter", 48 => "tab", 49 => "space", 51 => "backspace", 53 => "esc", 117 => "delete", 115 => "home", 119 => "end", 116 => "pageup", 121 => "pagedown", 123 => "left", 124 => "right", 125 => "down", 126 => "up"}.freeze
155
+ def native_input(name, event)
156
+ flags = O.send(event, "modifierFlags", result: :ulong)
157
+ modifiers = [[18, "ctrl"], [19, "alt"], [17, "shift"], [20, "cmd"]].filter_map { |bit, key| key unless (flags & (1 << bit)).zero? }
158
+ if name.start_with?("key")
159
+ code = O.send(event, "keyCode", result: :uint)
160
+ character = O.text(O.send(event, "charactersIgnoringModifiers")).downcase
161
+ key = KEYS.fetch(code, character)
162
+ unless key.empty? || (name == "keyDown:" && composing? && (modifiers & %w[cmd ctrl]).empty?)
163
+ stroke = (modifiers + [key]).join("-")
164
+ input(name == "keyDown:" ? Input::KeyDown.new(stroke, O.send(event, "isARepeat", result: :bool) != 0) : Input::KeyUp.new(stroke))
165
+ end
166
+ if name == "keyDown:" && (modifiers & %w[cmd ctrl]).empty?
167
+ context = O.send(@view, "inputContext")
168
+ O.send(context, "handleEvent:", event, args: [:pointer], result: :bool)
169
+ end
170
+ elsif name != "flagsChanged:"
171
+ x, y = O.send(event, "locationInWindow", result: :point)
172
+ position = Point.new(x, content_size.height - y)
173
+ button = [:left, :right, :middle].fetch(O.send(event, "buttonNumber", result: :long), :other)
174
+ input(case name
175
+ when /Down:/ then Input::MouseDown.new(position, button, modifiers, O.send(event, "clickCount", result: :long))
176
+ when /Up:/ then Input::MouseUp.new(position, button, modifiers)
177
+ when "scrollWheel:"
178
+ Input::ScrollWheel.new(position, Point.new(-O.send(event, "scrollingDeltaX", result: :double), -O.send(event, "scrollingDeltaY", result: :double)), O.send(event, "phase", result: :ulong), modifiers)
179
+ else Input::MouseMove.new(position, modifiers)
180
+ end)
181
+ end
182
+ end
183
+
184
+ def cocoa_text(value)
185
+ value = O.send(value, "string") if O.send(value, "isKindOfClass:", O.klass("NSAttributedString"), args: [:pointer], result: :bool) != 0
186
+ O.text(value)
187
+ end
188
+ def commit_text(value)
189
+ text = cocoa_text(value)
190
+ @marked, @marked_selection = "", [0, 0]
191
+ input(Input::Composition.new("", [0, 0]))
192
+ input(Input::TextInput.new(text)) unless text.empty?
193
+ end
194
+ def marked_text(value, selection)
195
+ @marked, @marked_selection = cocoa_text(value), selection
196
+ input(Input::Composition.new(@marked, selection))
197
+ end
198
+ def composing? = @marked && !@marked.empty?
199
+ def marked_range = composing? ? [0, @marked.encode("UTF-16LE").bytesize / 2] : [NS_NOT_FOUND, 0]
200
+ def selected_range = @marked_selection || [0, 0]
201
+ def candidate_rect
202
+ bounds = @ime_state.respond_to?(:bounds) ? @ime_state.bounds : @ime_state
203
+ bounds = Bounds.new(0, 0, 1, 20) unless bounds.is_a?(Bounds)
204
+ O.send(@handle, "convertRectToScreen:", [bounds.x, content_size.height - bounds.bottom, bounds.width, bounds.height], args: [:rect], result: :rect)
205
+ end
206
+ def ime_state=(value)
207
+ @ime_state = value
208
+ O.send(O.send(@view, "inputContext"), "invalidateCharacterCoordinates", result: :void) if @view
209
+ end
210
+ def clipboard
211
+ board = O.send(O.klass("NSPasteboard"), "generalPasteboard")
212
+ O.text(O.send(board, "stringForType:", O.string("public.utf8-plain-text"), args: [:pointer]))
213
+ end
214
+ def clipboard=(text)
215
+ board = O.send(O.klass("NSPasteboard"), "generalPasteboard")
216
+ O.send(board, "clearContents", result: :long)
217
+ O.send(board, "setString:forType:", O.string(text), O.string("public.utf8-plain-text"), args: [:pointer, :pointer], result: :bool)
218
+ end
219
+ def clipboard_paths
220
+ board = O.send(O.klass("NSPasteboard"), "generalPasteboard")
221
+ pasteboard_paths(board)
222
+ end
223
+ def pasteboard_paths(board)
224
+ list = O.send(board, "propertyListForType:", O.string("NSFilenamesPboardType"), args: [:pointer])
225
+ paths = Array.new(O.send(list, "count", result: :ulong)) { |i| O.text(O.send(list, "objectAtIndex:", i, args: [:ulong])) }
226
+ return paths unless paths.empty?
227
+ items = O.send(board, "pasteboardItems")
228
+ Array.new(O.send(items, "count", result: :ulong)) do |i|
229
+ item = O.send(items, "objectAtIndex:", i, args: [:ulong])
230
+ value = O.send(item, "stringForType:", O.string("public.file-url"), args: [:pointer])
231
+ next if value.zero?
232
+ url = O.send(O.klass("NSURL"), "URLWithString:", value, args: [:pointer])
233
+ O.text(O.send(url, "path")) if O.send(url, "isFileURL", result: :bool) != 0
234
+ end.compact
235
+ end
236
+ def file_drop(dragging)
237
+ paths = pasteboard_paths(O.send(dragging, "draggingPasteboard"))
238
+ return false if paths.empty?
239
+ x, y = O.send(dragging, "draggingLocation", result: :point)
240
+ input(Input::FileDrop.new(paths.freeze, Point.new(x, content_size.height - y)))
241
+ true
242
+ end
243
+ def appearance
244
+ name = O.text(O.send(O.send(@view, "effectiveAppearance"), "name"))
245
+ name.include?("Dark") ? :dark : :light
246
+ end
247
+ def on_appearance(&block) = @on_appearance = block
248
+ def appearance_changed
249
+ @on_appearance&.call(appearance)
250
+ request_frame
251
+ end
252
+ def tooltip=(text)
253
+ O.send(@view, "setToolTip:", O.string(text), args: [:pointer], result: :void)
254
+ end
255
+ def context_action(index) = @context_actions&.fetch(index)&.call
256
+ def context_menu(items, position: Point.new(0, 0))
257
+ menu = O.new("NSMenu")
258
+ O.send(menu, "setAutoenablesItems:", 0, args: [:bool], result: :void)
259
+ @context_actions = []
260
+ items.each do |label, action|
261
+ item = O.send(O.alloc("NSMenuItem"), "initWithTitle:action:keyEquivalent:", O.string(label), O.selector("contextMenuAction:"), O.string(""), args: [:pointer] * 3)
262
+ O.send(item, "setTarget:", @delegate, args: [:pointer], result: :void)
263
+ O.send(item, "setTag:", @context_actions.length, args: [:long], result: :void)
264
+ O.send(item, "setEnabled:", action ? 1 : 0, args: [:bool], result: :void)
265
+ O.send(menu, "addItem:", item, args: [:pointer], result: :void)
266
+ @context_actions << action
267
+ O.release(item)
268
+ end
269
+ O.send(menu, "popUpMenuPositioningItem:atLocation:inView:", 0, [position.x, position.y], @view, args: [:pointer, :point, :pointer], result: :bool)
270
+ ensure
271
+ @context_actions = nil
272
+ O.release(menu) if menu
273
+ end
274
+ def prompt_for_paths(multiple: false, directories: false, save: false)
275
+ panel = O.send(O.klass(save ? "NSSavePanel" : "NSOpenPanel"), save ? "savePanel" : "openPanel")
276
+ unless save
277
+ O.send(panel, "setAllowsMultipleSelection:", multiple ? 1 : 0, args: [:bool], result: :void)
278
+ O.send(panel, "setCanChooseDirectories:", directories ? 1 : 0, args: [:bool], result: :void)
279
+ end
280
+ return [] unless O.send(panel, "runModal", result: :long) == 1
281
+ urls = save ? [O.send(panel, "URL")] : begin
282
+ list = O.send(panel, "URLs")
283
+ Array.new(O.send(list, "count", result: :ulong)) { |i| O.send(list, "objectAtIndex:", i, args: [:ulong]) }
284
+ end
285
+ urls.map { |url| O.text(O.send(url, "path")) }
286
+ end
287
+ def cursor_style=(style)
288
+ selector = {arrow: "arrowCursor", text: "IBeamCursor", pointer: "pointingHandCursor", crosshair: "crosshairCursor", resize_horizontal: "resizeLeftRightCursor", resize_vertical: "resizeUpDownCursor"}.fetch(style)
289
+ O.send(O.send(O.klass("NSCursor"), selector), "set", result: :void)
290
+ end
291
+ def open_url(url)
292
+ object = O.send(O.klass("NSURL"), "URLWithString:", O.string(url), args: [:pointer])
293
+ O.send(O.send(O.klass("NSWorkspace"), "sharedWorkspace"), "openURL:", object, args: [:pointer], result: :bool) != 0
294
+ end
295
+ end
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ffi/objc"
4
+
5
+ module Zaniah
6
+ module Platform
7
+ module Mac
8
+ O = FFI::ObjC
9
+ APPKIT = FFI::Library.new("/System/Library/Frameworks/AppKit.framework/AppKit")
10
+ QUARTZ = FFI::Library.new("/System/Library/Frameworks/QuartzCore.framework/QuartzCore")
11
+ WINDOWS = {}
12
+ NS_NOT_FOUND = (1 << 63) - 1
13
+ end
14
+ end
15
+ end
16
+
17
+ require_relative "mac/displays"
18
+ require_relative "mac/app"
19
+ require_relative "mac/window"
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Platform
5
+ module TUI
6
+ # Decode terminal input bytes, never executable terminal output strings.
7
+ class InputDecoder
8
+ KEYS = {"A" => "up", "B" => "down", "C" => "right", "D" => "left", "H" => "home", "F" => "end",
9
+ "P" => "f1", "Q" => "f2", "R" => "f3", "S" => "f4", "Z" => "tab"}.freeze
10
+ TILDE_KEYS = {1 => "home", 2 => "insert", 3 => "delete", 4 => "end", 5 => "pageup", 6 => "pagedown", 7 => "home", 8 => "end",
11
+ 11 => "f1", 12 => "f2", 13 => "f3", 14 => "f4", 15 => "f5", 17 => "f6", 18 => "f7", 19 => "f8", 20 => "f9", 21 => "f10", 23 => "f11", 24 => "f12"}.freeze
12
+ attr_reader :buffer
13
+
14
+ def initialize(&emit)
15
+ @buffer, @emit = +"".b, emit
16
+ end
17
+
18
+ def feed(bytes)
19
+ @buffer << bytes.b
20
+ loop do
21
+ break if @buffer.empty?
22
+ if @buffer.start_with?("\e[200~")
23
+ ending = @buffer.index("\e[201~", @paste_scan || 6)
24
+ raise Error, "terminal paste exceeds 16 MiB" if @buffer.bytesize > 16_777_216
25
+ unless ending
26
+ @paste_scan = [@buffer.bytesize - 5, 6].max
27
+ break
28
+ end
29
+ @paste_scan = nil
30
+ value = @buffer.byteslice(6, ending - 6).force_encoding("UTF-8").scrub
31
+ @buffer.slice!(0, ending + 6)
32
+ @emit.call(Input::TextInput.new(value))
33
+ elsif @buffer.start_with?("\e[", "\eO")
34
+ sequence = @buffer.match(/\A\e[\[O]([\x30-\x3f]*)([\x20-\x2f]*)([\x40-\x7e])/n)
35
+ raise Error, "terminal control sequence exceeds 1024 bytes" if !sequence && @buffer.bytesize > 1024
36
+ break unless sequence
37
+ if sequence[0] == "\e[M"
38
+ break if @buffer.bytesize < 6
39
+ button, x, y = @buffer.byteslice(3, 3).bytes.map { |value| value - 32 }
40
+ @buffer.slice!(0, 6)
41
+ mouse(button, x, y, (button & 3) == 3 ? "m" : "M")
42
+ else
43
+ @buffer.slice!(0, sequence[0].bytesize)
44
+ control(sequence[1], sequence[2], sequence[3])
45
+ end
46
+ elsif @buffer.start_with?("\e]", "\eP", "\e_", "\e^")
47
+ ending = @buffer.match(/(?:\a|\e\\)/n)
48
+ raise Error, "terminal control string exceeds 64 KiB" if !ending && @buffer.bytesize > 65_536
49
+ break unless ending
50
+ @buffer.slice!(0, ending.end(0))
51
+ else
52
+ alt = @buffer.getbyte(0) == 27
53
+ break if alt && @buffer.bytesize == 1
54
+ start = alt ? 1 : 0
55
+ byte = @buffer.getbyte(start)
56
+ length = byte.between?(0xc2, 0xdf) ? 2 : byte.between?(0xe0, 0xef) ? 3 : byte.between?(0xf0, 0xf4) ? 4 : 1
57
+ break if @buffer.bytesize < start + length
58
+ value = @buffer.byteslice(start, length).force_encoding("UTF-8").scrub
59
+ @buffer.slice!(0, start + length)
60
+ character(value, alt ? ["alt"] : [])
61
+ end
62
+ end
63
+ self
64
+ end
65
+
66
+ def flush_escape
67
+ return false unless @buffer == "\e".b
68
+ @buffer.clear
69
+ key("esc")
70
+ true
71
+ end
72
+
73
+ private
74
+
75
+ def character(value, modifiers = [], held: false, released: false)
76
+ byte = value.length == 1 ? value.ord : nil
77
+ name = {0 => "ctrl-space", 8 => "backspace", 9 => "tab", 10 => "enter", 13 => "enter", 27 => "esc", 127 => "backspace"}[byte]
78
+ name ||= "ctrl-#{(96 + byte).chr}" if byte && byte.between?(1, 26)
79
+ name ||= {28 => "ctrl-\\", 29 => "ctrl-]", 30 => "ctrl-^", 31 => "ctrl-_"}[byte]
80
+ if name || !modifiers.empty? || released
81
+ key(name || value, modifiers, held: held, released: released)
82
+ else
83
+ @emit.call(Input::TextInput.new(value))
84
+ end
85
+ end
86
+
87
+ def key(name, modifiers = [], held: false, released: false)
88
+ stroke = (modifiers + [name]).join("-")
89
+ stroke = Input::Keystroke.normalize(stroke) unless name == "-"
90
+ @emit.call(released ? Input::KeyUp.new(stroke) : Input::KeyDown.new(stroke, held))
91
+ end
92
+
93
+ def modifiers(number)
94
+ flags = [number - 1, 0].max
95
+ [[4, "ctrl"], [2, "alt"], [1, "shift"], [8, "cmd"]].filter_map { |mask, name| name unless (flags & mask).zero? }
96
+ end
97
+
98
+ def control(parameters, intermediate, final)
99
+ return unless intermediate.empty?
100
+ if parameters.start_with?("<") && ["M", "m"].include?(final)
101
+ numbers = parameters.delete_prefix("<").split(";")
102
+ mouse(*numbers.map(&:to_i), final) if numbers.length == 3 && numbers.all? { |value| value.match?(/\A\d+\z/) }
103
+ return
104
+ end
105
+ return unless parameters.match?(/\A[\d;:]*\z/)
106
+ values = parameters.split(";")
107
+ modifier, event = values.fetch(1, "1").split(":").map(&:to_i)
108
+ mods = modifiers(modifier)
109
+ if final == "u"
110
+ codepoint = values.first.to_i
111
+ return unless codepoint.between?(0, 0x10ffff) && !codepoint.between?(0xd800, 0xdfff)
112
+ character(codepoint.chr("UTF-8"), mods, held: event == 2, released: event == 3)
113
+ elsif final == "~" && values[0].to_i == 27 && values[2]
114
+ codepoint = values[2].to_i
115
+ character(codepoint.chr("UTF-8"), mods) if codepoint.between?(0, 0x10ffff) && !codepoint.between?(0xd800, 0xdfff)
116
+ elsif (name = final == "~" ? TILDE_KEYS[values[0].to_i] : KEYS[final])
117
+ mods |= ["shift"] if final == "Z"
118
+ key(name, mods)
119
+ end
120
+ end
121
+
122
+ def mouse(code, x, y, final)
123
+ return unless code.between?(0, 255) && x.between?(1, 1_000_000) && y.between?(1, 1_000_000)
124
+ position = Point.new((x - 0.5) * 8, (y - 0.5) * 20)
125
+ mods = [[16, "ctrl"], [8, "alt"], [4, "shift"]].filter_map { |mask, name| name unless (code & mask).zero? }
126
+ button = {0 => :left, 1 => :middle, 2 => :right}.fetch(code & 3, @pressed_button || :other)
127
+ if (code & 64) != 0
128
+ delta = [Point.new(0, -40), Point.new(0, 40), Point.new(-40, 0), Point.new(40, 0)][code & 3]
129
+ @emit.call(Input::ScrollWheel.new(position, delta, :changed, mods))
130
+ elsif final == "m"
131
+ @emit.call(Input::MouseUp.new(position, button, mods))
132
+ @pressed_button = nil
133
+ elsif (code & 32) != 0
134
+ @emit.call(Input::MouseMove.new(position, mods))
135
+ else
136
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
137
+ clicks = @last_click && @last_click[0] == button && @last_click[1] == position && now - @last_click[2] < 0.4 ? 2 : 1
138
+ @pressed_button, @last_click = button, [button, position, now]
139
+ @emit.call(Input::MouseDown.new(position, button, mods, clicks))
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Platform
5
+ module TUI
6
+ # Terminal cells have fixed metrics; no font loading or rasterization.
7
+ class TextRenderer
8
+ attr_reader :runs
9
+ attr_accessor :scale_factor
10
+ def initialize
11
+ @cache, @runs = {}, []
12
+ end
13
+ def start_frame = @runs.clear
14
+ def end_frame; end
15
+ def close; end
16
+ def layout_line(text, font: nil, size: 14)
17
+ @cache.shift if @cache.length >= 2000
18
+ @cache[text] ||= begin
19
+ value, byte, width, carets = text.dup.freeze, 0, 0.0, [[0, 0.0]]
20
+ value.each_grapheme_cluster do |character|
21
+ byte += character.bytesize
22
+ width += Unicode.width(character) * 8
23
+ carets << [byte, width]
24
+ end
25
+ TextSystem::LineLayout.new(value, [].freeze, width, 14, 6, size, carets.freeze)
26
+ end
27
+ end
28
+ def paint_line(scene, line, x:, y:, color: "#ddd", spans: nil)
29
+ cursor = 0
30
+ spans.to_a.each do |first, last, tint|
31
+ @runs << [x + line.x_for_index(cursor), y - line.ascent, line.text.byteslice(cursor...first), color, scene.current_clip] if first > cursor
32
+ @runs << [x + line.x_for_index(first), y - line.ascent, line.text.byteslice(first...last), tint, scene.current_clip]
33
+ cursor = last
34
+ end
35
+ @runs << [x + line.x_for_index(cursor), y - line.ascent, line.text.byteslice(cursor..), color, scene.current_clip] if cursor < line.text.bytesize
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "text_renderer"
4
+ require_relative "input_decoder"
5
+
6
+ module Zaniah
7
+ module Platform
8
+ module TUI
9
+ class Window < Headless::Window
10
+ def initialize(input: $stdin, output: $stdout, **options)
11
+ @input, @output = input, output
12
+ super(**options)
13
+ @text_system = TextRenderer.new
14
+ @input_decoder = InputDecoder.new { |event| self.input(event) }
15
+ end
16
+
17
+ def run
18
+ require "io/console"
19
+ raise Error, "TUI requires a terminal" unless @input.tty? && @output.tty?
20
+ begin
21
+ @output.write("\e[?1049h\e[?25l\e[?2004h\e[?1002h\e[?1006h")
22
+ @output.flush
23
+ @input.raw do
24
+ until closed?
25
+ tick
26
+ if IO.select([@input], nil, nil, 0.05)
27
+ data = @input.readpartial(4096)
28
+ feed_input(data)
29
+ else
30
+ @input_decoder.flush_escape
31
+ end
32
+ end
33
+ end
34
+ ensure
35
+ @output.write("\e[?1006l\e[?1002l\e[?2004l\e[?25h\e[?1049l")
36
+ @output.flush
37
+ end
38
+ end
39
+
40
+ def feed_input(bytes) = @input_decoder.feed(bytes)
41
+
42
+ def render(element, **options)
43
+ super(element, **options, present: false)
44
+ rows = Array.new((content_size.height / 20).ceil) { Array.new((content_size.width / 8).ceil, " ") }
45
+ colors = Array.new(rows.length) { Array.new(rows.first.length) }
46
+ (@text_system.runs.empty? ? @text_runs : @text_system.runs).each do |x, y, text, color, clip|
47
+ row, col = (y / 20).floor, (x / 8).floor
48
+ next if row.negative? || !rows[row] || !text
49
+ next if clip && (y < clip.y || y >= clip.bottom)
50
+ first = clip ? [0, (clip.x / 8.0).ceil].max : 0
51
+ last = clip ? [rows[row].length, (clip.right / 8).floor].min : rows[row].length
52
+ text.each_grapheme_cluster do |char|
53
+ break if col >= rows[row].length
54
+ char = char.gsub(/[\x00-\x1f\x7f]/) { |control| control == "\x7f" ? "␡" : (0x2400 + control.ord).chr(Encoding::UTF_8) }
55
+ width = Unicode.width(char)
56
+ next if width.zero?
57
+ break if col + width > last
58
+ if col < first
59
+ col += width
60
+ next
61
+ end
62
+ rows[row][col - 1] = " " if col.positive? && rows[row][col].empty?
63
+ rows[row][col + 1] = " " if rows[row][col + 1] == ""
64
+ rows[row][col] = char if col >= 0
65
+ colors[row][col] = color
66
+ (1...width).each { |offset| rows[row][col + offset] = "" if col + offset >= 0 }
67
+ col += width
68
+ end
69
+ end
70
+ palette = {}
71
+ lines = rows.each_with_index.map do |cells, row|
72
+ current, line = nil, +""
73
+ cells.each_with_index do |char, col|
74
+ next if char.empty?
75
+ color = colors[row][col] || "#ddd"
76
+ if color != current
77
+ line << (palette[color] ||= "\e[38;2;#{Color.parse(color).to_a.first(3).map { |value| (value * 255).round }.join(';')}m")
78
+ current = color
79
+ end
80
+ line << char
81
+ end
82
+ line
83
+ end
84
+ @output.write("\e[H" + lines.join("\r\n") + "\e[0m")
85
+ @output.write("\e[#{(@ime_state.y / 20).floor + 1};#{(@ime_state.x / 8).floor + 1}H\e[?25h") if @ime_state && @output.tty?
86
+ @output.flush
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end