zaniah 0.2.0 → 0.4.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 (206) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/LICENSE.txt +18 -0
  4. data/README.md +14 -4
  5. data/assets/icons/check.svg +1 -0
  6. data/assets/icons/close.svg +1 -0
  7. data/assets/icons/info.svg +1 -0
  8. data/assets/icons/menu.svg +1 -0
  9. data/assets/icons/search.svg +1 -0
  10. data/assets/icons/warning.svg +1 -0
  11. data/docs/accessibility.md +61 -0
  12. data/docs/adr/004-style-resolution.md +17 -0
  13. data/docs/adr/005-instance-layout.md +43 -0
  14. data/docs/adr/006-component-model.md +17 -0
  15. data/docs/adr/007-text-buffer.md +17 -0
  16. data/docs/adr/008-accessibility-bridge.md +19 -0
  17. data/docs/adr/009-grid-scope.md +17 -0
  18. data/docs/adr/010-text-overlay-layout.md +24 -0
  19. data/docs/animation.md +46 -0
  20. data/docs/components.md +109 -0
  21. data/docs/gallery/command-palette-dark.png +0 -0
  22. data/docs/gallery/command-palette-high_contrast.png +0 -0
  23. data/docs/gallery/command-palette-light.png +0 -0
  24. data/docs/gallery/components-dark.png +0 -0
  25. data/docs/gallery/components-high_contrast.png +0 -0
  26. data/docs/gallery/components-light.png +0 -0
  27. data/docs/gallery/context-menu-dark.png +0 -0
  28. data/docs/gallery/context-menu-high_contrast.png +0 -0
  29. data/docs/gallery/context-menu-light.png +0 -0
  30. data/docs/gallery/dialog-dark.png +0 -0
  31. data/docs/gallery/dialog-high_contrast.png +0 -0
  32. data/docs/gallery/dialog-light.png +0 -0
  33. data/docs/gallery/drawer-left-dark.png +0 -0
  34. data/docs/gallery/drawer-left-high_contrast.png +0 -0
  35. data/docs/gallery/drawer-left-light.png +0 -0
  36. data/docs/gallery/drawer-right-dark.png +0 -0
  37. data/docs/gallery/drawer-right-high_contrast.png +0 -0
  38. data/docs/gallery/drawer-right-light.png +0 -0
  39. data/docs/gallery/menu-dark.png +0 -0
  40. data/docs/gallery/menu-high_contrast.png +0 -0
  41. data/docs/gallery/menu-light.png +0 -0
  42. data/docs/gallery/modal-dark.png +0 -0
  43. data/docs/gallery/modal-high_contrast.png +0 -0
  44. data/docs/gallery/modal-light.png +0 -0
  45. data/docs/gallery/popover-bottom-dark.png +0 -0
  46. data/docs/gallery/popover-bottom-high_contrast.png +0 -0
  47. data/docs/gallery/popover-bottom-light.png +0 -0
  48. data/docs/gallery/popover-left-dark.png +0 -0
  49. data/docs/gallery/popover-left-high_contrast.png +0 -0
  50. data/docs/gallery/popover-left-light.png +0 -0
  51. data/docs/gallery/popover-right-dark.png +0 -0
  52. data/docs/gallery/popover-right-high_contrast.png +0 -0
  53. data/docs/gallery/popover-right-light.png +0 -0
  54. data/docs/gallery/popover-top-dark.png +0 -0
  55. data/docs/gallery/popover-top-high_contrast.png +0 -0
  56. data/docs/gallery/popover-top-light.png +0 -0
  57. data/docs/gallery/toast-danger-dark.png +0 -0
  58. data/docs/gallery/toast-danger-high_contrast.png +0 -0
  59. data/docs/gallery/toast-danger-light.png +0 -0
  60. data/docs/gallery/toast-info-dark.png +0 -0
  61. data/docs/gallery/toast-info-high_contrast.png +0 -0
  62. data/docs/gallery/toast-info-light.png +0 -0
  63. data/docs/gallery/toast-success-dark.png +0 -0
  64. data/docs/gallery/toast-success-high_contrast.png +0 -0
  65. data/docs/gallery/toast-success-light.png +0 -0
  66. data/docs/gallery/toast-warning-dark.png +0 -0
  67. data/docs/gallery/toast-warning-high_contrast.png +0 -0
  68. data/docs/gallery/toast-warning-light.png +0 -0
  69. data/docs/gallery/tooltip-bottom-dark.png +0 -0
  70. data/docs/gallery/tooltip-bottom-high_contrast.png +0 -0
  71. data/docs/gallery/tooltip-bottom-light.png +0 -0
  72. data/docs/gallery/tooltip-left-dark.png +0 -0
  73. data/docs/gallery/tooltip-left-high_contrast.png +0 -0
  74. data/docs/gallery/tooltip-left-light.png +0 -0
  75. data/docs/gallery/tooltip-right-dark.png +0 -0
  76. data/docs/gallery/tooltip-right-high_contrast.png +0 -0
  77. data/docs/gallery/tooltip-right-light.png +0 -0
  78. data/docs/gallery/tooltip-top-dark.png +0 -0
  79. data/docs/gallery/tooltip-top-high_contrast.png +0 -0
  80. data/docs/gallery/tooltip-top-light.png +0 -0
  81. data/docs/index.html +74 -0
  82. data/docs/layout.md +31 -0
  83. data/docs/native.md +7 -2
  84. data/docs/site.css +32 -0
  85. data/docs/text.md +54 -0
  86. data/docs/theme.md +50 -0
  87. data/docs/tui.md +36 -0
  88. data/examples/gallery.rb +155 -0
  89. data/examples/native_ime.rb +13 -6
  90. data/lib/zaniah/accessibility/linux/service.rb +361 -0
  91. data/lib/zaniah/accessibility/linux.rb +23 -0
  92. data/lib/zaniah/accessibility/mac.rb +136 -0
  93. data/lib/zaniah/accessibility/native_tree.rb +53 -0
  94. data/lib/zaniah/accessibility/node.rb +12 -0
  95. data/lib/zaniah/accessibility/tree.rb +61 -0
  96. data/lib/zaniah/accessibility/windows/provider.rb +273 -0
  97. data/lib/zaniah/accessibility/windows.rb +38 -0
  98. data/lib/zaniah/accessibility.rb +63 -0
  99. data/lib/zaniah/animation/animator.rb +95 -0
  100. data/lib/zaniah/animation/easing.rb +45 -0
  101. data/lib/zaniah/animation/spring.rb +36 -0
  102. data/lib/zaniah/animation/transition.rb +5 -0
  103. data/lib/zaniah/animation.rb +6 -0
  104. data/lib/zaniah/app.rb +22 -2
  105. data/lib/zaniah/color.rb +16 -0
  106. data/lib/zaniah/devtools/hot_reload.rb +40 -0
  107. data/lib/zaniah/devtools/inspector.rb +111 -0
  108. data/lib/zaniah/devtools/stats_overlay.rb +16 -0
  109. data/lib/zaniah/devtools.rb +13 -0
  110. data/lib/zaniah/element.rb +210 -15
  111. data/lib/zaniah/frame_context.rb +4 -1
  112. data/lib/zaniah/gpu/frame_encoder.rb +10 -1
  113. data/lib/zaniah/gpu/instance_packing.rb +33 -5
  114. data/lib/zaniah/gpu/metal.rb +26 -10
  115. data/lib/zaniah/gpu/open_gl.rb +35 -14
  116. data/lib/zaniah/gpu/software.rb +116 -14
  117. data/lib/zaniah/gpu/vulkan/scene.frag +51 -0
  118. data/lib/zaniah/gpu/vulkan/scene.frag.spv +0 -0
  119. data/lib/zaniah/gpu/vulkan/scene.vert +50 -0
  120. data/lib/zaniah/gpu/vulkan/scene.vert.spv +0 -0
  121. data/lib/zaniah/gpu/vulkan.rb +282 -36
  122. data/lib/zaniah/gpu.rb +8 -3
  123. data/lib/zaniah/input/dispatcher.rb +96 -12
  124. data/lib/zaniah/input/focus_handle.rb +13 -4
  125. data/lib/zaniah/input/focus_tree.rb +77 -0
  126. data/lib/zaniah/input/keymap.rb +77 -0
  127. data/lib/zaniah/input.rb +1 -0
  128. data/lib/zaniah/interactivity.rb +30 -0
  129. data/lib/zaniah/layout/engine.rb +178 -36
  130. data/lib/zaniah/layout/grid.rb +134 -0
  131. data/lib/zaniah/layout/style.rb +16 -1
  132. data/lib/zaniah/layout.rb +1 -0
  133. data/lib/zaniah/length.rb +3 -0
  134. data/lib/zaniah/length_units.rb +7 -0
  135. data/lib/zaniah/list.rb +27 -15
  136. data/lib/zaniah/platform/appearance.rb +11 -0
  137. data/lib/zaniah/platform/headless/window.rb +101 -9
  138. data/lib/zaniah/platform/linux/appearance_aware.rb +8 -0
  139. data/lib/zaniah/platform/linux/wayland_window.rb +3 -1
  140. data/lib/zaniah/platform/linux/window.rb +4 -2
  141. data/lib/zaniah/platform/mac/app.rb +1 -1
  142. data/lib/zaniah/platform/mac/core_text.rb +3 -0
  143. data/lib/zaniah/platform/mac/window.rb +9 -1
  144. data/lib/zaniah/platform/tui/window.rb +1 -1
  145. data/lib/zaniah/platform/windows/window.rb +12 -2
  146. data/lib/zaniah/platform.rb +1 -0
  147. data/lib/zaniah/scene.rb +137 -28
  148. data/lib/zaniah/scroll_state.rb +117 -0
  149. data/lib/zaniah/scroll_view.rb +94 -0
  150. data/lib/zaniah/style/gradient.rb +28 -0
  151. data/lib/zaniah/style/ring.rb +13 -0
  152. data/lib/zaniah/style/shadow.rb +13 -0
  153. data/lib/zaniah/style/style_set.rb +43 -0
  154. data/lib/zaniah/style/transform.rb +40 -0
  155. data/lib/zaniah/svg.rb +19 -0
  156. data/lib/zaniah/text.rb +499 -7
  157. data/lib/zaniah/text_buffer.rb +119 -0
  158. data/lib/zaniah/text_selection.rb +18 -0
  159. data/lib/zaniah/text_system/atlas_cache.rb +1 -1
  160. data/lib/zaniah/text_system/fallback.rb +24 -0
  161. data/lib/zaniah/text_system/font_db.rb +4 -1
  162. data/lib/zaniah/text_system/kinsoku.rb +28 -0
  163. data/lib/zaniah/text_system/line_breaker.rb +58 -0
  164. data/lib/zaniah/text_system/paragraph.rb +246 -0
  165. data/lib/zaniah/text_system/renderer.rb +13 -2
  166. data/lib/zaniah/text_system/typesetter.rb +4 -0
  167. data/lib/zaniah/text_system.rb +3 -0
  168. data/lib/zaniah/theme.rb +39 -0
  169. data/lib/zaniah/ui/charts.rb +165 -0
  170. data/lib/zaniah/ui/choice_fields.rb +134 -0
  171. data/lib/zaniah/ui/component.rb +73 -0
  172. data/lib/zaniah/ui/controls.rb +439 -0
  173. data/lib/zaniah/ui/data.rb +364 -0
  174. data/lib/zaniah/ui/editors.rb +67 -0
  175. data/lib/zaniah/ui/forms.rb +131 -0
  176. data/lib/zaniah/ui/layout_components.rb +205 -0
  177. data/lib/zaniah/ui/modal.rb +129 -0
  178. data/lib/zaniah/ui/overlays.rb +282 -0
  179. data/lib/zaniah/ui/pickers.rb +169 -0
  180. data/lib/zaniah/ui/primitives.rb +199 -0
  181. data/lib/zaniah/ui/scrollbar.rb +104 -0
  182. data/lib/zaniah/ui/structure.rb +224 -0
  183. data/lib/zaniah/ui/text_inputs.rb +169 -0
  184. data/lib/zaniah/ui/variants.rb +23 -0
  185. data/lib/zaniah/ui.rb +18 -0
  186. data/lib/zaniah/unicode.rb +42 -0
  187. data/lib/zaniah/uniform_list.rb +27 -6
  188. data/lib/zaniah/version.rb +1 -1
  189. data/lib/zaniah.rb +14 -0
  190. data/sig/accessibility.rbs +34 -0
  191. data/sig/animation.rbs +54 -0
  192. data/sig/app.rbs +2 -1
  193. data/sig/devtools.rbs +37 -0
  194. data/sig/elements.rbs +108 -6
  195. data/sig/gpu.rbs +9 -2
  196. data/sig/input.rbs +31 -5
  197. data/sig/layout.rbs +2 -1
  198. data/sig/platform.rbs +24 -4
  199. data/sig/scene.rbs +15 -2
  200. data/sig/style.rbs +82 -0
  201. data/sig/text.rbs +86 -0
  202. data/sig/theme.rbs +64 -0
  203. data/sig/ui.rbs +390 -0
  204. data/sig/zaniah.rbs +56 -3
  205. data/tools/generate_component_gallery.rb +48 -0
  206. metadata +151 -5
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Input
5
+ class FocusTree
6
+ def initialize
7
+ @roots, @traps = [], []
8
+ end
9
+
10
+ def register(handle)
11
+ root = handle
12
+ root = root.parent while root.parent
13
+ @roots << root unless @roots.include?(root)
14
+ self
15
+ end
16
+
17
+ def clear = @roots.clear
18
+
19
+ def next(from = nil) = move(from, 1)
20
+ def previous(from = nil) = move(from, -1)
21
+
22
+ def spatial(from, direction)
23
+ return unless from&.bounds
24
+ raise ArgumentError, "direction must be left, right, up, or down" unless %i[left right up down].include?(direction)
25
+ source = from.bounds
26
+ sx, sy = center(source)
27
+ candidates.reject { |handle| handle.equal?(from) || !handle.bounds }.select do |handle|
28
+ tx, ty = center(handle.bounds)
29
+ delta = %i[left right].include?(direction) ? tx - sx : ty - sy
30
+ %i[left up].include?(direction) ? delta.negative? : delta.positive?
31
+ end.min_by do |handle|
32
+ target = handle.bounds
33
+ tx, ty = center(target)
34
+ primary = %i[left right].include?(direction) ? tx - sx : ty - sy
35
+ cross_gap = %i[left right].include?(direction) ? interval_gap(source.y, source.bottom, target.y, target.bottom) : interval_gap(source.x, source.right, target.x, target.right)
36
+ [cross_gap.zero? ? 0 : 1, primary.abs, cross_gap, Math.hypot(tx - sx, ty - sy)]
37
+ end
38
+ end
39
+
40
+ def trap(handle)
41
+ @traps << handle unless @traps.last.equal?(handle)
42
+ return handle unless block_given?
43
+ begin
44
+ yield
45
+ ensure
46
+ restore
47
+ end
48
+ end
49
+
50
+ def restore = @traps.pop
51
+ def release_trap(handle) = @traps.delete(handle)
52
+
53
+ private
54
+
55
+ def move(from, step)
56
+ items = candidates
57
+ return if items.empty?
58
+ index = items.index(from)
59
+ items[index ? (index + step) % items.length : (step.positive? ? 0 : -1)]
60
+ end
61
+
62
+ def candidates
63
+ list = []
64
+ roots = @traps.empty? ? @roots : [@traps.last]
65
+ visit = ->(handle) do
66
+ list << handle if handle.focusable && !handle.tab_index.nil?
67
+ handle.children.each(&visit)
68
+ end
69
+ roots.each(&visit)
70
+ list.each_with_index.sort_by { |(handle, index)| [handle.tab_index, index] }.map!(&:first)
71
+ end
72
+
73
+ def center(bounds) = [bounds.x + bounds.width / 2.0, bounds.y + bounds.height / 2.0]
74
+ def interval_gap(a1, a2, b1, b2) = [a1 - b2, b1 - a2, 0].max
75
+ end
76
+ end
77
+ end
@@ -7,6 +7,83 @@ require_relative "context_predicate"
7
7
  module Zaniah
