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
@@ -8,12 +8,27 @@ module Zaniah
8
8
  flex_grow: 0, flex_shrink: 1, flex_basis: :auto, width: :auto, height: :auto,
9
9
  min_width: 0, min_height: 0, max_width: Float::INFINITY, max_height: Float::INFINITY,
10
10
  padding: 0, margin: 0, border: 0, gap: 0, position: :relative,
11
- overflow: :visible}.freeze
11
+ direction: :ltr, padding_start: nil, padding_end: nil,
12
+ margin_start: nil, margin_end: nil, border_start: nil, border_end: nil,
13
+ overflow: :visible, aspect_ratio: nil, grid_template_columns: nil,
14
+ grid_template_rows: nil, grid_column: nil, grid_row: nil, row_gap: nil,
15
+ column_gap: nil, z_index: 0, opacity: 1.0, transform: nil,
16
+ background: nil, border_widths: nil, border_style: :solid,
17
+ border_color: nil, corner_radii: nil, shadows: nil, cursor: nil,
18
+ ring: nil, text_color: nil, font_size: nil, font_family: nil,
19
+ line_height: nil, letter_spacing: nil, text_align: :start,
20
+ text_wrap: :wrap, text_overflow: :clip}.freeze
21
+ INHERITED = %i[text_color font_size font_family line_height letter_spacing].freeze
12
22
 
13
23
  def initialize(**properties) = @values = DEFAULTS.merge(properties).freeze
14
24
  def [](name) = @values[name]
15
25
  def merge(**properties) = Style.new(**@values.merge(properties))
16
26
  def to_h = @values
27
+
28
+ def inherit(parent)
29
+ inherited = INHERITED.to_h { |name| [name, self[name] || parent[name]] }
30
+ merge(**inherited)
31
+ end
17
32
  end
18
33
  end
19
34
  end
data/lib/zaniah/layout.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "layout/available_space"
4
4
  require_relative "layout/style"
5
5
  require_relative "layout/node"
6
+ require_relative "layout/grid"
6
7
  require_relative "layout/engine"
7
8
  require_relative "layout/tree"
8
9
 
data/lib/zaniah/length.rb CHANGED
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zaniah
4
+ MinMax = Data.define(:min, :max)
5
+
4
6
  Length = Data.define(:value, :unit) do
5
7
  def resolve(available, rem: 16)
6
8
  case unit
7
9
  when :px then value
8
10
  when :percent then available * value / 100.0
9
11
  when :rem then rem * value
12
+ when :fr then raise ArgumentError, "fr lengths are only valid in grid tracks"
10
13
  else raise ArgumentError, "unknown length unit #{unit}"
11
14
  end
12
15
  end
@@ -5,5 +5,12 @@ module Zaniah
5
5
  def px(value) = Length.new(value, :px)
6
6
  def rems(value) = Length.new(value, :rem)
7
7
  def percent(value) = Length.new(value, :percent)
8
+ def fr(value) = Length.new(value, :fr)
9
+ def minmax(min, max) = MinMax.new(min, max)
10
+
11
+ def repeat(count, track)
12
+ raise ArgumentError, "repeat count must be a positive integer" unless count.is_a?(Integer) && count.positive?
13
+ Array.new(count, track)
14
+ end
8
15
  end
9
16
  end
data/lib/zaniah/list.rb CHANGED
@@ -4,32 +4,37 @@ module Zaniah
4
4
  # A variable-height, viewport-only list. Unknown rows use estimated_height;
5
5
  # measuring visible rows refines the prefix sums without moving the anchor.
6
6
  class List < Element
7
- attr_reader :scroll_y, :visible_range, :heights
7
+ attr_reader :visible_range, :heights, :scroll_state
8
8
 
9
9
  def initialize(count:, estimated_height: 24, overscan: 2, &render_item)
10
10
  super()
11
11
  raise ArgumentError, "a row renderer is required" unless render_item
12
12
  raise ArgumentError, "overscan must be a nonnegative integer" unless overscan.is_a?(Integer) && overscan >= 0
