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,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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Input
5
+ Composition = Data.define(:text, :selection)
6
+ end
7
+ 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Input
5
+ FileDrop = Data.define(:paths, :position)
6
+ end
7
+ 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Input
5
+ KeyDown = Data.define(:keystroke, :is_held)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Input
5
+ KeyUp = Data.define(:keystroke)
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Input
5
+ class Keymap
6
+ Binding = Data.define(:keys, :predicate, :action)
7
+ end
8
+ end
9
+ 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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Input
5
+ MouseDown = Data.define(:position, :button, :modifiers, :click_count)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Input
5
+ MouseMove = Data.define(:position, :modifiers)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Input
5
+ MouseUp = Data.define(:position, :button, :modifiers)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Input
5
+ ScrollWheel = Data.define(:position, :delta, :phase, :modifiers)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Input
5
+ TextInput = Data.define(:text)
6
+ end
7
+ end
@@ -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