8
8
  module Input
9
9
  class Keymap
10
+ class << self
11
+ def default_ui(platform: RUBY_PLATFORM, **options)
12
+ platform_defaults(platform, **options)
13
+ end
14
+
15
+ def platform_defaults(platform, **options)
16
+ primary = platform.to_s.match?(/darwin|mac/) ? "cmd" : "ctrl"
17
+ new(**options)
18
+ .bind("tab", :focus_next)
19
+ .bind("shift-tab", :focus_previous)
20
+ .bind("left", :focus_left, context: "!in_text_field && !in_list")
21
+ .bind("right", :focus_right, context: "!in_text_field && !in_list")
22
+ .bind("up", :focus_up, context: "!in_text_field && !in_list")
23
+ .bind("down", :focus_down, context: "!in_text_field && !in_list")
24
+ .bind("enter", :activate)
25
+ .bind("space", :activate, context: "!in_text_field")
26
+ .bind("esc", :dismiss)
27
+ .bind("home", :first, context: "in_list")
28
+ .bind("end", :last, context: "in_list")
29
+ .bind("pageup", :page_up, context: "in_list")
30
+ .bind("pagedown", :page_down, context: "in_list")
31
+ .bind("up", :previous_option, context: "in_list")
32
+ .bind("down", :next_option, context: "in_list")
33
+ .bind("#{primary}-a", :select_all, context: "in_text_field || in_list")
34
+ .bind("left", :move_left, context: "in_text_field")
35
+ .bind("right", :move_right, context: "in_text_field")
36
+ .bind("shift-left", :select_left, context: "in_text_field")
37
+ .bind("shift-right", :select_right, context: "in_text_field")
38
+ .bind("home", :line_start, context: "in_text_field")
39
+ .bind("end", :line_end, context: "in_text_field")
40
+ .bind("shift-home", :select_line_start, context: "in_text_field")
41
+ .bind("shift-end", :select_line_end, context: "in_text_field")
42
+ .bind("backspace", :delete_backward, context: "in_text_field")
43
+ .bind("delete", :delete_forward, context: "in_text_field")
44
+ .bind("enter", :insert_newline, context: "in_text_field")
45
+ .bind("left", :decrement, context: "in_slider")
46
+ .bind("down", :decrement, context: "in_slider")
47
+ .bind("right", :increment, context: "in_slider")
48
+ .bind("up", :increment, context: "in_slider")
49
+ .bind("home", :minimum, context: "in_slider")
50
+ .bind("end", :maximum, context: "in_slider")
51
+ .bind("pageup", :increment_page, context: "in_slider")
52
+ .bind("pagedown", :decrement_page, context: "in_slider")
53
+ .bind("up", :previous_option, context: "in_menu || in_tabs")
54
+ .bind("left", :previous_option, context: "in_menu || in_tabs")
55
+ .bind("down", :next_option, context: "in_menu || in_tabs")
56
+ .bind("right", :next_option, context: "in_menu || in_tabs")
57
+ .bind("home", :first, context: "in_menu || in_tabs")
58
+ .bind("end", :last, context: "in_menu || in_tabs")
59
+ .bind("up", :previous_option, context: "in_tree")
60
+ .bind("down", :next_option, context: "in_tree")
61
+ .bind("home", :first, context: "in_tree")
62
+ .bind("end", :last, context: "in_tree")
63
+ .bind("left", :collapse, context: "in_tree")
64
+ .bind("right", :expand, context: "in_tree")
65
+ .bind("up", :previous_option, context: "in_table")
66
+ .bind("down", :next_option, context: "in_table")
67
+ .bind("shift-up", :extend_previous, context: "in_table")
68
+ .bind("shift-down", :extend_next, context: "in_table")
69
+ .bind("home", :first, context: "in_table")
70
+ .bind("end", :last, context: "in_table")
71
+ .bind("pageup", :page_up, context: "in_table")
72
+ .bind("pagedown", :page_down, context: "in_table")
73
+ .bind("#{primary}-a", :select_all, context: "in_table")
74
+ .bind("up", :previous_option, context: "in_combobox")
75
+ .bind("down", :next_option, context: "in_combobox")
76
+ .bind("home", :first, context: "in_combobox")
77
+ .bind("end", :last, context: "in_combobox")
78
+ .bind("enter", :choose_option, context: "in_combobox")
79
+ .bind("esc", :dismiss, context: "in_combobox")
80
+ .bind("left", :previous_option, context: "in_chart")
81
+ .bind("right", :next_option, context: "in_chart")
82
+ .bind("home", :first, context: "in_chart")
83
+ .bind("end", :last, context: "in_chart")
84
+ end
85
+ end
86
+
10
87
  def initialize(timeout: 1.0, clock: MONOTONIC_CLOCK)