13
13
  @heights, @overscan, @render_item = HeightIndex.new(count, estimated_height), overscan, render_item
14
- @scroll_y, @viewport, @visible_range = 0.0, 0.0, 0...0
14
+ @scroll_state = ScrollState.new(axis: :vertical)
15
+ @scroll_state.update(content_size: Size.new(0, @heights.total), viewport_size: Size.new(0, 0))
16
+ @viewport, @visible_range = 0.0, 0...0
15
17
  style(overflow: :hidden)
16
18
  on_scroll_wheel do |event, cx|
17
- self.scroll_y = @scroll_y + event.delta.y
19
+ @scroll_state.glide_by(event.delta.y, animator: cx.animator, key: [:scroll, object_id],
20
+ duration: cx.theme.motion.reduced? ? 0 : cx.theme.motion.duration_slow)
18
21
  cx.window.request_frame
19
22
  end
20
23
  end
21
24
 
22
25
  def total_height = @heights.total
26
+ def scroll_y = @scroll_state.offset.y
23
27
 
24
28
  def scroll_y=(value)
25
- raise ArgumentError, "scroll offset must be finite" unless value.is_a?(Numeric) && value.finite?
26
- @scroll_y = value.to_f.clamp(0, [total_height - @viewport, 0].max)
29
+ @scroll_state.scroll_to(value)
30
+ value
27
31
  end
28
32
 
29
33
  def update_height(index, height)
30
- anchor = @heights.index_at(@scroll_y)
34
+ anchor = @heights.index_at(scroll_y)
31
35
  delta = @heights.update(index, height)
32
- self.scroll_y = @scroll_y + (index < anchor ? delta : 0)
36
+ @scroll_state.update(content_size: Size.new(@scroll_state.content_size.width, total_height), viewport_size: @scroll_state.viewport_size)
37
+ @scroll_state.preserve_anchor(delta) if index < anchor
33
38
  self
34
39
  end
35
40
 
@@ -40,7 +45,7 @@ module Zaniah
40
45
  when :center then top - (@viewport - height) / 2
41
46
  when :end then top + height - @viewport
42
47
  when :nearest
43
- top < @scroll_y ? top : (top + height > @scroll_y + @viewport ? top + height - @viewport : @scroll_y)
48
+ top < scroll_y ? top : (top + height > scroll_y + @viewport ? top + height - @viewport : scroll_y)
44
49
  else raise ArgumentError, "unknown alignment #{align.inspect}"
45
50
  end
46
51
  self.scroll_y = offset
@@ -49,11 +54,16 @@ module Zaniah
49
54
 
50
55
  def request_layout(cx)
51
56
  @state = cx.state(@key, &@state_initializer) if @key && @state_initializer
57
+ if cx.respond_to?(:animator)
58
+ @scroll_state.animation(animator: cx.animator, key: [:scroll, object_id],
59
+ duration: cx.theme.motion.reduced? ? 0 : cx.theme.motion.duration_slow)
60
+ end
61
+ @scroll_state.sample_glide
52
62
  @viewport = dimension(:height, cx.window.content_size.height)
53
63
  width = dimension(:width, cx.window.content_size.width)
54
- self.scroll_y = @scroll_y
55
- anchor = @heights.index_at(@scroll_y)
56
- within = @scroll_y - @heights.prefix(anchor)
64
+ @scroll_state.update(content_size: Size.new(width, total_height), viewport_size: Size.new(width, @viewport))
65
+ anchor = @heights.index_at(scroll_y)
66
+ within = scroll_y - @heights.prefix(anchor)
57
67
  measured, engine = {}, Layout::Engine.new
58
68
  loop do
59
69
  @visible_range = viewport_range
@@ -61,19 +71,21 @@ module Zaniah
61
71
  break if missing.empty?
62
72
  missing.each do |index|
63
73
  element = @render_item.call(index)
