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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +70 -0
- data/assets/fonts/Abel-Regular.ttf +0 -0
- data/assets/fonts/OFL.txt +93 -0
- data/docs/adr/000-template.md +24 -0
- data/docs/adr/001-native-gpu-backends.md +36 -0
- data/docs/adr/002-objective-c-aggregates.md +34 -0
- data/docs/adr/003-fiddle-call-boundary.md +33 -0
- data/docs/adr/README.md +10 -0
- data/docs/native.md +66 -0
- data/docs/process_pool.md +67 -0
- data/docs/text.md +90 -0
- data/docs/vector_and_list.md +52 -0
- data/examples/ffi_bench.rb +16 -0
- data/examples/linux_smoke.rb +53 -0
- data/examples/mac_event_check.rb +61 -0
- data/examples/mac_poll_bench.rb +24 -0
- data/examples/metal_state_check.rb +72 -0
- data/examples/native_bench.rb +18 -0
- data/examples/native_drop.rb +82 -0
- data/examples/native_font.rb +15 -0
- data/examples/native_ime.rb +50 -0
- data/examples/native_smoke.rb +75 -0
- data/examples/native_text_bench.rb +83 -0
- data/examples/native_watch.rb +25 -0
- data/examples/vulkan_triangle.rb +20 -0
- data/examples/word_count_worker.rb +8 -0
- data/lib/zaniah/anchored.rb +10 -0
- data/lib/zaniah/app.rb +130 -0
- data/lib/zaniah/bounds.rb +22 -0
- data/lib/zaniah/canvas.rb +15 -0
- data/lib/zaniah/color.rb +35 -0
- data/lib/zaniah/configuration.rb +5 -0
- data/lib/zaniah/corners.rb +7 -0
- data/lib/zaniah/data_compat.rb +25 -0
- data/lib/zaniah/device_pixels.rb +7 -0
- data/lib/zaniah/div.rb +5 -0
- data/lib/zaniah/edges.rb +7 -0
- data/lib/zaniah/element.rb +106 -0
- data/lib/zaniah/entity.rb +9 -0
- data/lib/zaniah/entity_context.rb +17 -0
- data/lib/zaniah/error.rb +5 -0
- data/lib/zaniah/ffi/com.rb +24 -0
- data/lib/zaniah/ffi/library.rb +29 -0
- data/lib/zaniah/ffi/objc.rb +86 -0
- data/lib/zaniah/ffi/struct/signature.rb +46 -0
- data/lib/zaniah/ffi/struct.rb +61 -0
- data/lib/zaniah/ffi/wayland.rb +140 -0
- data/lib/zaniah/frame_context.rb +14 -0
- data/lib/zaniah/geometry.rb +16 -0
- data/lib/zaniah/gpu/buffer.rb +23 -0
- data/lib/zaniah/gpu/frame_encoder.rb +43 -0
- data/lib/zaniah/gpu/instance_packing.rb +53 -0
- data/lib/zaniah/gpu/metal.rb +209 -0
- data/lib/zaniah/gpu/open_gl.rb +210 -0
- data/lib/zaniah/gpu/pipeline.rb +7 -0
- data/lib/zaniah/gpu/software.rb +126 -0
- data/lib/zaniah/gpu/texture.rb +33 -0
- data/lib/zaniah/gpu/vulkan.rb +233 -0
- data/lib/zaniah/gpu.rb +18 -0
- data/lib/zaniah/hsla.rb +14 -0
- data/lib/zaniah/image.rb +19 -0
- data/lib/zaniah/input/action_registry.rb +12 -0
- data/lib/zaniah/input/composition.rb +7 -0
- data/lib/zaniah/input/context_predicate.rb +87 -0
- data/lib/zaniah/input/dispatcher.rb +59 -0
- data/lib/zaniah/input/file_drop.rb +7 -0
- data/lib/zaniah/input/focus_handle.rb +24 -0
- data/lib/zaniah/input/key_down.rb +7 -0
- data/lib/zaniah/input/key_up.rb +7 -0
- data/lib/zaniah/input/keymap/binding.rb +9 -0
- data/lib/zaniah/input/keymap.rb +55 -0
- data/lib/zaniah/input/keystroke.rb +20 -0
- data/lib/zaniah/input/mouse_down.rb +7 -0
- data/lib/zaniah/input/mouse_move.rb +7 -0
- data/lib/zaniah/input/mouse_up.rb +7 -0
- data/lib/zaniah/input/scroll_wheel.rb +7 -0
- data/lib/zaniah/input/text_input.rb +7 -0
- data/lib/zaniah/input.rb +17 -0
- data/lib/zaniah/layout/available_space.rb +11 -0
- data/lib/zaniah/layout/engine.rb +214 -0
- data/lib/zaniah/layout/node.rb +36 -0
- data/lib/zaniah/layout/style.rb +19 -0
- data/lib/zaniah/layout/tree.rb +16 -0
- data/lib/zaniah/layout.rb +11 -0
- data/lib/zaniah/length.rb +14 -0
- data/lib/zaniah/length_units.rb +9 -0
- data/lib/zaniah/list/height_index.rb +68 -0
- data/lib/zaniah/list.rb +101 -0
- data/lib/zaniah/overlay.rb +10 -0
- data/lib/zaniah/platform/display.rb +7 -0
- data/lib/zaniah/platform/file_event.rb +7 -0
- data/lib/zaniah/platform/headless/window.rb +185 -0
- data/lib/zaniah/platform/linux/appearance_aware.rb +60 -0
- data/lib/zaniah/platform/linux/displays.rb +55 -0
- data/lib/zaniah/platform/linux/free_type.rb +80 -0
- data/lib/zaniah/platform/linux/watcher.rb +70 -0
- data/lib/zaniah/platform/linux/wayland_display_discovery.rb +60 -0
- data/lib/zaniah/platform/linux/wayland_window.rb +369 -0
- data/lib/zaniah/platform/linux/window.rb +418 -0
- data/lib/zaniah/platform/linux.rb +3 -0
- data/lib/zaniah/platform/mac/app.rb +67 -0
- data/lib/zaniah/platform/mac/core_text.rb +94 -0
- data/lib/zaniah/platform/mac/displays.rb +23 -0
- data/lib/zaniah/platform/mac/watcher.rb +63 -0
- data/lib/zaniah/platform/mac/window.rb +298 -0
- data/lib/zaniah/platform/mac.rb +19 -0
- data/lib/zaniah/platform/tui/input_decoder.rb +145 -0
- data/lib/zaniah/platform/tui/text_renderer.rb +40 -0
- data/lib/zaniah/platform/tui/window.rb +91 -0
- data/lib/zaniah/platform/windows/terminal.rb +127 -0
- data/lib/zaniah/platform/windows/watcher.rb +91 -0
- data/lib/zaniah/platform/windows/window.rb +407 -0
- data/lib/zaniah/platform/windows.rb +3 -0
- data/lib/zaniah/platform.rb +34 -0
- data/lib/zaniah/png.rb +118 -0
- data/lib/zaniah/point.rb +8 -0
- data/lib/zaniah/process_pool/worker.rb +7 -0
- data/lib/zaniah/process_pool.rb +239 -0
- data/lib/zaniah/process_wire.rb +127 -0
- data/lib/zaniah/quad_packer.rb +15 -0
- data/lib/zaniah/scene/sprite_batch.rb +7 -0
- data/lib/zaniah/scene.rb +167 -0
- data/lib/zaniah/scene_renderer.rb +8 -0
- data/lib/zaniah/size.rb +5 -0
- data/lib/zaniah/subscription.rb +14 -0
- data/lib/zaniah/svg/path.rb +134 -0
- data/lib/zaniah/svg/rasterizable.rb +186 -0
- data/lib/zaniah/svg.rb +295 -0
- data/lib/zaniah/task/cancelled.rb +7 -0
- data/lib/zaniah/task/timeout.rb +7 -0
- data/lib/zaniah/task.rb +51 -0
- data/lib/zaniah/task_executor.rb +105 -0
- data/lib/zaniah/text.rb +24 -0
- data/lib/zaniah/text_system/atlas/entry.rb +9 -0
- data/lib/zaniah/text_system/atlas.rb +102 -0
- data/lib/zaniah/text_system/atlas_cache.rb +146 -0
- data/lib/zaniah/text_system/font_db/face.rb +9 -0
- data/lib/zaniah/text_system/font_db.rb +193 -0
- data/lib/zaniah/text_system/glyph.rb +7 -0
- data/lib/zaniah/text_system/line_layout.rb +20 -0
- data/lib/zaniah/text_system/renderer.rb +196 -0
- data/lib/zaniah/text_system/shaper.rb +430 -0
- data/lib/zaniah/text_system/typesetter/copy_error.rb +9 -0
- data/lib/zaniah/text_system/typesetter.rb +168 -0
- data/lib/zaniah/text_system.rb +9 -0
- data/lib/zaniah/unicode.rb +13 -0
- data/lib/zaniah/uniform_list.rb +30 -0
- data/lib/zaniah/version.rb +5 -0
- data/lib/zaniah/view.rb +13 -0
- data/lib/zaniah.rb +29 -0
- data/sig/app.rbs +80 -0
- data/sig/elements.rbs +129 -0
- data/sig/gpu.rbs +108 -0
- data/sig/input.rbs +114 -0
- data/sig/layout.rbs +53 -0
- data/sig/native.rbs +88 -0
- data/sig/platform.rbs +94 -0
- data/sig/scene.rbs +52 -0
- data/sig/shaper.rbs +8 -0
- data/sig/text.rbs +137 -0
- data/sig/zaniah.rbs +120 -0
- data/tools/check_dependencies.rb +52 -0
- 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
|