11
88
  @bindings, @pending, @last_time, @timeout, @clock = [], [], 0, timeout, clock
12
89
  end
data/lib/zaniah/input.rb CHANGED
@@ -13,5 +13,6 @@ require_relative "input/keystroke"
13
13
  require_relative "input/context_predicate"
14
14
  require_relative "input/keymap"
15
15
  require_relative "input/focus_handle"
16
+ require_relative "input/focus_tree"
16
17
  require_relative "input/dispatcher"
17
18
  require_relative "input/action_registry"
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Zaniah
6
+ class Interactivity
7
+ def initialize = @flags = {}
8
+
9
+ def resolve(dispatcher, pointer_position, pointer_down)
10
+ @flags.clear
11
+ chain = dispatcher.hover_chain(pointer_position)
12
+ chain.each { |owner| flags(owner) << :hover }
13
+ chain.each { |owner| flags(owner) << :active } if pointer_down
14
+ owner = dispatcher.focused&.owner
15
+ flags(owner) << :focus if owner
16
+ flags(owner) << :focus_visible if owner && dispatcher.focus_visible?
17
+ self
18
+ end
19
+
20
+ def for(owner, static = nil)
21
+ result = @flags[owner]&.dup || Set.new
22
+ result.merge(static) if static
23
+ result
24
+ end
25
+
26
+ private
27
+
28
+ def flags(owner) = @flags[owner] ||= Set.new
29
+ end
30
+ end
@@ -3,6 +3,14 @@
3
3
  module Zaniah
