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,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../platform/file_event"
4
+
5
+ module Zaniah
6
+ module DevTools
7
+ class HotReload
8
+ attr_reader :error
9
+
10
+ def initialize(app, paths, watcher: nil, loader: nil, &on_reload)
11
+ @app, @watcher = app, watcher || Platform.watch(paths)
12
+ @loader, @on_reload, @closed = loader || ->(path) { load path }, on_reload, false
13
+ end
14
+
15
+ def poll
16
+ return [] if @closed
17
+ paths = @watcher.poll(timeout: 0).filter_map do |event|
18
+ event.path if %i[created modified renamed].include?(event.kind) && event.path&.end_with?(".rb")
19
+ end.uniq
20
+ paths.each { |path| @loader.call(path) }
21
+ unless paths.empty?
22
+ @error = nil
23
+ @on_reload&.call(paths.freeze)
24
+ @app.windows.each(&:request_frame)
25
+ end
26
+ paths
27
+ rescue StandardError => exception
28
+ @error = exception
29
+ @app.windows.each(&:request_frame)
30
+ []
31
+ end
32
+
33
+ def close
34
+ return if @closed
35
+ @closed = true
36
+ @watcher.close
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module DevTools
5
+ Entry = Data.define(:object, :name, :key, :test_id, :bounds, :style, :children)
6
+
7
+ class Inspector
8
+ attr_reader :window, :snapshot, :selected
9
+
10
+ def initialize(window, visible: false, stats: true)
11
+ @window, @visible, @snapshot = window, !!visible, nil
12
+ @stats = StatsOverlay.new if stats
13
+ previous = window.instance_variable_get(:@on_frame)
14
+ window.on_frame do |element, clear|
15
+ previous&.call(element, clear)
16
+ frame(element)
17
+ end
18
+ window.devtools = self
19
+ end
20
+
21
+ def visible? = @visible
22
+
23
+ def toggle
24
+ @visible = !@visible
25
+ window.request_frame
26
+ self
27
+ end
28
+
29
+ def handle_input(event)
30
+ return false unless event.is_a?(Input::KeyDown) && Input::Keystroke.normalize(event.keystroke) == "f12"
31
+ toggle
32
+ true
33
+ end
34
+
35
+ def frame(element)
36
+ @snapshot = collect(element)
37
+ return unless visible?
38
+ @selected = hovered
39
+ paint_highlight(@selected.bounds) if @selected&.bounds
40
+ paint_panel
41
+ end
42
+
43
+ private
44
+
45
+ def collect(object, seen = {})
46
+ return if !object || seen[object.object_id]
47
+ seen[object.object_id] = true
48
+ node = object.respond_to?(:layout_node) ? object.layout_node : nil
49
+ style = if object.respond_to?(:resolved_style) && object.resolved_style
50
+ object.resolved_style.to_h
51
+ elsif object.respond_to?(:root) && object.root&.respond_to?(:resolved_style) && object.root.resolved_style
52
+ object.root.resolved_style.to_h
53
+ else {}
54
+ end
55
+ children = object.respond_to?(:children) ? object.children.filter_map { |child| collect(child, seen) } : []
56
+ Entry.new(object: object, name: object.class.name.sub("Zaniah::", ""),
57
+ key: object.respond_to?(:identity_key) ? object.identity_key : nil,
58
+ test_id: object.respond_to?(:test_id) ? object.test_id : nil,
59
+ bounds: node&.bounds, style: style.freeze, children: children.freeze)
60
+ end
61
+
62
+ def hovered
63
+ owner = window.dispatcher.hits.reverse.find { |hit| window.pointer_position && hit.bounds.contains?(window.pointer_position) }&.owner
64
+ find_entry(@snapshot, owner) || @snapshot
65
+ end
66
+
67
+ def find_entry(entry, object)
68
+ return unless entry && object
69
+ return entry if entry.object.equal?(object)
70
+ entry.children.each { |child| return found if (found = find_entry(child, object)) }
71
+ nil
72
+ end
73
+
74
+ def paint_highlight(bounds)
75
+ window.scene.layer(Scene::LAYER_DEBUG) do
76
+ window.scene.quad(bounds.x, bounds.y, bounds.width, bounds.height,
77
+ color: "#38bdf833", border_width: 1, border_color: "#38bdf8")
78
+ end
79
+ end
80
+
81
+ def paint_panel
82
+ scene = window.scene
83
+ width, line_height = [window.content_size.width * 0.42, 360].min, 17
84
+ x = window.content_size.width - width
85
+ lines = flatten(@snapshot).first(((window.content_size.height - 70) / line_height).floor)
86
+ scene.layer(Scene::LAYER_DEBUG) do
87
+ scene.quad(x, 0, width, window.content_size.height, color: "#111827ee", border_width: 1, border_color: "#38bdf8")
88
+ lines.each_with_index { |(entry, depth), index| text("#{" " * depth}#{entry.name}#{entry.key ? " [#{entry.key}]" : ""}", x + 8, 8 + index * line_height) }
89
+ if @selected
90
+ text("bounds: #{@selected.bounds&.to_h || "none"}", x + 8, window.content_size.height - 48)
91
+ text("style: #{@selected.style.reject { |_key, value| value.nil? }.first(3).to_h}", x + 8, window.content_size.height - 31)
92
+ end
93
+ @stats&.paint(window, x: x + 8, y: window.content_size.height - 68)
94
+ end
95
+ end
96
+
97
+ def flatten(entry, depth = 0, result = [])
98
+ return result unless entry
99
+ result << [entry, depth]
100
+ entry.children.each { |child| flatten(child, depth + 1, result) }
101
+ result
102
+ end
103
+
104
+ def text(value, x, y)
105
+ return unless window.text_system
106
+ line = window.text_system.layout_line(value.to_s, size: 11)
107
+ window.text_system.paint_line(window.scene, line, x: x, y: y + line.ascent, color: "#e5e7eb")
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module DevTools
5
+ class StatsOverlay
6
+ def paint(window, x:, y:)
7
+ stats = window.frame_stats
8
+ value = format("%.0f fps %.2f ms layout %.2f paint %.2f %d commands",
9
+ stats[:fps], stats[:frame_ms], stats[:layout_ms], stats[:paint_ms], stats[:command_count])
10
+ return unless window.text_system
11
+ line = window.text_system.layout_line(value, size: 11)
12
+ window.text_system.paint_line(window.scene, line, x: x, y: y + line.ascent, color: "#67e8f9")
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "devtools/inspector"
4
+ require_relative "devtools/stats_overlay"
5
+ require_relative "devtools/hot_reload"
6
+
7
+ module Zaniah
8
+ module DevTools
9
+ module_function
10
+
11
+ def attach(window, **options) = Inspector.new(window, **options)
12
+ end
13
+ end
@@ -4,15 +4,18 @@ module Zaniah
4
4
  class Element
5
5
  include LengthUnits
6
6
 
7
- attr_reader :layout_node
7
+ attr_reader :layout_node, :parent, :resolved_style
8
8
 
9
9
  def initialize
10
10
  @style, @children, @handlers = Layout::Style.new, [], {}
11
- @background, @border_color, @radius = "#0000", "#0000", 0
11
+ @style_set, @static_flags = StyleSet.new(@style), Set.new
12
12
  end
13
13
 
14
14
  def child(element)
15
- @children << element if element
15
+ if element
16
+ element.send(:parent=, self) if element.respond_to?(:parent=, true)
17
+ @children << element
18
+ end
16
19
  self
17
20
  end
18
21
 
@@ -24,16 +27,39 @@ module Zaniah
24
27
 
25
28
  def style(**properties)
26
29
  @style = @style.merge(**properties)
30
+ @style_set.merge(**properties)
27
31
  self
28
32
  end
29
33
 
30
34
  def key(value) = (@key = value; self)
35
+ def identity_key = @key
36
+ def paint_style(**properties)
37
+ @paint_opacity = properties.delete(:opacity) if properties.key?(:opacity)
38
+ @paint_style = (@paint_style || {}).merge(properties)
39
+ self
40
+ end
41
+ def transition(*properties, duration:, easing: :ease_in_out)
42
+ raise ArgumentError, "transition needs at least one property" if properties.empty?
43
+ @transition = Transition.new(properties.map(&:to_sym).freeze, Float(duration), easing)
44
+ self
45
+ end
31
46
  def test_id(value = (getter = true)) = getter ? @test_id : (@test_id = value.to_s.freeze; self)
32
47
  def handlers = @handlers.keys.freeze
33
48
  def with_state(&initial) = (@state_initializer = initial; self)
34
- def bg(color) = (@background = color; self)
35
- def border_color(color) = (@border_color = color; self)
36
- def rounded(radius) = (@radius = radius; self)
49
+ def bg(color) = style(background: color)
50
+ def border_color(color) = style(border_color: color)
51
+ def rounded(radius) = style(corner_radii: radius)
52
+ def opacity(value) = style(opacity: value)
53
+ def ring(width, color = nil, offset = 0) = style(ring: Ring.new(width, color, offset))
54
+ def cursor(value) = style(cursor: value)
55
+ def focusable(tab_index: 0, context: {}, &on_action)
56
+ @focus_handle ||= Input::FocusHandle.new(owner: self)
57
+ @focus_handle.tab_index, @focus_handle.focusable = Integer(tab_index), true
58
+ @focus_handle.context.merge!(context)
59
+ @focus_handle.on_action = on_action if on_action
60
+ self
61
+ end
62
+ def focus_handle = @focus_handle
37
63
  def tooltip(text) = (@tooltip = text.to_s; self)
38
64
  def context_menu(items) = (@context_menu = items; self)
39
65
  def flex = style(display: :flex)
@@ -46,6 +72,21 @@ module Zaniah
46
72
  def flex_1 = style(flex_grow: 1, flex_basis: 0)
47
73
  def overflow_hidden = style(overflow: :hidden)
48
74
 
75
+ %i[hover active focus focus_visible].each do |state|
76
+ define_method(state) { |**properties, &block| state_style(state, properties, &block) }
77
+ end
78
+
79
+ %i[disabled selected].each do |state|
80
+ define_method(state) do |value = true, **properties, &block|
81
+ if block || !properties.empty?
82
+ state_style(state, properties, &block)
83
+ else
84
+ value ? @static_flags.add(state) : @static_flags.delete(state)
85
+ self
86
+ end
87
+ end
88
+ end
89
+
49
90
  {w: :width, h: :height, min_w: :min_width, min_h: :min_height,
50
91
  max_w: :max_width, max_h: :max_height, p: :padding, m: :margin,
51
92
  gap: :gap, border: :border, border_b: :border_bottom}.each do |method, property|
@@ -63,13 +104,57 @@ module Zaniah
63
104
  end
64
105
 
65
106
  def prepaint(bounds, _state, cx)
66
- unless @handlers.empty? && !@tooltip && !@context_menu
107
+ transform = @style[:transform] || Transform.identity
108
+ return prepaint_contents(bounds, cx) if transform == Transform.identity
109
+ cx.dispatcher.push_transform(transform) { prepaint_contents(bounds, cx) }
110
+ end
111
+
112
+ def paint(bounds, _state, _prepaint, cx)
113
+ flags = cx.interactivity.for(self, @static_flags)
114
+ @resolved_style = transition_style(@style_set.resolve(flags), cx)
115
+ @resolved_style = @resolved_style.merge(**@paint_style) if @paint_style && !@paint_style.empty?
116
+ cx.window.set_cursor(@resolved_style[:cursor]) if flags.include?(:hover) && @resolved_style[:cursor]
117
+ border = @resolved_style[:border_widths] || @resolved_style[:border]
118
+ border = 0 unless border.is_a?(Numeric) || border.is_a?(Edges)
119
+ background = @resolved_style[:background] || "#0000"
120
+ transform = @resolved_style[:transform] || Transform.identity
121
+ z_index = Float(@resolved_style[:z_index])
122
+ operation = lambda do
123
+ if z_index.zero?
124
+ paint_transformed(bounds, border, background, transform, cx)
125
+ else
126
+ cx.scene.layer(Scene::LAYER_CONTENT + z_index) do
127
+ paint_transformed(bounds, border, background, transform, cx)
128
+ end
129
+ end
130
+ end
131
+ @paint_opacity ? cx.scene.push_opacity(@paint_opacity, &operation) : operation.call
132
+ paint_ring(bounds, cx) if @resolved_style[:ring]
133
+ ensure
134
+ @paint_style = nil
135
+ @paint_opacity = nil
136
+ end
137
+
138
+ protected
139
+
140
+ attr_writer :parent
141
+
142
+ private
143
+
144
+ def prepaint_contents(bounds, cx)
145
+ if @focus_handle
146
+ @focus_handle.bounds = bounds
147
+ @focus_handle.focusable = !@static_flags.include?(:disabled)
148
+ cx.dispatcher.register_focus(@focus_handle)
149
+ end
150
+ unless @handlers.empty? && !@tooltip && !@context_menu && !@style_set.interactive? && !@focus_handle
67
151
  cx.dispatcher.hit(bounds, owner: self) do |event|
68
152
  if event.is_a?(Input::MouseDown) && event.button == :right && @context_menu
69
153
  cx.window.context_menu(@context_menu, position: event.position)
70
154
  next true
71
155
  end
72
156
  cx.window.offer_tooltip(@tooltip, position: event.position) if @tooltip && event.is_a?(Input::MouseMove)
157
+ cx.dispatcher.focus(@focus_handle, origin: :pointer) if @focus_handle && event.is_a?(Input::MouseDown)
73
158
  kind = case event
74
159
  when Input::MouseDown then @handlers.key?(:mouse_down) ? :mouse_down : :click
75
160
  when Input::MouseMove then @dragging && @handlers.key?(:drag) ? :drag : :hover
@@ -80,19 +165,129 @@ module Zaniah
80
165
  @dragging = true if event.is_a?(Input::MouseDown) && @handlers[:drag]
81
166
  @dragging = false if event.is_a?(Input::MouseUp)
82
167
  handler&.call(event, cx)
83
- @dragging && event.is_a?(Input::MouseDown) ? :capture : !!handler || !!(@tooltip && event.is_a?(Input::MouseMove))
168
+ @dragging && event.is_a?(Input::MouseDown) ? :capture : !!handler || !!(@focus_handle && event.is_a?(Input::MouseDown)) || !!(@tooltip && event.is_a?(Input::MouseMove))
84
169
  end
85
170
  end
86
- visit = -> { @children.each { |child| child.prepaint(child.layout_node.bounds, nil, cx) } }
87
- @style[:overflow] == :visible ? visit.call : cx.dispatcher.clip(bounds, &visit)
171
+ operation = -> { @children.each { |child| child.prepaint(child.layout_node.bounds, nil, cx) } }
172
+ @style[:overflow] == :visible ? operation.call : cx.dispatcher.clip(bounds, &operation)
173
+ prepare_child_animations(cx)
88
174
  end
89
175
 
90
- def paint(bounds, _state, _prepaint, cx)
91
- border = @style[:border].is_a?(Numeric) ? @style[:border] : 0
176
+ def paint_transformed(bounds, border, background, transform, cx)
177
+ return paint_contents(bounds, border, background, cx) if transform == Transform.identity
178
+ cx.scene.push_transform(transform) { paint_contents(bounds, border, background, cx) }
179
+ end
180
+
181
+ def paint_contents(bounds, border, background, cx)
182
+ shadows = @resolved_style[:shadows]
183
+ shadows = [shadows] if shadows && !shadows.is_a?(Array)
184
+ shadows&.each do |shadow|
185
+ next unless shadow.is_a?(Shadow)
186
+ cx.scene.shadow(bounds.x + shadow.x, bounds.y + shadow.y, bounds.width, bounds.height,
187
+ color: shadow.color, blur: shadow.blur, spread: shadow.spread,
188
+ radius: @resolved_style[:corner_radii] || 0, inset: shadow.inset)
189
+ end
92
190
  cx.scene.quad(bounds.x, bounds.y, bounds.width, bounds.height,
93
- color: @background, radius: @radius, border_width: border, border_color: @border_color)
94
- paint_children = -> { @children.each { |child| child.paint(child.layout_node.bounds, nil, nil, cx) unless child.layout_node.style[:display] == :none } }
95
- @style[:overflow] == :visible ? paint_children.call : cx.scene.clip(bounds, &paint_children)
191
+ color: background, opacity: @resolved_style[:opacity],
192
+ radius: @resolved_style[:corner_radii] || 0, border_width: border,
193
+ border_color: @resolved_style[:border_color] || "#0000",
194
+ border_style: @resolved_style[:border_style])
195
+ operation = lambda do
196
+ @children.each { |child| child.paint(child.layout_node.bounds, nil, nil, cx) unless child.layout_node.style[:display] == :none }
197
+ @departing_children&.each { |child, old_bounds, opacity| child.paint_style(opacity: opacity).paint(old_bounds, nil, nil, cx) }
198
+ end
199
+ @style[:overflow] == :visible ? operation.call : cx.scene.clip(bounds, &operation)
200
+ end
201
+
202
+ def paint_ring(bounds, cx)
203
+ ring = @resolved_style[:ring]
204
+ extent = ring.width + ring.offset
205
+ radius = @resolved_style[:corner_radii] || 0
206
+ radius += extent if radius.is_a?(Numeric)
207
+ cx.scene.layer(Scene::LAYER_FOCUS_RING) do
208
+ paint = -> { cx.scene.quad(bounds.x - extent, bounds.y - extent, bounds.width + extent * 2, bounds.height + extent * 2,
209
+ color: "#0000", radius: radius, border_width: ring.width, border_color: ring.color || cx.theme.colors.ring) }
210
+ transform = @resolved_style[:transform] || Transform.identity
211
+ transform == Transform.identity ? paint.call : cx.scene.push_transform(transform, &paint)
212
+ end
213
+ end
214
+
215
+ def state_style(state, properties)
216
+ builder = StyleBuilder.new(properties)
217
+ yield(builder) if block_given?
218
+ @style_set.on(state, **builder.properties)
219
+ self
220
+ end
221
+
222
+ def transition_style(target, cx)
223
+ return target unless @transition && @key
224
+ state = cx.state([:transition, @key]) { {targets: {}} }
225
+ values = {}
226
+ @transition.properties.each do |property|
227
+ value = target[property]
228
+ unless state[:targets].key?(property)
229
+ state[:targets][property] = value
230
+ next
231
+ end
232
+ previous = state[:targets][property]
233
+ animation_key = [:transition, @key, property]
234
+ if previous != value
235
+ from = cx.animator.value(animation_key, previous)
236
+ if Animation.interpolatable?(from, value)
237
+ cx.animator.animate(animation_key, from: from, to: value,
238
+ duration: @transition.duration, easing: @transition.easing)
239
+ else
240
+ cx.animator.cancel(animation_key)
241
+ end
242
+ state[:targets][property] = value
243
+ end
244
+ values[property] = cx.animator.value(animation_key, value)
245
+ end
246
+ values.empty? ? target : target.merge(**values)
247
+ end
248
+
249
+ def prepare_child_animations(cx)
250
+ keyed = @children.filter_map { |child| [child.identity_key, child] if child.respond_to?(:identity_key) && child.identity_key }
251
+ return if keyed.empty? && !@key && !@child_animation_state
252
+ identity = @key || object_id
253
+ state = cx.state([:children, identity]) { {previous: {}, departing: {}, initialized: false} }
254
+ @child_animation_state = state
255
+ current = keyed.to_h
256
+ duration = cx.theme.motion.duration_base
257
+ keyed.each do |key, child|
258
+ enter_key, move_key = [:enter, identity, key], [:move, identity, key]
259
+ if (departure = state[:departing].delete(key))
260
+ cx.animator.cancel([:exit, identity, key])
261
+ state[:previous][key] = departure.values_at(:child, :bounds)
262
+ end
263
+ previous = state[:previous][key]
264
+ if previous
265
+ old_bounds, new_bounds = previous.last, child.layout_node.bounds
266
+ if old_bounds.x != new_bounds.x || old_bounds.y != new_bounds.y
267
+ cx.animator.animate(move_key, from: 0.0, to: 1.0, duration: duration, easing: :ease_out)
268
+ end
269
+ progress = cx.animator.value(move_key, 1.0)
270
+ if progress < 1
271
+ child.paint_style(transform: Transform.translate((old_bounds.x - new_bounds.x) * (1 - progress), (old_bounds.y - new_bounds.y) * (1 - progress)))
272
+ end
273
+ elsif state[:initialized]
274
+ cx.animator.animate(enter_key, from: 0.0, to: 1.0, duration: duration, easing: :ease_out)
275
+ child.paint_style(opacity: cx.animator.value(enter_key, 1.0))
276
+ end
277
+ end
278
+ (state[:previous].keys - current.keys).each do |key|
279
+ child, old_bounds = state[:previous][key]
280
+ exit_key = [:exit, identity, key]
281
+ state[:departing][key] = {child: child, bounds: old_bounds}
282
+ cx.animator.animate(exit_key, from: 1.0, to: 0.0, duration: duration, easing: :ease_in) do
283
+ state[:departing].delete(key)
284
+ end
285
+ end
286
+ state[:previous] = keyed.to_h { |key, child| [key, [child, child.layout_node.bounds]] }
287
+ state[:initialized] = true
288
+ @departing_children = state[:departing].map do |key, departure|
289
+ [departure[:child], departure[:bounds], cx.animator.value([:exit, identity, key], 0.0)]
290
+ end
96
291
  end
97
292
  end
98
293
  end
@@ -2,13 +2,16 @@
2
2
 
3
3
  module Zaniah
4
4
  class FrameContext
5
- attr_reader :window, :scene, :dispatcher, :text_system
5
+ attr_reader :window, :scene, :dispatcher, :text_system, :interactivity
6
6
 
7
7
  def initialize(window)
8
8
  @window, @scene = window, window.scene
9
9
  @dispatcher, @text_system = window.dispatcher, window.text_system
10
+ @interactivity = Interactivity.new
10
11
  end
11
12
 
12
13
  def state(key, &initial) = @window.element_state(key, &initial)
14
+ def theme = @window.app&.global(:theme) || Theme.dark
15
+ def animator = @window.animator
13
16
  end
14
17
  end
@@ -21,7 +21,16 @@ module Zaniah
21
21
  raise ArgumentError, "instance buffer too small" if values.length < instance_count * Scene::QUAD_STRIDE
22
22
  instance_count.times do |index|
23
23
  quad = values.slice(index * Scene::QUAD_STRIDE, Scene::QUAD_STRIDE)
24
- @scene.quad(*quad.first(4), color: quad[4, 4], radius: quad[8, 4], border_width: quad[12], border_color: quad[13, 4])
24
+ color = if quad[24].zero?
25
+ quad[4, 4]
26
+ elsif quad[24] == 1
27
+ Gradient.linear(angle: quad[27], stops: [[quad[25], quad[4, 4]], [quad[26], quad[8, 4]]])
28
+ else
29
+ Gradient.radial(center: quad[28, 2], radius: quad[30], stops: [[quad[25], quad[4, 4]], [quad[26], quad[8, 4]]])
30
+ end
31
+ @scene.quad(*quad.first(4), color: color, radius: quad[12, 4],
32
+ border_width: Edges.new(*quad[20, 4]), border_color: quad[16, 4],
33
+ transform: Transform.new(*quad[32, 6]), border_style: quad[39] == 1 ? :dashed : :solid)
25
34
  end
26
35
  elsif [:mono_sprite, :poly_sprite].include?(@pipeline.shader)
27
36
  raise ArgumentError, "instance buffer too small" if values.length < instance_count * 12
@@ -4,8 +4,11 @@ module Zaniah
4
4
  module GPU
5
5
  # Consecutive commands only are batched, preserving alpha and layer order.
6
6
  module InstancePacking
7
- STRIDE = 24
7
+ STRIDE = 40
8
8
  def self.pack(scene)
9
+ packed = pack_quads(scene)
10
+ return packed if packed
11
+
9
12
  data, batches, bytes = [], [], "".b
10
13
  scene.each_command do |kind, offset, clip|
11
14
  texture = nil
@@ -15,14 +18,19 @@ module Zaniah
15
18
  when :quad
16
19
  q, i = scene.quads, offset
17
20
  data.push(q[i], q[i+1], q[i+2], q[i+3], q[i+4], q[i+5], q[i+6], q[i+7],
18
- q[i+8], q[i+9], q[i+10], q[i+11], q[i+13], q[i+14], q[i+15], q[i+16], q[i+12], 0, 0, 0, 0, 0, 0, 0)
21
+ q[i+8], q[i+9], q[i+10], q[i+11], q[i+12], q[i+13], q[i+14], q[i+15],
22
+ q[i+16], q[i+17], q[i+18], q[i+19], q[i+20], q[i+21], q[i+22], q[i+23],
23
+ q[i+24], q[i+25], q[i+26], q[i+27], q[i+28], q[i+29], q[i+30], q[i+31],
24
+ q[i+32], q[i+33], q[i+34], q[i+35], q[i+36], q[i+37], q[i+38], q[i+39])
19
25
  when :sprite
20
26
  s, i = scene.sprite_data, offset
21
27
  texture = scene.textures.fetch(s[i+8])
22
28
  data.push(s[i], s[i+1], s[i+2], s[i+3], s[i+4], s[i+5], s[i+6], s[i+7],
23
- 0, 0, 0, 0, 0, 0, 0, 0, 0, texture.format == :r8 ? 1 : 2, 0, 0,
29
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
24
30
  s[i+9].to_f / texture.width, s[i+10].to_f / texture.height,
25
- s[i+11].to_f / texture.width, s[i+12].to_f / texture.height)
31
+ s[i+11].to_f / texture.width, s[i+12].to_f / texture.height,
32
+ 0, 0, 0, 0, 0, 0, 0, texture.format == :r8 ? 1 : 2,
33
+ *scene.sprite_transform(i).to_a, 0, 0)
26
34
  when :sprite_batch
