zaniah 0.2.0 → 0.3.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 (205) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -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/animation.md +46 -0
  19. data/docs/components.md +109 -0
  20. data/docs/gallery/command-palette-dark.png +0 -0
  21. data/docs/gallery/command-palette-high_contrast.png +0 -0
  22. data/docs/gallery/command-palette-light.png +0 -0
  23. data/docs/gallery/components-dark.png +0 -0
  24. data/docs/gallery/components-high_contrast.png +0 -0
  25. data/docs/gallery/components-light.png +0 -0
  26. data/docs/gallery/context-menu-dark.png +0 -0
  27. data/docs/gallery/context-menu-high_contrast.png +0 -0
  28. data/docs/gallery/context-menu-light.png +0 -0
  29. data/docs/gallery/dialog-dark.png +0 -0
  30. data/docs/gallery/dialog-high_contrast.png +0 -0
  31. data/docs/gallery/dialog-light.png +0 -0
  32. data/docs/gallery/drawer-left-dark.png +0 -0
  33. data/docs/gallery/drawer-left-high_contrast.png +0 -0
  34. data/docs/gallery/drawer-left-light.png +0 -0
  35. data/docs/gallery/drawer-right-dark.png +0 -0
  36. data/docs/gallery/drawer-right-high_contrast.png +0 -0
  37. data/docs/gallery/drawer-right-light.png +0 -0
  38. data/docs/gallery/menu-dark.png +0 -0
  39. data/docs/gallery/menu-high_contrast.png +0 -0
  40. data/docs/gallery/menu-light.png +0 -0
  41. data/docs/gallery/modal-dark.png +0 -0
  42. data/docs/gallery/modal-high_contrast.png +0 -0
  43. data/docs/gallery/modal-light.png +0 -0
  44. data/docs/gallery/popover-bottom-dark.png +0 -0
  45. data/docs/gallery/popover-bottom-high_contrast.png +0 -0
  46. data/docs/gallery/popover-bottom-light.png +0 -0
  47. data/docs/gallery/popover-left-dark.png +0 -0
  48. data/docs/gallery/popover-left-high_contrast.png +0 -0
  49. data/docs/gallery/popover-left-light.png +0 -0
  50. data/docs/gallery/popover-right-dark.png +0 -0
  51. data/docs/gallery/popover-right-high_contrast.png +0 -0
  52. data/docs/gallery/popover-right-light.png +0 -0
  53. data/docs/gallery/popover-top-dark.png +0 -0
  54. data/docs/gallery/popover-top-high_contrast.png +0 -0
  55. data/docs/gallery/popover-top-light.png +0 -0
  56. data/docs/gallery/toast-danger-dark.png +0 -0
  57. data/docs/gallery/toast-danger-high_contrast.png +0 -0
  58. data/docs/gallery/toast-danger-light.png +0 -0
  59. data/docs/gallery/toast-info-dark.png +0 -0
  60. data/docs/gallery/toast-info-high_contrast.png +0 -0
  61. data/docs/gallery/toast-info-light.png +0 -0
  62. data/docs/gallery/toast-success-dark.png +0 -0
  63. data/docs/gallery/toast-success-high_contrast.png +0 -0
  64. data/docs/gallery/toast-success-light.png +0 -0
  65. data/docs/gallery/toast-warning-dark.png +0 -0
  66. data/docs/gallery/toast-warning-high_contrast.png +0 -0
  67. data/docs/gallery/toast-warning-light.png +0 -0
  68. data/docs/gallery/tooltip-bottom-dark.png +0 -0
  69. data/docs/gallery/tooltip-bottom-high_contrast.png +0 -0
  70. data/docs/gallery/tooltip-bottom-light.png +0 -0
  71. data/docs/gallery/tooltip-left-dark.png +0 -0
  72. data/docs/gallery/tooltip-left-high_contrast.png +0 -0
  73. data/docs/gallery/tooltip-left-light.png +0 -0
  74. data/docs/gallery/tooltip-right-dark.png +0 -0
  75. data/docs/gallery/tooltip-right-high_contrast.png +0 -0
  76. data/docs/gallery/tooltip-right-light.png +0 -0
  77. data/docs/gallery/tooltip-top-dark.png +0 -0
  78. data/docs/gallery/tooltip-top-high_contrast.png +0 -0
  79. data/docs/gallery/tooltip-top-light.png +0 -0
  80. data/docs/index.html +74 -0
  81. data/docs/layout.md +31 -0
  82. data/docs/native.md +7 -2
  83. data/docs/site.css +32 -0
  84. data/docs/text.md +35 -0
  85. data/docs/theme.md +50 -0
  86. data/docs/tui.md +36 -0
  87. data/examples/gallery.rb +155 -0
  88. data/examples/native_ime.rb +13 -6
  89. data/lib/zaniah/accessibility/linux/service.rb +361 -0
  90. data/lib/zaniah/accessibility/linux.rb +23 -0
  91. data/lib/zaniah/accessibility/mac.rb +136 -0
  92. data/lib/zaniah/accessibility/native_tree.rb +53 -0
  93. data/lib/zaniah/accessibility/node.rb +12 -0
  94. data/lib/zaniah/accessibility/tree.rb +61 -0
  95. data/lib/zaniah/accessibility/windows/provider.rb +273 -0
  96. data/lib/zaniah/accessibility/windows.rb +38 -0
  97. data/lib/zaniah/accessibility.rb +63 -0
  98. data/lib/zaniah/animation/animator.rb +95 -0
  99. data/lib/zaniah/animation/easing.rb +45 -0
  100. data/lib/zaniah/animation/spring.rb +36 -0
  101. data/lib/zaniah/animation/transition.rb +5 -0
  102. data/lib/zaniah/animation.rb +6 -0
  103. data/lib/zaniah/app.rb +22 -2
  104. data/lib/zaniah/color.rb +16 -0
  105. data/lib/zaniah/devtools/hot_reload.rb +40 -0
  106. data/lib/zaniah/devtools/inspector.rb +111 -0
  107. data/lib/zaniah/devtools/stats_overlay.rb +16 -0
  108. data/lib/zaniah/devtools.rb +13 -0
  109. data/lib/zaniah/element.rb +210 -15
  110. data/lib/zaniah/frame_context.rb +4 -1
  111. data/lib/zaniah/gpu/frame_encoder.rb +10 -1
  112. data/lib/zaniah/gpu/instance_packing.rb +33 -5
  113. data/lib/zaniah/gpu/metal.rb +26 -10
  114. data/lib/zaniah/gpu/open_gl.rb +35 -14
  115. data/lib/zaniah/gpu/software.rb +116 -14
  116. data/lib/zaniah/gpu/vulkan/scene.frag +51 -0
  117. data/lib/zaniah/gpu/vulkan/scene.frag.spv +0 -0
  118. data/lib/zaniah/gpu/vulkan/scene.vert +50 -0
  119. data/lib/zaniah/gpu/vulkan/scene.vert.spv +0 -0
  120. data/lib/zaniah/gpu/vulkan.rb +282 -36
  121. data/lib/zaniah/gpu.rb +8 -3
  122. data/lib/zaniah/input/dispatcher.rb +96 -12
  123. data/lib/zaniah/input/focus_handle.rb +13 -4
  124. data/lib/zaniah/input/focus_tree.rb +77 -0
  125. data/lib/zaniah/input/keymap.rb +77 -0
  126. data/lib/zaniah/input.rb +1 -0
  127. data/lib/zaniah/interactivity.rb +30 -0
  128. data/lib/zaniah/layout/engine.rb +173 -36
  129. data/lib/zaniah/layout/grid.rb +134 -0
  130. data/lib/zaniah/layout/style.rb +16 -1
  131. data/lib/zaniah/layout.rb +1 -0
  132. data/lib/zaniah/length.rb +3 -0
  133. data/lib/zaniah/length_units.rb +7 -0
  134. data/lib/zaniah/list.rb +27 -15
  135. data/lib/zaniah/platform/appearance.rb +11 -0
  136. data/lib/zaniah/platform/headless/window.rb +101 -9
  137. data/lib/zaniah/platform/linux/appearance_aware.rb +8 -0
  138. data/lib/zaniah/platform/linux/wayland_window.rb +3 -1
  139. data/lib/zaniah/platform/linux/window.rb +4 -2
  140. data/lib/zaniah/platform/mac/app.rb +1 -1
  141. data/lib/zaniah/platform/mac/core_text.rb +3 -0
  142. data/lib/zaniah/platform/mac/window.rb +9 -1
  143. data/lib/zaniah/platform/tui/window.rb +1 -1
  144. data/lib/zaniah/platform/windows/window.rb +12 -2
  145. data/lib/zaniah/platform.rb +1 -0
  146. data/lib/zaniah/scene.rb +137 -28
  147. data/lib/zaniah/scroll_state.rb +117 -0
  148. data/lib/zaniah/scroll_view.rb +94 -0
  149. data/lib/zaniah/style/gradient.rb +28 -0
  150. data/lib/zaniah/style/ring.rb +13 -0
  151. data/lib/zaniah/style/shadow.rb +13 -0
  152. data/lib/zaniah/style/style_set.rb +43 -0
  153. data/lib/zaniah/style/transform.rb +40 -0
  154. data/lib/zaniah/svg.rb +19 -0
  155. data/lib/zaniah/text.rb +261 -6
  156. data/lib/zaniah/text_buffer.rb +119 -0
  157. data/lib/zaniah/text_selection.rb +18 -0
  158. data/lib/zaniah/text_system/atlas_cache.rb +1 -1
  159. data/lib/zaniah/text_system/fallback.rb +24 -0
  160. data/lib/zaniah/text_system/font_db.rb +4 -1
  161. data/lib/zaniah/text_system/kinsoku.rb +28 -0
  162. data/lib/zaniah/text_system/line_breaker.rb +54 -0
  163. data/lib/zaniah/text_system/paragraph.rb +99 -0
  164. data/lib/zaniah/text_system/renderer.rb +13 -2
  165. data/lib/zaniah/text_system/typesetter.rb +4 -0
  166. data/lib/zaniah/text_system.rb +3 -0
  167. data/lib/zaniah/theme.rb +39 -0
  168. data/lib/zaniah/ui/charts.rb +165 -0
  169. data/lib/zaniah/ui/choice_fields.rb +134 -0
  170. data/lib/zaniah/ui/component.rb +73 -0
  171. data/lib/zaniah/ui/controls.rb +439 -0
  172. data/lib/zaniah/ui/data.rb +364 -0
  173. data/lib/zaniah/ui/editors.rb +67 -0
  174. data/lib/zaniah/ui/forms.rb +131 -0
  175. data/lib/zaniah/ui/layout_components.rb +205 -0
  176. data/lib/zaniah/ui/modal.rb +129 -0
  177. data/lib/zaniah/ui/overlays.rb +282 -0
  178. data/lib/zaniah/ui/pickers.rb +169 -0
  179. data/lib/zaniah/ui/primitives.rb +199 -0
  180. data/lib/zaniah/ui/scrollbar.rb +104 -0
  181. data/lib/zaniah/ui/structure.rb +224 -0
  182. data/lib/zaniah/ui/text_inputs.rb +169 -0
  183. data/lib/zaniah/ui/variants.rb +23 -0
  184. data/lib/zaniah/ui.rb +18 -0
  185. data/lib/zaniah/unicode.rb +42 -0
  186. data/lib/zaniah/uniform_list.rb +27 -6
  187. data/lib/zaniah/version.rb +1 -1
  188. data/lib/zaniah.rb +14 -0
  189. data/sig/accessibility.rbs +34 -0
  190. data/sig/animation.rbs +54 -0
  191. data/sig/app.rbs +2 -1
  192. data/sig/devtools.rbs +37 -0
  193. data/sig/elements.rbs +88 -6
  194. data/sig/gpu.rbs +9 -2
  195. data/sig/input.rbs +31 -5
  196. data/sig/layout.rbs +2 -1
  197. data/sig/platform.rbs +24 -4
  198. data/sig/scene.rbs +15 -2
  199. data/sig/style.rbs +82 -0
  200. data/sig/text.rbs +35 -0
  201. data/sig/theme.rbs +64 -0
  202. data/sig/ui.rbs +390 -0
  203. data/sig/zaniah.rbs +56 -3
  204. data/tools/generate_component_gallery.rb +48 -0
  205. metadata +147 -2