4
4
  module Layout
5
5
  class Engine
6
+ include GridLayout
7
+ EDGE_KEYS = {
8
+ padding: [%i[padding_top padding_right padding_bottom padding_left], :padding_start, :padding_end],
9
+ margin: [%i[margin_top margin_right margin_bottom margin_left], :margin_start, :margin_end],
10
+ border: [%i[border_top border_right border_bottom border_left], :border_start, :border_end]
11
+ }.freeze
12
+ ZERO_EDGES = [0, 0, 0, 0].freeze
13
+
6
14
  def initialize(rem: 16) = @rem = rem
7
15
 
8
16
  def compute(root, width:, height:, x: 0, y: 0)
@@ -15,11 +23,23 @@ module Zaniah
15
23
 
16
24
  def resolve_length(length, available, fallback = nil)
17
25
  return fallback if length.nil? || length == :auto
18
- length.is_a?(Length) ? length.resolve(available || 0, rem: @rem) : Float(length)
26
+ return length.resolve(available || 0, rem: @rem) if length.is_a?(Length)
27
+ length.is_a?(Numeric) ? length : Float(length)
19
28
  end
20
29
 
21
30
  def resolve_edges(style, name, available)
22
- input = style[name]
31
+ values = style.to_h
32
+ input = values[name]
33
+ direction = values[:direction]
34
+ raise ArgumentError, "direction must be ltr or rtl" unless %i[ltr rtl].include?(direction)
35
+ physical, start_key, end_key = EDGE_KEYS.fetch(name)
36
+ sides = physical.map { |key| values[key] }
37
+ start, finish = values[start_key], values[end_key]
38
+ if input.is_a?(Numeric) && sides.none? && start.nil? && finish.nil?
39
+ return ZERO_EDGES if input.zero?
40
+ return [input, input, input, input]
41
+ end
42
+
23
43
  input = input.to_h.values if input.is_a?(Edges)