27
35
  unless data.empty?
28
36
  bytes << data.pack("f*")
@@ -35,7 +43,9 @@ module Zaniah
35
43
  when :triangle
36
44
  p, i = scene.paths, offset
37
45
  data.push(p[i], p[i+1], p[i+2], p[i+3], p[i+6], p[i+7], p[i+8], p[i+9],
38
- p[i+4], p[i+5], 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0)
46
+ 0, 0, 0, 0, p[i+4], p[i+5], 0, 0, 0, 0, 0, 0,
47
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3,
48
+ p[i+10], p[i+11], p[i+12], p[i+13], p[i+14], p[i+15], 0, 0)
39
49
  else raise ArgumentError, "unsupported primitive #{kind}"
40
50
  end
41
51
  previous = batches.last&.first
@@ -48,6 +58,24 @@ module Zaniah
48
58
  bytes << data.pack("f*") unless data.empty?
49
59
  [bytes, batches]
50
60
  end
61
+
62
+ def self.pack_quads(scene)
63
+ batches = []
64
+ expected = 0
65
+ scene.each_command do |kind, offset, clip|
66
+ return unless kind == :quad && offset == expected
67
+ if batches.empty? || batches.last[0][2] != clip
68
+ batches << [[:quad, nil, clip], expected / STRIDE, 1]
69
+ else
70
+ batches.last[2] += 1
71
+ end
72
+ expected += STRIDE
73
+ end
74
+ bytes = scene.__send__(:packed_quad_bytes)
75
+ return unless bytes && expected * 4 == bytes.bytesize
76
+ [bytes, batches]
77
+ end
78
+ private_class_method :pack_quads
51
79
  end