@@ -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,17 @@ 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)
68
128
  key = [node.generation, x, y, width, height, available_w, available_h]
69
129
  return if node.cache[key]
70
130
  if node.style[:display] == :none
@@ -72,12 +132,20 @@ module Zaniah
72
132
  node.children.each { |child| hide(child, x, y) }
73
133
  return
74
134
  end
75
- width = clamp_size(node, :width, width, available_w)
76
- height = clamp_size(node, :height, height, available_h)
135
+ if clamp
136
+ width = clamp_size(node, :width, width, available_w)
137
+ height = clamp_size(node, :height, height, available_h)
138
+ end
77
139
  node.bounds = Bounds.new(x, y, width, height)
140
+ if node.children.empty?
141
+ node.cache.shift if node.cache.length >= 5
142
+ node.cache[key] = true
143
+ return
144
+ end
78
145
  content = content_bounds(node)
79
146
  flow, absolute = node.children.partition { |child| child.style[:position] != :absolute }
80
- layout_flow_children(node, content, flow)
147
+ s = node.style
148
+ s[:display] == :grid ? layout_grid_children(node, content, flow) : layout_flow_children(node, content, flow)
81
149
  layout_absolute_children(absolute, content)
82
150
  node.cache.shift if node.cache.length >= 5
83
151
  node.cache[key] = true