24
44
  input = [input] unless input.is_a?(Array)
25
45
  input = case input.length
@@ -29,26 +49,59 @@ module Zaniah
29
49
  when 4 then input
30
50
  else raise ArgumentError, "invalid #{name}"
31
51
  end
32
- %i[top right bottom left].each_with_index.map do |side, i|
33
- val = style["#{name}_#{side}".to_sym] || input[i]
52
+ values = sides.each_with_index.map do |value, i|
53
+ val = value || input[i]
34
54
  val == :auto ? :auto : resolve_length(val, available, 0)
35
55
  end
56
+ left, right = direction == :ltr ? [start, finish] : [finish, start]
57
+ values[3] = left == :auto ? :auto : resolve_length(left, available, 0) unless left.nil?
58
+ values[1] = right == :auto ? :auto : resolve_length(right, available, 0) unless right.nil?
59
+ values
36
60
  end
37
61
 
38
62
  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)
63
+ clamp_value(node.style.to_h, dimension, size, available)
64
+ end
65
+
66
+ def clamp_value(values, dimension, size, available)
67
+ if dimension == :width
68
+ min, max = values[:min_width], values[:max_width]
69
+ else
70
+ min, max = values[:min_height], values[:max_height]
71
+ end
72
+ return size.clamp(min, [min, max].max) if min.is_a?(Numeric) && max.is_a?(Numeric)
73
+ min = resolve_length(min, available, 0)
74
+ max = resolve_length(max, available, Float::INFINITY)
41
75
  size.clamp(min, [min, max].max)
42
76
  end
43
77
 
44
78
  def natural_size(node, available_w, available_h)
45
- s = node.style
79
+ style = node.style
80
+ s = style.to_h
46
81
  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]
82
+ if !node.measure && node.children.empty? && s.length == Style::DEFAULTS.length &&
83
+ s[:padding] == 0 && s[:border] == 0 && !s[:aspect_ratio] &&
84
+ !s[:padding_start] && !s[:padding_end] && !s[:border_start] && !s[:border_end] &&
85
+ s[:min_width] == 0 && s[:min_height] == 0 &&
86
+ s[:max_width] == Float::INFINITY && s[:max_height] == Float::INFINITY
87
+ return [resolve_length(s[:width], available_w, 0), resolve_length(s[:height], available_h, 0)]
88
+ end
89
+ padding, border = uniform_edge(style, :padding), uniform_edge(style, :border)
90
+ if padding && border
91
+ padding_w = padding_h = (padding + border) * 2
92
+ else
93
+ p = resolve_edges(style, :padding, available_w).map { |edge| edge == :auto ? 0 : edge }
94
+ b = resolve_edges(style, :border, available_w).map { |edge| edge == :auto ? 0 : edge }
95
+ padding_w, padding_h = p[1] + p[3] + b[1] + b[3], p[0] + p[2] + b[0] + b[2]
96
+ end
50
97
  w = resolve_length(s[:width], available_w)
51
98
  h = resolve_length(s[:height], available_h)
99
+ if s[:aspect_ratio]
100
+ ratio = Float(s[:aspect_ratio])
101
+ raise ArgumentError, "aspect ratio must be positive and finite" unless ratio.positive? && ratio.finite?
102
+ h = w / ratio if w && !h
103
+ w = h * ratio if h && !w
104
+ end
52
105
  if node.measure
53
106
  measured = node.measure.call([available_w - padding_w, 0].max, [available_h - padding_h, 0].max)
54
107
  w ||= measured[0] + padding_w
@@ -61,10 +114,27 @@ module Zaniah
61
114
  w ||= (row ? sizes.sum(&:first) + gap : sizes.map(&:first).max || 0) + padding_w
62
115
  h ||= (row ? sizes.map(&:last).max || 0 : sizes.sum(&:last) + gap) + padding_h
63
116
  end
64
- [clamp_size(node, :width, w || padding_w, available_w), clamp_size(node, :height, h || padding_h, available_h)]
117
+ [clamp_value(s, :width, w || padding_w, available_w), clamp_value(s, :height, h || padding_h, available_h)]
118
+ end
119
+
120
+ def uniform_edge(style, name)
121
+ values = style.to_h
122
+ physical, start_key, end_key = EDGE_KEYS.fetch(name)
123
+ value = values[name]
124
+ value if value.is_a?(Numeric) && physical.none? { |key| values[key] } && values[start_key].nil? && values[end_key].nil?
65
125
  end
66
126
 