52
80
  end
53
81
  end
@@ -19,35 +19,51 @@ module Zaniah
19
19
  #include <metal_stdlib>
20
20
  using namespace metal;
21
21
  struct Vertex { float4 position [[position]]; float2 local; float2 size;
22
- float4 color; float4 radii; float4 border; float4 info; float2 uv; };
22
+ float4 color; float4 secondary; float4 radii; float4 border; float4 widths;
23
+ float4 gradient; float4 center_kind; float2 uv; float dash; };
23
24
  vertex Vertex zaniah_vertex(uint v [[vertex_id]], uint instance [[instance_id]],
24
25
  const device float *data [[buffer(0)]], constant float2 &viewport [[buffer(1)]]) {
25
- const device float *p = data + instance * 24;
26
+ const device float *p = data + instance * 40;
26
27
  float2 corners[4] = {float2(0,0),float2(1,0),float2(0,1),float2(1,1)};
27
28
  float2 corner = corners[v], size = float2(p[2],p[3]);
28
29
  float2 position = float2(p[0],p[1]) + corner * size;
29
- if (p[17] == 3) position = v == 0 ? float2(p[0],p[1]) : v == 1 ? float2(p[2],p[3]) : float2(p[8],p[9]);
30
+ if (p[31] == 3) position = v == 0 ? float2(p[0],p[1]) : v == 1 ? float2(p[2],p[3]) : float2(p[12],p[13]);
31
+ position = float2(p[32] * position.x + p[34] * position.y + p[36],
32
+ p[33] * position.x + p[35] * position.y + p[37]);
30
33
  Vertex out;
31
34
  out.position = float4(position.x / viewport.x * 2 - 1, 1 - position.y / viewport.y * 2, 0, 1);
32
35
  out.local = corner * size; out.size = size;
33
- out.color = float4(p[4],p[5],p[6],p[7]); out.radii = float4(p[8],p[9],p[10],p[11]);
34
- out.border = float4(p[12],p[13],p[14],p[15]); out.info = float4(p[16],p[17],p[18],p[19]);
35
- out.uv = float2(p[20],p[21]) + corner * float2(p[22],p[23]); return out;
36
+ out.color = float4(p[4],p[5],p[6],p[7]); out.secondary = float4(p[8],p[9],p[10],p[11]);
37
+ out.radii = float4(p[12],p[13],p[14],p[15]); out.border = float4(p[16],p[17],p[18],p[19]);
38
+ out.widths = float4(p[20],p[21],p[22],p[23]); out.gradient = float4(p[24],p[25],p[26],p[27]);
39
+ out.center_kind = float4(p[28],p[29],p[30],p[31]);
40
+ out.uv = float2(p[20],p[21]) + corner * float2(p[22],p[23]); out.dash = p[39]; return out;
36
41
  }
