zaniah 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +70 -0
  5. data/assets/fonts/Abel-Regular.ttf +0 -0
  6. data/assets/fonts/OFL.txt +93 -0
  7. data/docs/adr/000-template.md +24 -0
  8. data/docs/adr/001-native-gpu-backends.md +36 -0
  9. data/docs/adr/002-objective-c-aggregates.md +34 -0
  10. data/docs/adr/003-fiddle-call-boundary.md +33 -0
  11. data/docs/adr/README.md +10 -0
  12. data/docs/native.md +66 -0
  13. data/docs/process_pool.md +67 -0
  14. data/docs/text.md +90 -0
  15. data/docs/vector_and_list.md +52 -0
  16. data/examples/ffi_bench.rb +16 -0
  17. data/examples/linux_smoke.rb +53 -0
  18. data/examples/mac_event_check.rb +61 -0
  19. data/examples/mac_poll_bench.rb +24 -0
  20. data/examples/metal_state_check.rb +72 -0
  21. data/examples/native_bench.rb +18 -0
  22. data/examples/native_drop.rb +82 -0
  23. data/examples/native_font.rb +15 -0
  24. data/examples/native_ime.rb +50 -0
  25. data/examples/native_smoke.rb +75 -0
  26. data/examples/native_text_bench.rb +83 -0
  27. data/examples/native_watch.rb +25 -0
  28. data/examples/vulkan_triangle.rb +20 -0
  29. data/examples/word_count_worker.rb +8 -0
  30. data/lib/zaniah/anchored.rb +10 -0
  31. data/lib/zaniah/app.rb +130 -0
  32. data/lib/zaniah/bounds.rb +22 -0
  33. data/lib/zaniah/canvas.rb +15 -0
  34. data/lib/zaniah/color.rb +35 -0
  35. data/lib/zaniah/configuration.rb +5 -0
  36. data/lib/zaniah/corners.rb +7 -0
  37. data/lib/zaniah/data_compat.rb +25 -0
  38. data/lib/zaniah/device_pixels.rb +7 -0
  39. data/lib/zaniah/div.rb +5 -0
  40. data/lib/zaniah/edges.rb +7 -0
  41. data/lib/zaniah/element.rb +106 -0
  42. data/lib/zaniah/entity.rb +9 -0
  43. data/lib/zaniah/entity_context.rb +17 -0
  44. data/lib/zaniah/error.rb +5 -0
  45. data/lib/zaniah/ffi/com.rb +24 -0
  46. data/lib/zaniah/ffi/library.rb +29 -0
  47. data/lib/zaniah/ffi/objc.rb +86 -0
  48. data/lib/zaniah/ffi/struct/signature.rb +46 -0
  49. data/lib/zaniah/ffi/struct.rb +61 -0
  50. data/lib/zaniah/ffi/wayland.rb +140 -0
  51. data/lib/zaniah/frame_context.rb +14 -0
  52. data/lib/zaniah/geometry.rb +16 -0
  53. data/lib/zaniah/gpu/buffer.rb +23 -0
  54. data/lib/zaniah/gpu/frame_encoder.rb +43 -0
  55. data/lib/zaniah/gpu/instance_packing.rb +53 -0
  56. data/lib/zaniah/gpu/metal.rb +209 -0
  57. data/lib/zaniah/gpu/open_gl.rb +210 -0
  58. data/lib/zaniah/gpu/pipeline.rb +7 -0
  59. data/lib/zaniah/gpu/software.rb +126 -0
  60. data/lib/zaniah/gpu/texture.rb +33 -0
  61. data/lib/zaniah/gpu/vulkan.rb +233 -0
  62. data/lib/zaniah/gpu.rb +18 -0
  63. data/lib/zaniah/hsla.rb +14 -0
  64. data/lib/zaniah/image.rb +19 -0
  65. data/lib/zaniah/input/action_registry.rb +12 -0
  66. data/lib/zaniah/input/composition.rb +7 -0
  67. data/lib/zaniah/input/context_predicate.rb +87 -0
  68. data/lib/zaniah/input/dispatcher.rb +59 -0
  69. data/lib/zaniah/input/file_drop.rb +7 -0
  70. data/lib/zaniah/input/focus_handle.rb +24 -0
  71. data/lib/zaniah/input/key_down.rb +7 -0
  72. data/lib/zaniah/input/key_up.rb +7 -0
  73. data/lib/zaniah/input/keymap/binding.rb +9 -0
  74. data/lib/zaniah/input/keymap.rb +55 -0
  75. data/lib/zaniah/input/keystroke.rb +20 -0
  76. data/lib/zaniah/input/mouse_down.rb +7 -0
  77. data/lib/zaniah/input/mouse_move.rb +7 -0
  78. data/lib/zaniah/input/mouse_up.rb +7 -0
  79. data/lib/zaniah/input/scroll_wheel.rb +7 -0
  80. data/lib/zaniah/input/text_input.rb +7 -0
  81. data/lib/zaniah/input.rb +17 -0
  82. data/lib/zaniah/layout/available_space.rb +11 -0
  83. data/lib/zaniah/layout/engine.rb +214 -0
  84. data/lib/zaniah/layout/node.rb +36 -0
  85. data/lib/zaniah/layout/style.rb +19 -0
  86. data/lib/zaniah/layout/tree.rb +16 -0
  87. data/lib/zaniah/layout.rb +11 -0
  88. data/lib/zaniah/length.rb +14 -0
  89. data/lib/zaniah/length_units.rb +9 -0
  90. data/lib/zaniah/list/height_index.rb +68 -0
  91. data/lib/zaniah/list.rb +101 -0
  92. data/lib/zaniah/overlay.rb +10 -0
  93. data/lib/zaniah/platform/display.rb +7 -0
  94. data/lib/zaniah/platform/file_event.rb +7 -0
  95. data/lib/zaniah/platform/headless/window.rb +185 -0
  96. data/lib/zaniah/platform/linux/appearance_aware.rb +60 -0
  97. data/lib/zaniah/platform/linux/displays.rb +55 -0
  98. data/lib/zaniah/platform/linux/free_type.rb +80 -0
  99. data/lib/zaniah/platform/linux/watcher.rb +70 -0
  100. data/lib/zaniah/platform/linux/wayland_display_discovery.rb +60 -0
  101. data/lib/zaniah/platform/linux/wayland_window.rb +369 -0
  102. data/lib/zaniah/platform/linux/window.rb +418 -0
  103. data/lib/zaniah/platform/linux.rb +3 -0
  104. data/lib/zaniah/platform/mac/app.rb +67 -0
  105. data/lib/zaniah/platform/mac/core_text.rb +94 -0
  106. data/lib/zaniah/platform/mac/displays.rb +23 -0
  107. data/lib/zaniah/platform/mac/watcher.rb +63 -0
  108. data/lib/zaniah/platform/mac/window.rb +298 -0
  109. data/lib/zaniah/platform/mac.rb +19 -0
  110. data/lib/zaniah/platform/tui/input_decoder.rb +145 -0
  111. data/lib/zaniah/platform/tui/text_renderer.rb +40 -0
  112. data/lib/zaniah/platform/tui/window.rb +91 -0
  113. data/lib/zaniah/platform/windows/terminal.rb +127 -0
  114. data/lib/zaniah/platform/windows/watcher.rb +91 -0
  115. data/lib/zaniah/platform/windows/window.rb +407 -0
  116. data/lib/zaniah/platform/windows.rb +3 -0
  117. data/lib/zaniah/platform.rb +34 -0
  118. data/lib/zaniah/png.rb +118 -0
  119. data/lib/zaniah/point.rb +8 -0
  120. data/lib/zaniah/process_pool/worker.rb +7 -0
  121. data/lib/zaniah/process_pool.rb +239 -0
  122. data/lib/zaniah/process_wire.rb +127 -0
  123. data/lib/zaniah/quad_packer.rb +15 -0
  124. data/lib/zaniah/scene/sprite_batch.rb +7 -0
  125. data/lib/zaniah/scene.rb +167 -0
  126. data/lib/zaniah/scene_renderer.rb +8 -0
  127. data/lib/zaniah/size.rb +5 -0
  128. data/lib/zaniah/subscription.rb +14 -0
  129. data/lib/zaniah/svg/path.rb +134 -0
  130. data/lib/zaniah/svg/rasterizable.rb +186 -0
  131. data/lib/zaniah/svg.rb +295 -0
  132. data/lib/zaniah/task/cancelled.rb +7 -0
  133. data/lib/zaniah/task/timeout.rb +7 -0
  134. data/lib/zaniah/task.rb +51 -0
  135. data/lib/zaniah/task_executor.rb +105 -0
  136. data/lib/zaniah/text.rb +24 -0
  137. data/lib/zaniah/text_system/atlas/entry.rb +9 -0
  138. data/lib/zaniah/text_system/atlas.rb +102 -0
  139. data/lib/zaniah/text_system/atlas_cache.rb +146 -0
  140. data/lib/zaniah/text_system/font_db/face.rb +9 -0
  141. data/lib/zaniah/text_system/font_db.rb +193 -0
  142. data/lib/zaniah/text_system/glyph.rb +7 -0
  143. data/lib/zaniah/text_system/line_layout.rb +20 -0
  144. data/lib/zaniah/text_system/renderer.rb +196 -0
  145. data/lib/zaniah/text_system/shaper.rb +430 -0
  146. data/lib/zaniah/text_system/typesetter/copy_error.rb +9 -0
  147. data/lib/zaniah/text_system/typesetter.rb +168 -0
  148. data/lib/zaniah/text_system.rb +9 -0
  149. data/lib/zaniah/unicode.rb +13 -0
  150. data/lib/zaniah/uniform_list.rb +30 -0
  151. data/lib/zaniah/version.rb +5 -0
  152. data/lib/zaniah/view.rb +13 -0
  153. data/lib/zaniah.rb +29 -0
  154. data/sig/app.rbs +80 -0
  155. data/sig/elements.rbs +129 -0
  156. data/sig/gpu.rbs +108 -0
  157. data/sig/input.rbs +114 -0
  158. data/sig/layout.rbs +53 -0
  159. data/sig/native.rbs +88 -0
  160. data/sig/platform.rbs +94 -0
  161. data/sig/scene.rbs +52 -0
  162. data/sig/shaper.rbs +8 -0
  163. data/sig/text.rbs +137 -0
  164. data/sig/zaniah.rbs +120 -0
  165. data/tools/check_dependencies.rb +52 -0
  166. metadata +249 -0
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Layout
5
+ class Tree
6
+ attr_reader :nodes
7
+
8
+ def initialize = @nodes = []
9
+ def new_node(**options) = @nodes.push(Node.new(**options)).length - 1
10
+ def add_child(parent, child) = @nodes.fetch(parent).add(@nodes.fetch(child))
11
+ def set_style(id, **style) = @nodes.fetch(id).style = @nodes.fetch(id).style.merge(**style)
12
+ def compute(id, width:, height:) = Engine.new.compute(@nodes.fetch(id), width: width, height: height)
13
+ def bounds(id) = @nodes.fetch(id).bounds
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "layout/available_space"
4
+ require_relative "layout/style"
5
+ require_relative "layout/node"
6
+ require_relative "layout/engine"
7
+ require_relative "layout/tree"
8
+
9
+ module Zaniah
10
+ LayoutTree = Layout::Tree
11
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ Length = Data.define(:value, :unit) do
5
+ def resolve(available, rem: 16)
6
+ case unit
7
+ when :px then value
8
+ when :percent then available * value / 100.0
9
+ when :rem then rem * value
10
+ else raise ArgumentError, "unknown length unit #{unit}"
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module LengthUnits
5
+ def px(value) = Length.new(value, :px)
6
+ def rems(value) = Length.new(value, :rem)
7
+ def percent(value) = Length.new(value, :percent)
8
+ end
9
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ class List
5
+ class HeightIndex
6
+ attr_reader :count
7
+
8
+ def initialize(count, estimate)
9
+ raise ArgumentError, "count must be a nonnegative integer" unless count.is_a?(Integer) && count >= 0
10
+ raise ArgumentError, "height must be finite and positive" unless estimate.is_a?(Numeric) && estimate.finite? && estimate.positive?
11
+ @count, @estimate, @values = count, estimate.to_f, {}
12
+ @tree = Array.new(count + 1) { |index| (index & -index) * @estimate }
13
+ end
14
+
15
+ def [](index)
16
+ validate_index(index)
17
+ @values.fetch(index, @estimate)
18
+ end
19
+
20
+ def update(index, height)
21
+ validate_index(index)
22
+ raise ArgumentError, "height must be finite and positive" unless height.is_a?(Numeric) && height.finite? && height.positive?
23
+ delta = height.to_f - self[index]
24
+ height == @estimate ? @values.delete(index) : @values[index] = height.to_f
25
+ cursor = index + 1
26
+ while cursor <= @count
27
+ @tree[cursor] += delta
28
+ cursor += cursor & -cursor
29
+ end
30
+ delta
31
+ end
32
+
33
+ def prefix(index)
34
+ raise IndexError, "row boundary outside list" unless index.is_a?(Integer) && index.between?(0, @count)
35
+ total = 0.0
36
+ while index.positive?
37
+ total += @tree[index]
38
+ index -= index & -index
39
+ end
40
+ total
41
+ end
42
+
43
+ def total = prefix(@count)
44
+
45
+ # Index containing y; count is the sentinel at/beyond the bottom edge.
46
+ def index_at(y)
47
+ raise ArgumentError, "row offset must be finite" unless y.is_a?(Numeric) && y.finite?
48
+ return 0 if y <= 0
49
+ index, accumulated = 0, 0.0
50
+ bit = 1 << @count.bit_length
51
+ while bit.positive?
52
+ candidate = index + bit
53
+ if candidate <= @count && accumulated + @tree[candidate] <= y
54
+ index, accumulated = candidate, accumulated + @tree[candidate]
55
+ end
56
+ bit >>= 1
57
+ end
58
+ index
59
+ end
60
+
61
+ private
62
+
63
+ def validate_index(index)
64
+ raise IndexError, "row outside list" unless index.is_a?(Integer) && index.between?(0, @count - 1)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ # A variable-height, viewport-only list. Unknown rows use estimated_height;
5
+ # measuring visible rows refines the prefix sums without moving the anchor.
6
+ class List < Element
7
+ attr_reader :scroll_y, :visible_range, :heights
8
+
9
+ def initialize(count:, estimated_height: 24, overscan: 2, &render_item)
10
+ super()
11
+ raise ArgumentError, "a row renderer is required" unless render_item
12
+ raise ArgumentError, "overscan must be a nonnegative integer" unless overscan.is_a?(Integer) && overscan >= 0
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
15
+ style(overflow: :hidden)
16
+ on_scroll_wheel do |event, cx|
17
+ self.scroll_y = @scroll_y + event.delta.y
18
+ cx.window.request_frame
19
+ end
20
+ end
21
+
22
+ def total_height = @heights.total
23
+
24
+ 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)
27
+ end
28
+
29
+ def update_height(index, height)
30
+ anchor = @heights.index_at(@scroll_y)
31
+ delta = @heights.update(index, height)
32
+ self.scroll_y = @scroll_y + (index < anchor ? delta : 0)
33
+ self
34
+ end
35
+
36
+ def scroll_to(index, align: :start)
37
+ height, top = @heights[index], @heights.prefix(index)
38
+ offset = case align
39
+ when :start then top
40
+ when :center then top - (@viewport - height) / 2
41
+ when :end then top + height - @viewport
42
+ when :nearest
43
+ top < @scroll_y ? top : (top + height > @scroll_y + @viewport ? top + height - @viewport : @scroll_y)
44
+ else raise ArgumentError, "unknown alignment #{align.inspect}"
45
+ end
46
+ self.scroll_y = offset
47
+ self
48
+ end
49
+
50
+ def request_layout(cx)
51
+ @state = cx.state(@key, &@state_initializer) if @key && @state_initializer
52
+ @viewport = dimension(:height, cx.window.content_size.height)
53
+ 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)
57
+ measured, engine = {}, Layout::Engine.new
58
+ loop do
59
+ @visible_range = viewport_range
60
+ missing = @visible_range.reject { |index| measured.key?(index) }
61
+ break if missing.empty?
62
+ missing.each do |index|
63
+ element = @render_item.call(index)
64
+ raise TypeError, "row renderer must return an Element" unless element.is_a?(Element)
65
+ node = element.request_layout(cx)
66
+ _natural_width, height = engine.measure(node, width: width, height: @viewport)
67
+ @heights.update(index, [height, 1.0].max)
68
+ measured[index] = [element, node]
69
+ end
70
+ self.scroll_y = @heights.prefix(anchor) + [within, anchor < @heights.count ? @heights[anchor] : 0].min
71
+ end
72
+ @children = @visible_range.map { |index| measured.fetch(index).first }
73
+ nodes = @visible_range.map do |index|
74
+ node = measured.fetch(index).last
75
+ node.style = node.style.merge(position: :absolute, left: 0, right: 0,
76
+ top: @heights.prefix(index) - @scroll_y, height: @heights[index])
77
+ node
78
+ end
79
+ @layout_node = Layout::Node.new(style: @style, children: nodes,
80
+ measure: ->(_width, _height) { [width, @viewport] })
81
+ end
82
+
83
+ private
84
+
85
+ def dimension(property, available)
86
+ value = @style[property]
87
+ value = value.resolve(available) if value.is_a?(Length)
88
+ value.is_a?(Numeric) ? [value, 0].max : available
89
+ end
90
+
91
+ def viewport_range
92
+ 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
95
+ first...last
96
+ end
97
+ end
98
+
99
+ end
100
+
101
+ require_relative "list/height_index"
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ class Overlay < Div
5
+ def initialize
6
+ super
7
+ style(position: :absolute, left: 0, right: 0, top: 0, bottom: 0)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Platform
5
+ Display = Data.define(:id, :name, :bounds, :scale_factor, :primary)
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Platform
5
+ FileEvent = Data.define(:kind, :path, :old_path) unless const_defined?(:FileEvent)
6
+ end
7
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Platform
5
+ module Headless
6
+ class Window
7
+ attr_reader :content_size, :scene, :device, :dispatcher, :scale_factor, :text_runs
8
+ attr_accessor :text_system, :ime_state, :title
9
+
10
+ def initialize(width: 800, height: 600, title: "Zaniah UI", scale_factor: 1)
11
+ @content_size, @scale_factor, @title = Size.new(width, height), scale_factor, title
12
+ @scene, @device, @dispatcher = Scene.new, GPU::Software.new(width, height), Input::Dispatcher.new
13
+ @state, @used_state, @text_runs = {}, {}, []
14
+ @dirty, @closed = true, false
15
+ end
16
+
17
+ def on_input(&block) = @on_input = block
18
+ def on_resize(&block) = @on_resize = block
19
+ def on_close(&block) = @on_close = block
20
+ def on_moved(&block) = @on_moved = block
21
+ def on_tick(&block) = @on_tick = block
22
+ def draw(&block) = @draw = block
23
+ def request_frame = @dirty = true
24
+ def dirty? = @dirty
25
+ def closed? = @closed
26
+ def displays = [Display.new(0, "Headless", Bounds.new(0, 0, @content_size.width, @content_size.height), @scale_factor, true)]
27
+
28
+ def resize(width, height)
29
+ @content_size = Size.new(width, height)
30
+ @device.resize(width, height)
31
+ @on_resize&.call(@content_size)
32
+ request_frame
33
+ end
34
+
35
+ def input(event)
36
+ return if popup_input(event)
37
+ @tooltip_offered = false if event.is_a?(Input::MouseMove)
38
+ @tooltip = nil if event.is_a?(Input::MouseDown) || event.is_a?(Input::KeyDown)
39
+ @on_input&.call(event)
40
+ if event.respond_to?(:position)
41
+ @dispatcher.mouse(event)
42
+ elsif event.is_a?(Input::KeyDown)
43
+ @dispatcher.key(event.keystroke)
44
+ end
45
+ @tooltip = nil if event.is_a?(Input::MouseMove) && !@tooltip_offered
46
+ request_frame
47
+ end
48
+
49
+ def offer_tooltip(text, position:, delay: 0.5)
50
+ @tooltip_offered = true
51
+ return if @tooltip && @tooltip[:text] == text
52
+ @tooltip = {text: text.to_s, position: position, at: Process.clock_gettime(Process::CLOCK_MONOTONIC) + delay, shown: false}
53
+ end
54
+
55
+ def context_menu(items, position: Point.new(0, 0))
56
+ 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)) }
57
+ @menu = {items: items.map(&:dup), position: position, index: items.index { |_, callback| callback } || 0}
58
+ @tooltip = nil
59
+ request_frame
60
+ end
61
+
62
+ def close
63
+ return if @closed
64
+ return false if @on_close && @on_close.call == false
65
+ @closed = true
66
+ @device.release
67
+ @text_system.close if @text_system.respond_to?(:close)
68
+ true
69
+ end
70
+
71
+ def tick
72
+ @on_tick&.call
73
+ if @tooltip && !@tooltip[:shown] && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @tooltip[:at]
74
+ @tooltip[:shown] = true
75
+ request_frame
76
+ end
77
+ return unless @dirty && !@closed
78
+ @dirty = false
79
+ result = @draw&.call(self)
80
+ render(result) if result.is_a?(Element)
81
+ @device.render(result) if result.is_a?(Scene)
82
+ end
83
+
84
+ def render(element, clear: "#181b20", present: true)
85
+ @text_system.scale_factor = @scale_factor if @text_system.respond_to?(:scale_factor=)
86
+ @scene.clear
87
+ @text_runs.clear
88
+ @text_system.start_frame if @text_system.respond_to?(:start_frame)
89
+ @used_state.clear
90
+ @dispatcher.clear_hits
91
+ cx = FrameContext.new(self)
92
+ root = element.request_layout(cx)
93
+ Layout::Engine.new.compute(root, width: @content_size.width, height: @content_size.height)
94
+ element.prepaint(root.bounds, nil, cx)
95
+ element.paint(root.bounds, nil, nil, cx)
96
+ paint_popups
97
+ @state.delete_if { |key, _| !@used_state[key] }
98
+ @device.render(@scene, clear: clear) if present
99
+ @text_system.end_frame if @text_system.respond_to?(:end_frame)
100
+ end
101
+
102
+ def element_state(key)
103
+ @used_state[key] = true
104
+ return @state[key] if @state.key?(key)
105
+ @state[key] = yield
106
+ end
107
+
108
+ def write_png(path) = @device.write_png(path)
109
+
110
+ private
111
+
112
+ def popup_input(event)
113
+ return false unless @menu
114
+ if event.is_a?(Input::KeyDown)
115
+ key = Input::Keystroke.normalize(event.keystroke)
116
+ if key == "esc"
117
+ @menu = nil
118
+ elsif key == "enter"
119
+ callback = @menu[:items][@menu[:index]]&.last
120
+ @menu = nil
121
+ callback&.call
122
+ elsif ["up", "down"].include?(key) && !@menu[:items].empty?
123
+ step = key == "up" ? -1 : 1
124
+ @menu[:items].length.times do
125
+ @menu[:index] = (@menu[:index] + step) % @menu[:items].length
126
+ break if @menu[:items][@menu[:index]].last
127
+ end
128
+ end
129
+ elsif event.is_a?(Input::MouseDown)
130
+ box = popup_bounds(@menu)
131
+ index = ((event.position.y - box.y - 4) / 26).floor
132
+ callback = @menu[:items][index]&.last if box.contains?(event.position) && index >= 0
133
+ @menu = nil
134
+ callback&.call
135
+ elsif event.is_a?(Input::MouseMove)
136
+ box = popup_bounds(@menu)
137
+ index = ((event.position.y - box.y - 4) / 26).floor
138
+ @menu[:index] = index if box.contains?(event.position) && index.between?(0, @menu[:items].length - 1)
139
+ end
140
+ request_frame
141
+ true
142
+ end
143
+
144
+ def popup_bounds(menu)
145
+ width = [menu[:items].map { |label, _| label.length * 8 + 24 }.max || 80, @content_size.width].min
146
+ height = [menu[:items].length * 26 + 8, @content_size.height].min
147
+ Bounds.new(menu[:position].x.clamp(0, @content_size.width - width), menu[:position].y.clamp(0, @content_size.height - height), width, height)
148
+ end
149
+
150
+ def popup_text(text, x, y, color)
151
+ if @text_system
152
+ line = @text_system.layout_line(text, size: 13)
153
+ @text_system.paint_line(@scene, line, x: x, y: y + line.ascent, color: color)
154
+ end
155
+ @text_runs << [x, y, text, color]
156
+ end
157
+
158
+ def paint_popups
159
+ if @menu
160
+ box = popup_bounds(@menu)
161
+ @scene.layer(1_000_000) do
162
+ @scene.quad(box.x, box.y, box.width, box.height, color: "#202936", radius: 4, border_width: 1, border_color: "#526176")
163
+ @scene.clip(box) do
164
+ @menu[:items].each_with_index do |(label, callback), index|
165
+ y = box.y + 4 + index * 26
166
+ @scene.quad(box.x + 3, y, box.width - 6, 26, color: "#345477") if index == @menu[:index]
167
+ popup_text(label, box.x + 12, y + 4, callback ? "#edf2f7" : "#8997aa")
168
+ end
169
+ end
170
+ end
171
+ elsif @tooltip && @tooltip[:shown]
172
+ text = @tooltip[:text]
173
+ position = @tooltip[:position]
174
+ width = [text.length * 8 + 20, @content_size.width].min
175
+ x, y = position.x.clamp(0, @content_size.width - width), (position.y + 20).clamp(0, [@content_size.height - 30, 0].max)
176
+ @scene.layer(1_000_000) do
177
+ @scene.quad(x, y, width, 28, color: "#202936", radius: 4, border_width: 1, border_color: "#526176")
178
+ @scene.clip(Bounds.new(x, y, width, 28)) { popup_text(text, x + 10, y + 5, "#edf2f7") }
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Zaniah
6
+ module Platform
7
+ module Linux
8
+ module AppearanceAware
9
+ PORTAL = ["--session", "--dest", "org.freedesktop.portal.Desktop", "--object-path", "/org/freedesktop/portal/desktop"].freeze
10
+ def appearance
11
+ output, status = Open3.capture2e("gdbus", "call", *PORTAL, "--timeout", "1", "--method", "org.freedesktop.portal.Settings.Read", "org.freedesktop.appearance", "color-scheme")
12
+ return :dark if status.success? && output.match?(/uint32\s+1\b/)
13
+ return :light if status.success? && output.match?(/uint32\s+2\b/)
14
+ output, status = Open3.capture2e("gsettings", "get", "org.gnome.desktop.interface", "color-scheme")
15
+ status.success? && output.include?("prefer-dark") ? :dark : :light
16
+ rescue Errno::ENOENT
17
+ ENV.fetch("GTK_THEME", "").downcase.include?("dark") ? :dark : :light
18
+ end
19
+
20
+ def on_appearance(&block)
21
+ @on_appearance = block
22
+ close_appearance
23
+ return unless block
24
+ stdin, @appearance_io, @appearance_process = Open3.popen2e("gdbus", "monitor", *PORTAL)
25
+ stdin.close
26
+ @appearance_buffer = +""
27
+ rescue Errno::ENOENT
28
+ # Minimal desktops without GIO still use the initial GTK_THEME value.
29
+ nil
30
+ end
31
+
32
+ def poll_appearance
33
+ return unless @appearance_io
34
+ data = @appearance_io.read_nonblock(8192, exception: false)
35
+ return close_appearance if data.nil?
36
+ return unless data.is_a?(String)
37
+ @appearance_buffer << data
38
+ while (newline = @appearance_buffer.index("\n"))
39
+ line = @appearance_buffer.slice!(0, newline + 1)
40
+ next unless line.include?("SettingChanged") && line.include?("color-scheme")
41
+ @on_appearance&.call(line.match?(/uint32\s+1\b/) ? :dark : :light)
42
+ request_frame
43
+ end
44
+ @appearance_buffer.clear if @appearance_buffer.bytesize > 65_536
45
+ rescue IOError
46
+ close_appearance
47
+ end
48
+
49
+ def close_appearance
50
+ @appearance_io&.close unless @appearance_io&.closed?
51
+ if @appearance_process&.alive?
52
+ Process.kill("TERM", @appearance_process.pid) rescue Errno::ESRCH
53
+ @appearance_process.join(0.2)
54
+ end
55
+ @appearance_io = @appearance_process = nil
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle/import"
4
+ require_relative "../../ffi/library"
5
+
6
+ module Zaniah
7
+ module Platform
8
+ module Linux
9
+ module MonitorTypes
10
+ extend Fiddle::Importer
11
+ Monitor = struct ["unsigned long name", "int primary", "int automatic", "int output_count", "int x", "int y", "int width", "int height", "int mm_width", "int mm_height", "void *outputs"]
12
+ end
13
+
14
+ def self.displays(display_server: :auto)
15
+ raise ArgumentError, "unknown Linux display server" unless [:auto, :x11, :wayland].include?(display_server)
16
+ if display_server == :wayland || (display_server == :auto && ENV["WAYLAND_DISPLAY"])
17
+ begin
18
+ require_relative "wayland_display_discovery"
19
+ return WaylandDisplayDiscovery.read
20
+ rescue LoadError, Error
21
+ raise if display_server == :wayland || !ENV["DISPLAY"]
22
+ end
23
+ end
24
+ ptype, long, int = Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG, Fiddle::TYPE_INT
25
+ library = FFI::Library.new("libX11.so.6")
26
+ display = library.fn(:XOpenDisplay, [ptype], ptype).call(0)
27
+ raise Error, "cannot open X display" if display.null?
28
+ root = library.fn(:XDefaultRootWindow, [ptype], long).call(display)
29
+ resources = library.fn(:XResourceManagerString, [ptype], ptype).call(display)
30
+ scale = resources.null? ? 1.0 : (resources.to_s[/Xft\.dpi:\s*(\d+(?:\.\d+)?)/, 1]&.to_f || 96) / 96.0
31
+ scale = scale.clamp(1.0, 4.0)
32
+ begin
33
+ randr = FFI::Library.new("libXrandr.so.2")
34
+ rescue LoadError
35
+ screen = library.fn(:XDefaultScreen, [ptype], int).call(display)
36
+ width = library.fn(:XDisplayWidth, [ptype, int], int).call(display, screen)
37
+ height = library.fn(:XDisplayHeight, [ptype, int], int).call(display, screen)
38
+ return [Display.new(root, "X11 screen", Bounds.new(0, 0, width / scale, height / scale), scale, true)]
39
+ end
40
+ count = [0].pack("i")
41
+ monitors = randr.fn(:XRRGetMonitors, [ptype, long, int, ptype], ptype).call(display, root, 1, count)
42
+ Array.new(count.unpack1("i")) do |index|
43
+ record = MonitorTypes::Monitor.new(monitors + index * MonitorTypes::Monitor.size)
44
+ name = library.fn(:XGetAtomName, [ptype, long], ptype).call(display, record.name)
45
+ text = name.null? ? "Display #{index}" : name.to_s.dup
46
+ library.fn(:XFree, [ptype], int).call(name) unless name.null?
47
+ Display.new(record.name, text, Bounds.new(record.x / scale, record.y / scale, record.width / scale, record.height / scale), scale, record.primary != 0)
48
+ end
49
+ ensure
50
+ randr&.fn(:XRRFreeMonitors, [ptype], Fiddle::TYPE_VOID)&.call(monitors) if monitors && !monitors.null?
51
+ library&.fn(:XCloseDisplay, [ptype], int)&.call(display) if display && !display.null?
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle/import"
4
+ require_relative "../../ffi/library"
5
+
6
+ module Zaniah
7
+ module Platform
8
+ module Linux
9
+ class FreeType
10
+ module Types
11
+ extend Fiddle::Importer
12
+ Face = struct ["long num_faces", "long face_index", "long face_flags", "long style_flags", "long num_glyphs", "void *family_name", "void *style_name", "int num_fixed_sizes", "void *available_sizes", "int num_charmaps", "void *charmaps", "void *generic_data", "void *generic_finalizer", "long bbox[4]", "unsigned short units_per_em", "short ascender", "short descender", "short height", "short max_advance_width", "short max_advance_height", "short underline_position", "short underline_thickness", "void *glyph", "void *size", "void *charmap"]
13
+ Bitmap = struct ["unsigned int rows", "unsigned int width", "int pitch", "void *buffer", "unsigned short num_grays", "unsigned char pixel_mode", "unsigned char palette_mode", "void *palette"]
14
+ Slot = struct ["void *library", "void *face", "void *next", "unsigned int glyph_index", "void *generic_data", "void *generic_finalizer", "long metrics[8]", "long linear_hori_advance", "long linear_vert_advance", "long advance[2]", "unsigned int format", {bitmap: Bitmap}, "int bitmap_left", "int bitmap_top"]
15
+ end
16
+ P, I, L, U = Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_UINT
17
+ def initialize
18
+ require "alhena"
19
+ @lib = FFI::Library.new("libfreetype.so.6", "/opt/homebrew/lib/libfreetype.6.dylib", "/usr/local/lib/libfreetype.6.dylib", "freetype.dll")
20
+ pointer = [0].pack("J")
21
+ check(@lib.fn(:FT_Init_FreeType, [P], I).call(pointer))
22
+ @library, @faces = pointer.unpack1("J"), {}
23
+ end
24
+ def cache_key
25
+ raise IOError, "FreeType provider closed" unless @library
26
+ @cache_key ||= begin
27
+ major, minor, patch = [0].pack("i"), [0].pack("i"), [0].pack("i")
28
+ @lib.fn(:FT_Library_Version, [P, P, P, P], Fiddle::TYPE_VOID).call(@library, major, minor, patch)
29
+ "freetype/#{RUBY_PLATFORM}/#{[major, minor, patch].map { |value| value.unpack1('i') }.join('.')}".freeze
30
+ end
31
+ end
32
+ def rasterize(font, glyph, size:, subpixel_x: 0, hinting: false, **_options)
33
+ raise IOError, "FreeType provider closed" unless @library
34
+ raise ArgumentError, "invalid font size" unless size.is_a?(Numeric) && size.finite? && size.positive?
35
+ raise ArgumentError, "invalid glyph ID" unless glyph.is_a?(Integer) && glyph.between?(0, font.glyph_count - 1)
36
+ raise ArgumentError, "invalid subpixel position" unless subpixel_x.is_a?(Numeric) && subpixel_x.finite?
37
+ face = @faces[font] ||= begin
38
+ pointer = [0].pack("J")
39
+ check(@lib.fn(:FT_New_Memory_Face, [P, P, L, L, P], I).call(@library, font.data, font.data.bytesize, font.index, pointer))
40
+ handle = pointer.unpack1("J")
41
+ unless font.axis_values.empty?
42
+ coordinates = font.axes.map { |tag, axis| (font.axis_values.fetch(tag, axis[:default]).clamp(axis[:min], axis[:max]) * 65_536).round }.pack("l!*")
43
+ check(@lib.fn(:FT_Set_Var_Design_Coordinates, [P, U, P], I).call(handle, font.axes.length, coordinates))
44
+ end
45
+ handle
46
+ end
47
+ check(@lib.fn(:FT_Set_Char_Size, [P, L, L, U, U], I).call(face, 0, (size * 64).round, 72, 72))
48
+ delta = [(subpixel_x * 64).round, 0].pack("l!2")
49
+ @lib.fn(:FT_Set_Transform, [P, P, P], Fiddle::TYPE_VOID).call(face, 0, delta)
50
+ check(@lib.fn(:FT_Load_Glyph, [P, U, I], I).call(face, glyph, 4 | 8 | (hinting ? 0 : 2)))
51
+ slot = Types::Slot.new(Types::Face.new(Fiddle::Pointer.new(face)).glyph)
52
+ bitmap = slot.bitmap
53
+ raise Error, "FreeType bitmap exceeds size limit" if bitmap.width * bitmap.rows > 16_777_216
54
+ bytes = String.new(capacity: bitmap.width * bitmap.rows, encoding: Encoding::BINARY)
55
+ bitmap.rows.times do |row|
56
+ pointer = Fiddle::Pointer.new(bitmap.buffer.to_i + row * bitmap.pitch)
57
+ case bitmap.pixel_mode
58
+ when 2 then bytes << pointer[0, bitmap.width]
59
+ when 1
60
+ packed = pointer[0, (bitmap.width + 7) / 8]
61
+ bitmap.width.times { |column| bytes << ((packed.getbyte(column / 8) & (0x80 >> (column % 8))).zero? ? 0 : 255) }
62
+ else raise Error, "unsupported FreeType coverage mode #{bitmap.pixel_mode}"
63
+ end
64
+ end
65
+ Alhena::Bitmap.new(width: bitmap.width, height: bitmap.rows, left: slot.bitmap_left, top: slot.bitmap_top, coverage: bytes)
66
+ end
67
+ def check(code)
68
+ raise Error, "FreeType error #{code}" unless code.zero?
69
+ end
70
+ def close
71
+ return unless @library
72
+ @faces.each_value { |face| @lib.fn(:FT_Done_Face, [P], I).call(face) }
73
+ @faces.clear
74
+ @lib.fn(:FT_Done_FreeType, [P], I).call(@library)
75
+ @library = nil
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end