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
data/lib/zaniah/image.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
class Image < Element
|
|
5
|
+
def initialize(path)
|
|
6
|
+
super()
|
|
7
|
+
width, height, pixels = PNG.decode(File.binread(path))
|
|
8
|
+
@texture = GPU::Texture.new(width, height, data: pixels)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def request_layout(_cx)
|
|
12
|
+
@layout_node = Layout::Node.new(style: @style, measure: ->(_width, _height) { [@texture.width, @texture.height] })
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def paint(bounds, _state, _prepaint, cx)
|
|
16
|
+
cx.scene.sprite(bounds.x, bounds.y, bounds.width, bounds.height, texture: @texture)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
module Input
|
|
5
|
+
class ActionRegistry
|
|
6
|
+
def initialize = @actions = {}
|
|
7
|
+
def register(name, description: name, &block) = @actions[name.to_s] = [description, block]
|
|
8
|
+
def call(name, *args) = @actions.fetch(name.to_s)[1].call(*args)
|
|
9
|
+
def entries = @actions.transform_values(&:first).freeze
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "strscan"
|
|
5
|
+
|
|
6
|
+
module Zaniah
|
|
7
|
+
module Input
|
|
8
|
+
# Parse a deliberately small predicate grammar. Settings never become Ruby code.
|
|
9
|
+
class ContextPredicate
|
|
10
|
+
def initialize(source)
|
|
11
|
+
@tokens = []
|
|
12
|
+
scanner = StringScanner.new(source.to_s)
|
|
13
|
+
until scanner.eos?
|
|
14
|
+
next if scanner.scan(/\s+/)
|
|
15
|
+
token = scanner.scan(/&&|\|\||==|!=|[!()]|[\w.:-]+|"(?:\\.|[^"\\])*"/)
|
|
16
|
+
raise ArgumentError, "invalid context expression at #{scanner.pos}" unless token
|
|
17
|
+
@tokens << token
|
|
18
|
+
end
|
|
19
|
+
@position = 0
|
|
20
|
+
@tree = @tokens.empty? ? [:literal, true] : expression
|
|
21
|
+
raise ArgumentError, "unexpected context token" unless @position == @tokens.length
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(context) = !!evaluate(@tree, context)
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def peek = @tokens[@position]
|
|
29
|
+
def take = @tokens[@position].tap { @position += 1 }
|
|
30
|
+
|
|
31
|
+
def expression
|
|
32
|
+
left = conjunction
|
|
33
|
+
while peek == "||"
|
|
34
|
+
take
|
|
35
|
+
left = [:or, left, conjunction]
|
|
36
|
+
end
|
|
37
|
+
left
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def conjunction
|
|
41
|
+
left = atom
|
|
42
|
+
while peek == "&&"
|
|
43
|
+
take
|
|
44
|
+
left = [:and, left, atom]
|
|
45
|
+
end
|
|
46
|
+
left
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def atom
|
|
50
|
+
if peek == "!"
|
|
51
|
+
take
|
|
52
|
+
return [:not, atom]
|
|
53
|
+
end
|
|
54
|
+
if peek == "("
|
|
55
|
+
take
|
|
56
|
+
node = expression
|
|
57
|
+
raise ArgumentError, "unclosed context group" unless take == ")"
|
|
58
|
+
return node
|
|
59
|
+
end
|
|
60
|
+
name = take
|
|
61
|
+
raise ArgumentError, "context identifier expected" unless name && name.match?(/\A[\w.:-]+\z/)
|
|
62
|
+
if ["==", "!="].include?(peek)
|
|
63
|
+
operator, operand = take, take
|
|
64
|
+
raise ArgumentError, "context value expected" unless operand && operand.match?(/\A(?:[\w.:-]+|".*")\z/)
|
|
65
|
+
value = operand.start_with?('"') ? JSON.parse(operand) : operand
|
|
66
|
+
[operator.to_sym, name, value]
|
|
67
|
+
else
|
|
68
|
+
[:id, name]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def evaluate(node, context)
|
|
73
|
+
kind, a, b = node
|
|
74
|
+
lookup = ->(name) { context.key?(name) ? context[name] : context[name.to_sym] }
|
|
75
|
+
case kind
|
|
76
|
+
when :literal then a
|
|
77
|
+
when :id then a == "true" || (a != "false" && lookup.call(a))
|
|
78
|
+
when :and then evaluate(a, context) && evaluate(b, context)
|
|
79
|
+
when :or then evaluate(a, context) || evaluate(b, context)
|
|
80
|
+
when :not then !evaluate(a, context)
|
|
81
|
+
when :== then lookup.call(a).to_s == b
|
|
82
|
+
when :!= then lookup.call(a).to_s != b
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
module Input
|
|
5
|
+
class Dispatcher
|
|
6
|
+
attr_reader :focused
|
|
7
|
+
|
|
8
|
+
def initialize(keymap: Keymap.new)
|
|
9
|
+
@keymap, @hits = keymap, []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def focus(handle)
|
|
13
|
+
return if @focused == handle
|
|
14
|
+
@focused&.on_focus&.call(false)
|
|
15
|
+
@focused = handle
|
|
16
|
+
handle&.on_focus&.call(true)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def key(key)
|
|
20
|
+
chain = @focused ? @focused.ancestors : []
|
|
21
|
+
context = chain.reverse.each_with_object({}) { |handle, result| result.merge!(handle.context) }
|
|
22
|
+
action = @keymap.dispatch(key, context: context)
|
|
23
|
+
return action if action.nil? || action == :pending
|
|
24
|
+
chain.each { |handle| break if handle.on_action&.call(action) }
|
|
25
|
+
action
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def clear_hits = @hits.clear
|
|
29
|
+
|
|
30
|
+
def hit(bounds, clip: nil, &handler)
|
|
31
|
+
bounds = bounds.intersect(@clip) if @clip
|
|
32
|
+
@hits << [clip ? bounds.intersect(clip) : bounds, handler]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def clip(bounds)
|
|
36
|
+
previous = @clip
|
|
37
|
+
@clip = previous ? previous.intersect(bounds) : bounds
|
|
38
|
+
yield
|
|
39
|
+
ensure
|
|
40
|
+
@clip = previous
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def mouse(event)
|
|
44
|
+
if @capture
|
|
45
|
+
@capture.call(event)
|
|
46
|
+
@capture = nil if event.is_a?(MouseUp)
|
|
47
|
+
return true
|
|
48
|
+
end
|
|
49
|
+
@hits.reverse_each do |bounds, handler|
|
|
50
|
+
next unless bounds.contains?(event.position)
|
|
51
|
+
handled = handler.call(event)
|
|
52
|
+
@capture = handler if handled == :capture && event.is_a?(MouseDown)
|
|
53
|
+
return true if handled
|
|
54
|
+
end
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
module Input
|
|
5
|
+
class FocusHandle
|
|
6
|
+
attr_reader :parent, :children, :context
|
|
7
|
+
attr_accessor :on_action, :on_focus
|
|
8
|
+
|
|
9
|
+
def initialize(parent: nil, context: {})
|
|
10
|
+
@parent, @context, @children = parent, context, []
|
|
11
|
+
parent&.children&.push(self)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def ancestors
|
|
15
|
+
list, current = [], self
|
|
16
|
+
while current
|
|
17
|
+
list << current
|
|
18
|
+
current = current.parent
|
|
19
|
+
end
|
|
20
|
+
list
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "keystroke"
|
|
5
|
+
require_relative "context_predicate"
|
|
6
|
+
|
|
7
|
+
module Zaniah
|
|
8
|
+
module Input
|
|
9
|
+
class Keymap
|
|
10
|
+
def initialize(timeout: 1.0)
|
|
11
|
+
@bindings, @pending, @last_time, @timeout = [], [], 0, timeout
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def bind(keys, action, context: "")
|
|
15
|
+
@bindings << Binding.new(keys.split.map { |key| Keystroke.normalize(key) }, ContextPredicate.new(context), action)
|
|
16
|
+
self
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def load_json(text)
|
|
20
|
+
JSON.parse(text).each do |group|
|
|
21
|
+
group.fetch("bindings").each { |key, action| bind(key, action, context: group.fetch("context", "")) }
|
|
22
|
+
end
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def dispatch(key, context: {}, now: Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
27
|
+
@pending.clear if now - @last_time > @timeout
|
|
28
|
+
@last_time = now
|
|
29
|
+
key = Keystroke.normalize(key)
|
|
30
|
+
@pending << key
|
|
31
|
+
candidates = @bindings.reverse.select { |binding| binding.predicate.call(context) && binding.keys.first(@pending.length) == @pending }
|
|
32
|
+
if candidates.empty? && @pending.length > 1
|
|
33
|
+
@pending.replace([key])
|
|
34
|
+
candidates = @bindings.reverse.select { |binding| binding.predicate.call(context) && binding.keys.first(1) == @pending }
|
|
35
|
+
end
|
|
36
|
+
# The newest matching prefix also overrides an older complete binding.
|
|
37
|
+
# Otherwise a default ctrl-k action makes a later ctrl-k ctrl-s unusable.
|
|
38
|
+
match = candidates.first
|
|
39
|
+
if match && match.keys.length == @pending.length
|
|
40
|
+
@pending.clear
|
|
41
|
+
match.action
|
|
42
|
+
elsif candidates.empty?
|
|
43
|
+
@pending.clear
|
|
44
|
+
nil
|
|
45
|
+
else
|
|
46
|
+
:pending
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def bindings = @bindings.dup.freeze
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
require_relative "keymap/binding"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
module Input
|
|
5
|
+
module Keystroke
|
|
6
|
+
ORDER = %w[ctrl alt shift cmd].freeze
|
|
7
|
+
ALIASES = {"control" => "ctrl", "option" => "alt", "meta" => "cmd", "super" => "cmd",
|
|
8
|
+
"return" => "enter", "escape" => "esc", " " => "space"}.freeze
|
|
9
|
+
|
|
10
|
+
def self.normalize(value)
|
|
11
|
+
words = value.downcase.split("-")
|
|
12
|
+
key = value.end_with?("--") ? "-" : words.pop
|
|
13
|
+
key = "-" if key.nil? || key.empty?
|
|
14
|
+
modifiers = words.map { |word| ALIASES.fetch(word, word) }
|
|
15
|
+
raise ArgumentError, "unknown key modifier" unless (modifiers - ORDER).empty?
|
|
16
|
+
(ORDER.select { |modifier| modifiers.include?(modifier) } << ALIASES.fetch(key, key)).join("-")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/zaniah/input.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "input/key_down"
|
|
4
|
+
require_relative "input/key_up"
|
|
5
|
+
require_relative "input/text_input"
|
|
6
|
+
require_relative "input/mouse_down"
|
|
7
|
+
require_relative "input/mouse_up"
|
|
8
|
+
require_relative "input/mouse_move"
|
|
9
|
+
require_relative "input/scroll_wheel"
|
|
10
|
+
require_relative "input/composition"
|
|
11
|
+
require_relative "input/file_drop"
|
|
12
|
+
require_relative "input/keystroke"
|
|
13
|
+
require_relative "input/context_predicate"
|
|
14
|
+
require_relative "input/keymap"
|
|
15
|
+
require_relative "input/focus_handle"
|
|
16
|
+
require_relative "input/dispatcher"
|
|
17
|
+
require_relative "input/action_registry"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
module Layout
|
|
5
|
+
AvailableSpace = Data.define(:kind, :value) do
|
|
6
|
+
def self.definite(value) = new(:definite, value)
|
|
7
|
+
def self.min_content = new(:min_content, nil)
|
|
8
|
+
def self.max_content = new(:max_content, nil)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
module Layout
|
|
5
|
+
class Engine
|
|
6
|
+
def initialize(rem: 16) = @rem = rem
|
|
7
|
+
|
|
8
|
+
def compute(root, width:, height:, x: 0, y: 0)
|
|
9
|
+
layout(root, x, y, width, height, width, height)
|
|
10
|
+
root.bounds
|
|
11
|
+
end
|
|
12
|
+
def measure(node, width:, height:) = natural_size(node, width, height)
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def resolve_length(length, available, fallback = nil)
|
|
17
|
+
return fallback if length.nil? || length == :auto
|
|
18
|
+
length.is_a?(Length) ? length.resolve(available || 0, rem: @rem) : Float(length)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resolve_edges(style, name, available)
|
|
22
|
+
input = style[name]
|
|
23
|
+
input = input.to_h.values if input.is_a?(Edges)
|
|
24
|
+
input = [input] unless input.is_a?(Array)
|
|
25
|
+
input = case input.length
|
|
26
|
+
when 1 then input * 4
|
|
27
|
+
when 2 then [input[0], input[1], input[0], input[1]]
|
|
28
|
+
when 3 then [input[0], input[1], input[2], input[1]]
|
|
29
|
+
when 4 then input
|
|
30
|
+
else raise ArgumentError, "invalid #{name}"
|
|
31
|
+
end
|
|
32
|
+
%i[top right bottom left].each_with_index.map do |side, i|
|
|
33
|
+
val = style["#{name}_#{side}".to_sym] || input[i]
|
|
34
|
+
val == :auto ? :auto : resolve_length(val, available, 0)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def clamp_size(node, dimension, size, available)
|
|
39
|
+
min = resolve_length(node.style["min_#{dimension}".to_sym], available, 0)
|
|
40
|
+
max = resolve_length(node.style["max_#{dimension}".to_sym], available, Float::INFINITY)
|
|
41
|
+
size.clamp(min, [min, max].max)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def natural_size(node, available_w, available_h)
|
|
45
|
+
s = node.style
|
|
46
|
+
return [0, 0] if s[:display] == :none
|
|
47
|
+
p = resolve_edges(s, :padding, available_w).map { |v| v == :auto ? 0 : v }
|
|
48
|
+
b = resolve_edges(s, :border, available_w).map { |v| v == :auto ? 0 : v }
|
|
49
|
+
padding_w, padding_h = p[1] + p[3] + b[1] + b[3], p[0] + p[2] + b[0] + b[2]
|
|
50
|
+
w = resolve_length(s[:width], available_w)
|
|
51
|
+
h = resolve_length(s[:height], available_h)
|
|
52
|
+
if node.measure
|
|
53
|
+
measured = node.measure.call([available_w - padding_w, 0].max, [available_h - padding_h, 0].max)
|
|
54
|
+
w ||= measured[0] + padding_w
|
|
55
|
+
h ||= measured[1] + padding_h
|
|
56
|
+
node.baseline = measured[2] || measured[1]
|
|
57
|
+
elsif !node.children.empty?
|
|
58
|
+
sizes = node.children.reject { |c| c.style[:position] == :absolute || c.style[:display] == :none }.map { |c| natural_size(c, available_w, available_h) }
|
|
59
|
+
row = s[:flex_direction].to_s.start_with?("row")
|
|
60
|
+
gap = resolve_length(s[:gap], available_w, 0) * [sizes.length - 1, 0].max
|
|
61
|
+
w ||= (row ? sizes.sum(&:first) + gap : sizes.map(&:first).max || 0) + padding_w
|
|
62
|
+
h ||= (row ? sizes.map(&:last).max || 0 : sizes.sum(&:last) + gap) + padding_h
|
|
63
|
+
end
|
|
64
|
+
[clamp_size(node, :width, w || padding_w, available_w), clamp_size(node, :height, h || padding_h, available_h)]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def layout(node, x, y, width, height, available_w, available_h)
|
|
68
|
+
key = [node.generation, x, y, width, height, available_w, available_h]
|
|
69
|
+
return if node.cache[key]
|
|
70
|
+
if node.style[:display] == :none
|
|
71
|
+
node.bounds = Bounds.new(x, y, 0, 0)
|
|
72
|
+
node.children.each { |child| hide(child, x, y) }
|
|
73
|
+
return
|
|
74
|
+
end
|
|
75
|
+
width = clamp_size(node, :width, width, available_w)
|
|
76
|
+
height = clamp_size(node, :height, height, available_h)
|
|
77
|
+
node.bounds = Bounds.new(x, y, width, height)
|
|
78
|
+
content = content_bounds(node)
|
|
79
|
+
flow, absolute = node.children.partition { |child| child.style[:position] != :absolute }
|
|
80
|
+
layout_flow_children(node, content, flow)
|
|
81
|
+
layout_absolute_children(absolute, content)
|
|
82
|
+
node.cache.shift if node.cache.length >= 5
|
|
83
|
+
node.cache[key] = true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def content_bounds(node)
|
|
87
|
+
padding = resolve_edges(node.style, :padding, node.bounds.width).map { |v| v == :auto ? 0 : v }
|
|
88
|
+
border = resolve_edges(node.style, :border, node.bounds.width).map { |v| v == :auto ? 0 : v }
|
|
89
|
+
node.bounds.inset(Edges.new(*padding.zip(border).map(&:sum)))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def layout_flow_children(node, content, flow)
|
|
93
|
+
s = node.style
|
|
94
|
+
row = s[:flex_direction].to_s.start_with?("row")
|
|
95
|
+
reverse = s[:flex_direction].to_s.end_with?("reverse")
|
|
96
|
+
main_size, cross_size = row ? [content.width, content.height] : [content.height, content.width]
|
|
97
|
+
before, after, cross_before, cross_after = row ? [3, 1, 0, 2] : [0, 2, 3, 1]
|
|
98
|
+
main_dimension, cross_dimension = row ? [:width, :height] : [:height, :width]
|
|
99
|
+
gap = resolve_length(s[:gap], main_size, 0)
|
|
100
|
+
flow = flow.reject { |child| child.style[:display] == :none && hide(child, node.bounds.x, node.bounds.y) }
|
|
101
|
+
items = flow.map do |child|
|
|
102
|
+
nw, nh = natural_size(child, content.width, content.height)
|
|
103
|
+
base = resolve_length(child.style[:flex_basis], main_size, row ? nw : nh)
|
|
104
|
+
margins = resolve_edges(child.style, :margin, content.width)
|
|
105
|
+
{node: child, base: base, size: clamp_size(child, main_dimension, base, main_size),
|
|
106
|
+
cross: row ? nh : nw, margins: margins}
|
|
107
|
+
end
|
|
108
|
+
lines = [[]]
|
|
109
|
+
occupied = 0
|
|
110
|
+
items.each do |item|
|
|
111
|
+
margin = item[:margins].values_at(before, after).sum { |v| v == :auto ? 0 : v }
|
|
112
|
+
required = item[:size] + margin
|
|
113
|
+
if s[:flex_wrap] == :wrap && !lines.last.empty? && occupied + gap + required > main_size
|
|
114
|
+
lines << []
|
|
115
|
+
occupied = 0
|
|
116
|
+
end
|
|
117
|
+
occupied += (lines.last.empty? ? 0 : gap) + required
|
|
118
|
+
lines.last << item
|
|
119
|
+
end
|
|
120
|
+
cross_cursor = 0
|
|
121
|
+
lines.each do |line|
|
|
122
|
+
next if line.empty?
|
|
123
|
+
fixed_margin = line.sum { |i| i[:margins].values_at(before, after).sum { |v| v == :auto ? 0 : v } }
|
|
124
|
+
distribute_space(line, main_size - gap * (line.length - 1) - fixed_margin, main_dimension)
|
|
125
|
+
free = main_size - line.sum { |i| i[:size] } - fixed_margin - gap * (line.length - 1)
|
|
126
|
+
autos = line.sum { |i| i[:margins].values_at(before, after).count(:auto) }
|
|
127
|
+
auto_margin = autos.positive? ? [free, 0].max / autos : 0
|
|
128
|
+
free = 0 if autos.positive?
|
|
129
|
+
leading, spacing = case s[:justify_content]
|
|
130
|
+
when :end then [free, gap]
|
|
131
|
+
when :center then [free / 2.0, gap]
|
|
132
|
+
when :space_between then [0, gap + (line.length > 1 ? [free, 0].max / (line.length - 1) : 0)]
|
|
133
|
+
when :space_around then [[free, 0].max / line.length / 2.0, gap + [free, 0].max / line.length]
|
|
134
|
+
when :space_evenly then [[free, 0].max / (line.length + 1), gap + [free, 0].max / (line.length + 1)]
|
|
135
|
+
else [0, gap]
|
|
136
|
+
end
|
|
137
|
+
line_cross = lines.length == 1 ? cross_size : line.map { |i| i[:cross] + i[:margins].values_at(cross_before, cross_after).sum { |v| v == :auto ? 0 : v } }.max
|
|
138
|
+
baseline = line.map { |i| i[:node].baseline }.max || 0
|
|
139
|
+
cursor = leading
|
|
140
|
+
line.each do |item|
|
|
141
|
+
child, margins = item.values_at(:node, :margins)
|
|
142
|
+
mb, ma = margins.values_at(before, after).map { |v| v == :auto ? auto_margin : v }
|
|
143
|
+
cb, ca = margins.values_at(cross_before, cross_after).map { |v| v == :auto ? 0 : v }
|
|
144
|
+
align = child.style[:align_self] == :auto ? s[:align_items] : child.style[:align_self]
|
|
145
|
+
cross = item[:cross]
|
|
146
|
+
cross = [line_cross - cb - ca, 0].max if align == :stretch && child.style[cross_dimension] == :auto
|
|
147
|
+
cross = clamp_size(child, cross_dimension, cross, cross_size)
|
|
148
|
+
cross_free = line_cross - cross - cb - ca
|
|
149
|
+
cross_offset = case align
|
|
150
|
+
when :end then cross_free
|
|
151
|
+
when :center then cross_free / 2.0
|
|
152
|
+
when :baseline then baseline - child.baseline
|
|
153
|
+
else 0
|
|
154
|
+
end
|
|
155
|
+
cross_autos = margins.values_at(cross_before, cross_after).count(:auto)
|
|
156
|
+
if cross_autos.positive?
|
|
157
|
+
cross_offset = margins[cross_before] == :auto ? [cross_free, 0].max / cross_autos : 0
|
|
158
|
+
end
|
|
159
|
+
cursor += mb
|
|
160
|
+
main_position = reverse ? main_size - cursor - item[:size] : cursor
|
|
161
|
+
cross_position = cross_cursor + cb + cross_offset
|
|
162
|
+
cx, cy = row ? [content.x + main_position, content.y + cross_position] : [content.x + cross_position, content.y + main_position]
|
|
163
|
+
cw, ch = row ? [item[:size], cross] : [cross, item[:size]]
|
|
164
|
+
cx += resolve_length(child.style[:left], content.width, 0) - resolve_length(child.style[:right], content.width, 0)
|
|
165
|
+
cy += resolve_length(child.style[:top], content.height, 0) - resolve_length(child.style[:bottom], content.height, 0)
|
|
166
|
+
layout(child, cx, cy, cw, ch, content.width, content.height)
|
|
167
|
+
cursor += item[:size] + ma + spacing
|
|
168
|
+
end
|
|
169
|
+
cross_cursor += line_cross + gap
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def layout_absolute_children(children, content)
|
|
174
|
+
children.each do |child|
|
|
175
|
+
nw, nh = natural_size(child, content.width, content.height)
|
|
176
|
+
cs = child.style
|
|
177
|
+
left, right = resolve_length(cs[:left], content.width), resolve_length(cs[:right], content.width)
|
|
178
|
+
top, bottom = resolve_length(cs[:top], content.height), resolve_length(cs[:bottom], content.height)
|
|
179
|
+
cw = resolve_length(cs[:width], content.width, left && right ? content.width - left - right : nw)
|
|
180
|
+
ch = resolve_length(cs[:height], content.height, top && bottom ? content.height - top - bottom : nh)
|
|
181
|
+
cx = content.x + (left || (right ? content.width - right - cw : 0))
|
|
182
|
+
cy = content.y + (top || (bottom ? content.height - bottom - ch : 0))
|
|
183
|
+
layout(child, cx, cy, cw, ch, content.width, content.height)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def hide(node, x, y)
|
|
188
|
+
node.bounds = Bounds.new(x, y, 0, 0)
|
|
189
|
+
node.children.each { |child| hide(child, x, y) }
|
|
190
|
+
true
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def distribute_space(line, available, dimension)
|
|
194
|
+
growing = line.sum { |i| i[:base] } < available
|
|
195
|
+
active = line.dup
|
|
196
|
+
remaining = available
|
|
197
|
+
until active.empty?
|
|
198
|
+
factor = active.sum { |i| growing ? i[:node].style[:flex_grow] : i[:node].style[:flex_shrink] * i[:base] }
|
|
199
|
+
free = remaining - active.sum { |i| i[:base] }
|
|
200
|
+
frozen = []
|
|
201
|
+
active.each do |item|
|
|
202
|
+
weight = growing ? item[:node].style[:flex_grow] : item[:node].style[:flex_shrink] * item[:base]
|
|
203
|
+
proposed = item[:base] + (factor.positive? ? free * weight / [factor, growing ? 1 : factor].max : 0)
|
|
204
|
+
item[:size] = clamp_size(item[:node], dimension, proposed, available)
|
|
205
|
+
frozen << item if (item[:size] - proposed).abs > 0.00001 || weight.zero?
|
|
206
|
+
end
|
|
207
|
+
break if frozen.empty?
|
|
208
|
+
frozen.each { |item| remaining -= item[:size]; active.delete(item) }
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
module Layout
|
|
5
|
+
class Node
|
|
6
|
+
attr_reader :style, :children, :generation, :cache
|
|
7
|
+
attr_accessor :bounds, :parent, :measure, :baseline
|
|
8
|
+
|
|
9
|
+
def initialize(style: Style.new, children: [], measure: nil)
|
|
10
|
+
@style = style.is_a?(Hash) ? Style.new(**style) : style
|
|
11
|
+
@children, @generation, @cache, @measure = [], 0, {}, measure
|
|
12
|
+
@bounds, @baseline = Bounds.new(0, 0, 0, 0), 0
|
|
13
|
+
children.each { |child| add(child) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add(child)
|
|
17
|
+
raise ArgumentError, "node already has a parent" if child.parent
|
|
18
|
+
child.parent = self
|
|
19
|
+
@children << child
|
|
20
|
+
touch
|
|
21
|
+
child
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def style=(style)
|
|
25
|
+
@style = style.is_a?(Hash) ? Style.new(**style) : style
|
|
26
|
+
touch
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def touch
|
|
30
|
+
@generation += 1
|
|
31
|
+
@cache.clear
|
|
32
|
+
parent&.touch
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
module Layout
|
|
5
|
+
class Style
|
|
6
|
+
DEFAULTS = {display: :flex, flex_direction: :column, flex_wrap: :nowrap,
|
|
7
|
+
justify_content: :start, align_items: :stretch, align_self: :auto,
|
|
8
|
+
flex_grow: 0, flex_shrink: 1, flex_basis: :auto, width: :auto, height: :auto,
|
|
9
|
+
min_width: 0, min_height: 0, max_width: Float::INFINITY, max_height: Float::INFINITY,
|
|
10
|
+
padding: 0, margin: 0, border: 0, gap: 0, position: :relative,
|
|
11
|
+
overflow: :visible}.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(**properties) = @values = DEFAULTS.merge(properties).freeze
|
|
14
|
+
def [](name) = @values[name]
|
|
15
|
+
def merge(**properties) = Style.new(**@values.merge(properties))
|
|
16
|
+
def to_h = @values
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|