67
- def layout(node, x, y, width, height, available_w, available_h)
127
+ def layout(node, x, y, width, height, available_w, available_h, clamp = true)
128
+ if node.children.empty?
129
+ if node.style[:display] == :none
130
+ node.bounds = Bounds.new(x, y, 0, 0)
131
+ else
132
+ width = clamp_size(node, :width, width, available_w) if clamp
133
+ height = clamp_size(node, :height, height, available_h) if clamp
134
+ node.bounds = Bounds.new(x, y, width, height)
135
+ end
136
+ return
137
+ end
68
138
  key = [node.generation, x, y, width, height, available_w, available_h]
69
139
  return if node.cache[key]
70
140
  if node.style[:display] == :none
@@ -72,12 +142,15 @@ module Zaniah
72
142
  node.children.each { |child| hide(child, x, y) }
73
143
  return
74
144
  end
75
- width = clamp_size(node, :width, width, available_w)
76
- height = clamp_size(node, :height, height, available_h)
145
+ if clamp
146
+ width = clamp_size(node, :width, width, available_w)
147
+ height = clamp_size(node, :height, height, available_h)
148
+ end
77
149
  node.bounds = Bounds.new(x, y, width, height)
78
150
  content = content_bounds(node)
79
151
  flow, absolute = node.children.partition { |child| child.style[:position] != :absolute }
80
- layout_flow_children(node, content, flow)
152
+ s = node.style
153
+ s[:display] == :grid ? layout_grid_children(node, content, flow) : layout_flow_children(node, content, flow)
81
154
  layout_absolute_children(absolute, content)
82
155
  node.cache.shift if node.cache.length >= 5
83
156
  node.cache[key] = true
@@ -90,20 +163,27 @@ module Zaniah
90
163
  end
91
164
 
92
165
  def layout_flow_children(node, content, flow)
93
- s = node.style
166
+ s = node.style.to_h
94
167
  row = s[:flex_direction].to_s.start_with?("row")
95
168
  reverse = s[:flex_direction].to_s.end_with?("reverse")
96
169
  main_size, cross_size = row ? [content.width, content.height] : [content.height, content.width]
97
170
  before, after, cross_before, cross_after = row ? [3, 1, 0, 2] : [0, 2, 3, 1]
98
171
  main_dimension, cross_dimension = row ? [:width, :height] : [:height, :width]
99
172
  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) }
173
+ flow = flow.reject { |child| child.style.to_h[:display] == :none && hide(child, node.bounds.x, node.bounds.y) }
174
+ if s[:flex_wrap] == :nowrap && s[:justify_content] == :start && s[:align_items] == :stretch &&
175
+ layout_simple_flow(flow, content, row, reverse, main_size, cross_size, cross_dimension, gap)
176
+ return
177
+ end
101
178
  items = flow.map do |child|
179
+ style = child.style.to_h
102
180
  nw, nh = natural_size(child, content.width, content.height)
103
- base = resolve_length(child.style[:flex_basis], main_size, row ? nw : nh)
181
+ basis = style[:flex_basis]
182
+ base = resolve_length(basis, main_size, row ? nw : nh)
104
183
  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}
184
+ size = basis == :auto ? base : clamp_size(child, main_dimension, base, main_size)
185
+ {node: child, base: base, size: size,
186
+ cross: row ? nh : nw, margins: margins, style: style}
107
187
  end
108
188
  lines = [[]]
109
189
  occupied = 0
@@ -120,7 +200,10 @@ module Zaniah
120
200
  cross_cursor = 0
121
201
  lines.each do |line|
122
202
  next if line.empty?
123
- fixed_margin = line.sum { |i| i[:margins].values_at(before, after).sum { |v| v == :auto ? 0 : v } }
203
+ fixed_margin = line.sum do |item|
204
+ margins = item[:margins]
205
+ (margins[before] == :auto ? 0 : margins[before]) + (margins[after] == :auto ? 0 : margins[after])
206
+ end
124
207
  distribute_space(line, main_size - gap * (line.length - 1) - fixed_margin, main_dimension)
125
208
  free = main_size - line.sum { |i| i[:size] } - fixed_margin - gap * (line.length - 1)
126
209
  autos = line.sum { |i| i[:margins].values_at(before, after).count(:auto) }
@@ -135,16 +218,20 @@ module Zaniah
135
218
  else [0, gap]
136
219
  end
137
220
  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
221
+ baseline = line.reduce(0) { |value, item| item[:node].baseline > value ? item[:node].baseline : value }
139
222
  cursor = leading
140
223
  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]
224
+ child, margins = item[:node], item[:margins]
225
+ mb = margins[before] == :auto ? auto_margin : margins[before]
226
+ ma = margins[after] == :auto ? auto_margin : margins[after]
227
+ cb = margins[cross_before] == :auto ? 0 : margins[cross_before]
228
+ ca = margins[cross_after] == :auto ? 0 : margins[cross_after]
229
+ style = item[:style]
230
+ align = style[:align_self] == :auto ? s[:align_items] : style[:align_self]
145
231
  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)
232
+ cross = [line_cross - cb - ca, 0].max if align == :stretch && style[cross_dimension] == :auto
233
+ default_limits = cross_dimension == :width ? style[:min_width] == 0 && style[:max_width] == Float::INFINITY : style[:min_height] == 0 && style[:max_height] == Float::INFINITY
234
+ cross = clamp_size(child, cross_dimension, cross, cross_size) unless default_limits
148
235
  cross_free = line_cross - cross - cb - ca
149
236
  cross_offset = case align
150
237
  when :end then cross_free
@@ -152,24 +239,79 @@ module Zaniah
152
239
  when :baseline then baseline - child.baseline
153
240
  else 0
154
241
  end
155
- cross_autos = margins.values_at(cross_before, cross_after).count(:auto)
242
+ cross_autos = (margins[cross_before] == :auto ? 1 : 0) + (margins[cross_after] == :auto ? 1 : 0)
156
243
  if cross_autos.positive?
157
244
  cross_offset = margins[cross_before] == :auto ? [cross_free, 0].max / cross_autos : 0
158
245
  end