@@ -90,20 +158,27 @@ module Zaniah
90
158
  end
91
159
 
92
160
  def layout_flow_children(node, content, flow)
93
- s = node.style
161
+ s = node.style.to_h
94
162
  row = s[:flex_direction].to_s.start_with?("row")
95
163
  reverse = s[:flex_direction].to_s.end_with?("reverse")
96
164
  main_size, cross_size = row ? [content.width, content.height] : [content.height, content.width]
97
165
  before, after, cross_before, cross_after = row ? [3, 1, 0, 2] : [0, 2, 3, 1]
98
166
  main_dimension, cross_dimension = row ? [:width, :height] : [:height, :width]
99
167
  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) }
168
+ flow = flow.reject { |child| child.style.to_h[:display] == :none && hide(child, node.bounds.x, node.bounds.y) }
169
+ if s[:flex_wrap] == :nowrap && s[:justify_content] == :start && s[:align_items] == :stretch &&
170
+ layout_simple_flow(flow, content, row, reverse, main_size, cross_size, cross_dimension, gap)
171
+ return
172
+ end
101
173
  items = flow.map do |child|
174
+ style = child.style.to_h
102
175
  nw, nh = natural_size(child, content.width, content.height)
103
- base = resolve_length(child.style[:flex_basis], main_size, row ? nw : nh)
176
+ basis = style[:flex_basis]
177
+ base = resolve_length(basis, main_size, row ? nw : nh)
104
178
  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}