64
- raise TypeError, "row renderer must return an Element" unless element.is_a?(Element)
74
+ raise TypeError, "row renderer must return a renderable element" unless element.respond_to?(:request_layout) && element.respond_to?(:prepaint) && element.respond_to?(:paint)
65
75
  node = element.request_layout(cx)
66
76
  _natural_width, height = engine.measure(node, width: width, height: @viewport)
67
77
  @heights.update(index, [height, 1.0].max)
68
78
  measured[index] = [element, node]
69
79
  end
80
+ @scroll_state.update(content_size: Size.new(width, total_height), viewport_size: Size.new(width, @viewport))
70
81
  self.scroll_y = @heights.prefix(anchor) + [within, anchor < @heights.count ? @heights[anchor] : 0].min
71
82
  end
72
83
  @children = @visible_range.map { |index| measured.fetch(index).first }
84
+ @children.each { |child| child.send(:parent=, self) }
73
85
  nodes = @visible_range.map do |index|
74
86
  node = measured.fetch(index).last
75
87
  node.style = node.style.merge(position: :absolute, left: 0, right: 0,
76
- top: @heights.prefix(index) - @scroll_y, height: @heights[index])
88
+ top: @heights.prefix(index) - scroll_y, height: @heights[index])
77
89
  node
78
90
  end