159
246
  cursor += mb
160
247
  main_position = reverse ? main_size - cursor - item[:size] : cursor
161
248
  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)
249
+ if row
250
+ cx, cy, cw, ch = content.x + main_position, content.y + cross_position, item[:size], cross
251
+ else
252
+ cx, cy, cw, ch = content.x + cross_position, content.y + main_position, cross, item[:size]
253
+ end
254
+ if style[:left] || style[:right]
255
+ cx += resolve_length(style[:left], content.width, 0) - resolve_length(style[:right], content.width, 0)
256
+ end
257
+ if style[:top] || style[:bottom]
258
+ cy += resolve_length(style[:top], content.height, 0) - resolve_length(style[:bottom], content.height, 0)
259
+ end
260
+ layout(child, cx, cy, cw, ch, content.width, content.height, false)
167
261
  cursor += item[:size] + ma + spacing
168
262
  end
169
263
  cross_cursor += line_cross + gap
170
264
  end
171
265
  end
172
266
 
267
+ def layout_simple_flow(flow, content, row, reverse, main_size, cross_size, cross_dimension, gap)
268
+ styles = flow.map { |child| child.style.to_h }
269
+ return false unless styles.all? do |style|
270
+ margin = style[:margin]
271
+ margin.is_a?(Numeric) && margin.zero? && style[:position] == :relative && style[:flex_basis] == :auto &&
272
+ (style[:align_self] == :auto || style[:align_self] == :stretch) && style[:min_width] == 0 && style[:min_height] == 0 &&
273
+ style[:max_width] == Float::INFINITY && style[:max_height] == Float::INFINITY &&
274
+ style[:margin_top].nil? && style[:margin_right].nil? && style[:margin_bottom].nil? && style[:margin_left].nil? &&
275
+ style[:margin_start].nil? && style[:margin_end].nil? && style[:left].nil? && style[:right].nil? &&
276
+ style[:top].nil? && style[:bottom].nil? && style[:flex_grow].is_a?(Numeric) && style[:flex_shrink].is_a?(Numeric)
277
+ end
278
+
279
+ bases, crosses = flow.map do |child|
280
+ width, height = natural_size(child, content.width, content.height)
281
+ row ? [width, height] : [height, width]
282
+ end.transpose
283
+ bases ||= []
284
+ crosses ||= []
285
+ available = main_size - gap * [flow.length - 1, 0].max
286
+ total = bases.sum
287
+ growing = total < available
288
+ factor = styles.each_with_index.sum do |style, index|
289
+ growing ? style[:flex_grow] : style[:flex_shrink] * bases[index]
290
+ end
291
+ free = available - total
292
+ divisor = growing ? [factor, 1].max : factor
293
+ sizes = bases.each_index.map do |index|
294
+ weight = growing ? styles[index][:flex_grow] : styles[index][:flex_shrink] * bases[index]
295
+ bases[index] + (factor.positive? ? free * weight / divisor : 0)
296
+ end
297
+ return false if sizes.any?(&:negative?)
298
+
299
+ cursor = 0
300
+ flow.each_with_index do |child, index|
301
+ style = styles[index]
302
+ size = sizes[index]
303
+ cross = style[cross_dimension] == :auto ? cross_size : crosses[index]
304
+ main_position = reverse ? main_size - cursor - size : cursor
305
+ if row
306
+ layout(child, content.x + main_position, content.y, size, cross, content.width, content.height, false)
307
+ else
308
+ layout(child, content.x, content.y + main_position, cross, size, content.width, content.height, false)
309
+ end
310
+ cursor += size + gap
311
+ end
312
+ true
313
+ end
314
+
173
315
  def layout_absolute_children(children, content)
174
316
  children.each do |child|
175
317
  nw, nh = natural_size(child, content.width, content.height)
@@ -195,11 +337,11 @@ module Zaniah
195
337
  active = line.dup
196
338
  remaining = available
197
339
  until active.empty?
198
- factor = active.sum { |i| growing ? i[:node].style[:flex_grow] : i[:node].style[:flex_shrink] * i[:base] }
340
+ factor = active.sum { |i| growing ? i[:style][:flex_grow] : i[:style][:flex_shrink] * i[:base] }
199
341
  free = remaining - active.sum { |i| i[:base] }
200
342
  frozen = []
201
343
  active.each do |item|
202
- weight = growing ? item[:node].style[:flex_grow] : item[:node].style[:flex_shrink] * item[:base]
344
+ weight = growing ? item[:style][:flex_grow] : item[:style][:flex_shrink] * item[:base]
203
345
  proposed = item[:base] + (factor.positive? ? free * weight / [factor, growing ? 1 : factor].max : 0)
204
346
  item[:size] = clamp_size(item[:node], dimension, proposed, available)