179
+ size = basis == :auto ? base : clamp_size(child, main_dimension, base, main_size)
180
+ {node: child, base: base, size: size,
181
+ cross: row ? nh : nw, margins: margins, style: style}
107
182
  end
108
183
  lines = [[]]
109
184
  occupied = 0
@@ -120,7 +195,10 @@ module Zaniah
120
195
  cross_cursor = 0
121
196
  lines.each do |line|
122
197
  next if line.empty?
123
- fixed_margin = line.sum { |i| i[:margins].values_at(before, after).sum { |v| v == :auto ? 0 : v } }
198
+ fixed_margin = line.sum do |item|
199
+ margins = item[:margins]
200
+ (margins[before] == :auto ? 0 : margins[before]) + (margins[after] == :auto ? 0 : margins[after])
201
+ end
124
202
  distribute_space(line, main_size - gap * (line.length - 1) - fixed_margin, main_dimension)
125
203
  free = main_size - line.sum { |i| i[:size] } - fixed_margin - gap * (line.length - 1)
126
204
  autos = line.sum { |i| i[:margins].values_at(before, after).count(:auto) }
@@ -135,16 +213,20 @@ module Zaniah
135
213
  else [0, gap]
136
214
  end
137
215
  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
216
+ baseline = line.reduce(0) { |value, item| item[:node].baseline > value ? item[:node].baseline : value }
139
217
  cursor = leading
140
218
  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]
219
+ child, margins = item[:node], item[:margins]
220
+ mb = margins[before] == :auto ? auto_margin : margins[before]
221
+ ma = margins[after] == :auto ? auto_margin : margins[after]
222
+ cb = margins[cross_before] == :auto ? 0 : margins[cross_before]
223
+ ca = margins[cross_after] == :auto ? 0 : margins[cross_after]
224
+ style = item[:style]
225
+ align = style[:align_self] == :auto ? s[:align_items] : style[:align_self]
145
226
  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)
227
+ cross = [line_cross - cb - ca, 0].max if align == :stretch && style[cross_dimension] == :auto
228
+ default_limits = cross_dimension == :width ? style[:min_width] == 0 && style[:max_width] == Float::INFINITY : style[:min_height] == 0 && style[:max_height] == Float::INFINITY
229
+ cross = clamp_size(child, cross_dimension, cross, cross_size) unless default_limits
148
230
  cross_free = line_cross - cross - cb - ca
149
231
  cross_offset = case align
150
232
  when :end then cross_free
@@ -152,24 +234,79 @@ module Zaniah
152
234
  when :baseline then baseline - child.baseline
153
235
  else 0
154
236
  end
155
- cross_autos = margins.values_at(cross_before, cross_after).count(:auto)
237
+ cross_autos = (margins[cross_before] == :auto ? 1 : 0) + (margins[cross_after] == :auto ? 1 : 0)
156
238
  if cross_autos.positive?
157
239
  cross_offset = margins[cross_before] == :auto ? [cross_free, 0].max / cross_autos : 0
158
240
  end
159
241
  cursor += mb
160
242
  main_position = reverse ? main_size - cursor - item[:size] : cursor
161
243
  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)