37
42
  fragment float4 zaniah_fragment(Vertex in [[stage_in]], texture2d<float> atlas [[texture(0)]]) {
38
43
  float4 color = in.color;
39
- if (in.info.y == 0) {
44
+ if (in.center_kind.w == 0) {
45
+ if (in.gradient.x > 0) {
46
+ float2 normalized = in.local / in.size;
47
+ float raw = in.gradient.x == 1
48
+ ? dot(normalized - 0.5f, float2(cos(in.gradient.w * 0.01745329252f), sin(in.gradient.w * 0.01745329252f))) + 0.5f
49
+ : length(normalized - in.center_kind.xy) / in.center_kind.z;
50
+ color = mix(in.color, in.secondary, clamp((raw - in.gradient.y) / max(in.gradient.z - in.gradient.y, 0.000001f), 0.0f, 1.0f));
51
+ }
40
52
  float radius = in.local.y < in.size.y/2 ? (in.local.x < in.size.x/2 ? in.radii.x : in.radii.y) : (in.local.x < in.size.x/2 ? in.radii.w : in.radii.z);
41
53
  radius = clamp(radius, 0.0f, min(in.size.x,in.size.y)/2);
42
54
  float2 q = abs(in.local - in.size/2) - in.size/2 + radius;
43
55
  float distance = length(max(q,0.0f)) + min(max(q.x,q.y),0.0f) - radius;
44
56
  float coverage = clamp(0.5f - distance, 0.0f, 1.0f);
45
- if (in.info.x > 0 && distance >= -in.info.x) color = in.border;
57
+ float4 edge_distance = float4(in.local.y, in.size.x-in.local.x, in.size.y-in.local.y, in.local.x);
58
+ float edge = min(min(edge_distance.x,edge_distance.y),min(edge_distance.z,edge_distance.w));
59
+ float border_width = edge == edge_distance.x ? in.widths.x : edge == edge_distance.y ? in.widths.y : edge == edge_distance.z ? in.widths.z : in.widths.w;
60
+ float coordinate = edge == edge_distance.x || edge == edge_distance.z ? in.local.x : in.local.y;
61
+ if (border_width > 0 && distance >= -border_width && !(in.dash == 1 && fmod(coordinate,6.0f) >= 3.0f)) color = in.border;
46
62
  color.a *= coverage;
47
- } else if (in.info.y == 1 || in.info.y == 2) {
63
+ } else if (in.center_kind.w == 1 || in.center_kind.w == 2) {
48
64
  constexpr sampler texture_sampler(coord::normalized, address::clamp_to_edge, filter::nearest);
49
65
  float4 sample = atlas.sample(texture_sampler,in.uv);
50
- if (in.info.y == 1) color.a *= sample.r; else color *= sample;
66
+ if (in.center_kind.w == 1) color.a *= sample.r; else color *= sample;
51
67
  }
52
68
  return float4(color.rgb * color.a, color.a);
53
69
  }