205
347
  frozen << item if (item[:size] - proposed).abs > 0.00001 || weight.zero?
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Layout
5
+ module GridLayout
6
+ private
7
+
8
+ def layout_grid_children(node, content, children)
9
+ children = children.reject { |child| child.style[:display] == :none && hide(child, node.bounds.x, node.bounds.y) }
10
+ columns = Array(node.style[:grid_template_columns]).flatten
11
+ columns = [:auto] if columns.empty?
12
+ parsed = children.map do |child|
13
+ column, column_span = grid_placement(child.style[:grid_column])
14
+ row, row_span = grid_placement(child.style[:grid_row])
15
+ {node: child, column: column, column_span: column_span || 1, row: row, row_span: row_span || 1}
16
+ end
17
+ column_count = [columns.length, parsed.filter_map { |item| item[:column] && item[:column] + item[:column_span] }.max || 1].max
18
+ columns += Array.new(column_count - columns.length, :auto)
19
+ items = place_grid_items(parsed, column_count)
20
+ row_count = [Array(node.style[:grid_template_rows]).flatten.length,
21
+ items.map { |item| item[:row] + item[:row_span] }.max || 1].max
22
+ rows = Array(node.style[:grid_template_rows]).flatten
23
+ rows += Array.new(row_count - rows.length, :auto)
24
+ column_gap = resolve_length(node.style[:column_gap] || node.style[:gap], content.width, 0)
25
+ row_gap = resolve_length(node.style[:row_gap] || node.style[:gap], content.height, 0)
26
+ natural = items.to_h { |item| [item, natural_size(item[:node], content.width, content.height)] }
27
+ widths = grid_track_sizes(columns, content.width, column_gap,
28
+ grid_intrinsic(items, natural, :column, :column_span, 0, column_count))
29
+ heights = grid_track_sizes(rows, content.height, row_gap,
30
+ grid_intrinsic(items, natural, :row, :row_span, 1, row_count))
31
+
32
+ items.each do |item|
33
+ column, row = item.values_at(:column, :row)
34
+ width = widths.slice(column, item[:column_span]).sum + column_gap * (item[:column_span] - 1)
35
+ height = heights.slice(row, item[:row_span]).sum + row_gap * (item[:row_span] - 1)
36
+ x = content.x + widths.take(column).sum + column_gap * column
37
+ y = content.y + heights.take(row).sum + row_gap * row
38
+ layout(item[:node], x, y, width, height, content.width, content.height)
39
+ end
40
+ end
41
+
42
+ def grid_placement(value)
43
+ case value
44
+ when nil then [nil, nil]
45
+ when Integer
46
+ raise ArgumentError, "grid positions start at 1" unless value.positive?
47
+ [value - 1, 1]
48
+ when Range
49
+ first, last = Integer(value.begin), Integer(value.end)
50
+ last -= 1 if value.exclude_end?
51
+ raise ArgumentError, "invalid grid range" unless first.positive? && last >= first
52
+ [first - 1, last - first + 1]
53
+ else raise ArgumentError, "grid placement must be an Integer or Range"
54
+ end
55
+ end
56
+
57
+ def place_grid_items(items, columns)
58
+ occupied = {}
59
+ cursor = [0, 0]
60
+ items.each_with_index.sort_by { |item, index| [item[:row] && item[:column] ? 0 : item[:row] || item[:column] ? 1 : 2, index] }.each do |item, _index|
61
+ explicit = item[:row] || item[:column]
62
+ row, column = next_grid_cell(occupied, columns, item, cursor)
63
+ item[:row], item[:column] = row, column
64
+ item[:row_span].times { |dy| item[:column_span].times { |dx| occupied[[row + dy, column + dx]] = true } }
65
+ cursor = column + item[:column_span] >= columns ? [row + 1, 0] : [row, column + item[:column_span]] unless explicit
66
+ end
67
+ items
68
+ end
69
+
70
+ def next_grid_cell(occupied, columns, item, cursor)
71
+ column_span, row_span = item.values_at(:column_span, :row_span)
72
+ raise ArgumentError, "grid item span exceeds column count" if column_span > columns
73
+ fixed_row, fixed_column = item.values_at(:row, :column)
74
+ row = fixed_row || (fixed_column ? 0 : cursor[0])
75
+ column = fixed_column || (fixed_row ? 0 : cursor[1])
76
+ loop do
77
+ fits = column + column_span <= columns && row_span.times.all? do |dy|
78
+ column_span.times.none? { |dx| occupied[[row + dy, column + dx]] }
79
+ end
80
+ return [row, column] if fits
81
+ raise ArgumentError, "grid row is already occupied" if fixed_row && fixed_column
82
+ if fixed_column
83
+ row += 1
84
+ else
85
+ column += 1
86
+ row, column = [row + 1, 0] if column + column_span > columns
87
+ raise ArgumentError, "grid row is already occupied" if fixed_row && row > fixed_row
88
+ end
89
+ end
90
+ end
91
+
92
+ def grid_intrinsic(items, natural, start_key, span_key, dimension, count)
93
+ values = Array.new(count, 0.0)
94
+ items.each do |item|
95
+ start, span = item.values_at(start_key, span_key)
96
+ wanted = natural.fetch(item)[dimension]
97
+ share = ([wanted - values.slice(start, span).sum, 0].max / span)
98
+ span.times { |index| values[start + index] += share }
99
+ end
100
+ values
101
+ end
102
+
103
+ def grid_track_sizes(specs, available, gap, intrinsic)
104
+ total = [available - gap * [specs.length - 1, 0].max, 0].max
105
+ sizes = specs.each_with_index.map { |spec, index| grid_track_min(spec, total, intrinsic[index]) }
106
+ weights = specs.map { |spec| grid_fraction(spec) }
107
+ active = weights.each_index.select { |index| weights[index].positive? }
108
+ remaining = total - sizes.each_index.reject { |index| active.include?(index) }.sum { |index| sizes[index] }
109
+ until active.empty?
110
+ unit = remaining / active.sum { |index| weights[index] }
111
+ constrained = active.select { |index| unit * weights[index] < sizes[index] }
112
+ if constrained.empty?
113
+ active.each { |index| sizes[index] = unit * weights[index] }
114
+ break
115
+ end
116
+ constrained.each { |index| remaining -= sizes[index]; active.delete(index) }
117
+ end
118
+ sizes
119
+ end
120
+
121
+ def grid_track_min(spec, available, intrinsic)
122
+ spec = spec.min if spec.is_a?(MinMax)
123
+ return intrinsic if spec.nil? || spec == :auto
124
+ return 0.0 if spec.is_a?(Length) && spec.unit == :fr
125
+ resolve_length(spec, available, 0)
126
+ end
127
+
128
+ def grid_fraction(spec)
129
+ spec = spec.max if spec.is_a?(MinMax)
130
+ spec.is_a?(Length) && spec.unit == :fr ? spec.value.to_f : 0.0
131
+ end
132
+ end
133
+ end
134
+ end