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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +70 -0
- data/assets/fonts/Abel-Regular.ttf +0 -0
- data/assets/fonts/OFL.txt +93 -0
- data/docs/adr/000-template.md +24 -0
- data/docs/adr/001-native-gpu-backends.md +36 -0
- data/docs/adr/002-objective-c-aggregates.md +34 -0
- data/docs/adr/003-fiddle-call-boundary.md +33 -0
- data/docs/adr/README.md +10 -0
- data/docs/native.md +66 -0
- data/docs/process_pool.md +67 -0
- data/docs/text.md +90 -0
- data/docs/vector_and_list.md +52 -0
- data/examples/ffi_bench.rb +16 -0
- data/examples/linux_smoke.rb +53 -0
- data/examples/mac_event_check.rb +61 -0
- data/examples/mac_poll_bench.rb +24 -0
- data/examples/metal_state_check.rb +72 -0
- data/examples/native_bench.rb +18 -0
- data/examples/native_drop.rb +82 -0
- data/examples/native_font.rb +15 -0
- data/examples/native_ime.rb +50 -0
- data/examples/native_smoke.rb +75 -0
- data/examples/native_text_bench.rb +83 -0
- data/examples/native_watch.rb +25 -0
- data/examples/vulkan_triangle.rb +20 -0
- data/examples/word_count_worker.rb +8 -0
- data/lib/zaniah/anchored.rb +10 -0
- data/lib/zaniah/app.rb +130 -0
- data/lib/zaniah/bounds.rb +22 -0
- data/lib/zaniah/canvas.rb +15 -0
- data/lib/zaniah/color.rb +35 -0
- data/lib/zaniah/configuration.rb +5 -0
- data/lib/zaniah/corners.rb +7 -0
- data/lib/zaniah/data_compat.rb +25 -0
- data/lib/zaniah/device_pixels.rb +7 -0
- data/lib/zaniah/div.rb +5 -0
- data/lib/zaniah/edges.rb +7 -0
- data/lib/zaniah/element.rb +106 -0
- data/lib/zaniah/entity.rb +9 -0
- data/lib/zaniah/entity_context.rb +17 -0
- data/lib/zaniah/error.rb +5 -0
- data/lib/zaniah/ffi/com.rb +24 -0
- data/lib/zaniah/ffi/library.rb +29 -0
- data/lib/zaniah/ffi/objc.rb +86 -0
- data/lib/zaniah/ffi/struct/signature.rb +46 -0
- data/lib/zaniah/ffi/struct.rb +61 -0
- data/lib/zaniah/ffi/wayland.rb +140 -0
- data/lib/zaniah/frame_context.rb +14 -0
- data/lib/zaniah/geometry.rb +16 -0
- data/lib/zaniah/gpu/buffer.rb +23 -0
- data/lib/zaniah/gpu/frame_encoder.rb +43 -0
- data/lib/zaniah/gpu/instance_packing.rb +53 -0
- data/lib/zaniah/gpu/metal.rb +209 -0
- data/lib/zaniah/gpu/open_gl.rb +210 -0
- data/lib/zaniah/gpu/pipeline.rb +7 -0
- data/lib/zaniah/gpu/software.rb +126 -0
- data/lib/zaniah/gpu/texture.rb +33 -0
- data/lib/zaniah/gpu/vulkan.rb +233 -0
- data/lib/zaniah/gpu.rb +18 -0
- data/lib/zaniah/hsla.rb +14 -0
- data/lib/zaniah/image.rb +19 -0
- data/lib/zaniah/input/action_registry.rb +12 -0
- data/lib/zaniah/input/composition.rb +7 -0
- data/lib/zaniah/input/context_predicate.rb +87 -0
- data/lib/zaniah/input/dispatcher.rb +59 -0
- data/lib/zaniah/input/file_drop.rb +7 -0
- data/lib/zaniah/input/focus_handle.rb +24 -0
- data/lib/zaniah/input/key_down.rb +7 -0
- data/lib/zaniah/input/key_up.rb +7 -0
- data/lib/zaniah/input/keymap/binding.rb +9 -0
- data/lib/zaniah/input/keymap.rb +55 -0
- data/lib/zaniah/input/keystroke.rb +20 -0
- data/lib/zaniah/input/mouse_down.rb +7 -0
- data/lib/zaniah/input/mouse_move.rb +7 -0
- data/lib/zaniah/input/mouse_up.rb +7 -0
- data/lib/zaniah/input/scroll_wheel.rb +7 -0
- data/lib/zaniah/input/text_input.rb +7 -0
- data/lib/zaniah/input.rb +17 -0
- data/lib/zaniah/layout/available_space.rb +11 -0
- data/lib/zaniah/layout/engine.rb +214 -0
- data/lib/zaniah/layout/node.rb +36 -0
- data/lib/zaniah/layout/style.rb +19 -0
- data/lib/zaniah/layout/tree.rb +16 -0
- data/lib/zaniah/layout.rb +11 -0
- data/lib/zaniah/length.rb +14 -0
- data/lib/zaniah/length_units.rb +9 -0
- data/lib/zaniah/list/height_index.rb +68 -0
- data/lib/zaniah/list.rb +101 -0
- data/lib/zaniah/overlay.rb +10 -0
- data/lib/zaniah/platform/display.rb +7 -0
- data/lib/zaniah/platform/file_event.rb +7 -0
- data/lib/zaniah/platform/headless/window.rb +185 -0
- data/lib/zaniah/platform/linux/appearance_aware.rb +60 -0
- data/lib/zaniah/platform/linux/displays.rb +55 -0
- data/lib/zaniah/platform/linux/free_type.rb +80 -0
- data/lib/zaniah/platform/linux/watcher.rb +70 -0
- data/lib/zaniah/platform/linux/wayland_display_discovery.rb +60 -0
- data/lib/zaniah/platform/linux/wayland_window.rb +369 -0
- data/lib/zaniah/platform/linux/window.rb +418 -0
- data/lib/zaniah/platform/linux.rb +3 -0
- data/lib/zaniah/platform/mac/app.rb +67 -0
- data/lib/zaniah/platform/mac/core_text.rb +94 -0
- data/lib/zaniah/platform/mac/displays.rb +23 -0
- data/lib/zaniah/platform/mac/watcher.rb +63 -0
- data/lib/zaniah/platform/mac/window.rb +298 -0
- data/lib/zaniah/platform/mac.rb +19 -0
- data/lib/zaniah/platform/tui/input_decoder.rb +145 -0
- data/lib/zaniah/platform/tui/text_renderer.rb +40 -0
- data/lib/zaniah/platform/tui/window.rb +91 -0
- data/lib/zaniah/platform/windows/terminal.rb +127 -0
- data/lib/zaniah/platform/windows/watcher.rb +91 -0
- data/lib/zaniah/platform/windows/window.rb +407 -0
- data/lib/zaniah/platform/windows.rb +3 -0
- data/lib/zaniah/platform.rb +34 -0
- data/lib/zaniah/png.rb +118 -0
- data/lib/zaniah/point.rb +8 -0
- data/lib/zaniah/process_pool/worker.rb +7 -0
- data/lib/zaniah/process_pool.rb +239 -0
- data/lib/zaniah/process_wire.rb +127 -0
- data/lib/zaniah/quad_packer.rb +15 -0
- data/lib/zaniah/scene/sprite_batch.rb +7 -0
- data/lib/zaniah/scene.rb +167 -0
- data/lib/zaniah/scene_renderer.rb +8 -0
- data/lib/zaniah/size.rb +5 -0
- data/lib/zaniah/subscription.rb +14 -0
- data/lib/zaniah/svg/path.rb +134 -0
- data/lib/zaniah/svg/rasterizable.rb +186 -0
- data/lib/zaniah/svg.rb +295 -0
- data/lib/zaniah/task/cancelled.rb +7 -0
- data/lib/zaniah/task/timeout.rb +7 -0
- data/lib/zaniah/task.rb +51 -0
- data/lib/zaniah/task_executor.rb +105 -0
- data/lib/zaniah/text.rb +24 -0
- data/lib/zaniah/text_system/atlas/entry.rb +9 -0
- data/lib/zaniah/text_system/atlas.rb +102 -0
- data/lib/zaniah/text_system/atlas_cache.rb +146 -0
- data/lib/zaniah/text_system/font_db/face.rb +9 -0
- data/lib/zaniah/text_system/font_db.rb +193 -0
- data/lib/zaniah/text_system/glyph.rb +7 -0
- data/lib/zaniah/text_system/line_layout.rb +20 -0
- data/lib/zaniah/text_system/renderer.rb +196 -0
- data/lib/zaniah/text_system/shaper.rb +430 -0
- data/lib/zaniah/text_system/typesetter/copy_error.rb +9 -0
- data/lib/zaniah/text_system/typesetter.rb +168 -0
- data/lib/zaniah/text_system.rb +9 -0
- data/lib/zaniah/unicode.rb +13 -0
- data/lib/zaniah/uniform_list.rb +30 -0
- data/lib/zaniah/version.rb +5 -0
- data/lib/zaniah/view.rb +13 -0
- data/lib/zaniah.rb +29 -0
- data/sig/app.rbs +80 -0
- data/sig/elements.rbs +129 -0
- data/sig/gpu.rbs +108 -0
- data/sig/input.rbs +114 -0
- data/sig/layout.rbs +53 -0
- data/sig/native.rbs +88 -0
- data/sig/platform.rbs +94 -0
- data/sig/scene.rbs +52 -0
- data/sig/shaper.rbs +8 -0
- data/sig/text.rbs +137 -0
- data/sig/zaniah.rbs +120 -0
- data/tools/check_dependencies.rb +52 -0
- 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,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
|
data/lib/zaniah/list.rb
ADDED
|
@@ -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,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
|