79
91
  @layout_node = Layout::Node.new(style: @style, children: nodes,
@@ -90,8 +102,8 @@ module Zaniah
90
102
 
91
103
  def viewport_range
92
104
  return 0...0 if @heights.count.zero? || @viewport.zero?
93
- first = [@heights.index_at(@scroll_y) - @overscan, 0].max
94
- last = [@heights.index_at(@scroll_y + @viewport) + 1 + @overscan, @heights.count].min
105
+ first = [@heights.index_at(scroll_y) - @overscan, 0].max
106
+ last = [@heights.index_at(scroll_y + @viewport) + 1 + @overscan, @heights.count].min
95
107
  first...last
96
108
  end
97
109
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Platform
5
+ module Appearance
6
+ def appearance = :dark
7
+ def reduced_motion? = false
8
+ def on_appearance(&block) = @on_appearance = block
9
+ end
10
+ end
11
+ end
@@ -6,20 +6,24 @@ module Zaniah
6
6
  Popup = Data.define(:labels, :enabled, :selected_index, :bounds)
7
7
 
8
8
  class Window
9
+ include Appearance
9
10
  DEFAULT_CLEAR = "#181b20"
10
11
 
11
- attr_reader :content_size, :scene, :device, :dispatcher, :scale_factor, :text_runs
12
- attr_accessor :text_system, :ime_state, :title
12
+ attr_reader :content_size, :scene, :device, :dispatcher, :scale_factor, :text_runs, :pointer_position, :cursor_style, :animator, :clock, :accessibility_tree, :accessibility_revision, :frame_stats
13
+ attr_accessor :text_system, :ime_state, :title, :app, :devtools
13
14
 
14
15
  def initialize(width: 800, height: 600, title: "Zaniah UI", scale_factor: 1,
15
16
  keymap: nil, clock: MONOTONIC_CLOCK)
16
17
  @content_size, @scale_factor, @title = Size.new(width, height), scale_factor, title
17
18
  @scene = Scene.new
18
19
  @device = GPU::Software.new(width, height)
19
- @dispatcher = Input::Dispatcher.new(keymap: keymap || Input::Keymap.new(clock: clock))
20
+ @dispatcher = Input::Dispatcher.new(keymap: keymap || Input::Keymap.default_ui(clock: clock))
20
21
  @clock = clock
22
+ @animator = Animator.new(clock: clock)
23
+ @accessibility_tree, @accessibility_revision = Accessibility::Tree.new, 0
24
+ @frame_stats = {fps: 0.0, frame_ms: 0.0, layout_ms: 0.0, prepaint_ms: 0.0, paint_ms: 0.0, command_count: 0}.freeze
21
25
  @state, @used_state, @text_runs = {}, {}, []
22
- @dirty, @closed = true, false
26
+ @dirty, @closed, @pointer_down, @cursor_style = true, false, false, :arrow
23
27
  end
24
28
 
25
29
  def on_input(&block) = @on_input = block
@@ -31,10 +35,25 @@ module Zaniah
31
35
  def draw(&block) = @draw = block
32
36
  def request_frame = @dirty = true
33
37
  def dirty? = @dirty
38
+ def animation_active? = !!@animator&.active?
34
39
  def closed? = @closed
40
+ def pointer_down? = @pointer_down
41
+ def set_cursor(style)
42
+ raise ArgumentError, "unknown cursor #{style}" unless %i[arrow text pointer crosshair resize_horizontal resize_vertical].include?(style)
43
+ return style if @cursor_style == style
44
+ self.cursor_style = style
45
+ @cursor_style = style
46
+ end
47
+ def cursor_style=(style)
48
+ @cursor_style = style
49
+ end
35
50
  def displays = [Display.new(0, "Headless", Bounds.new(0, 0, @content_size.width, @content_size.height), @scale_factor, true)]
36
51
 
37
52
  def popup
53
+ if @popup_component.respond_to?(:popup_data)
54
+ data = @popup_component.popup_data
55
+ return Popup.new(labels: data[:labels], enabled: data[:enabled], selected_index: data[:selected_index], bounds: data[:bounds])
56
+ end
38
57
  return unless @menu
39
58
 
40
59
  Popup.new(labels: @menu[:items].map { |label, _| label.dup.freeze }.freeze,
@@ -51,13 +70,19 @@ module Zaniah
51
70
 
52
71
  def input(event)
53
72
  return if popup_input(event)
73
+ @pointer_position = event.position if event.respond_to?(:position)
74
+ @pointer_down = true if event.is_a?(Input::MouseDown)
75
+ @pointer_down = false if event.is_a?(Input::MouseUp)
54
76
  @tooltip_offered = false if event.is_a?(Input::MouseMove)
55
77
  @tooltip = nil if event.is_a?(Input::MouseDown) || event.is_a?(Input::KeyDown)
56
78
  @on_input&.call(event)
79
+ @devtools&.handle_input(event)
57
80
  if event.respond_to?(:position)
58
81
  @dispatcher.mouse(event)
59
82
  elsif event.is_a?(Input::KeyDown)
60
83
  @dispatcher.key(event.keystroke)
84
+ else
85
+ @dispatcher.input(event)
61
86
  end
62
87
  @tooltip = nil if event.is_a?(Input::MouseMove) && !@tooltip_offered
63
88
  request_frame
@@ -71,6 +96,13 @@ module Zaniah
71
96
 
72
97
  def context_menu(items, position: Point.new(0, 0))
73
98
  raise ArgumentError, "menu items must be label/callback pairs" unless items.is_a?(Array) && items.all? { |item| item.is_a?(Array) && item.length == 2 && item.first.is_a?(String) && (item.last.nil? || item.last.respond_to?(:call)) }
99
+ if defined?(Zaniah::UI::ContextMenu)
100
+ @popup_component = Zaniah::UI::ContextMenu.new(items, anchor: position)
101
+ @menu = nil
102
+ @tooltip = nil
103
+ request_frame
104
+ return @popup_component
105
+ end
74
106
  @menu = {items: items.map(&:dup), position: position, index: items.index { |_, callback| callback } || 0}
75
107
  @tooltip = nil
76
108
  request_frame
@@ -91,14 +123,20 @@ module Zaniah
91
123
  @tooltip[:shown] = true
92
124
  request_frame
93
125
  end
126
+ request_frame if @animator&.active?
94
127
  return unless @dirty && !@closed
95
128
  @dirty = false
96
129
  result = @draw&.call(self)
97
- render(result) if result.is_a?(Element)
98
- @device.render(result) if result.is_a?(Scene)
130
+ if result.respond_to?(:request_layout) && result.respond_to?(:prepaint) && result.respond_to?(:paint)
131
+ render(result)
132
+ elsif result.is_a?(Scene)
133
+ @device.render(result)
134
+ end
135
+ request_frame if @animator&.active?
99
136
  end
100
137
 
101
138
  def render(element, clear: DEFAULT_CLEAR, present: true)
139
+ frame_started = MONOTONIC_CLOCK.call
102
140
  @text_system.scale_factor = @scale_factor if @text_system.respond_to?(:scale_factor=)
103
141
  @scene.clear
104
142
  @text_runs.clear
@@ -106,12 +144,31 @@ module Zaniah
106
144
  @used_state.clear
107
145
  @dispatcher.clear_hits
108
146
  cx = FrameContext.new(self)
147
+ @animator.reduced_motion = cx.theme.motion.reduced?
148
+ layout_started = MONOTONIC_CLOCK.call
109
149
  root = element.request_layout(cx)
110
150
  Layout::Engine.new.compute(root, width: @content_size.width, height: @content_size.height)
151
+ layout_finished = MONOTONIC_CLOCK.call
152
+ @animator.sample(@clock.call)
111
153
  element.prepaint(root.bounds, nil, cx)
154
+ cx.interactivity.resolve(@dispatcher, @pointer_position, @pointer_down)
155
+ prepaint_finished = MONOTONIC_CLOCK.call
156
+ set_cursor(:arrow)
112
157
  element.paint(root.bounds, nil, nil, cx)
113
158
  paint_popups
159
+ paint_finished = MONOTONIC_CLOCK.call
160
+ overlays = [@popup_component].compact
161
+ accessibility_changed(@accessibility_tree.root, @accessibility_tree.changes) if @accessibility_tree.update(element, cx, overlays: overlays)
114
162
  @state.delete_if { |key, _| !@used_state[key] }
163
+ elapsed = frame_started - (@last_frame_at || frame_started)
164
+ fps = elapsed.positive? ? 1.0 / elapsed : 0.0
165
+ @smoothed_fps = @smoothed_fps ? @smoothed_fps * 0.9 + fps * 0.1 : fps
166
+ @last_frame_at = frame_started
167
+ @frame_stats = {fps: @smoothed_fps, frame_ms: (paint_finished - frame_started) * 1000,
168
+ layout_ms: (layout_finished - layout_started) * 1000,
169
+ prepaint_ms: (prepaint_finished - layout_finished) * 1000,
170
+ paint_ms: (paint_finished - prepaint_finished) * 1000,
171
+ command_count: @scene.commands.length / 4}.freeze
115
172
  @on_frame&.call(element, clear)
116
173
  @device.render(@scene, clear: clear) if present
117
174
  @text_system.end_frame if @text_system.respond_to?(:end_frame)
@@ -123,11 +180,34 @@ module Zaniah
123
180
  @state[key] = yield
124
181
  end
125
182
 
183
+ def accessibility_changed(root, changes)
184
+ @accessibility_revision += 1
185
+ Accessibility.publish(self, root, changes)
186
+ end
187
+
126
188
  def write_png(path) = @device.write_png(path)
127
189
 
128
190
  private
129
191
 
130
192
  def popup_input(event)
193
+ if @popup_component
194
+ unless @popup_component.open?
195
+ @popup_component = nil
196
+ return false
197
+ end
198
+ @pointer_position = event.position if event.respond_to?(:position)
199
+ @pointer_down = true if event.is_a?(Input::MouseDown)
200
+ @pointer_down = false if event.is_a?(Input::MouseUp)
201
+ if event.respond_to?(:position)
202
+ @dispatcher.mouse(event)
203
+ elsif event.is_a?(Input::KeyDown)
204
+ @dispatcher.key(event.keystroke)
205
+ else
206
+ @dispatcher.input(event)
207
+ end
208
+ request_frame
209
+ return true
210
+ end
131
211
  return false unless @menu
132
212
  if event.is_a?(Input::KeyDown)
133
213
  key = Input::Keystroke.normalize(event.keystroke)
@@ -174,9 +254,11 @@ module Zaniah
174
254
  end
175
255
 
176
256
  def paint_popups
177
- if @menu
257
+ if @popup_component
258
+ paint_popup_component(@popup_component)
259
+ elsif @menu
178
260
  box = popup_bounds(@menu)
179
- @scene.layer(1_000_000) do
261
+ @scene.layer(Scene::LAYER_POPUP) do
180
262
  @scene.quad(box.x, box.y, box.width, box.height, color: "#202936", radius: 4, border_width: 1, border_color: "#526176")
181
263
  @scene.clip(box) do
182
264
  @menu[:items].each_with_index do |(label, callback), index|
@@ -186,17 +268,27 @@ module Zaniah
186
268
  end
187
269
  end
188
270
  end
271
+ elsif @tooltip && @tooltip[:shown] && defined?(Zaniah::UI::Tooltip)
272
+ paint_popup_component(Zaniah::UI::Tooltip.new(@tooltip[:text], anchor: @tooltip[:position]))
189
273
  elsif @tooltip && @tooltip[:shown]
190
274
  text = @tooltip[:text]
191
275
  position = @tooltip[:position]
192
276
  width = [text.length * 8 + 20, @content_size.width].min
193
277
  x, y = position.x.clamp(0, @content_size.width - width), (position.y + 20).clamp(0, [@content_size.height - 30, 0].max)
194
- @scene.layer(1_000_000) do
278
+ @scene.layer(Scene::LAYER_TOOLTIP) do
195
279
  @scene.quad(x, y, width, 28, color: "#202936", radius: 4, border_width: 1, border_color: "#526176")
196
280
  @scene.clip(Bounds.new(x, y, width, 28)) { popup_text(text, x + 10, y + 5, "#edf2f7") }
197
281
  end
198
282
  end
199
283
  end
284
+
285
+ def paint_popup_component(component)
286
+ cx = FrameContext.new(self)
287
+ root = component.request_layout(cx)
288
+ Layout::Engine.new.compute(root, width: @content_size.width, height: @content_size.height)
289
+ component.prepaint(root.bounds, nil, cx)
290
+ component.paint(root.bounds, nil, nil, cx)
291
+ end
200
292
  end
201
293
  end
202
294
  end
@@ -17,6 +17,14 @@ module Zaniah
17
17
  ENV.fetch("GTK_THEME", "").downcase.include?("dark") ? :dark : :light
18
18
  end
19
19
 
20
+ def reduced_motion?
21
+ return @reduced_motion if instance_variable_defined?(:@reduced_motion)
22
+ output, status = Open3.capture2e("gsettings", "get", "org.gnome.desktop.interface", "enable-animations")
23
+ @reduced_motion = status.success? && output.strip == "false"
24
+ rescue Errno::ENOENT
25
+ @reduced_motion = false
26
+ end
27
+
20
28
  def on_appearance(&block)
21
29
  @on_appearance = block
22
30
  close_appearance
@@ -130,6 +130,7 @@ module Zaniah
130
130
  return if closed?
131
131
  @connection.poll
132
132
  poll_appearance
133
+ Accessibility.poll(self)
133
134
  return close if @close_requested
134
135
  finish_drop if @pending_drop
135
136
  if @repeat_key && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @repeat_at
@@ -140,7 +141,7 @@ module Zaniah
140
141
  end
141
142
  def run
142
143
  until closed?
143
- @connection.poll(timeout: dirty? ? 0 : 0.05)
144
+ @connection.poll(timeout: dirty? || animation_active? ? 0 : 0.05)
144
145
  tick
145
146
  end
146
147
  end
@@ -345,6 +346,7 @@ module Zaniah
345
346
  end
346
347
  def close
347
348
  return false unless super
349
+ Accessibility.close(self)
348
350
  close_appearance
349
351
  cleanup_native
350
352
  true
@@ -108,6 +108,7 @@ module Zaniah
108
108
  def tick
109
109
  poll_events unless closed?
110
110
  poll_appearance unless closed?
111
+ Accessibility.poll(self) unless closed?
111
112
  raise @native_error if @native_error
112
113
  super
113
114
  end
@@ -116,7 +117,7 @@ module Zaniah
116
117
  io = IO.for_fd(fd, autoclose: false)
117
118
  until closed?
118
119
  tick
119
- IO.select([io], nil, nil, 0.05) unless dirty? || closed?
120
+ IO.select([io], nil, nil, 0.05) unless dirty? || animation_active? || closed?
120
121
  end
121
122
  end
122
123
  def poll_events
@@ -285,7 +286,7 @@ module Zaniah
285
286
  next if line.start_with?("#")
286
287
  uri = URI.parse(line.strip) rescue nil
287
288
  next unless uri&.scheme == "file" && [nil, "", "localhost"].include?(uri.host)
288
- path = URI::DEFAULT_PARSER.unescape(uri.path).force_encoding("UTF-8")
289
+ path = URI::RFC2396_PARSER.unescape(uri.path).force_encoding("UTF-8")
289
290
  path if path.start_with?("/") && !path.include?("\0") && path.valid_encoding?
290
291
  end
291
292
  end
@@ -402,6 +403,7 @@ module Zaniah
402
403
  end
403
404
  def close
404
405
  return false unless super
406
+ Accessibility.close(self)
405
407
  close_appearance
406
408
  x(:XDestroyIC, [P], V, @ic) if @ic
407
409
  x(:XCloseIM, [P], I, @im) if @im && !@im.null?
@@ -57,7 +57,7 @@ module Zaniah
57
57
  def run
58
58
  yield self if block_given?
59
59
  until WINDOWS.values.uniq.all?(&:closed?)
60
- poll(wait: WINDOWS.values.any?(&:dirty?) ? 0 : 0.05)
60
+ poll(wait: WINDOWS.values.any? { |window| window.dirty? || window.animation_active? } ? 0 : 0.05)
61
61
  WINDOWS.values.uniq.each { |window| window.tick(poll_events: false) }
62
62
  end
63
63
  end
@@ -7,6 +7,9 @@ module Zaniah
7
7
  module Mac
8
8
  class CoreText
9
9
  P, L, D, V = Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG, Fiddle::TYPE_DOUBLE, Fiddle::TYPE_VOID
10
+ # Initializing CoreText first can leave AppKit's later NSFont cache invalid.
11
+ APPKIT = FFI::Library.new("/System/Library/Frameworks/AppKit.framework/AppKit")
12
+
10
13
  def initialize
11
14
  require "alhena"
12
15
  @cf = FFI::Library.new("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")
@@ -124,12 +124,13 @@ module Zaniah
124
124
  end
125
125
  def run
126
126
  until closed?
127
- App.instance.poll(wait: dirty? ? 0 : 0.05)
127
+ App.instance.poll(wait: dirty? || animation_active? ? 0 : 0.05)
128
128
  tick(poll_events: false)
129
129
  end
130
130
  end
131
131
  def close
132
132
  return false unless super
133
+ Accessibility.close(self)
133
134
  WINDOWS.delete_if { |_, window| window.equal?(self) }
134
135
  O.send(@handle, "setDelegate:", 0, args: [:pointer], result: :void)
135
136
  O.send(@handle, "close", result: :void)
@@ -244,6 +245,12 @@ module Zaniah
244
245
  name = O.text(O.send(O.send(@view, "effectiveAppearance"), "name"))
245
246
  name.include?("Dark") ? :dark : :light
246
247
  end
248
+ def reduced_motion?
249
+ workspace = O.send(O.klass("NSWorkspace"), "sharedWorkspace")
250
+ selector = O.selector("accessibilityDisplayShouldReduceMotion")
251
+ return false if O.send(workspace, "respondsToSelector:", selector, args: [:pointer], result: :bool).zero?
252
+ O.send(workspace, "accessibilityDisplayShouldReduceMotion", result: :bool) != 0
253
+ end
247
254
  def on_appearance(&block) = @on_appearance = block
248
255
  def appearance_changed
249
256
  @on_appearance&.call(appearance)
@@ -254,6 +261,7 @@ module Zaniah
254
261
  end
255
262
  def context_action(index) = @context_actions&.fetch(index)&.call
256
263
  def context_menu(items, position: Point.new(0, 0))
264
+ return super if defined?(Zaniah::UI::ContextMenu)
257
265
  menu = O.new("NSMenu")
258
266
  O.send(menu, "setAutoenablesItems:", 0, args: [:bool], result: :void)
259
267
  @context_actions = []
@@ -23,7 +23,7 @@ module Zaniah
23
23
  @input.raw do
24
24
  until closed?
25
25
  tick
26
- if IO.select([@input], nil, nil, 0.05)
26
+ if IO.select([@input], nil, nil, animation_active? ? 0 : 0.05)
27
27
  data = @input.readpartial(4096)
28
28
  feed_input(data)
29
29
  else
@@ -9,7 +9,7 @@ module Zaniah
9
9
  module Windows
10
10
  def self.displays
11
11
  user, scale = FFI::Library.new("user32.dll"), FFI::Library.new("shcore.dll")
12
- ptype, int, uint = Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_UINT
12
+ ptype, int = Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT
13
13
  result, error = [], nil
14
14
  callback = Fiddle::Closure::BlockCaller.new(int, [ptype, ptype, ptype, Fiddle::TYPE_INTPTR_T]) do |monitor, _dc, _rect, _data|
15
15
  begin
@@ -128,7 +128,7 @@ module Zaniah
128
128
  def run
129
129
  until closed?
130
130
  tick
131
- @user.fn(:MsgWaitForMultipleObjectsEx, [U, P, U, U, U], U).call(0, 0, 50, 0x04FF, 4) unless dirty? || closed?
131
+ @user.fn(:MsgWaitForMultipleObjectsEx, [U, P, U, U, U], U).call(0, 0, 50, 0x04FF, 4) unless dirty? || animation_active? || closed?
132
132
  end
133
133
  end
134
134
  def native_callback
@@ -146,6 +146,9 @@ module Zaniah
146
146
  def message(message, wparam, lparam)
147
147
  case message
148
148
  when 0x0010 then close; return 0
149
+ when 0x003d
150
+ result = Accessibility::Windows.provider_result(self, wparam, lparam)
151
+ return result if result
149
152
  when 0x0233
150
153
  dropped_files(wparam)
151
154
  return 0
@@ -327,8 +330,14 @@ module Zaniah
327
330
  wide("Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"), wide("AppsUseLightTheme"), 0x10, 0, value, size)
328
331
  result.zero? && value.unpack1("I").zero? ? :dark : :light
329
332
  end
333
+ def reduced_motion?
334
+ enabled = [1].pack("I")
335
+ result = @user.fn(:SystemParametersInfoW, [U, U, P, U], I).call(0x1042, 0, enabled, 0)
336
+ !result.zero? && enabled.unpack1("I").zero?
337
+ end
330
338
  def on_appearance(&block) = @on_appearance = block
331
339
  def context_menu(items, position: nil)
340
+ return super(items, position: position || Point.new(0, 0)) if defined?(Zaniah::UI::ContextMenu)
332
341
  menu = @user.fn(:CreatePopupMenu, [], P).call
333
342
  raise Error, "CreatePopupMenu failed" if menu.null?
334
343
  items.each_with_index do |(label, action), index|
@@ -393,6 +402,7 @@ module Zaniah
393
402
  end
394
403
  def close
395
404
  return false unless super
405
+ Accessibility.close(self)
396
406
  @gl.fn(:wglMakeCurrent, [P, P], I).call(0, 0)
397
407
  @gl.fn(:wglDeleteContext, [P], I).call(@context)
398
408
  @user.fn(:ReleaseDC, [P, P], I).call(@handle, @dc)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "platform/display"
4
+ require_relative "platform/appearance"
4
5
  require_relative "platform/headless/window"
5
6
  require_relative "platform/tui/window"
6
7