244
+ if row
245
+ cx, cy, cw, ch = content.x + main_position, content.y + cross_position, item[:size], cross
246
+ else
247
+ cx, cy, cw, ch = content.x + cross_position, content.y + main_position, cross, item[:size]
248
+ end
249
+ if style[:left] || style[:right]
250
+ cx += resolve_length(style[:left], content.width, 0) - resolve_length(style[:right], content.width, 0)
251
+ end
252
+ if style[:top] || style[:bottom]
253
+ cy += resolve_length(style[:top], content.height, 0) - resolve_length(style[:bottom], content.height, 0)
254
+ end
255
+ layout(child, cx, cy, cw, ch, content.width, content.height, false)
167
256
  cursor += item[:size] + ma + spacing
168
257
  end
169
258
  cross_cursor += line_cross + gap
170
259
  end
171
260
  end
172
261
 
262
+ def layout_simple_flow(flow, content, row, reverse, main_size, cross_size, cross_dimension, gap)
263
+ styles = flow.map { |child| child.style.to_h }
264
+ return false unless styles.all? do |style|
265
+ margin = style[:margin]
266
+ margin.is_a?(Numeric) && margin.zero? && style[:position] == :relative && style[:flex_basis] == :auto &&
267
+ (style[:align_self] == :auto || style[:align_self] == :stretch) && style[:min_width] == 0 && style[:min_height] == 0 &&
268
+ style[:max_width] == Float::INFINITY && style[:max_height] == Float::INFINITY &&
269
+ style[:margin_top].nil? && style[:margin_right].nil? && style[:margin_bottom].nil? && style[:margin_left].nil? &&
270
+ style[:margin_start].nil? && style[:margin_end].nil? && style[:left].nil? && style[:right].nil? &&
271
+ style[:top].nil? && style[:bottom].nil? && style[:flex_grow].is_a?(Numeric) && style[:flex_shrink].is_a?(Numeric)
272
+ end
273
+
274
+ bases, crosses = flow.map do |child|
275
+ width, height = natural_size(child, content.width, content.height)
276
+ row ? [width, height] : [height, width]
277
+ end.transpose
278
+ bases ||= []
279
+ crosses ||= []
280
+ available = main_size - gap * [flow.length - 1, 0].max
281
+ total = bases.sum
282
+ growing = total < available
283
+ factor = styles.each_with_index.sum do |style, index|
284
+ growing ? style[:flex_grow] : style[:flex_shrink] * bases[index]
285
+ end
286
+ free = available - total
287
+ divisor = growing ? [factor, 1].max : factor
288
+ sizes = bases.each_index.map do |index|
289
+ weight = growing ? styles[index][:flex_grow] : styles[index][:flex_shrink] * bases[index]
290
+ bases[index] + (factor.positive? ? free * weight / divisor : 0)
291
+ end
292
+ return false if sizes.any?(&:negative?)
293
+
294
+ cursor = 0
295
+ flow.each_with_index do |child, index|
296
+ style = styles[index]
297
+ size = sizes[index]
298
+ cross = style[cross_dimension] == :auto ? cross_size : crosses[index]
299
+ main_position = reverse ? main_size - cursor - size : cursor
300
+ if row
301
+ layout(child, content.x + main_position, content.y, size, cross, content.width, content.height, false)
302
+ else
303
+ layout(child, content.x, content.y + main_position, cross, size, content.width, content.height, false)
304
+ end
305
+ cursor += size + gap
306
+ end
307
+ true
308
+ end
309
+
173
310
  def layout_absolute_children(children, content)
174
311
  children.each do |child|
175
312
  nw, nh = natural_size(child, content.width, content.height)
@@ -195,11 +332,11 @@ module Zaniah
195
332
  active = line.dup
196
333
  remaining = available
197
334
  until active.empty?
198
- factor = active.sum { |i| growing ? i[:node].style[:flex_grow] : i[:node].style[:flex_shrink] * i[:base] }
335
+ factor = active.sum { |i| growing ? i[:style][:flex_grow] : i[:style][:flex_shrink] * i[:base] }
199
336
  free = remaining - active.sum { |i| i[:base] }
200
337
  frozen = []
201
338
  active.each do |item|
202
- weight = growing ? item[:node].style[:flex_grow] : item[:node].style[:flex_shrink] * item[:base]
339
+ weight = growing ? item[:style][:flex_grow] : item[:style][:flex_shrink] * item[:base]
203
340
  proposed = item[:base] + (factor.positive? ? free * weight / [factor, growing ? 1 : factor].max : 0)
204
341
  item[:size] = clamp_size(item[:node], dimension, proposed, available)
205
342
  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